mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-09-27 04:36:12 +08:00
381 lines
8.1 KiB
Go
381 lines
8.1 KiB
Go
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/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"
|
|
}
|
|
|
|
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
|
|
padSize := int(b[len(b)-1])
|
|
return b[:len(b)-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()
|
|
|
|
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
|
|
}
|
|
|
|
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, fmt.Errorf("Expected StatusCode to be %d, received %d", http.StatusUnauthorized, res.StatusCode)
|
|
}
|
|
|
|
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)
|
|
}
|