├── frontend
├── src
│ ├── global.d.ts
│ ├── index.tsx
│ └── umbrella
│ │ └── SfuApp.tsx
├── html
│ ├── index.html
│ └── generic.html
├── package.json
├── tsconfig.json
├── public
│ └── styles.css
└── build.js
├── .gitignore
├── deployment
├── Dockerfile
├── build_docker.sh
└── docker-compose-example.yml
├── razor
├── README.md
├── common.go
├── lockandnotify.go
├── logger.go
├── messagequeue.go
└── messagehandler.go
├── sfu
├── descriptors.go
├── clients.go
├── tracks.go
├── peerconnection.go
├── client_rtsp.go
├── sfu.go
└── client_webrtc.go
├── LICENSE
├── go.mod
├── docs
├── docker.md
├── openwrt.md
├── KEYS_SETUP.md
├── sfu-diagram-2.svg
└── sfu-diagram-1.svg
├── proto
└── sfu.proto
├── go.sum
├── README.md
└── main.go
/frontend/src/global.d.ts:
--------------------------------------------------------------------------------
1 | interface UmbrellaInjectedParameters {
2 | HttpPrefix: string;
3 | }
4 |
5 | interface Window {
6 | __injected__: UmbrellaInjectedParameters;
7 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | frontend/dist/
2 | frontend/node_modules/
3 | frontend/src/generated/
4 | /umbrella
5 | deployment/tmp
6 |
7 | *.pb.go
8 |
9 | myCA.*
10 | service.*
11 | *.key
12 | *.pem
13 | *.srl
14 | *.crt
--------------------------------------------------------------------------------
/deployment/Dockerfile:
--------------------------------------------------------------------------------
1 | # Run Stage
2 | FROM ubuntu:22.04
3 |
4 | COPY tmp/umbrella umbrella
5 |
6 | # Expose application ports
7 | EXPOSE 8081
8 | EXPOSE 9091
9 | EXPOSE 50000-60000
10 |
11 | # Start app
12 | CMD ./umbrella
13 |
--------------------------------------------------------------------------------
/deployment/build_docker.sh:
--------------------------------------------------------------------------------
1 | rm -rf tmp
2 | mkdir tmp
3 |
4 | rm -rf ../frontend/dist
5 |
6 | set -e
7 |
8 | echo "Build"
9 |
10 | pushd ../
11 |
12 | ./build_amd64_linux.sh
13 |
14 | popd
15 |
16 | cp -r ../umbrella tmp/umbrella
17 |
18 | docker build --no-cache -t umbrella-sfu .
19 |
--------------------------------------------------------------------------------
/razor/README.md:
--------------------------------------------------------------------------------
1 | ### razor
2 |
3 | This is a set of opinionated utility libraries which follow me around in different languages. This is the first rough implementation in golang, so is not as well battle tested as other things yet.
4 |
5 | This will likely be spun out somewhere else when it stabilizes.
--------------------------------------------------------------------------------
/frontend/html/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | yak.nu
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/sfu/descriptors.go:
--------------------------------------------------------------------------------
1 | package sfu
2 |
3 | import "github.com/pion/webrtc/v4"
4 |
5 | func trackKindToWebrtcKind(kind TrackKind) webrtc.RTPCodecType {
6 | switch kind {
7 | case TrackKind_Audio:
8 | return webrtc.RTPCodecTypeAudio
9 | case TrackKind_Video:
10 | return webrtc.RTPCodecTypeVideo
11 | }
12 |
13 | return webrtc.RTPCodecTypeUnknown
14 | }
15 |
16 | func trackKindFromWebrtcKind(kind webrtc.RTPCodecType) TrackKind {
17 | switch kind {
18 | case webrtc.RTPCodecTypeAudio:
19 | return TrackKind_Audio
20 | case webrtc.RTPCodecTypeVideo:
21 | return TrackKind_Video
22 | }
23 |
24 | return TrackKind_Unknown
25 | }
26 |
--------------------------------------------------------------------------------
/razor/common.go:
--------------------------------------------------------------------------------
1 | package razor
2 |
3 | import (
4 | "os"
5 | "os/signal"
6 | "time"
7 | )
8 |
9 | // HORRIFYING HACK
10 | var EndOfTime = time.Unix((1<<62)-1, 0)
11 |
12 | type Optional[T any] struct {
13 | Value T
14 | IsSet bool
15 | }
16 |
17 | func NewOptional[T any](value T) Optional[T] {
18 | return Optional[T]{Value: value, IsSet: true}
19 | }
20 |
21 | func NilOptional[T any]() Optional[T] {
22 | return Optional[T]{IsSet: false}
23 | }
24 |
25 | func Swap[T any](a, b *T) {
26 | *a, *b = *b, *a
27 | }
28 |
29 | func Assert(cond bool) {
30 | if !cond {
31 | panic("Assertion failure")
32 | }
33 | }
34 |
35 | func WaitForOsInterruptSignal() {
36 | stop := make(chan os.Signal, 1)
37 | signal.Notify(stop, os.Interrupt)
38 | <-stop
39 | }
40 |
--------------------------------------------------------------------------------
/frontend/html/generic.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ .Title }}
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "type": "module",
7 | "scripts": {
8 | "test": "echo \"Error: no test specified\" && exit 1",
9 | "build": "node build.js"
10 | },
11 | "keywords": [],
12 | "author": "",
13 | "license": "ISC",
14 | "devDependencies": {
15 | "@types/serviceworker": "^0.0.102",
16 | "esbuild": "^0.24.0",
17 | "typescript": "^5.6.3",
18 | "fs-extra": "^11.2.0",
19 | "protoc-gen-js": "^3.21.2",
20 | "ts-protoc-gen": "^0.15.0",
21 | "@protobuf-ts/plugin": "^2.9.4"
22 | },
23 | "dependencies": {
24 | "@types/react": "^18.3.12",
25 | "@types/react-dom": "^18.3.1",
26 | "react": "^18.3.1",
27 | "react-dom": "^18.3.1"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "jsx": "react-jsx", // Use "react" if you're using React 16 or earlier
4 | "target": "es2016", // Specify ECMAScript target
5 | "module": "commonjs", // Specify module code generation
6 | "outDir": "./dist", // Redirect output structure to the 'dist' directory
7 | "strict": true, // Enable all strict type-checking options
8 | "esModuleInterop": true, // Enables emit interoperability between CommonJS and ES Modules
9 | "skipLibCheck": true // Skip type checking of declaration files
10 | },
11 | "include": ["src/**/*.ts", "src/index.tsx", "src/**/*.js"], // Include all .ts files in the 'src' directory
12 | "exclude": ["node_modules"] // Exclude the 'node_modules' directory
13 | }
14 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Nigel Birkenshaw
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/razor/lockandnotify.go:
--------------------------------------------------------------------------------
1 | package razor
2 |
3 | import (
4 | "sync"
5 | "time"
6 | )
7 |
8 | type LockAndNotify struct {
9 | mutex sync.Mutex
10 |
11 | waker chan struct{}
12 | waiting chan bool
13 | waiter chan struct{}
14 | }
15 |
16 | func NewLockAndNotify() *LockAndNotify {
17 | waker := make(chan struct{}, 16)
18 | waiting := make(chan bool, 1)
19 | waiter := make(chan struct{}, 1)
20 |
21 | go func() {
22 | iswaiting := false
23 | for {
24 | select {
25 | case is := <-waiting:
26 | iswaiting = is
27 | case <-waker:
28 | if iswaiting {
29 | iswaiting = false
30 | waiter <- struct{}{}
31 | }
32 | }
33 | }
34 | }()
35 |
36 | return &LockAndNotify{
37 | waker: waker,
38 | waiting: waiting,
39 | waiter: waiter,
40 | }
41 | }
42 |
43 | func (l *LockAndNotify) Enter() {
44 | l.mutex.Lock()
45 | }
46 |
47 | func (l *LockAndNotify) Leave() {
48 | l.mutex.Unlock()
49 | }
50 |
51 | // This is really dangerous if we've aborted!
52 | func (l *LockAndNotify) Notify() {
53 | l.waker <- struct{}{}
54 | }
55 |
56 | func (l *LockAndNotify) Wait(delay time.Duration) {
57 | l.waiting <- true
58 |
59 | select {
60 | case <-time.After(delay):
61 | case <-l.waiter:
62 | }
63 |
64 | l.waiting <- false
65 | }
66 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module atomirex.com/umbrella
2 |
3 | go 1.22.5
4 |
5 | require (
6 | github.com/atomirex/mdns v0.0.13-0.20241125201824-943da8fa1231
7 | github.com/bluenviron/gortsplib/v4 v4.11.2
8 | github.com/google/uuid v1.6.0
9 | github.com/gorilla/websocket v1.5.3
10 | github.com/pion/interceptor v0.1.37
11 | github.com/pion/logging v0.2.2
12 | github.com/pion/rtcp v1.2.14
13 | github.com/pion/rtp v1.8.9
14 | github.com/pion/webrtc/v4 v4.0.1
15 | golang.org/x/net v0.31.0
16 | google.golang.org/protobuf v1.35.1
17 | )
18 |
19 | require (
20 | github.com/bluenviron/mediacommon v1.13.1 // indirect
21 | github.com/pion/datachannel v1.5.9 // indirect
22 | github.com/pion/dtls/v3 v3.0.3 // indirect
23 | github.com/pion/ice/v4 v4.0.2 // indirect
24 | github.com/pion/mdns/v2 v2.0.7 // indirect
25 | github.com/pion/randutil v0.1.0 // indirect
26 | github.com/pion/sctp v1.8.33 // indirect
27 | github.com/pion/sdp/v3 v3.0.9 // indirect
28 | github.com/pion/srtp/v3 v3.0.4 // indirect
29 | github.com/pion/stun/v3 v3.0.0 // indirect
30 | github.com/pion/transport/v3 v3.0.7 // indirect
31 | github.com/pion/turn/v4 v4.0.0 // indirect
32 | github.com/wlynxg/anet v0.0.3 // indirect
33 | golang.org/x/crypto v0.29.0 // indirect
34 | golang.org/x/sys v0.27.0 // indirect
35 | )
36 |
--------------------------------------------------------------------------------
/frontend/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { useRef, useState } from 'react';
4 | import { createRoot } from 'react-dom/client';
5 | import { SfuApp, StatusApp, ServersApp } from './umbrella/SfuApp'
6 |
7 | const EmptyApp = () => {
8 | return (
9 |
10 |
This is the default empty page, shouldn't be seen.
11 |
12 | );
13 | };
14 |
15 | const injected = window.__injected__;
16 |
17 | // Prefix when deployed
18 | const httpPrefix = injected.HttpPrefix;
19 |
20 | function removePrefix(path: string) : string {
21 | if(httpPrefix.length > 0 && path.startsWith(httpPrefix)) {
22 | path = path.substring(httpPrefix.length, path.length);
23 | }
24 |
25 | return path;
26 | }
27 |
28 | export function runApp() {
29 | const root = createRoot(document.getElementById('main-container')!);
30 |
31 | console.log("Creating app on page "+window.location.pathname);
32 |
33 | switch(removePrefix(window.location.pathname)) {
34 | case "/sfu":
35 | root.render();
36 | break;
37 | case "/servers":
38 | root.render();
39 | break;
40 | case "/status":
41 | root.render();
42 | break;
43 | default:
44 | root.render();
45 | break;
46 | }
47 | };
48 |
49 | runApp();
--------------------------------------------------------------------------------
/docs/docker.md:
--------------------------------------------------------------------------------
1 | #### Docker/traefik
2 | For running on a public webserver something like docker compose with traefik will take away a lot of headaches. However, it introduces at least one big one. WebRTC uses a lot of ephemeral ports for communication, and these need to be exposed directly by the container. This means you have to run the container with host networking.
3 |
4 | There are several environment variables which configure the SFU to run in cloud mode.
5 | * UMBRELLA_CLOUD=1 - set to cloud mode
6 | * UMBRELLA_HTTP_PREFIX - the path from the base url to the sfu, including forward slash, so "/umbrella" serves at https://domain:port/umbrella/sfu
7 | * UMBRELLA_HTTP_SERVE_ADDR - the addr the server serves on, as host and port. To override just the default port 8081 set to :PORT, e.g. ":9000".
8 | * UMBRELLA_PUBLIC_IP= - set to the public IP of the server. i.e. 245.234.244.122
9 | * UMBRELLA_PUBLIC_HOST= - set to the public host of the server. i.e. www.atomirex.com
10 | * UMBRELLA_MIN_PORT= , UMBRELLA_MAX_PORT= - set to the minimum and maximum ephemeral ports to allocate - e.g. UMBRELLA_MIN_PORT=50000, UMBRELLA_MAX_PORT=55000
11 |
12 | The frontend is served on 8081, unless you override UMBRELLA_HTTP_SERVE_ADDR, and will need proxying for https for the public internet. You probably want to block whatever port you use from the public internet (here assumed to be on eth0) with something like:
13 | ```
14 | iptables -A INPUT -p tcp --dport 8081 -i eth0 -j REJECT
15 | ```
16 |
17 | This won't persist across reboots by default though, which can be done with the package iptables-persistent.
18 |
19 | An example docker-compose file is in the deployment directory.
--------------------------------------------------------------------------------
/frontend/public/styles.css:
--------------------------------------------------------------------------------
1 |
2 | * {
3 | -webkit-user-select: none;
4 | user-select: none;
5 | color: white;
6 | font-family: Arial, Helvetica, sans-serif;
7 | margin: 0px;
8 | }
9 |
10 | html {
11 | height: 100%;
12 | margin: 0;
13 | }
14 |
15 | body {
16 | background-color: #1a1a1a;
17 | height: 100%;
18 | margin: 0;
19 | }
20 |
21 | input {
22 | color: black;
23 | font-size: 16px;
24 | padding: 12px;
25 | margin: 12px;
26 | }
27 |
28 | button {
29 | color: black;
30 | margin: 12px;
31 | padding: 12px;
32 | font-size: 16px;
33 | font-weight: bold;
34 | border-radius: 24px;
35 | border: white solid 2px;
36 | background-color: white;
37 | }
38 |
39 | .centering-container {
40 | display: flex;
41 | justify-content: center;
42 | align-content: center;
43 | align-items: center;
44 | height: 100vh;
45 | flex-wrap: wrap;
46 | gap: 12px;
47 | }
48 |
49 | .video-container {
50 | display: inline-block;
51 | position: relative;
52 | }
53 |
54 | .video-overlay {
55 | position: absolute;
56 | z-index: 1;
57 | top:0;
58 | left: 0;
59 | padding: 16px;
60 | margin: 12px;
61 | font-weight: bold;
62 | text-shadow: 0px 3px 6px rgba(0, 0, 0, 0.85);
63 | }
64 |
65 | .local-video {
66 | display: block;
67 | max-width: 30vmax;
68 | max-height: 30vmax;
69 | border-radius: 16px;
70 | border: white solid 2px;
71 | margin: 12px;
72 | }
73 |
74 | .remote-video {
75 | display: block;
76 | max-width: 30vmax;
77 | max-height: 30vmax;
78 | border-radius: 16px;
79 | border: white solid 2px;
80 | margin: 12px;
81 | }
82 |
83 | p {
84 | font-size: 16px;
85 | font-family: Arial, Helvetica, sans-serif;
86 | margin: 0px;
87 | padding: 0px;
88 | }
89 |
--------------------------------------------------------------------------------
/docs/openwrt.md:
--------------------------------------------------------------------------------
1 | #### OpenWrt
2 | While it can run on OpenWrt it will not run on every OpenWrt device, and it won't run well on many it could run on. The target device has been a D-Link AX3200 M32, which has a dual core AArch64 (Arm) CPU and 512MB of RAM. Thanks to the golang cross compilation support this is a basic arm64 linux target and can be built with build_arm64_linux.sh .
3 |
4 | Once setup the steps are the same as for running locally on Linux. (See below).
5 |
6 | OpenWrt is a generally well behaved small Linux distro, with the exceptions here being aspects related to the security requirements for browser WebRTC support. OpenWrt uses different things for core services than you may expect, such as the package manager being opkg. Assuming you have OpenWrt installed, working, and ssh access to the device . . .
7 |
8 | ##### umdns and all lower case hostname
9 | Out of the box OpenWrt sets the hostname "OpenWrt" and does not include mdns support. Luckily there is a convenient package umdns: https://openwrt.org/docs/guide-developer/mdns
10 |
11 | Unfortunately, at least with Apple devices as clients, umdns causes confusion with letter case in hostnames, which will then cause the certificate checks to fail, so the security prerequisites are no longer valid. The workaround is to set the OpenWrt device hostname to all lower case letters. (All the certs will need to reflect this).
12 |
13 | It's useful to access the services of one AP from machines connected to other APs. To advertise mdns back out to the WAN to do this /etc/config/umdns has to include something like:
14 | ```
15 | config umdns
16 | option jail 1
17 | list network lan
18 | list network wan
19 | ```
20 |
21 | ##### Firewall
22 | This also required allowing mdns, so /etc/config/firewall has this in it for multicast on the usual IP/port combo:
23 | ```
24 | config rule
25 | option src_port '5353'
26 | option src '*'
27 | option name 'Allow-mDNS'
28 | option target 'ACCEPT'
29 | option dest_ip '224.0.0.251'
30 | option dest_port '5353'
31 | option proto 'udp'
32 | ```
33 |
--------------------------------------------------------------------------------
/sfu/clients.go:
--------------------------------------------------------------------------------
1 | package sfu
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "atomirex.com/umbrella/razor"
8 | "github.com/gorilla/websocket"
9 | )
10 |
11 | type RemoteClient interface {
12 | Label() string
13 | getStatus() *SFUStatusClient
14 | stop()
15 | AddOutgoingTracksForIncomingTrack(*incomingTrack)
16 | RemoveOutgoingTracksForIncomingTrack(*incomingTrack)
17 | RequestEvalState()
18 | }
19 |
20 | type BaseClient struct {
21 | label string
22 | logger *razor.Logger
23 | }
24 |
25 | func (bc *BaseClient) Label() string {
26 | return bc.label
27 | }
28 |
29 | type RemoteClientParameters struct {
30 | logger *razor.Logger
31 | trunkurl string
32 | s *Sfu
33 | ws *websocket.Conn
34 | }
35 |
36 | type RemoteClientFactory interface {
37 | NewClient(params *RemoteClientParameters) RemoteClient
38 | }
39 |
40 | type DefaultRemoteClientFactory struct{}
41 |
42 | // This can actually block ever returning . . . thanks to looping on the websocket
43 | // not great
44 | func (rcf *DefaultRemoteClientFactory) NewClient(params *RemoteClientParameters) RemoteClient {
45 | if params.trunkurl == "" {
46 | c := &client{
47 | BaseClient: BaseClient{
48 | label: fmt.Sprintf("Incoming client from %s", params.ws.UnderlyingConn().RemoteAddr()),
49 | logger: params.logger,
50 | },
51 |
52 | websocket: params.ws,
53 | }
54 |
55 | c.run(params.ws, params.s)
56 |
57 | c.continueWebsocket(params.s)
58 |
59 | return c
60 | } else {
61 | if strings.HasPrefix(params.trunkurl, "rtsp") {
62 | c := &RtspClient{
63 | BaseClient: BaseClient{
64 | label: fmt.Sprintf("RTSP client of %s", params.trunkurl),
65 | logger: params.logger,
66 | },
67 |
68 | url: params.trunkurl,
69 | }
70 |
71 | c.run(params.s)
72 |
73 | return c
74 | } else {
75 | c := &client{
76 | BaseClient: BaseClient{
77 | label: fmt.Sprintf("Trunking client to %s", params.trunkurl),
78 | logger: params.logger,
79 | },
80 |
81 | trunkurl: params.trunkurl,
82 | }
83 |
84 | c.run(nil, params.s)
85 |
86 | return c
87 | }
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/deployment/docker-compose-example.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | name: umbrella-sfu
4 |
5 | services:
6 | umbrella-sfu:
7 | image: ${VLAN_BASE_IP}:5000/umbrella-sfu:latest
8 | container_name: umbrella-sfu
9 | restart: unless-stopped
10 | network_mode: host
11 |
12 | extra_hosts:
13 | - "host.docker.internal:${VLAN_LOCAL_IP}"
14 | environment:
15 | - UMBRELLA_CLOUD=1
16 | - UMBRELLA_HTTP_PREFIX=/umbrella
17 | - UMBRELLA_PUBLIC_IP=211.72.93.112
18 | - UMBRELLA_PUBLIC_HOST=www.atomirex.com
19 | - UMBRELLA_MIN_PORT=50000
20 | - UMBRELLA_MAX_PORT=54000
21 | labels:
22 | - "traefik.enable=true"
23 |
24 | - "traefik.http.routers.umbrella-sfu.entrypoints=http"
25 | - "traefik.http.routers.umbrella-sfu.rule=(Host(`atomirex.com`) || Host(`www.atomirex.com`)) && PathPrefix(`/umbrella`)"
26 | - "traefik.http.middlewares.umbrella-sfu-https-redirect.redirectscheme.scheme=https"
27 | - "traefik.http.middlewares.umbrella-sfu-https-redirect.redirectscheme.permanent=true"
28 | - "traefik.http.routers.umbrella-sfu.middlewares=umbrella-sfu-https-redirect"
29 |
30 | - "traefik.http.routers.umbrella-sfu-secure.entrypoints=https"
31 | - "traefik.http.routers.umbrella-sfu-secure.rule=(Host(`atomirex.com`) || Host(`www.atomirex.com`)) && PathPrefix(`/umbrella`)"
32 | - "traefik.http.routers.umbrella-sfu-secure.tls=true"
33 | - "traefik.http.routers.umbrella-sfu-secure.tls.certresolver=myresolver"
34 |
35 | - "traefik.http.services.umbrella-sfu.loadbalancer.server.port=8081"
36 | - "traefik.http.services.umbrella-sfu.loadbalancer.server.url=http://host.docker.internal:8081"
37 |
38 | - "traefik.http.middlewares.umbrella-sfu-nowww.redirectregex.regex=^https://atomirex.com/(.*)$$"
39 | - "traefik.http.middlewares.umbrella-sfu-nowww.redirectregex.replacement=https://www.atomirex.com/$${2}"
40 | - "traefik.http.middlewares.umbrella-sfu-nowww.redirectregex.permanent=true"
41 |
42 | - "traefik.http.routers.umbrella-sfu-secure.middlewares=umbrella-sfu-nowww"
43 | ports:
44 | - "${VLAN_LOCAL_IP}:8081:8081"
45 | - "50000-54000"
--------------------------------------------------------------------------------
/sfu/tracks.go:
--------------------------------------------------------------------------------
1 | package sfu
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/pion/webrtc/v4"
7 | )
8 |
9 | type incomingTrack struct {
10 | descriptor *TrackDescriptor
11 | remote *webrtc.TrackRemote
12 | relay *webrtc.TrackLocalStaticRTP
13 | receiver *webrtc.RTPReceiver
14 | }
15 |
16 | func (it *incomingTrack) String() string {
17 | return fmt.Sprintf("{IncomingTrack id: %s}", it.descriptor.UmbrellaId)
18 | }
19 |
20 | type incomingTrackWithClientState struct {
21 | track *incomingTrack
22 |
23 | transceiverMid string // The mid of the transceiver when the track is received, until then ""
24 | }
25 |
26 | func (it *incomingTrackWithClientState) String() string {
27 | return fmt.Sprintf("{IncomingTrackWithClientState id: %s mid: %s}", it.track.descriptor.UmbrellaId, it.transceiverMid)
28 | }
29 |
30 | type outgoingTrack struct {
31 | descriptor *TrackDescriptor
32 | }
33 |
34 | func (ot *outgoingTrack) String() string {
35 | return fmt.Sprintf("{OutgoingTrack id: %s}", ot.descriptor.UmbrellaId)
36 | }
37 |
38 | type outgoingTrackWithClientState struct {
39 | track *outgoingTrack
40 |
41 | source *incomingTrack
42 | remoteNotified bool // Indicates we have sent upstreamTracks including this track to the remote device
43 | remoteAccepted bool // Indicates we have received confirmation this track is expected by the remote device
44 | }
45 |
46 | func (ot *outgoingTrackWithClientState) String() string {
47 | return fmt.Sprintf("{CutgoingTrackWithClientState id: %s}", ot.track.descriptor.UmbrellaId)
48 | }
49 |
50 | // This points to the definition of descriptor being wrong
51 | // i.e. the descriptor needs to include separate fields for the mid, the id, the src mid etc.
52 | // then need to create an "incomingID" of clientid-srcmid for each track
53 | func (t *incomingTrack) UmbrellaID() string {
54 | return t.descriptor.UmbrellaId
55 | }
56 |
57 | func (t *outgoingTrack) UmbrellaID() string {
58 | return t.descriptor.UmbrellaId
59 | }
60 |
61 | func (t *incomingTrackWithClientState) UmbrellaID() string {
62 | return t.track.UmbrellaID()
63 | }
64 |
65 | func (t *outgoingTrackWithClientState) UmbrellaID() string {
66 | return t.track.UmbrellaID()
67 | }
68 |
--------------------------------------------------------------------------------
/razor/logger.go:
--------------------------------------------------------------------------------
1 | package razor
2 |
3 | import (
4 | "log"
5 | )
6 |
7 | type LoggingLevel int
8 |
9 | const (
10 | LoggingLevelOff LoggingLevel = iota
11 | LogLevelError
12 | LogLevelWarn
13 | LogLevelInfo
14 | LogLevelVerbose
15 | LogLevelDebug
16 | LogLevelTrace
17 | )
18 |
19 | func (l LoggingLevel) String() string {
20 | switch l {
21 | case LoggingLevelOff:
22 | return "OFF"
23 | case LogLevelError:
24 | return "ERR"
25 | case LogLevelWarn:
26 | return "WRN"
27 | case LogLevelInfo:
28 | return "IFO"
29 | case LogLevelVerbose:
30 | return "VRB"
31 | case LogLevelDebug:
32 | return "DBG"
33 | case LogLevelTrace:
34 | return "TRC"
35 | }
36 |
37 | return "UNK"
38 | }
39 |
40 | type Logger struct {
41 | level LoggingLevel
42 | panicOnAssertFailure bool
43 | }
44 |
45 | func NewLogger(level LoggingLevel, panicOnAssertFailure bool) *Logger {
46 | return &Logger{}
47 | }
48 |
49 | func (l *Logger) log(level LoggingLevel, component string, msg string) {
50 | if level <= l.level {
51 | log.Println(level, component, "--", msg)
52 | }
53 | }
54 |
55 | func (l *Logger) NilErrCheck(component string, msg string, err error) bool {
56 | if err != nil {
57 | if l.panicOnAssertFailure {
58 | panic("Error is not nil " + component + " " + msg + " " + err.Error())
59 | } else {
60 | l.log(LogLevelError, component, msg)
61 | }
62 | return true
63 | }
64 | return false
65 | }
66 |
67 | func (l *Logger) Assert(component string, msg string, condition bool) {
68 | if !condition {
69 | if l.panicOnAssertFailure {
70 | panic("Assertion failure in " + component + " " + msg)
71 | } else {
72 | l.log(LogLevelError, component, "Assertion failure: "+msg)
73 | }
74 | }
75 | }
76 |
77 | func (l *Logger) Error(component string, msg string) {
78 | l.log(LogLevelError, component, msg)
79 | }
80 |
81 | func (l *Logger) Warn(component string, msg string) {
82 | l.log(LogLevelWarn, component, msg)
83 | }
84 |
85 | func (l *Logger) Info(component string, msg string) {
86 | l.log(LogLevelInfo, component, msg)
87 | }
88 |
89 | func (l *Logger) Verbose(component string, msg string) {
90 | l.log(LogLevelVerbose, component, msg)
91 | }
92 |
93 | func (l *Logger) Debug(component string, msg string) {
94 | l.log(LogLevelDebug, component, msg)
95 | }
96 |
97 | func (l *Logger) Trace(component string, msg string) {
98 | l.log(LogLevelTrace, component, msg)
99 | }
100 |
--------------------------------------------------------------------------------
/frontend/build.js:
--------------------------------------------------------------------------------
1 | import { build } from 'esbuild';
2 | import fs from 'fs';
3 | import fsextra from 'fs-extra';
4 | import { exec, execSync } from 'child_process';
5 | import path from 'path';
6 |
7 | console.log("Starting node build");
8 |
9 | const __dirname = new URL('.', import.meta.url).pathname;
10 |
11 | function mkdir(dir) {
12 | if (!fs.existsSync(dir)) {
13 | fs.mkdirSync(dir, { recursive: true });
14 | }
15 | }
16 |
17 | const srcDir = path.join(__dirname, 'src');
18 | const distDir = path.join(__dirname, 'dist');
19 | mkdir(distDir);
20 | mkdir(path.join(distDir, 'static'));
21 | mkdir(path.join(distDir, 'templates'));
22 | mkdir(path.join(srcDir, 'generated'));
23 |
24 | const version = Date.now(); // or use any unique versioning scheme
25 |
26 | const protocCommand = `
27 | protoc --ts_out src/generated --proto_path ../proto ../proto/*.proto
28 | `;
29 |
30 | function runCommand(cmd) {
31 | try {
32 | // Execute the command synchronously
33 | const stdout = execSync(cmd, { stdio: 'pipe' }); // 'pipe' captures output
34 | console.log(`stdout: ${stdout.toString()}`); // Convert Buffer to string
35 | } catch (error) {
36 | // Handle errors
37 | console.error(`exec error: ${error.message}`);
38 | if (error.stdout) {
39 | console.error(`stdout: ${error.stdout.toString()}`);
40 | }
41 | if (error.stderr) {
42 | console.error(`stderr: ${error.stderr.toString()}`);
43 | }
44 | process.exit(1); // Exit with an error code
45 | }
46 | }
47 |
48 | console.log("Generating protobufs");
49 | runCommand(protocCommand);
50 |
51 | console.log("Checking typescript types");
52 | runCommand(`tsc --noEmit`);
53 |
54 | function updateHtml(input, output) {
55 | const indexInPath = path.join(__dirname, input);
56 | const indexOutPath = path.join(__dirname, output);
57 | const indexContent = fs.readFileSync(indexInPath, 'utf-8');
58 |
59 | const updatedContent = indexContent
60 | .replace(/bundle\.js/g, `bundle.js?v=${version}`)
61 | .replace(/styles\.css/g, `styles.css?v=${version}`);
62 |
63 | fs.writeFileSync(indexOutPath, updatedContent);
64 | }
65 |
66 | console.log("Preparing html static and templates");
67 | updateHtml('html/index.html', 'dist/static/index.html');
68 | updateHtml('html/generic.html', 'dist/templates/generic.html');
69 |
70 | console.log("Copying static resources");
71 | fsextra.copy('public', 'dist/static', { overwrite: true });
72 |
73 | console.log("esbuild of frontend");
74 | build({
75 | entryPoints: ['./src/index.tsx'],
76 | loader: {'.ts': 'ts', '.tsx': 'tsx', '.js': 'js'},
77 | tsconfig: 'tsconfig.json',
78 | jsx: 'automatic',
79 | bundle: true,
80 | outfile: 'dist/static/bundle.js',
81 | minify: true,
82 | sourcemap: true,
83 | target: ['es2016'],
84 | }).catch(() => process.exit(1));
85 |
86 | console.log("Finished node build");
--------------------------------------------------------------------------------
/docs/KEYS_SETUP.md:
--------------------------------------------------------------------------------
1 | # Keys setup
2 | This uses WebRTC features of web browsers. For the API for this to work browsers must be persuaded they are operating in a secure context. https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
3 |
4 | ## Cloud deployments
5 | For securing the cloud deployment the easiest way is to deploy a docker container behind something like https://github.com/traefik/traefik configured to use https://letsencrypt.org as the certificate authority. The docker-compose-example.yml in deployment shows how to use a traefik wide certificate resolver as is normal for such things.
6 |
7 | ## Local subnetworks
8 | Some notes in advance:
9 | * For OpenWrt servers having all lower case host names helps avoid problems with umdns.
10 | * Recent versions of desktop Chrome seem to completely ignore mdns. You can work around this by using the actual device IP instead, and going through the security warnings. (To get a device IP from a Mac open a terminal and run ```dns-sd -q target-machine-name.local``` and it will list the IPs of that device on your subnet).
11 |
12 | ### Server setup
13 | You will need to know the host name of each server device.
14 |
15 | Create a private key for this server:
16 | ```
17 | openssl genrsa -out service.key 2048
18 | ```
19 |
20 | Create a server certificate:
21 | ```
22 | openssl req -x509 -new -key service.key -sha256 -days 365 -out service.crt
23 | ```
24 |
25 | When prompted you will need to enter the host name and domain as the "Common Name" field, meaning it should be all lower case and with ".local" at the end. For example, for a machine called "Atomirex-Machine" enter "atomirex-machine.local". (OpenWrt servers should have hostnames all lowercase because the umdns responder may not respond properly).
26 |
27 | The resulting service.crt and service.key files need to be in the directory alongside umbrella when it is launched.
28 |
29 | The local subnetwork mode sets up the sfu page to serve at /sfu .
30 |
31 | ### Client setup
32 | When the server is run it will output a line equivalent to "Starting server at https://atomirex-machine.local:8081/" which tells you the base url. The actual sfu is at /sfu for local deployments, and in the cloud likely /umbrella/sfu if following the default docker setup.
33 |
34 | For cloud only deployments no client specific incantations are needed. For local subnetworks some of the notes here might help:
35 |
36 | #### Safari
37 | Vist the page https://[[hostname:port]]/sfu . The first time you will get "This Connection Is Not Private". Select "Show Details" then "Visit Website". It should load successfully.
38 | #### Firefox (all platforms)
39 | In Settings disable DNS over HTTPS, then assuming your operating system is setup to use mdns properly you should be able to vist the page at the url. It will tell you it's not secure - go through more details and visit anyway, after which it will work.
40 | #### Android
41 | You need to disable "Private DNS" in system settings. (Easiest to search for DNS). Set it to "Off".
42 |
43 | Visit the page. Press "Show Advanced" and click on "Proceed to . . . " and it will load as normal.
--------------------------------------------------------------------------------
/proto/sfu.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 |
3 | package sfu;
4 |
5 | option go_package = "./sfu";
6 |
7 | enum TrackKind {
8 | Unknown = 0;
9 | Audio = 1;
10 | Video = 2;
11 | }
12 |
13 | message TrackDescriptor {
14 | string id = 1;
15 | TrackKind kind = 2;
16 | string streamId = 3;
17 | string umbrellaId = 4;
18 | }
19 |
20 | message CandidateMessage {
21 | string candidate = 1;
22 | bool incoming = 2; // Indicates the direction of the pc, relative to the sender of the message (i.e. true if the sender PC is incoming)
23 | }
24 |
25 | message OfferMessage {
26 | // Complete JSON of offer object, not just the sdp field
27 | string offer = 1;
28 | }
29 |
30 | message AnswerMessage {
31 | // Complete JSON of answer object, not just the sdp field
32 | string answer = 1;
33 | }
34 |
35 | // client->server this is a request saying what is available
36 | message SetUpstreamTracks {
37 | repeated TrackDescriptor tracks = 1;
38 | }
39 |
40 | // server->client it is information about what it is ready for accepting
41 | message AcceptUpstreamTracks {
42 | repeated TrackDescriptor tracks = 1;
43 | }
44 |
45 | // client->server - when the mapping for ID to mid is known notify the server
46 | message MidToUmbrellaIDMapping {
47 | string umbrellaId = 1;
48 | string mid = 2;
49 | }
50 |
51 | message MidToUmbrellaIDMappings {
52 | repeated MidToUmbrellaIDMapping mapping = 1;
53 | }
54 |
55 | // Possibly the dumbest conceivable almost symmetrical signalling protocol
56 | message RemoteNodeMessage {
57 | CandidateMessage candidate = 1;
58 | OfferMessage offer = 2;
59 | AnswerMessage answer = 3;
60 | SetUpstreamTracks upstreamTracks = 4;
61 | AcceptUpstreamTracks acceptTracks = 5;
62 | MidToUmbrellaIDMappings midMappings = 6;
63 | }
64 |
65 | // Returned from the /servers endpoint with content-type application/x-protobuf
66 | message CurrentServers {
67 | repeated string servers = 1; // urls of the servers
68 | }
69 |
70 | // Returned by the /status endpoint with content-type application/x-protobuf
71 | message SFUStatus {
72 | repeated TrackDescriptor relayingTracks = 1;
73 | repeated SFUStatusClient clients = 2;
74 | repeated string servers = 3;
75 | }
76 |
77 |
78 | // TODO could use the pc getstats interface for more info later
79 | message SFUStatusPeerConnection {
80 | string connectionState = 1;
81 | string signalingState = 2;
82 | string iceConnectionState = 3;
83 | string iceGatheringState = 4;
84 |
85 | int32 transceiverCount = 5;
86 | int32 senderCount = 6;
87 | int32 receiverCount = 7;
88 | }
89 |
90 | message SFUStatusSender {
91 | bool hasTrack = 1;
92 | string trackIdIfSet = 2;
93 | string umbrellaId = 3;
94 | }
95 |
96 | message SFUStatusStagedIncomingTrack {
97 | string streamId = 1;
98 | string trackId = 2;
99 | string mid = 3;
100 | }
101 |
102 | message SFUStatusClient {
103 | string label = 1;
104 | string trunkUrl = 2;
105 | SFUStatusPeerConnection incomingPC = 3;
106 | SFUStatusPeerConnection outgoingPC = 4;
107 | repeated TrackDescriptor incomingTracks = 5;
108 | repeated TrackDescriptor outgoingTracks = 6;
109 | repeated SFUStatusSender senders = 7;
110 | repeated MidToUmbrellaIDMapping midMapping = 8;
111 | repeated SFUStatusStagedIncomingTrack stagedIncomingTracks = 9;
112 | }
--------------------------------------------------------------------------------
/razor/messagequeue.go:
--------------------------------------------------------------------------------
1 | package razor
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | type MessageQueueWhat comparable
8 |
9 | type MessageQueueItem[MessageQueueWhat, PayloadType any] struct {
10 | at time.Time
11 | what MessageQueueWhat
12 | payload *PayloadType
13 | }
14 |
15 | type MessageQueue[What MessageQueueWhat, PayloadType any] struct {
16 | heap []MessageQueueItem[What, PayloadType]
17 | size uint16
18 | capacity uint16
19 | }
20 |
21 | func NewMessageQueue[MessageQueueWhat comparable, PayloadType any](capacity uint16) *MessageQueue[MessageQueueWhat, PayloadType] {
22 | return &MessageQueue[MessageQueueWhat, PayloadType]{
23 | heap: make([]MessageQueueItem[MessageQueueWhat, PayloadType], capacity),
24 | size: 0,
25 | capacity: capacity,
26 | }
27 | }
28 |
29 | func (mq *MessageQueue[MessageQueueWhat, PayloadType]) CurrentSize() uint16 {
30 | return mq.size
31 | }
32 |
33 | func (mq *MessageQueue[MessageQueueWhat, PayloadType]) Capacity() uint16 {
34 | return mq.capacity
35 | }
36 |
37 | func (mq *MessageQueue[MessageQueueWhat, PayloadType]) TryPush(at time.Time, what MessageQueueWhat, payload *PayloadType) bool {
38 | Assert(mq.size < mq.capacity)
39 |
40 | if mq.size >= mq.capacity {
41 | return false
42 | }
43 |
44 | mq.heap[mq.size] = MessageQueueItem[MessageQueueWhat, PayloadType]{at: at, what: what, payload: payload}
45 | mq.size++
46 |
47 | mq.heapifyUp(mq.size - 1)
48 | return true
49 | }
50 |
51 | func (mq *MessageQueue[MessageQueueWhat, PayloadType]) TryPop() Optional[MessageQueueItem[MessageQueueWhat, PayloadType]] {
52 | if mq.size == 0 {
53 | return NilOptional[MessageQueueItem[MessageQueueWhat, PayloadType]]()
54 | }
55 |
56 | item := mq.heap[0]
57 | mq.size--
58 | mq.heap[0] = mq.heap[mq.size]
59 | mq.heapifyDown(0)
60 |
61 | return NewOptional(item)
62 | }
63 |
64 | func (mq *MessageQueue[MessageQueueWhat, PayloadType]) TryPopAtTime(at time.Time) Optional[MessageQueueItem[MessageQueueWhat, PayloadType]] {
65 | if mq.size == 0 {
66 | return NilOptional[MessageQueueItem[MessageQueueWhat, PayloadType]]()
67 | }
68 |
69 | item := mq.heap[0]
70 | if at.After(item.at) {
71 | mq.size--
72 | mq.heap[0] = mq.heap[mq.size]
73 | mq.heapifyDown(0)
74 |
75 | return NewOptional(item)
76 | } else {
77 | return NilOptional[MessageQueueItem[MessageQueueWhat, PayloadType]]()
78 | }
79 | }
80 |
81 | func (mq *MessageQueue[MessageQueueWhat, PayloadType]) TryPeek() Optional[MessageQueueItem[MessageQueueWhat, PayloadType]] {
82 | if mq.size == 0 {
83 | return NilOptional[MessageQueueItem[MessageQueueWhat, PayloadType]]()
84 | }
85 |
86 | item := mq.heap[0]
87 | return NewOptional(item)
88 | }
89 |
90 | func (mq *MessageQueue[MessageQueueWhat, PayloadType]) Clear() {
91 | // Delete all buffers
92 | for i := uint16(0); i < mq.size; i++ {
93 | if mq.heap[i].payload != nil {
94 | // delete heap[i].payload;
95 | mq.heap[i].payload = nil
96 | }
97 | }
98 |
99 | // Set size to 0
100 | mq.size = 0
101 | }
102 |
103 | func (mq *MessageQueue[WHAT, PayloadType]) RemoveMessagesOfWhat(what WHAT) {
104 | for i := uint16(0); i < mq.size; {
105 | if mq.heap[i].what == what {
106 | // Delete the payload!!
107 | item := mq.heap[i]
108 | if item.payload != nil {
109 | // delete item.payload;
110 | mq.heap[i].payload = nil
111 | }
112 |
113 | // Replace the item with the last item
114 | mq.size--
115 | if mq.size > 0 {
116 | mq.heap[i] = mq.heap[mq.size]
117 | mq.heapifyDown(i) // Restore the heap property
118 | }
119 | } else {
120 | i++ // Only move to the next item if no removal happened
121 | }
122 | }
123 | }
124 |
125 | func (mq *MessageQueue[MessageQueueWhat, PayloadType]) heapifyUp(index uint16) {
126 | for index > 0 {
127 | parent := (index - 1) / 2
128 | if mq.heap[index].at.Before(mq.heap[parent].at) {
129 | Swap(&mq.heap[index], &mq.heap[parent])
130 | index = parent
131 | } else {
132 | return
133 | }
134 | }
135 |
136 | }
137 |
138 | func (mq *MessageQueue[MessageQueueWhat, PayloadType]) heapifyDown(index uint16) {
139 | for {
140 | left := 2*index + 1
141 | right := 2*index + 2
142 | smallest := index
143 |
144 | if left < mq.size && mq.heap[left].at.Before(mq.heap[smallest].at) {
145 | smallest = left
146 | }
147 |
148 | if right < mq.size && mq.heap[right].at.Before(mq.heap[smallest].at) {
149 | smallest = right
150 | }
151 |
152 | if smallest == index {
153 | // Completed!
154 | return
155 | }
156 |
157 | Swap(&mq.heap[index], &mq.heap[smallest])
158 | index = smallest
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/sfu/peerconnection.go:
--------------------------------------------------------------------------------
1 | package sfu
2 |
3 | import (
4 | "atomirex.com/umbrella/razor"
5 | "github.com/pion/rtcp"
6 | "github.com/pion/webrtc/v4"
7 | )
8 |
9 | type PeerConnection struct {
10 | label string
11 | wrapped *webrtc.PeerConnection
12 | logger *razor.Logger
13 |
14 | OnICECandidate func(w *webrtc.ICECandidate)
15 | OnICEConnectionStateChange func(is webrtc.ICEConnectionState)
16 | OnSignalingStateChange func(ss webrtc.SignalingState)
17 | OnConnectionStateChange func(pcs webrtc.PeerConnectionState)
18 | OnTrack func(tr *webrtc.TrackRemote, r *webrtc.RTPReceiver)
19 | OnNegotiationNeeded func()
20 | }
21 |
22 | type PeerConnectionFactory interface {
23 | NewPeerConnection(label string) (*PeerConnection, error)
24 | }
25 |
26 | type PionPeerConnectionFactory struct {
27 | webrtcApi *webrtc.API
28 | pcConfig *webrtc.Configuration
29 | logger *razor.Logger
30 | }
31 |
32 | func (p *PionPeerConnectionFactory) NewPeerConnection(label string) (*PeerConnection, error) {
33 | pc, err := p.webrtcApi.NewPeerConnection(*p.pcConfig)
34 |
35 | if err != nil {
36 | return nil, err
37 | }
38 |
39 | newPc := &PeerConnection{
40 | label: label,
41 | wrapped: pc,
42 | logger: p.logger,
43 | }
44 |
45 | pc.OnICECandidate(func(i *webrtc.ICECandidate) {
46 | p.logger.Verbose(label, "Ice candidate")
47 | if newPc.OnICECandidate != nil {
48 | newPc.OnICECandidate(i)
49 | }
50 | })
51 |
52 | pc.OnSignalingStateChange(func(ss webrtc.SignalingState) {
53 | p.logger.Info(label, "OnSignalingStateChange"+ss.String())
54 | if newPc.OnSignalingStateChange != nil {
55 | newPc.OnSignalingStateChange(ss)
56 | }
57 | })
58 |
59 | pc.OnICEConnectionStateChange(func(is webrtc.ICEConnectionState) {
60 | p.logger.Info(label, "OnICEConnectionStateChange"+is.String())
61 | if newPc.OnICEConnectionStateChange != nil {
62 | newPc.OnICEConnectionStateChange(is)
63 | }
64 | })
65 |
66 | pc.OnConnectionStateChange(func(pcs webrtc.PeerConnectionState) {
67 | p.logger.Info(label, "OnConnectionStateChange"+pcs.String())
68 | if newPc.OnConnectionStateChange != nil {
69 | newPc.OnConnectionStateChange(pcs)
70 | }
71 | })
72 |
73 | pc.OnTrack(func(tr *webrtc.TrackRemote, r *webrtc.RTPReceiver) {
74 | p.logger.Info(label, "OnTrack")
75 | if newPc.OnTrack != nil {
76 | newPc.OnTrack(tr, r)
77 | }
78 | })
79 |
80 | pc.OnNegotiationNeeded(func() {
81 | p.logger.Info(label, "OnNegotiationNeeded")
82 | if newPc.OnNegotiationNeeded != nil {
83 | newPc.OnNegotiationNeeded()
84 | }
85 | })
86 |
87 | return newPc, nil
88 | }
89 |
90 | func (pc *PeerConnection) GetStatus() *SFUStatusPeerConnection {
91 | return &SFUStatusPeerConnection{
92 | ConnectionState: pc.wrapped.ConnectionState().String(),
93 | SignalingState: pc.wrapped.SignalingState().String(),
94 | IceConnectionState: pc.wrapped.ICEConnectionState().String(),
95 | IceGatheringState: pc.wrapped.ICEGatheringState().String(),
96 |
97 | TransceiverCount: int32(len(pc.wrapped.GetTransceivers())),
98 | SenderCount: int32(len(pc.wrapped.GetSenders())),
99 | ReceiverCount: int32(len(pc.wrapped.GetReceivers())),
100 | }
101 | }
102 |
103 | func (pc *PeerConnection) Close() error {
104 | pc.logger.Info(pc.label, "Closing")
105 |
106 | err := pc.wrapped.Close()
107 |
108 | pc.logger.NilErrCheck(pc.label, "Error when closing", err)
109 |
110 | return err
111 | }
112 |
113 | func (pc *PeerConnection) CreateDataChannel(name string, params *webrtc.DataChannelInit) (*webrtc.DataChannel, error) {
114 | return pc.wrapped.CreateDataChannel(name, params)
115 | }
116 |
117 | func (pc *PeerConnection) GetReceivers() []*webrtc.RTPReceiver {
118 | return pc.wrapped.GetReceivers()
119 | }
120 |
121 | func (pc *PeerConnection) GetSenders() []*webrtc.RTPSender {
122 | return pc.wrapped.GetSenders()
123 | }
124 |
125 | func (pc *PeerConnection) GetTransceivers() []*webrtc.RTPTransceiver {
126 | return pc.wrapped.GetTransceivers()
127 | }
128 |
129 | func (pc *PeerConnection) WriteRTCP(pkts []rtcp.Packet) error {
130 | return pc.wrapped.WriteRTCP(pkts)
131 | }
132 |
133 | func (pc *PeerConnection) AddTransceiverFromKind(kind webrtc.RTPCodecType, params webrtc.RTPTransceiverInit) (*webrtc.RTPTransceiver, error) {
134 | return pc.wrapped.AddTransceiverFromKind(kind, params)
135 | }
136 |
137 | func (pc *PeerConnection) IsTerminated() bool {
138 | switch pc.wrapped.ConnectionState() {
139 | case webrtc.PeerConnectionStateClosed:
140 | case webrtc.PeerConnectionStateDisconnected:
141 | case webrtc.PeerConnectionStateFailed:
142 | return true
143 | }
144 |
145 | return false
146 | }
147 |
148 | func (pc *PeerConnection) AddICECandidate(candidate webrtc.ICECandidateInit) error {
149 | return pc.wrapped.AddICECandidate(candidate)
150 | }
151 |
152 | func (pc *PeerConnection) CreateAnswer(options *webrtc.AnswerOptions) (webrtc.SessionDescription, error) {
153 | return pc.wrapped.CreateAnswer(options)
154 | }
155 |
156 | func (pc *PeerConnection) CreateOffer(options *webrtc.OfferOptions) (webrtc.SessionDescription, error) {
157 | return pc.wrapped.CreateOffer(options)
158 | }
159 |
160 | func (pc *PeerConnection) SetRemoteDescription(description webrtc.SessionDescription) error {
161 | return pc.wrapped.SetRemoteDescription(description)
162 | }
163 |
164 | func (pc *PeerConnection) SetLocalDescription(description webrtc.SessionDescription) error {
165 | return pc.wrapped.SetLocalDescription(description)
166 | }
167 |
168 | func (pc *PeerConnection) AddTrack(track webrtc.TrackLocal) (*webrtc.RTPSender, error) {
169 | return pc.wrapped.AddTrack(track)
170 | }
171 |
172 | func (pc *PeerConnection) RemoveTrack(sender *webrtc.RTPSender) error {
173 | return pc.wrapped.RemoveTrack(sender)
174 | }
175 |
--------------------------------------------------------------------------------
/sfu/client_rtsp.go:
--------------------------------------------------------------------------------
1 | package sfu
2 |
3 | import (
4 | "log"
5 | "time"
6 |
7 | "atomirex.com/umbrella/razor"
8 | "github.com/bluenviron/gortsplib/v4"
9 | "github.com/bluenviron/gortsplib/v4/pkg/base"
10 | "github.com/bluenviron/gortsplib/v4/pkg/description"
11 | "github.com/bluenviron/gortsplib/v4/pkg/format"
12 | "github.com/google/uuid"
13 | "github.com/pion/rtcp"
14 | "github.com/pion/rtp"
15 | "github.com/pion/webrtc/v4"
16 | )
17 |
18 | // Very experimental rtsp video only ingest
19 | // Serving as basis of client refactor and understanding more about rtsp
20 |
21 | type rtspClientCommand int
22 |
23 | const (
24 | rtspClientStop rtspClientCommand = iota
25 | rtspClientDial
26 | )
27 |
28 | type rtspClientCommandMessage struct{}
29 |
30 | type RtspClient struct {
31 | BaseClient
32 |
33 | url string
34 | handler *razor.MessageHandler[rtspClientCommand, rtspClientCommandMessage]
35 |
36 | rtsplibClient *gortsplib.Client
37 | rtspDescription *description.Session
38 | }
39 |
40 | func (r *RtspClient) stop() {
41 | r.handler.Send(rtspClientStop, nil)
42 | }
43 |
44 | func (r *RtspClient) run(s *Sfu) {
45 | r.handler = razor.NewMessageHandler(r.logger, r.label, 16, func(what rtspClientCommand, payload *rtspClientCommandMessage) bool {
46 | switch what {
47 | case rtspClientStop:
48 | if r.rtsplibClient != nil {
49 | r.rtsplibClient.Close()
50 | r.rtsplibClient = nil
51 | }
52 |
53 | r.handler.Abort()
54 | return true
55 | case rtspClientDial:
56 | if r.rtsplibClient != nil {
57 | return true
58 | }
59 |
60 | failedCheck := func(err error) bool {
61 | if err == nil {
62 | return false
63 | }
64 |
65 | log.Println(err)
66 |
67 | if r.rtsplibClient != nil {
68 | r.rtsplibClient.Close()
69 | r.rtsplibClient = nil
70 | }
71 |
72 | r.handler.Timeout(rtspClientDial, nil, 5000*time.Millisecond)
73 | return true
74 | }
75 |
76 | u, err := base.ParseURL(r.url)
77 | if err != nil {
78 | log.Println("Invalid url", err)
79 | return true
80 | }
81 |
82 | r.rtsplibClient = &gortsplib.Client{}
83 | err = r.rtsplibClient.Start(u.Scheme, u.Host)
84 | if failedCheck(err) {
85 | return true
86 | }
87 |
88 | r.rtspDescription, _, err = r.rtsplibClient.Describe(u)
89 | if failedCheck(err) {
90 | return true
91 | }
92 |
93 | // Video only for now with the eufy
94 | // Audio breaks things massively, and doesn't work at all
95 | // Might be best to work out how to use go2rtc libraries
96 | videointrack := &incomingTrack{
97 | descriptor: &TrackDescriptor{
98 | UmbrellaId: "UMB_ID" + uuid.NewString(),
99 | Kind: TrackKind_Video,
100 | },
101 | }
102 |
103 | videorelay, err := webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{
104 | MimeType: webrtc.MimeTypeH264,
105 | ClockRate: 90000,
106 | Channels: 0,
107 | SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f",
108 | RTCPFeedback: nil,
109 | }, "UMB_RSTP_SRC"+uuid.New().String(), "rtsp-src-stream-id"+uuid.NewString())
110 |
111 | videointrack.relay = videorelay
112 |
113 | if failedCheck(err) {
114 | return true
115 | }
116 |
117 | go func() {
118 | defer s.removeOutgoingTracksForIncomingTrack(videointrack)
119 |
120 | // setup all medias
121 | // this must be called before StartRecording(), since it overrides the control attribute.
122 | err = r.rtsplibClient.SetupAll(r.rtspDescription.BaseURL, r.rtspDescription.Medias)
123 | if failedCheck(err) {
124 | return
125 | }
126 |
127 | // read RTP packets from the reader and route them to the publisher
128 | r.rtsplibClient.OnPacketRTPAny(func(medi *description.Media, forma format.Format, pkt *rtp.Packet) {
129 | if medi.Type == description.MediaTypeVideo {
130 | p := pkt.Clone()
131 | p.Extension = false
132 | p.Extensions = nil
133 |
134 | if err := videointrack.relay.WriteRTP(p); err != nil {
135 | r.logger.Error(r.label, "Error writing rtp from "+videointrack.String()+" to relay "+err.Error())
136 | return
137 | }
138 | }
139 | })
140 |
141 | rtcpStop := make(chan struct{})
142 |
143 | go func() {
144 | // Otherwise it basically never does this!
145 | keyframehack := time.NewTicker(12 * time.Second)
146 | defer keyframehack.Stop()
147 |
148 | // Ugh
149 | defer func() {
150 | if r := recover(); r != nil {
151 | log.Println("RTCP panicked", r)
152 | }
153 | }()
154 |
155 | for {
156 | select {
157 | case _, ok := <-rtcpStop:
158 | if !ok {
159 | return
160 | }
161 | case <-keyframehack.C:
162 | err := r.rtsplibClient.WritePacketRTCP(r.rtspDescription.Medias[0], &rtcp.PictureLossIndication{})
163 | if err != nil {
164 | return
165 | }
166 | }
167 | }
168 | }()
169 |
170 | // start playing
171 | _, err = r.rtsplibClient.Play(nil)
172 | failedCheck(err)
173 |
174 | failedCheck(r.rtsplibClient.Wait())
175 |
176 | // Properly stop the other goroutines
177 | // Dislike this, especially needing to check for panics
178 | close(rtcpStop)
179 | }()
180 |
181 | s.handler.Send(sfuAddOutgoingTracksForIncomingTrack, &sfuCommandMessage{intrack: videointrack})
182 |
183 | return true
184 | }
185 | return true
186 | })
187 |
188 | r.handler.Send(rtspClientDial, nil)
189 |
190 | r.handler.Loop(func() {
191 | })
192 | }
193 |
194 | func (r *RtspClient) AddOutgoingTracksForIncomingTrack(intrack *incomingTrack) {
195 | // Do nothing
196 | }
197 |
198 | func (r *RtspClient) RemoveOutgoingTracksForIncomingTrack(intrack *incomingTrack) {
199 | // Do nothing
200 | }
201 |
202 | func (r *RtspClient) RequestEvalState() {
203 |
204 | }
205 |
206 | func (r *RtspClient) getStatus() *SFUStatusClient {
207 | return nil
208 | }
209 |
--------------------------------------------------------------------------------
/razor/messagehandler.go:
--------------------------------------------------------------------------------
1 | package razor
2 |
3 | import (
4 | "fmt"
5 | "time"
6 | )
7 |
8 | // Returns if the payload should be destroyed, which in golang doesn't do anything
9 | type MessageHandlerCallback[MessageQueueWhat comparable, PayloadType any] func(what MessageQueueWhat, payload *PayloadType) bool
10 |
11 | type MessageHandler[MessageQueueWhat comparable, PayloadType any] struct {
12 | label string
13 |
14 | queue *MessageQueue[MessageQueueWhat, PayloadType]
15 | callback MessageHandlerCallback[MessageQueueWhat, PayloadType]
16 | lock *LockAndNotify
17 |
18 | aborted bool
19 |
20 | logger *Logger
21 | }
22 |
23 | func NewMessageHandler[MessageQueueWhat comparable, PayloadType any](logger *Logger, label string, queueLength uint16, callback MessageHandlerCallback[MessageQueueWhat, PayloadType]) *MessageHandler[MessageQueueWhat, PayloadType] {
24 | return &MessageHandler[MessageQueueWhat, PayloadType]{
25 | label: label,
26 | queue: NewMessageQueue[MessageQueueWhat, PayloadType](queueLength),
27 | callback: callback,
28 | lock: NewLockAndNotify(),
29 | aborted: false,
30 | logger: logger,
31 | }
32 | }
33 |
34 | func (mh *MessageHandler[MessageQueueWhat, PayloadType]) Size() uint16 {
35 | return mh.queue.CurrentSize()
36 | }
37 |
38 | func (mh *MessageHandler[MessageQueueWhat, PayloadType]) Capacity() uint16 {
39 | return mh.queue.Capacity()
40 | }
41 |
42 | func (mh *MessageHandler[MessageQueueWhat, PayloadType]) Send(what MessageQueueWhat, payload *PayloadType) bool {
43 | mh.logger.Trace(mh.label, fmt.Sprintf("Send: %v", what))
44 | mh.lock.Enter()
45 |
46 | if mh.aborted {
47 | mh.logger.Info(mh.label, "Send called on aborted messagehandler")
48 | mh.lock.Leave()
49 |
50 | return false
51 | } else {
52 | result := mh.queue.TryPush(time.Now(), what, payload)
53 |
54 | mh.lock.Leave()
55 |
56 | mh.logger.Assert(mh.label, "Handler queue too short in send", !result)
57 |
58 | mh.lock.Notify()
59 |
60 | return result
61 | }
62 | }
63 |
64 | func (mh *MessageHandler[MessageQueueWhat, PayloadType]) Timeout(what MessageQueueWhat, payload *PayloadType, delay time.Duration) bool {
65 | mh.logger.Trace(mh.label, fmt.Sprintf("Timeout: %v %s", what, delay))
66 | mh.lock.Enter()
67 |
68 | if mh.aborted {
69 | mh.logger.Info(mh.label, "Timeout called on aborted messagehandler")
70 | mh.lock.Leave()
71 | return false
72 | } else {
73 | result := mh.queue.TryPush(time.Now().Add(delay), what, payload)
74 | mh.lock.Leave()
75 |
76 | mh.logger.Assert(mh.label, "Handler queue too short in timout", !result)
77 |
78 | mh.lock.Notify()
79 |
80 | return result
81 | }
82 | }
83 |
84 | func (mh *MessageHandler[MessageQueueWhat, PayloadType]) Cancel(what MessageQueueWhat) {
85 | mh.logger.Trace(mh.label, fmt.Sprintf("Cancel: %v", what))
86 | mh.lock.Enter()
87 |
88 | if mh.aborted {
89 | mh.logger.Info(mh.label, "Cancel called on aborted messagehandler")
90 | mh.lock.Leave()
91 | } else {
92 | mh.queue.RemoveMessagesOfWhat(what)
93 |
94 | mh.lock.Leave()
95 | mh.lock.Notify()
96 | }
97 | }
98 |
99 | func (mh *MessageHandler[MessageQueueWhat, PayloadType]) CancelAll() {
100 | mh.logger.Trace(mh.label, "CancelAll")
101 | mh.lock.Enter()
102 |
103 | if mh.aborted {
104 | mh.logger.Info(mh.label, "CancelAll called on aborted messagehandler (will happen once every time)")
105 | mh.lock.Leave()
106 | } else {
107 | mh.queue.Clear()
108 |
109 | mh.lock.Leave()
110 | mh.lock.Notify()
111 | }
112 | }
113 |
114 | func (mh *MessageHandler[MessageQueueWhat, PayloadType]) DoWork(now time.Time) {
115 | max_messages_per_update := 10
116 |
117 | for max_messages_per_update > 0 {
118 | // See if we have any work to do, and if so to do it
119 | mh.lock.Enter()
120 | itemOptional := mh.queue.TryPopAtTime(now)
121 | mh.lock.Leave()
122 |
123 | if itemOptional.IsSet {
124 | // Do the work
125 | item := itemOptional.Value
126 |
127 | mh.logger.Trace(mh.label, fmt.Sprintf("WorkEvent: %v", item.what))
128 | // completed := make(chan bool, 0)
129 |
130 | // go func() {
131 | destroyBuffer := mh.callback(item.what, item.payload)
132 | if destroyBuffer {
133 | // Not really needed in golang
134 | }
135 | // completed <- true
136 | // }()
137 |
138 | // select {
139 | // case <-time.After(200 * time.Millisecond):
140 | // pstr := fmt.Sprintf("Handler %s took too long processing work item %v", mh.label, item.what)
141 | // panic(pstr)
142 | // case <-completed:
143 | // }
144 | mh.logger.Trace(mh.label, fmt.Sprintf("WorkEventCompleted: %v", item.what))
145 |
146 | max_messages_per_update--
147 | } else {
148 | return
149 | }
150 | }
151 | }
152 |
153 | func (mh *MessageHandler[MessageQueueWhat, PayloadType]) NextWorkAt() time.Time {
154 | mh.lock.Enter()
155 | itemOptional := mh.queue.TryPeek()
156 | mh.lock.Leave()
157 |
158 | if itemOptional.IsSet {
159 | return itemOptional.Value.at
160 | } else {
161 | // Sleep until some work arrives
162 | return EndOfTime
163 | }
164 | }
165 |
166 | func (mh *MessageHandler[MessageQueueWhat, PayloadType]) Loop(oncleanup func()) {
167 | go func() {
168 | work := func() {
169 | for {
170 | if mh.aborted {
171 | return
172 | }
173 |
174 | now := time.Now()
175 | waitUntil := now.Add(5 * time.Second)
176 |
177 | mh.DoWork(now)
178 |
179 | nextWork := mh.NextWorkAt()
180 |
181 | if nextWork.Before(waitUntil) {
182 | waitUntil = nextWork
183 | }
184 |
185 | if now.Before(waitUntil) {
186 | mh.lock.Wait(waitUntil.Sub(now))
187 | } else {
188 | // This is because we must call wait regularly to stop the waker in lock from blocking!
189 | mh.lock.Wait(5 * time.Millisecond)
190 | }
191 | }
192 | }
193 |
194 | work()
195 | if oncleanup != nil {
196 | oncleanup()
197 | }
198 | }()
199 | }
200 |
201 | // Clears all work and stops any loops
202 | // Does not block waiting though
203 | func (mh *MessageHandler[MessageQueueWhat, PayloadType]) Abort() {
204 | mh.logger.Trace(mh.label, "Abort")
205 | mh.lock.Enter()
206 | mh.aborted = true
207 | mh.lock.Leave()
208 |
209 | mh.CancelAll()
210 | mh.logger.Trace(mh.label, "Abort returned")
211 | }
212 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/atomirex/mdns v0.0.13-0.20241125201824-943da8fa1231 h1:91mEHouufaTTLYjiqHY6C2vBEYW7xPdWmS/FmthixJ0=
2 | github.com/atomirex/mdns v0.0.13-0.20241125201824-943da8fa1231/go.mod h1:nhf3AaaXAZYM8Za49tsvZW5sEB+UO2OHKydJKAkbcyU=
3 | github.com/bluenviron/gortsplib/v4 v4.11.2 h1:V9WjA9sY99X0OiQyz/JgLOMeHaXyOcE3XsOIU+yQS4U=
4 | github.com/bluenviron/gortsplib/v4 v4.11.2/go.mod h1:H6bdvXU0+poDcR0etOvdcwsNKC/1xzAMVuBNO4hxeL4=
5 | github.com/bluenviron/mediacommon v1.13.1 h1:agxDtkooknxSxOO/oOpB+tEW48OLMqty1PDMC3x2n4E=
6 | github.com/bluenviron/mediacommon v1.13.1/go.mod h1:HDyW2CzjvhYJXtdxstdFPio3G0qSocPhqkhUt/qffec=
7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
10 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
11 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
12 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
13 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
14 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
15 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
16 | github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
17 | github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
18 | github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
19 | github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
20 | github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
21 | github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
22 | github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI=
23 | github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
24 | github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
25 | github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
26 | github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
27 | github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
28 | github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
29 | github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
30 | github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
31 | github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
32 | github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk=
33 | github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
34 | github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw=
35 | github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM=
36 | github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
37 | github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
38 | github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
39 | github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
40 | github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
41 | github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
42 | github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
43 | github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
44 | github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
45 | github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
46 | github.com/pion/webrtc/v4 v4.0.1 h1:6Unwc6JzoTsjxetcAIoWH81RUM4K5dBc1BbJGcF9WVE=
47 | github.com/pion/webrtc/v4 v4.0.1/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
48 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
49 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
50 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
51 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
52 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
53 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
54 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
55 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
56 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
57 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
58 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
59 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
60 | github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
61 | github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
62 | golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
63 | golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
64 | golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
65 | golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
66 | golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
67 | golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
68 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
69 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
70 | google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
71 | google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
72 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
73 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
74 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
75 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
76 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Umbrella
2 |
3 | Demo video: https://x.com/atomirex/status/1863956802984984728
4 |
5 | This is a hacked up proof of concept WebRTC SFU that can run on many things, such as a local OpenWrt AP or in the cloud with Docker Compose/Traefik, including having a group session that spans both at once by backhauling from the AP to the cloud, and ingesting video from security cameras. Today it serves to help explore the problem space more than being any solution to anything.
6 |
7 | It's not production ready, mainly having almost no security! It also lacks quite a lot of potential optimizations. If you need a production video conf system today try https://livekit.io or https://www.daily.co (unaffiliated with either). It's also not very selective, just forwarding everything to everyone.
8 |
9 | This began as a fork of the pion example sfu-ws to see if it could run on OpenWrt. https://github.com/pion/example-webrtc-applications/tree/master/sfu-ws
10 |
11 | ## What is it?
12 |
13 | A Selective-Forwarding-Unit is a server that relays incoming traffic from one client by copying it to any number of other clients, system resources permitting. In the context of WebRTC it is decrypting the packets that it receives, and then re-encrypting them before forwarding them on, but it is not actually decoding and reencoding the media streams. A TURN ( https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT ) relay does not decode or decrypt a stream, but does not fan out the incoming traffic, only acting point-to-point, and so serves different purposes.
14 |
15 | Reasons you might want a SFU:
16 | * Isolating clients so they only see the IP of the SFU and not of each other - in a normal WebRTC session clients can see the IPs of other clients, which may present a security problem.
17 | * A central location for recording streams - since we are decrypting and not decoding the streams you can cheaply dump the decrypted but compressed media packets to disk to replay later. (Pion includes other examples showing this).
18 | * Reducing the amount of bandwidth needed at each node in contexts such as large group calls - in a normal P2P WebRTC session each additional participant must connect directly to every other participant causing an explosion in the number of connections it has to maintain, each of which is carrying a copy of the media. A central SFU provides a single point for all clients to connect to and absorbs the load of distributing the data, hopefully leveraging better connectivity.
19 |
20 | 
21 |
22 | There are also reasons you might not want an SFU, primarily if you do not trust the machine the SFUs are operated on.
23 |
24 | The semi-novel bit here is the backhaul from the assumed AP host to the cloud. The idea is this reduces load on the link between them, enabling different clients at each end to share the transported media streams.
25 |
26 | 
27 |
28 | There are possible extensions to this like making the cloud server purely signalling and connecting two APs at separate sites directly together, and even merging the network connection negotiation information so devices can dynamically switch which SFU to use based on what is optimal for their situation.
29 |
30 | ## How do I try it out?
31 | It's fairly easy to build/run on a generic desktop, assuming you meet the security prerequisites and have the dependencies. The OpenWrt and Docker/traefik sections detail specific aspects of those environments.
32 |
33 | It would be unwise to leave this running on a public network as is for very long, and I cannot be responsible for anything that would result! Try it at your own risk, ideally somewhere unlikely to be found.
34 |
35 | ### Security prerequisites
36 | Using WebRTC features in a browser requires the browser to believe it is in a "secure context", which in practice for this means serving https. For the cloud side of things this is fairly easy since you can use something like letsencrypt in the normal way.
37 |
38 | Running the SFU on a local subnet, such as that run by an AP, requires creating a key and certificate for the server so devices can keep track of the identity of the server. This is detailed in [docs/KEYS_SETUP.md](docs/KEYS_SETUP.md) .
39 |
40 | ### Building
41 | The main SFU is a golang project using the pion library for webrtc functionality. Protobufs are used for communication, and there is a small React/Typescript front end which gets bundled into the binary for ease of deployment. The main entry points for building are the scripts build_PROCESSOR_OS.sh scripts which use the golang cross compilation support to build for the different target architectures. The frontend project is built with esbuild and triggered via the npm commands in build_common.sh, along with the protoc compilation step.
42 |
43 | To build you need:
44 | * golang
45 | * npm
46 | * protoc, including the golang support
47 |
48 | Then run one of the shell scripts, and it should produce an executable at ./umbrella .
49 |
50 | #### OpenWrt
51 | One of the main targets for this is access points running OpenWrt. More details are in [docs/openwrt.md](docs/openwrt.md) .
52 |
53 | #### Docker/traefik
54 | For running on a public webserver something like docker compose with traefik will take away a lot of headaches. More details are in [docs/docker.md](docs/docker.md) .
55 |
56 | ### Running it
57 |
58 | #### Locally
59 | Running locally should be a question of running the umbrella executable, in a directory with the service.crt and service.key files created earlier (see [docs/KEYS_SETUP.md](docs/KEYS_SETUP.md) ). Port 8081 will need to be available and accessible. Then you can visit: https://HOSTNAME:8081/sfu
60 |
61 | #### Cloud
62 | The easiest way to run it in the cloud is dockerized behind something like a traefik reverse proxy. This way traefik can act as the https endpoint, taking care of the certs, leaving the only real problem being ensuring you use host networking.
63 |
64 | The docker-compose-example.yml in deployment shows how you can run it as a sub page of an existing domain.
65 |
66 | When it's running you should be able to access it at https://DOMAIN/umbrella/sfu .
67 |
68 | #### Connecting the two
69 | The real party trick here is a joint session across SFUs. To do this access the server to push to the other (most likely local to cloud, so local), and visit https://HOSTNAME:8081/servers . In the text field put the websocket address for the other server and press the button. The websocket address is "wsb" instead of "sfu", and the protocol is "wss" instead of "https". For example: wss://DOMAIN/umbrella/wsb To stop trunking remove the server connection.
70 |
71 | What should happen is all client data for each SFU is relayed to every client of both SFUs, as the result of the data going over the connection between the relays.
72 |
73 | This is a hacked up UI, but it does work.
74 |
75 | #### Using RTSP for cameras
76 | Assuming you can access the rtsp feed of a camera (verifiable using VLC) you can ingest from the camera. Different camera brands are more/less reliable for this, and the whole feature is highly experimental, creating a whole load of new problems.
77 |
78 | A RTSP camera acts as a server, so to use it you add a server on the same page as connecting between SFUs, i.e. https://HOSTNAME:8081/servers The format is something like rtsp://yourcameraname:yourcamerapassword@CAMERAIP/stream1 but it will vary based on your camera brand.
79 |
80 | This only brings in video for now.
81 |
82 | ## How do I develop against it?
83 | This is a real proof of concept mess. Any focused PRs or issues are welcome, as are forks. Assume zero stability at this stage.
84 |
85 | The immediate priority is to get this out there to see what happens, and then work on adding "role" type functionality, primarily so signalling can be separated from the SFU so that two remote sites can directly bridge between them. Other things likely to happen soon are auth and adding an mqtt broker for IoT integrations.
86 |
87 | If you're not into the whole multi-site aspect of it you're almost certainly better off with livekit or daily as mentioned at the top!
88 |
89 | ## What does it not do?
90 | * Authentication - there isn't any at all.
91 | * Simulcast - a feature where clients upload streams at multiple different bitrates, and the SFU forwards on the most appropriate bitrate available for each client depending on the available bandwidth.
92 | * "Pull" optimizations - right now media is forwarded to endpoints whether it is consumed there or not. For example, if you have backhaul to the cloud active all AP client media is forwarded to the cloud even if no clients are connected to the cloud instance.
93 | * Cycles in backhaul will explode. It can deal with star topologies but because each node simply relays everything right now a cycle will go very wrong.
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "embed"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "io/fs"
10 | "log"
11 | "net"
12 | "net/http"
13 | "os"
14 | "runtime"
15 | "runtime/debug"
16 | "strconv"
17 | "strings"
18 | "text/template"
19 | "time"
20 |
21 | "atomirex.com/umbrella/razor"
22 | "atomirex.com/umbrella/sfu"
23 | "github.com/atomirex/mdns"
24 | "golang.org/x/net/ipv4"
25 | "golang.org/x/net/ipv6"
26 | "google.golang.org/protobuf/proto"
27 | )
28 |
29 | // nolint
30 | var ()
31 |
32 | //go:embed frontend/dist/static/*
33 | var staticFiles embed.FS
34 |
35 | //go:embed frontend/dist/templates/*
36 | var templateFiles embed.FS
37 |
38 | type responseRecorder struct {
39 | http.ResponseWriter
40 | statusCode int
41 | }
42 |
43 | func (rec *responseRecorder) WriteHeader(code int) {
44 | rec.statusCode = code
45 | rec.ResponseWriter.WriteHeader(code)
46 | }
47 |
48 | // Implement http.Hijacker for websocket
49 | func (rr *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
50 | hijacker, ok := rr.ResponseWriter.(http.Hijacker)
51 | if !ok {
52 | return nil, nil, fmt.Errorf("underlying ResponseWriter does not support hijacking")
53 | }
54 | return hijacker.Hijack()
55 | }
56 |
57 | type umbrellaInjectedParameters struct {
58 | HttpPrefix string
59 | }
60 |
61 | func main() {
62 | cloudEnv := os.Getenv("UMBRELLA_CLOUD")
63 | isCloud := cloudEnv == "1"
64 |
65 | // Path from the domain base, to the umbrella sfu base, including any leading slash
66 | // For example "/umbrella" serves at https://domain:port/umbrella/sfu etc.
67 | // This is the same form traefik uses for PathPrefix, so you can use a common env var
68 | httpPrefixEnv := os.Getenv("UMBRELLA_HTTP_PREFIX")
69 | httpPrefix := ""
70 |
71 | if httpPrefixEnv != "" {
72 | httpPrefix = httpPrefixEnv
73 | }
74 |
75 | publicIpEnv := os.Getenv("UMBRELLA_PUBLIC_IP")
76 | publicIp := net.ParseIP(publicIpEnv)
77 |
78 | publicHostEnv := os.Getenv("UMBRELLA_PUBLIC_HOST")
79 |
80 | publicMinPortEnv := os.Getenv("UMBRELLA_MIN_PORT")
81 | publicMaxPortEnv := os.Getenv("UMBRELLA_MAX_PORT")
82 |
83 | minPort := uint16(40000)
84 | maxPort := uint16(60000)
85 |
86 | if publicMinPortEnv != "" {
87 | p, err := strconv.Atoi(publicMinPortEnv)
88 | if err == nil {
89 | minPort = uint16(p)
90 | }
91 | }
92 |
93 | if publicMaxPortEnv != "" {
94 | p, err := strconv.Atoi(publicMaxPortEnv)
95 | if err == nil {
96 | maxPort = uint16(p)
97 | }
98 | }
99 |
100 | httpServeAddrEnv := os.Getenv("UMBRELLA_HTTP_SERVE_ADDR")
101 |
102 | // Default is to serve on 8081
103 | httpServeAddr := ":8081"
104 | if httpServeAddrEnv != "" {
105 | httpServeAddr = httpServeAddrEnv
106 | }
107 |
108 | log.Println("Hello there", runtime.GOOS, runtime.GOARCH)
109 | if isCloud {
110 | log.Println("Running in cloud configuration")
111 |
112 | if publicIp == nil {
113 | log.Println("WARNING: public IP must be set when running as cloud")
114 | } else {
115 | log.Println("Public IP is", publicIp.String())
116 | }
117 | } else {
118 | log.Println("Running in edge configuration")
119 | }
120 |
121 | debug.SetMemoryLimit(256 * 1024 * 1024)
122 |
123 | host, err := os.Hostname()
124 | if err != nil {
125 | log.Fatal(err)
126 | return
127 | }
128 |
129 | if !strings.HasSuffix(host, ".local") {
130 | host = host + ".local"
131 | }
132 |
133 | if isCloud && publicHostEnv != "" {
134 | host = publicHostEnv
135 | } else {
136 | overriddenhost, overriddenport, err := net.SplitHostPort(httpServeAddr)
137 | if err != nil {
138 | host += ":8081"
139 | } else {
140 | usehost := host
141 | useport := "8081"
142 |
143 | if overriddenhost != "" {
144 | usehost = overriddenhost
145 | }
146 |
147 | if overriddenport != "" {
148 | useport = overriddenport
149 | }
150 |
151 | host = net.JoinHostPort(usehost, useport)
152 | }
153 | }
154 |
155 | log.Println("Starting server at", fmt.Sprintf("https://%v%s/", strings.ToLower(host), httpPrefix))
156 |
157 | templates, err := template.ParseFS(templateFiles, "frontend/dist/templates/*.html")
158 | if err != nil {
159 | log.Fatal(err)
160 | return
161 | }
162 |
163 | staticFilesSub, err := fs.Sub(staticFiles, "frontend/dist/static")
164 | if err != nil {
165 | log.Fatalf("failed to create file system: %v", err)
166 | }
167 |
168 | var ipStr *string
169 | if publicIp != nil {
170 | s := publicIp.String()
171 | ipStr = &s
172 | }
173 |
174 | logger := razor.NewLogger(razor.LogLevelError, false)
175 | s := sfu.NewSfu(logger, minPort, maxPort, ipStr)
176 |
177 | mux := http.NewServeMux()
178 |
179 | addHandler := func(pattern string, handler http.Handler) {
180 | if httpPrefix == "" {
181 | mux.Handle(pattern, handler)
182 | } else {
183 | mux.Handle(httpPrefix+pattern, http.StripPrefix(httpPrefix, handler))
184 | }
185 | }
186 |
187 | addHandler("/wsb", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
188 | s.WebsocketHandler(w, r)
189 | }))
190 |
191 | addHandler("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFilesSub))))
192 |
193 | generic := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
194 | if r.URL.Path == "/status" {
195 | contentType := r.Header.Get("Content-Type")
196 | if contentType == "application/x-protobuf" {
197 | status := s.GetStatus()
198 |
199 | log.Println("SFU STATUS", status)
200 |
201 | data, err := proto.Marshal(status)
202 | if err != nil {
203 | http.Error(w, "Failed to serialize Protobuf", http.StatusInternalServerError)
204 | return
205 | }
206 |
207 | w.Header().Set("Content-Type", contentType)
208 | w.WriteHeader(http.StatusOK)
209 |
210 | w.Write(data)
211 | return
212 | }
213 | }
214 |
215 | if r.URL.Path == "/servers" {
216 | contentType := r.Header.Get("Content-Type")
217 | if contentType == "application/x-protobuf" {
218 | switch r.Method {
219 | case http.MethodGet:
220 | data, err := proto.Marshal(s.GetCurrentServers())
221 | if err != nil {
222 | http.Error(w, "Failed to serialize Protobuf", http.StatusInternalServerError)
223 | return
224 | }
225 |
226 | w.Header().Set("Content-Type", contentType)
227 | w.WriteHeader(http.StatusOK)
228 |
229 | w.Write(data)
230 | return
231 | case http.MethodPost:
232 | body, err := io.ReadAll(r.Body)
233 | if err != nil {
234 | http.Error(w, "Failed to read payload", http.StatusInternalServerError)
235 | return
236 | }
237 |
238 | var update sfu.CurrentServers
239 | err = proto.Unmarshal(body, &update)
240 | if err != nil {
241 | http.Error(w, "Failed to deserialize payload", http.StatusInternalServerError)
242 | return
243 | }
244 |
245 | data, err := proto.Marshal(s.SetCurrentServers(&update))
246 | if err != nil {
247 | http.Error(w, "Failed to serialize Protobuf", http.StatusInternalServerError)
248 | return
249 | }
250 |
251 | w.Header().Set("Content-Type", contentType)
252 | w.WriteHeader(http.StatusOK)
253 |
254 | w.Write(data)
255 | return
256 | }
257 | }
258 | }
259 |
260 | injectedBytes, err := json.Marshal(&umbrellaInjectedParameters{
261 | HttpPrefix: httpPrefix,
262 | })
263 |
264 | if err != nil {
265 | http.Error(w, "Error preparing template", http.StatusInternalServerError)
266 | return
267 | }
268 |
269 | data := map[string]string{
270 | "Title": "SFU test page",
271 | "HttpPrefix": httpPrefix,
272 | "Injected": string(injectedBytes),
273 | }
274 |
275 | if err := templates.ExecuteTemplate(w, "generic.html", data); err != nil {
276 | http.Error(w, "Error rendering template", http.StatusInternalServerError)
277 | log.Println("Error executing template:", err)
278 | }
279 | })
280 |
281 | addHandler("/sfu", generic)
282 | addHandler("/servers", generic)
283 | addHandler("/status", generic)
284 |
285 | wrapped := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
286 | defer func() {
287 | log.Println("Request handling complete for", r.URL.Path)
288 | }()
289 |
290 | log.Println("Req ", r.URL.Path)
291 |
292 | rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
293 | mux.ServeHTTP(rec, r)
294 |
295 | if rec.statusCode == http.StatusNotFound {
296 | log.Println("Requesting unfound url", r.URL.Path)
297 | }
298 | })
299 |
300 | if isCloud {
301 | go func() {
302 | err = http.ListenAndServe(httpServeAddr, wrapped)
303 | if err != nil {
304 | log.Fatal(err)
305 | }
306 | }()
307 | } else {
308 | go func() {
309 | err = http.ListenAndServeTLS(httpServeAddr, "service.crt", "service.key", wrapped)
310 | if err != nil {
311 | log.Fatal(err)
312 | }
313 | }()
314 |
315 | // Use atomirex fork of the pion mdns which works with android clients
316 | go func() {
317 | addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4)
318 | if err != nil {
319 | panic(err)
320 | }
321 |
322 | addr6, err := net.ResolveUDPAddr("udp6", mdns.DefaultAddressIPv6)
323 | if err != nil {
324 | panic(err)
325 | }
326 |
327 | l4, err := net.ListenUDP("udp4", addr4)
328 | if err != nil {
329 | panic(err)
330 | }
331 |
332 | l6, err := net.ListenUDP("udp6", addr6)
333 | if err != nil {
334 | panic(err)
335 | }
336 |
337 | hostname, _ := os.Hostname()
338 | hostname = strings.TrimSuffix(strings.ToLower(hostname), ".")
339 | hostname = strings.TrimSuffix(hostname, ".local")
340 | hostname = hostname + ".local"
341 |
342 | log.Println("Broadcasting hostname via mdns", hostname)
343 |
344 | mdnsConn, err := mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{
345 | LocalNames: []mdns.RegisteredHost{{Name: hostname}},
346 | })
347 | if err != nil {
348 | panic(err)
349 | }
350 |
351 | s.SetMdnsConn(mdnsConn)
352 | select {}
353 | }()
354 | }
355 |
356 | go func() {
357 | for {
358 | <-time.After(5 * time.Second)
359 | runtime.GC()
360 | }
361 | }()
362 |
363 | razor.WaitForOsInterruptSignal()
364 |
365 | }
366 |
--------------------------------------------------------------------------------
/sfu/sfu.go:
--------------------------------------------------------------------------------
1 | package sfu
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net/http"
7 | "time"
8 |
9 | "atomirex.com/umbrella/razor"
10 | "github.com/atomirex/mdns"
11 | "github.com/gorilla/websocket"
12 | "github.com/pion/interceptor"
13 | "github.com/pion/logging"
14 | "github.com/pion/webrtc/v4"
15 | )
16 |
17 | var (
18 | upgrader = websocket.Upgrader{
19 | CheckOrigin: func(r *http.Request) bool { return true },
20 | }
21 | )
22 |
23 | type sfuCommand int
24 |
25 | const (
26 | sfuAddOutgoingTracksForIncomingTrack sfuCommand = iota
27 | sfuRemoveAllOutgoingTracksForIncomingTrack
28 | sfuSignalClients
29 | sfuAddClient
30 | sfuRemoveClient
31 |
32 | sfuGetCurrentServers
33 | sfuSetCurrentServers
34 |
35 | sfuGetStatus
36 | )
37 |
38 | type sfuCommandMessage struct {
39 | intrack *incomingTrack
40 | client *client
41 | SetCurrentServers *CurrentServers
42 |
43 | result *sfuCommandResult
44 | }
45 |
46 | type sfuCommandResult struct {
47 | servers chan *CurrentServers
48 | status chan *SFUStatus
49 | }
50 |
51 | type Sfu struct {
52 | clients []RemoteClient
53 | sfuCommands chan sfuCommandMessage
54 |
55 | peerConnectionFactory PeerConnectionFactory
56 | remoteClientFactory RemoteClientFactory
57 |
58 | // UmbrellaID -> track
59 | localTracks map[string]*incomingTrack // Set of all incoming tracks which are being relayed
60 |
61 | handler *razor.MessageHandler[sfuCommand, sfuCommandMessage]
62 |
63 | intendedServers map[string]bool // This works because strings completely define the spec of the server right now
64 | servers map[string]RemoteClient
65 |
66 | logger *razor.Logger
67 |
68 | loggerPion logging.LeveledLogger
69 |
70 | mdnsConn *mdns.Conn
71 | }
72 |
73 | func (s *Sfu) GetStatus() *SFUStatus {
74 | msg := sfuCommandMessage{result: &sfuCommandResult{
75 | status: make(chan *SFUStatus, 1),
76 | }}
77 |
78 | s.handler.Send(sfuGetStatus, &msg)
79 |
80 | return <-msg.result.status
81 | }
82 |
83 | func (s *Sfu) GetCurrentServers() *CurrentServers {
84 | msg := sfuCommandMessage{result: &sfuCommandResult{
85 | servers: make(chan *CurrentServers, 1),
86 | }}
87 |
88 | s.handler.Send(sfuGetCurrentServers, &msg)
89 |
90 | return <-msg.result.servers
91 | }
92 |
93 | func (s *Sfu) SetCurrentServers(update *CurrentServers) *CurrentServers {
94 | msg := sfuCommandMessage{
95 | SetCurrentServers: update,
96 | result: &sfuCommandResult{
97 | servers: make(chan *CurrentServers, 1),
98 | },
99 | }
100 |
101 | s.handler.Send(sfuSetCurrentServers, &msg)
102 |
103 | return <-msg.result.servers
104 | }
105 |
106 | func NewSfu(logger *razor.Logger, minPort uint16, maxPort uint16, ip *string) *Sfu {
107 | loggerPion := logging.NewDefaultLoggerFactory().NewLogger("sfu-ws")
108 | loggerPion.(*logging.DefaultLeveledLogger).SetLevel(logging.LogLevelError)
109 |
110 | settingEngine := webrtc.SettingEngine{}
111 |
112 | settingEngine.SetEphemeralUDPPortRange(minPort, maxPort)
113 |
114 | if ip != nil {
115 | settingEngine.SetNAT1To1IPs([]string{*ip}, webrtc.ICECandidateTypeHost)
116 | }
117 |
118 | m := &webrtc.MediaEngine{}
119 | if err := m.RegisterDefaultCodecs(); err != nil {
120 | panic("Error setting default codecs")
121 | }
122 |
123 | // Now we know what this is and why . . . . facepalm
124 | // This is the "default" nack, sr, rr etc. handling for rtcp
125 | // We can come back to it when it's a problem
126 | interceptorRegistry := &interceptor.Registry{}
127 | if err := webrtc.RegisterDefaultInterceptors(m, interceptorRegistry); err != nil {
128 | panic("Panic setting interceptors")
129 | }
130 |
131 | webrtcApi := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine), webrtc.WithMediaEngine(m))
132 |
133 | s := &Sfu{
134 | sfuCommands: make(chan sfuCommandMessage, 256),
135 | remoteClientFactory: &DefaultRemoteClientFactory{},
136 | peerConnectionFactory: &PionPeerConnectionFactory{
137 | pcConfig: &webrtc.Configuration{
138 | ICEServers: []webrtc.ICEServer{
139 | {
140 | URLs: []string{"stun:stun.l.google.com:19302"},
141 | },
142 | },
143 | },
144 | webrtcApi: webrtcApi,
145 | logger: logger,
146 | },
147 | localTracks: make(map[string]*incomingTrack),
148 | intendedServers: make(map[string]bool),
149 | servers: make(map[string]RemoteClient),
150 | logger: logger,
151 | loggerPion: loggerPion,
152 | }
153 |
154 | s.handler = razor.NewMessageHandler(logger, "sfu", 1024, func(what sfuCommand, payload *sfuCommandMessage) bool {
155 | shouldSignalClients := false
156 |
157 | switch what {
158 | case sfuAddClient:
159 | s.clients = append(s.clients, payload.client)
160 |
161 | // Add all existing tracks
162 | for _, t := range s.localTracks {
163 | payload.client.handler.Send(clientAddOutgoingTrackForIncomingTrack, &clientCommandMessage{incomingTrack: t})
164 | }
165 |
166 | shouldSignalClients = true
167 | case sfuRemoveClient:
168 | index := -1
169 | for i, c := range s.clients {
170 | if c == payload.client {
171 | index = i
172 | }
173 | }
174 |
175 | if index >= 0 {
176 | s.clients = append(s.clients[:index], s.clients[index+1:]...)
177 | }
178 |
179 | shouldSignalClients = true
180 | case sfuAddOutgoingTracksForIncomingTrack:
181 | logger.Info("sfu", "adding track: "+payload.intrack.String())
182 | intrack := payload.intrack
183 | s.localTracks[intrack.UmbrellaID()] = intrack
184 |
185 | for _, c := range s.clients {
186 | c.AddOutgoingTracksForIncomingTrack(intrack)
187 | }
188 |
189 | shouldSignalClients = true
190 | case sfuRemoveAllOutgoingTracksForIncomingTrack:
191 | logger.Info("sfu", "removing all outgoing tracks for track: "+payload.intrack.String())
192 | delete(s.localTracks, payload.intrack.UmbrellaID())
193 |
194 | for _, c := range s.clients {
195 | c.RemoveOutgoingTracksForIncomingTrack(payload.intrack)
196 | }
197 |
198 | shouldSignalClients = true
199 | case sfuSignalClients:
200 | shouldSignalClients = true
201 | case sfuGetStatus:
202 | // Terrible version of this which blocks while polling everything
203 | // Should really pass around a thing that gathers the info
204 | // but in reality I should move to a pub/sub thing that pushes the data out on changes anyway
205 | // this is to debug the mess as is :P
206 | logger.Info("sfu", "SFU getting status servers")
207 | servers := make([]string, 0)
208 | for t := range s.servers {
209 | servers = append(servers, t)
210 | }
211 | logger.Info("sfu", "SFU getting status relaying")
212 | relaying := make([]*TrackDescriptor, 0)
213 | for _, t := range s.localTracks {
214 | relaying = append(relaying, t.descriptor)
215 | }
216 | logger.Info("sfu", "SFU getting status clients")
217 | clients := make([]*SFUStatusClient, 0)
218 | for _, c := range s.clients {
219 | logger.Info("sfu", "SFU getting status for client "+c.Label())
220 | clients = append(clients, c.getStatus())
221 | logger.Info("sfu", "SFU received status for client "+c.Label())
222 | }
223 |
224 | logger.Info("sfu", "SFU getting status returning")
225 | status := &SFUStatus{
226 | RelayingTracks: relaying,
227 | Servers: servers,
228 | Clients: clients,
229 | }
230 |
231 | payload.result.status <- status
232 | case sfuGetCurrentServers:
233 | result := &CurrentServers{Servers: make([]string, 0)}
234 |
235 | for t := range s.servers {
236 | result.Servers = append(result.Servers, t)
237 | }
238 |
239 | payload.result.servers <- result
240 | case sfuSetCurrentServers:
241 | // "intended" servers model, then regularly evaluate, so setup/teardown repeatedly is ok
242 | mentioned := make(map[string]bool)
243 | for _, t := range payload.SetCurrentServers.Servers {
244 | mentioned[t] = true
245 | }
246 |
247 | // Just overwrite it with what the client sent! (For now)
248 | s.intendedServers = mentioned
249 |
250 | s.evaluateServers()
251 |
252 | shouldSignalClients = true
253 |
254 | result := &CurrentServers{Servers: make([]string, 0)}
255 |
256 | for t := range s.servers {
257 | result.Servers = append(result.Servers, t)
258 | }
259 |
260 | payload.result.servers <- result
261 | }
262 |
263 | if shouldSignalClients {
264 | logger.Verbose("sfu", "SFU should signaling clients")
265 | s.handler.Cancel(sfuSignalClients)
266 |
267 | for _, c := range s.clients {
268 | logger.Verbose("sfu", "SFU signaling client "+c.Label())
269 | c.RequestEvalState()
270 | }
271 |
272 | logger.Verbose("sfu", "SFU finished signaling clients")
273 | }
274 |
275 | return true
276 | })
277 |
278 | s.handler.Loop(func() {
279 | panic("SFU unexpectedly terminated")
280 | })
281 |
282 | return s
283 | }
284 |
285 | func (s *Sfu) evaluateServers() {
286 | // Ensure any running servers that should be stopped are stopping or stopped
287 | for t := range s.servers {
288 | mention := s.intendedServers[t]
289 | if !mention {
290 | s.servers[t].stop()
291 |
292 | // TODO only actually delete the server when it's really stopped (need the get servers endpoint to be pub/sub based first or it'll be useless)
293 | delete(s.servers, t)
294 | }
295 | }
296 |
297 | // Create and start any servers that should exist but do not (i.e. if they are still stopping leave them until stopped and removed)
298 | for t, _ := range s.intendedServers {
299 | _, exists := s.servers[t]
300 | if !exists {
301 | server := s.remoteClientFactory.NewClient(&RemoteClientParameters{
302 | logger: s.logger,
303 | trunkurl: t,
304 | s: s,
305 | })
306 | s.servers[t] = server
307 | }
308 | }
309 | }
310 |
311 | func (s *Sfu) addTrack(intrack *incomingTrack) {
312 | s.handler.Send(sfuAddOutgoingTracksForIncomingTrack, &sfuCommandMessage{intrack: intrack})
313 | }
314 |
315 | func (s *Sfu) removeOutgoingTracksForIncomingTrack(t *incomingTrack) {
316 | s.handler.Send(sfuRemoveAllOutgoingTracksForIncomingTrack, &sfuCommandMessage{intrack: t})
317 | }
318 |
319 | func (s *Sfu) WebsocketHandler(w http.ResponseWriter, r *http.Request) {
320 | ws, err := upgrader.Upgrade(w, r, nil)
321 | if err != nil {
322 | s.logger.Error("sfu", "Failed to upgrade HTTP to Websocket: "+err.Error())
323 | return
324 | }
325 |
326 | s.remoteClientFactory.NewClient(&RemoteClientParameters{logger: s.logger, ws: ws, s: s})
327 | }
328 |
329 | func (s *Sfu) SetMdnsConn(mdnsConn *mdns.Conn) {
330 | s.mdnsConn = mdnsConn
331 | }
332 |
333 | // Use our own mdns for resolving .local addresses to work around compatibility problems
334 | // If we are in cloud mode this will return an error
335 | // If the host is not found this will return an error
336 | // If the host is found the string returned will contain the IP as a string . . .
337 | func (s *Sfu) mdnsLookup(hostname string) (string, error) {
338 | if s.mdnsConn == nil {
339 | return "", fmt.Errorf("attempting to resolve local subnet host while mdns not in use")
340 | }
341 |
342 | ctx, _ := context.WithTimeout(context.Background(), 1*time.Second)
343 | _, addr, err := s.mdnsConn.QueryAddr(ctx, hostname)
344 | if err != nil {
345 | return "", err
346 | } else {
347 | return addr.String(), nil
348 | }
349 | }
350 |
--------------------------------------------------------------------------------
/docs/sfu-diagram-2.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
158 |
--------------------------------------------------------------------------------
/docs/sfu-diagram-1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
219 |
--------------------------------------------------------------------------------
/frontend/src/umbrella/SfuApp.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { useRef, useState } from 'react';
4 | import { SetUpstreamTracks, RemoteNodeMessage, TrackDescriptor, TrackKind, CurrentServers, MidToUmbrellaIDMapping, SFUStatus, SFUStatusClient, SFUStatusPeerConnection } from '../generated/sfu'
5 |
6 | function trackKindFromString(k: string) : TrackKind {
7 | switch(k) {
8 | case "audio":
9 | return TrackKind.Audio;
10 | case "video":
11 | return TrackKind.Video;
12 | default:
13 | return TrackKind.Unknown;
14 | }
15 | }
16 |
17 | function trackKindToString(k: TrackKind) : string {
18 | switch(k) {
19 | case TrackKind.Audio:
20 | return "audio";
21 | case TrackKind.Video:
22 | return "video";
23 | default:
24 | return "unknown";
25 | }
26 | }
27 |
28 | abstract class track {
29 | private mediaStream: MediaStream;
30 | private mediaStreamTrack: MediaStreamTrack;
31 | umbrellaId: string;
32 |
33 | constructor(mediaStream: MediaStream, mediaStreamTrack: MediaStreamTrack, umbrellaId: string) {
34 | this.mediaStream = mediaStream;
35 | this.mediaStreamTrack = mediaStreamTrack;
36 | this.umbrellaId = umbrellaId;
37 | }
38 |
39 | kind() : TrackKind {
40 | return trackKindFromString(this.mediaStreamTrack.kind);
41 | }
42 |
43 | getDescriptor() : TrackDescriptor {
44 | return ({
45 | id: this.mediaStreamTrack.id,
46 | kind: trackKindFromString(this.mediaStreamTrack.kind),
47 | streamId: this.mediaStream.id,
48 | umbrellaId: this.umbrellaId,
49 | });
50 | }
51 |
52 | getTrack() : MediaStreamTrack {
53 | return this.mediaStreamTrack;
54 | }
55 |
56 | getStream() : MediaStream {
57 | return this.mediaStream;
58 | }
59 | }
60 |
61 | // This is the data associated with an incoming track before we can conclusively move to it being a remote track
62 | class stagedIncomingTrack {
63 | mediaStream: MediaStream;
64 | mediaStreamTrack: MediaStreamTrack;
65 | completed: boolean = false;
66 |
67 | constructor(mediaStream: MediaStream, mediaStreamTrack: MediaStreamTrack) {
68 | this.mediaStream = mediaStream;
69 | this.mediaStreamTrack = mediaStreamTrack;
70 | }
71 | }
72 |
73 | class remoteTrack extends track {
74 | }
75 |
76 | class localTrack extends track {
77 | published: boolean;
78 | private transceiver: RTCRtpTransceiver | null = null;
79 |
80 | constructor(mediaStream: MediaStream, mediaStreamTrack: MediaStreamTrack) {
81 | super(mediaStream, mediaStreamTrack, "UMB_TRACK_"+crypto.randomUUID());
82 | this.published = false;
83 | }
84 |
85 | setTransceiver(transceiver : RTCRtpTransceiver | null) {
86 | this.transceiver = transceiver;
87 | }
88 |
89 | getTransceiver() : RTCRtpTransceiver | null {
90 | return this.transceiver;
91 | }
92 | }
93 |
94 | interface LocalVideoProps {
95 | stream: MediaStream | null;
96 | }
97 |
98 | const LocalVideo: React.FC = ({ stream }) => {
99 | const videoRef = useRef(null);
100 |
101 | useEffect(() => {
102 | if (videoRef.current && stream) {
103 | videoRef.current.srcObject = stream;
104 |
105 | const vt = stream.getVideoTracks()[0];
106 | if(vt) {
107 | const facingMode = vt.getSettings().facingMode;
108 | if(facingMode == undefined || facingMode == "user") {
109 | videoRef.current.style.transform = "scaleX(-1)";
110 | } else {
111 | videoRef.current.style.transform = "scaleX(1)";
112 | }
113 | }
114 | }
115 | }, [stream]);
116 |
117 | return ;
118 | };
119 |
120 | interface RemoteVideosProps {
121 | tracks: remoteTrack[];
122 | }
123 |
124 | const RemoteVideos: React.FC = ({ tracks }) => {
125 | return (
126 | <>
127 | {tracks.map((track, index) => (
128 |
129 | ))}
130 | >
131 | );
132 | };
133 |
134 | const RemoteVideo: React.FC<{ track: remoteTrack }> = ({ track }) => {
135 | const videoRef = useRef(null);
136 |
137 | useEffect(() => {
138 | if (videoRef.current) {
139 | videoRef.current.srcObject = track.getStream();
140 | }
141 | }, [track]);
142 |
143 | return ;
144 | };
145 |
146 | interface SfuAppJoinedProps {
147 | requestLocalMediaFirst: boolean;
148 | }
149 |
150 | const SfuAppJoined: React.FC = ({requestLocalMediaFirst}) => {
151 | const [localStream, setLocalStream] = useState(null);
152 | const [remoteTracks, setRemoteTracks] = useState([]);
153 |
154 | const websocketRef = useRef(null);
155 | const offerNeededTimerRef = useRef(-1);
156 |
157 | useEffect(() => {
158 | const pageUrl = window.location.href;
159 | const wsUrl = "wss" + pageUrl.substring(pageUrl.indexOf(":"), pageUrl.lastIndexOf("/")) + "/wsb";
160 | console.log("Websocket url set to: " + wsUrl);
161 |
162 | function log(msg: string) {
163 | console.log("SFULOG: "+msg);
164 | }
165 |
166 | // Always have to ask permission for the streams for webrtc connections to actually work
167 | // We can safely ignore the streams afterwards
168 | //
169 | // The view only one we set because some devices without webcams still benefit from being
170 | // able to prompt the user (like firefox) due to implementing RFC 8828 with any
171 | // successful getUserMedia being interpreted as consent for ICE candidate discovery.
172 | // Without that you will get cryptic errors.
173 | //
174 | // You will also get Linux Firefox ICE errors if someone is sending h264 and you don't
175 | // have the OpenH264 plugin installed and enabled.
176 | // https://support.mozilla.org/en-US/kb/open-h264-plugin-firefox
177 | //
178 | // For now we cap at 720P
179 | navigator.mediaDevices.getUserMedia({ video: requestLocalMediaFirst ? { width:{max:1280}, height: {max: 720}} : false, audio: true })
180 | .then(streamInit => {
181 | const stream = requestLocalMediaFirst ? streamInit : null;
182 |
183 | const pcConfig: RTCConfiguration = {
184 | iceServers: [
185 | {urls: ["stun:stun.l.google.com:19302","stun:stun2.1.google.com:19302"]}
186 | ]
187 | };
188 |
189 | const incoming = new RTCPeerConnection(pcConfig);
190 | const outgoing = new RTCPeerConnection(pcConfig);
191 |
192 | // Gives the offers something to be about
193 | const dataOut = outgoing.createDataChannel("data-out", {ordered: false});
194 | const dataIn = incoming.createDataChannel("data-in", {ordered: false});
195 |
196 | const midToUmbrellaIDMapping = new Map();
197 | let stagedIncomingTracks : stagedIncomingTrack[] = [];
198 |
199 | // Should be called whenever the mapping changes or we receive a new track in incoming.ontrack
200 | // Then if we can fuse the data together the new remotetrack is created
201 | function evaluateIncomingTracks() {
202 | const newRemoteTracks : remoteTrack[] = [];
203 |
204 | stagedIncomingTracks.forEach(sit => {
205 | const transceiversForIncomingTrack = incoming.getTransceivers().filter((trx) => trx.receiver.track == sit.mediaStreamTrack);
206 | if(transceiversForIncomingTrack.length > 0) {
207 | console.log("FOUND THESE TRANSCEIVERS: "+JSON.stringify(transceiversForIncomingTrack)+" mid first "+transceiversForIncomingTrack[0].mid);
208 | const mid = transceiversForIncomingTrack[0].mid;
209 | if(mid != null) {
210 | const umbrellaId = midToUmbrellaIDMapping.get(mid);
211 | console.log("Umbrella ID with transceiver MID is "+umbrellaId);
212 | if(umbrellaId) {
213 | newRemoteTracks.push(new remoteTrack(sit.mediaStream, sit.mediaStreamTrack, umbrellaId));
214 | sit.completed = true;
215 |
216 | sit.mediaStreamTrack.onmute = function (event) {
217 | // Hmm
218 | };
219 |
220 | sit.mediaStream.onremovetrack = ({ track }) => {
221 | console.log("Incoming video track removed: "+umbrellaId);
222 | setRemoteTracks((prev) => prev.filter(t => t.umbrellaId !== umbrellaId));
223 | };
224 | }
225 | }
226 | }
227 | });
228 |
229 | stagedIncomingTracks = stagedIncomingTracks.filter(sit => !sit.completed);
230 |
231 | setRemoteTracks((prev) => [...prev, ...newRemoteTracks]);
232 | }
233 |
234 | incoming.ontrack = (event) => {
235 | if (event.track.kind === 'audio') {
236 | console.log("ONTRACK Don't need to worry about audio");
237 | return;
238 | }
239 |
240 | if(event.streams.length == 0 || event.streams[0] == null) {
241 | console.log("ONTRACK provided empty streams");
242 | return;
243 | }
244 |
245 | if (stream != null && stream.id == event.streams[0].id) {
246 | console.log("ONTRACK Rejecting due to loopback suspicions "+stream.id+" "+event.streams);
247 | // Loopback detected - this doesn't seem right
248 | return;
249 | }
250 |
251 | if(stream != null) {
252 | const localVideoTrack = stream.getVideoTracks()[0];
253 | console.log("Local video track id "+localVideoTrack.id+" local stream id "+stream.id+" incoming stream id "+event.streams[0].id+" incoming track id "+event.track.id);
254 | }
255 |
256 | const incomingStream = event.streams[0];
257 | const incomingTrack = incomingStream.getVideoTracks()[0];
258 |
259 | stagedIncomingTracks.push(new stagedIncomingTrack(incomingStream, incomingTrack));
260 |
261 | evaluateIncomingTracks();
262 | };
263 |
264 | incoming.onconnectionstatechange = (() => {
265 | switch(incoming.connectionState) {
266 | case "disconnected":
267 | case "failed":
268 | case "closed":
269 | console.log("Incoming connection terminated, state: "+incoming.connectionState);
270 | setRemoteTracks([]);
271 | }
272 | });
273 |
274 | let ws = new WebSocket(wsUrl);
275 | websocketRef.current = ws;
276 |
277 | ws.binaryType = "arraybuffer";
278 |
279 | if(stream != null) {
280 | setLocalStream(stream);
281 | }
282 |
283 | const localTracks = new Map();
284 | if(stream != null) {
285 | stream.getTracks().forEach(track => {
286 | localTracks.set(track.id, new localTrack(stream, track));
287 | });
288 | }
289 |
290 | ws.onopen = e => {
291 | ws.send(RemoteNodeMessage.toBinary({
292 | upstreamTracks: {
293 | tracks: Array.from(localTracks.values()).map(track => track.getDescriptor()),
294 | },
295 | }));
296 |
297 | offerNeeded();
298 | };
299 |
300 | incoming.onicecandidate = e => {
301 | if (!e.candidate) {
302 | return;
303 | }
304 |
305 | ws.send(RemoteNodeMessage.toBinary({
306 | candidate: {
307 | candidate: JSON.stringify(e.candidate),
308 | incoming: true,
309 | },
310 | }));
311 | };
312 |
313 | outgoing.onicecandidate = e => {
314 | if (!e.candidate) {
315 | return;
316 | }
317 |
318 | ws.send(RemoteNodeMessage.toBinary({
319 | candidate: {
320 | candidate: JSON.stringify(e.candidate),
321 | incoming: false,
322 | },
323 | }));
324 | };
325 |
326 | ws.onclose = function (evt) {
327 | log("ws.onclose");
328 |
329 | websocketRef.current = null;
330 |
331 | window.alert("Websocket has closed")
332 | }
333 |
334 | function offerNeeded() {
335 | if(offerNeededTimerRef.current >= 0) {
336 | clearTimeout(offerNeededTimerRef.current);
337 | }
338 |
339 | offerNeededTimerRef.current = setTimeout(async () => {
340 | const offer = await outgoing.createOffer();
341 | await outgoing.setLocalDescription(offer);
342 |
343 | if(ws != null) {
344 | ws.send(RemoteNodeMessage.toBinary({
345 | offer: {
346 | offer: JSON.stringify(offer),
347 | },
348 | }));
349 | }
350 | }, 100);
351 | }
352 |
353 | outgoing.onsignalingstatechange = () => {
354 | log("OUTGOING SIGNAL STATE CHANGE "+outgoing.signalingState);
355 |
356 | if(outgoing.signalingState === "stable") {
357 | // Review transceivers for local tracks and notify remote server of mappings
358 | const mappings : MidToUmbrellaIDMapping[] = [];
359 |
360 | const trx = outgoing.getTransceivers();
361 | localTracks.forEach((lt, id) => {
362 | const mst = lt.getTrack();
363 |
364 | trx.forEach((transceiver) => {
365 | if(transceiver.sender.track === mst && transceiver.mid != null) {
366 | console.log("FOUND TRANSCEIVER FOR TRACK! mid: "+transceiver.mid);
367 |
368 | mappings.push({
369 | mid: transceiver.mid,
370 | umbrellaId: lt.umbrellaId,
371 | })
372 | }
373 | });
374 | });
375 |
376 | ws.send(RemoteNodeMessage.toBinary({midMappings: {mapping: mappings}}));
377 | }
378 | };
379 |
380 | ws.onmessage = async function (event) {
381 | if (!event.data) {
382 | return;
383 | }
384 |
385 | let msg = RemoteNodeMessage.fromBinary(new Uint8Array(event.data), { readUnknownField: true });
386 |
387 | log("ws.onmessage: " + JSON.stringify(msg));
388 |
389 | if (msg.offer) {
390 | let offer = JSON.parse(msg.offer.offer);
391 |
392 | incoming.setRemoteDescription(offer);
393 | const answer = await incoming.createAnswer();
394 | await incoming.setLocalDescription(answer);
395 |
396 | ws.send(RemoteNodeMessage.toBinary({
397 | answer: {
398 | answer: JSON.stringify(answer),
399 | },
400 | }));
401 | }
402 |
403 | if(msg.answer) {
404 | // Outgoing stuff
405 | await outgoing.setRemoteDescription(JSON.parse(msg.answer.answer));
406 | }
407 |
408 | if (msg.candidate) {
409 | let candidate = JSON.parse(msg.candidate.candidate);
410 | // Incoming from pov of the sender!
411 | (msg.candidate.incoming ? outgoing : incoming).addIceCandidate(candidate);
412 | }
413 |
414 | if (msg.upstreamTracks) {
415 | log("Upstream tracks recevied "+JSON.stringify(msg.upstreamTracks));
416 |
417 | // Just echoing it for now, unlike pion we don't need to get ready
418 | ws.send(RemoteNodeMessage.toBinary({acceptTracks: {tracks: msg.upstreamTracks.tracks}}));
419 | }
420 |
421 | if (msg.midMappings) {
422 | log("MID <-> Umbrella mappings received "+JSON.stringify(msg.midMappings.mapping));
423 |
424 | msg.midMappings.mapping.forEach(m => {
425 | midToUmbrellaIDMapping.set(m.mid, m.umbrellaId);
426 | });
427 |
428 | evaluateIncomingTracks();
429 | }
430 |
431 | if (msg.acceptTracks) {
432 | let changed = false;
433 |
434 | msg.acceptTracks.tracks.forEach(descriptor => {
435 | const t = localTracks.get(descriptor.id);
436 |
437 | if(t) {
438 | log("Confirmed track "+JSON.stringify(t.getDescriptor()));
439 | if(!t.published) {
440 | log("Publishing track "+t.getTrack().id);
441 | outgoing.addTrack(t.getTrack(), t.getStream());
442 |
443 | t.published = true;
444 | changed = true;
445 | }
446 | } else {
447 | log("Remote track incoming: "+JSON.stringify(descriptor));
448 | }
449 | });
450 |
451 | if(changed) {
452 | offerNeeded();
453 | }
454 | }
455 | }
456 |
457 | ws.onerror = function (evt) {
458 | log("ws.onerror");
459 | }
460 | }).catch(console.log)
461 |
462 | return () => {
463 | if(offerNeededTimerRef.current >= 0) {
464 | clearTimeout(offerNeededTimerRef.current);
465 | offerNeededTimerRef.current = 0;
466 | }
467 | };
468 | }, []);
469 |
470 | const sendWsMessage = (data: Uint8Array) => {
471 | if(websocketRef.current !== null) {
472 | websocketRef.current.send(data);
473 | }
474 | };
475 |
476 | return (
477 |
478 |
479 | { requestLocalMediaFirst && }
480 |
481 |
482 |
483 | );
484 | };
485 |
486 | export const SfuApp = () => {
487 | const [joined, setJoined] = useState(false);
488 |
489 | const requestLocalMediaFirstRef = useRef(false);
490 |
491 | const joinWithLocalMedia = () => {
492 | requestLocalMediaFirstRef.current = true;
493 | setJoined(true);
494 | };
495 |
496 | const joinAsViewer = () => {
497 | requestLocalMediaFirstRef.current = false;
498 | setJoined(true);
499 | };
500 |
501 | return (
502 | <>
503 | { joined ? (
504 |
505 | ) : (
506 |
507 |
508 |
509 |
510 | ) }
511 | >
512 | )
513 | };
514 |
515 | export const ServersApp = () => {
516 | const [servers, setServers] = useState([]);
517 | const addServerInputRef = useRef(null);
518 |
519 | useEffect(() => {
520 | fetch(window.location.pathname, {method: 'GET', headers:{'Content-Type': "application/x-protobuf"}})
521 | .then((response) => response.arrayBuffer())
522 | .then((buffer) => {
523 | setServers(CurrentServers.fromBinary(new Uint8Array(buffer)).servers);
524 | addServerInputRef.current?.focus();
525 | }).catch(console.log);
526 |
527 | return () => {};
528 | }, []);
529 |
530 | const addServerClick = () => {
531 | const updateVal = addServerInputRef.current!.value!;
532 | const serversUpdate = updateVal === "" ? [] : [updateVal];
533 |
534 | fetch(window.location.pathname,
535 | {
536 | method: 'POST',
537 | headers:{'Content-Type': "application/x-protobuf"},
538 | body: CurrentServers.toBinary({servers:serversUpdate})
539 | }
540 | ).then((response) => response.arrayBuffer())
541 | .then((buffer) => {
542 | setServers(CurrentServers.fromBinary(new Uint8Array(buffer)).servers);
543 | addServerInputRef.current?.focus();
544 | }).catch(console.log);
545 |
546 | addServerInputRef.current!.value = "";
547 | };
548 |
549 | const removeServerClick = (server: string) => {
550 | fetch(window.location.pathname,
551 | {
552 | method: 'POST',
553 | headers:{'Content-Type': "application/x-protobuf"},
554 | body: CurrentServers.toBinary({servers: servers.filter((s) => s !== server)})
555 | }
556 | ).then((response) => response.arrayBuffer())
557 | .then((buffer) => {
558 | setServers(CurrentServers.fromBinary(new Uint8Array(buffer)).servers);
559 | }).catch(console.log);
560 | };
561 |
562 | return (
563 | <>
564 |
565 |
Servers
566 |
567 |
568 | { servers.length === 0 ? ( - No servers
) : (<>
569 | { servers.map((server, index) => (
570 | - { server }
571 | )) }
572 | >) }
573 |
574 |
575 |
576 |
577 | >
578 | )
579 | };
580 |
581 | const TrackDescriptorStatusListElement: React.FC<{ descriptor: TrackDescriptor }> = ({descriptor}) => {
582 | return (
583 | { descriptor.umbrellaId }
584 | - Kind { trackKindToString(descriptor.kind) }
585 | - Track ID { descriptor.id }
586 | - Stream ID { descriptor.streamId }
587 |
588 | );
589 | };
590 |
591 | const PeerConnectionStatusListElement: React.FC<{ label: string, pc: SFUStatusPeerConnection | undefined }> = ({label, pc}) => {
592 | return (
593 | { label }
596 | );
597 | };
598 |
599 | const ClientStatusListElement: React.FC<{ client: SFUStatusClient }> = ({client}) => {
600 | return (
601 | { client.label }
602 | - Trunk url: { client.trunkUrl }
603 |
604 |
605 | - Incoming tracks
606 | { client.incomingTracks.map(t => )}
607 |
608 | - Outgoing tracks
609 | { client.outgoingTracks.map(t => )}
610 |
611 | - Senders
612 | { client.senders.map(s => - {s.umbrellaId} { s.hasTrack ? ("Has a track of ID " + s.trackIdIfSet) : "Has no track ID"}
)}
613 |
614 | - MID to Umbrella ID mappings
615 | { client.midMapping.map(m => - {m.mid} : {m.umbrellaId}
)}
616 |
617 | - Staged incoming tracks
618 | { client.stagedIncomingTracks.map(sit => - {sit.streamId} {sit.trackId} {sit.mid}
)}
619 |
620 |
621 | );
622 | };
623 |
624 | export const StatusApp = () => {
625 | const [status, setStatus] = useState(null);
626 |
627 | useEffect(() => {
628 | fetch(window.location.pathname, {method: 'GET', headers:{'Content-Type': "application/x-protobuf"}})
629 | .then((response) => response.arrayBuffer())
630 | .then((buffer) => {
631 | setStatus(SFUStatus.fromBinary(new Uint8Array(buffer)));
632 | }).catch(console.log);
633 |
634 | return () => {};
635 | }, []);
636 |
637 | return (
638 | <>
639 |
640 |
Status
641 | { (status == null) ? (
642 |
Status is null
643 | ) : (
644 | <>
645 |
Relaying tracks
646 |
647 | {status.relayingTracks.map(td => (
648 |
649 | ))}
650 |
651 |
Clients
652 | {status.clients.map(c => (
653 |
654 | ))}
655 |
servers
656 |
657 | {status.servers.map(t => (
658 | - { t }
659 | ))}
660 |
661 | >
662 | )}
663 |
664 | >
665 | )
666 | };
--------------------------------------------------------------------------------
/sfu/client_webrtc.go:
--------------------------------------------------------------------------------
1 | package sfu
2 |
3 | import (
4 | "context"
5 | "crypto/tls"
6 | "encoding/json"
7 | "fmt"
8 | "net"
9 | "strings"
10 | "time"
11 |
12 | "atomirex.com/umbrella/razor"
13 | "github.com/google/uuid"
14 | "github.com/gorilla/websocket"
15 | "github.com/pion/rtcp"
16 | "github.com/pion/rtp"
17 | "github.com/pion/webrtc/v4"
18 | "google.golang.org/protobuf/proto"
19 | )
20 |
21 | type clientCommand int
22 |
23 | const (
24 | clientSendProto clientCommand = iota
25 | clientHandleWSMessage
26 | clientStop
27 | clientAddOutgoingTrackForIncomingTrack
28 | clientRemoveOutgoingTracksForIncomingTrack
29 | clientKeyframeTick
30 | clientKeyframeGateUnlock
31 | clientEvalState
32 | clientDialWs
33 | clientIncomingTrackAdded
34 | clientGetStatus
35 | )
36 |
37 | type rawIncomingTrack struct {
38 | track *webrtc.TrackRemote
39 | receiver *webrtc.RTPReceiver
40 | }
41 |
42 | type clientCommandMessage struct {
43 | message *RemoteNodeMessage
44 | incomingTrack *incomingTrack
45 | newincomingTrack *rawIncomingTrack
46 | result *clientCommandResult
47 | }
48 |
49 | type clientCommandResult struct {
50 | status chan *SFUStatusClient
51 | }
52 |
53 | type client struct {
54 | BaseClient
55 |
56 | trunkurl string // Empty means this is a normal web client - should replace with proper roles or similar
57 | incoming *PeerConnection
58 | outgoing *PeerConnection
59 | websocket *websocket.Conn
60 |
61 | // The umbrellaId -> incomingTrack
62 | incomingTracks map[string]*incomingTrackWithClientState
63 |
64 | // The umbrellaId -> outgoingTrack
65 | outgoingTracks map[string]*outgoingTrackWithClientState
66 |
67 | // The umbrellaId -> RTPSender
68 | senders map[string]*webrtc.RTPSender
69 |
70 | handler *razor.MessageHandler[clientCommand, clientCommandMessage]
71 |
72 | // mid <-> umbrella track ID mappings - only set when known to be valid!
73 | incomingMidToUmbrellaTrackID map[string]string
74 |
75 | // Incoming tracks that have yet to be attached to MIDs
76 | stagedIncomingTracks []*rawIncomingTrack
77 | }
78 |
79 | func (c *client) getStatus() *SFUStatusClient {
80 | msg := clientCommandMessage{result: &clientCommandResult{
81 | status: make(chan *SFUStatusClient, 1),
82 | }}
83 |
84 | c.handler.Send(clientGetStatus, &msg)
85 |
86 | return <-msg.result.status
87 | }
88 |
89 | func (c *client) run(ws *websocket.Conn, s *Sfu) {
90 | c.incomingTracks = make(map[string]*incomingTrackWithClientState)
91 | c.outgoingTracks = make(map[string]*outgoingTrackWithClientState)
92 |
93 | c.incomingMidToUmbrellaTrackID = make(map[string]string)
94 |
95 | c.stagedIncomingTracks = make([]*rawIncomingTrack, 0)
96 |
97 | c.senders = make(map[string]*webrtc.RTPSender)
98 |
99 | incoming, err := s.peerConnectionFactory.NewPeerConnection(fmt.Sprintf("incoming for %s", c.label))
100 | if c.logger.NilErrCheck(c.label, "Failed to create an incoming peer connection", err) {
101 | return
102 | }
103 |
104 | outgoing, err := s.peerConnectionFactory.NewPeerConnection(fmt.Sprintf("outgoing for %s", c.label))
105 | if c.logger.NilErrCheck(c.label, "Failed to create an outgoing peer connection", err) {
106 | return
107 | }
108 |
109 | c.incoming = incoming
110 | c.outgoing = outgoing
111 |
112 | falseptr := false
113 |
114 | _, err = incoming.CreateDataChannel("data-out", &webrtc.DataChannelInit{Ordered: &falseptr})
115 | c.logger.NilErrCheck(c.label, "Failed to create an incoming data channel", err)
116 |
117 | _, err = outgoing.CreateDataChannel("data-in", &webrtc.DataChannelInit{Ordered: &falseptr})
118 | c.logger.NilErrCheck(c.label, "Failed to create an outgoing data channel", err)
119 |
120 | _, err = c.outgoing.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{
121 | Direction: webrtc.RTPTransceiverDirectionSendonly,
122 | })
123 | c.logger.NilErrCheck(c.label, "Error adding fake transceiver to outgoing pc", err)
124 |
125 | // keyframe throttling
126 | sendKeyFrameGate := false
127 |
128 | c.handler = razor.NewMessageHandler(c.logger, c.label, 1024, func(what clientCommand, payload *clientCommandMessage) bool {
129 | shouldSendKeyframe := false
130 | shouldEvalState := false
131 |
132 | stop := func() {
133 | c.logger.Info(c.label, "Stopping")
134 |
135 | c.handler.Abort()
136 |
137 | c.logger.Info(c.label, "Stopped")
138 | }
139 |
140 | switch what {
141 | case clientKeyframeTick:
142 | shouldSendKeyframe = true
143 | case clientKeyframeGateUnlock:
144 | sendKeyFrameGate = false
145 | case clientSendProto:
146 | if c.websocket == nil {
147 | c.logger.Error(c.label, "Attempting send when not connected to websocket")
148 | return true
149 | }
150 |
151 | data, err := proto.Marshal(payload.message)
152 |
153 | if err != nil {
154 | c.logger.Error(c.label, "Failed to marshal proto for client")
155 | stop()
156 | return true
157 | }
158 |
159 | c.logger.Verbose(c.label, "ws proto sending "+payload.message.String())
160 |
161 | err = c.websocket.WriteMessage(websocket.BinaryMessage, data)
162 |
163 | if c.logger.NilErrCheck(c.label, "Failed to write proto to ws", err) {
164 | stop()
165 | return true
166 | }
167 | case clientHandleWSMessage:
168 | c.handleWsMessage(payload.message, s)
169 | case clientStop:
170 | stop()
171 | return true
172 | case clientAddOutgoingTrackForIncomingTrack:
173 | // Add it to our outgoing if it's not on incoming
174 | if _, incomingExists := c.incomingTracks[payload.incomingTrack.UmbrellaID()]; !incomingExists {
175 | c.outgoingTracks[payload.incomingTrack.UmbrellaID()] = &outgoingTrackWithClientState{
176 | track: &outgoingTrack{descriptor: payload.incomingTrack.descriptor},
177 | source: payload.incomingTrack,
178 | }
179 |
180 | c.handler.Cancel(clientEvalState)
181 | c.handler.Timeout(clientEvalState, nil, 500*time.Millisecond)
182 | }
183 | case clientRemoveOutgoingTracksForIncomingTrack:
184 | if _, exists := c.outgoingTracks[payload.incomingTrack.UmbrellaID()]; exists {
185 | delete(c.outgoingTracks, payload.incomingTrack.UmbrellaID())
186 |
187 | c.handler.Cancel(clientEvalState)
188 | c.handler.Timeout(clientEvalState, nil, 500*time.Millisecond)
189 | }
190 | case clientEvalState:
191 | if c.pcTerminated() {
192 | stop()
193 | return true
194 | }
195 |
196 | shouldEvalState = true
197 | case clientGetStatus:
198 | // Like the SFU this is horribly blocking
199 | // Also could cause problems if the client goes AWOL while the SFU is wanting to get status (i.e. would need a timeout)
200 |
201 | intd := make([]*TrackDescriptor, 0)
202 | for _, t := range c.incomingTracks {
203 | intd = append(intd, t.track.descriptor)
204 | }
205 | outtd := make([]*TrackDescriptor, 0)
206 | for _, t := range c.outgoingTracks {
207 | outtd = append(outtd, t.track.descriptor)
208 | }
209 | senderStatus := make([]*SFUStatusSender, 0)
210 | for umbrellaId, s := range c.senders {
211 | t := s.Track()
212 | id := ""
213 | if t != nil {
214 | id = t.ID()
215 | }
216 | senderStatus = append(senderStatus, &SFUStatusSender{HasTrack: t != nil, TrackIdIfSet: id, UmbrellaId: umbrellaId})
217 | }
218 | midMapping := make([]*MidToUmbrellaIDMapping, 0)
219 | for mid, umbrellaId := range c.incomingMidToUmbrellaTrackID {
220 | midMapping = append(midMapping, &MidToUmbrellaIDMapping{Mid: mid, UmbrellaId: umbrellaId})
221 | }
222 | stagedIncoming := make([]*SFUStatusStagedIncomingTrack, 0)
223 | for _, s := range c.stagedIncomingTracks {
224 | stagedIncoming = append(stagedIncoming, &SFUStatusStagedIncomingTrack{
225 | StreamId: s.track.StreamID(),
226 | TrackId: s.track.ID(),
227 | Mid: s.receiver.RTPTransceiver().Mid(),
228 | })
229 | }
230 |
231 | status := &SFUStatusClient{
232 | Label: c.label,
233 | TrunkUrl: c.trunkurl,
234 | IncomingPC: c.incoming.GetStatus(),
235 | OutgoingPC: c.outgoing.GetStatus(),
236 | IncomingTracks: intd,
237 | OutgoingTracks: outtd,
238 | Senders: senderStatus,
239 | MidMapping: midMapping,
240 | StagedIncomingTracks: stagedIncoming,
241 | }
242 |
243 | payload.result.status <- status
244 | case clientDialWs:
245 | if c.websocket != nil {
246 | return true
247 | }
248 |
249 | dialer := websocket.Dialer{
250 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // TODO remove this bad ignoring of certs, but useful for local testing
251 |
252 | NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
253 | host, port, err := net.SplitHostPort(addr)
254 | if err != nil {
255 | port = "443" // Default wss port
256 | } else {
257 | // Use our own mdns to discover it, if possible
258 | // This is to enable an OpenWrt AP to discover Apple servers on the same subnet
259 | if strings.HasSuffix(host, ".local") {
260 | found, err := s.mdnsLookup(host)
261 | if err == nil {
262 | host = found
263 | }
264 | }
265 | }
266 |
267 | dialer := &net.Dialer{}
268 | return dialer.DialContext(ctx, network, net.JoinHostPort(host, port))
269 | },
270 | }
271 |
272 | conn, _, err := dialer.Dial(c.trunkurl, nil)
273 | if c.logger.NilErrCheck(c.label, "Error dialling ws "+c.trunkurl, err) {
274 | c.handler.Timeout(clientDialWs, nil, time.Second*10)
275 | return true
276 | }
277 |
278 | c.websocket = conn
279 | go func() {
280 | c.continueWebsocket(s)
281 | }()
282 | case clientIncomingTrackAdded:
283 | t := payload.newincomingTrack.track
284 | tsc := payload.newincomingTrack.receiver.RTPTransceiver()
285 |
286 | c.logger.Info(c.label, "Incoming track to add stream id "+t.StreamID()+" "+t.ID()+" MID "+tsc.Mid())
287 |
288 | c.stagedIncomingTracks = append(c.stagedIncomingTracks, payload.newincomingTrack)
289 |
290 | c.evalIncomingState(s)
291 | }
292 |
293 | if shouldEvalState {
294 | c.handler.Cancel(clientEvalState)
295 |
296 | shouldSendKeyframe = true
297 | c.evalState(s)
298 | }
299 |
300 | if shouldSendKeyframe {
301 | // Throttled
302 | if !sendKeyFrameGate {
303 | c.handler.Cancel(clientKeyframeTick)
304 |
305 | sendKeyFrameGate = true
306 | c.handler.Timeout(clientKeyframeGateUnlock, nil, time.Millisecond*500)
307 |
308 | for _, receiver := range c.incoming.GetReceivers() {
309 | if receiver.Track() != nil {
310 | _ = c.incoming.WriteRTCP([]rtcp.Packet{
311 | &rtcp.PictureLossIndication{
312 | MediaSSRC: uint32(receiver.Track().SSRC()),
313 | },
314 | })
315 | }
316 | }
317 |
318 | c.handler.Timeout(clientKeyframeTick, nil, time.Second*3)
319 | }
320 | }
321 |
322 | return true
323 | })
324 |
325 | if ws == nil {
326 | c.handler.Send(clientDialWs, nil)
327 | }
328 |
329 | c.handler.Loop(func() {
330 | for _, it := range c.incomingTracks {
331 | s.removeOutgoingTracksForIncomingTrack(it.track)
332 | }
333 |
334 | if ws != nil {
335 | ws.Close()
336 | ws = nil
337 | }
338 |
339 | if outgoing != nil {
340 | outgoing.wrapped.GracefulClose()
341 | }
342 | outgoing = nil
343 |
344 | if incoming != nil {
345 | incoming.wrapped.GracefulClose()
346 | }
347 | incoming = nil
348 |
349 | c.logger.Info(c.label, "Post clean up finished")
350 | })
351 | }
352 |
353 | func (c *client) stop() {
354 | c.handler.CancelAll()
355 | c.handler.Send(clientStop, nil)
356 | }
357 |
358 | func (c *client) pcTerminated() bool {
359 | return c.incoming.IsTerminated() || c.outgoing.IsTerminated()
360 | }
361 |
362 | func (c *client) handleWsMessage(message *RemoteNodeMessage, s *Sfu) {
363 | if message.Candidate != nil {
364 | c.logger.Info(c.label, "WS PROTO RECEIVED ice candidate")
365 | candidate := webrtc.ICECandidateInit{}
366 | if err := json.Unmarshal([]byte(message.Candidate.Candidate), &candidate); err != nil {
367 | c.logger.Error(c.label, "Failed to unmarshal json to candidate: "+err.Error())
368 | return
369 | }
370 |
371 | c.logger.Info(c.label, "Got candidate: "+candidate.Candidate)
372 |
373 | pc := c.incoming
374 | // Incoming from pov of the sender!
375 | if message.Candidate.Incoming {
376 | pc = c.outgoing
377 | }
378 |
379 | if err := pc.AddICECandidate(candidate); err != nil {
380 | c.logger.Error(c.label, "Failed to add ICE candidate: "+err.Error())
381 | return
382 | }
383 | }
384 |
385 | if message.Answer != nil {
386 | c.logger.Info(c.label, "WS PROTO RECEIVED answer")
387 | answer := webrtc.SessionDescription{}
388 | if err := json.Unmarshal([]byte(message.Answer.Answer), &answer); err != nil {
389 | c.logger.Error(c.label, "Failed to umarshal JSON to answer: "+err.Error())
390 | return
391 | }
392 |
393 | c.logger.Info(c.label, "Got answer: "+answer.SDP)
394 |
395 | if err := c.outgoing.SetRemoteDescription(answer); err != nil {
396 | c.logger.Error(c.label, "Failed to set remote description on outgoing from answer: "+err.Error())
397 | return
398 | }
399 | }
400 |
401 | if message.Offer != nil {
402 | c.logger.Info(c.label, "WS PROTO RECEIVED offer")
403 | offer := webrtc.SessionDescription{}
404 | if err := json.Unmarshal([]byte(message.Offer.Offer), &offer); err != nil {
405 | c.logger.Error(c.label, "Failed to umarshal JSON to offer: "+err.Error())
406 | return
407 | }
408 |
409 | c.logger.Info(c.label, "Got offer: "+offer.SDP)
410 |
411 | if err := c.incoming.SetRemoteDescription(offer); err != nil {
412 | c.logger.Error(c.label, "Failed to set remote description on incoming from offer: "+err.Error())
413 | return
414 | }
415 |
416 | answer, err := c.incoming.CreateAnswer(&webrtc.AnswerOptions{})
417 | if err != nil {
418 | c.logger.Error(c.label, "Failed to create answer in response to offer: "+err.Error())
419 | return
420 | }
421 |
422 | c.incoming.SetLocalDescription(answer)
423 |
424 | answerString, err := json.Marshal(answer)
425 | if err != nil {
426 | c.logger.Error(c.label, "Failed to marshal answer to json: "+err.Error())
427 | return
428 | }
429 |
430 | c.writeProto(&RemoteNodeMessage{Answer: &AnswerMessage{Answer: string(answerString)}})
431 | }
432 |
433 | if message.AcceptTracks != nil {
434 | c.logger.Info(c.label, "WS PROTO RECEIVED accept tracks "+message.AcceptTracks.String())
435 |
436 | // Mark the accepted tracks and schedule an evaluation
437 | for _, td := range message.AcceptTracks.Tracks {
438 | ot := c.outgoingTracks[td.UmbrellaId]
439 | if ot != nil {
440 | ot.remoteNotified = true
441 | ot.remoteAccepted = true
442 | }
443 | }
444 |
445 | c.handler.Send(clientEvalState, nil)
446 | }
447 |
448 | if message.UpstreamTracks != nil {
449 | c.logger.Info(c.label, "WS PROTO RECEIVED upstream tracks "+message.UpstreamTracks.String())
450 |
451 | // Any previously unknown upstream tracks need to have transceivers created for them
452 | // Then we acknowledge with the complete set of expected upstream tracks
453 | for _, td := range message.UpstreamTracks.Tracks {
454 | _, exists := c.incomingTracks[td.UmbrellaId]
455 | if !exists {
456 | if td.Kind != TrackKind_Unknown {
457 | c.logger.Info(c.label, "Adding transceiver "+td.Id+" "+td.Kind.String())
458 |
459 | _, err := c.incoming.AddTransceiverFromKind(trackKindToWebrtcKind(td.Kind), webrtc.RTPTransceiverInit{
460 | Direction: webrtc.RTPTransceiverDirectionRecvonly,
461 | })
462 |
463 | if err != nil {
464 | c.logger.Error(c.label, "Failed to add transceiver "+err.Error())
465 | } else {
466 | intrack := &incomingTrackWithClientState{
467 | track: &incomingTrack{descriptor: td},
468 | }
469 |
470 | c.incomingTracks[intrack.UmbrellaID()] = intrack
471 | }
472 | }
473 | }
474 | }
475 |
476 | acceptedTracks := make([]*TrackDescriptor, 0)
477 | for _, intrack := range c.incomingTracks {
478 | acceptedTracks = append(acceptedTracks, intrack.track.descriptor)
479 | }
480 |
481 | c.writeProto(&RemoteNodeMessage{AcceptTracks: &AcceptUpstreamTracks{Tracks: acceptedTracks}})
482 |
483 | s.handler.Send(sfuSignalClients, nil)
484 | }
485 |
486 | if message.MidMappings != nil {
487 | c.logger.Info(c.label, "WS PROTO RECEIVED mid <-> umbrella mapping "+message.MidMappings.String())
488 | // Review all incoming tracks to assign MIDs, and if newly so then fan out appropriately
489 |
490 | // Update all the mappings in the client
491 | for _, mapping := range message.MidMappings.Mapping {
492 | c.incomingMidToUmbrellaTrackID[mapping.Mid] = mapping.UmbrellaId
493 | }
494 |
495 | c.evalIncomingState(s)
496 | }
497 | }
498 |
499 | func (c *client) evalIncomingState(s *Sfu) {
500 | c.logger.Info(c.label, "Eval incoming state")
501 |
502 | // Review staged incoming tracks to see if any can be updated as a result
503 | for i := 0; i < len(c.stagedIncomingTracks); i++ {
504 | sit := c.stagedIncomingTracks[i]
505 |
506 | mid := sit.receiver.RTPTransceiver().Mid()
507 | if mid == "" {
508 | continue
509 | }
510 |
511 | umbrellaId, midKnown := c.incomingMidToUmbrellaTrackID[mid]
512 | if midKnown {
513 | intrack, trackExists := c.incomingTracks[umbrellaId]
514 | if trackExists {
515 | if intrack.track.remote == nil {
516 | c.logger.Info(c.label, "Ready to fan out track: "+umbrellaId)
517 |
518 | // Assign it, remove from staged, and launch fan out
519 | intrack.track.remote = sit.track
520 |
521 | intrack.track.descriptor.Id = sit.track.ID()
522 | intrack.track.descriptor.StreamId = sit.track.StreamID()
523 | intrack.track.receiver = sit.receiver
524 | intrack.transceiverMid = mid
525 |
526 | // Remove from staged
527 | c.stagedIncomingTracks = append(c.stagedIncomingTracks[:i], c.stagedIncomingTracks[i+1:]...)
528 | i--
529 |
530 | relay, err := webrtc.NewTrackLocalStaticRTP(intrack.track.remote.Codec().RTPCodecCapability, "UMB_RELAY"+uuid.New().String(), intrack.track.remote.StreamID())
531 | if err != nil {
532 | panic(err)
533 | }
534 |
535 | intrack.track.relay = relay
536 |
537 | go c.fanoutIncoming(intrack.track, s)
538 |
539 | s.handler.Send(sfuAddOutgoingTracksForIncomingTrack, &sfuCommandMessage{intrack: intrack.track})
540 | c.logger.Info(c.label, "New incoming track sent to SFU: "+umbrellaId)
541 | }
542 | }
543 | }
544 | }
545 | }
546 |
547 | func (c *client) fanoutIncoming(intrack *incomingTrack, s *Sfu) {
548 | defer s.removeOutgoingTracksForIncomingTrack(intrack)
549 |
550 | bufSize := 32768
551 |
552 | if intrack.remote.Kind() == webrtc.RTPCodecTypeVideo {
553 | bufSize = bufSize * 8
554 | }
555 |
556 | buf := make([]byte, bufSize)
557 |
558 | rtpPkt := &rtp.Packet{}
559 | for {
560 | i, _, err := intrack.remote.Read(buf)
561 | if c.logger.NilErrCheck(c.label, "Fan out error reading rtp on track "+intrack.String(), err) {
562 | return
563 | }
564 |
565 | if err = rtpPkt.Unmarshal(buf[:i]); err != nil {
566 | c.logger.Error(c.label, "Error unmarshaling rtp on track "+intrack.String()+" "+err.Error())
567 | return
568 | }
569 |
570 | rtpPkt.Extension = false
571 | rtpPkt.Extensions = nil
572 |
573 | if err := intrack.relay.WriteRTP(rtpPkt); err != nil {
574 | c.logger.Error(c.label, "Error writing rtp from "+intrack.String()+" to relay "+err.Error())
575 | return
576 | }
577 | }
578 | }
579 |
580 | func (c *client) evalState(s *Sfu) {
581 | // Do we have any non attached tracks we should be uploading which have not yet been sent for confirmation?
582 | needsNotification := false
583 | for _, ot := range c.outgoingTracks {
584 | needsNotification = needsNotification || !ot.remoteNotified
585 | }
586 |
587 | // If so, send them up, return and wait before evalling again
588 | if needsNotification {
589 | notifications := make([]*TrackDescriptor, 0)
590 | for _, ot := range c.outgoingTracks {
591 | notifications = append(notifications, ot.track.descriptor)
592 | ot.remoteNotified = true
593 | }
594 |
595 | c.writeProto(&RemoteNodeMessage{UpstreamTracks: &SetUpstreamTracks{Tracks: notifications}})
596 |
597 | c.handler.Cancel(clientEvalState)
598 | c.handler.Timeout(clientEvalState, nil, 300*time.Millisecond) // Should probably do this a better way, like in the event listener
599 | return
600 | }
601 |
602 | // If we have any we're waiting on confirmation of we should return and wait before evalling again
603 | needsConfirmation := false
604 | for _, ot := range c.outgoingTracks {
605 | needsConfirmation = needsConfirmation || !ot.remoteAccepted
606 | }
607 |
608 | if needsConfirmation {
609 | // Wait for the accept tracks event
610 |
611 | c.handler.Cancel(clientEvalState)
612 | c.handler.Timeout(clientEvalState, nil, 300*time.Millisecond) // Should probably do this a better way, like in the event listener
613 | return
614 | }
615 |
616 | ss := c.outgoing.wrapped.SignalingState()
617 | if ss != webrtc.SignalingStateStable {
618 | c.handler.Cancel(clientEvalState)
619 | c.handler.Timeout(clientEvalState, nil, 300*time.Millisecond) // Should probably do this a better way, like in the event listener
620 | return
621 | }
622 |
623 | senderRemovalFailed := false
624 | // Find any senders that don't have a track, and remove them
625 | for umbrellaId, sender := range c.senders {
626 | _, exists := c.outgoingTracks[umbrellaId]
627 | if !exists {
628 | c.logger.Debug(c.label, "eval state removing sender for track with umb id "+umbrellaId)
629 |
630 | if err := c.outgoing.RemoveTrack(sender); err != nil {
631 | c.logger.Error(c.label, "Error removing sender for track with umb id "+umbrellaId+" "+err.Error())
632 | senderRemovalFailed = true
633 | } else {
634 | delete(c.senders, umbrellaId)
635 | }
636 | }
637 | }
638 |
639 | if senderRemovalFailed {
640 | // Retry again later
641 | c.handler.Cancel(clientEvalState)
642 | c.handler.Timeout(clientEvalState, nil, 300*time.Millisecond)
643 | return
644 | }
645 |
646 | addingTrackFailed := false
647 | // Find any tracks which don't have a sender, and add them
648 | for umbrellaId, ot := range c.outgoingTracks {
649 | sender, exists := c.senders[umbrellaId]
650 | if !exists || sender.Track() == nil {
651 | c.logger.Debug(c.label, "eval state creating sender for track with umb id "+umbrellaId)
652 | if sender, err := c.outgoing.AddTrack(ot.source.relay); err != nil {
653 | c.logger.Error(c.label, "Error creating sender for track with umb id "+umbrellaId+" "+err.Error())
654 | addingTrackFailed = true
655 | } else {
656 | c.senders[umbrellaId] = sender
657 | }
658 | }
659 | }
660 |
661 | if addingTrackFailed {
662 | // Retry again later
663 | c.handler.Cancel(clientEvalState)
664 | c.handler.Timeout(clientEvalState, nil, 300*time.Millisecond)
665 | return
666 | }
667 |
668 | c.logger.Info(c.label, "eval state creating offer")
669 | offer, err := c.outgoing.CreateOffer(nil)
670 | if c.logger.NilErrCheck(c.label, "eval state creating offer error", err) {
671 | // Retry again later
672 | c.handler.Cancel(clientEvalState)
673 | c.handler.Timeout(clientEvalState, nil, 300*time.Millisecond)
674 | c.logger.Info(c.label, "eval state creating offer erred so retry scheduled")
675 | return
676 | }
677 |
678 | c.logger.Info(c.label, "eval state setting local description")
679 | if err = c.outgoing.SetLocalDescription(offer); err != nil {
680 | c.logger.Error(c.label, "eval state setting local description error"+err.Error())
681 | // Retry again later
682 | c.handler.Cancel(clientEvalState)
683 | c.handler.Timeout(clientEvalState, nil, 300*time.Millisecond)
684 | c.logger.Info(c.label, "eval state setting local description erred so retry scheduled")
685 | return
686 | }
687 |
688 | offerString, err := json.Marshal(offer)
689 | if c.logger.NilErrCheck(c.label, "Eval state offer marshalling failed", err) {
690 | return
691 | }
692 |
693 | c.logger.Info(c.label, "Send offer to client: "+offer.SDP)
694 |
695 | c.writeProto(&RemoteNodeMessage{
696 | Offer: &OfferMessage{
697 | Offer: string(offerString),
698 | },
699 | })
700 | }
701 |
702 | func (c *client) writeProto(m *RemoteNodeMessage) {
703 | c.handler.Send(clientSendProto, &clientCommandMessage{message: m})
704 | }
705 |
706 | func (c *client) continueWebsocket(s *Sfu) {
707 | // This takes care of the various cleanup
708 | defer func() {
709 | c.stop()
710 | }()
711 |
712 | ws := c.websocket
713 | incoming := c.incoming
714 | outgoing := c.outgoing
715 |
716 | s.handler.Send(sfuAddClient, &sfuCommandMessage{client: c})
717 |
718 | defer s.handler.Send(sfuRemoveClient, &sfuCommandMessage{client: c})
719 |
720 | icecandidate := func(i *webrtc.ICECandidate, incoming bool) {
721 | if i == nil {
722 | return
723 | }
724 |
725 | // If you are serializing a candidate make sure to use ToJSON
726 | // Using Marshal will result in errors around `sdpMid` (thanks pion commenter!)
727 | candidateString, err := json.Marshal(i.ToJSON())
728 |
729 | if c.logger.NilErrCheck(c.label, "Failed to marshal candidate to json", err) {
730 | return
731 | }
732 |
733 | c.writeProto(&RemoteNodeMessage{
734 | Candidate: &CandidateMessage{
735 | Candidate: string(candidateString),
736 | Incoming: incoming,
737 | },
738 | })
739 | }
740 |
741 | incoming.OnICECandidate = func(i *webrtc.ICECandidate) {
742 | icecandidate(i, true)
743 | }
744 |
745 | outgoing.OnICECandidate = func(i *webrtc.ICECandidate) {
746 | icecandidate(i, false)
747 | }
748 |
749 | incoming.OnConnectionStateChange = func(p webrtc.PeerConnectionState) {
750 | switch p {
751 | case webrtc.PeerConnectionStateFailed:
752 | case webrtc.PeerConnectionStateDisconnected:
753 | case webrtc.PeerConnectionStateClosed:
754 | c.stop()
755 | default:
756 | }
757 |
758 | s.handler.Send(sfuSignalClients, nil)
759 | }
760 |
761 | outgoing.OnConnectionStateChange = func(p webrtc.PeerConnectionState) {
762 | switch p {
763 | case webrtc.PeerConnectionStateFailed:
764 | case webrtc.PeerConnectionStateDisconnected:
765 | case webrtc.PeerConnectionStateClosed:
766 | c.stop()
767 | default:
768 | }
769 |
770 | s.handler.Send(sfuSignalClients, nil)
771 | }
772 |
773 | outgoing.OnSignalingStateChange = func(ss webrtc.SignalingState) {
774 | if ss == webrtc.SignalingStateStable {
775 | mappings := make([]*MidToUmbrellaIDMapping, 0)
776 |
777 | for umbrellaId := range c.outgoingTracks {
778 | sender, exists := c.senders[umbrellaId]
779 | if exists && sender.Track() != nil {
780 | for _, trx := range outgoing.GetTransceivers() {
781 | if trx.Sender() == sender {
782 | mappings = append(mappings, &MidToUmbrellaIDMapping{
783 | Mid: trx.Mid(),
784 | UmbrellaId: umbrellaId,
785 | })
786 | }
787 | }
788 | }
789 | }
790 |
791 | c.writeProto(&RemoteNodeMessage{MidMappings: &MidToUmbrellaIDMappings{Mapping: mappings}})
792 | }
793 | }
794 |
795 | incoming.OnTrack = func(t *webrtc.TrackRemote, r *webrtc.RTPReceiver) {
796 | c.logger.Info(c.label, "OnTrack "+t.ID()+" "+r.RTPTransceiver().Mid())
797 | c.handler.Send(clientIncomingTrackAdded, &clientCommandMessage{newincomingTrack: &rawIncomingTrack{track: t, receiver: r}})
798 | }
799 |
800 | incoming.OnNegotiationNeeded = func() {
801 | c.handler.Send(clientEvalState, nil)
802 | }
803 |
804 | outgoing.OnNegotiationNeeded = func() {
805 | c.handler.Send(clientEvalState, nil)
806 | }
807 |
808 | // Signal for the new PeerConnection
809 | s.handler.Send(sfuSignalClients, nil)
810 |
811 | for {
812 | var message RemoteNodeMessage
813 | _, raw, err := ws.ReadMessage()
814 | if c.logger.NilErrCheck(c.label, "Failed to read message", err) {
815 | return
816 | }
817 |
818 | if err := proto.Unmarshal(raw, &message); err != nil {
819 | c.logger.Error(c.label, "Failed to unmarshal proto to message: "+err.Error())
820 | return
821 | }
822 |
823 | c.handler.Send(clientHandleWSMessage, &clientCommandMessage{message: &message})
824 | }
825 | }
826 |
827 | func (c *client) AddOutgoingTracksForIncomingTrack(intrack *incomingTrack) {
828 | c.handler.Send(clientAddOutgoingTrackForIncomingTrack, &clientCommandMessage{incomingTrack: intrack})
829 | }
830 |
831 | func (c *client) RemoveOutgoingTracksForIncomingTrack(intrack *incomingTrack) {
832 | c.handler.Send(clientRemoveOutgoingTracksForIncomingTrack, &clientCommandMessage{incomingTrack: intrack})
833 | }
834 |
835 | func (c *client) RequestEvalState() {
836 | c.handler.Send(clientEvalState, nil)
837 | }
838 |
--------------------------------------------------------------------------------