Skip to content

Commit

Permalink
Improve WebRTC candidates handling
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexxIT committed May 12, 2024
1 parent f7b9804 commit 205018c
Show file tree
Hide file tree
Showing 8 changed files with 289 additions and 119 deletions.
2 changes: 1 addition & 1 deletion internal/ngrok/ngrok.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func Init() {

log.Info().Str("addr", address).Msg("[ngrok] add external candidate for WebRTC")

webrtc.AddCandidate(address, "tcp")
webrtc.AddCandidate("tcp", address)
}
}
})
Expand Down
106 changes: 99 additions & 7 deletions internal/webrtc/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,105 @@
What you should to know about WebRTC:

- It's almost always a **direct [peer-to-peer](https://en.wikipedia.org/wiki/Peer-to-peer) connection** from your browser to go2rtc app
- When you use Home Assistant, Frigate, Nginx, Nabu Casa, Cloudflare and other software - they are only **involved in establishing** the connection, but they are **not involved in transferring** media data
- WebRTC media cannot be transferred inside an HTTP connection
- Usually, WebRTC uses random UDP ports on client and server side to establish a connection
- Usually, WebRTC uses public [STUN](https://en.wikipedia.org/wiki/STUN) servers to establish a connection outside LAN, such servers are only needed to establish a connection and are not involved in data transfer
- Usually, WebRTC will automatically discover all of your local addresses and all of your public addresses and try to establish a connection

If an external connection via STUN is used:

- Uses [UDP hole punching](https://en.wikipedia.org/wiki/UDP_hole_punching) technology to bypass NAT even if you not open your server to the World
- For about 20% of users, the techology will not work because of the [Symmetric NAT](https://tomchen.github.io/symmetric-nat-test/)
- UDP is not suitable for transmitting 2K and 4K high bitrate video over open networks because of the high loss rate

## Default config

```yaml
webrtc:
listen: ":8555/tcp"
ice_servers:
- urls: [ "stun:stun.l.google.com:19302" ]
```

## Config

- supported TCP: fixed port (default), disabled
- supported UDP: random port (default), fixed port
**Important!** This example is not for copypasting!

```yaml
webrtc:
# fix local TCP or UDP or both ports for WebRTC media
listen: ":8555/tcp" # address of your local server

# add additional host candidates manually
# order is important, the first will have a higher priority
candidates:
- 216.58.210.174:8555 # if you have static public IP-address
- stun:8555 # if you have dynamic public IP-address
- home.duckdns.org:8555 # if you have domain

# add custom STUN and TURN servers
# use `ice_servers: []` for remove defaults and leave empty
ice_servers:
- urls: [ stun:stun1.l.google.com:19302 ]
- urls: [ turn:123.123.123.123:3478 ]
username: your_user
credential: your_pass

# optional filter list for auto discovery logic
# some settings only make sense if you don't specify a fixed UDP port
filters:
# list of host candidates from auto discovery to be sent
# including candidates from the `listen` option
# use `candidates: []` to remove all auto discovery candidates
candidates: [ 192.168.1.123 ]

# list of network types to be used for connection
# including candidates from the `listen` option
networks: [ udp4, udp6, tcp4, tcp6 ]

# list of interfaces to be used for connection
# not related to the `listen` option
interfaces: [ eno1 ]

# list of host IP-addresses to be used for connection
# not related to the `listen` option
ips: [ 192.168.1.123 ]

# range for random UDP ports [min, max] to be used for connection
# not related to the `listen` option
udp_ports: [ 50000, 50100 ]
```

By default go2rtc uses **fixed TCP** port and multiple **random UDP** ports for each WebRTC connection - `listen: ":8555/tcp"`.

You can set **fixed TCP** and **fixed UDP** port for all connections - `listen: ":8555"`. This may has lower performance, but it's your choice.

Don't know why, but you can disable TCP port and leave only random UDP ports - `listen: ""`.

## Config filters

Filters allow you to exclude unnecessary candidates. Extra candidates don't make your connection worse or better. But the wrong filter settings can break everything. Skip this setting if you don't understand it.

For example, go2rtc is installed on the host system. And there are unnecessary interfaces. You can keep only the relevant via `interfaces` or `ips` options. You can also exclude IPv6 candidates if your server supports them but your home network does not.

```yaml
webrtc:
listen: ":8555/tcp" # use fixed TCP port and random UDP ports
filters:
ips: [ 192.168.1.2 ] # IP-address of your server
networks: [ udp4, tcp4 ] # skip IPv6, if it's not supported for you
```

For example, go2rtc inside closed docker container (ex. [Frigate](https://frigate.video/)). You shouldn't filter docker interfaces, otherwise go2rtc will not be able to connect anywhere. But you can filter the docker candidates because no one can connect to them.

| Config examples | TCP | UDP |
|-----------------------|-------|--------|
| `listen: ":8555/tcp"` | fixed | random |
| `listen: ":8555"` | fixed | fixed |
| `listen: ""` | no | random |
```yaml
webrtc:
listen: ":8555" # use fixed TCP and UDP ports
candidates: [ 192.168.1.2:8555 ] # add manual host candidate (use docker port forwarding)
filters:
candidates: [] # skip all internal docker candidates
```

## Userful links

Expand Down
109 changes: 60 additions & 49 deletions internal/webrtc/candidates.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,60 @@ package webrtc

import (
"net"
"slices"
"strings"

"github.com/AlexxIT/go2rtc/internal/api/ws"
"github.com/AlexxIT/go2rtc/pkg/webrtc"
"github.com/pion/sdp/v3"
pion "github.com/pion/webrtc/v3"
)

type Address struct {
Host string
Port string
Network string
Offset int
host string
Port string
Network string
Priority uint32
}

func (a *Address) Marshal() string {
host := a.Host
if host == "stun" {
func (a *Address) Host() string {
if a.host == "stun" {
ip, err := webrtc.GetCachedPublicIP()
if err != nil {
return ""
}
host = ip.String()
return ip.String()
}
return a.host
}

switch a.Network {
case "udp":
return webrtc.CandidateManualHostUDP(host, a.Port, a.Offset)
case "tcp":
return webrtc.CandidateManualHostTCPPassive(host, a.Port, a.Offset)
func (a *Address) Marshal() string {
if host := a.Host(); host != "" {
return webrtc.CandidateICE(a.Network, host, a.Port, a.Priority)
}

return ""
}

var addresses []*Address
var filters webrtc.Filters

func AddCandidate(network, address string) {
if network == "" {
AddCandidate("tcp", address)
AddCandidate("udp", address)
return
}

func AddCandidate(address, network string) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return
}

offset := -1 - len(addresses) // every next candidate will have a lower priority
// start from 1, so manual candidates will be lower than built-in
// and every next candidate will have a lower priority
candidateIndex := 1 + len(addresses)

switch network {
case "tcp", "udp":
addresses = append(addresses, &Address{host, port, network, offset})
default:
addresses = append(
addresses, &Address{host, port, "udp", offset}, &Address{host, port, "tcp", offset},
)
}
priority := webrtc.CandidateHostPriority(network, candidateIndex)
addresses = append(addresses, &Address{host, port, network, priority})
}

func GetCandidates() (candidates []string) {
Expand All @@ -64,6 +67,38 @@ func GetCandidates() (candidates []string) {
return
}

// FilterCandidate return true if candidate passed the check
func FilterCandidate(candidate *pion.ICECandidate) bool {
if candidate == nil {
return false
}

// host candidate should be in the hosts list
if candidate.Typ == pion.ICECandidateTypeHost && filters.Candidates != nil {
if !slices.Contains(filters.Candidates, candidate.Address) {
return false
}
}

if filters.Networks != nil {
networkType := NetworkType(candidate.Protocol.String(), candidate.Address)
if !slices.Contains(filters.Networks, networkType) {
return false
}
}

return true
}

// NetworkType convert tcp/udp network to tcp4/tcp6/udp4/udp6
func NetworkType(network, host string) string {
if strings.IndexByte(host, ':') >= 0 {
return network + "6"
} else {
return network + "4"
}
}

func asyncCandidates(tr *ws.Transport, cons *webrtc.Conn) {
tr.WithContext(func(ctx map[any]any) {
if candidates, ok := ctx["candidate"].([]string); ok {
Expand All @@ -86,30 +121,6 @@ func asyncCandidates(tr *ws.Transport, cons *webrtc.Conn) {
}
}

func syncCanditates(answer string) (string, error) {
if len(addresses) == 0 {
return answer, nil
}

sd := &sdp.SessionDescription{}
if err := sd.Unmarshal([]byte(answer)); err != nil {
return "", err
}

md := sd.MediaDescriptions[0]

for _, candidate := range GetCandidates() {
md.WithPropertyAttribute(candidate)
}

data, err := sd.Marshal()
if err != nil {
return "", err
}

return string(data), nil
}

func candidateHandler(tr *ws.Transport, msg *ws.Message) error {
// process incoming candidate in sync function
tr.WithContext(func(ctx map[any]any) {
Expand Down
5 changes: 1 addition & 4 deletions internal/webrtc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,7 @@ func inputWebRTC(w http.ResponseWriter, r *http.Request) {
return
}

answer, err := prod.GetCompleteAnswer()
if err == nil {
answer, err = syncCanditates(answer)
}
answer, err := prod.GetCompleteAnswer(GetCandidates(), FilterCandidate)
if err != nil {
log.Warn().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
Expand Down
25 changes: 10 additions & 15 deletions internal/webrtc/webrtc.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func Init() {
Listen string `yaml:"listen"`
Candidates []string `yaml:"candidates"`
IceServers []pion.ICEServer `yaml:"ice_servers"`
Filters webrtc.Filters `yaml:"filters"`
} `yaml:"webrtc"`
}

Expand All @@ -32,20 +33,15 @@ func Init() {

log = app.GetLogger("webrtc")

address, network, _ := strings.Cut(cfg.Mod.Listen, "/")
filters = cfg.Mod.Filters

var candidateHost []string
address, network, _ := strings.Cut(cfg.Mod.Listen, "/")
for _, candidate := range cfg.Mod.Candidates {
if strings.HasPrefix(candidate, "host:") {
candidateHost = append(candidateHost, candidate[5:])
continue
}

AddCandidate(candidate, network)
AddCandidate(network, candidate)
}

// create pionAPI with custom codecs list and custom network settings
serverAPI, err := webrtc.NewServerAPI(address, network, candidateHost)
serverAPI, err := webrtc.NewServerAPI(network, address, &filters)
if err != nil {
log.Error().Err(err).Caller().Send()
return
Expand All @@ -55,8 +51,7 @@ func Init() {
clientAPI := serverAPI

if address != "" {
log.Info().Str("addr", address).Msg("[webrtc] listen")

log.Info().Str("addr", cfg.Mod.Listen).Msg("[webrtc] listen")
clientAPI, _ = webrtc.NewAPI()
}

Expand Down Expand Up @@ -139,6 +134,9 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) error {
}

case *pion.ICECandidate:
if !FilterCandidate(msg) {
return
}
_ = sendAnswer.Wait()

s := msg.ToJSON().Candidate
Expand Down Expand Up @@ -248,10 +246,7 @@ func ExchangeSDP(stream *streams.Stream, offer, desc, userAgent string) (answer
stream.AddProducer(conn)
}

answer, err = conn.GetCompleteAnswer()
if err == nil {
answer, err = syncCanditates(answer)
}
answer, err = conn.GetCompleteAnswer(GetCandidates(), FilterCandidate)
log.Trace().Msgf("[webrtc] answer\n%s", answer)

if err != nil {
Expand Down

0 comments on commit 205018c

Please sign in to comment.