Move cmd module to internal

This commit is contained in:
Alexey Khit
2023-05-01 12:55:14 +03:00
parent bc770f1a85
commit 75f61b38ac
58 changed files with 109 additions and 104 deletions
+8
View File
@@ -0,0 +1,8 @@
## Userful links
- https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html
- https://www.ietf.org/id/draft-murillo-whep-01.html
- https://github.com/Glimesh/broadcast-box/
- https://github.com/obsproject/obs-studio/pull/7926
- https://misi.github.io/webrtc-c0d3l4b/
- https://github.com/webtorrent/webtorrent/blob/master/docs/faq.md
+122
View File
@@ -0,0 +1,122 @@
package webrtc
import (
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/pkg/webrtc"
"github.com/pion/sdp/v3"
"strconv"
"strings"
)
type Address struct {
Host string
Port int
}
var addresses []Address
func AddCandidate(address string) {
var port int
// try to get port from address string
if i := strings.LastIndexByte(address, ':'); i > 0 {
if v, _ := strconv.Atoi(address[i+1:]); v != 0 {
address = address[:i]
port = v
}
}
// use default WebRTC port
if port == 0 {
port, _ = strconv.Atoi(Port)
}
addresses = append(addresses, Address{Host: address, Port: port})
}
func GetCandidates() (candidates []string) {
for _, address := range addresses {
// using stun server for receive public IP-address
if address.Host == "stun" {
ip, err := webrtc.GetCachedPublicIP()
if err != nil {
continue
}
// this is a copy, original host unchanged
address.Host = ip.String()
}
candidates = append(
candidates,
webrtc.CandidateManualHostUDP(address.Host, address.Port),
webrtc.CandidateManualHostTCPPassive(address.Host, address.Port),
)
}
return
}
func asyncCandidates(tr *api.Transport, cons *webrtc.Conn) {
tr.WithContext(func(ctx map[any]any) {
if candidates, ok := ctx["candidate"].([]string); ok {
// process candidates that receive before this moment
for _, candidate := range candidates {
_ = cons.AddCandidate(candidate)
}
// remove already processed candidates
delete(ctx, "candidate")
}
// set variable for process candidates after this moment
ctx["webrtc"] = cons
})
for _, candidate := range GetCandidates() {
log.Trace().Str("candidate", candidate).Msg("[webrtc] config")
tr.Write(&api.Message{Type: "webrtc/candidate", Value: candidate})
}
}
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 *api.Transport, msg *api.Message) error {
// process incoming candidate in sync function
tr.WithContext(func(ctx map[any]any) {
candidate := msg.String()
log.Trace().Str("candidate", candidate).Msg("[webrtc] remote")
if cons, ok := ctx["webrtc"].(*webrtc.Conn); ok {
// if webrtc.Server already initialized - process candidate
_ = cons.AddCandidate(candidate)
} else {
// or collect candidate and process it later
list, _ := ctx["candidate"].([]string)
ctx["candidate"] = append(list, candidate)
}
})
return nil
}
+178
View File
@@ -0,0 +1,178 @@
package webrtc
import (
"errors"
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/webrtc"
"github.com/gorilla/websocket"
pion "github.com/pion/webrtc/v3"
"io"
"net/http"
"strings"
"time"
)
func streamsHandler(url string) (core.Producer, error) {
url = url[7:]
if i := strings.Index(url, "://"); i > 0 {
switch url[:i] {
case "ws", "wss":
return asyncClient(url)
case "http", "https":
return syncClient(url)
}
}
return nil, errors.New("unsupported url: " + url)
}
// asyncClient can connect only to go2rtc server
// ex: ws://localhost:1984/api/ws?src=camera1
func asyncClient(url string) (core.Producer, error) {
// 1. Connect to signalign server
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
_ = ws.Close()
}
}()
// 2. Create PeerConnection
pc, err := PeerConnection(true)
if err != nil {
log.Error().Err(err).Caller().Send()
return nil, err
}
var sendOffer core.Waiter
prod := webrtc.NewConn(pc)
prod.Desc = "WebRTC/WebSocket async"
prod.Mode = core.ModeActiveProducer
prod.Listen(func(msg any) {
switch msg := msg.(type) {
case pion.PeerConnectionState:
_ = ws.Close()
case *pion.ICECandidate:
sendOffer.Wait()
s := msg.ToJSON().Candidate
log.Trace().Str("candidate", s).Msg("[webrtc] local")
_ = ws.WriteJSON(&api.Message{Type: "webrtc/candidate", Value: s})
}
})
medias := []*core.Media{
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
{Kind: core.KindAudio, Direction: core.DirectionSendonly},
}
// 3. Create offer
offer, err := prod.CreateOffer(medias)
if err != nil {
return nil, err
}
// 4. Send offer
msg := &api.Message{Type: "webrtc/offer", Value: offer}
if err = ws.WriteJSON(msg); err != nil {
return nil, err
}
sendOffer.Done()
// 5. Get answer
if err = ws.ReadJSON(msg); err != nil {
return nil, err
}
if msg.Type != "webrtc/answer" {
return nil, errors.New("wrong answer: " + msg.Type)
}
answer := msg.String()
if err = prod.SetAnswer(answer); err != nil {
return nil, err
}
// 6. Continue to receiving candidates
go func() {
for {
// receive data from remote
msg := new(api.Message)
if err = ws.ReadJSON(msg); err != nil {
if cerr, ok := err.(*websocket.CloseError); ok {
log.Trace().Err(err).Caller().Msgf("[webrtc] ws code=%d", cerr)
}
break
}
switch msg.Type {
case "webrtc/candidate":
if msg.Value != nil {
_ = prod.AddCandidate(msg.String())
}
}
}
_ = ws.Close()
}()
return prod, nil
}
// syncClient - support WebRTC-HTTP Egress Protocol (WHEP)
// ex: http://localhost:1984/api/webrtc?src=camera1
func syncClient(url string) (core.Producer, error) {
// 2. Create PeerConnection
pc, err := PeerConnection(true)
if err != nil {
log.Error().Err(err).Caller().Send()
return nil, err
}
prod := webrtc.NewConn(pc)
prod.Desc = "WebRTC/WHEP sync"
prod.Mode = core.ModeActiveProducer
medias := []*core.Media{
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
}
// 3. Create offer
offer, err := prod.CreateCompleteOffer(medias)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", url, strings.NewReader(offer))
req.Header.Set("Content-Type", MimeSDP)
if err != nil {
return nil, err
}
client := http.Client{Timeout: time.Second * 5000}
defer client.CloseIdleConnections()
res, err := client.Do(req)
if err != nil {
return nil, err
}
answer, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if err = prod.SetAnswer(string(answer)); err != nil {
return nil, err
}
return prod, nil
}
+275
View File
@@ -0,0 +1,275 @@
package webrtc
import (
"errors"
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/webrtc"
pion "github.com/pion/webrtc/v3"
"github.com/rs/zerolog"
"net"
)
func Init() {
var cfg struct {
Mod struct {
Listen string `yaml:"listen"`
Candidates []string `yaml:"candidates"`
IceServers []pion.ICEServer `yaml:"ice_servers"`
} `yaml:"webrtc"`
}
cfg.Mod.Listen = ":8555/tcp"
cfg.Mod.IceServers = []pion.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
}
app.LoadConfig(&cfg)
log = app.GetLogger("webrtc")
address := cfg.Mod.Listen
// create pionAPI with custom codecs list and custom network settings
serverAPI, err := webrtc.NewAPI(address)
if err != nil {
log.Error().Err(err).Caller().Send()
return
}
// use same API for WebRTC server and client if no address
clientAPI := serverAPI
if address != "" {
log.Info().Str("addr", address).Msg("[webrtc] listen")
_, Port, _ = net.SplitHostPort(address)
clientAPI, _ = webrtc.NewAPI("")
}
pionConf := pion.Configuration{
ICEServers: cfg.Mod.IceServers,
SDPSemantics: pion.SDPSemanticsUnifiedPlanWithFallback,
}
PeerConnection = func(active bool) (*pion.PeerConnection, error) {
// active - client, passive - server
if active {
return clientAPI.NewPeerConnection(pionConf)
} else {
return serverAPI.NewPeerConnection(pionConf)
}
}
for _, candidate := range cfg.Mod.Candidates {
AddCandidate(candidate)
}
// async WebRTC server (two API versions)
api.HandleWS("webrtc", asyncHandler)
api.HandleWS("webrtc/offer", asyncHandler)
api.HandleWS("webrtc/candidate", candidateHandler)
// sync WebRTC server (two API versions)
api.HandleFunc("api/webrtc", syncHandler)
// WebRTC client
streams.HandleFunc("webrtc", streamsHandler)
}
var Port string
var log zerolog.Logger
var PeerConnection func(active bool) (*pion.PeerConnection, error)
func asyncHandler(tr *api.Transport, msg *api.Message) error {
var stream *streams.Stream
var mode core.Mode
query := tr.Request.URL.Query()
if name := query.Get("src"); name != "" {
stream = streams.GetOrNew(name)
mode = core.ModePassiveConsumer
log.Debug().Str("src", name).Msg("[webrtc] new consumer")
} else if name = query.Get("dst"); name != "" {
stream = streams.Get(name)
mode = core.ModePassiveProducer
log.Debug().Str("src", name).Msg("[webrtc] new producer")
}
if stream == nil {
return errors.New(api.StreamNotFound)
}
// create new PeerConnection instance
pc, err := PeerConnection(false)
if err != nil {
log.Error().Err(err).Caller().Send()
return err
}
var sendAnswer core.Waiter
conn := webrtc.NewConn(pc)
conn.Desc = "WebRTC/WebSocket async"
conn.Mode = mode
conn.UserAgent = tr.Request.UserAgent()
conn.Listen(func(msg any) {
switch msg := msg.(type) {
case pion.PeerConnectionState:
if msg != pion.PeerConnectionStateClosed {
return
}
switch mode {
case core.ModePassiveConsumer:
stream.RemoveConsumer(conn)
case core.ModePassiveProducer:
stream.RemoveProducer(conn)
}
case *pion.ICECandidate:
sendAnswer.Wait()
s := msg.ToJSON().Candidate
log.Trace().Str("candidate", s).Msg("[webrtc] local")
tr.Write(&api.Message{Type: "webrtc/candidate", Value: s})
}
})
// V2 - json/object exchange, V1 - raw SDP exchange
apiV2 := msg.Type == "webrtc"
// 1. SetOffer, so we can get remote client codecs
var offer string
if apiV2 {
offer = msg.GetString("sdp")
} else {
offer = msg.String()
}
log.Trace().Msgf("[webrtc] offer:\n%s", offer)
if err = conn.SetOffer(offer); err != nil {
log.Warn().Err(err).Caller().Send()
return err
}
switch mode {
case core.ModePassiveConsumer:
// 2. AddConsumer, so we get new tracks
if err = stream.AddConsumer(conn); err != nil {
log.Debug().Err(err).Msg("[webrtc] add consumer")
_ = conn.Close()
return err
}
case core.ModePassiveProducer:
stream.AddProducer(conn)
}
// 3. Exchange SDP without waiting all candidates
answer, err := conn.GetAnswer()
log.Trace().Msgf("[webrtc] answer\n%s", answer)
if err != nil {
log.Error().Err(err).Caller().Send()
return err
}
if apiV2 {
desc := pion.SessionDescription{Type: pion.SDPTypeAnswer, SDP: answer}
tr.Write(&api.Message{Type: "webrtc", Value: desc})
} else {
tr.Write(&api.Message{Type: "webrtc/answer", Value: answer})
}
sendAnswer.Done()
asyncCandidates(tr, conn)
return nil
}
func ExchangeSDP(stream *streams.Stream, offer, desc, userAgent string) (answer string, err error) {
pc, err := PeerConnection(false)
if err != nil {
log.Error().Err(err).Caller().Send()
return
}
// create new webrtc instance
conn := webrtc.NewConn(pc)
conn.Desc = desc
conn.UserAgent = userAgent
conn.Listen(func(msg any) {
switch msg := msg.(type) {
case pion.PeerConnectionState:
if msg != pion.PeerConnectionStateClosed {
return
}
if conn.Mode == core.ModePassiveConsumer {
stream.RemoveConsumer(conn)
} else {
stream.RemoveProducer(conn)
}
}
})
// 1. SetOffer, so we can get remote client codecs
log.Trace().Msgf("[webrtc] offer:\n%s", offer)
if err = conn.SetOffer(offer); err != nil {
log.Warn().Err(err).Caller().Send()
return
}
if IsConsumer(conn) {
conn.Mode = core.ModePassiveConsumer
// 2. AddConsumer, so we get new tracks
if err = stream.AddConsumer(conn); err != nil {
log.Warn().Err(err).Caller().Send()
_ = conn.Close()
return
}
} else {
conn.Mode = core.ModePassiveProducer
stream.AddProducer(conn)
}
answer, err = conn.GetCompleteAnswer()
if err == nil {
answer, err = syncCanditates(answer)
}
log.Trace().Msgf("[webrtc] answer\n%s", answer)
if err != nil {
log.Error().Err(err).Caller().Send()
}
return
}
func IsConsumer(conn *webrtc.Conn) bool {
// if wants get video - consumer
for _, media := range conn.GetMedias() {
if media.Kind == core.KindVideo && media.Direction == core.DirectionSendonly {
return true
}
}
// if wants send video - producer
for _, media := range conn.GetMedias() {
if media.Kind == core.KindVideo && media.Direction == core.DirectionRecvonly {
return false
}
}
// if wants something - consumer
for _, media := range conn.GetMedias() {
if media.Direction == core.DirectionSendonly {
return true
}
}
return false
}
+210
View File
@@ -0,0 +1,210 @@
package webrtc
import (
"encoding/json"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/webrtc"
pion "github.com/pion/webrtc/v3"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const MimeSDP = "application/sdp"
var sessions = map[string]*webrtc.Conn{}
func syncHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "POST":
query := r.URL.Query()
if query.Get("src") != "" {
// WHEP or JSON SDP or raw SDP exchange
outputWebRTC(w, r)
} else if query.Get("dst") != "" {
// WHIP SDP exchange
inputWebRTC(w, r)
} else {
http.Error(w, "", http.StatusBadRequest)
}
case "PATCH":
// TODO: WHEP/WHIP
http.Error(w, "", http.StatusMethodNotAllowed)
case "DELETE":
if id := r.URL.Query().Get("id"); id != "" {
if conn, ok := sessions[id]; ok {
delete(sessions, id)
_ = conn.Close()
} else {
http.Error(w, "", http.StatusNotFound)
}
} else {
http.Error(w, "", http.StatusBadRequest)
}
default:
http.Error(w, "", http.StatusMethodNotAllowed)
}
}
// outputWebRTC support API depending on Content-Type:
// 1. application/json - receive {"type":"offer","sdp":"v=0\r\n..."} and response {"type":"answer","sdp":"v=0\r\n..."}
// 2. application/sdp - receive/response SDP via WebRTC-HTTP Egress Protocol (WHEP)
// 3. other - receive/response raw SDP
func outputWebRTC(w http.ResponseWriter, r *http.Request) {
url := r.URL.Query().Get("src")
stream := streams.Get(url)
if stream == nil {
return
}
mediaType := r.Header.Get("Content-Type")
if mediaType != "" {
mediaType, _, _ = strings.Cut(mediaType, ";")
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
}
var offer string
switch mediaType {
case "application/json":
var desc pion.SessionDescription
if err := json.NewDecoder(r.Body).Decode(&desc); err != nil {
log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
offer = desc.SDP
default:
body, err := io.ReadAll(r.Body)
if err != nil {
log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
offer = string(body)
}
var desc string
switch mediaType {
case "application/json":
desc = "WebRTC/JSON sync"
case MimeSDP:
desc = "WebRTC/WHEP sync"
default:
desc = "WebRTC/HTTP sync"
}
answer, err := ExchangeSDP(stream, offer, desc, r.UserAgent())
if err != nil {
log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
switch mediaType {
case "application/json":
w.Header().Set("Content-Type", mediaType)
v := pion.SessionDescription{
Type: pion.SDPTypeAnswer, SDP: answer,
}
err = json.NewEncoder(w).Encode(v)
case MimeSDP:
w.Header().Set("Content-Type", mediaType)
w.WriteHeader(http.StatusCreated)
_, err = w.Write([]byte(answer))
default:
_, err = w.Write([]byte(answer))
}
if err != nil {
log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func inputWebRTC(w http.ResponseWriter, r *http.Request) {
dst := r.URL.Query().Get("dst")
stream := streams.Get(dst)
if stream == nil {
stream = streams.New(dst, nil)
}
// 1. Get offer
offer, err := io.ReadAll(r.Body)
if err != nil {
log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Trace().Msgf("[webrtc] WHIP offer\n%s", offer)
pc, err := PeerConnection(false)
if err != nil {
log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// create new webrtc instance
prod := webrtc.NewConn(pc)
prod.Desc = "WebRTC/WHIP sync"
prod.Mode = core.ModePassiveProducer
prod.UserAgent = r.UserAgent()
if err = prod.SetOffer(string(offer)); err != nil {
log.Warn().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
answer, err := prod.GetCompleteAnswer()
if err == nil {
answer, err = syncCanditates(answer)
}
if err != nil {
log.Warn().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Trace().Msgf("[webrtc] WHIP answer\n%s", answer)
id := strconv.FormatInt(time.Now().UnixNano(), 36)
sessions[id] = prod
prod.Listen(func(msg any) {
switch msg := msg.(type) {
case pion.PeerConnectionState:
if msg == pion.PeerConnectionStateClosed {
stream.RemoveProducer(prod)
if _, ok := sessions[id]; ok {
delete(sessions, id)
}
}
}
})
stream.AddProducer(prod)
w.Header().Set("Content-Type", MimeSDP)
w.Header().Set("Location", "webrtc?id="+id)
w.WriteHeader(http.StatusCreated)
if _, err = w.Write([]byte(answer)); err != nil {
log.Warn().Err(err).Caller().Send()
return
}
}