install go2rtc on bob
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
package tapo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
func (c *Client) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
|
||||
if c.sender == nil {
|
||||
if err := c.SetupBackchannel(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
muxer := mpegts.NewMuxer()
|
||||
pid := muxer.AddTrack(mpegts.StreamTypePCMATapo)
|
||||
if err := c.WriteBackchannel(muxer.GetHeader()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.sender = core.NewSender(media, track.Codec)
|
||||
c.sender.Handler = func(packet *rtp.Packet) {
|
||||
b := muxer.GetPayload(pid, packet.Timestamp, packet.Payload)
|
||||
_ = c.WriteBackchannel(b)
|
||||
}
|
||||
}
|
||||
|
||||
c.sender.HandleRTP(track)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) SetupBackchannel() (err error) {
|
||||
// if conn1 is not used - we will use it for backchannel
|
||||
// or we need to start another conn for session2
|
||||
if c.session1 != "" {
|
||||
if c.conn2, err = c.newConn(); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.conn2 = c.conn1
|
||||
}
|
||||
|
||||
c.session2, err = c.Request(c.conn2, []byte(`{"params":{"talk":{"mode":"aec"},"method":"get"},"seq":3,"type":"request"}`))
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) WriteBackchannel(body []byte) (err error) {
|
||||
// TODO: fixme (size)
|
||||
buf := bytes.NewBuffer(nil)
|
||||
buf.WriteString("----client-stream-boundary--\r\n")
|
||||
buf.WriteString("Content-Type: audio/mp2t\r\n")
|
||||
buf.WriteString("X-If-Encrypt: 0\r\n")
|
||||
buf.WriteString("X-Session-Id: " + c.session2 + "\r\n")
|
||||
buf.WriteString("Content-Length: " + strconv.Itoa(len(body)) + "\r\n\r\n")
|
||||
buf.Write(body)
|
||||
|
||||
_, err = buf.WriteTo(c.conn2)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package tapo
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/pcm"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
)
|
||||
|
||||
// Deprecated: should be rewritten to core.Connection
|
||||
type Client struct {
|
||||
core.Listener
|
||||
|
||||
url *url.URL
|
||||
|
||||
medias []*core.Media
|
||||
receivers []*core.Receiver
|
||||
sender *core.Sender
|
||||
|
||||
conn1 net.Conn
|
||||
conn2 net.Conn
|
||||
|
||||
decrypt func(b []byte) []byte
|
||||
|
||||
session1 string
|
||||
session2 string
|
||||
request string
|
||||
|
||||
recv int
|
||||
send int
|
||||
}
|
||||
|
||||
// block ciphers using cipher block chaining.
|
||||
type cbcMode interface {
|
||||
cipher.BlockMode
|
||||
SetIV([]byte)
|
||||
}
|
||||
|
||||
// Dial support different urls:
|
||||
// - tapo://{cloud-password}@192.168.1.123 - auth to Tapo cameras
|
||||
// with cloud password (autodetect hash method)
|
||||
// - tapo://admin:{hashed-cloud-password}@192.168.1.123 - auth to Tapo cameras
|
||||
// with pre-hashed cloud password
|
||||
// - vigi://admin:{password}@192.168.1.123 - auth to Vigi cameras with password
|
||||
// for admin account (other not supported)
|
||||
func Dial(rawURL string) (*Client, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if u.Port() == "" {
|
||||
u.Host += ":8800"
|
||||
}
|
||||
|
||||
c := &Client{url: u}
|
||||
if c.conn1, err = c.newConn(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) newConn() (net.Conn, error) {
|
||||
req, err := http.NewRequest("POST", "http://"+c.url.Host+"/stream", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := c.url.Query()
|
||||
|
||||
if deviceId := query.Get("deviceId"); deviceId != "" {
|
||||
req.URL.RawQuery = "deviceId=" + deviceId
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "multipart/mixed; boundary=--client-stream-boundary--")
|
||||
|
||||
username := c.url.User.Username()
|
||||
password, _ := c.url.User.Password()
|
||||
|
||||
conn, res, err := dial(req, c.url.Scheme, username, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, errors.New(res.Status)
|
||||
}
|
||||
|
||||
if c.decrypt == nil {
|
||||
c.newDectypter(res, c.url.Scheme, username, password)
|
||||
}
|
||||
|
||||
channel := query.Get("channel")
|
||||
if channel == "" {
|
||||
channel = "0"
|
||||
}
|
||||
|
||||
subtype := query.Get("subtype")
|
||||
switch subtype {
|
||||
case "", "0":
|
||||
subtype = "HD"
|
||||
case "1":
|
||||
subtype = "VGA"
|
||||
}
|
||||
|
||||
c.request = fmt.Sprintf(
|
||||
`{"params":{"preview":{"audio":["default"],"channels":[%s],"resolutions":["%s"]},"method":"get"},"seq":1,"type":"request"}`,
|
||||
channel, subtype,
|
||||
)
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *Client) newDectypter(res *http.Response, brand, username, password string) {
|
||||
exchange := res.Header.Get("Key-Exchange")
|
||||
nonce := core.Between(exchange, `nonce="`, `"`)
|
||||
|
||||
if brand == "tapo" && password == "" {
|
||||
if strings.Contains(exchange, `encrypt_type="3"`) {
|
||||
password = fmt.Sprintf("%32X", sha256.Sum256([]byte(username)))
|
||||
} else {
|
||||
password = fmt.Sprintf("%16X", md5.Sum([]byte(username)))
|
||||
}
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
if strings.Contains(exchange, `username="none"`) {
|
||||
// https://nvd.nist.gov/vuln/detail/CVE-2022-37255
|
||||
username = "none"
|
||||
password = "TPL075526460603"
|
||||
}
|
||||
|
||||
key := md5.Sum([]byte(nonce + ":" + password))
|
||||
iv := md5.Sum([]byte(username + ":" + nonce))
|
||||
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cbc := cipher.NewCBCDecrypter(block, iv[:]).(cbcMode)
|
||||
|
||||
c.decrypt = func(b []byte) []byte {
|
||||
// restore IV
|
||||
cbc.SetIV(iv[:])
|
||||
|
||||
// decrypt
|
||||
cbc.CryptBlocks(b, b)
|
||||
|
||||
// unpad
|
||||
n := len(b)
|
||||
padSize := int(b[n-1])
|
||||
return b[:n-padSize]
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetupStream() (err error) {
|
||||
if c.session1 != "" {
|
||||
return
|
||||
}
|
||||
|
||||
// audio: default, disable, enable
|
||||
c.session1, err = c.Request(c.conn1, []byte(c.request))
|
||||
return
|
||||
}
|
||||
|
||||
// Handle - first run will be in probe state
|
||||
func (c *Client) Handle() error {
|
||||
rd := multipart.NewReader(c.conn1, "--device-stream-boundary--")
|
||||
demux := mpegts.NewDemuxer()
|
||||
|
||||
var transcode func([]byte) []byte
|
||||
|
||||
for {
|
||||
p, err := rd.NextRawPart()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ct := p.Header.Get("Content-Type"); ct != "video/mp2t" {
|
||||
continue
|
||||
}
|
||||
|
||||
cl := p.Header.Get("Content-Length")
|
||||
size, err := strconv.Atoi(cl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.recv += size
|
||||
|
||||
body := make([]byte, size)
|
||||
|
||||
b := body
|
||||
for {
|
||||
if n, err2 := p.Read(b); err2 == nil {
|
||||
b = b[n:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
body = c.decrypt(body)
|
||||
bytesRd := bytes.NewReader(body)
|
||||
|
||||
for {
|
||||
pkt, err2 := demux.ReadPacket(bytesRd)
|
||||
if pkt == nil || err2 == io.EOF {
|
||||
break
|
||||
}
|
||||
if err2 != nil {
|
||||
return err2
|
||||
}
|
||||
|
||||
if pkt.PayloadType == mpegts.StreamTypePCMUTapo {
|
||||
// TODO: rewrite this part in the future
|
||||
// Some cameras in the new firmware began to use PCMU/16000.
|
||||
// https://github.com/AlexxIT/go2rtc/issues/1954
|
||||
// I don't know why Tapo considers this an improvement. The codec is no better than the previous one.
|
||||
// Unfortunately, we don't know in advance what codec the camera will use.
|
||||
// Therefore, it's easier to transcode to a standard codec that all Tapo cameras have.
|
||||
if transcode == nil {
|
||||
transcode = pcm.Transcode(
|
||||
&core.Codec{Name: core.CodecPCMA, ClockRate: 8000},
|
||||
&core.Codec{Name: core.CodecPCMU, ClockRate: 16000},
|
||||
)
|
||||
}
|
||||
pkt.PayloadType = mpegts.StreamTypePCMATapo
|
||||
pkt.Payload = transcode(pkt.Payload)
|
||||
}
|
||||
|
||||
for _, receiver := range c.receivers {
|
||||
if receiver.ID == pkt.PayloadType {
|
||||
mpegts.TimestampToRTP(pkt, receiver.Codec)
|
||||
receiver.WriteRTP(pkt)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Close() (err error) {
|
||||
if c.conn1 != nil {
|
||||
err = c.conn1.Close()
|
||||
}
|
||||
if c.conn2 != nil {
|
||||
_ = c.conn2.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) Request(conn net.Conn, body []byte) (string, error) {
|
||||
// TODO: fixme (size)
|
||||
buf := bytes.NewBuffer(nil)
|
||||
buf.WriteString("----client-stream-boundary--\r\n")
|
||||
buf.WriteString("Content-Type: application/json\r\n")
|
||||
buf.WriteString("Content-Length: " + strconv.Itoa(len(body)) + "\r\n\r\n")
|
||||
buf.Write(body)
|
||||
buf.WriteString("\r\n")
|
||||
|
||||
if _, err := buf.WriteTo(conn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mpReader := multipart.NewReader(conn, "--device-stream-boundary--")
|
||||
|
||||
for {
|
||||
p, err := mpReader.NextRawPart()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var v struct {
|
||||
Params struct {
|
||||
SessionID string `json:"session_id"`
|
||||
} `json:"params"`
|
||||
}
|
||||
|
||||
if err = json.NewDecoder(p).Decode(&v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return v.Params.SessionID, nil
|
||||
}
|
||||
}
|
||||
|
||||
func dial(req *http.Request, brand, username, password string) (net.Conn, *http.Response, error) {
|
||||
conn, err := net.DialTimeout("tcp", req.URL.Host, core.ConnDialTimeout)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err = req.Write(conn); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
r := bufio.NewReader(conn)
|
||||
|
||||
res, err := http.ReadResponse(r, req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, res.Body) // discard leftovers
|
||||
_ = res.Body.Close() // ignore response body
|
||||
|
||||
auth := res.Header.Get("WWW-Authenticate")
|
||||
|
||||
if res.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(auth, "Digest") {
|
||||
return nil, nil, errors.New("tapo: wrond status: " + res.Status)
|
||||
}
|
||||
|
||||
if brand == "tapo" && password == "" {
|
||||
// support cloud password in place of username
|
||||
if strings.Contains(auth, `encrypt_type="3"`) {
|
||||
password = fmt.Sprintf("%32X", sha256.Sum256([]byte(username)))
|
||||
} else {
|
||||
password = fmt.Sprintf("%16X", md5.Sum([]byte(username)))
|
||||
}
|
||||
username = "admin"
|
||||
} else if brand == "vigi" && username == "admin" {
|
||||
password = securityEncode(password)
|
||||
}
|
||||
|
||||
realm := tcp.Between(auth, `realm="`, `"`)
|
||||
nonce := tcp.Between(auth, `nonce="`, `"`)
|
||||
qop := tcp.Between(auth, `qop="`, `"`)
|
||||
uri := req.URL.RequestURI()
|
||||
ha1 := tcp.HexMD5(username, realm, password)
|
||||
ha2 := tcp.HexMD5(req.Method, uri)
|
||||
nc := "00000001"
|
||||
cnonce := core.RandString(32, 64)
|
||||
response := tcp.HexMD5(ha1, nonce, nc, cnonce, qop, ha2)
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc7616
|
||||
header := fmt.Sprintf(
|
||||
`Digest username="%s", realm="%s", nonce="%s", uri="%s", qop=%s, nc=%s, cnonce="%s", response="%s"`,
|
||||
username, realm, nonce, uri, qop, nc, cnonce, response,
|
||||
)
|
||||
|
||||
if opaque := tcp.Between(auth, `opaque="`, `"`); opaque != "" {
|
||||
header += fmt.Sprintf(`, opaque="%s", algorithm=MD5`, opaque)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", header)
|
||||
|
||||
if err = req.Write(conn); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if res, err = http.ReadResponse(r, req); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return conn, res, nil
|
||||
}
|
||||
|
||||
const (
|
||||
keyShort = "RDpbLfCPsJZ7fiv"
|
||||
keyLong = "yLwVl0zKqws7LgKPRQ84Mdt708T1qQ3Ha7xv3H7NyU84p21BriUWBU43odz3iP4rBL3cD02KZciXTysVXiV8ngg6vL48rPJyAUw0HurW20xqxv9aYb4M9wK1Ae0wlro510qXeU07kV57fQMc8L6aLgMLwygtc0F10a0Dg70TOoouyFhdysuRMO51yY5ZlOZZLEal1h0t9YQW0Ko7oBwmCAHoic4HYbUyVeU3sfQ1xtXcPcf1aT303wAQhv66qzW"
|
||||
)
|
||||
|
||||
func securityEncode(s string) string {
|
||||
size := len(s)
|
||||
|
||||
var n int // max
|
||||
if size > len(keyShort) {
|
||||
n = size
|
||||
} else {
|
||||
n = len(keyShort)
|
||||
}
|
||||
|
||||
b := make([]byte, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
c1 := 187
|
||||
c2 := 187
|
||||
if i >= size {
|
||||
c1 = int(keyShort[i])
|
||||
} else if i >= len(keyShort) {
|
||||
c2 = int(s[i])
|
||||
} else {
|
||||
c1 = int(keyShort[i])
|
||||
c2 = int(s[i])
|
||||
}
|
||||
b[i] = keyLong[(c1^c2)%len(keyLong)]
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package tapo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
)
|
||||
|
||||
func (c *Client) GetMedias() []*core.Media {
|
||||
if c.medias == nil {
|
||||
// don't know if all Tapo has this capabilities...
|
||||
c.medias = []*core.Media{
|
||||
{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecH264, ClockRate: 90000, PayloadType: core.PayloadTypeRAW},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecPCMA, ClockRate: 8000, PayloadType: 8},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecPCMA, ClockRate: 8000, PayloadType: 8},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return c.medias
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
for _, track := range c.receivers {
|
||||
if track.Codec == codec {
|
||||
return track, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.SetupStream(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
track := core.NewReceiver(media, codec)
|
||||
switch media.Kind {
|
||||
case core.KindVideo:
|
||||
track.ID = mpegts.StreamTypeH264
|
||||
case core.KindAudio:
|
||||
track.ID = mpegts.StreamTypePCMATapo
|
||||
}
|
||||
c.receivers = append(c.receivers, track)
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
return c.Handle()
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
for _, receiver := range c.receivers {
|
||||
receiver.Close()
|
||||
}
|
||||
if c.sender != nil {
|
||||
c.sender.Close()
|
||||
}
|
||||
return c.Close()
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
info := &core.Connection{
|
||||
ID: core.ID(c),
|
||||
FormatName: c.url.Scheme,
|
||||
Protocol: "http",
|
||||
Medias: c.medias,
|
||||
Recv: c.recv,
|
||||
Receivers: c.receivers,
|
||||
Send: c.send,
|
||||
}
|
||||
if c.sender != nil {
|
||||
info.Senders = []*core.Sender{c.sender}
|
||||
}
|
||||
if c.conn1 != nil {
|
||||
info.RemoteAddr = c.conn1.RemoteAddr().String()
|
||||
}
|
||||
return json.Marshal(info)
|
||||
}
|
||||
Reference in New Issue
Block a user