diff --git a/README.md b/README.md index 31155164..02978500 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,6 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - streaming to [RTSP](#module-rtsp), [WebRTC](#module-webrtc), [MSE/MP4](#module-mp4), [HomeKit](#module-homekit) [HLS](#module-hls) or [MJPEG](#module-mjpeg) - [publish](#publish-stream) any source to popular streaming services (YouTube, Telegram, etc.) - first project in the World with support streaming from [HomeKit Cameras](#source-homekit) -- support H265 for WebRTC in browser (Safari only, [read more](https://github.com/AlexxIT/Blog/issues/5)) - on-the-fly transcoding for unsupported codecs via [FFmpeg](#source-ffmpeg) - play audio files and live streams on some cameras with [speaker](#stream-to-camera) - multi-source two-way [codecs negotiation](#codecs-negotiation) @@ -28,6 +27,20 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - [two-way audio](#two-way-audio) for some cameras - can be [integrated to](#module-api) any smart home platform or be used as [standalone app](#go2rtc-binary) +**Supported Formats** - describes the communication API: authorization, encryption, command set, structure of media packets + +- devices: `alsa` (Linux audio), `v4l2` (Linux video) +- files: `adts`, `flv`, `h264`, `hevc`, `hls`, `mjpeg`, `mpegts`, `mp4`, `wav` +- network (public and well known): `mpjpeg`, `onvif`, `rtmp`, `rtp`, `rtsp`, `webrtc`, `yuv4mpegpipe` +- network (private and exclusive): `bubble`, `doorbird`, `dvrip`, `eseecloud`, `gopro`, `hass` (Home Assistant), `homekit` (Apple), `isapi` (Hikvision), `kasa` (TP-Link), `multitrans` (TP-Link), `nest` (Google), `ring`, `roborock`, `tapo` and `vigi` (TP-Link), `tuya`, `webtorrent`, `wyze`, `xiaomi` (Mi Home) +- webrtc related: `creality`, `kinesis` (Amazon), `openipc`, `switchbot`, `whep`, `whip`, `wyze` +- other: `ascii`, `echo`, `exec`, `expr`, `ffmpeg` + +**Supported Protocols** - describes the transport for data transmission + +- public: `http`, `pipe`, `rtmp`, `rtsp`, `tcp`, `udp`, `webrtc`, `ws` (WebSocket) +- private: `cs2` (PPPP), `hap` and `hds` (HomeKit), `tutk` (P2P) + **Inspired by:** - series of streaming projects from [@deepch](https://github.com/deepch) @@ -39,46 +52,51 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - creator of the project's logo [@v_novoseltsev](https://www.instagram.com/v_novoseltsev) > [!CAUTION] -> There is NO existing website for go2rtc project other than this GitHub repository. The website go2rtc[.]com is in no way associated with the authors of this project. +> The official website of the project is this GitHub repository and go2rtc.org (hosted on GitHub Pages). The website go2rtc[.]com is in no way associated with the authors of this project. --- - [Fast start](#fast-start) - [go2rtc: Binary](#go2rtc-binary) - [go2rtc: Docker](#go2rtc-docker) - - [go2rtc: Home Assistant Add-on](#go2rtc-home-assistant-add-on) - - [go2rtc: Home Assistant Integration](#go2rtc-home-assistant-integration) + - [go2rtc: Home Assistant add-on](#go2rtc-home-assistant-add-on) + - [go2rtc: Home Assistant integration](#go2rtc-home-assistant-integration) - [go2rtc: Dev version](#go2rtc-dev-version) - [Configuration](#configuration) - [Module: Streams](#module-streams) - - [Two-way audio](#two-way-audio) - - [Source: RTSP](#source-rtsp) - - [Source: RTMP](#source-rtmp) - - [Source: HTTP](#source-http) - - [Source: ONVIF](#source-onvif) - - [Source: FFmpeg](#source-ffmpeg) - - [Source: FFmpeg Device](#source-ffmpeg-device) - - [Source: Exec](#source-exec) - - [Source: Echo](#source-echo) - - [Source: Expr](#source-expr) - - [Source: HomeKit](#source-homekit) - - [Source: Bubble](#source-bubble) - - [Source: DVRIP](#source-dvrip) - - [Source: Tapo](#source-tapo) - - [Source: Kasa](#source-kasa) - - [Source: GoPro](#source-gopro) - - [Source: Ivideon](#source-ivideon) - - [Source: Hass](#source-hass) - - [Source: ISAPI](#source-isapi) - - [Source: Nest](#source-nest) - - [Source: Ring](#source-ring) - - [Source: Roborock](#source-roborock) - - [Source: WebRTC](#source-webrtc) - - [Source: WebTorrent](#source-webtorrent) - - [Incoming sources](#incoming-sources) - - [Stream to camera](#stream-to-camera) - - [Publish stream](#publish-stream) - - [Preload stream](#preload-stream) + - [Two-way audio](#two-way-audio) + - [Source: RTSP](#source-rtsp) + - [Source: RTMP](#source-rtmp) + - [Source: HTTP](#source-http) + - [Source: ONVIF](#source-onvif) + - [Source: FFmpeg](#source-ffmpeg) + - [Source: FFmpeg Device](#source-ffmpeg-device) + - [Source: Exec](#source-exec) + - [Source: Echo](#source-echo) + - [Source: Expr](#source-expr) + - [Source: HomeKit](#source-homekit) + - [Source: Bubble](#source-bubble) + - [Source: DVRIP](#source-dvrip) + - [Source: Tapo](#source-tapo) + - [Source: Kasa](#source-kasa) + - [Source: Multitrans](#source-multitrans) + - [Source: Tuya](#source-tuya) + - [Source: Xiaomi](#source-xiaomi) + - [Source: Wyze](#source-wyze) + - [Source: GoPro](#source-gopro) + - [Source: Ivideon](#source-ivideon) + - [Source: Hass](#source-hass) + - [Source: ISAPI](#source-isapi) + - [Source: Nest](#source-nest) + - [Source: Ring](#source-ring) + - [Source: Roborock](#source-roborock) + - [Source: Doorbird](#source-doorbird) + - [Source: WebRTC](#source-webrtc) + - [Source: WebTorrent](#source-webtorrent) + - [Incoming sources](#incoming-sources) + - [Stream to camera](#stream-to-camera) + - [Publish stream](#publish-stream) + - [Preload stream](#preload-stream) - [Module: API](#module-api) - [Module: RTSP](#module-rtsp) - [Module: RTMP](#module-rtmp) @@ -96,11 +114,10 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - [Codecs madness](#codecs-madness) - [Codecs negotiation](#codecs-negotiation) - [Projects using go2rtc](#projects-using-go2rtc) -- [Camera experience](#camera-experience) +- [Camera experience](#cameras-experience) - [TIPS](#tips) -- [FAQ](#faq) -## Fast start +# Fast start 1. Download [binary](#go2rtc-binary) or use [Docker](#go2rtc-docker) or Home Assistant [add-on](#go2rtc-home-assistant-add-on) or [Integration](#go2rtc-home-assistant-integration) 2. Open web interface: `http://localhost:1984/` @@ -115,7 +132,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - write your own [web interface](#module-api) - integrate [web api](#module-api) into your smart home platform -### go2rtc: Binary +## go2rtc: Binary Download binary for your OS from [latest release](https://github.com/AlexxIT/go2rtc/releases/): @@ -135,11 +152,13 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2 Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac. -### go2rtc: Docker +PS. The application is compiled with the latest versions of the Go language for maximum speed and security. Therefore, the [minimum OS versions](https://go.dev/wiki/MinimumRequirements) depend on the Go language. + +## go2rtc: Docker The Docker container [`alexxit/go2rtc`](https://hub.docker.com/r/alexxit/go2rtc) supports multiple architectures including `amd64`, `386`, `arm64`, and `arm`. This container offers the same functionality as the [Home Assistant add-on](#go2rtc-home-assistant-add-on) but is designed to operate independently of Home Assistant. It comes preinstalled with [FFmpeg](#source-ffmpeg) and [Python](#source-echo). -### go2rtc: Home Assistant add-on +## go2rtc: Home Assistant add-on [![](https://my.home-assistant.io/badges/supervisor_addon.svg)](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons) @@ -148,11 +167,11 @@ The Docker container [`alexxit/go2rtc`](https://hub.docker.com/r/alexxit/go2rtc) - go2rtc > Install > Start 2. Setup [Integration](#module-hass) -### go2rtc: Home Assistant Integration +## go2rtc: Home Assistant Integration [WebRTC Camera](https://github.com/AlexxIT/WebRTC) custom component can be used on any [Home Assistant installation](https://www.home-assistant.io/installation/), including [HassWP](https://github.com/AlexxIT/HassWP) on Windows. It can automatically download and use the latest version of go2rtc. Or it can connect to an existing version of go2rtc. Addon installation in this case is optional. -### go2rtc: Dev version +## go2rtc: Dev version Latest, but maybe unstable version: @@ -160,7 +179,7 @@ Latest, but maybe unstable version: - Docker: `alexxit/go2rtc:master` or `alexxit/go2rtc:master-hardware` versions - Home Assistant add-on: `go2rtc master` or `go2rtc master hardware` versions -## Configuration +# Configuration - by default go2rtc will search `go2rtc.yaml` in the current work directory - `api` server will start on default **1984 port** (TCP) @@ -184,7 +203,7 @@ Available modules: - [hass](#module-hass) - Home Assistant integration - [log](#module-log) - logs config -### Module: Streams +## Module: Streams **go2rtc** supports different stream source types. You can config one or multiple links of any type as a stream source. @@ -202,20 +221,25 @@ Available source types: - [homekit](#source-homekit) - streaming from HomeKit Camera - [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR - [dvrip](#source-dvrip) - streaming from DVR-IP NVR +- [eseecloud](#source-eseecloud) - streaming from ESeeCloud/dvr163 NVR - [tapo](#source-tapo) - TP-Link Tapo cameras with [two-way audio](#two-way-audio) support - [ring](#source-ring) - Ring cameras with [two-way audio](#two-way-audio) support +- [tuya](#source-tuya) - Tuya cameras with [two-way audio](#two-way-audio) support +- [xiaomi](#source-xiaomi) - Xiaomi cameras with [two-way audio](#two-way-audio) support - [kasa](#source-tapo) - TP-Link Kasa cameras - [gopro](#source-gopro) - GoPro cameras - [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service - [hass](#source-hass) - Home Assistant integration - [isapi](#source-isapi) - two-way audio for Hikvision (ISAPI) cameras - [roborock](#source-roborock) - Roborock vacuums with cameras +- [doorbird](#source-doorbird) - Doorbird cameras with [two-way audio](#two-way-audio) support - [webrtc](#source-webrtc) - WebRTC/WHEP sources - [webtorrent](#source-webtorrent) - WebTorrent source from another go2rtc +- [wyze](#source-wyze) - Wyze cameras with [two-way audio](#two-way-audio) support Read more about [incoming sources](#incoming-sources) -#### Two-way audio +## Two-way audio Supported sources: @@ -224,15 +248,19 @@ Supported sources: - [TP-Link Tapo](#source-tapo) cameras - [Hikvision ISAPI](#source-isapi) cameras - [Roborock vacuums](#source-roborock) models with cameras +- [Doorbird](#source-doorbird) cameras - [Exec](#source-exec) audio on server - [Ring](#source-ring) cameras +- [Tuya](#source-tuya) cameras +- [Wyze](#source-wyze) cameras +- [Xiaomi](#source-xiaomi) cameras - [Any Browser](#incoming-browser) as IP-camera Two-way audio can be used in browser with [WebRTC](#module-webrtc) technology. The browser will give access to the microphone only for HTTPS sites ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https)). go2rtc also supports [play audio](#stream-to-camera) files and live streams on this cameras. -#### Source: RTSP +## Source: RTSP ```yaml streams: @@ -276,7 +304,7 @@ streams: dahua-rtsp-ws: rtsp://user:pass@192.168.1.123/cam/realmonitor?channel=1&subtype=1&proto=Private3#transport=ws://192.168.1.123/rtspoverwebsocket ``` -#### Source: RTMP +## Source: RTMP You can get a stream from an RTMP server, for example [Nginx with nginx-rtmp-module](https://github.com/arut/nginx-rtmp-module). @@ -285,7 +313,7 @@ streams: rtmp_stream: rtmp://192.168.1.123/live/camera1 ``` -#### Source: HTTP +## Source: HTTP Support Content-Type: @@ -316,7 +344,7 @@ streams: **PS.** Dahua camera has a bug: if you select MJPEG codec for RTSP second stream, snapshot won't work. -#### Source: ONVIF +## Source: ONVIF *[New in v1.5.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.5.0)* @@ -331,7 +359,7 @@ streams: tapo1: onvif://admin:password@192.168.1.123:2020 ``` -#### Source: FFmpeg +## Source: FFmpeg You can get any stream, file or device via FFmpeg and push it to go2rtc. The app will automatically start FFmpeg with the proper arguments when someone starts watching the stream. @@ -361,16 +389,18 @@ streams: rotate: ffmpeg:rtsp://12345678@192.168.1.123/av_stream/ch0#video=h264#rotate=90 ``` -All transcoding formats have [built-in templates](./internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`. +All transcoding formats have [built-in templates](internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`. But you can override them via YAML config. You can also add your own formats to the config and use them with source params. ```yaml ffmpeg: bin: ffmpeg # path to ffmpeg binary + global: "-hide_banner" + timeout: 5 # default timeout in seconds for rtsp inputs h264: "-codec:v libx264 -g:v 30 -preset:v superfast -tune:v zerolatency -profile:v main -level:v 4.1" mycodec: "-any args that supported by ffmpeg..." - myinput: "-fflags nobuffer -flags low_delay -timeout 5000000 -i {input}" + myinput: "-fflags nobuffer -flags low_delay -timeout {timeout} -i {input}" myraw: "-ss 00:00:20" ``` @@ -380,16 +410,17 @@ ffmpeg: - You can use `width` and/or `height` params, important with transcoding (ex. `#video=h264#width=1280`) - You can use `drawtext` to add a timestamp (ex. `drawtext=x=2:y=2:fontsize=12:fontcolor=white:box=1:boxcolor=black`) - This will greatly increase the CPU of the server, even with hardware acceleration +- You can use `timeout` param to set RTSP input timeout in seconds (ex. `#timeout=10`) - You can use `raw` param for any additional FFmpeg arguments (ex. `#raw=-vf transpose=1`) - You can use `input` param to override default input template (ex. `#input=rtsp/udp` will change RTSP transport from TCP to UDP+TCP) - - You can use raw input value (ex. `#input=-timeout 5000000 -i {input}`) + - You can use raw input value (ex. `#input=-timeout {timeout} -i {input}`) - You can add your own input templates Read more about [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration). **PS.** It is recommended to check the available hardware in the WebUI add page. -#### Source: FFmpeg Device +## Source: FFmpeg Device You can get video from any USB camera or Webcam as RTSP or WebRTC stream. This is part of FFmpeg integration. @@ -410,7 +441,7 @@ streams: **PS.** It is recommended to check the available devices in the WebUI add page. -#### Source: Exec +## Source: Exec Exec source can run any external application and expect data from it. Two transports are supported - **pipe** (*from [v1.5.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.5.0)*) and **RTSP**. @@ -431,6 +462,7 @@ Pipe commands support parameters (format: `exec:{command}#{param1}#{param2}`): - `killsignal` - signal which will be sent to stop the process (numeric form) - `killtimeout` - time in seconds for forced termination with sigkill - `backchannel` - enable backchannel for two-way audio +- `starttimeout` - time in seconds for waiting first byte from RTSP ```yaml streams: @@ -443,7 +475,7 @@ streams: play_pcm48k: exec:ffplay -fflags nobuffer -f s16be -ar 48000 -i -#backchannel=1 ``` -#### Source: Echo +## Source: Echo Some sources may have a dynamic link. And you will need to get it using a Bash or Python script. Your script should echo a link to the source. RTSP, FFmpeg or any of the [supported sources](#module-streams). @@ -456,13 +488,15 @@ streams: apple_hls: echo:python3 hls.py https://developer.apple.com/streaming/examples/basic-stream-osx-ios5.html ``` -#### Source: Expr +## Source: Expr *[New in v1.8.2](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.2)* -Like `echo` source, but uses the built-in [expr](https://github.com/antonmedv/expr) expression language ([read more](./internal/expr/README.md)). +Like `echo` source, but uses the built-in [expr](https://github.com/antonmedv/expr) expression language. -#### Source: HomeKit +*[read more](internal/expr/README.md)* + +## Source: HomeKit **Important:** @@ -495,7 +529,7 @@ RTSP link with "normal" audio for any player: `rtsp://192.168.1.123:8554/aqara_g **This source is in active development!** Tested only with [Aqara Camera Hub G3](https://www.aqara.com/eu/product/camera-hub-g3) (both EU and CN versions). -#### Source: Bubble +## Source: Bubble *[New in v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)* @@ -509,7 +543,7 @@ streams: camera1: bubble://username:password@192.168.1.123:34567/bubble/live?ch=0&stream=0 ``` -#### Source: DVRIP +## Source: DVRIP *[New in v1.2.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.2.0)* @@ -529,7 +563,16 @@ streams: - dvrip://username:password@192.168.1.123:34567?backchannel=1 ``` -#### Source: Tapo +## Source: EseeCloud + +*[New in v1.9.10](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.10)* + +```yaml +streams: + camera1: eseecloud://user:pass@192.168.1.123:80/livestream/12 +``` + +## Source: Tapo *[New in v1.2.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.2.0)* @@ -559,7 +602,7 @@ echo -n "cloud password" | md5 | awk '{print toupper($0)}' echo -n "cloud password" | shasum -a 256 | awk '{print toupper($0)}' ``` -#### Source: Kasa +## Source: Kasa *[New in v1.7.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0)* @@ -575,13 +618,43 @@ streams: Tested: KD110, KC200, KC401, KC420WS, EC71. -#### Source: GoPro +## Source: Multitrans + +Two-way audio support for Chinese version of [TP-Link cameras](https://www.tp-link.com.cn/list_2549.html). + +*[read more](internal/multitrans/README.md)* + +## Source: Tuya + +*[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* + +[Tuya](https://www.tuya.com/) proprietary camera protocol with **two-way audio** support. Go2rtc supports `Tuya Smart API` and `Tuya Cloud API`. + +*[read more](internal/tuya/README.md)* + +## Source: Xiaomi + +*[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* + +This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. + +*[read more](internal/xiaomi/README.md)* + +## Source: Wyze + +This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol - no `docker-wyze-bridge` required. Supports H.264/H.265 video, AAC/G.711 audio, and two-way audio. + +*[read more](internal/wyze/README.md)* + +## Source: GoPro *[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* -Support streaming from [GoPro](https://gopro.com/) cameras, connected via USB or Wi-Fi to Linux, Mac, Windows. [Read more](https://github.com/AlexxIT/go2rtc/tree/master/internal/gopro). +Support streaming from [GoPro](https://gopro.com/) cameras, connected via USB or Wi-Fi to Linux, Mac, Windows. -#### Source: Ivideon +*[read more](internal/gopro/README.md)* + +## Source: Ivideon Support public cameras from the service [Ivideon](https://tv.ivideon.com/). @@ -590,7 +663,7 @@ streams: quailcam: ivideon:100-tu5dkUPct39cTp9oNEN2B6/0 ``` -#### Source: Hass +## Source: Hass Support import camera links from [Home Assistant](https://www.home-assistant.io/) config files: @@ -626,7 +699,7 @@ streams: By default, the Home Assistant API does not allow you to get a dynamic RTSP link to a camera stream. So more cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), and possibly others, can also be imported using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-cameras-from-home-assistant-to-go2rtc-or-frigate). -#### Source: ISAPI +## Source: ISAPI *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -639,7 +712,7 @@ streams: - isapi://admin:password@192.168.1.123:80/ ``` -#### Source: Nest +## Source: Nest *[New in v1.6.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.0)* @@ -652,7 +725,7 @@ streams: nest-doorbell: nest:?client_id=***&client_secret=***&refresh_token=***&project_id=***&device_id=*** ``` -#### Source: Ring +## Source: Ring This source type support Ring cameras with [two-way audio](#two-way-audio) support. If you have a `refresh_token` and `device_id` - you can use it in `go2rtc.yaml` config file. Otherwise, you can use the go2rtc interface and add your ring account (WebUI > Add > Ring). Once added, it will list all your Ring cameras. @@ -662,7 +735,7 @@ streams: ring_snapshot: ring:?device_id=XXX&refresh_token=XXX&snapshot ``` -#### Source: Roborock +## Source: Roborock *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -676,7 +749,13 @@ Source supports loading Roborock credentials from Home Assistant [custom integra If you have a graphic PIN for your vacuum, add it as a numeric PIN (lines: 123, 456, 789) to the end of the `roborock` link. -#### Source: WebRTC +## Source: Doorbird + +This source type supports [Doorbird](https://www.doorbird.com/) devices including MJPEG stream, audio stream as well as two-way audio. + +*[read more](internal/doorbird/README.md)* + +## Source: WebRTC *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -694,9 +773,9 @@ This format is only supported in go2rtc. Unlike WHEP, it supports asynchronous W Support connection to [OpenIPC](https://openipc.org/) cameras. -**wyze** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*) +**wyze (via docker-wyze-bridge)** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*) -Supports connection to [Wyze](https://www.wyze.com/) cameras, using WebRTC protocol. You can use the [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge) project to get connection credentials. +Legacy method to connect to [Wyze](https://www.wyze.com/) cameras using WebRTC protocol via [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge). For native P2P support without docker-wyze-bridge, see [Source: Wyze](#source-wyze). **kinesis** (*from [v1.6.1](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1)*) @@ -718,7 +797,7 @@ streams: **PS.** For `kinesis` sources, you can use [echo](#source-echo) to get connection params using `bash`, `python` or any other script language. -#### Source: WebTorrent +## Source: WebTorrent *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -729,7 +808,7 @@ streams: webtorrent1: webtorrent:?share=huofssuxaty00izc&pwd=k3l2j9djeg8v8r7e ``` -#### Incoming sources +## Incoming sources By default, go2rtc establishes a connection to the source when any client requests it. Go2rtc drops the connection to the source when it has no clients left. @@ -765,7 +844,7 @@ By default, go2rtc establishes a connection to the source when any client reques ffmpeg -re -i BigBuckBunny.mp4 -c copy -f mpegts http://localhost:1984/api/stream.ts?dst=camera1 ``` -#### Incoming: Browser +### Incoming: Browser *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -777,7 +856,7 @@ You can turn the browser of any PC or mobile into an IP camera with support for 4. Select `camera+microphone` or `display+speaker` option 5. Open `webrtc` local page (your go2rtc **should work over HTTPS!**) or `share link` via [WebTorrent](#module-webtorrent) technology (work over HTTPS by default) -#### Incoming: WebRTC/WHIP +### Incoming: WebRTC/WHIP *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -785,7 +864,7 @@ You can use **OBS Studio** or any other broadcast software with [WHIP](https://w - Settings > Stream > Service: WHIP > `http://192.168.1.123:1984/api/webrtc?dst=camera1` -#### Stream to camera +## Stream to camera *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -807,7 +886,7 @@ POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com - you can stop active playback by calling the API with the empty `src` parameter - you will see one active producer and one active consumer in go2rtc WebUI info page during streaming -### Publish stream +## Publish stream *[New in v1.8.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.0)* @@ -845,7 +924,7 @@ streams: - **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming. - **YouTube** > Create > Go live > Stream latency: Ultra low-latency > Copy: Stream URL + Stream key. -### Preload stream +## Preload stream You can preload any stream on go2rtc start. This is useful for cameras that take a long time to start up. @@ -865,13 +944,13 @@ streams: - ffmpeg:camera3#video=h264#audio=opus#hardware ``` -### Module: API +## Module: API The HTTP API is the main part for interacting with the application. Default address: `http://localhost:1984/`. **Important!** go2rtc passes requests from localhost and from Unix sockets without HTTP authorisation, even if you have it configured! It is your responsibility to set up secure external access to the API. If not properly configured, an attacker can gain access to your cameras and even your server. -[API description](https://github.com/AlexxIT/go2rtc/tree/master/api). +[API description](api/README.md). **Module config** @@ -886,6 +965,7 @@ api: listen: ":1984" # default ":1984", HTTP API port ("" - disabled) username: "admin" # default "", Basic auth for WebUI password: "pass" # default "", Basic auth for WebUI + local_auth: true # default false, Enable auth check for localhost requests base_path: "/rtc" # default "", API prefix for serving on suburl (/api => /rtc/api) static_dir: "www" # default "", folder for static files (custom web interface) origin: "*" # default "", allow CORS requests (only * supported) @@ -906,7 +986,7 @@ api: - MJPEG over WebSocket plays better than native MJPEG because Chrome [bug](https://bugs.chromium.org/p/chromium/issues/detail?id=527446) - MP4 over WebSocket was created only for Apple iOS because it doesn't support MSE and native MP4 -### Module: RTSP +## Module: RTSP You can get any stream as RTSP-stream: `rtsp://192.168.1.123:8554/{stream_name}` @@ -929,7 +1009,7 @@ By default go2rtc provide RTSP-stream with only one first video and only one fir Read more about [codecs filters](#codecs-filters). -### Module: RTMP +## Module: RTMP *[New in v1.8.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.0)* @@ -942,7 +1022,7 @@ rtmp: listen: ":1935" # by default - disabled! ``` -### Module: WebRTC +## Module: WebRTC In most cases, [WebRTC](https://en.wikipedia.org/wiki/WebRTC) uses a direct peer-to-peer connection from your browser to go2rtc and sends media data via UDP. It **can't pass** media data through your Nginx or Cloudflare or [Nabu Casa](https://www.nabucasa.com/) HTTP TCP connection! @@ -1000,7 +1080,7 @@ webrtc: credential: your_pass ``` -### Module: HomeKit +## Module: HomeKit *[New in v1.7.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0)* @@ -1054,7 +1134,7 @@ homekit: aqara1: # same stream ID from streams list ``` -### Module: WebTorrent +## Module: WebTorrent *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* @@ -1078,13 +1158,15 @@ webtorrent: src: rtsp-dahua1 # stream name from streams section ``` -Link example: `https://alexxit.github.io/go2rtc/#share=02SNtgjKXY&pwd=wznEQqznxW&media=video+audio` +Link example: `https://go2rtc.org/webtorrent/#share=02SNtgjKXY&pwd=wznEQqznxW&media=video+audio` -### Module: ngrok +## Module: ngrok -With [ngrok](https://ngrok.com/) integration, you can get external access to your streams in situations when you have Internet with a private IP address ([read more](./internal/ngrok/README.md)). +With [ngrok](https://ngrok.com/) integration, you can get external access to your streams in situations when you have internet with a private IP address. -### Module: Hass +*[read more](internal/ngrok/README.md)* + +## Module: Hass While [go2rtc is used by default in Home Assistant](https://www.home-assistant.io/integrations/go2rtc/), the best and easiest way to have full control over it is to install the [WebRTC Camera](#go2rtc-home-assistant-integration) custom integration and card. @@ -1115,7 +1197,7 @@ streams: **PS.** There is also another nice card with two-way audio support through go2rtc - [Advanced Camera Card](https://github.com/dermotduffy/advanced-camera-card). -### Module: MP4 +## Module: MP4 Provides several features: @@ -1138,7 +1220,7 @@ Read more about [codecs filters](#codecs-filters). **PS.** Rotate and scale params don't use transcoding and change video using metadata. -### Module: HLS +## Module: HLS *[New in v1.1.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.1.0)* @@ -1153,39 +1235,15 @@ API examples: Read more about [codecs filters](#codecs-filters). -### Module: MJPEG +## Module: MJPEG -**Important.** For stream in MJPEG format, your source MUST contain the MJPEG codec. If your stream has an MJPEG codec, you can receive **MJPEG stream** or **JPEG snapshots** via API. +- This module can provide and receive streams in MJPEG format. +- This module is also responsible for receiving snapshots in JPEG format. +- This module also supports streaming to the server console (terminal) in the **animated ASCII art** format. -You can receive an MJPEG stream in several ways: +*[read more](internal/mjpeg/README.md)* -- some cameras support MJPEG codec inside [RTSP stream](#source-rtsp) (ex. second stream for Dahua cameras) -- some cameras have an HTTP link with [MJPEG stream](#source-http) -- some cameras have an HTTP link with snapshots - go2rtc can convert them to [MJPEG stream](#source-http) -- you can convert H264/H265 stream from your camera via [FFmpeg integraion](#source-ffmpeg) - -With this example, your stream will have both H264 and MJPEG codecs: - -```yaml -streams: - camera1: - - rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0 - - ffmpeg:camera1#video=mjpeg -``` - -API examples: - -- MJPEG stream: `http://192.168.1.123:1984/api/stream.mjpeg?src=camera1` -- JPEG snapshots: `http://192.168.1.123:1984/api/frame.jpeg?src=camera1` - - You can use `width`/`w` and/or `height`/`h` params - - You can use `rotate` param with `90`, `180`, `270` or `-90` values - - You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) - -**PS.** This module also supports streaming to the server console (terminal) in the **animated ASCII art** format ([read more](./internal/mjpeg/README.md)): - -[![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M) - -### Module: Log +## Module: Log You can set different log levels for different modules. @@ -1199,7 +1257,28 @@ log: webrtc: fatal ``` -## Security +# Security + +> [!IMPORTANT] +> If an attacker gains access to the API, you are in danger. Through the API, an attacker can use insecure sources such as echo and exec. And get full access to your server. + +For maximum (paranoid) security, go2rtc has special settings: + +```yaml +app: + # use only allowed modules + modules: [api, rtsp, webrtc, exec, ffmpeg, mjpeg] + +api: + # use only allowed API paths + allow_paths: [/api, /api/streams, /api/webrtc, /api/frame.jpeg] + # enable auth for localhost (used together with username and password) + local_auth: true + +exec: + # use only allowed exec paths + allow_paths: [ffmpeg] +``` By default, `go2rtc` starts the Web interface on port `1984` and RTSP on port `8554`, as well as uses port `8555` for WebRTC connections. The three ports are accessible from your local network. So anyone on your local network can watch video from your cameras without authorization. The same rule applies to the Home Assistant add-on. @@ -1225,7 +1304,7 @@ If you need web interface protection without the Home Assistant add-on, you need PS. Additionally, WebRTC will try to use the 8555 UDP port to transmit encrypted media. It works without problems on the local network, and sometimes also works for external access, even if you haven't opened this port on your router ([read more](https://en.wikipedia.org/wiki/UDP_hole_punching)). But for stable external WebRTC access, you need to open the 8555 port on your router for both TCP and UDP. -## Codecs filters +# Codecs filters go2rtc can automatically detect which codecs your device supports for [WebRTC](#module-webrtc) and [MSE](#module-mp4) technologies. @@ -1248,27 +1327,24 @@ Some examples: - `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=flac` - MP4 file with PCMA/PCMU/PCM audio support, won't work on old devices (ex. iOS 12) - `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=all` - MP4 file with non-standard audio codecs, won't work on some players -## Codecs madness +# Codecs madness -`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has many limitations in supporting different devices and browsers. It's all about patents and money; you can't do anything about it. +`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has many limitations in supporting different devices and browsers. -| Device | WebRTC | MSE | HTTP* | HLS | -|--------------------------------------------------------------------------|-----------------------------------------|-----------------------------------------|----------------------------------------------|-----------------------------| -| *latency* | best | medium | bad | bad | -| - Desktop Chrome 107+
- Desktop Edge
- Android Chrome 107+ | H264
PCMU, PCMA
OPUS | H264, H265*
AAC, FLAC*
OPUS | H264, H265*
AAC, FLAC*
OPUS, MP3 | no | -| Desktop Firefox | H264
PCMU, PCMA
OPUS | H264
AAC, FLAC*
OPUS | H264
AAC, FLAC*
OPUS | no | -| - Desktop Safari 14+
- iPad Safari 14+
- iPhone Safari 17.1+ | H264, H265*
PCMU, PCMA
OPUS | H264, H265
AAC, FLAC* | **no!** | H264, H265
AAC, FLAC* | -| iPhone Safari 14+ | H264, H265*
PCMU, PCMA
OPUS | **no!** | **no!** | H264, H265
AAC, FLAC* | -| macOS [Home Assistant App][1] | no | no | no | H264, H265
AAC, FLAC* | +| Device | WebRTC | MSE | HTTP* | HLS | +|--------------------------------------------------------------------|-----------------------------------------|-----------------------------------------|----------------------------------------------|-----------------------------| +| *latency* | best | medium | bad | bad | +| Desktop Chrome 136+
Desktop Edge
Android Chrome 136+ | H264, H265*
PCMU, PCMA
OPUS | H264, H265*
AAC, FLAC*
OPUS | H264, H265*
AAC, FLAC*
OPUS, MP3 | no | +| Desktop Firefox | H264
PCMU, PCMA
OPUS | H264
AAC, FLAC*
OPUS | H264
AAC, FLAC*
OPUS | no | +| Desktop Safari 14+
iPad Safari 14+
iPhone Safari 17.1+ | H264, H265*
PCMU, PCMA
OPUS | H264, H265
AAC, FLAC* | **no!** | H264, H265
AAC, FLAC* | +| iPhone Safari 14+ | H264, H265*
PCMU, PCMA
OPUS | **no!** | **no!** | H264, H265
AAC, FLAC* | +| macOS [Hass App][1] | no | no | no | H264, H265
AAC, FLAC* | [1]: https://apps.apple.com/app/home-assistant/id1099568401 -`HTTP*` - HTTP Progressive Streaming, not related to [progressive download](https://en.wikipedia.org/wiki/Progressive_download), because the file has no size and no end - -- Chrome H265: [read this](https://chromestatus.com/feature/5186511939567616) and [read this](https://github.com/StaZhu/enable-chromium-hevc-hardware-decoding) -- Edge H265: [read this](https://www.reddit.com/r/MicrosoftEdge/comments/v9iw8k/enable_hevc_support_in_edge/) -- Desktop Safari H265: Menu > Develop > Experimental > WebRTC H265 -- iOS Safari H265: Settings > Safari > Advanced > Experimental > WebRTC H265 +- `HTTP*` - HTTP Progressive Streaming, not related to [progressive download](https://en.wikipedia.org/wiki/Progressive_download), because the file has no size and no end +- `WebRTC H265` - supported in [Chrome 136+](https://developer.chrome.com/release-notes/136), supported in [Safari 18+](https://developer.apple.com/documentation/safari-release-notes/safari-18-release-notes) +- `MSE iPhone` - supported in [iOS 17.1+](https://webkit.org/blog/14735/webkit-features-in-safari-17-1/) **Audio** @@ -1279,7 +1355,7 @@ Some examples: **Apple devices** - all Apple devices don't support HTTP progressive streaming -- iPhones don't support MSE technology because it competes with the HTTP Live Streaming (HLS) technology, invented by Apple +- old iPhone firmwares don't support MSE technology because it competes with the HTTP Live Streaming (HLS) technology, invented by Apple - HLS is the worst technology for **live** streaming, it still exists only because of iPhones **Codec names** @@ -1292,7 +1368,7 @@ Some examples: - AAC = MPEG4-GENERIC - MP3 = MPEG-1 Audio Layer III or MPEG-2 Audio Layer III -## Built-in transcoding +# Built-in transcoding There are no plans to embed complex transcoding algorithms inside go2rtc. [FFmpeg source](#source-ffmpeg) does a great job with this. Including [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) support. @@ -1321,7 +1397,7 @@ PCMU/xxx => PCMU/8000 => WebRTC - FLAC codec not supported in an RTSP stream. If you are using Frigate or Home Assistant for recording MP4 files with PCMA/PCMU/PCM audio, you should set up transcoding to the AAC codec. - PCMA and PCMU are VERY low-quality codecs. They support only 256! different sounds. Use them only when you have no other options. -## Codecs negotiation +# Codecs negotiation For example, you want to watch RTSP-stream from [Dahua IPC-K42](https://www.dahuasecurity.com/fr/products/All-Products/Network-Cameras/Wireless-Series/Wi-Fi-Series/4MP/IPC-K42) camera in your Chrome browser. @@ -1350,9 +1426,10 @@ streams: **PS.** You can select `PCMU` or `PCMA` codec in camera settings and not use transcoding at all. Or you can select `AAC` codec for main stream and `PCMU` codec for second stream and add both RTSP to YAML config, this also will work fine. -## Projects using go2rtc +# Projects using go2rtc -- [Frigate](https://frigate.video/) - open-source NVR built around real-time AI object detection +- [Home Assistant](https://www.home-assistant.io/) [2024.11+](https://www.home-assistant.io/integrations/go2rtc/) - top open-source smart home project +- [Frigate](https://frigate.video/) [0.12+](https://docs.frigate.video/guides/configuring_go2rtc/) - open-source NVR built around real-time AI object detection - [Advanced Camera Card](https://github.com/dermotduffy/advanced-camera-card) - custom card for Home Assistant - [OpenIPC](https://github.com/OpenIPC/firmware/tree/master/general/package/go2rtc) - alternative IP camera firmware from an open community - [wz_mini_hacks](https://github.com/gtxaspec/wz_mini_hacks) - custom firmware for Wyze cameras @@ -1373,7 +1450,7 @@ streams: - [Synology NAS](https://synocommunity.com/package/go2rtc) - [Unraid](https://unraid.net/community/apps?q=go2rtc) -## Camera experience +# Camera experience - [Dahua](https://www.dahuasecurity.com/) - reference implementation streaming protocols, a lot of settings, high stream quality, multiple streaming clients - [EZVIZ](https://www.ezviz.com/) - awful RTSP protocol implementation, many bugs in SDP @@ -1383,7 +1460,7 @@ streams: - [TP-Link](https://www.tp-link.com/) - few streaming clients, packet loss? - Chinese cheap noname cameras, Wyze Cams, Xiaomi cameras with hacks (usually have `/live/ch00_1` in RTSP URL) - awful but usable RTSP protocol implementation, low stream quality, few settings, packet loss? -## Tips +# Tips **Using apps for low RTSP delay** @@ -1393,27 +1470,3 @@ streams: **Snapshots to Telegram** [read more](https://github.com/AlexxIT/go2rtc/wiki/Snapshot-to-Telegram) - -## FAQ - -**Q. What's the difference between go2rtc and WebRTC Camera?** - -**go2rtc** is a new version of the server-side [WebRTC Camera](https://github.com/AlexxIT/WebRTC) integration, completely rewritten from scratch, with a number of fixes and a huge number of new features. [go2rtc is used by default in Home Assistant](https://www.home-assistant.io/integrations/go2rtc/). - -**Q. Should I use the go2rtc add-on or WebRTC Camera integration?** - -**go2rtc** is more than just viewing your stream online with WebRTC/MSE/HLS/etc. You can use it all the time for your various tasks. But every time Home Assistant is rebooted, all integrations are also rebooted. So your streams may be interrupted if you use them in additional tasks. - -Basic users can use the **WebRTC Camera** integration. Advanced users can use the go2rtc add-on or the Frigate add-on. - -**Q. Which RTSP link should I use inside Home Assistant?** - -You can use a direct link to your cameras there (as you always do). **go2rtc** supports zero-config feature. You may leave `streams` config section empty. And your streams will be created on the fly on first start from Home Assistant. And your cameras will have multiple connections. Some from Home Assistant directly and one from **go2rtc**. - -Also, you can specify your streams in **go2rtc** [config file](#configuration) and use RTSP links to this add-on with additional features: multi-source [codecs negotiation](#codecs-negotiation) or FFmpeg [transcoding](#source-ffmpeg) for unsupported codecs. Or use them as a source for Frigate. And your cameras will have one connection from **go2rtc**. And **go2rtc** will have multiple connections - some from Home Assistant via RTSP protocol, some from your browser via WebRTC/MSE/HLS protocols. - -Use any config that you like. - -**Q. What about a Home Assistant card with support for two-way audio?** - -No built-in card supports two-way audio as of now in Home Assistant, but you can use [WebRTC Camera](https://github.com/AlexxIT/WebRTC/) or [Advanced Camera Card](https://github.com/dermotduffy/advanced-camera-card). diff --git a/api/README.md b/api/README.md index 76dc875b..03b99bc4 100644 --- a/api/README.md +++ b/api/README.md @@ -4,7 +4,7 @@ Fill free to make any API design proposals. ## HTTP API -Interactive [OpenAPI](https://alexxit.github.io/go2rtc/api/). +Interactive [OpenAPI](https://go2rtc.org/api/). `www/stream.html` - universal viewer with support params in URL: diff --git a/api/openapi.yaml b/api/openapi.yaml index a2d66a87..b6110572 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2,8 +2,8 @@ openapi: 3.1.0 info: title: go2rtc + version: 1.9.13 license: { name: MIT,url: https://opensource.org/licenses/MIT } - version: 1.0.0 contact: { url: https://github.com/AlexxIT/go2rtc } description: | Ultimate camera streaming application with support RTSP, RTMP, HTTP-FLV, WebRTC, MSE, HLS, MP4, MJPEG, HomeKit, FFmpeg, etc. @@ -11,6 +11,28 @@ info: servers: - url: http://localhost:1984 +tags: + - name: Application + description: "[Module: API](https://github.com/AlexxIT/go2rtc#module-api)" + - name: Config + description: "[Configuration](https://github.com/AlexxIT/go2rtc#configuration)" + - name: Streams list + description: "[Module: Streams](https://github.com/AlexxIT/go2rtc#module-streams)" + - name: Consume stream + - name: HLS + - name: Snapshot + - name: Produce stream + - name: WebSocket + description: "WebSocket API endpoint: `/api/ws` (see `api/README.md`)" + - name: Discovery + - name: HomeKit + - name: ONVIF + - name: RTSPtoWebRTC + - name: WebTorrent + description: "[Module: WebTorrent](https://github.com/AlexxIT/go2rtc#module-webtorrent)" + - name: FFmpeg + - name: Debug + components: parameters: stream_src_path: @@ -20,6 +42,7 @@ components: required: true schema: { type: string } example: camera1 + stream_dst_path: name: dst in: path @@ -27,6 +50,7 @@ components: required: true schema: { type: string } example: camera1 + stream_src_query: name: src in: query @@ -34,6 +58,15 @@ components: required: true schema: { type: string } example: camera1 + + hls_session_id_path: + name: id + in: path + description: HLS session ID (passed as query param `id`) + required: true + schema: { type: string } + example: DvmHdd9w + mp4_filter: name: mp4 in: query @@ -43,6 +76,7 @@ components: type: string enum: [ "", flac, all ] example: flac + video_filter: name: video in: query @@ -51,6 +85,7 @@ components: type: string enum: [ "", all, h264, h265, mjpeg ] example: h264,h265 + audio_filter: name: audio in: query @@ -59,35 +94,20 @@ components: type: string enum: [ "", all, aac, opus, pcm, pcmu, pcma ] example: aac + responses: discovery: description: "" content: application/json: example: { streams: [ { "name": "Camera 1","url": "..." } ] } + webtorrent: description: "" content: application/json: example: { share: AKDypPy4zz, pwd: H0Km1HLTTP } -tags: - - name: Application - description: "[Module: API](https://github.com/AlexxIT/go2rtc#module-api)" - - name: Config - description: "[Configuration](https://github.com/AlexxIT/go2rtc#configuration)" - - name: Streams list - description: "[Module: Streams](https://github.com/AlexxIT/go2rtc#module-streams)" - - name: Consume stream - - name: Snapshot - - name: Produce stream - - name: Discovery - - name: ONVIF - - name: RTSPtoWebRTC - - name: WebTorrent - description: "[Module: WebTorrent](https://github.com/AlexxIT/go2rtc#module-webtorrent)" - - name: Debug - paths: /api: get: @@ -98,7 +118,17 @@ paths: description: "" content: application/json: - example: { config_path: "/config/go2rtc.yaml",host: "192.168.1.123:1984",rtsp: { listen: ":8554",default_query: "video&audio" },version: "1.5.0" } + schema: + type: object + properties: + config_path: { type: string, example: "/config/go2rtc.yaml" } + host: { type: string, example: "192.168.1.123:1984" } + rtsp: + type: object + properties: + listen: { type: string, example: ":8554" } + default_query: { type: string, example: "video&audio" } + version: { type: string, example: "1.9.12" } /api/exit: post: @@ -112,17 +142,39 @@ paths: schema: { type: integer } example: 100 responses: - default: - description: Default response + default: + description: "" /api/restart: post: - summary: Restart Daemon + summary: Restart daemon description: Restarts the daemon. tags: [ Application ] responses: - default: - description: Default response + default: + description: "" + + /api/log: + get: + summary: Get in-memory logs buffer + description: | + Returns current log output from the in-memory circular buffer. + tags: [ Application ] + responses: + "200": + description: OK + content: + application/jsonlines: + example: | + {"level":"info","version":"1.9.13","platform":"linux/amd64","revision":"dfe4755","time":1766841087331,"message":"go2rtc"} + delete: + summary: Clear in-memory logs buffer + tags: [ Application ] + responses: + "200": + description: "" + content: + text/plain: { example: "" } /api/config: get: @@ -133,6 +185,8 @@ paths: description: "" content: application/yaml: { example: "streams:..." } + "404": + description: Config file not found post: summary: Rewrite main config file tags: [ Config ] @@ -140,8 +194,8 @@ paths: content: "*/*": { example: "streams:..." } responses: - default: - description: Default response + default: + description: "" patch: summary: Merge changes to main config file tags: [ Config ] @@ -149,8 +203,8 @@ paths: content: "*/*": { example: "streams:..." } responses: - default: - description: Default response + default: + description: "" @@ -162,7 +216,16 @@ paths: "200": description: "" content: - application/json: { example: { camera1: { producers: [ ],consumers: [ ] } } } + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + producers: + type: array + consumers: + type: array put: summary: Create new stream tags: [ Streams list ] @@ -180,8 +243,8 @@ paths: schema: { type: string } example: camera1 responses: - default: - description: Default response + default: + description: "" patch: summary: Update stream source tags: [ Streams list ] @@ -199,8 +262,8 @@ paths: schema: { type: string } example: camera1 responses: - default: - description: Default response + default: + description: "" delete: summary: Delete stream tags: [ Streams list ] @@ -212,8 +275,8 @@ paths: schema: { type: string } example: camera1 responses: - default: - description: Default response + default: + description: "" post: summary: Send stream from source to destination description: "[Stream to camera](https://github.com/AlexxIT/go2rtc#stream-to-camera)" @@ -232,12 +295,46 @@ paths: schema: { type: string } example: camera1 responses: - default: - description: Default response - + default: + description: "" + /api/streams.dot: + get: + summary: Get streams graph in Graphviz DOT format + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream name filter. Repeat `src` to include multiple streams. + required: false + schema: { type: string } + example: camera1 + responses: + "200": + description: OK + content: + text/vnd.graphviz: + example: "digraph { ... }" /api/preload: + get: + summary: Get all preloaded streams + tags: [ Streams list ] + responses: + "200": + description: "" + content: + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + consumer: + type: object + query: + type: string + example: "video&audio" put: summary: Preload new stream tags: [ Streams list ] @@ -267,8 +364,8 @@ paths: schema: { type: string } example: all,aac,opus,... responses: - default: - description: Default response + default: + description: "" delete: summary: Delete preloaded stream tags: [ Streams list ] @@ -280,9 +377,22 @@ paths: schema: { type: string } example: "camera1" responses: - default: - description: Default response + default: + description: "" + /api/schemes: + get: + summary: Get supported source URL schemes + tags: [ Streams list ] + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: { type: string } + example: [ rtsp, rtmp, webrtc, ffmpeg, hass ] /api/streams?src={src}: @@ -296,7 +406,17 @@ paths: description: "" content: application/json: - example: { producers: [ { url: "rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0" } ], consumers: [ ] } + schema: + type: object + additionalProperties: + type: object + properties: + producers: + type: array + items: { type: object } + consumers: + type: array + items: { type: object } /api/webrtc?src={src}: post: @@ -316,7 +436,6 @@ paths: application/json: { example: { type: offer, sdp: "v=0..." } } "application/sdp": { example: "v=0..." } "*/*": { example: "v=0..." } - responses: "200": description: "Response on JSON or raw SDP" @@ -347,6 +466,16 @@ paths: required: false schema: { type: string } example: camera1.mp4 + - name: rotate + in: query + description: "Rotate video (degrees). Supported values: 90, 180, 270." + required: false + schema: { type: integer, enum: [ 90, 180, 270 ] } + - name: scale + in: query + description: Scale video in format `width:height` + required: false + schema: { type: string, example: "1280:720" } - $ref: "#/components/parameters/mp4_filter" - $ref: "#/components/parameters/video_filter" - $ref: "#/components/parameters/audio_filter" @@ -359,7 +488,7 @@ paths: get: summary: Get stream in HLS format description: "[Module: HLS](https://github.com/AlexxIT/go2rtc#module-hls)" - tags: [ Consume stream ] + tags: [ Consume stream, HLS ] parameters: - $ref: "#/components/parameters/stream_src_path" - $ref: "#/components/parameters/mp4_filter" @@ -370,6 +499,62 @@ paths: description: "" content: { application/vnd.apple.mpegurl: { example: "" } } + /api/hls/playlist.m3u8?id={id}: + get: + summary: Get HLS media playlist for an active session + tags: [ HLS ] + parameters: + - $ref: "#/components/parameters/hls_session_id_path" + responses: + "200": + description: OK + content: + application/vnd.apple.mpegurl: { example: "" } + "404": + description: Session not found + + /api/hls/segment.ts?id={id}: + get: + summary: Get HLS MPEG-TS segment for an active session + tags: [ HLS ] + parameters: + - $ref: "#/components/parameters/hls_session_id_path" + responses: + "200": + description: OK + content: + video/mp2t: { example: "" } + "404": + description: Segment or session not found + + /api/hls/init.mp4?id={id}: + get: + summary: Get HLS fMP4 init segment for an active session + tags: [ HLS ] + parameters: + - $ref: "#/components/parameters/hls_session_id_path" + responses: + "200": + description: OK + content: + video/mp4: { example: "" } + "404": + description: Segment or session not found + + /api/hls/segment.m4s?id={id}: + get: + summary: Get HLS fMP4 media segment for an active session + tags: [ HLS ] + parameters: + - $ref: "#/components/parameters/hls_session_id_path" + responses: + "200": + description: OK + content: + video/iso.segment: { example: "" } + "404": + description: Segment or session not found + /api/stream.mjpeg?src={src}: get: summary: Get stream in MJPEG format @@ -382,7 +567,91 @@ paths: description: "" content: { multipart/x-mixed-replace: { example: "" } } + /api/stream.ascii?src={src}: + get: + summary: Get stream in ASCII-art format (ANSI escape codes) + description: "[Module: MJPEG](https://github.com/AlexxIT/go2rtc#module-mjpeg)" + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + - name: color + in: query + description: Foreground mode (`8`, `256`, `rgb` or ANSI SGR code) + required: false + schema: { type: string } + - name: back + in: query + description: Background mode (`8`, `256`, `rgb` or ANSI SGR code) + required: false + schema: { type: string } + - name: text + in: query + description: Charset preset (empty/default, `block`) or custom characters + required: false + schema: { type: string } + responses: + "200": + description: OK + content: + text/plain: { example: "" } + "404": + description: Stream not found + /api/stream.y4m?src={src}: + get: + summary: Get stream in YUV4MPEG2 format (y4m) + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + "200": + description: OK + content: + application/octet-stream: { example: "" } + "404": + description: Stream not found + + /api/stream.ts?src={src}: + get: + summary: Get stream in MPEG-TS format + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + "200": + description: OK + content: + video/mp2t: { example: "" } + "404": + description: Stream not found + + /api/stream.aac?src={src}: + get: + summary: Get stream audio in AAC (ADTS) format + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + "200": + description: OK + content: + audio/aac: { example: "" } + "404": + description: Stream not found + + /api/stream.flv?src={src}: + get: + summary: Get stream in FLV format + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + "200": + description: OK + content: + video/x-flv: { example: "" } + "404": + description: Stream not found /api/frame.jpeg?src={src}: get: @@ -391,10 +660,37 @@ paths: tags: [ Snapshot ] parameters: - $ref: "#/components/parameters/stream_src_path" + - name: name + in: query + description: Optional stream name to create/update if `src` is a URL + required: false + schema: { type: string } + - name: width + in: query + description: "Scale output width (alias: `w`)" + required: false + schema: { type: integer, minimum: 1 } + - name: height + in: query + description: "Scale output height (alias: `h`)" + required: false + schema: { type: integer, minimum: 1 } + - name: rotate + in: query + description: "Rotate output (degrees). Supported values: 90, 180, 270." + required: false + schema: { type: integer, enum: [ 90, 180, 270 ] } + - name: hardware + in: query + description: "Hardware acceleration engine for FFmpeg snapshot transcoding (alias: `hw`)" + required: false + schema: { type: string } responses: - 200: + "200": description: "" - content: { image/jpeg: { example: "" } } + content: + image/jpeg: { example: "" } + /api/frame.mp4?src={src}: get: summary: Get snapshot in MP4 format @@ -402,23 +698,51 @@ paths: tags: [ Snapshot ] parameters: - $ref: "#/components/parameters/stream_src_path" + - name: filename + in: query + description: Download as a file with this name + required: false + schema: { type: string } + example: camera1.mp4 responses: 200: description: "" - content: { video/mp4: { example: "" } } + content: + video/mp4: { example: "" } /api/webrtc?dst={dst}: post: - summary: Post stream in WebRTC format + summary: Post stream in WebRTC format (WHIP) description: "[Incoming: WebRTC/WHIP](https://github.com/AlexxIT/go2rtc#incoming-webrtcwhip)" tags: [ Produce stream ] parameters: - $ref: "#/components/parameters/stream_dst_path" responses: - default: - description: Default response + "201": + description: Created + headers: + Location: + description: Resource URL for session + schema: { type: string } + content: + application/sdp: { example: "v=0..." } + "404": + description: Stream not found + + /api/stream?dst={dst}: + post: + summary: Post stream in auto-detected format + description: | + Incoming source with automatic format detection. Use for pushing a stream into an existing `dst` stream. + tags: [ Produce stream ] + parameters: + - $ref: "#/components/parameters/stream_dst_path" + responses: + default: + description: "" + /api/stream.flv?dst={dst}: post: summary: Post stream in FLV format @@ -427,8 +751,9 @@ paths: parameters: - $ref: "#/components/parameters/stream_dst_path" responses: - default: - description: Default response + default: + description: "" + /api/stream.ts?dst={dst}: post: summary: Post stream in MPEG-TS format @@ -437,8 +762,9 @@ paths: parameters: - $ref: "#/components/parameters/stream_dst_path" responses: - default: - description: Default response + default: + description: "" + /api/stream.mjpeg?dst={dst}: post: summary: Post stream in MJPEG format @@ -447,10 +773,55 @@ paths: parameters: - $ref: "#/components/parameters/stream_dst_path" responses: - default: - description: Default response + default: + description: "" + /api/ffmpeg: + post: + summary: Play file/live/TTS into a stream via FFmpeg + description: | + Helper endpoint for "stream to camera" scenarios. + Exactly one of `file`, `live`, `text` should be provided. + tags: [ FFmpeg ] + parameters: + - name: dst + in: query + description: Destination stream name + required: true + schema: { type: string } + example: camera1 + - name: file + in: query + description: Input URL to treat as file (`#input=file`) + required: false + schema: { type: string } + example: "http://example.com/song.mp3" + - name: live + in: query + description: Live input URL + required: false + schema: { type: string } + example: "http://example.com/live.mp3" + - name: text + in: query + description: Text-to-speech phrase + required: false + schema: { type: string } + example: "Hello" + - name: voice + in: query + description: Optional TTS voice (engine-dependent) + required: false + schema: { type: string } + responses: + "200": + description: OK + "400": + description: Invalid parameters + "404": + description: Stream not found + /api/dvrip: get: @@ -458,8 +829,7 @@ paths: description: "[Source: DVRIP](https://github.com/AlexxIT/go2rtc#source-dvrip)" tags: [ Discovery ] responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } /api/ffmpeg/devices: get: @@ -467,56 +837,275 @@ paths: description: "[Source: FFmpeg Device](https://github.com/AlexxIT/go2rtc#source-ffmpeg-device)" tags: [ Discovery ] responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } + /api/ffmpeg/hardware: get: summary: FFmpeg hardware transcoding discovery description: "[Hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration)" tags: [ Discovery ] responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } + + /api/v4l2: + get: + summary: V4L2 video devices discovery (Linux) + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/alsa: + get: + summary: ALSA audio devices discovery (Linux) + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/gopro: + get: + summary: GoPro cameras discovery + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/ring: + get: + summary: Ring cameras discovery + description: | + Provide either `email`/`password` (and optional `code` for 2FA) or `refresh_token`. + If 2FA is required, returns a JSON prompt instead of sources. + tags: [ Discovery ] + parameters: + - name: email + in: query + required: false + schema: { type: string } + - name: password + in: query + required: false + schema: { type: string } + - name: code + in: query + required: false + schema: { type: string } + - name: refresh_token + in: query + required: false + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: { example: "" } + + /api/tuya: + get: + summary: Tuya cameras discovery + tags: [ Discovery ] + parameters: + - name: region + in: query + description: Tuya API host (region) + required: true + schema: { type: string } + example: "openapi.tuyaus.com" + - name: email + in: query + required: true + schema: { type: string } + - name: password + in: query + required: true + schema: { type: string } + responses: + "200": { $ref: "#/components/responses/discovery" } + "400": + description: Invalid parameters + "404": + description: No cameras found + /api/hass: get: summary: Home Assistant cameras discovery description: "[Source: Hass](https://github.com/AlexxIT/go2rtc#source-hass)" tags: [ Discovery ] responses: - default: - description: Default response - /api/homekit: + "200": { $ref: "#/components/responses/discovery" } + "404": { description: No Hass config } + + /api/discovery/homekit: get: summary: HomeKit cameras discovery description: "[Source: HomeKit](https://github.com/AlexxIT/go2rtc#source-homekit)" tags: [ Discovery ] responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } + /api/nest: get: summary: Nest cameras discovery tags: [ Discovery ] + parameters: + - name: client_id + in: query + required: true + schema: { type: string } + - name: client_secret + in: query + required: true + schema: { type: string } + - name: refresh_token + in: query + required: true + schema: { type: string } + - name: project_id + in: query + required: true + schema: { type: string } responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } + /api/onvif: get: summary: ONVIF cameras discovery description: "[Source: ONVIF](https://github.com/AlexxIT/go2rtc#source-onvif)" tags: [ Discovery ] + parameters: + - name: src + in: query + description: Optional ONVIF device URL to enumerate profiles + required: false + schema: { type: string } + example: "onvif://user:pass@192.168.1.50:80" responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } + /api/roborock: get: - summary: Roborock vacuums discovery + summary: Roborock vacuums discovery (requires prior auth) description: "[Source: Roborock](https://github.com/AlexxIT/go2rtc#source-roborock)" tags: [ Discovery ] responses: - default: - description: Default response + "200": { $ref: "#/components/responses/discovery" } + "404": + description: No auth + post: + summary: Roborock login and discovery + tags: [ Discovery ] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + username: { type: string } + password: { type: string } + required: [ username, password ] + responses: + "200": { $ref: "#/components/responses/discovery" } + /api/homekit: + get: + summary: Get HomeKit servers state + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Optional stream name (server ID) + required: false + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: { example: "" } + "404": + description: Server not found + post: + summary: Pair HomeKit camera and create/update stream + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Stream name to create/update + required: true + schema: { type: string } + - name: src + in: query + description: HomeKit URL (without pin) + required: true + schema: { type: string } + - name: pin + in: query + description: HomeKit PIN + required: true + schema: { type: string } + responses: + "200": + description: OK + delete: + summary: Unpair HomeKit camera and delete stream + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Stream name / server ID + required: true + schema: { type: string } + responses: + "200": + description: OK + "404": + description: Stream not found + + /api/homekit/accessories: + get: + summary: Get HomeKit accessories JSON for a stream + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Stream name + required: true + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: { example: { } } + "404": + description: Stream not found + + /pair-setup: + post: + summary: HomeKit Pair Setup (HAP) + description: HomeKit Accessory Protocol endpoint (TLV8). + tags: [ HomeKit ] + requestBody: + required: true + content: + application/pairing+tlv8: { example: "" } + responses: + "200": + description: OK + content: + application/pairing+tlv8: { example: "" } + + /pair-verify: + post: + summary: HomeKit Pair Verify (HAP) + description: HomeKit Accessory Protocol endpoint (TLV8). + tags: [ HomeKit ] + requestBody: + required: true + content: + application/pairing+tlv8: { example: "" } + responses: + "200": + description: OK + content: + application/pairing+tlv8: { example: "" } /onvif/: @@ -525,8 +1114,8 @@ paths: description: Simple realisation of the ONVIF protocol. Accepts any suburl requests tags: [ ONVIF ] responses: - default: - description: Default response + default: + description: "" @@ -536,8 +1125,33 @@ paths: description: Simple API for support [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration tags: [ RTSPtoWebRTC ] responses: - default: - description: Default response + default: + description: "" + + + /api/ws: + get: + summary: WebSocket endpoint + description: | + Upgrade to WebSocket and exchange JSON messages: + - Request: `{ "type": "...", "value": ... }` + - Response: `{ "type": "...", "value": ... }` + + Supported message types depend on enabled modules (see `api/README.md`). + tags: [ WebSocket ] + parameters: + - name: src + in: query + description: Stream name (consumer) + required: false + schema: { type: string } + - name: dst + in: query + description: Stream name (producer) + required: false + schema: { type: string } + responses: + "101": { description: Switching Protocols } @@ -556,15 +1170,13 @@ paths: - $ref: "#/components/parameters/stream_src_path" responses: 200: { $ref: "#/components/responses/webtorrent" } - delete: summary: Delete WebTorrent share tags: [ WebTorrent ] parameters: - $ref: "#/components/parameters/stream_src_path" responses: - default: - description: Default response + default: { description: "" } /api/webtorrent: get: diff --git a/docker/Dockerfile b/docker/Dockerfile index 8d064f21..9efded4b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -47,6 +47,7 @@ RUN if [ "${TARGETARCH}" = "amd64" ]; then apk add --no-cache libva-intel-driver COPY --from=build /build/go2rtc /usr/local/bin/ +EXPOSE 1984 8554 8555 8555/udp ENTRYPOINT ["/sbin/tini", "--"] VOLUME /config WORKDIR /config diff --git a/docker/hardware.Dockerfile b/docker/hardware.Dockerfile index a80d08d7..563843b5 100644 --- a/docker/hardware.Dockerfile +++ b/docker/hardware.Dockerfile @@ -49,6 +49,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,t COPY --from=build /build/go2rtc /usr/local/bin/ +EXPOSE 1984 8554 8555 8555/udp ENTRYPOINT ["/usr/bin/tini", "--"] VOLUME /config WORKDIR /config diff --git a/docker/rockchip.Dockerfile b/docker/rockchip.Dockerfile index 949db83b..6ab924ee 100644 --- a/docker/rockchip.Dockerfile +++ b/docker/rockchip.Dockerfile @@ -43,6 +43,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,t COPY --from=build /build/go2rtc /usr/local/bin/ ADD --chmod=755 https://github.com/MarcA711/Rockchip-FFmpeg-Builds/releases/download/6.1-8-no_extra_dump/ffmpeg /usr/local/bin +EXPOSE 1984 8554 8555 8555/udp ENTRYPOINT ["/usr/bin/tini", "--"] VOLUME /config WORKDIR /config diff --git a/examples/mod_pinggy/go.mod b/examples/mod_pinggy/go.mod new file mode 100644 index 00000000..893e6011 --- /dev/null +++ b/examples/mod_pinggy/go.mod @@ -0,0 +1,9 @@ +module pinggy + +go 1.25 + +require ( + github.com/Pinggy-io/pinggy-go/pinggy v0.6.9 // indirect + golang.org/x/crypto v0.8.0 // indirect + golang.org/x/sys v0.7.0 // indirect +) diff --git a/examples/mod_pinggy/go.sum b/examples/mod_pinggy/go.sum new file mode 100644 index 00000000..05298fb4 --- /dev/null +++ b/examples/mod_pinggy/go.sum @@ -0,0 +1,39 @@ +github.com/Pinggy-io/pinggy-go/pinggy v0.6.9 h1:lzZ00JK6BUGQXnpkJZ+cVj8kIkXsmiVBUci9uEkSwEY= +github.com/Pinggy-io/pinggy-go/pinggy v0.6.9/go.mod h1:V1Sxb+4zyr36o9atZiqtT4XhsKtW1RSb2GvsbTbTJYw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/examples/mod_pinggy/main.go b/examples/mod_pinggy/main.go new file mode 100644 index 00000000..f4942632 --- /dev/null +++ b/examples/mod_pinggy/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "log" + "os" + + "github.com/Pinggy-io/pinggy-go/pinggy" +) + +func main() { + tunType := os.Args[1] + address := os.Args[2] + + log.SetFlags(log.Llongfile | log.LstdFlags) + + config := pinggy.Config{ + Type: pinggy.TunnelType(tunType), + TcpForwardingAddr: address, + + //SshOverSsl: true, + //Stdout: os.Stderr, + //Stderr: os.Stderr, + } + + if tunType == "http" { + hman := pinggy.CreateHeaderManipulationAndAuthConfig() + //hman.SetReverseProxy(address) + //hman.SetPassPreflight(true) + //hman.SetNoReverseProxy() + config.HeaderManipulationAndAuth = hman + } + + pl, err := pinggy.ConnectWithConfig(config) + if err != nil { + log.Panicln(err) + } + log.Println("Addrs: ", pl.RemoteUrls()) + //err = pl.InitiateWebDebug("localhost:3424") + //log.Println(err) + pl.StartForwarding() +} diff --git a/examples/tutk_decoder/README.md b/examples/tutk_decoder/README.md new file mode 100644 index 00000000..197bd820 --- /dev/null +++ b/examples/tutk_decoder/README.md @@ -0,0 +1,5 @@ +# tutk_decoder + +1. Wireshark > Select any packet > Follow > UDP Stream +2. Wireshark > File > Export Packet Dissections > As JSON > Displayed, Values +3. `tutk_decoder wireshark.json decoded.txt` diff --git a/examples/tutk_decoder/main.go b/examples/tutk_decoder/main.go new file mode 100644 index 00000000..0b6d90a9 --- /dev/null +++ b/examples/tutk_decoder/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "log" + "os" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/tutk" +) + +func main() { + if len(os.Args) != 3 { + fmt.Println("Usage: tutk_decoder wireshark.json decoded.txt") + return + } + + src, err := os.Open(os.Args[1]) + if err != nil { + log.Fatal(err) + } + defer src.Close() + + dst, err := os.Create(os.Args[2]) + if err != nil { + log.Fatal(err) + } + defer dst.Close() + + var items []item + if err = json.NewDecoder(src).Decode(&items); err != nil { + log.Fatal(err) + } + + var b []byte + + for _, v := range items { + if v.Source.Layers.Data.DataData == "" { + continue + } + + s := strings.ReplaceAll(v.Source.Layers.Data.DataData, ":", "") + b, err = hex.DecodeString(s) + if err != nil { + log.Fatal(err) + } + + tutk.ReverseTransCodePartial(b, b) + + ts := v.Source.Layers.Frame.FrameTimeRelative + + _, _ = fmt.Fprintf(dst, "%8s: %s -> %s [%4d] %x\n", + ts[:len(ts)-6], + v.Source.Layers.Ip.IpSrc, v.Source.Layers.Ip.IpDst, + len(b), b) + } +} + +type item struct { + Source struct { + Layers struct { + Frame struct { + FrameTimeRelative string `json:"frame.time_relative"` + FrameNumber string `json:"frame.number"` + } `json:"frame"` + Ip struct { + IpSrc string `json:"ip.src"` + IpDst string `json:"ip.dst"` + } `json:"ip"` + Udp struct { + UdpSrcport string `json:"udp.srcport"` + UdpDstport string `json:"udp.dstport"` + } `json:"udp"` + Data struct { + DataData string `json:"data.data"` + DataLen string `json:"data.len"` + } `json:"data"` + } `json:"layers"` + } `json:"_source"` +} diff --git a/go.mod b/go.mod index b67161ed..485509e6 100644 --- a/go.mod +++ b/go.mod @@ -3,47 +3,50 @@ module github.com/AlexxIT/go2rtc go 1.24.0 require ( - github.com/asticode/go-astits v1.13.0 - github.com/expr-lang/expr v1.17.6 + github.com/asticode/go-astits v1.14.0 + github.com/eclipse/paho.mqtt.golang v1.5.1 + github.com/expr-lang/expr v1.17.7 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/mattn/go-isatty v0.0.20 - github.com/miekg/dns v1.1.68 - github.com/pion/ice/v4 v4.0.10 - github.com/pion/interceptor v0.1.41 + github.com/miekg/dns v1.1.70 + github.com/pion/dtls/v3 v3.0.10 + github.com/pion/ice/v4 v4.2.0 + github.com/pion/interceptor v0.1.43 github.com/pion/rtcp v1.2.16 - github.com/pion/rtp v1.8.24 - github.com/pion/sdp/v3 v3.0.16 - github.com/pion/srtp/v3 v3.0.8 - github.com/pion/stun/v3 v3.0.0 - github.com/pion/webrtc/v4 v4.1.6 + github.com/pion/rtp v1.10.0 + github.com/pion/sdp/v3 v3.0.17 + github.com/pion/srtp/v3 v3.0.10 + github.com/pion/stun/v3 v3.1.1 + github.com/pion/webrtc/v4 v4.2.3 github.com/rs/zerolog v1.34.0 github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f github.com/stretchr/testify v1.11.1 github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 - golang.org/x/crypto v0.43.0 + golang.org/x/crypto v0.47.0 + golang.org/x/net v0.49.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/asticode/go-astikit v0.56.0 // indirect + github.com/asticode/go-astikit v0.57.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/pion/datachannel v1.5.10 // indirect - github.com/pion/dtls/v3 v3.0.7 // indirect + github.com/pion/datachannel v1.6.0 // indirect github.com/pion/logging v0.2.4 // indirect - github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/sctp v1.8.40 // indirect - github.com/pion/transport/v3 v3.0.8 // indirect - github.com/pion/turn/v4 v4.1.1 // indirect + github.com/pion/sctp v1.9.2 // indirect + github.com/pion/transport/v3 v3.1.1 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/turn/v4 v4.1.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.46.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index c76fdb56..897bb8a2 100644 --- a/go.sum +++ b/go.sum @@ -1,22 +1,22 @@ github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= -github.com/asticode/go-astikit v0.54.0 h1:uq9eurgisdkYwJU9vSWIQaPH4MH0cac82sQH00kmSNQ= -github.com/asticode/go-astikit v0.54.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= -github.com/asticode/go-astikit v0.56.0 h1:DmD2p7YnvxiPdF0h+dRmos3bsejNEXbycENsY5JfBqw= -github.com/asticode/go-astikit v0.56.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= -github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c= -github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= +github.com/asticode/go-astikit v0.57.1 h1:fEykwH98Nny08kcRbk4uer+S8h0rKveCIpG9F6NVLuA= +github.com/asticode/go-astikit v0.57.1/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= +github.com/asticode/go-astits v1.14.0 h1:zkgnZzipx2XX5mWycqsSBeEyDH58+i4HtyF4j2ROb00= +github.com/asticode/go-astits v1.14.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/expr-lang/expr v1.17.2 h1:o0A99O/Px+/DTjEnQiodAgOIK9PPxL8DtXhBRKC+Iso= -github.com/expr-lang/expr v1.17.2/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= -github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= -github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec= github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8= +github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -32,82 +32,66 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= -github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= -github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= -github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= -github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= -github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= +github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= +github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA= +github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= -github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= -github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= -github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= -github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= -github.com/pion/ice/v4 v4.0.9 h1:VKgU4MwA2LUDVLq+WBkpEHTcAb8c5iCvFMECeuPOZNk= -github.com/pion/ice/v4 v4.0.9/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= -github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= -github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= -github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= -github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= -github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= -github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= -github.com/pion/interceptor v0.1.41 h1:NpvX3HgWIukTf2yTBVjVGFXtpSpWgXjqz7IIpu7NsOw= -github.com/pion/interceptor v0.1.41/go.mod h1:nEt4187unvRXJFyjiw00GKo+kIuXMWQI9K89fsosDLY= -github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= -github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/dtls/v3 v3.0.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM= +github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os= +github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg= +github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8= +github.com/pion/ice/v4 v4.1.0 h1:YlxIii2bTPWyC08/4hdmtYq4srbrY0T9xcTsTjldGqU= +github.com/pion/ice/v4 v4.1.0/go.mod h1:5gPbzYxqenvn05k7zKPIZFuSAufolygiy6P1U9HzvZ4= +github.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw= +github.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4= +github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ= +github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU= +github.com/pion/interceptor v0.1.43 h1:6hmRfnmjogSs300xfkR0JxYFZ9k5blTEvCD7wxEDuNQ= +github.com/pion/interceptor v0.1.43/go.mod h1:BSiC1qKIJt1XVr3l3xQ2GEmCFStk9tx8fwtCZxxgR7M= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= -github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= -github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= -github.com/pion/rtp v1.8.13 h1:8uSUPpjSL4OlwZI8Ygqu7+h2p9NPFB+yAZ461Xn5sNg= -github.com/pion/rtp v1.8.13/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= -github.com/pion/rtp v1.8.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI= -github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= -github.com/pion/rtp v1.8.24 h1:+ICyZXUQDv95EsHN70RrA4XKJf5MGWyC6QQc1u6/ynI= -github.com/pion/rtp v1.8.24/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= -github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs= -github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= -github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= -github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= -github.com/pion/sctp v1.8.40 h1:bqbgWYOrUhsYItEnRObUYZuzvOMsVplS3oNgzedBlG8= -github.com/pion/sctp v1.8.40/go.mod h1:SPBBUENXE6ThkEksN5ZavfAhFYll+h+66ZiG6IZQuzo= -github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI= -github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= -github.com/pion/sdp/v3 v3.0.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI= -github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= +github.com/pion/rtp v1.8.26 h1:VB+ESQFQhBXFytD+Gk8cxB6dXeVf2WQzg4aORvAvAAc= +github.com/pion/rtp v1.8.26/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/rtp v1.10.0 h1:XN/xca4ho6ZEcijpdF2VGFbwuHUfiIMf3ew8eAAE43w= +github.com/pion/rtp v1.10.0/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/sctp v1.8.41 h1:20R4OHAno4Vky3/iE4xccInAScAa83X6nWUfyc65MIs= +github.com/pion/sctp v1.8.41/go.mod h1:2wO6HBycUH7iCssuGyc2e9+0giXVW0pyCv3ZuL8LiyY= +github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo= +github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8= github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo= github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= -github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= -github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= -github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= -github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= -github.com/pion/srtp/v3 v3.0.8 h1:RjRrjcIeQsilPzxvdaElN0CpuQZdMvcl9VZ5UY9suUM= -github.com/pion/srtp/v3 v3.0.8/go.mod h1:2Sq6YnDH7/UDCvkSoHSDNDeyBcFgWL0sAVycVbAsXFg= -github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= -github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= -github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= -github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/transport/v3 v3.0.8 h1:oI3myyYnTKUSTthu/NZZ8eu2I5sHbxbUNNFW62olaYc= -github.com/pion/transport/v3 v3.0.8/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= -github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= -github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= -github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps= -github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= -github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc= -github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= -github.com/pion/webrtc/v4 v4.0.14 h1:nyds/sFRR+HvmWoBa6wrL46sSfpArE0qR883MBW96lg= -github.com/pion/webrtc/v4 v4.0.14/go.mod h1:R3+qTnQTS03UzwDarYecgioNf7DYgTsldxnCXB821Kk= -github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c= -github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM= -github.com/pion/webrtc/v4 v4.1.6 h1:srHH2HwvCGwPba25EYJgUzgLqCQoXl1VCUnrGQMSzUw= -github.com/pion/webrtc/v4 v4.1.6/go.mod h1:wKecGRlkl3ox/As/MYghJL+b/cVXMEhoPMJWPuGQFhU= +github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo= +github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= +github.com/pion/srtp/v3 v3.0.9 h1:lRGF4G61xxj+m/YluB3ZnBpiALSri2lTzba0kGZMrQY= +github.com/pion/srtp/v3 v3.0.9/go.mod h1:E+AuWd7Ug2Fp5u38MKnhduvpVkveXJX6J4Lq4rxUYt8= +github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ= +github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M= +github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU= +github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA= +github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= +github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/turn/v4 v4.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA= +github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A= +github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= +github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= +github.com/pion/webrtc/v4 v4.1.8 h1:ynkjfiURDQ1+8EcJsoa60yumHAmyeYjz08AaOuor+sk= +github.com/pion/webrtc/v4 v4.1.8/go.mod h1:KVaARG2RN0lZx0jc7AWTe38JpPv+1/KicOZ9jN52J/s= +github.com/pion/webrtc/v4 v4.2.3 h1:RtdWDnkenNQGxUrZqWa5gSkTm5ncsLg5d+zu0M4cXt4= +github.com/pion/webrtc/v4 v4.2.3/go.mod h1:7vsyFzRzaKP5IELUnj8zLcglPyIT6wWwqTppBZ1k6Kc= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= @@ -124,55 +108,45 @@ github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxI github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDfahT5kd61wLAF5AAeh5ZPLVI4JJ/tYo8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= -golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= -golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/api.go b/internal/api/api.go index 419e2bdf..dfb65117 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "os" + "slices" "strconv" "strings" "sync" @@ -23,6 +24,7 @@ func Init() { Listen string `yaml:"listen"` Username string `yaml:"username"` Password string `yaml:"password"` + LocalAuth bool `yaml:"local_auth"` BasePath string `yaml:"base_path"` StaticDir string `yaml:"static_dir"` Origin string `yaml:"origin"` @@ -30,6 +32,8 @@ func Init() { TLSCert string `yaml:"tls_cert"` TLSKey string `yaml:"tls_key"` UnixListen string `yaml:"unix_listen"` + + AllowPaths []string `yaml:"allow_paths"` } `yaml:"api"` } @@ -43,6 +47,7 @@ func Init() { return } + allowPaths = cfg.Mod.AllowPaths basePath = cfg.Mod.BasePath log = app.GetLogger("api") @@ -61,7 +66,7 @@ func Init() { } if cfg.Mod.Username != "" { - Handler = middlewareAuth(cfg.Mod.Username, cfg.Mod.Password, Handler) // 2nd + Handler = middlewareAuth(cfg.Mod.Username, cfg.Mod.Password, cfg.Mod.LocalAuth, Handler) // 2nd } if log.Trace().Enabled() { @@ -152,6 +157,10 @@ func HandleFunc(pattern string, handler http.HandlerFunc) { if len(pattern) == 0 || pattern[0] != '/' { pattern = basePath + "/" + pattern } + if allowPaths != nil && !slices.Contains(allowPaths, pattern) { + log.Trace().Str("path", pattern).Msg("[api] ignore path not in allow_paths") + return + } log.Trace().Str("path", pattern).Msg("[api] register path") http.HandleFunc(pattern, handler) } @@ -185,6 +194,7 @@ func Response(w http.ResponseWriter, body any, contentType string) { const StreamNotFound = "stream not found" +var allowPaths []string var basePath string var log zerolog.Logger @@ -195,9 +205,13 @@ func middlewareLog(next http.Handler) http.Handler { }) } -func middlewareAuth(username, password string, next http.Handler) http.Handler { +func isLoopback(remoteAddr string) bool { + return strings.HasPrefix(remoteAddr, "127.") || strings.HasPrefix(remoteAddr, "[::1]") || remoteAddr == "@" +} + +func middlewareAuth(username, password string, localAuth bool, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.HasPrefix(r.RemoteAddr, "127.") && !strings.HasPrefix(r.RemoteAddr, "[::1]") && r.RemoteAddr != "@" { + if localAuth || !isLoopback(r.RemoteAddr) { user, pass, ok := r.BasicAuth() if !ok || user != username || pass != password { w.Header().Set("Www-Authenticate", `Basic realm="go2rtc"`) diff --git a/internal/app/app.go b/internal/app/app.go index eb803584..cbce37e3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,6 +11,7 @@ import ( var ( Version string + Modules []string UserAgent string ConfigPath string Info = make(map[string]any) @@ -76,6 +77,16 @@ func Init() { if ConfigPath != "" { Logger.Info().Str("path", ConfigPath).Msg("config") } + + var cfg struct { + Mod struct { + Modules []string `yaml:"modules"` + } `yaml:"app"` + } + + LoadConfig(&cfg) + + Modules = cfg.Mod.Modules } func readRevisionTime() (revision, vcsTime string) { @@ -92,10 +103,20 @@ func readRevisionTime() (revision, vcsTime string) { vcsTime = setting.Value case "vcs.modified": if setting.Value == "true" { - revision = "mod." + revision + revision += ".dirty" } } } + + // Check version from -buildvcs info + // Format for tagged version : v1.9.13 + // Format for modified code: v1.9.14-0.20251215184105-753d6617ab58+dirty + if info.Main.Version != "v"+Version { + // Format: 1.9.13+dev.753d661[.dirty] + // Compatible with "awesomeversion" and "packaging.version" from python. + // Version will be larger than the previous release, but smaller than the next release. + Version += "+dev." + revision + } } return } diff --git a/internal/app/log.go b/internal/app/log.go index 9ec89a2c..61fd474c 100644 --- a/internal/app/log.go +++ b/internal/app/log.go @@ -14,6 +14,8 @@ import ( var MemoryLog = newBuffer() func GetLogger(module string) zerolog.Logger { + Logger.Trace().Str("module", module).Msgf("[log] init") + if s, ok := modules[module]; ok { lvl, err := zerolog.ParseLevel(s) if err == nil { diff --git a/internal/doorbird/README.md b/internal/doorbird/README.md new file mode 100644 index 00000000..7c31efae --- /dev/null +++ b/internal/doorbird/README.md @@ -0,0 +1,21 @@ +# Doorbird + +*[added in v1.9.8](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.11)* + +This source type supports [Doorbird](https://www.doorbird.com/) devices including MJPEG stream, audio stream as well as two-way audio. + +It is recommended to create a sepearate user within your doorbird setup for go2rtc. Minimum permissions for the user are: + +- Watch always +- API operator + +## Configuration + +```yaml +streams: + doorbird1: + - rtsp://admin:password@192.168.1.123:8557/mpeg/720p/media.amp # RTSP stream + - doorbird://admin:password@192.168.1.123?media=video # MJPEG stream + - doorbird://admin:password@192.168.1.123?media=audio # audio stream + - doorbird://admin:password@192.168.1.123 # two-way audio +``` diff --git a/internal/echo/echo.go b/internal/echo/echo.go index fb105cec..f33982fa 100644 --- a/internal/echo/echo.go +++ b/internal/echo/echo.go @@ -2,7 +2,9 @@ package echo import ( "bytes" + "errors" "os/exec" + "slices" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/streams" @@ -10,11 +12,25 @@ import ( ) func Init() { + var cfg struct { + Mod struct { + AllowPaths []string `yaml:"allow_paths"` + } `yaml:"echo"` + } + + app.LoadConfig(&cfg) + + allowPaths := cfg.Mod.AllowPaths + log := app.GetLogger("echo") streams.RedirectFunc("echo", func(url string) (string, error) { args := shell.QuoteSplit(url[5:]) + if allowPaths != nil && !slices.Contains(allowPaths, args[0]) { + return "", errors.New("echo: bin not in allow_paths: " + args[0]) + } + b, err := exec.Command(args[0], args[1:]...).Output() if err != nil { return "", err @@ -26,4 +42,5 @@ func Init() { return string(b), nil }) + streams.MarkInsecure("echo") } diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 711be8a2..e428aefb 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -9,6 +9,7 @@ import ( "io" "net/url" "os" + "slices" "strings" "sync" "syscall" @@ -26,6 +27,16 @@ import ( ) func Init() { + var cfg struct { + Mod struct { + AllowPaths []string `yaml:"allow_paths"` + } `yaml:"exec"` + } + + app.LoadConfig(&cfg) + + allowPaths = cfg.Mod.AllowPaths + rtsp.HandleFunc(func(conn *pkg.Conn) bool { waitersMu.Lock() waiter := waiters[conn.URL.Path] @@ -45,10 +56,13 @@ func Init() { }) streams.HandleFunc("exec", execHandle) + streams.MarkInsecure("exec") log = app.GetLogger("exec") } +var allowPaths []string + func execHandle(rawURL string) (prod core.Producer, err error) { rawURL, rawQuery, _ := strings.Cut(rawURL, "#") query := streams.ParseQuery(rawQuery) @@ -73,6 +87,11 @@ func execHandle(rawURL string) (prod core.Producer, err error) { debug: log.Debug().Enabled(), } + if allowPaths != nil && !slices.Contains(allowPaths, cmd.Args[0]) { + _ = cmd.Close() + return nil, errors.New("exec: bin not in allow_paths: " + cmd.Args[0]) + } + if s := query.Get("killsignal"); s != "" { sig := syscall.Signal(core.Atoi(s)) cmd.Cancel = func() error { @@ -89,10 +108,17 @@ func execHandle(rawURL string) (prod core.Producer, err error) { return pcm.NewBackchannel(cmd, query.Get("audio")) } + var timeout time.Duration + if s := query.Get("starttimeout"); s != "" { + timeout = time.Duration(core.Atoi(s)) * time.Second + } else { + timeout = 30 * time.Second + } + if path == "" { prod, err = handlePipe(rawURL, cmd) } else { - prod, err = handleRTSP(rawURL, cmd, path) + prod, err = handleRTSP(rawURL, cmd, path, timeout) } if err != nil { @@ -141,7 +167,7 @@ func handlePipe(source string, cmd *shell.Command) (core.Producer, error) { return prod, nil } -func handleRTSP(source string, cmd *shell.Command, path string) (core.Producer, error) { +func handleRTSP(source string, cmd *shell.Command, path string, timeout time.Duration) (core.Producer, error) { if log.Trace().Enabled() { cmd.Stdout = os.Stdout } @@ -167,11 +193,11 @@ func handleRTSP(source string, cmd *shell.Command, path string) (core.Producer, return nil, err } - timeout := time.NewTimer(30 * time.Second) - defer timeout.Stop() + timer := time.NewTimer(timeout) + defer timer.Stop() select { - case <-timeout.C: + case <-timer.C: // haven't received data from app in timeout log.Error().Str("source", source).Msg("[exec] timeout") return nil, errors.New("exec: timeout") diff --git a/internal/expr/README.md b/internal/expr/README.md index 35e3c612..db5a27d4 100644 --- a/internal/expr/README.md +++ b/internal/expr/README.md @@ -12,34 +12,94 @@ - `fetch` - JS-like HTTP requests - `match` - JS-like RegExp queries -## Examples +## Fetch examples + +Multiple fetch requests are executed within a single session. They share the same cookie. + +**HTTP GET** + +```js +var r = fetch('https://example.org/products.json'); +``` + +**HTTP POST JSON** + +```js +var r = fetch('https://example.org/post', { + method: 'POST', + // Content-Type: application/json will be set automatically + json: {username: 'example'} +}); +``` + +**HTTP POST Form** + +```js +var r = fetch('https://example.org/post', { + method: 'POST', + // Content-Type: application/x-www-form-urlencoded will be set automatically + data: {username: 'example', password: 'password'} +}); +``` + +## Script examples **Two way audio for Dahua VTO** ```yaml streams: dahua_vto: | - expr: let host = "admin:password@192.168.1.123"; - fetch("http://"+host+"/cgi-bin/configManager.cgi?action=setConfig&Encode[0].MainFormat[0].Audio.Compression=G.711A&Encode[0].MainFormat[0].Audio.Frequency=8000").ok - ? "rtsp://"+host+"/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif" : "" + expr: + let host = 'admin:password@192.168.1.123'; + + var r = fetch('http://' + host + '/cgi-bin/configManager.cgi?action=setConfig&Encode[0].MainFormat[0].Audio.Compression=G.711A&Encode[0].MainFormat[0].Audio.Frequency=8000'); + + 'rtsp://' + host + '/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif' ``` **dom.ru** -You can get credentials via: - -- https://github.com/alexmorbo/domru (file `/share/domru/accounts`) -- https://github.com/ad/domru +You can get credentials from https://github.com/ad/domru ```yaml streams: dom_ru: | - expr: let camera = "99999999"; let token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; let operator = 99; - fetch("https://myhome.novotelecom.ru/rest/v1/forpost/cameras/"+camera+"/video", { - headers: {Authorization: "Bearer "+token, Operator: operator} + expr: + let camera = '***'; + let token = '***'; + let operator = '***'; + + fetch('https://myhome.proptech.ru/rest/v1/forpost/cameras/' + camera + '/video', { + headers: { + 'Authorization': 'Bearer ' + token, + 'User-Agent': 'Google sdkgphone64x8664 | Android 14 | erth | 8.26.0 (82600010) | 0 | 0 | 0', + 'Operator': operator + } }).json().data.URL ``` +**dom.ufanet.ru** + +```yaml +streams: + ufanet_ru: | + expr: + let username = '***'; + let password = '***'; + let cameraid = '***'; + + let r1 = fetch('https://ucams.ufanet.ru/api/internal/login/', { + method: 'POST', + data: {username: username, password: password} + }); + let r2 = fetch('https://ucams.ufanet.ru/api/v0/cameras/this/?lang=ru', { + method: 'POST', + json: {'fields': ['token_l', 'server'], 'token_l_ttl': 3600, 'numbers': [cameraid]}, + }).json().results[0]; + + 'rtsp://' + r2.server.domain + '/' + r2.number + '?token=' + r2.token_l +``` + **Parse HLS files from Apple** Same example in two languages - python and expr. diff --git a/internal/expr/expr.go b/internal/expr/expr.go index 8fd6c9c2..60d32a84 100644 --- a/internal/expr/expr.go +++ b/internal/expr/expr.go @@ -25,4 +25,5 @@ func Init() { return url, nil }) + streams.MarkInsecure("expr") } diff --git a/internal/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go index 242c151d..a3d589b1 100644 --- a/internal/ffmpeg/ffmpeg.go +++ b/internal/ffmpeg/ffmpeg.go @@ -58,15 +58,15 @@ func Init() { } var defaults = map[string]string{ - "bin": "ffmpeg", - "global": "-hide_banner", + "bin": "ffmpeg", + "global": "-hide_banner", + "timeout": "5", // inputs - "file": "-re -i {input}", - "http": "-fflags nobuffer -flags low_delay -i {input}", - "rtsp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i {input}", - - "rtsp/udp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i {input}", + "file": "-re -i {input}", + "http": "-fflags nobuffer -flags low_delay -i {input}", + "rtsp": "-fflags nobuffer -flags low_delay -timeout {timeout} -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i {input}", + "rtsp/udp": "-fflags nobuffer -flags low_delay -timeout {timeout} -user_agent go2rtc/ffmpeg -i {input}", // output "output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}", @@ -169,6 +169,13 @@ func inputTemplate(name, s string, query url.Values) string { } else { template = defaults[name] } + if strings.Contains(template, "{timeout}") { + timeout := query.Get("timeout") + if timeout == "" { + timeout = defaults["timeout"] + } + template = strings.Replace(template, "{timeout}", timeout+"000000", 1) + } return strings.Replace(template, "{input}", s, 1) } diff --git a/internal/ffmpeg/ffmpeg_test.go b/internal/ffmpeg/ffmpeg_test.go index 30052d78..b9d02183 100644 --- a/internal/ffmpeg/ffmpeg_test.go +++ b/internal/ffmpeg/ffmpeg_test.go @@ -123,6 +123,11 @@ func TestParseArgsIpCam(t *testing.T) { source: "rtmp://example.com#input=rtsp/udp", expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i rtmp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, }, + { + name: "[RTSP] custom timeout", + source: "rtsp://example.com#timeout=10", + expect: `ffmpeg -hide_banner -allowed_media_types video+audio -fflags nobuffer -flags low_delay -timeout 10000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/ffmpeg/producer.go b/internal/ffmpeg/producer.go index 97cf3d5c..fb044467 100644 --- a/internal/ffmpeg/producer.go +++ b/internal/ffmpeg/producer.go @@ -46,6 +46,7 @@ func NewProducer(url string) (core.Producer, error) { {Name: core.CodecPCM, ClockRate: 16000}, {Name: core.CodecPCMA, ClockRate: 16000}, {Name: core.CodecPCMU, ClockRate: 16000}, + {Name: core.CodecPCML, ClockRate: 8000}, {Name: core.CodecPCM, ClockRate: 8000}, {Name: core.CodecPCMA, ClockRate: 8000}, {Name: core.CodecPCMU, ClockRate: 8000}, @@ -95,11 +96,11 @@ func (p *Producer) newURL() string { codec := receiver.Codec switch codec.Name { case core.CodecOpus: - s += "#audio=opus" + s += "#audio=opus/16000" case core.CodecAAC: s += "#audio=aac/16000" case core.CodecPCML: - s += "#audio=pcml/16000" + s += "#audio=pcml/" + strconv.Itoa(int(codec.ClockRate)) case core.CodecPCM: s += "#audio=pcm/" + strconv.Itoa(int(codec.ClockRate)) case core.CodecPCMA: diff --git a/internal/hass/api.go b/internal/hass/api.go index e3de23b3..9f110fc8 100644 --- a/internal/hass/api.go +++ b/internal/hass/api.go @@ -30,10 +30,10 @@ func apiStream(w http.ResponseWriter, r *http.Request) { // 1. link to go2rtc stream: rtsp://...:8554/{stream_name} // 2. static link to Hass camera // 3. dynamic link to Hass camera - if streams.Patch(v.Name, v.Channels.First.Url) != nil { + if _, err := streams.Patch(v.Name, v.Channels.First.Url); err == nil { apiOK(w, r) } else { - http.Error(w, "", http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) } // /stream/{id}/channel/0/webrtc diff --git a/internal/hass/hass.go b/internal/hass/hass.go index ea172b02..99c63692 100644 --- a/internal/hass/hass.go +++ b/internal/hass/hass.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path" + "strings" "sync" "github.com/AlexxIT/go2rtc/internal/api" @@ -37,8 +38,13 @@ func Init() { api.HandleFunc("/streams", apiOK) api.HandleFunc("/stream/", apiStream) - streams.RedirectFunc("hass", func(url string) (string, error) { - if location := entities[url[5:]]; location != "" { + streams.RedirectFunc("hass", func(rawURL string) (string, error) { + rawURL, rawQuery, _ := strings.Cut(rawURL, "#") + + if location := entities[rawURL[5:]]; location != "" { + if rawQuery != "" { + return location + "#" + rawQuery, nil + } return location, nil } diff --git a/internal/hls/ws.go b/internal/hls/ws.go index 608f515f..00eedfe2 100644 --- a/internal/hls/ws.go +++ b/internal/hls/ws.go @@ -11,7 +11,7 @@ import ( ) func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error { - stream := streams.GetOrPatch(tr.Request.URL.Query()) + stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) if stream == nil { return errors.New(api.StreamNotFound) } diff --git a/internal/homekit/api.go b/internal/homekit/api.go index 9f76c2d6..885a40fa 100644 --- a/internal/homekit/api.go +++ b/internal/homekit/api.go @@ -3,6 +3,7 @@ package homekit import ( "errors" "fmt" + "io" "net/http" "net/url" "strings" @@ -14,56 +15,97 @@ import ( "github.com/AlexxIT/go2rtc/pkg/mdns" ) -func apiHandler(w http.ResponseWriter, r *http.Request) { +func apiDiscovery(w http.ResponseWriter, r *http.Request) { + sources, err := discovery() + if err != nil { + api.Error(w, err) + return + } + + urls := findHomeKitURLs() + for id, u := range urls { + deviceID := u.Query().Get("device_id") + for _, source := range sources { + if strings.Contains(source.URL, deviceID) { + source.Location = id + break + } + } + } + + for _, source := range sources { + if source.Location == "" { + source.Location = " " + } + } + + api.ResponseSources(w, sources) +} + +func apiHomekit(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + switch r.Method { case "GET": - sources, err := discovery() - if err != nil { - api.Error(w, err) - return - } - - urls := findHomeKitURLs() - for id, u := range urls { - deviceID := u.Query().Get("device_id") - for _, source := range sources { - if strings.Contains(source.URL, deviceID) { - source.Location = id - break - } + if id := r.Form.Get("id"); id != "" { + if srv := servers[id]; srv != nil { + api.ResponsePrettyJSON(w, srv) + } else { + http.Error(w, "server not found", http.StatusNotFound) } + } else { + api.ResponsePrettyJSON(w, servers) } - for _, source := range sources { - if source.Location == "" { - source.Location = " " - } - } - - api.ResponseSources(w, sources) - case "POST": - if err := r.ParseMultipartForm(1024); err != nil { - api.Error(w, err) - return - } - - if err := apiPair(r.Form.Get("id"), r.Form.Get("url")); err != nil { - api.Error(w, err) + id := r.Form.Get("id") + rawURL := r.Form.Get("src") + "&pin=" + r.Form.Get("pin") + if err := apiPair(id, rawURL); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) } case "DELETE": - if err := r.ParseMultipartForm(1024); err != nil { - api.Error(w, err) - return - } - - if err := apiUnpair(r.Form.Get("id")); err != nil { - api.Error(w, err) + id := r.Form.Get("id") + if err := apiUnpair(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) } } } +func apiHomekitAccessories(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + stream := streams.Get(id) + if stream == nil { + http.Error(w, "", http.StatusNotFound) + return + } + + rawURL := findHomeKitURL(stream.Sources()) + if rawURL == "" { + http.Error(w, "", http.StatusBadRequest) + return + } + + client, err := hap.Dial(rawURL) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer client.Close() + + res, err := client.Get(hap.PathAccessories) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", api.MimeJSON) + _, _ = io.Copy(w, res.Body) +} + func discovery() ([]*api.Source, error) { var sources []*api.Source diff --git a/internal/homekit/homekit.go b/internal/homekit/homekit.go index b4237211..59b84b3b 100644 --- a/internal/homekit/homekit.go +++ b/internal/homekit/homekit.go @@ -2,8 +2,6 @@ package homekit import ( "errors" - "io" - "net" "net/http" "strings" @@ -26,6 +24,7 @@ func Init() { Name string `yaml:"name"` DeviceID string `yaml:"device_id"` DevicePrivate string `yaml:"device_private"` + CategoryID string `yaml:"category_id"` Pairings []string `yaml:"pairings"` } `yaml:"homekit"` } @@ -35,12 +34,15 @@ func Init() { streams.HandleFunc("homekit", streamHandler) - api.HandleFunc("api/homekit", apiHandler) + api.HandleFunc("api/homekit", apiHomekit) + api.HandleFunc("api/homekit/accessories", apiHomekitAccessories) + api.HandleFunc("api/discovery/homekit", apiDiscovery) if cfg.Mod == nil { return } + hosts = map[string]*server{} servers = map[string]*server{} var entries []*mdns.ServiceEntry @@ -63,36 +65,19 @@ func Init() { deviceID := calcDeviceID(conf.DeviceID, id) // random MAC-address name := calcName(conf.Name, deviceID) + setupID := calcSetupID(id) srv := &server{ stream: id, - srtp: srtp.Server, pairings: conf.Pairings, + setupID: setupID, } srv.hap = &hap.Server{ - Pin: pin, - DeviceID: deviceID, - DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id), - GetPair: srv.GetPair, - AddPair: srv.AddPair, - Handler: homekit.ServerHandler(srv), - } - - if url := findHomeKitURL(stream.Sources()); url != "" { - // 1. Act as transparent proxy for HomeKit camera - dial := func() (net.Conn, error) { - client, err := homekit.Dial(url, srtp.Server) - if err != nil { - return nil, err - } - return client.Conn(), nil - } - srv.hap.Handler = homekit.ProxyHandler(srv, dial) - } else { - // 2. Act as basic HomeKit camera - srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version) - srv.hap.Handler = homekit.ServerHandler(srv) + Pin: pin, + DeviceID: deviceID, + DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id), + GetClientPublic: srv.GetPair, } srv.mdns = &mdns.ServiceEntry{ @@ -106,23 +91,32 @@ func Init() { hap.TXTProtoVersion: "1.1", hap.TXTStateNumber: "1", hap.TXTStatusFlags: hap.StatusNotPaired, - hap.TXTCategory: hap.CategoryCamera, - hap.TXTSetupHash: srv.hap.SetupHash(), + hap.TXTCategory: calcCategoryID(conf.CategoryID), + hap.TXTSetupHash: hap.SetupHash(setupID, deviceID), }, } entries = append(entries, srv.mdns) srv.UpdateStatus() + if url := findHomeKitURL(stream.Sources()); url != "" { + // 1. Act as transparent proxy for HomeKit camera + srv.proxyURL = url + } else { + // 2. Act as basic HomeKit camera + srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version) + } + host := srv.mdns.Host(mdns.ServiceHAP) - servers[host] = srv + hosts[host] = srv + servers[id] = srv + + log.Trace().Msgf("[homekit] new server: %s", srv.mdns) } api.HandleFunc(hap.PathPairSetup, hapHandler) api.HandleFunc(hap.PathPairVerify, hapHandler) - log.Trace().Msgf("[homekit] mdns: %s", entries) - go func() { if err := mdns.Serve(mdns.ServiceHAP, entries); err != nil { log.Error().Err(err).Caller().Send() @@ -131,6 +125,7 @@ func Init() { } var log zerolog.Logger +var hosts map[string]*server var servers map[string]*server func streamHandler(rawURL string) (core.Producer, error) { @@ -142,6 +137,8 @@ func streamHandler(rawURL string) (core.Producer, error) { client, err := homekit.Dial(rawURL, srtp.Server) if client != nil && rawQuery != "" { query := streams.ParseQuery(rawQuery) + client.MaxWidth = core.Atoi(query.Get("maxwidth")) + client.MaxHeight = core.Atoi(query.Get("maxheight")) client.Bitrate = parseBitrate(query.Get("bitrate")) } @@ -149,45 +146,27 @@ func streamHandler(rawURL string) (core.Producer, error) { } func resolve(host string) *server { - if len(servers) == 1 { - for _, srv := range servers { + if len(hosts) == 1 { + for _, srv := range hosts { return srv } } - if srv, ok := servers[host]; ok { + if srv, ok := hosts[host]; ok { return srv } return nil } func hapHandler(w http.ResponseWriter, r *http.Request) { - conn, rw, err := w.(http.Hijacker).Hijack() - if err != nil { - return - } - - defer conn.Close() - // Can support multiple HomeKit cameras on single port ONLY for Apple devices. // Doesn't support Home Assistant and any other open source projects // because they don't send the host header in requests. srv := resolve(r.Host) if srv == nil { log.Error().Msg("[homekit] unknown host: " + r.Host) - _ = hap.WriteBackoff(rw) return } - - switch r.RequestURI { - case hap.PathPairSetup: - err = srv.hap.PairSetup(r, rw, conn) - case hap.PathPairVerify: - err = srv.hap.PairVerify(r, rw, conn) - } - - if err != nil && err != io.EOF { - log.Error().Err(err).Caller().Send() - } + srv.Handle(w, r) } func findHomeKitURL(sources []string) string { @@ -203,7 +182,7 @@ func findHomeKitURL(sources []string) string { if strings.HasPrefix(url, "hass") { location, _ := streams.Location(url) if strings.HasPrefix(location, "homekit") { - return url + return location } } diff --git a/internal/homekit/server.go b/internal/homekit/server.go index 6c8b37ae..86cfbc15 100644 --- a/internal/homekit/server.go +++ b/internal/homekit/server.go @@ -4,10 +4,16 @@ import ( "crypto/ed25519" "crypto/sha512" "encoding/hex" + "encoding/json" + "errors" "fmt" + "io" "net" + "net/http" "net/url" + "slices" "strings" + "sync" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/ffmpeg" @@ -16,23 +22,142 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/hap" "github.com/AlexxIT/go2rtc/pkg/hap/camera" + "github.com/AlexxIT/go2rtc/pkg/hap/hds" "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" "github.com/AlexxIT/go2rtc/pkg/homekit" "github.com/AlexxIT/go2rtc/pkg/magic" "github.com/AlexxIT/go2rtc/pkg/mdns" - "github.com/AlexxIT/go2rtc/pkg/srtp" ) type server struct { - stream string // stream name from YAML - hap *hap.Server // server for HAP connection and encryption - mdns *mdns.ServiceEntry - srtp *srtp.Server - accessory *hap.Accessory // HAP accessory - pairings []string // pairings list + hap *hap.Server // server for HAP connection and encryption + mdns *mdns.ServiceEntry - streams map[string]*homekit.Consumer - consumer *homekit.Consumer + pairings []string // pairings list + conns []any + mu sync.Mutex + + accessory *hap.Accessory // HAP accessory + consumer *homekit.Consumer + proxyURL string + setupID string + stream string // stream name from YAML +} + +func (s *server) MarshalJSON() ([]byte, error) { + v := struct { + Name string `json:"name"` + DeviceID string `json:"device_id"` + Paired int `json:"paired,omitempty"` + CategoryID string `json:"category_id,omitempty"` + SetupCode string `json:"setup_code,omitempty"` + SetupID string `json:"setup_id,omitempty"` + Conns []any `json:"connections,omitempty"` + }{ + Name: s.mdns.Name, + DeviceID: s.mdns.Info[hap.TXTDeviceID], + CategoryID: s.mdns.Info[hap.TXTCategory], + Paired: len(s.pairings), + Conns: s.conns, + } + if v.Paired == 0 { + v.SetupCode = s.hap.Pin + v.SetupID = s.setupID + } + return json.Marshal(v) +} + +func (s *server) Handle(w http.ResponseWriter, r *http.Request) { + conn, rw, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + + defer conn.Close() + + // Fix reading from Body after Hijack. + r.Body = io.NopCloser(rw) + + switch r.RequestURI { + case hap.PathPairSetup: + id, key, err := s.hap.PairSetup(r, rw) + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + + s.AddPair(id, key, hap.PermissionAdmin) + + case hap.PathPairVerify: + id, key, err := s.hap.PairVerify(r, rw) + if err != nil { + log.Debug().Err(err).Caller().Send() + return + } + + log.Debug().Str("stream", s.stream).Str("client_id", id).Msgf("[homekit] %s: new conn", conn.RemoteAddr()) + + controller, err := hap.NewConn(conn, rw, key, false) + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + + s.AddConn(controller) + defer s.DelConn(controller) + + var handler homekit.HandlerFunc + + switch { + case s.accessory != nil: + handler = homekit.ServerHandler(s) + case s.proxyURL != "": + client, err := hap.Dial(s.proxyURL) + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + handler = homekit.ProxyHandler(s, client.Conn) + } + + // If your iPhone goes to sleep, it will be an EOF error. + if err = handler(controller); err != nil && !errors.Is(err, io.EOF) { + log.Error().Err(err).Caller().Send() + return + } + } +} + +type logger struct { + v any +} + +func (l logger) String() string { + switch v := l.v.(type) { + case *hap.Conn: + return "hap " + v.RemoteAddr().String() + case *hds.Conn: + return "hds " + v.RemoteAddr().String() + case *homekit.Consumer: + return "rtp " + v.RemoteAddr + } + return "unknown" +} + +func (s *server) AddConn(v any) { + log.Trace().Str("stream", s.stream).Msgf("[homekit] add conn %s", logger{v}) + s.mu.Lock() + s.conns = append(s.conns, v) + s.mu.Unlock() +} + +func (s *server) DelConn(v any) { + log.Trace().Str("stream", s.stream).Msgf("[homekit] del conn %s", logger{v}) + s.mu.Lock() + if i := slices.Index(s.conns, v); i >= 0 { + s.conns = slices.Delete(s.conns, i, i+1) + } + s.mu.Unlock() } func (s *server) UpdateStatus() { @@ -44,12 +169,68 @@ func (s *server) UpdateStatus() { } } +func (s *server) pairIndex(id string) int { + id = "client_id=" + id + for i, pairing := range s.pairings { + if strings.HasPrefix(pairing, id) { + return i + } + } + return -1 +} + +func (s *server) GetPair(id string) []byte { + s.mu.Lock() + defer s.mu.Unlock() + + if i := s.pairIndex(id); i >= 0 { + query, _ := url.ParseQuery(s.pairings[i]) + b, _ := hex.DecodeString(query.Get("client_public")) + return b + } + return nil +} + +func (s *server) AddPair(id string, public []byte, permissions byte) { + log.Debug().Str("stream", s.stream).Msgf("[homekit] add pair id=%s public=%x perm=%d", id, public, permissions) + + s.mu.Lock() + if s.pairIndex(id) < 0 { + s.pairings = append(s.pairings, fmt.Sprintf( + "client_id=%s&client_public=%x&permissions=%d", id, public, permissions, + )) + s.UpdateStatus() + s.PatchConfig() + } + s.mu.Unlock() +} + +func (s *server) DelPair(id string) { + log.Debug().Str("stream", s.stream).Msgf("[homekit] del pair id=%s", id) + + s.mu.Lock() + if i := s.pairIndex(id); i >= 0 { + s.pairings = append(s.pairings[:i], s.pairings[i+1:]...) + s.UpdateStatus() + s.PatchConfig() + } + s.mu.Unlock() +} + +func (s *server) PatchConfig() { + if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil { + log.Error().Err(err).Msgf( + "[homekit] can't save %s pairings=%v", s.stream, s.pairings, + ) + } +} + func (s *server) GetAccessories(_ net.Conn) []*hap.Accessory { return []*hap.Accessory{s.accessory} } func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any { - log.Trace().Msgf("[homekit] %s: get char aid=%d iid=0x%x", conn.RemoteAddr(), aid, iid) + log.Trace().Str("stream", s.stream).Msgf("[homekit] get char aid=%d iid=0x%x", aid, iid) char := s.accessory.GetCharacterByID(iid) if char == nil { @@ -59,11 +240,12 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any { switch char.Type { case camera.TypeSetupEndpoints: - if s.consumer == nil { + consumer := s.consumer + if consumer == nil { return nil } - answer := s.consumer.GetAnswer() + answer := consumer.GetAnswer() v, err := tlv8.MarshalBase64(answer) if err != nil { return nil @@ -76,7 +258,7 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any { } func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value any) { - log.Trace().Msgf("[homekit] %s: set char aid=%d iid=0x%x value=%v", conn.RemoteAddr(), aid, iid, value) + log.Trace().Str("stream", s.stream).Msgf("[homekit] set char aid=%d iid=0x%x value=%v", aid, iid, value) char := s.accessory.GetCharacterByID(iid) if char == nil { @@ -86,61 +268,64 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a switch char.Type { case camera.TypeSetupEndpoints: - var offer camera.SetupEndpoints + var offer camera.SetupEndpointsRequest if err := tlv8.UnmarshalBase64(value, &offer); err != nil { return } - s.consumer = homekit.NewConsumer(conn, srtp2.Server) - s.consumer.SetOffer(&offer) + consumer := homekit.NewConsumer(conn, srtp2.Server) + consumer.SetOffer(&offer) + s.consumer = consumer case camera.TypeSelectedStreamConfiguration: - var conf camera.SelectedStreamConfig + var conf camera.SelectedStreamConfiguration if err := tlv8.UnmarshalBase64(value, &conf); err != nil { return } - log.Trace().Msgf("[homekit] %s stream id=%x cmd=%d", conn.RemoteAddr(), conf.Control.SessionID, conf.Control.Command) + log.Trace().Str("stream", s.stream).Msgf("[homekit] stream id=%x cmd=%d", conf.Control.SessionID, conf.Control.Command) switch conf.Control.Command { case camera.SessionCommandEnd: - if consumer := s.streams[conf.Control.SessionID]; consumer != nil { - _ = consumer.Stop() + for _, consumer := range s.conns { + if consumer, ok := consumer.(*homekit.Consumer); ok { + if consumer.SessionID() == conf.Control.SessionID { + _ = consumer.Stop() + return + } + } } case camera.SessionCommandStart: - if s.consumer == nil { + consumer := s.consumer + if consumer == nil { return } - if !s.consumer.SetConfig(&conf) { + if !consumer.SetConfig(&conf) { log.Warn().Msgf("[homekit] wrong config") return } - if s.streams == nil { - s.streams = map[string]*homekit.Consumer{} - } - - s.streams[conf.Control.SessionID] = s.consumer + s.AddConn(consumer) stream := streams.Get(s.stream) - if err := stream.AddConsumer(s.consumer); err != nil { + if err := stream.AddConsumer(consumer); err != nil { return } go func() { - _, _ = s.consumer.WriteTo(nil) - stream.RemoveConsumer(s.consumer) + _, _ = consumer.WriteTo(nil) + stream.RemoveConsumer(consumer) - delete(s.streams, conf.Control.SessionID) + s.DelConn(consumer) }() } } } func (s *server) GetImage(conn net.Conn, width, height int) []byte { - log.Trace().Msgf("[homekit] %s: get image width=%d height=%d", conn.RemoteAddr(), width, height) + log.Trace().Str("stream", s.stream).Msgf("[homekit] get image width=%d height=%d", width, height) stream := streams.Get(s.stream) cons := magic.NewKeyframe() @@ -166,69 +351,6 @@ func (s *server) GetImage(conn net.Conn, width, height int) []byte { return b } -func (s *server) GetPair(conn net.Conn, id string) []byte { - log.Trace().Msgf("[homekit] %s: get pair id=%s", conn.RemoteAddr(), id) - - for _, pairing := range s.pairings { - if !strings.Contains(pairing, id) { - continue - } - - query, err := url.ParseQuery(pairing) - if err != nil { - continue - } - - if query.Get("client_id") != id { - continue - } - - s := query.Get("client_public") - b, _ := hex.DecodeString(s) - return b - } - return nil -} - -func (s *server) AddPair(conn net.Conn, id string, public []byte, permissions byte) { - log.Trace().Msgf("[homekit] %s: add pair id=%s public=%x perm=%d", conn.RemoteAddr(), id, public, permissions) - - query := url.Values{ - "client_id": []string{id}, - "client_public": []string{hex.EncodeToString(public)}, - "permissions": []string{string('0' + permissions)}, - } - if s.GetPair(conn, id) == nil { - s.pairings = append(s.pairings, query.Encode()) - s.UpdateStatus() - s.PatchConfig() - } -} - -func (s *server) DelPair(conn net.Conn, id string) { - log.Trace().Msgf("[homekit] %s: del pair id=%s", conn.RemoteAddr(), id) - - id = "client_id=" + id - for i, pairing := range s.pairings { - if !strings.Contains(pairing, id) { - continue - } - - s.pairings = append(s.pairings[:i], s.pairings[i+1:]...) - s.UpdateStatus() - s.PatchConfig() - break - } -} - -func (s *server) PatchConfig() { - if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil { - log.Error().Err(err).Msgf( - "[homekit] can't save %s pairings=%v", s.stream, s.pairings, - ) - } -} - func calcName(name, seed string) string { if name != "" { return name @@ -263,3 +385,21 @@ func calcDevicePrivate(private, seed string) []byte { b := sha512.Sum512([]byte(seed)) return ed25519.NewKeyFromSeed(b[:ed25519.SeedSize]) } + +func calcSetupID(seed string) string { + b := sha512.Sum512([]byte(seed)) + return fmt.Sprintf("%02X%02X", b[44], b[46]) +} + +func calcCategoryID(categoryID string) string { + switch categoryID { + case "bridge": + return hap.CategoryBridge + case "doorbell": + return hap.CategoryDoorbell + } + if core.Atoi(categoryID) > 0 { + return categoryID + } + return hap.CategoryCamera +} diff --git a/internal/mjpeg/README.md b/internal/mjpeg/README.md index a09e59c4..1d701de1 100644 --- a/internal/mjpeg/README.md +++ b/internal/mjpeg/README.md @@ -1,3 +1,45 @@ +# MJPEG + +**Important.** For stream in MJPEG format, your source MUST contain the MJPEG codec. If your stream has an MJPEG codec, you can receive **MJPEG stream** or **JPEG snapshots** via API. + +You can receive an MJPEG stream in several ways: + +- some cameras support MJPEG codec inside [RTSP stream](#source-rtsp) (ex. second stream for Dahua cameras) +- some cameras have an HTTP link with [MJPEG stream](#source-http) +- some cameras have an HTTP link with snapshots - go2rtc can convert them to [MJPEG stream](#source-http) +- you can convert H264/H265 stream from your camera via [FFmpeg integraion](#source-ffmpeg) + +With this example, your stream will have both H264 and MJPEG codecs: + +```yaml +streams: + camera1: + - rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0 + - ffmpeg:camera1#video=mjpeg +``` + +## API examples + +**MJPEG stream** + +``` +http://192.168.1.123:1984/api/stream.mjpeg?src=camera1` +``` + +**JPEG snapshots** + +``` +http://192.168.1.123:1984/api/frame.jpeg?src=camera1 +``` + +- You can use `width`/`w` and/or `height`/`h` params. +- You can use `rotate` param with `90`, `180`, `270` or `-90` values. +- You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration). +- You can use `cache` param (`1m`, `10s`, etc.) to get a cached snapshot. + - The snapshot is cached only when requested with the `cache` parameter. + - A cached snapshot will be used if its time is not older than the time specified in the `cache` parameter. + - The `cache` parameter does not check the image sizes from the cache and those specified in the query. + ## Stream as ASCII to Terminal [![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M) diff --git a/internal/mjpeg/init.go b/internal/mjpeg/mjpeg.go similarity index 79% rename from internal/mjpeg/init.go rename to internal/mjpeg/mjpeg.go index 27c557e4..e9f973aa 100644 --- a/internal/mjpeg/init.go +++ b/internal/mjpeg/mjpeg.go @@ -6,6 +6,7 @@ import ( "net/http" "strconv" "strings" + "sync" "time" "github.com/AlexxIT/go2rtc/internal/api" @@ -36,12 +37,44 @@ func Init() { var log zerolog.Logger func handlerKeyframe(w http.ResponseWriter, r *http.Request) { - stream := streams.GetOrPatch(r.URL.Query()) + query := r.URL.Query() + stream, _ := streams.GetOrPatch(query) if stream == nil { http.Error(w, api.StreamNotFound, http.StatusNotFound) return } + var b []byte + + if s := query.Get("cache"); s != "" { + if timeout, err := time.ParseDuration(s); err == nil { + src := query.Get("src") + + cacheMu.Lock() + entry, found := cache[src] + cacheMu.Unlock() + + if found && time.Since(entry.timestamp) < timeout { + writeJPEGResponse(w, entry.payload) + return + } + + defer func() { + if b == nil { + return + } + entry = cacheEntry{payload: b, timestamp: time.Now()} + cacheMu.Lock() + if cache == nil { + cache = map[string]cacheEntry{src: entry} + } else { + cache[src] = entry + } + cacheMu.Unlock() + }() + } + } + cons := magic.NewKeyframe() cons.WithRequest(r) @@ -52,7 +85,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { once := &core.OnceBuffer{} // init and first frame _, _ = cons.WriteTo(once) - b := once.Buffer() + b = once.Buffer() stream.RemoveConsumer(cons) @@ -60,7 +93,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { case core.CodecH264, core.CodecH265: ts := time.Now() var err error - if b, err = ffmpeg.JPEGWithQuery(b, r.URL.Query()); err != nil { + if b, err = ffmpeg.JPEGWithQuery(b, query); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -69,6 +102,19 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { b = mjpeg.FixJPEG(b) } + writeJPEGResponse(w, b) +} + +var cache map[string]cacheEntry +var cacheMu sync.Mutex + +// cacheEntry represents a cached keyframe with its timestamp +type cacheEntry struct { + payload []byte + timestamp time.Time +} + +func writeJPEGResponse(w http.ResponseWriter, b []byte) { h := w.Header() h.Set("Content-Type", "image/jpeg") h.Set("Content-Length", strconv.Itoa(len(b))) @@ -145,7 +191,7 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) { } func handlerWS(tr *ws.Transport, _ *ws.Message) error { - stream := streams.GetOrPatch(tr.Request.URL.Query()) + stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) if stream == nil { return errors.New(api.StreamNotFound) } diff --git a/internal/mp4/mp4.go b/internal/mp4/mp4.go index cca5220c..d0a6d971 100644 --- a/internal/mp4/mp4.go +++ b/internal/mp4/mp4.go @@ -91,7 +91,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) { return } - stream := streams.GetOrPatch(query) + stream, _ := streams.GetOrPatch(query) if stream == nil { http.Error(w, api.StreamNotFound, http.StatusNotFound) return diff --git a/internal/mp4/ws.go b/internal/mp4/ws.go index c880fb58..c1afac24 100644 --- a/internal/mp4/ws.go +++ b/internal/mp4/ws.go @@ -11,7 +11,7 @@ import ( ) func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error { - stream := streams.GetOrPatch(tr.Request.URL.Query()) + stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) if stream == nil { return errors.New(api.StreamNotFound) } @@ -43,7 +43,7 @@ func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error { } func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error { - stream := streams.GetOrPatch(tr.Request.URL.Query()) + stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) if stream == nil { return errors.New(api.StreamNotFound) } diff --git a/internal/multitrans/README.md b/internal/multitrans/README.md new file mode 100644 index 00000000..6201f8b6 --- /dev/null +++ b/internal/multitrans/README.md @@ -0,0 +1,16 @@ +# Multitrans + +**added in v1.9.14** by [@forrestsocool](https://github.com/forrestsocool) + +Two-way audio support for Chinese version of [TP-Link cameras](https://www.tp-link.com.cn/list_2549.html). + +## Configuration + +```yaml +streams: + tplink_cam: + # video use standard RTSP + - rtsp://admin:admin@192.168.1.202:554/stream1 + # two-way audio use MULTITRANS schema + - multitrans://admin:admin@192.168.1.202:554 +``` diff --git a/internal/multitrans/multitrans.go b/internal/multitrans/multitrans.go new file mode 100644 index 00000000..31e6a9a4 --- /dev/null +++ b/internal/multitrans/multitrans.go @@ -0,0 +1,10 @@ +package multitrans + +import ( + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/multitrans" +) + +func Init() { + streams.HandleFunc("multitrans", multitrans.Dial) +} diff --git a/internal/onvif/onvif.go b/internal/onvif/onvif.go index 6dfa633a..c305b706 100644 --- a/internal/onvif/onvif.go +++ b/internal/onvif/onvif.go @@ -7,6 +7,7 @@ import ( "net/url" "os" "strconv" + "strings" "time" "github.com/AlexxIT/go2rtc/internal/api" @@ -43,8 +44,17 @@ func streamOnvif(rawURL string) (core.Producer, error) { return nil, err } + // Append hash-based arguments to the retrieved URI + if i := strings.IndexByte(rawURL, '#'); i > 0 { + uri += rawURL[i:] + } + log.Debug().Msgf("[onvif] new uri=%s", uri) + if err = streams.Validate(uri); err != nil { + return nil, err + } + return streams.GetProducer(uri) } @@ -64,8 +74,10 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) { log.Trace().Msgf("[onvif] server request %s %s:\n%s", r.Method, r.RequestURI, b) switch operation { - case onvif.DeviceGetNetworkInterfaces, // important for Hass + case onvif.ServiceGetServiceCapabilities, // important for Hass + onvif.DeviceGetNetworkInterfaces, // important for Hass onvif.DeviceGetSystemDateAndTime, // important for Hass + onvif.DeviceSetSystemDateAndTime, // return just OK onvif.DeviceGetDiscoveryMode, onvif.DeviceGetDNS, onvif.DeviceGetHostname, @@ -73,8 +85,10 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) { onvif.DeviceGetNetworkProtocols, onvif.DeviceGetNTP, onvif.DeviceGetScopes, + onvif.MediaGetVideoEncoderConfiguration, onvif.MediaGetVideoEncoderConfigurations, onvif.MediaGetAudioEncoderConfigurations, + onvif.MediaGetVideoEncoderConfigurationOptions, onvif.MediaGetAudioSources, onvif.MediaGetAudioSourceConfigurations: b = onvif.StaticResponse(operation) @@ -90,11 +104,6 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) { // important for Hass: SerialNumber (unique server ID) b = onvif.GetDeviceInformationResponse("", "go2rtc", app.Version, r.Host) - case onvif.ServiceGetServiceCapabilities: - // important for Hass - // TODO: check path links to media - b = onvif.GetMediaServiceCapabilitiesResponse() - case onvif.DeviceSystemReboot: b = onvif.StaticResponse(operation) @@ -124,8 +133,7 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) { case onvif.MediaGetStreamUri: host, _, err := net.SplitHostPort(r.Host) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + host = r.Host // in case of Host without port } uri := "rtsp://" + host + ":" + rtsp.Port + "/" + onvif.FindTagValue(b, "ProfileToken") @@ -156,21 +164,21 @@ func apiOnvif(w http.ResponseWriter, r *http.Request) { var items []*api.Source if src == "" { - urls, err := onvif.DiscoveryStreamingURLs() + devices, err := onvif.DiscoveryStreamingDevices() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - for _, rawURL := range urls { - u, err := url.Parse(rawURL) + for _, device := range devices { + u, err := url.Parse(device.URL) if err != nil { - log.Warn().Str("url", rawURL).Msg("[onvif] broken") + log.Warn().Str("url", device.URL).Msg("[onvif] broken") continue } if u.Scheme != "http" { - log.Warn().Str("url", rawURL).Msg("[onvif] unsupported") + log.Warn().Str("url", device.URL).Msg("[onvif] unsupported") continue } @@ -181,7 +189,11 @@ func apiOnvif(w http.ResponseWriter, r *http.Request) { u.Path = "" } - items = append(items, &api.Source{Name: u.Host, URL: u.String()}) + items = append(items, &api.Source{ + Name: u.Host, + URL: u.String(), + Info: device.Name + " " + device.Hardware, + }) } } else { client, err := onvif.NewClient(src) diff --git a/internal/pinggy/README.md b/internal/pinggy/README.md new file mode 100644 index 00000000..cd270021 --- /dev/null +++ b/internal/pinggy/README.md @@ -0,0 +1,54 @@ +# Pinggy + +[Pinggy](https://pinggy.io/) - nice service for public tunnels to your local services. + +**Features:** + +- A free account does not require registration. +- It does not require downloading third-party binaries and works over the SSH protocol. +- Works with HTTP, TCP and UDP protocols. +- Creates HTTPS for your HTTP services. + +> [!IMPORTANT] +> A free account creates a tunnel with a random address that only works for an hour. It is suitable for testing purposes ONLY. + +> [!CAUTION] +> Public access to go2rtc without authorization puts your entire home network at risk. Use with caution. + +**Why:** + +- It's easy to set up HTTPS for testing two-way audio. +- It's easy to check whether external access via WebRTC technology will work. +- It's easy to share direct access to your RTSP or HTTP camera with the go2rtc developer. If such access is necessary to debug your problem. + +## Configuration + +You will find public links in the go2rtc log after startup. + +**Tunnel to go2rtc WebUI.** + +```yaml +pinggy: + tunnel: http://localhost:1984 +``` + +**Tunnel to RTSP camera.** + +For example, you have camera: `rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0` + +```yaml +pinggy: + tunnel: tcp://192.168.10.91:554 +``` + +In go2rtc logs you will get similar output: + +``` +16:17:43.167 INF [pinggy] proxy url=tcp://abcde-123-123-123-123.a.free.pinggy.link:12345 +``` + +Now you have working stream: + +``` +rtsp://admin:password@abcde-123-123-123-123.a.free.pinggy.link:12345/cam/realmonitor?channel=1&subtype=0 +``` diff --git a/internal/pinggy/pinggy.go b/internal/pinggy/pinggy.go new file mode 100644 index 00000000..2e7258e2 --- /dev/null +++ b/internal/pinggy/pinggy.go @@ -0,0 +1,60 @@ +package pinggy + +import ( + "net/url" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/pinggy" + "github.com/rs/zerolog" +) + +func Init() { + var cfg struct { + Mod struct { + Tunnel string `yaml:"tunnel"` + } `yaml:"pinggy"` + } + + app.LoadConfig(&cfg) + + if cfg.Mod.Tunnel == "" { + return + } + + log = app.GetLogger("pinggy") + + u, err := url.Parse(cfg.Mod.Tunnel) + if err != nil { + log.Error().Err(err).Send() + return + } + + go proxy(u.Scheme, u.Host) +} + +var log zerolog.Logger + +func proxy(proto, address string) { + client, err := pinggy.NewClient(proto) + if err != nil { + log.Error().Err(err).Send() + return + } + defer client.Close() + + urls, err := client.GetURLs() + if err != nil { + log.Error().Err(err).Send() + return + } + + for _, s := range urls { + log.Info().Str("url", s).Msgf("[pinggy] proxy") + } + + err = client.Proxy(address) + if err != nil { + log.Error().Err(err).Send() + return + } +} diff --git a/internal/rtsp/rtsp.go b/internal/rtsp/rtsp.go index 9b18982f..31c2c5db 100644 --- a/internal/rtsp/rtsp.go +++ b/internal/rtsp/rtsp.go @@ -198,6 +198,8 @@ func tcpHandler(conn *rtsp.Conn) { {Name: core.CodecPCM, ClockRate: 8000}, {Name: core.CodecPCMA, ClockRate: 8000}, {Name: core.CodecPCMU, ClockRate: 8000}, + {Name: core.CodecAAC, ClockRate: 8000}, + {Name: core.CodecAAC, ClockRate: 16000}, }, }) } diff --git a/internal/streams/api.go b/internal/streams/api.go index 28f09708..d6142eb0 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -52,8 +52,8 @@ func apiStreams(w http.ResponseWriter, r *http.Request) { name = src } - if New(name, query["src"]...) == nil { - http.Error(w, "", http.StatusBadRequest) + if _, err := New(name, query["src"]...); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -69,8 +69,8 @@ func apiStreams(w http.ResponseWriter, r *http.Request) { } // support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass - if Patch(name, src) == nil { - http.Error(w, "", http.StatusBadRequest) + if _, err := Patch(name, src); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) } case "POST": @@ -130,16 +130,15 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { } func apiPreload(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - src := query.Get("src") - - // check if stream exists - stream := Get(src) - if stream == nil { - http.Error(w, "", http.StatusNotFound) + // GET - return all preloads + if r.Method == "GET" { + api.ResponseJSON(w, GetPreloads()) return } + query := r.URL.Query() + src := query.Get("src") + switch r.Method { case "PUT": // it's safe to delete from map while iterating @@ -153,7 +152,7 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { rawQuery := query.Encode() - if err := AddPreload(stream, rawQuery); err != nil { + if err := AddPreload(src, rawQuery); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -163,7 +162,7 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { } case "DELETE": - if err := DelPreload(stream); err != nil { + if err := DelPreload(src); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -176,3 +175,7 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { http.Error(w, "", http.StatusMethodNotAllowed) } } + +func apiSchemes(w http.ResponseWriter, r *http.Request) { + api.ResponseJSON(w, SupportedSchemes()) +} diff --git a/internal/streams/api_test.go b/internal/streams/api_test.go new file mode 100644 index 00000000..2cb93d2a --- /dev/null +++ b/internal/streams/api_test.go @@ -0,0 +1,66 @@ +package streams + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/stretchr/testify/require" +) + +func TestApiSchemes(t *testing.T) { + // Setup: Register some test handlers and redirects + HandleFunc("rtsp", func(url string) (core.Producer, error) { return nil, nil }) + HandleFunc("rtmp", func(url string) (core.Producer, error) { return nil, nil }) + RedirectFunc("http", func(url string) (string, error) { return "", nil }) + + t.Run("GET request returns schemes", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/schemes", nil) + w := httptest.NewRecorder() + + apiSchemes(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var schemes []string + err := json.Unmarshal(w.Body.Bytes(), &schemes) + require.NoError(t, err) + require.NotEmpty(t, schemes) + + // Check that our test schemes are in the response + require.Contains(t, schemes, "rtsp") + require.Contains(t, schemes, "rtmp") + require.Contains(t, schemes, "http") + }) +} + +func TestApiSchemesNoDuplicates(t *testing.T) { + // Setup: Register a scheme in both handlers and redirects + HandleFunc("duplicate", func(url string) (core.Producer, error) { return nil, nil }) + RedirectFunc("duplicate", func(url string) (string, error) { return "", nil }) + + req := httptest.NewRequest("GET", "/api/schemes", nil) + w := httptest.NewRecorder() + + apiSchemes(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var schemes []string + err := json.Unmarshal(w.Body.Bytes(), &schemes) + require.NoError(t, err) + + // Count occurrences of "duplicate" + count := 0 + for _, scheme := range schemes { + if scheme == "duplicate" { + count++ + } + } + + // Should only appear once + require.Equal(t, 1, count, "scheme 'duplicate' should appear exactly once") +} diff --git a/internal/streams/handlers.go b/internal/streams/handlers.go index 3240abb5..9433044b 100644 --- a/internal/streams/handlers.go +++ b/internal/streams/handlers.go @@ -2,6 +2,7 @@ package streams import ( "errors" + "regexp" "strings" "github.com/AlexxIT/go2rtc/pkg/core" @@ -15,6 +16,21 @@ func HandleFunc(scheme string, handler Handler) { handlers[scheme] = handler } +func SupportedSchemes() []string { + uniqueKeys := make(map[string]struct{}, len(handlers)+len(redirects)) + for scheme := range handlers { + uniqueKeys[scheme] = struct{}{} + } + for scheme := range redirects { + uniqueKeys[scheme] = struct{}{} + } + resultKeys := make([]string, 0, len(uniqueKeys)) + for key := range uniqueKeys { + resultKeys = append(resultKeys, key) + } + return resultKeys +} + func HasProducer(url string) bool { if i := strings.IndexByte(url, ':'); i > 0 { scheme := url[:i] @@ -95,3 +111,24 @@ func GetConsumer(url string) (core.Consumer, func(), error) { return nil, nil, errors.New("streams: unsupported scheme: " + url) } + +var insecure = map[string]bool{} + +func MarkInsecure(scheme string) { + insecure[scheme] = true +} + +var sanitize = regexp.MustCompile(`\s`) + +func Validate(source string) error { + // TODO: Review the entire logic of insecure sources + if i := strings.IndexByte(source, ':'); i > 0 { + if insecure[source[:i]] { + return errors.New("streams: source from insecure producer") + } + } + if sanitize.MatchString(source) { + return errors.New("streams: source with spaces may be insecure") + } + return nil +} diff --git a/internal/streams/preload.go b/internal/streams/preload.go index 527746ac..447b5ac3 100644 --- a/internal/streams/preload.go +++ b/internal/streams/preload.go @@ -1,23 +1,24 @@ package streams import ( - "errors" + "fmt" + "maps" "net/url" "sync" "github.com/AlexxIT/go2rtc/pkg/probe" ) -var preloads = map[*Stream]*probe.Probe{} -var preloadsMu sync.Mutex - -func Preload(stream *Stream, rawQuery string) { - if err := AddPreload(stream, rawQuery); err != nil { - log.Error().Err(err).Caller().Send() - } +type Preload struct { + stream *Stream // Don't output the stream to JSON to not worry about its secrets. + Cons *probe.Probe `json:"consumer"` + Query string `json:"query"` } -func AddPreload(stream *Stream, rawQuery string) error { +var preloads = map[string]*Preload{} +var preloadsMu sync.Mutex + +func AddPreload(name, rawQuery string) error { if rawQuery == "" { rawQuery = "video&audio" } @@ -30,29 +31,39 @@ func AddPreload(stream *Stream, rawQuery string) error { preloadsMu.Lock() defer preloadsMu.Unlock() - if cons := preloads[stream]; cons != nil { - stream.RemoveConsumer(cons) + if p := preloads[name]; p != nil { + p.stream.RemoveConsumer(p.Cons) } + stream := Get(name) + if stream == nil { + return fmt.Errorf("streams: stream not found: %s", name) + } cons := probe.Create("preload", query) if err = stream.AddConsumer(cons); err != nil { return err } - preloads[stream] = cons + preloads[name] = &Preload{stream: stream, Cons: cons, Query: rawQuery} return nil } -func DelPreload(stream *Stream) error { +func DelPreload(name string) error { preloadsMu.Lock() defer preloadsMu.Unlock() - if cons := preloads[stream]; cons != nil { - stream.RemoveConsumer(cons) - delete(preloads, stream) + if p := preloads[name]; p != nil { + p.stream.RemoveConsumer(p.Cons) + delete(preloads, name) return nil } - return errors.New("streams: preload not found") + return fmt.Errorf("streams: preload not found: %s", name) +} + +func GetPreloads() map[string]*Preload { + preloadsMu.Lock() + defer preloadsMu.Unlock() + return maps.Clone(preloads) } diff --git a/internal/streams/streams.go b/internal/streams/streams.go index a0b1ed68..f3b8df03 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -3,7 +3,6 @@ package streams import ( "errors" "net/url" - "regexp" "sync" "time" @@ -30,6 +29,7 @@ func Init() { api.HandleFunc("api/streams", apiStreams) api.HandleFunc("api/streams.dot", apiStreamsDOT) api.HandleFunc("api/preload", apiPreload) + api.HandleFunc("api/schemes", apiSchemes) if cfg.Publish == nil && cfg.Preload == nil { return @@ -43,27 +43,21 @@ func Init() { } } for name, rawQuery := range cfg.Preload { - if stream := Get(name); stream != nil { - Preload(stream, rawQuery) + if err := AddPreload(name, rawQuery); err != nil { + log.Error().Err(err).Caller().Send() } } }) } -var sanitize = regexp.MustCompile(`\s`) - -// Validate - not allow creating dynamic streams with spaces in the source -func Validate(source string) error { - if sanitize.MatchString(source) { - return errors.New("streams: invalid dynamic source") - } - return nil -} - -func New(name string, sources ...string) *Stream { +func New(name string, sources ...string) (*Stream, error) { for _, source := range sources { - if Validate(source) != nil { - return nil + if !HasProducer(source) { + return nil, errors.New("streams: source not supported") + } + + if err := Validate(source); err != nil { + return nil, err } } @@ -73,10 +67,10 @@ func New(name string, sources ...string) *Stream { streams[name] = stream streamsMu.Unlock() - return stream + return stream, nil } -func Patch(name string, source string) *Stream { +func Patch(name string, source string) (*Stream, error) { streamsMu.Lock() defer streamsMu.Unlock() @@ -88,7 +82,7 @@ func Patch(name string, source string) *Stream { // link (alias) streams[name] to streams[rtspName] streams[name] = stream } - return stream + return stream, nil } } @@ -97,46 +91,44 @@ func Patch(name string, source string) *Stream { // link (alias) streams[name] to streams[source] streams[name] = stream } - return stream + return stream, nil } // check if src has supported scheme if !HasProducer(source) { - return nil + return nil, errors.New("streams: source not supported") } - if Validate(source) != nil { - return nil + if err := Validate(source); err != nil { + return nil, err } // check an existing stream with this name if stream, ok := streams[name]; ok { stream.SetSource(source) - return stream + return stream, nil } // create new stream with this name stream := NewStream(source) streams[name] = stream - return stream + return stream, nil } -func GetOrPatch(query url.Values) *Stream { +func GetOrPatch(query url.Values) (*Stream, error) { // check if src param exists source := query.Get("src") if source == "" { - return nil + return nil, errors.New("streams: source empty") } // check if src is stream name if stream := Get(source); stream != nil { - return stream + return stream, nil } // check if name param provided if name := query.Get("name"); name != "" { - log.Info().Msgf("[streams] create new stream url=%s", source) - return Patch(name, source) } diff --git a/internal/tuya/README.md b/internal/tuya/README.md new file mode 100644 index 00000000..b37a27c3 --- /dev/null +++ b/internal/tuya/README.md @@ -0,0 +1,39 @@ +# Tuya + +*[New in v1.9.13](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13)* + +[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Tuya Smart API` and `Tuya Cloud API`. + +**Tuya Smart API (recommended)**: +- Cameras can be discovered through the go2rtc web interface via Tuya Smart account (Add > Tuya > Select region and fill in email and password > Login). +- **Smart Life accounts are not supported**, you need to create a Tuya Smart account. If the cameras are already added to the Smart Life app, you need to remove them and add them again to the Tuya Smart app. + +**Tuya Cloud API**: +- Requires setting up a cloud project in the Tuya Developer Platform. +- Obtain `device_id`, `client_id`, `client_secret`, and `uid` from [Tuya IoT Platform](https://iot.tuya.com/). [Here's a guide](https://xzetsubou.github.io/hass-localtuya/cloud_api/). +- Please ensure that you have subscribed to the `IoT Video Live Stream` service (Free Trial) in the Tuya Developer Platform, otherwise the stream will not work (Tuya Developer Platform > Service API > Authorize > IoT Video Live Stream). + +## Configuration + +Use `resolution` parameter to select the stream (not all cameras support `hd` stream through WebRTC even if the camera has it): +- `hd` - HD stream (default) +- `sd` - SD stream + +```yaml +streams: + # Tuya Smart API: WebRTC main stream (use Add > Tuya to discover the URL) + tuya_main: + - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX + + # Tuya Smart API: WebRTC sub stream (use Add > Tuya to discover the URL) + tuya_sub: + - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX&resolution=sd + + # Tuya Cloud API: WebRTC main stream + tuya_webrtc: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX + + # Tuya Cloud API: WebRTC sub stream + tuya_webrtc_sd: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=sd +``` diff --git a/internal/tuya/tuya.go b/internal/tuya/tuya.go new file mode 100644 index 00000000..9dcf2721 --- /dev/null +++ b/internal/tuya/tuya.go @@ -0,0 +1,248 @@ +package tuya + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tuya" +) + +func Init() { + streams.HandleFunc("tuya", func(source string) (core.Producer, error) { + return tuya.Dial(source) + }) + + api.HandleFunc("api/tuya", apiTuya) +} + +func apiTuya(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + region := query.Get("region") + email := query.Get("email") + password := query.Get("password") + + if email == "" || password == "" || region == "" { + http.Error(w, "email, password and region are required", http.StatusBadRequest) + return + } + + var tuyaRegion *tuya.Region + for _, r := range tuya.AvailableRegions { + if r.Host == region { + tuyaRegion = &r + break + } + } + + if tuyaRegion == nil { + http.Error(w, fmt.Sprintf("invalid region: %s", region), http.StatusBadRequest) + return + } + + httpClient := tuya.CreateHTTPClientWithSession() + + _, err := login(httpClient, tuyaRegion.Host, email, password, tuyaRegion.Continent) + if err != nil { + http.Error(w, fmt.Sprintf("login failed: %v", err), http.StatusInternalServerError) + return + } + + tuyaAPI, err := tuya.NewTuyaSmartApiClient( + httpClient, + tuyaRegion.Host, + email, + password, + "", + ) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var devices []tuya.Device + + homes, _ := tuyaAPI.GetHomeList() + if homes != nil && len(homes.Result) > 0 { + for _, home := range homes.Result { + roomList, err := tuyaAPI.GetRoomList(strconv.Itoa(home.Gid)) + if err != nil { + continue + } + + for _, room := range roomList.Result { + for _, device := range room.DeviceList { + if (device.Category == "sp" || device.Category == "dghsxj") && !containsDevice(devices, device.DeviceId) { + devices = append(devices, device) + } + } + } + } + } + + sharedHomes, _ := tuyaAPI.GetSharedHomeList() + if sharedHomes != nil && len(sharedHomes.Result.SecurityWebCShareInfoList) > 0 { + for _, sharedHome := range sharedHomes.Result.SecurityWebCShareInfoList { + for _, device := range sharedHome.DeviceInfoList { + if (device.Category == "sp" || device.Category == "dghsxj") && !containsDevice(devices, device.DeviceId) { + devices = append(devices, device) + } + } + } + } + + if len(devices) == 0 { + http.Error(w, "no cameras found", http.StatusNotFound) + return + } + + var items []*api.Source + for _, device := range devices { + cleanQuery := url.Values{} + cleanQuery.Set("device_id", device.DeviceId) + cleanQuery.Set("email", email) + cleanQuery.Set("password", password) + url := fmt.Sprintf("tuya://%s?%s", tuyaRegion.Host, cleanQuery.Encode()) + + items = append(items, &api.Source{ + Name: device.DeviceName, + URL: url, + }) + } + + api.ResponseSources(w, items) +} + +func login(client *http.Client, serverHost, email, password, countryCode string) (*tuya.LoginResult, error) { + tokenResp, err := getLoginToken(client, serverHost, email, countryCode) + if err != nil { + return nil, err + } + + encryptedPassword, err := tuya.EncryptPassword(password, tokenResp.Result.PbKey) + if err != nil { + return nil, fmt.Errorf("failed to encrypt password: %v", err) + } + + var loginResp *tuya.PasswordLoginResponse + var url string + + loginReq := tuya.PasswordLoginRequest{ + CountryCode: countryCode, + Passwd: encryptedPassword, + Token: tokenResp.Result.Token, + IfEncrypt: 1, + Options: `{"group":1}`, + } + + if tuya.IsEmailAddress(email) { + url = fmt.Sprintf("https://%s/api/private/email/login", serverHost) + loginReq.Email = email + } else { + url = fmt.Sprintf("https://%s/api/private/phone/login", serverHost) + loginReq.Mobile = email + } + + loginResp, err = performLogin(client, url, loginReq, serverHost) + + if err != nil { + return nil, err + } + + if !loginResp.Success { + return nil, errors.New(loginResp.ErrorMsg) + } + + return &loginResp.Result, nil +} + +func getLoginToken(client *http.Client, serverHost, username, countryCode string) (*tuya.LoginTokenResponse, error) { + url := fmt.Sprintf("https://%s/api/login/token", serverHost) + + tokenReq := tuya.LoginTokenRequest{ + CountryCode: countryCode, + Username: username, + IsUid: false, + } + + jsonData, err := json.Marshal(tokenReq) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", fmt.Sprintf("https://%s", serverHost)) + req.Header.Set("Referer", fmt.Sprintf("https://%s/login", serverHost)) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var tokenResp tuya.LoginTokenResponse + if err = json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return nil, err + } + + if !tokenResp.Success { + return nil, errors.New("tuya: " + tokenResp.Msg) + } + + return &tokenResp, nil +} + +func performLogin(client *http.Client, url string, loginReq tuya.PasswordLoginRequest, serverHost string) (*tuya.PasswordLoginResponse, error) { + jsonData, err := json.Marshal(loginReq) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", fmt.Sprintf("https://%s", serverHost)) + req.Header.Set("Referer", fmt.Sprintf("https://%s/login", serverHost)) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var loginResp tuya.PasswordLoginResponse + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return nil, err + } + + return &loginResp, nil +} + +func containsDevice(devices []tuya.Device, deviceID string) bool { + for _, device := range devices { + if device.DeviceId == deviceID { + return true + } + } + return false +} diff --git a/internal/webrtc/candidates.go b/internal/webrtc/candidates.go index 1138db76..d378022f 100644 --- a/internal/webrtc/candidates.go +++ b/internal/webrtc/candidates.go @@ -18,9 +18,11 @@ type Address struct { Priority uint32 } +var stuns []string + func (a *Address) Host() string { if a.host == "stun" { - ip, err := webrtc.GetCachedPublicIP() + ip, err := webrtc.GetCachedPublicIP(stuns...) if err != nil { return "" } diff --git a/internal/webrtc/webrtc.go b/internal/webrtc/webrtc.go index 11e9db89..2a5b4ad6 100644 --- a/internal/webrtc/webrtc.go +++ b/internal/webrtc/webrtc.go @@ -2,6 +2,7 @@ package webrtc import ( "errors" + "net" "strings" "github.com/AlexxIT/go2rtc/internal/api" @@ -26,24 +27,49 @@ func Init() { cfg.Mod.Listen = ":8555" cfg.Mod.IceServers = []pion.ICEServer{ - {URLs: []string{"stun:stun.l.google.com:19302"}}, + {URLs: []string{"stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"}}, } app.LoadConfig(&cfg) log = app.GetLogger("webrtc") - filters = cfg.Mod.Filters + if log.Debug().Enabled() { + itfs, _ := net.Interfaces() + for _, itf := range itfs { + addrs, _ := itf.Addrs() + log.Debug().Msgf("[webrtc] interface %+v addrs %v", itf, addrs) + } + } address, network, _ := strings.Cut(cfg.Mod.Listen, "/") for _, candidate := range cfg.Mod.Candidates { AddCandidate(network, candidate) + + if strings.HasPrefix(candidate, "stun:") && stuns == nil { + for _, ice := range cfg.Mod.IceServers { + for _, url := range ice.URLs { + if strings.HasPrefix(url, "stun:") { + stuns = append(stuns, url[5:]) + } + } + } + } + } + + webrtc.OnNewListener = func(ln any) { + switch ln := ln.(type) { + case *net.TCPListener: + log.Info().Stringer("addr", ln.Addr()).Msg("[webrtc] listen tcp") + case *net.UDPConn: + log.Info().Stringer("addr", ln.LocalAddr()).Msg("[webrtc] listen udp") + } } var err error // create pionAPI with custom codecs list and custom network settings - serverAPI, err = webrtc.NewServerAPI(network, address, &filters) + serverAPI, err = webrtc.NewServerAPI(network, address, &cfg.Mod.Filters) if err != nil { log.Error().Err(err).Caller().Send() return @@ -53,7 +79,6 @@ func Init() { clientAPI = serverAPI if address != "" { - log.Info().Str("addr", cfg.Mod.Listen).Msg("[webrtc] listen") clientAPI, _ = webrtc.NewAPI() } @@ -95,7 +120,7 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) (err error) { query := tr.Request.URL.Query() if name := query.Get("src"); name != "" { - stream = streams.GetOrPatch(query) + stream, _ = streams.GetOrPatch(query) mode = core.ModePassiveConsumer log.Debug().Str("src", name).Msg("[webrtc] new consumer") } else if name = query.Get("dst"); name != "" { diff --git a/internal/wyze/README.md b/internal/wyze/README.md new file mode 100644 index 00000000..ca7cf6c4 --- /dev/null +++ b/internal/wyze/README.md @@ -0,0 +1,106 @@ +# Wyze + +This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol without the Wyze app or SDK. + +**Important:** + +1. **Requires Wyze account**. You need to login once via the WebUI to load your cameras. +2. **Requires firmware with DTLS**. Only cameras with DTLS-enabled firmware are supported. +3. Internet access is only needed when loading cameras from your account. After that, all streaming is local P2P. +4. Connection to the camera is local only (direct P2P to camera IP). + +**Features:** + +- H.264 and H.265 video codec support +- AAC, G.711, PCM, and Opus audio codec support +- Two-way audio (intercom) support +- Resolution switching (HD/SD) + +## Setup + +1. Get your API Key from [Wyze Developer Portal](https://support.wyze.com/hc/en-us/articles/16129834216731) +2. Go to go2rtc WebUI > Add > Wyze +3. Enter your API ID, API Key, email, and password +4. Select cameras to add - stream URLs are generated automatically + +**Example Config** + +```yaml +wyze: + user@email.com: + api_id: "your-api-id" + api_key: "your-api-key" + password: "yourpassword" # or MD5 triple-hash with "md5:" prefix + +streams: + wyze_cam: wyze://192.168.1.123?uid=WYZEUID1234567890AB&enr=xxx&mac=AABBCCDDEEFF&model=HL_CAM4&dtls=true +``` + +## Stream URL Format + +The stream URL is automatically generated when you add cameras via the WebUI: + +``` +wyze://[IP]?uid=[P2P_ID]&enr=[ENR]&mac=[MAC]&model=[MODEL]&subtype=[hd|sd]&dtls=true +``` + +| Parameter | Description | +|-----------|-------------| +| `IP` | Camera's local IP address | +| `uid` | P2P identifier (20 chars) | +| `enr` | Encryption key for DTLS | +| `mac` | Device MAC address | +| `model` | Camera model (e.g., HL_CAM4) | +| `dtls` | Enable DTLS encryption (default: true) | +| `subtype` | Camera resolution: `hd` or `sd` (default: `hd`) | + +## Configuration + +### Resolution + +You can change the camera's resolution using the `subtype` parameter: + +```yaml +streams: + wyze_hd: wyze://...&subtype=hd + wyze_sd: wyze://...&subtype=sd +``` + +### Two-Way Audio + +Two-way audio (intercom) is supported automatically. When a consumer sends audio to the stream, it will be transmitted to the camera's speaker. + +## Camera Compatibility + +| Name | Model | Firmware | Protocol | Encryption | Codecs | +|------|-------|----------|----------|------------|--------| +| Wyze Cam v4 | HL_CAM4 | 4.52.9.4188 | TUTK | TransCode | h264, aac | +| | | 4.52.9.5332 | TUTK | HMAC-SHA1 | h264, aac | +| Wyze Cam v3 Pro | | | TUTK | | | +| Wyze Cam v3 | WYZE_CAKP2JFUS | 4.36.14.3497 | TUTK | TransCode | h264, pcm | +| Wyze Cam v2 | WYZEC1-JZ | 4.9.9.3006 | TUTK | TransCode | h264, pcmu | +| Wyze Cam v1 | | | TUTK | | | +| Wyze Cam Pan v4 | | | Gwell* | | | +| Wyze Cam Pan v3 | | | TUTK | | | +| Wyze Cam Pan v2 | | | TUTK | | | +| Wyze Cam Pan v1 | | | TUTK | | | +| Wyze Cam OG | | | Gwell* | | | +| Wyze Cam OG Telephoto | | | Gwell* | | | +| Wyze Cam OG (2025) | | | Gwell* | | | +| Wyze Cam Outdoor v2 | | | TUTK | | | +| Wyze Cam Outdoor v1 | | | TUTK | | | +| Wyze Cam Floodlight Pro | | | ? | | | +| Wyze Cam Floodlight v2 | | | TUTK | | | +| Wyze Cam Floodlight | | | TUTK | | | +| Wyze Video Doorbell v2 | HL_DB2 | 4.51.3.4992 | TUTK | TransCode | h264, pcm | +| Wyze Video Doorbell v1 | | | TUTK | | | +| Wyze Video Doorbell Pro | | | ? | | | +| Wyze Battery Video Doorbell | | | ? | | | +| Wyze Duo Cam Doorbell | | | ? | | | +| Wyze Battery Cam Pro | | | ? | | | +| Wyze Solar Cam Pan | | | ? | | | +| Wyze Duo Cam Pan | | | ? | | | +| Wyze Window Cam | | | ? | | | +| Wyze Bulb Cam | | | ? | | | + +_* Gwell based protocols are not yet supported._ diff --git a/internal/wyze/wyze.go b/internal/wyze/wyze.go new file mode 100644 index 00000000..982a16ed --- /dev/null +++ b/internal/wyze/wyze.go @@ -0,0 +1,202 @@ +package wyze + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "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/wyze" +) + +type AccountConfig struct { + APIKey string `yaml:"api_key"` + APIID string `yaml:"api_id"` + Password string `yaml:"password"` +} + +var accounts map[string]AccountConfig + +func Init() { + var v struct { + Cfg map[string]AccountConfig `yaml:"wyze"` + } + app.LoadConfig(&v) + + accounts = v.Cfg + + log := app.GetLogger("wyze") + + streams.HandleFunc("wyze", func(rawURL string) (core.Producer, error) { + log.Debug().Msgf("wyze: dial %s", rawURL) + return wyze.NewProducer(rawURL) + }) + + api.HandleFunc("api/wyze", apiWyze) +} + +func getCloud(email string) (*wyze.Cloud, error) { + cfg, ok := accounts[email] + if !ok { + return nil, fmt.Errorf("wyze: account not found: %s", email) + } + + if cfg.APIKey == "" || cfg.APIID == "" { + return nil, fmt.Errorf("wyze: api_key and api_id required for account: %s", email) + } + + cloud := wyze.NewCloud(cfg.APIKey, cfg.APIID) + + if err := cloud.Login(email, cfg.Password); err != nil { + return nil, err + } + + return cloud, nil +} + +func apiWyze(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + apiDeviceList(w, r) + case "POST": + apiAuth(w, r) + } +} + +func apiDeviceList(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + email := query.Get("id") + if email == "" { + accountList := make([]string, 0, len(accounts)) + for id := range accounts { + accountList = append(accountList, id) + } + api.ResponseJSON(w, accountList) + return + } + + err := func() error { + cloud, err := getCloud(email) + if err != nil { + return err + } + + cameras, err := cloud.GetCameraList() + if err != nil { + return err + } + + var items []*api.Source + for _, cam := range cameras { + items = append(items, &api.Source{ + Name: cam.Nickname, + Info: fmt.Sprintf("%s | %s | %s", cam.ProductModel, cam.MAC, cam.IP), + URL: buildStreamURL(cam), + }) + } + + api.ResponseSources(w, items) + return nil + }() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func apiAuth(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + email := r.Form.Get("email") + password := r.Form.Get("password") + apiKey := r.Form.Get("api_key") + apiID := r.Form.Get("api_id") + + if email == "" || password == "" || apiKey == "" || apiID == "" { + http.Error(w, "email, password, api_key and api_id required", http.StatusBadRequest) + return + } + + // Try to login + cloud := wyze.NewCloud(apiKey, apiID) + + if err := cloud.Login(email, password); err != nil { + // Check for MFA error + var authErr *wyze.AuthError + if ok := isAuthError(err, &authErr); ok { + w.Header().Set("Content-Type", api.MimeJSON) + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(authErr) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + cfg := map[string]string{ + "password": password, + "api_key": apiKey, + "api_id": apiID, + } + + if err := app.PatchConfig([]string{"wyze", email}, cfg); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if accounts == nil { + accounts = make(map[string]AccountConfig) + } + accounts[email] = AccountConfig{ + APIKey: apiKey, + APIID: apiID, + Password: password, + } + + cameras, err := cloud.GetCameraList() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var items []*api.Source + for _, cam := range cameras { + items = append(items, &api.Source{ + Name: cam.Nickname, + Info: fmt.Sprintf("%s | %s | %s", cam.ProductModel, cam.MAC, cam.IP), + URL: buildStreamURL(cam), + }) + } + + api.ResponseSources(w, items) +} + +func buildStreamURL(cam *wyze.Camera) string { + query := url.Values{} + query.Set("uid", cam.P2PID) + query.Set("enr", cam.ENR) + query.Set("mac", cam.MAC) + query.Set("model", cam.ProductModel) + + if cam.DTLS == 1 { + query.Set("dtls", "true") + } + + return fmt.Sprintf("wyze://%s?%s", cam.IP, query.Encode()) +} + +func isAuthError(err error, target **wyze.AuthError) bool { + if e, ok := err.(*wyze.AuthError); ok { + *target = e + return true + } + return false +} diff --git a/internal/xiaomi/README.md b/internal/xiaomi/README.md new file mode 100644 index 00000000..f46dcdd1 --- /dev/null +++ b/internal/xiaomi/README.md @@ -0,0 +1,64 @@ +# Xiaomi + +**Added in v1.9.13. Improved in v1.9.14.** + +This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. + +Since 2020, Xiaomi has introduced a unified protocol for cameras called `miss`. I think it means **Mi Secure Streaming**. Until this point, the camera protocols were in chaos. Almost every model had different authorization, encryption, command lists, and media packet formats. + +Go2rtc support two formats: `xiaomi/mess` and `xiaomi/legacy`. +And multiple P2P protocols: `cs2+udp`, `cs2+tcp`, several versions of `tutk+udp`. + +Almost all cameras in the `xiaomi/mess` format and the `cs2` protocol work well. +Older `xiaomi/legacy` format cameras may have support issues. +The `tutk` protocol is the worst thing that's ever happened to the P2P world. It works terribly. + +**Important:** + +1. **Not all cameras are supported**. The list of supported cameras is collected in [this issue](https://github.com/AlexxIT/go2rtc/issues/1982). +2. Each time you connect to the camera, you need internet access to obtain encryption keys. +3. Connection to the camera is local only. + +**Features:** + +- Multiple Xiaomi accounts supported +- Cameras from multiple regions are supported for a single account +- Two-way audio is supported +- Cameras with multiple lenses are supported + +## Setup + +1. Goto go2rtc WebUI > Add > Xiaomi > Login with username and password +2. Receive verification code by email or phone if required. +3. Complete the captcha if required. +4. If everything is OK, your account will be added, and you can load cameras from it. + +**Example** + +```yaml +xiaomi: + 1234567890: V1:*** + +streams: + xiaomi1: xiaomi://1234567890:cn@192.168.1.123?did=9876543210&model=isa.camera.hlc7 +``` + +## Configuration + +Quality in the `miss` protocol is specified by a number from 0 to 5. Usually 0 means auto, 1 - sd, 2 - hd. +Go2rtc by default sets quality to 2. But some new cameras have HD quality at number 3. +Old cameras may have broken codec settings at number 3, so this number should not be set for all cameras. + +You can change camera's quality: `subtype=hd/sd/auto/0-5`. + +```yaml +streams: + xiaomi1: xiaomi://***&subtype=sd +``` + +You can use second channel for Dual cameras: `channel=2`. + +```yaml +streams: + xiaomi1: xiaomi://***&channel=2 +``` diff --git a/internal/xiaomi/xiaomi.go b/internal/xiaomi/xiaomi.go new file mode 100644 index 00000000..1801fa86 --- /dev/null +++ b/internal/xiaomi/xiaomi.go @@ -0,0 +1,351 @@ +package xiaomi + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + + "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/xiaomi" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" +) + +func Init() { + var v struct { + Cfg map[string]string `yaml:"xiaomi"` + } + app.LoadConfig(&v) + + tokens = v.Cfg + + log := app.GetLogger("xiaomi") + + streams.HandleFunc("xiaomi", func(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + if u.User != nil { + rawURL, err = getCameraURL(u) + if err != nil { + return nil, err + } + } + + log.Debug().Msgf("xiaomi: dial %s", rawURL) + + return xiaomi.Dial(rawURL) + }) + + api.HandleFunc("api/xiaomi", apiXiaomi) +} + +var tokens map[string]string +var clouds map[string]*xiaomi.Cloud +var cloudsMu sync.Mutex + +func getCloud(userID string) (*xiaomi.Cloud, error) { + cloudsMu.Lock() + defer cloudsMu.Unlock() + + if cloud := clouds[userID]; cloud != nil { + return cloud, nil + } + + cloud := xiaomi.NewCloud(AppXiaomiHome) + if err := cloud.LoginWithToken(userID, tokens[userID]); err != nil { + return nil, err + } + if clouds == nil { + clouds = map[string]*xiaomi.Cloud{userID: cloud} + } else { + clouds[userID] = cloud + } + return cloud, nil +} + +func cloudRequest(userID, region, apiURL, params string) ([]byte, error) { + cloud, err := getCloud(userID) + if err != nil { + return nil, err + } + return cloud.Request(GetBaseURL(region), apiURL, params, nil) +} + +func cloudUserRequest(user *url.Userinfo, apiURL, params string) ([]byte, error) { + userID := user.Username() + region, _ := user.Password() + return cloudRequest(userID, region, apiURL, params) +} + +func getCameraURL(url *url.URL) (string, error) { + model := url.Query().Get("model") + + // It is not known which models need to be awakened. + // Probably all the doorbells and all the battery cameras. + if strings.Contains(model, ".cateye.") { + _ = wakeUpCamera(url) + } + + // The getMissURL request has a fallback to getP2PURL. + // But for known models we can save one request to the cloud. + if xiaomi.IsLegacy(model) { + return getLegacyURL(url) + } + return getMissURL(url) +} + +func getLegacyURL(url *url.URL) (string, error) { + query := url.Query() + + clientPublic, clientPrivate, err := crypto.GenerateKey() + if err != nil { + return "", err + } + + params := fmt.Sprintf(`{"did":"%s","toSignAppData":"%x"}`, query.Get("did"), clientPublic) + + userID := url.User.Username() + region, _ := url.User.Password() + res, err := cloudRequest(userID, region, "/device/devicepass", params) + if err != nil { + return "", err + } + + var v struct { + UID string `json:"p2p_id"` + Password string `json:"password"` + PublicKey string `json:"p2p_dev_public_key"` + Sign string `json:"signForAppData"` + } + if err = json.Unmarshal(res, &v); err != nil { + return "", err + } + + query.Set("uid", v.UID) + + if v.Sign != "" { + query.Set("client_public", hex.EncodeToString(clientPublic)) + query.Set("client_private", hex.EncodeToString(clientPrivate)) + query.Set("device_public", v.PublicKey) + query.Set("sign", v.Sign) + } else { + query.Set("password", v.Password) + } + + url.RawQuery = query.Encode() + return url.String(), nil +} + +func getMissURL(url *url.URL) (string, error) { + clientPublic, clientPrivate, err := crypto.GenerateKey() + if err != nil { + return "", err + } + + query := url.Query() + params := fmt.Sprintf( + `{"app_pubkey":"%x","did":"%s","support_vendors":"TUTK_CS2_MTP"}`, + clientPublic, query.Get("did"), + ) + + res, err := cloudUserRequest(url.User, "/v2/device/miss_get_vendor", params) + if err != nil { + if strings.Contains(err.Error(), "no available vendor support") { + return getLegacyURL(url) + } + return "", err + } + + var v struct { + Vendor struct { + ID byte `json:"vendor"` + Params struct { + UID string `json:"p2p_id"` + } `json:"vendor_params"` + } `json:"vendor"` + PublicKey string `json:"public_key"` + Sign string `json:"sign"` + } + if err = json.Unmarshal(res, &v); err != nil { + return "", err + } + + query.Set("client_public", hex.EncodeToString(clientPublic)) + query.Set("client_private", hex.EncodeToString(clientPrivate)) + query.Set("device_public", v.PublicKey) + query.Set("sign", v.Sign) + query.Set("vendor", getVendorName(v.Vendor.ID)) + + if v.Vendor.ID == 1 { + query.Set("uid", v.Vendor.Params.UID) + } + + url.RawQuery = query.Encode() + return url.String(), nil +} + +func getVendorName(i byte) string { + switch i { + case 1: + return "tutk" + case 3: + return "agora" + case 4: + return "cs2" + case 6: + return "mtp" + } + return fmt.Sprintf("%d", i) +} + +func wakeUpCamera(url *url.URL) error { + const params = `{"id":1,"method":"wakeup","params":{"video":"1"}}` + did := url.Query().Get("did") + _, err := cloudUserRequest(url.User, "/home/rpc/"+did, params) + return err +} + +func apiXiaomi(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + apiDeviceList(w, r) + case "POST": + apiAuth(w, r) + } +} + +func apiDeviceList(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + user := query.Get("id") + if user == "" { + cloudsMu.Lock() + users := make([]string, 0, len(tokens)) + for s := range tokens { + users = append(users, s) + } + cloudsMu.Unlock() + + api.ResponseJSON(w, users) + return + } + + err := func() error { + region := query.Get("region") + res, err := cloudRequest(user, region, "/v2/home/device_list_page", "{}") + if err != nil { + return err + } + var v struct { + List []*Device `json:"list"` + } + + if err = json.Unmarshal(res, &v); err != nil { + return err + } + + var items []*api.Source + + for _, device := range v.List { + if !strings.Contains(device.Model, ".camera.") && !strings.Contains(device.Model, ".cateye.") { + continue + } + items = append(items, &api.Source{ + Name: device.Name, + Info: fmt.Sprintf("ip: %s, mac: %s", device.IP, device.MAC), + URL: fmt.Sprintf("xiaomi://%s:%s@%s?did=%s&model=%s", user, region, device.IP, device.Did, device.Model), + }) + } + + api.ResponseSources(w, items) + return nil + }() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +type Device struct { + Did string `json:"did"` + Name string `json:"name"` + Model string `json:"model"` + MAC string `json:"mac"` + IP string `json:"localip"` +} + +var auth *xiaomi.Cloud + +func apiAuth(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + username := r.Form.Get("username") + password := r.Form.Get("password") + captcha := r.Form.Get("captcha") + verify := r.Form.Get("verify") + + var err error + + switch { + case username != "" || password != "": + auth = xiaomi.NewCloud(AppXiaomiHome) + err = auth.Login(username, password) + case captcha != "": + err = auth.LoginWithCaptcha(captcha) + case verify != "": + err = auth.LoginWithVerify(verify) + default: + http.Error(w, "wrong request", http.StatusBadRequest) + return + } + + if err == nil { + userID, token := auth.UserToken() + auth = nil + + cloudsMu.Lock() + if tokens == nil { + tokens = map[string]string{userID: token} + } else { + tokens[userID] = token + } + cloudsMu.Unlock() + + err = app.PatchConfig([]string{"xiaomi", userID}, token) + } + + if err != nil { + var login *xiaomi.LoginError + if errors.As(err, &login) { + w.Header().Set("Content-Type", api.MimeJSON) + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(err) + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +const AppXiaomiHome = "xiaomiio" + +func GetBaseURL(region string) string { + switch region { + case "de", "i2", "ru", "sg", "us": + return "https://" + region + ".api.io.mi.com/app" + } + return "https://api.io.mi.com/app" +} diff --git a/main.go b/main.go index fe9eb004..ca70e84d 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + "slices" + "github.com/AlexxIT/go2rtc/internal/alsa" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api/ws" @@ -25,9 +27,11 @@ import ( "github.com/AlexxIT/go2rtc/internal/mjpeg" "github.com/AlexxIT/go2rtc/internal/mp4" "github.com/AlexxIT/go2rtc/internal/mpegts" + "github.com/AlexxIT/go2rtc/internal/multitrans" "github.com/AlexxIT/go2rtc/internal/nest" "github.com/AlexxIT/go2rtc/internal/ngrok" "github.com/AlexxIT/go2rtc/internal/onvif" + "github.com/AlexxIT/go2rtc/internal/pinggy" "github.com/AlexxIT/go2rtc/internal/ring" "github.com/AlexxIT/go2rtc/internal/roborock" "github.com/AlexxIT/go2rtc/internal/rtmp" @@ -35,77 +39,85 @@ import ( "github.com/AlexxIT/go2rtc/internal/srtp" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/tapo" + "github.com/AlexxIT/go2rtc/internal/tuya" "github.com/AlexxIT/go2rtc/internal/v4l2" "github.com/AlexxIT/go2rtc/internal/webrtc" "github.com/AlexxIT/go2rtc/internal/webtorrent" "github.com/AlexxIT/go2rtc/internal/wyoming" + "github.com/AlexxIT/go2rtc/internal/wyze" + "github.com/AlexxIT/go2rtc/internal/xiaomi" "github.com/AlexxIT/go2rtc/internal/yandex" "github.com/AlexxIT/go2rtc/pkg/shell" ) func main() { - app.Version = "1.9.11" + // version will be set later from -buildvcs info, this used only as fallback + app.Version = "1.9.14" - // 1. Core modules: app, api/ws, streams + type module struct { + name string + init func() + } - app.Init() // init config and logs + modules := []module{ + {"", app.Init}, // init config and logs + {"api", api.Init}, // init API before all others + {"ws", ws.Init}, // init WS API endpoint + {"", streams.Init}, + // Main sources and servers + {"http", http.Init}, // rtsp source, HTTP server + {"rtsp", rtsp.Init}, // rtsp source, RTSP server + {"webrtc", webrtc.Init}, // webrtc source, WebRTC server + // Main API + {"mp4", mp4.Init}, // MP4 API + {"hls", hls.Init}, // HLS API + {"mjpeg", mjpeg.Init}, // MJPEG API + // Other sources and servers + {"hass", hass.Init}, // hass source, Hass API server + {"homekit", homekit.Init}, // homekit source, HomeKit server + {"onvif", onvif.Init}, // onvif source, ONVIF API server + {"rtmp", rtmp.Init}, // rtmp source, RTMP server + {"webtorrent", webtorrent.Init}, // webtorrent source, WebTorrent module + {"wyoming", wyoming.Init}, + // Exec and script sources + {"echo", echo.Init}, + {"exec", exec.Init}, + {"expr", expr.Init}, + {"ffmpeg", ffmpeg.Init}, + // Hardware sources + {"alsa", alsa.Init}, + {"v4l2", v4l2.Init}, + // Other sources + {"bubble", bubble.Init}, + {"doorbird", doorbird.Init}, + {"dvrip", dvrip.Init}, + {"eseecloud", eseecloud.Init}, + {"flussonic", flussonic.Init}, + {"gopro", gopro.Init}, + {"isapi", isapi.Init}, + {"ivideon", ivideon.Init}, + {"mpegts", mpegts.Init}, + {"multitrans", multitrans.Init}, + {"nest", nest.Init}, + {"ring", ring.Init}, + {"roborock", roborock.Init}, + {"tapo", tapo.Init}, + {"tuya", tuya.Init}, + {"wyze", wyze.Init}, + {"xiaomi", xiaomi.Init}, + {"yandex", yandex.Init}, + // Helper modules + {"debug", debug.Init}, + {"ngrok", ngrok.Init}, + {"pinggy", pinggy.Init}, + {"srtp", srtp.Init}, + } - api.Init() // init API before all others - ws.Init() // init WS API endpoint - - streams.Init() // streams module - - // 2. Main sources and servers - - rtsp.Init() // rtsp source, RTSP server - webrtc.Init() // webrtc source, WebRTC server - - // 3. Main API - - mp4.Init() // MP4 API - hls.Init() // HLS API - mjpeg.Init() // MJPEG API - - // 4. Other sources and servers - - hass.Init() // hass source, Hass API server - onvif.Init() // onvif source, ONVIF API server - webtorrent.Init() // webtorrent source, WebTorrent module - wyoming.Init() - - // 5. Other sources - - rtmp.Init() // rtmp source - exec.Init() // exec source - ffmpeg.Init() // ffmpeg source - echo.Init() // echo source - ivideon.Init() // ivideon source - http.Init() // http/tcp source - dvrip.Init() // dvrip source - tapo.Init() // tapo source - isapi.Init() // isapi source - mpegts.Init() // mpegts passive source - roborock.Init() // roborock source - homekit.Init() // homekit source - ring.Init() // ring source - nest.Init() // nest source - bubble.Init() // bubble source - expr.Init() // expr source - gopro.Init() // gopro source - doorbird.Init() // doorbird source - v4l2.Init() // v4l2 source - alsa.Init() // alsa source - flussonic.Init() - eseecloud.Init() - yandex.Init() - - // 6. Helper modules - - ngrok.Init() // ngrok module - srtp.Init() // SRTP server - debug.Init() // debug API - - // 7. Go + for _, m := range modules { + if app.Modules == nil || m.name == "" || slices.Contains(app.Modules, m.name) { + m.init() + } + } shell.RunUntilSignal() } diff --git a/pkg/README.md b/pkg/README.md index e2759638..479dcce3 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -10,35 +10,49 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent - Codecs can be incoming - **Recevers codecs** - Codecs can be outgoing (two way audio) - **Senders codecs** -| Format | Source protocols | Ingress protocols | Recevers codecs | Senders codecs | Example | -|--------------|------------------|-------------------|------------------------------|--------------------|---------------| -| adts | http,tcp,pipe | http | aac | | `http:` | -| alsa | pipe | | | pcm | `alsa:` | -| bubble | http | | h264,hevc,pcm_alaw | | `bubble:` | -| dvrip | tcp | | h264,hevc,pcm_alaw,pcm_mulaw | pcm_alaw | `dvrip:` | -| flv | http,tcp,pipe | http | h264,aac | | `http:` | -| gopro | http+udp | | TODO | | `gopro:` | -| hass/webrtc | ws+udp,tcp | | TODO | | `hass:` | -| hls/mpegts | http | | h264,h265,aac,opus | | `http:` | -| homekit | homekit+udp | | h264,eld* | | `homekit:` | -| isapi | http | | | pcm_alaw,pcm_mulaw | `isapi:` | -| ivideon | ws | | h264 | | `ivideon:` | -| kasa | http | | h264,pcm_mulaw | | `kasa:` | -| h264 | http,tcp,pipe | http | h264 | | `http:` | -| hevc | http,tcp,pipe | http | hevc | | `http:` | -| mjpeg | http,tcp,pipe | http | mjpeg | | `http:` | -| mpjpeg | http,tcp,pipe | http | mjpeg | | `http:` | -| mpegts | http,tcp,pipe | http | h264,hevc,aac,opus | | `http:` | -| nest/webrtc | http+udp | | TODO | | `nest:` | -| roborock | mqtt+udp | | h264,opus | opus | `roborock:` | -| rtmp | rtmp | rtmp | h264,aac | | `rtmp:` | -| rtsp | rtsp+tcp,ws | rtsp+tcp | h264,hevc,aac,pcm*,opus | pcm*,opus | `rtsp:` | -| stdin | pipe | | | pcm_alaw,pcm_mulaw | `stdin:` | -| tapo | http | | h264,pcma | pcm_alaw | `tapo:` | -| wav | http,tcp,pipe | http | pcm_alaw,pcm_mulaw | | `http:` | -| webrtc* | TODO | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw | `webrtc:` | -| webtorrent | TODO | TODO | TODO | TODO | `webtorrent:` | -| yuv4mpegpipe | http,tcp,pipe | http | rawvideo | | `http:` | +| Group | Format | Protocols | Ingress | Recevers codecs | Senders codecs | Example | +|------------|--------------|-----------------|---------|---------------------------------|---------------------|---------------| +| Devices | alsa | pipe | | | pcm | `alsa:` | +| Devices | v4l2 | pipe | | | | `v4l2:` | +| Files | adts | http, tcp, pipe | http | aac | | `http:` | +| Files | flv | http, tcp, pipe | http | h264, aac | | `http:` | +| Files | h264 | http, tcp, pipe | http | h264 | | `http:` | +| Files | hevc | http, tcp, pipe | http | hevc | | `http:` | +| Files | hls | http | | h264, h265, aac, opus | | `http:` | +| Files | mjpeg | http, tcp, pipe | http | mjpeg | | `http:` | +| Files | mpegts | http, tcp, pipe | http | h264, hevc, aac, opus | | `http:` | +| Files | wav | http, tcp, pipe | http | pcm_alaw, pcm_mulaw | | `http:` | +| Net (pub) | mpjpeg | http, tcp, pipe | http | mjpeg | | `http:` | +| Net (pub) | onvif | rtsp | | | | `onvif:` | +| Net (pub) | rtmp | rtmp | rtmp | h264, aac | | `rtmp:` | +| Net (pub) | rtsp | rtsp, ws | rtsp | h264, hevc, aac, pcm*, opus | pcm*, opus | `rtsp:` | +| Net (pub) | webrtc* | webrtc | webrtc | h264, pcm_alaw, pcm_mulaw, opus | pcm_alaw, pcm_mulaw | `webrtc:` | +| Net (pub) | yuv4mpegpipe | http, tcp, pipe | http | rawvideo | | `http:` | +| Net (priv) | bubble | http | | h264, hevc, pcm_alaw | | `bubble:` | +| Net (priv) | doorbird | http | | | | `doorbird:` | +| Net (priv) | dvrip | tcp | | h264, hevc, pcm_alaw, pcm_mulaw | pcm_alaw | `dvrip:` | +| Net (priv) | eseecloud | http | | | | `eseecloud:` | +| Net (priv) | gopro | udp | | TODO | | `gopro:` | +| Net (priv) | hass | webrtc | | TODO | | `hass:` | +| Net (priv) | homekit | hap | | h264, eld* | | `homekit:` | +| Net (priv) | isapi | http | | | pcm_alaw, pcm_mulaw | `isapi:` | +| Net (priv) | kasa | http | | h264, pcm_mulaw | | `kasa:` | +| Net (priv) | nest | rtsp, webrtc | | TODO | | `nest:` | +| Net (priv) | ring | webrtc | | | | `ring:` | +| Net (priv) | roborock | webrtc | | h264, opus | opus | `roborock:` | +| Net (priv) | tapo | http | | h264, pcma | pcm_alaw | `tapo:` | +| Net (priv) | tuya | webrtc | | | | `tuya:` | +| Net (priv) | vigi | http | | | | `vigi:` | +| Net (priv) | webtorrent | webrtc | TODO | TODO | TODO | `webtorrent:` | +| Net (priv) | xiaomi* | cs2, tutk | | | | `xiaomi:` | +| Services | flussonic | ws | | | | `flussonic:` | +| Services | ivideon | ws | | h264 | | `ivideon:` | +| Services | yandex | webrtc | | | | `yandex:` | +| Other | echo | * | | | | `echo:` | +| Other | exec | pipe, rtsp | | | | `exec:` | +| Other | expr | * | | | | `expr:` | +| Other | ffmpeg | pipe, rtsp | | | | `ffmpeg:` | +| Other | stdin | pipe | | | pcm_alaw, pcm_mulaw | `stdin:` | - **eld** - rare variant of aac codec - **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le @@ -46,23 +60,23 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent ## Consumers (output) -| Format | Protocol | Send codecs | Recv codecs | Example | -|--------------|-------------|------------------------------|-------------------------|---------------------------------------| -| adts | http | aac | | `GET /api/stream.adts` | -| ascii | http | mjpeg | | `GET /api/stream.ascii` | -| flv | http | h264,aac | | `GET /api/stream.flv` | -| hls/mpegts | http | h264,hevc,aac | | `GET /api/stream.m3u8` | -| hls/fmp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.m3u8?mp4` | -| homekit | homekit+udp | h264,opus | | Apple HomeKit app | -| mjpeg | ws | mjpeg | | `{"type":"mjpeg"}` -> `/api/ws` | -| mpjpeg | http | mjpeg | | `GET /api/stream.mjpeg` | -| mp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.mp4` | -| mse/fmp4 | ws | h264,hevc,aac,pcm*,opus | | `{"type":"mse"}` -> `/api/ws` | -| mpegts | http | h264,hevc,aac | | `GET /api/stream.ts` | -| rtmp | rtmp | h264,aac | | `rtmp://localhost:1935/{stream_name}` | -| rtsp | rtsp+tcp | h264,hevc,aac,pcm*,opus | | `rtsp://localhost:8554/{stream_name}` | -| webrtc | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw,opus | `{"type":"webrtc"}` -> `/api/ws` | -| yuv4mpegpipe | http | rawvideo | | `GET /api/stream.y4m` | +| Format | Protocol | Send codecs | Recv codecs | Example | +|--------------|----------|---------------------------------|---------------------------|---------------------------------------| +| adts | http | aac | | `GET /api/stream.adts` | +| ascii | http | mjpeg | | `GET /api/stream.ascii` | +| flv | http | h264, aac | | `GET /api/stream.flv` | +| hls/mpegts | http | h264, hevc, aac | | `GET /api/stream.m3u8` | +| hls/fmp4 | http | h264, hevc, aac, pcm*, opus | | `GET /api/stream.m3u8?mp4` | +| homekit | hap | h264, opus | | Apple HomeKit app | +| mjpeg | ws | mjpeg | | `{"type":"mjpeg"}` -> `/api/ws` | +| mpjpeg | http | mjpeg | | `GET /api/stream.mjpeg` | +| mp4 | http | h264, hevc, aac, pcm*, opus | | `GET /api/stream.mp4` | +| mse/fmp4 | ws | h264, hevc, aac, pcm*, opus | | `{"type":"mse"}` -> `/api/ws` | +| mpegts | http | h264, hevc, aac | | `GET /api/stream.ts` | +| rtmp | rtmp | h264, aac | | `rtmp://localhost:1935/{stream_name}` | +| rtsp | rtsp | h264, hevc, aac, pcm*, opus | | `rtsp://localhost:8554/{stream_name}` | +| webrtc | webrtc | h264, pcm_alaw, pcm_mulaw, opus | pcm_alaw, pcm_mulaw, opus | `{"type":"webrtc"}` -> `/api/ws` | +| yuv4mpegpipe | http | rawvideo | | `GET /api/stream.y4m` | - **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le diff --git a/pkg/aac/adts.go b/pkg/aac/adts.go index 6688d319..140b1ba2 100644 --- a/pkg/aac/adts.go +++ b/pkg/aac/adts.go @@ -8,8 +8,26 @@ import ( "github.com/pion/rtp" ) +const ADTSHeaderSize = 7 + +func ADTSHeaderLen(b []byte) int { + if HasCRC(b) { + return 9 // 7 bytes header + 2 bytes CRC + } + return ADTSHeaderSize +} + func IsADTS(b []byte) bool { - return len(b) > 7 && b[0] == 0xFF && b[1]&0xF6 == 0xF0 + // AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ) + // A 12 Syncword, all bits must be set to 1. + // C 2 Layer, always set to 0. + return len(b) >= ADTSHeaderSize && b[0] == 0xFF && b[1]&0b1111_0110 == 0xF0 +} + +func HasCRC(b []byte) bool { + // AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ) + // D 1 Protection absence, set to 1 if there is no CRC and 0 if there is CRC. + return b[1]&0b1 == 0 } func ADTSToCodec(b []byte) *core.Codec { @@ -58,7 +76,7 @@ func ADTSToCodec(b []byte) *core.Codec { func ReadADTSSize(b []byte) uint16 { // AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ) _ = b[5] // bounds - return uint16(b[3]&0x03)<<(8+3) | uint16(b[4])<<3 | uint16(b[5]>>5) + return uint16(b[3]&0b11)<<11 | uint16(b[4])<<3 | uint16(b[5]>>5) } func WriteADTSSize(b []byte, size uint16) { diff --git a/pkg/aac/producer.go b/pkg/aac/producer.go index efd2d175..a2c73f92 100644 --- a/pkg/aac/producer.go +++ b/pkg/aac/producer.go @@ -2,7 +2,7 @@ package aac import ( "bufio" - "encoding/binary" + "errors" "io" "github.com/AlexxIT/go2rtc/pkg/core" @@ -17,16 +17,22 @@ type Producer struct { func Open(r io.Reader) (*Producer, error) { rd := bufio.NewReader(r) - b, err := rd.Peek(8) + b, err := rd.Peek(ADTSHeaderSize) if err != nil { return nil, err } + codec := ADTSToCodec(b) + if codec == nil { + return nil, errors.New("adts: wrong header") + } + codec.PayloadType = core.PayloadTypeRAW + medias := []*core.Media{ { Kind: core.KindAudio, Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ADTSToCodec(b)}, + Codecs: []*core.Codec{codec}, }, } return &Producer{ @@ -42,14 +48,25 @@ func Open(r io.Reader) (*Producer, error) { func (c *Producer) Start() error { for { - b, err := c.rd.Peek(6) - if err != nil { + // read ADTS header + adts := make([]byte, ADTSHeaderSize) + if _, err := io.ReadFull(c.rd, adts); err != nil { return err } - auSize := ReadADTSSize(b) - payload := make([]byte, 2+2+auSize) - if _, err = io.ReadFull(c.rd, payload[4:]); err != nil { + auSize := ReadADTSSize(adts) - ADTSHeaderSize + + if HasCRC(adts) { + // skip CRC after header + if _, err := c.rd.Discard(2); err != nil { + return err + } + auSize -= 2 + } + + // read AAC payload after header + payload := make([]byte, auSize) + if _, err := io.ReadFull(c.rd, payload); err != nil { return err } @@ -59,9 +76,6 @@ func (c *Producer) Start() error { continue } - payload[1] = 16 // header size in bits - binary.BigEndian.PutUint16(payload[2:], auSize<<3) - pkt := &rtp.Packet{ Header: rtp.Header{Timestamp: core.Now90000()}, Payload: payload, diff --git a/pkg/aac/rtp.go b/pkg/aac/rtp.go index 1faa2e27..08846c06 100644 --- a/pkg/aac/rtp.go +++ b/pkg/aac/rtp.go @@ -8,7 +8,6 @@ import ( ) const RTPPacketVersionAAC = 0 -const ADTSHeaderSize = 7 func RTPDepay(handler core.HandlerFunc) core.HandlerFunc { var timestamp uint32 @@ -65,7 +64,8 @@ func RTPDepay(handler core.HandlerFunc) core.HandlerFunc { } func RTPPay(handler core.HandlerFunc) core.HandlerFunc { - sequencer := rtp.NewRandomSequencer() + var seq uint16 + var ts uint32 return func(packet *rtp.Packet) { if packet.Version != RTPPacketVersionAAC { @@ -85,12 +85,15 @@ func RTPPay(handler core.HandlerFunc) core.HandlerFunc { Header: rtp.Header{ Version: 2, Marker: true, - SequenceNumber: sequencer.NextSequenceNumber(), - Timestamp: packet.Timestamp, + SequenceNumber: seq, + Timestamp: ts, }, Payload: payload, } handler(&clone) + + seq++ + ts += AUTime } } diff --git a/pkg/core/codec.go b/pkg/core/codec.go index ba0c656a..11276bc7 100644 --- a/pkg/core/codec.go +++ b/pkg/core/codec.go @@ -259,9 +259,9 @@ func ParseCodecString(s string) *Codec { codec.Name = CodecPCM case "pcm_s16le", "s16le", "pcml": codec.Name = CodecPCML - case "pcm_alaw", "alaw", "pcma": + case "pcm_alaw", "alaw", "pcma", "g711a": codec.Name = CodecPCMA - case "pcm_mulaw", "mulaw", "pcmu": + case "pcm_mulaw", "mulaw", "pcmu", "g711u": codec.Name = CodecPCMU case "aac", "mpeg4-generic": codec.Name = CodecAAC @@ -277,7 +277,7 @@ func ParseCodecString(s string) *Codec { codec.ClockRate = uint32(Atoi(ss[1])) } if len(ss) >= 3 { - codec.Channels = uint8(Atoi(ss[1])) + codec.Channels = uint8(Atoi(ss[2])) } return &codec diff --git a/pkg/core/helpers.go b/pkg/core/helpers.go index 161a5504..52b969a7 100644 --- a/pkg/core/helpers.go +++ b/pkg/core/helpers.go @@ -11,9 +11,9 @@ import ( const ( BufferSize = 64 * 1024 // 64K - ConnDialTimeout = time.Second * 3 - ConnDeadline = time.Second * 5 - ProbeTimeout = time.Second * 3 + ConnDialTimeout = 5 * time.Second + ConnDeadline = 5 * time.Second + ProbeTimeout = 5 * time.Second ) // Now90000 - timestamp for Video (clock rate = 90000 samples per second) @@ -67,6 +67,21 @@ func Atoi(s string) (i int) { return } +// ParseByte - fast parsing string to byte function +func ParseByte(s string) (b byte) { + for i, ch := range []byte(s) { + ch -= '0' + if ch > 9 { + return 0 + } + if i > 0 { + b *= 10 + } + b += ch + } + return +} + func Assert(ok bool) { if !ok { _, file, line, _ := runtime.Caller(1) diff --git a/pkg/debug/conn.go b/pkg/debug/conn.go new file mode 100644 index 00000000..6261cb75 --- /dev/null +++ b/pkg/debug/conn.go @@ -0,0 +1,47 @@ +package debug + +import ( + "bytes" + "math/rand" + "net" +) + +type badConn struct { + net.Conn + delay int + buf []byte +} + +func NewBadConn(conn net.Conn) net.Conn { + return &badConn{Conn: conn} +} + +const ( + missChance = 0.05 + delayChance = 0.1 +) + +func (c *badConn) Read(b []byte) (n int, err error) { + if rand.Float32() < missChance { + if _, err = c.Conn.Read(b); err != nil { + return + } + //log.Printf("bad conn: miss") + } + + if c.delay > 0 { + if c.delay--; c.delay == 0 { + n = copy(b, c.buf) + return + } + } else if rand.Float32() < delayChance { + if n, err = c.Conn.Read(b); err != nil { + return + } + c.delay = 1 + rand.Intn(5) + c.buf = bytes.Clone(b[:n]) + //log.Printf("bad conn: delay %d", c.delay) + } + + return c.Conn.Read(b) +} diff --git a/pkg/expr/expr.go b/pkg/expr/expr.go index 4a8a663c..e82551be 100644 --- a/pkg/expr/expr.go +++ b/pkg/expr/expr.go @@ -1,40 +1,78 @@ package expr import ( + "bytes" "encoding/json" "fmt" "io" "net/http" + "net/http/cookiejar" + "net/url" "regexp" "strings" + "time" - "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/expr-lang/expr" "github.com/expr-lang/expr/vm" ) -func newRequest(method, url string, headers map[string]any, body string) (*http.Request, error) { +func newRequest(rawURL string, options map[string]any) (*http.Request, error) { + var method, contentType string var rd io.Reader - if method == "" { + // method from js fetch + if s, ok := options["method"].(string); ok { + method = s + } else { method = "GET" } - if body != "" { - rd = strings.NewReader(body) + + // params key from python requests + if kv, ok := options["params"].(map[string]any); ok { + rawURL += "?" + url.Values(kvToString(kv)).Encode() } - req, err := http.NewRequest(method, url, rd) + // json key from python requests + // data key from python requests + // body key from js fetch + if v, ok := options["json"]; ok { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + contentType = "application/json" + rd = bytes.NewReader(b) + } else if kv, ok := options["data"].(map[string]any); ok { + contentType = "application/x-www-form-urlencoded" + rd = strings.NewReader(url.Values(kvToString(kv)).Encode()) + } else if s, ok := options["body"].(string); ok { + rd = strings.NewReader(s) + } + + req, err := http.NewRequest(method, rawURL, rd) if err != nil { return nil, err } - for k, v := range headers { - req.Header.Set(k, fmt.Sprintf("%v", v)) + if kv, ok := options["headers"].(map[string]any); ok { + req.Header = kvToString(kv) + } + + if contentType != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", contentType) } return req, nil } +func kvToString(kv map[string]any) map[string][]string { + dst := make(map[string][]string, len(kv)) + for k, v := range kv { + dst[k] = []string{fmt.Sprintf("%v", v)} + } + return dst +} + func regExp(params ...any) (*regexp.Regexp, error) { exp := params[0].(string) if len(params) >= 2 { @@ -49,72 +87,69 @@ func regExp(params ...any) (*regexp.Regexp, error) { return regexp.Compile(exp) } -var Options = []expr.Option{ - expr.Function( - "fetch", - func(params ...any) (any, error) { - var req *http.Request - var err error - - url := params[0].(string) - - if len(params) == 2 { - options := params[1].(map[string]any) - method, _ := options["method"].(string) - headers, _ := options["headers"].(map[string]any) - body, _ := options["body"].(string) - req, err = newRequest(method, url, headers, body) - } else { - req, err = http.NewRequest("GET", url, nil) - } - - if err != nil { - return nil, err - } - - res, err := tcp.Do(req) - if err != nil { - return nil, err - } - - b, _ := io.ReadAll(res.Body) - - return map[string]any{ - "ok": res.StatusCode < 400, - "status": res.Status, - "text": string(b), - "json": func() (v any) { - _ = json.Unmarshal(b, &v) - return - }, - }, nil - }, - //new(func(url string) map[string]any), - //new(func(url string, options map[string]any) map[string]any), - ), - expr.Function( - "match", - func(params ...any) (any, error) { - re, err := regExp(params[1:]...) - if err != nil { - return nil, err - } - str := params[0].(string) - return re.FindStringSubmatch(str), nil - }, - //new(func(str, expr string) []string), - //new(func(str, expr, flags string) []string), - ), - expr.Function( - "RegExp", - func(params ...any) (any, error) { - return regExp(params) - }, - ), -} - func Compile(input string) (*vm.Program, error) { - return expr.Compile(input, Options...) + // support http sessions + jar, _ := cookiejar.New(nil) + client := http.Client{ + Jar: jar, + Timeout: 5 * time.Second, + } + + return expr.Compile( + input, + expr.Function( + "fetch", + func(params ...any) (any, error) { + var req *http.Request + var err error + + rawURL := params[0].(string) + + if len(params) == 2 { + options := params[1].(map[string]any) + req, err = newRequest(rawURL, options) + } else { + req, err = http.NewRequest("GET", rawURL, nil) + } + + if err != nil { + return nil, err + } + + res, err := client.Do(req) + if err != nil { + return nil, err + } + + b, _ := io.ReadAll(res.Body) + + return map[string]any{ + "ok": res.StatusCode < 400, + "status": res.Status, + "text": string(b), + "json": func() (v any) { + _ = json.Unmarshal(b, &v) + return + }, + }, nil + }, + //new(func(url string) map[string]any), + //new(func(url string, options map[string]any) map[string]any), + ), + expr.Function( + "match", + func(params ...any) (any, error) { + re, err := regExp(params[1:]...) + if err != nil { + return nil, err + } + str := params[0].(string) + return re.FindStringSubmatch(str), nil + }, + //new(func(str, expr string) []string), + //new(func(str, expr, flags string) []string), + ), + ) } func Eval(input string, env any) (any, error) { diff --git a/pkg/flv/flv_test.go b/pkg/flv/flv_test.go new file mode 100644 index 00000000..389272b0 --- /dev/null +++ b/pkg/flv/flv_test.go @@ -0,0 +1,21 @@ +package flv + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTimeToRTP(t *testing.T) { + // Reolink camera has 20 FPS + // Video timestamp increases by 50ms, SampleRate 90000, RTP timestamp increases by 4500 + // Audio timestamp increases by 64ms, SampleRate 16000, RTP timestamp increases by 1024 + frameN := 1 + for i := 0; i < 32; i++ { + // 1000ms/(90000/4500) = 50ms + require.Equal(t, uint32(frameN*4500), TimeToRTP(uint32(frameN*50), 90000)) + // 1000ms/(16000/1024) = 64ms + require.Equal(t, uint32(frameN*1024), TimeToRTP(uint32(frameN*64), 16000)) + frameN *= 2 + } +} diff --git a/pkg/flv/muxer.go b/pkg/flv/muxer.go index 98794265..b04d8981 100644 --- a/pkg/flv/muxer.go +++ b/pkg/flv/muxer.go @@ -34,7 +34,7 @@ func (m *Muxer) GetInit() []byte { switch codec.Name { case core.CodecH264: b[4] |= FlagsVideo - obj["videocodecid"] = CodecAVC + obj["videocodecid"] = CodecH264 case core.CodecAAC: b[4] |= FlagsAudio diff --git a/pkg/flv/producer.go b/pkg/flv/producer.go index 33762d20..38c601d5 100644 --- a/pkg/flv/producer.go +++ b/pkg/flv/producer.go @@ -44,7 +44,9 @@ const ( TagData = 18 CodecAAC = 10 - CodecAVC = 7 + + CodecH264 = 7 + CodecHEVC = 12 ) const ( @@ -207,15 +209,18 @@ func (c *Producer) probe() error { } else { _ = pkt.Payload[0] >> 4 // FrameType - if codecID := pkt.Payload[0] & 0b1111; codecID != CodecAVC { - continue - } - if packetType := pkt.Payload[1]; packetType != PacketTypeAVCHeader { // check if header continue } - codec = h264.ConfigToCodec(pkt.Payload[5:]) + switch codecID := pkt.Payload[0] & 0b1111; codecID { + case CodecH264: + codec = h264.ConfigToCodec(pkt.Payload[5:]) + case CodecHEVC: + codec = h265.ConfigToCodec(pkt.Payload[5:]) + default: + continue + } } media := &core.Media{ @@ -294,8 +299,12 @@ func (c *Producer) readPacket() (*rtp.Packet, error) { return pkt, nil } -func TimeToRTP(timeMS uint32, clockRate uint32) uint32 { - return timeMS * clockRate / 1000 +// TimeToRTP convert time in milliseconds to RTP time +func TimeToRTP(timeMS, clockRate uint32) uint32 { + // for clockRates 90000, 16000, 8000, etc. - we can use: + // return timeMS * (clockRate / 1000) + // but for clockRates 44100, 22050, 11025 - we should use: + return uint32(uint64(timeMS) * uint64(clockRate) / 1000) } func isExHeader(data []byte) bool { diff --git a/pkg/h265/h265_test.go b/pkg/h265/h265_test.go index 75fa03d7..278e09a3 100644 --- a/pkg/h265/h265_test.go +++ b/pkg/h265/h265_test.go @@ -17,3 +17,14 @@ func TestDecodeSPS(t *testing.T) { require.Equal(t, uint16(5120), sps.Width()) require.Equal(t, uint16(1440), sps.Height()) } + +func TestDecodeSPS2(t *testing.T) { + s := "QgEBIUAAAAMAkAAAAwAAAwCWoAUCAWlnpbkShc1AQIC4QAAAAwBAAAAFFEn/eEAOpgAV+V8IBBA=" + b, err := base64.StdEncoding.DecodeString(s) + require.Nil(t, err) + + sps := DecodeSPS(b) + require.NotNil(t, sps) + require.Equal(t, uint16(640), sps.Width()) + require.Equal(t, uint16(360), sps.Height()) +} diff --git a/pkg/hap/camera/README.md b/pkg/hap/camera/README.md new file mode 100644 index 00000000..c6c6f236 --- /dev/null +++ b/pkg/hap/camera/README.md @@ -0,0 +1,3 @@ +## Useful links + +- https://github.com/bauer-andreas/secure-video-specification diff --git a/pkg/hap/camera/accessory.go b/pkg/hap/camera/accessory.go index 973983ec..37724497 100644 --- a/pkg/hap/camera/accessory.go +++ b/pkg/hap/camera/accessory.go @@ -49,17 +49,17 @@ func ServiceCameraRTPStreamManagement() *hap.Service { val120, _ := tlv8.MarshalBase64(StreamingStatus{ Status: StreamingStatusAvailable, }) - val114, _ := tlv8.MarshalBase64(SupportedVideoStreamConfig{ - Codecs: []VideoCodec{ + val114, _ := tlv8.MarshalBase64(SupportedVideoStreamConfiguration{ + Codecs: []VideoCodecConfiguration{ { CodecType: VideoCodecTypeH264, - CodecParams: []VideoParams{ + CodecParams: []VideoCodecParameters{ { ProfileID: []byte{VideoCodecProfileMain}, Level: []byte{VideoCodecLevel31, VideoCodecLevel40}, }, }, - VideoAttrs: []VideoAttrs{ + VideoAttrs: []VideoCodecAttributes{ {Width: 1920, Height: 1080, Framerate: 30}, {Width: 1280, Height: 720, Framerate: 30}, // important for iPhones {Width: 320, Height: 240, Framerate: 15}, // apple watch @@ -67,23 +67,23 @@ func ServiceCameraRTPStreamManagement() *hap.Service { }, }, }) - val115, _ := tlv8.MarshalBase64(SupportedAudioStreamConfig{ - Codecs: []AudioCodec{ + val115, _ := tlv8.MarshalBase64(SupportedAudioStreamConfiguration{ + Codecs: []AudioCodecConfiguration{ { CodecType: AudioCodecTypeOpus, - CodecParams: []AudioParams{ + CodecParams: []AudioCodecParameters{ { - Channels: 1, - Bitrate: AudioCodecBitrateVariable, - SampleRate: []byte{AudioCodecSampleRate16Khz}, + Channels: 1, + BitrateMode: AudioCodecBitrateVariable, + SampleRate: []byte{AudioCodecSampleRate16Khz}, }, }, }, }, - ComfortNoise: 0, + ComfortNoiseSupport: 0, }) - val116, _ := tlv8.MarshalBase64(SupportedRTPConfig{ - CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80}, + val116, _ := tlv8.MarshalBase64(SupportedRTPConfiguration{ + SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80}, }) service := &hap.Service{ diff --git a/pkg/hap/camera/accessory_test.go b/pkg/hap/camera/accessory_test.go index 3f5dcd71..53c99a49 100644 --- a/pkg/hap/camera/accessory_test.go +++ b/pkg/hap/camera/accessory_test.go @@ -63,19 +63,19 @@ func TestAqaraG3(t *testing.T) { { name: "114", value: "AaoBAQACEQEBAQIBAAAAAgECAwEABAEAAwsBAoAHAgI4BAMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAkABAgK0AAMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAgAEAgIAAwMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAkABAgLwAAMBHg==", - actual: &SupportedVideoStreamConfig{}, - expect: &SupportedVideoStreamConfig{ - Codecs: []VideoCodec{ + actual: &SupportedVideoStreamConfiguration{}, + expect: &SupportedVideoStreamConfiguration{ + Codecs: []VideoCodecConfiguration{ { CodecType: VideoCodecTypeH264, - CodecParams: []VideoParams{ + CodecParams: []VideoCodecParameters{ { ProfileID: []byte{VideoCodecProfileMain}, Level: []byte{VideoCodecLevel31, VideoCodecLevel40}, CVOEnabled: []byte{0}, }, }, - VideoAttrs: []VideoAttrs{ + VideoAttrs: []VideoCodecAttributes{ {Width: 1920, Height: 1080, Framerate: 30}, {Width: 1280, Height: 720, Framerate: 30}, {Width: 640, Height: 360, Framerate: 30}, @@ -94,29 +94,29 @@ func TestAqaraG3(t *testing.T) { { name: "115", value: "AQ4BAQICCQEBAQIBAAMBAQIBAA==", - actual: &SupportedAudioStreamConfig{}, - expect: &SupportedAudioStreamConfig{ - Codecs: []AudioCodec{ + actual: &SupportedAudioStreamConfiguration{}, + expect: &SupportedAudioStreamConfiguration{ + Codecs: []AudioCodecConfiguration{ { CodecType: AudioCodecTypeAACELD, - CodecParams: []AudioParams{ + CodecParams: []AudioCodecParameters{ { - Channels: 1, - Bitrate: AudioCodecBitrateVariable, - SampleRate: []byte{AudioCodecSampleRate16Khz}, + Channels: 1, + BitrateMode: AudioCodecBitrateVariable, + SampleRate: []byte{AudioCodecSampleRate16Khz}, }, }, }, }, - ComfortNoise: 0, + ComfortNoiseSupport: 0, }, }, { name: "116", value: "AgEAAAACAQEAAAIBAg==", - actual: &SupportedRTPConfig{}, - expect: &SupportedRTPConfig{ - CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoAES_CM_256_HMAC_SHA1_80, CryptoNone}, + actual: &SupportedRTPConfiguration{}, + expect: &SupportedRTPConfiguration{ + SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoAES_CM_256_HMAC_SHA1_80, CryptoDisabled}, }, }, } @@ -130,18 +130,18 @@ func TestHomebridge(t *testing.T) { { name: "114", value: "AcUBAQACHQEBAAAAAQEBAAABAQICAQAAAAIBAQAAAgECAwEAAwsBAkABAgK0AAMBHgAAAwsBAkABAgLwAAMBDwAAAwsBAkABAgLwAAMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAoAHAgI4BAMBHgAAAwsBAkAGAgKwBAMBHg==", - actual: &SupportedVideoStreamConfig{}, - expect: &SupportedVideoStreamConfig{ - Codecs: []VideoCodec{ + actual: &SupportedVideoStreamConfiguration{}, + expect: &SupportedVideoStreamConfiguration{ + Codecs: []VideoCodecConfiguration{ { CodecType: VideoCodecTypeH264, - CodecParams: []VideoParams{ + CodecParams: []VideoCodecParameters{ { ProfileID: []byte{VideoCodecProfileConstrainedBaseline, VideoCodecProfileMain, VideoCodecProfileHigh}, Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40}, }, }, - VideoAttrs: []VideoAttrs{ + VideoAttrs: []VideoCodecAttributes{ {Width: 320, Height: 180, Framerate: 30}, {Width: 320, Height: 240, Framerate: 15}, @@ -162,9 +162,9 @@ func TestHomebridge(t *testing.T) { { name: "116", value: "AgEA", - actual: &SupportedRTPConfig{}, - expect: &SupportedRTPConfig{ - CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80}, + actual: &SupportedRTPConfiguration{}, + expect: &SupportedRTPConfiguration{ + SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80}, }, }, } @@ -178,18 +178,18 @@ func TestScrypted(t *testing.T) { { name: "114", value: "AVIBAQACEwEBAQIBAAAAAgEBAAACAQIDAQADCwECAA8CAnAIAwEeAAADCwECgAcCAjgEAwEeAAADCwECAAUCAtACAwEeAAADCwECQAECAvAAAwEP", - actual: &SupportedVideoStreamConfig{}, - expect: &SupportedVideoStreamConfig{ - Codecs: []VideoCodec{ + actual: &SupportedVideoStreamConfiguration{}, + expect: &SupportedVideoStreamConfiguration{ + Codecs: []VideoCodecConfiguration{ { CodecType: VideoCodecTypeH264, - CodecParams: []VideoParams{ + CodecParams: []VideoCodecParameters{ { ProfileID: []byte{VideoCodecProfileMain}, Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40}, }, }, - VideoAttrs: []VideoAttrs{ + VideoAttrs: []VideoCodecAttributes{ {Width: 3840, Height: 2160, Framerate: 30}, {Width: 1920, Height: 1080, Framerate: 30}, {Width: 1280, Height: 720, Framerate: 30}, @@ -202,15 +202,15 @@ func TestScrypted(t *testing.T) { { name: "115", value: "AScBAQMCIgEBAQIBAAMBAAAAAwEAAAADAQEAAAMBAQAAAwECAAADAQICAQA=", - actual: &SupportedAudioStreamConfig{}, - expect: &SupportedAudioStreamConfig{ - Codecs: []AudioCodec{ + actual: &SupportedAudioStreamConfiguration{}, + expect: &SupportedAudioStreamConfiguration{ + Codecs: []AudioCodecConfiguration{ { CodecType: AudioCodecTypeOpus, - CodecParams: []AudioParams{ + CodecParams: []AudioCodecParameters{ { - Channels: 1, - Bitrate: AudioCodecBitrateVariable, + Channels: 1, + BitrateMode: AudioCodecBitrateVariable, SampleRate: []byte{ AudioCodecSampleRate8Khz, AudioCodecSampleRate8Khz, AudioCodecSampleRate16Khz, AudioCodecSampleRate16Khz, @@ -220,15 +220,15 @@ func TestScrypted(t *testing.T) { }, }, }, - ComfortNoise: 0, + ComfortNoiseSupport: 0, }, }, { name: "116", value: "AgEAAAACAQI=", - actual: &SupportedRTPConfig{}, - expect: &SupportedRTPConfig{ - CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoNone}, + actual: &SupportedRTPConfiguration{}, + expect: &SupportedRTPConfiguration{ + SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoDisabled}, }, }, } diff --git a/pkg/hap/camera/ch114_supported_video.go b/pkg/hap/camera/ch114_supported_video.go index 196f0286..ec70dc61 100644 --- a/pkg/hap/camera/ch114_supported_video.go +++ b/pkg/hap/camera/ch114_supported_video.go @@ -2,15 +2,15 @@ package camera const TypeSupportedVideoStreamConfiguration = "114" -type SupportedVideoStreamConfig struct { - Codecs []VideoCodec `tlv8:"1"` +type SupportedVideoStreamConfiguration struct { + Codecs []VideoCodecConfiguration `tlv8:"1"` } -type VideoCodec struct { - CodecType byte `tlv8:"1"` - CodecParams []VideoParams `tlv8:"2"` - VideoAttrs []VideoAttrs `tlv8:"3"` - RTPParams []RTPParams `tlv8:"4"` +type VideoCodecConfiguration struct { + CodecType byte `tlv8:"1"` + CodecParams []VideoCodecParameters `tlv8:"2"` + VideoAttrs []VideoCodecAttributes `tlv8:"3"` + RTPParams []RTPParams `tlv8:"4"` } //goland:noinspection ALL @@ -31,15 +31,15 @@ const ( VideoCodecCvoSuppported = 1 ) -type VideoParams struct { +type VideoCodecParameters struct { ProfileID []byte `tlv8:"1"` // 0 - baseline, 1 - main, 2 - high Level []byte `tlv8:"2"` // 0 - 3.1, 1 - 3.2, 2 - 4.0 PacketizationMode byte `tlv8:"3"` // only 0 - non interleaved CVOEnabled []byte `tlv8:"4"` // 0 - not supported, 1 - supported - CVOID []byte `tlv8:"5"` // ??? + CVOID []byte `tlv8:"5"` // ID for CVO RTP extensio } -type VideoAttrs struct { +type VideoCodecAttributes struct { Width uint16 `tlv8:"1"` Height uint16 `tlv8:"2"` Framerate uint8 `tlv8:"3"` diff --git a/pkg/hap/camera/ch115_supported_audio.go b/pkg/hap/camera/ch115_supported_audio.go index efb0d881..f7ba9b44 100644 --- a/pkg/hap/camera/ch115_supported_audio.go +++ b/pkg/hap/camera/ch115_supported_audio.go @@ -2,9 +2,9 @@ package camera const TypeSupportedAudioStreamConfiguration = "115" -type SupportedAudioStreamConfig struct { - Codecs []AudioCodec `tlv8:"1"` - ComfortNoise byte `tlv8:"2"` +type SupportedAudioStreamConfiguration struct { + Codecs []AudioCodecConfiguration `tlv8:"1"` + ComfortNoiseSupport byte `tlv8:"2"` } //goland:noinspection ALL @@ -31,16 +31,16 @@ const ( RTPTimeAACLD24 = 40 // 24000/1000*40=960 ) -type AudioCodec struct { - CodecType byte `tlv8:"1"` - CodecParams []AudioParams `tlv8:"2"` - RTPParams []RTPParams `tlv8:"3"` - ComfortNoise []byte `tlv8:"4"` +type AudioCodecConfiguration struct { + CodecType byte `tlv8:"1"` + CodecParams []AudioCodecParameters `tlv8:"2"` + RTPParams []RTPParams `tlv8:"3"` + ComfortNoise []byte `tlv8:"4"` } -type AudioParams struct { - Channels uint8 `tlv8:"1"` - Bitrate byte `tlv8:"2"` // 0 - variable, 1 - constant - SampleRate []byte `tlv8:"3"` // 0 - 8000, 1 - 16000, 2 - 24000 - RTPTime []uint8 `tlv8:"4"` // 20, 30, 40, 60 +type AudioCodecParameters struct { + Channels uint8 `tlv8:"1"` + BitrateMode byte `tlv8:"2"` // 0 - variable, 1 - constant + SampleRate []byte `tlv8:"3"` // 0 - 8000, 1 - 16000, 2 - 24000 + RTPTime []uint8 `tlv8:"4"` // 20, 30, 40, 60 } diff --git a/pkg/hap/camera/ch116_rtp_config.go b/pkg/hap/camera/ch116_supported_rtp.go similarity index 60% rename from pkg/hap/camera/ch116_rtp_config.go rename to pkg/hap/camera/ch116_supported_rtp.go index fb4be550..f0ca0db9 100644 --- a/pkg/hap/camera/ch116_rtp_config.go +++ b/pkg/hap/camera/ch116_supported_rtp.go @@ -6,9 +6,9 @@ const TypeSupportedRTPConfiguration = "116" const ( CryptoAES_CM_128_HMAC_SHA1_80 = 0 CryptoAES_CM_256_HMAC_SHA1_80 = 1 - CryptoNone = 2 + CryptoDisabled = 2 ) -type SupportedRTPConfig struct { - CryptoType []byte `tlv8:"2"` +type SupportedRTPConfiguration struct { + SRTPCryptoType []byte `tlv8:"2"` } diff --git a/pkg/hap/camera/ch117_selected_stream.go b/pkg/hap/camera/ch117_selected_stream.go index aa0c7038..d94ba96b 100644 --- a/pkg/hap/camera/ch117_selected_stream.go +++ b/pkg/hap/camera/ch117_selected_stream.go @@ -2,10 +2,10 @@ package camera const TypeSelectedStreamConfiguration = "117" -type SelectedStreamConfig struct { - Control SessionControl `tlv8:"1"` - VideoCodec VideoCodec `tlv8:"2"` - AudioCodec AudioCodec `tlv8:"3"` +type SelectedStreamConfiguration struct { + Control SessionControl `tlv8:"1"` + VideoCodec VideoCodecConfiguration `tlv8:"2"` + AudioCodec AudioCodecConfiguration `tlv8:"3"` } //goland:noinspection ALL diff --git a/pkg/hap/camera/ch118_setup_endpoints.go b/pkg/hap/camera/ch118_setup_endpoints.go index 9405de4a..e0f426c0 100644 --- a/pkg/hap/camera/ch118_setup_endpoints.go +++ b/pkg/hap/camera/ch118_setup_endpoints.go @@ -2,25 +2,32 @@ package camera const TypeSetupEndpoints = "118" -type SetupEndpoints struct { - SessionID string `tlv8:"1"` - Status []byte `tlv8:"2"` - Address Addr `tlv8:"3"` - VideoCrypto CryptoSuite `tlv8:"4"` - AudioCrypto CryptoSuite `tlv8:"5"` - VideoSSRC []uint32 `tlv8:"6"` - AudioSSRC []uint32 `tlv8:"7"` +type SetupEndpointsRequest struct { + SessionID string `tlv8:"1"` + Address Address `tlv8:"3"` + VideoCrypto SRTPCryptoSuite `tlv8:"4"` + AudioCrypto SRTPCryptoSuite `tlv8:"5"` } -type Addr struct { +type SetupEndpointsResponse struct { + SessionID string `tlv8:"1"` + Status byte `tlv8:"2"` + Address Address `tlv8:"3"` + VideoCrypto SRTPCryptoSuite `tlv8:"4"` + AudioCrypto SRTPCryptoSuite `tlv8:"5"` + VideoSSRC uint32 `tlv8:"6"` + AudioSSRC uint32 `tlv8:"7"` +} + +type Address struct { IPVersion byte `tlv8:"1"` IPAddr string `tlv8:"2"` VideoRTPPort uint16 `tlv8:"3"` AudioRTPPort uint16 `tlv8:"4"` } -type CryptoSuite struct { - CryptoType byte `tlv8:"1"` - MasterKey string `tlv8:"2"` // 16 (AES_CM_128) or 32 (AES_256_CM) - MasterSalt string `tlv8:"3"` // 14 byte +type SRTPCryptoSuite struct { + CryptoSuite byte `tlv8:"1"` + MasterKey string `tlv8:"2"` // 16 (AES_CM_128) or 32 (AES_256_CM) + MasterSalt string `tlv8:"3"` // 14 byte } diff --git a/pkg/hap/camera/ch120_streaming_status.go b/pkg/hap/camera/ch120_streaming_status.go index 2fe53911..e617df27 100644 --- a/pkg/hap/camera/ch120_streaming_status.go +++ b/pkg/hap/camera/ch120_streaming_status.go @@ -9,6 +9,6 @@ type StreamingStatus struct { //goland:noinspection ALL const ( StreamingStatusAvailable = 0 - StreamingStatusBusy = 1 + StreamingStatusInUse = 1 StreamingStatusUnavailable = 2 ) diff --git a/pkg/hap/camera/ch130_data_stream_transport.go b/pkg/hap/camera/ch130_data_stream_transport.go new file mode 100644 index 00000000..808f822d --- /dev/null +++ b/pkg/hap/camera/ch130_data_stream_transport.go @@ -0,0 +1,11 @@ +package camera + +const TypeSupportedDataStreamTransportConfiguration = "130" + +type SupportedDataStreamTransportConfiguration struct { + Configs []TransferTransportConfiguration `tlv8:"1"` +} + +type TransferTransportConfiguration struct { + TransportType byte `tlv8:"1"` +} diff --git a/pkg/hap/camera/ch131_data_stream.go b/pkg/hap/camera/ch131_data_stream.go index 067b01b4..4f4ab49f 100644 --- a/pkg/hap/camera/ch131_data_stream.go +++ b/pkg/hap/camera/ch131_data_stream.go @@ -2,13 +2,13 @@ package camera const TypeSetupDataStreamTransport = "131" -type SetupDataStreamRequest struct { +type SetupDataStreamTransportRequest struct { SessionCommandType byte `tlv8:"1"` TransportType byte `tlv8:"2"` ControllerKeySalt string `tlv8:"3"` } -type SetupDataStreamResponse struct { +type SetupDataStreamTransportResponse struct { Status byte `tlv8:"1"` TransportTypeSessionParameters struct { TCPListeningPort uint16 `tlv8:"1"` diff --git a/pkg/hap/camera/ch205.go b/pkg/hap/camera/ch205.go new file mode 100644 index 00000000..431db7b0 --- /dev/null +++ b/pkg/hap/camera/ch205.go @@ -0,0 +1,18 @@ +package camera + +const TypeSupportedCameraRecordingConfiguration = "205" + +type SupportedCameraRecordingConfiguration struct { + PrebufferLength uint32 `tlv8:"1"` + EventTriggerOptions uint64 `tlv8:"2"` + MediaContainerConfigurations `tlv8:"3"` +} + +type MediaContainerConfigurations struct { + MediaContainerType uint8 `tlv8:"1"` + MediaContainerParameters `tlv8:"2"` +} + +type MediaContainerParameters struct { + FragmentLength uint32 `tlv8:"1"` +} diff --git a/pkg/hap/camera/ch206.go b/pkg/hap/camera/ch206.go new file mode 100644 index 00000000..89219fa7 --- /dev/null +++ b/pkg/hap/camera/ch206.go @@ -0,0 +1,20 @@ +package camera + +const TypeSupportedVideoRecordingConfiguration = "206" + +type SupportedVideoRecordingConfiguration struct { + CodecConfigs []VideoRecordingCodecConfiguration `tlv8:"1"` +} + +type VideoRecordingCodecConfiguration struct { + CodecType uint8 `tlv8:"1"` + CodecParams VideoRecordingCodecParameters `tlv8:"2"` + CodecAttrs VideoCodecAttributes `tlv8:"3"` +} + +type VideoRecordingCodecParameters struct { + ProfileID uint8 `tlv8:"1"` + Level uint8 `tlv8:"2"` + Bitrate uint32 `tlv8:"3"` + IFrameInterval uint32 `tlv8:"4"` +} diff --git a/pkg/hap/camera/ch207.go b/pkg/hap/camera/ch207.go new file mode 100644 index 00000000..5d389923 --- /dev/null +++ b/pkg/hap/camera/ch207.go @@ -0,0 +1,19 @@ +package camera + +const TypeSupportedAudioRecordingConfiguration = "207" + +type SupportedAudioRecordingConfiguration struct { + CodecConfigs []AudioRecordingCodecConfiguration `tlv8:"1"` +} + +type AudioRecordingCodecConfiguration struct { + CodecType byte `tlv8:"1"` + CodecParams []AudioRecordingCodecParameters `tlv8:"2"` +} + +type AudioRecordingCodecParameters struct { + Channels uint8 `tlv8:"1"` + BitrateMode []byte `tlv8:"2"` + SampleRate []byte `tlv8:"3"` + MaxAudioBitrate []uint32 `tlv8:"4"` +} diff --git a/pkg/hap/camera/ch209.go b/pkg/hap/camera/ch209.go new file mode 100644 index 00000000..c51359fb --- /dev/null +++ b/pkg/hap/camera/ch209.go @@ -0,0 +1,9 @@ +package camera + +const TypeSelectedCameraRecordingConfiguration = "209" + +type SelectedCameraRecordingConfiguration struct { + GeneralConfig SupportedCameraRecordingConfiguration `tlv8:"1"` + VideoConfig SupportedVideoRecordingConfiguration `tlv8:"2"` + AudioConfig SupportedAudioRecordingConfiguration `tlv8:"3"` +} diff --git a/pkg/hap/camera/stream.go b/pkg/hap/camera/stream.go index 23d53c39..bda67920 100644 --- a/pkg/hap/camera/stream.go +++ b/pkg/hap/camera/stream.go @@ -15,7 +15,7 @@ type Stream struct { } func NewStream( - client *hap.Client, videoCodec *VideoCodec, audioCodec *AudioCodec, + client *hap.Client, videoCodec *VideoCodecConfiguration, audioCodec *AudioCodecConfiguration, videoSession, audioSession *srtp.Session, bitrate int, ) (*Stream, error) { stream := &Stream{ @@ -58,7 +58,7 @@ func NewStream( } audioCodec.ComfortNoise = []byte{0} - config := &SelectedStreamConfig{ + config := &SelectedStreamConfiguration{ Control: SessionControl{ SessionID: stream.id, Command: SessionCommandStart, @@ -103,19 +103,19 @@ func (s *Stream) GetFreeStream() error { } func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) error { - req := SetupEndpoints{ + req := SetupEndpointsRequest{ SessionID: s.id, - Address: Addr{ + Address: Address{ IPVersion: 0, IPAddr: videoSession.Local.Addr, VideoRTPPort: videoSession.Local.Port, AudioRTPPort: audioSession.Local.Port, }, - VideoCrypto: CryptoSuite{ + VideoCrypto: SRTPCryptoSuite{ MasterKey: string(videoSession.Local.MasterKey), MasterSalt: string(videoSession.Local.MasterSalt), }, - AudioCrypto: CryptoSuite{ + AudioCrypto: SRTPCryptoSuite{ MasterKey: string(audioSession.Local.MasterKey), MasterSalt: string(audioSession.Local.MasterSalt), }, @@ -129,7 +129,7 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err return err } - var res SetupEndpoints + var res SetupEndpointsResponse if err := s.client.GetCharacter(char); err != nil { return err } @@ -142,7 +142,7 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err Port: res.Address.VideoRTPPort, MasterKey: []byte(res.VideoCrypto.MasterKey), MasterSalt: []byte(res.VideoCrypto.MasterSalt), - SSRC: res.VideoSSRC[0], + SSRC: res.VideoSSRC, } audioSession.Remote = &srtp.Endpoint{ @@ -150,13 +150,13 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err Port: res.Address.AudioRTPPort, MasterKey: []byte(res.AudioCrypto.MasterKey), MasterSalt: []byte(res.AudioCrypto.MasterSalt), - SSRC: res.AudioSSRC[0], + SSRC: res.AudioSSRC, } return nil } -func (s *Stream) SetStreamConfig(config *SelectedStreamConfig) error { +func (s *Stream) SetStreamConfig(config *SelectedStreamConfiguration) error { char := s.service.GetCharacter(TypeSelectedStreamConfiguration) if err := char.Write(config); err != nil { return err @@ -169,7 +169,7 @@ func (s *Stream) SetStreamConfig(config *SelectedStreamConfig) error { } func (s *Stream) Close() error { - config := &SelectedStreamConfig{ + config := &SelectedStreamConfiguration{ Control: SessionControl{ SessionID: s.id, Command: SessionCommandEnd, diff --git a/pkg/hap/client.go b/pkg/hap/client.go index 2c1f7dd3..ed4faa02 100644 --- a/pkg/hap/client.go +++ b/pkg/hap/client.go @@ -18,7 +18,6 @@ import ( "github.com/AlexxIT/go2rtc/pkg/hap/curve25519" "github.com/AlexxIT/go2rtc/pkg/hap/ed25519" "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" - "github.com/AlexxIT/go2rtc/pkg/hap/secure" "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" "github.com/AlexxIT/go2rtc/pkg/mdns" ) @@ -46,7 +45,7 @@ type Client struct { err error } -func NewClient(rawURL string) (*Client, error) { +func Dial(rawURL string) (*Client, error) { u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -61,6 +60,10 @@ func NewClient(rawURL string) (*Client, error) { ClientPrivate: DecodeKey(query.Get("client_private")), } + if err = c.Dial(); err != nil { + return nil, err + } + return c, nil } @@ -96,6 +99,7 @@ func (c *Client) Dial() (err error) { return false }) + // TODO: close conn on error if c.Conn, err = net.DialTimeout("tcp", c.DeviceAddress, ConnDialTimeout); err != nil { return } @@ -124,7 +128,7 @@ func (c *Client) Dial() (err error) { EncryptedData string `tlv8:"5"` State byte `tlv8:"6"` } - if err = tlv8.UnmarshalReader(res.Body, &cipherM2); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &cipherM2); err != nil { return err } if cipherM2.State != StateM2 { @@ -209,15 +213,17 @@ func (c *Client) Dial() (err error) { var plainM4 struct { State byte `tlv8:"6"` } - if err = tlv8.UnmarshalReader(res.Body, &plainM4); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM4); err != nil { return } if plainM4.State != StateM4 { return newResponseError(cipherM3, plainM4) } + rw := bufio.NewReadWriter(c.reader, bufio.NewWriter(c.Conn)) + // like tls.Client wrapper over net.Conn - if c.Conn, err = secure.Client(c.Conn, sessionShared, true); err != nil { + if c.Conn, err = NewConn(c.Conn, rw, sessionShared, true); err != nil { return } // new reader for new conn diff --git a/pkg/hap/client_http.go b/pkg/hap/client_http.go index 360f48bc..7f8314f8 100644 --- a/pkg/hap/client_http.go +++ b/pkg/hap/client_http.go @@ -82,3 +82,20 @@ func ReadResponse(r *bufio.Reader, req *http.Request) (*http.Response, error) { return res, nil } + +func WriteEvent(w io.Writer, res *http.Response) error { + return res.Write(&eventWriter{w: w}) +} + +type eventWriter struct { + w io.Writer + done bool +} + +func (e *eventWriter) Write(p []byte) (n int, err error) { + if !e.done { + p = append([]byte("EVENT/1.0"), p[8:]...) + e.done = true + } + return e.w.Write(p) +} diff --git a/pkg/hap/client_pairing.go b/pkg/hap/client_pairing.go index baec7be5..f253783d 100644 --- a/pkg/hap/client_pairing.go +++ b/pkg/hap/client_pairing.go @@ -107,7 +107,7 @@ func (c *Client) Pair(feature, pin string) (err error) { State byte `tlv8:"6"` Error byte `tlv8:"7"` } - if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil { return } if plainM2.State != StateM2 { @@ -121,9 +121,7 @@ func (c *Client) Pair(feature, pin string) (err error) { username := []byte("Pair-Setup") // Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE) - pake, err := srp.NewSRP( - "rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username), - ) + pake, err := srp.NewSRP("rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username)) if err != nil { return } @@ -132,6 +130,7 @@ func (c *Client) Pair(feature, pin string) (err error) { // username: "Pair-Setup", password: PIN (with dashes) session := pake.NewClientSession(username, []byte(pin)) + sessionShared, err := session.ComputeKey([]byte(plainM2.Salt), []byte(plainM2.SessionKey)) if err != nil { return @@ -159,7 +158,7 @@ func (c *Client) Pair(feature, pin string) (err error) { EncryptedData string `tlv8:"5"` // skip EncryptedData validation (for MFi devices) } - if err = tlv8.UnmarshalReader(res.Body, &plainM4); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM4); err != nil { return } if plainM4.State != StateM4 { @@ -232,7 +231,7 @@ func (c *Client) Pair(feature, pin string) (err error) { State byte `tlv8:"6"` Error byte `tlv8:"7"` }{} - if err = tlv8.UnmarshalReader(res.Body, &cipherM6); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &cipherM6); err != nil { return } if cipherM6.State != StateM6 || cipherM6.Error != 0 { @@ -296,7 +295,7 @@ func (c *Client) ListPairings() error { State byte `tlv8:"6"` Permission byte `tlv8:"11"` } - if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil { return err } @@ -329,7 +328,7 @@ func (c *Client) PairingsAdd(clientID string, clientPublic []byte, admin bool) e State byte `tlv8:"6"` Unknown byte `tlv8:"7"` } - if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil { return err } @@ -354,7 +353,7 @@ func (c *Client) DeletePairing(id string) error { var plainM2 struct { State byte `tlv8:"6"` } - if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil { + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil { return err } if plainM2.State != StateM2 { diff --git a/pkg/hap/secure/secure.go b/pkg/hap/conn.go similarity index 66% rename from pkg/hap/secure/secure.go rename to pkg/hap/conn.go index 576ee127..2b039dc8 100644 --- a/pkg/hap/secure/secure.go +++ b/pkg/hap/conn.go @@ -1,32 +1,50 @@ -package secure +package hap import ( "bufio" "encoding/binary" + "encoding/json" "errors" "io" "net" + "sync" "time" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" ) type Conn struct { conn net.Conn - - rd *bufio.Reader - wr *bufio.Writer + rw *bufio.ReadWriter + wmu sync.Mutex encryptKey []byte decryptKey []byte encryptCnt uint64 decryptCnt uint64 + //ClientID string SharedKey []byte + + recv int + send int } -func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) { +func (c *Conn) MarshalJSON() ([]byte, error) { + conn := core.Connection{ + ID: core.ID(c), + FormatName: "homekit", + Protocol: "hap", + RemoteAddr: c.conn.RemoteAddr().String(), + Recv: c.recv, + Send: c.send, + } + return json.Marshal(conn) +} + +func NewConn(conn net.Conn, rw *bufio.ReadWriter, sharedKey []byte, isClient bool) (*Conn, error) { key1, err := hkdf.Sha512(sharedKey, "Control-Salt", "Control-Read-Encryption-Key") if err != nil { return nil, err @@ -39,8 +57,7 @@ func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) { c := &Conn{ conn: conn, - rd: bufio.NewReaderSize(conn, 32*1024), - wr: bufio.NewWriterSize(conn, 32*1024), + rw: rw, SharedKey: sharedKey, } @@ -55,8 +72,8 @@ func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) { } const ( - // PacketSizeMax is the max length of encrypted packets - PacketSizeMax = 0x400 + // packetSizeMax is the max length of encrypted packets + packetSizeMax = 0x400 VerifySize = 2 NonceSize = 8 @@ -64,19 +81,19 @@ const ( ) func (c *Conn) Read(b []byte) (n int, err error) { - if cap(b) < PacketSizeMax { + if cap(b) < packetSizeMax { return 0, errors.New("hap: read buffer is too small") } - verify := make([]byte, 2) // verify = plain message size - if _, err = io.ReadFull(c.rd, verify); err != nil { + verify := make([]byte, VerifySize) // verify = plain message size + if _, err = io.ReadFull(c.rw, verify); err != nil { return } n = int(binary.LittleEndian.Uint16(verify)) - ciphertext := make([]byte, n+Overhead) - if _, err = io.ReadFull(c.rd, ciphertext); err != nil { + ciphertext := make([]byte, n+Overhead) + if _, err = io.ReadFull(c.rw, ciphertext); err != nil { return } @@ -85,22 +102,27 @@ func (c *Conn) Read(b []byte) (n int, err error) { c.decryptCnt++ _, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, b[:0], nonce, ciphertext, verify) + + c.recv += n return } func (c *Conn) Write(b []byte) (n int, err error) { - buf := make([]byte, 0, PacketSizeMax+Overhead) + c.wmu.Lock() + defer c.wmu.Unlock() + + buf := make([]byte, 0, packetSizeMax+Overhead) nonce := make([]byte, NonceSize) verify := make([]byte, VerifySize) for len(b) > 0 { size := len(b) - if size > PacketSizeMax { - size = PacketSizeMax + if size > packetSizeMax { + size = packetSizeMax } binary.LittleEndian.PutUint16(verify, uint16(size)) - if _, err = c.wr.Write(verify); err != nil { + if _, err = c.rw.Write(verify); err != nil { return } @@ -112,7 +134,7 @@ func (c *Conn) Write(b []byte) (n int, err error) { return } - if _, err = c.wr.Write(buf[:size+Overhead]); err != nil { + if _, err = c.rw.Write(buf[:size+Overhead]); err != nil { return } @@ -120,7 +142,9 @@ func (c *Conn) Write(b []byte) (n int, err error) { n += size } - err = c.wr.Flush() + err = c.rw.Flush() + + c.send += n return } diff --git a/pkg/hap/hds/hds.go b/pkg/hap/hds/hds.go index a7b2c74a..0e299919 100644 --- a/pkg/hap/hds/hds.go +++ b/pkg/hap/hds/hds.go @@ -4,16 +4,19 @@ package hds import ( "bufio" "encoding/binary" + "encoding/json" + "errors" "io" "net" "time" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hap" "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" - "github.com/AlexxIT/go2rtc/pkg/hap/secure" ) -func Client(conn net.Conn, key []byte, salt string, controller bool) (*Conn, error) { +func NewConn(conn net.Conn, key []byte, salt string, controller bool) (*Conn, error) { writeKey, err := hkdf.Sha512(key, salt, "HDS-Write-Encryption-Key") if err != nil { return nil, err @@ -49,43 +52,91 @@ type Conn struct { encryptKey []byte decryptCnt uint64 encryptCnt uint64 + + recv int + send int } -func (c *Conn) Read(p []byte) (n int, err error) { +func (c *Conn) MarshalJSON() ([]byte, error) { + conn := core.Connection{ + ID: core.ID(c), + FormatName: "homekit", + Protocol: "hds", + RemoteAddr: c.conn.RemoteAddr().String(), + Recv: c.recv, + Send: c.send, + } + return json.Marshal(conn) +} + +func (c *Conn) read() (b []byte, err error) { verify := make([]byte, 4) if _, err = io.ReadFull(c.rd, verify); err != nil { return } - n = int(binary.BigEndian.Uint32(verify) & 0xFFFFFF) + n := int(binary.BigEndian.Uint32(verify) & 0xFFFFFF) - ciphertext := make([]byte, n+secure.Overhead) + ciphertext := make([]byte, n+hap.Overhead) if _, err = io.ReadFull(c.rd, ciphertext); err != nil { return } - nonce := make([]byte, secure.NonceSize) + nonce := make([]byte, hap.NonceSize) binary.LittleEndian.PutUint64(nonce, c.decryptCnt) c.decryptCnt++ - _, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, p[:0], nonce, ciphertext, verify) + c.recv += n + + return chacha20poly1305.DecryptAndVerify(c.decryptKey, ciphertext[:0], nonce, ciphertext, verify) +} + +func (c *Conn) Read(p []byte) (n int, err error) { + b, err := c.read() + if err != nil { + return 0, err + } + n = copy(p, b) + if len(b) > n { + err = errors.New("hds: read buffer too small") + } return } +func (c *Conn) WriteTo(w io.Writer) (int64, error) { + var total int64 + for { + b, err := c.read() + if err != nil { + return total, err + } + + n, err := w.Write(b) + total += int64(n) + if err != nil { + return total, err + } + } +} + func (c *Conn) Write(b []byte) (n int, err error) { n = len(b) + if n > 0xFFFFFF { + return 0, errors.New("hds: write buffer too big") + } + verify := make([]byte, 4) binary.BigEndian.PutUint32(verify, 0x01000000|uint32(n)) if _, err = c.wr.Write(verify); err != nil { return } - nonce := make([]byte, secure.NonceSize) + nonce := make([]byte, hap.NonceSize) binary.LittleEndian.PutUint64(nonce, c.encryptCnt) c.encryptCnt++ - buf := make([]byte, n+secure.Overhead) + buf := make([]byte, n+hap.Overhead) if _, err = chacha20poly1305.EncryptAndSeal(c.encryptKey, buf[:0], nonce, b, verify); err != nil { return } @@ -95,6 +146,8 @@ func (c *Conn) Write(b []byte) (n int, err error) { } err = c.wr.Flush() + + c.send += n return } diff --git a/pkg/hap/helpers.go b/pkg/hap/helpers.go index 3900f935..3c3b287c 100644 --- a/pkg/hap/helpers.go +++ b/pkg/hap/helpers.go @@ -3,6 +3,8 @@ package hap import ( "crypto/ed25519" "crypto/rand" + "crypto/sha512" + "encoding/base64" "encoding/hex" "errors" "fmt" @@ -99,6 +101,12 @@ func GenerateUUID() string { return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:] } +func SetupHash(setupID, deviceID string) string { + // should be setup_id (random 4 alphanum) + device_id (mac address) + b := sha512.Sum512([]byte(setupID + deviceID)) + return base64.StdEncoding.EncodeToString(b[:4]) +} + func Append(items ...any) (b []byte) { for _, item := range items { switch v := item.(type) { diff --git a/pkg/hap/server.go b/pkg/hap/server.go index 2a912324..a992528c 100644 --- a/pkg/hap/server.go +++ b/pkg/hap/server.go @@ -3,32 +3,25 @@ package hap import ( "bufio" "crypto/sha512" - "encoding/base64" "errors" "fmt" - "io" - "net" "net/http" "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" "github.com/AlexxIT/go2rtc/pkg/hap/curve25519" "github.com/AlexxIT/go2rtc/pkg/hap/ed25519" "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" - "github.com/AlexxIT/go2rtc/pkg/hap/secure" "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" + "github.com/tadglines/go-pkgs/crypto/srp" ) -type HandlerFunc func(net.Conn) error - type Server struct { Pin string DeviceID string DevicePrivate []byte - GetPair func(conn net.Conn, id string) []byte - AddPair func(conn net.Conn, id string, public []byte, permissions byte) - - Handler HandlerFunc + // GetClientPublic may be nil, so client validation will be disabled + GetClientPublic func(id string) []byte } func (s *Server) ServerPublic() []byte { @@ -42,44 +35,240 @@ func (s *Server) ServerPublic() []byte { // return StatusPaired //} -func (s *Server) SetupHash() string { - // should be setup_id (random 4 alphanum) + device_id (mac address) - // but device_id is random, so OK - b := sha512.Sum512([]byte(s.DeviceID)) - return base64.StdEncoding.EncodeToString(b[:4]) -} - -func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Conn) error { - // Request from iPhone +func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter) (id string, publicKey []byte, err error) { + // STEP 1. Request from iPhone var plainM1 struct { - PublicKey string `tlv8:"3"` - State byte `tlv8:"6"` + State byte `tlv8:"6"` + Method byte `tlv8:"0"` + Flags uint32 `tlv8:"19"` } - if err := tlv8.UnmarshalReader(io.LimitReader(rw, req.ContentLength), &plainM1); err != nil { - return err + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil { + return } if plainM1.State != StateM1 { - return newRequestError(plainM1) + err = newRequestError(plainM1) + return + } + + username := []byte("Pair-Setup") + + // Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE) + pake, err := srp.NewSRP("rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username)) + if err != nil { + return + } + + pake.SaltLength = 16 + + salt, verifier, err := pake.ComputeVerifier([]byte(s.Pin)) + if err != nil { + return + } + + session := pake.NewServerSession(username, salt, verifier) + + // STEP 2. Response to iPhone + plainM2 := struct { + State byte `tlv8:"6"` + PublicKey string `tlv8:"3"` + Salt string `tlv8:"2"` + }{ + State: StateM2, + PublicKey: string(session.GetB()), + Salt: string(salt), + } + body, err := tlv8.Marshal(plainM2) + if err != nil { + return + } + if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { + return + } + + // STEP 3. Request from iPhone + if req, err = http.ReadRequest(rw.Reader); err != nil { + return + } + + var plainM3 struct { + State byte `tlv8:"6"` + PublicKey string `tlv8:"3"` + Proof string `tlv8:"4"` + } + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM3); err != nil { + return + } + if plainM3.State != StateM3 { + err = newRequestError(plainM3) + return + } + + // important to compute key before verify client + sessionShared, err := session.ComputeKey([]byte(plainM3.PublicKey)) + if err != nil { + return + } + + if !session.VerifyClientAuthenticator([]byte(plainM3.Proof)) { + err = errors.New("hap: VerifyClientAuthenticator") + return + } + + proof := session.ComputeAuthenticator([]byte(plainM3.Proof)) // server proof + + // STEP 4. Response to iPhone + payloadM4 := struct { + State byte `tlv8:"6"` + Proof string `tlv8:"4"` + }{ + State: StateM4, + Proof: string(proof), + } + if body, err = tlv8.Marshal(payloadM4); err != nil { + return + } + if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { + return + } + + // STEP 5. Request from iPhone + if req, err = http.ReadRequest(rw.Reader); err != nil { + return + } + var cipherM5 struct { + State byte `tlv8:"6"` + EncryptedData string `tlv8:"5"` + } + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM5); err != nil { + return + } + if cipherM5.State != StateM5 { + err = newRequestError(cipherM5) + return + } + + // decrypt message using session shared + encryptKey, err := hkdf.Sha512(sessionShared, "Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info") + if err != nil { + return + } + + b, err := chacha20poly1305.Decrypt(encryptKey, "PS-Msg05", []byte(cipherM5.EncryptedData)) + if err != nil { + return + } + + // unpack message from TLV8 + var plainM5 struct { + Identifier string `tlv8:"1"` + PublicKey string `tlv8:"3"` + Signature string `tlv8:"10"` + } + if err = tlv8.Unmarshal(b, &plainM5); err != nil { + return + } + + // 3. verify client ID and Public + remoteSign, err := hkdf.Sha512( + sessionShared, "Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info", + ) + if err != nil { + return + } + + b = Append(remoteSign, plainM5.Identifier, plainM5.PublicKey) + if !ed25519.ValidateSignature([]byte(plainM5.PublicKey), b, []byte(plainM5.Signature)) { + err = errors.New("hap: ValidateSignature") + return + } + + // 4. generate signature to our ID and Public + localSign, err := hkdf.Sha512( + sessionShared, "Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info", + ) + if err != nil { + return + } + + b = Append(localSign, s.DeviceID, s.ServerPublic()) // ServerPublic + signature, err := ed25519.Signature(s.DevicePrivate, b) + if err != nil { + return + } + + // 5. pack our ID and Public + plainM6 := struct { + Identifier string `tlv8:"1"` + PublicKey string `tlv8:"3"` + Signature string `tlv8:"10"` + }{ + Identifier: s.DeviceID, + PublicKey: string(s.ServerPublic()), + Signature: string(signature), + } + if b, err = tlv8.Marshal(plainM6); err != nil { + return + } + + // 6. encrypt message + b, err = chacha20poly1305.Encrypt(encryptKey, "PS-Msg06", b) + if err != nil { + return + } + + // STEP 6. Response to iPhone + cipherM6 := struct { + State byte `tlv8:"6"` + EncryptedData string `tlv8:"5"` + }{ + State: StateM6, + EncryptedData: string(b), + } + if body, err = tlv8.Marshal(cipherM6); err != nil { + return + } + if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { + return + } + + id = plainM5.Identifier + publicKey = []byte(plainM5.PublicKey) + + return +} + +func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter) (id string, sessionKey []byte, err error) { + // Request from iPhone + var plainM1 struct { + State byte `tlv8:"6"` + PublicKey string `tlv8:"3"` + } + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil { + return + } + if plainM1.State != StateM1 { + err = newRequestError(plainM1) + return } // Generate the key pair sessionPublic, sessionPrivate := curve25519.GenerateKeyPair() sessionShared, err := curve25519.SharedSecret(sessionPrivate, []byte(plainM1.PublicKey)) if err != nil { - return err + return } encryptKey, err := hkdf.Sha512( sessionShared, "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info", ) if err != nil { - return err + return } b := Append(sessionPublic, s.DeviceID, plainM1.PublicKey) signature, err := ed25519.Signature(s.DevicePrivate, b) if err != nil { - return err + return } // STEP M2. Response to iPhone @@ -91,12 +280,12 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co Signature: string(signature), } if b, err = tlv8.Marshal(plainM2); err != nil { - return err + return } b, err = chacha20poly1305.Encrypt(encryptKey, "PV-Msg02", b) if err != nil { - return err + return } cipherM2 := struct { @@ -110,30 +299,32 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co } body, err := tlv8.Marshal(cipherM2) if err != nil { - return err + return } if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { - return err + return } // STEP M3. Request from iPhone if req, err = http.ReadRequest(rw.Reader); err != nil { - return err + return } var cipherM3 struct { - EncryptedData string `tlv8:"5"` State byte `tlv8:"6"` + EncryptedData string `tlv8:"5"` } - if err = tlv8.UnmarshalReader(req.Body, &cipherM3); err != nil { - return err + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM3); err != nil { + return } if cipherM3.State != StateM3 { - return newRequestError(cipherM3) + err = newRequestError(cipherM3) + return } - if b, err = chacha20poly1305.Decrypt(encryptKey, "PV-Msg03", []byte(cipherM3.EncryptedData)); err != nil { - return err + b, err = chacha20poly1305.Decrypt(encryptKey, "PV-Msg03", []byte(cipherM3.EncryptedData)) + if err != nil { + return } var plainM3 struct { @@ -141,17 +332,21 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co Signature string `tlv8:"10"` } if err = tlv8.Unmarshal(b, &plainM3); err != nil { - return err + return } - clientPublic := s.GetPair(conn, plainM3.Identifier) - if clientPublic == nil { - return fmt.Errorf("hap: PairVerify from: %s, with unknown client_id: %s", conn.RemoteAddr(), plainM3.Identifier) - } + if s.GetClientPublic != nil { + clientPublic := s.GetClientPublic(plainM3.Identifier) + if clientPublic == nil { + err = errors.New("hap: PairVerify with unknown client_id: " + plainM3.Identifier) + return + } - b = Append(plainM1.PublicKey, plainM3.Identifier, sessionPublic) - if !ed25519.ValidateSignature(clientPublic, b, []byte(plainM3.Signature)) { - return errors.New("new: ValidateSignature") + b = Append(plainM1.PublicKey, plainM3.Identifier, sessionPublic) + if !ed25519.ValidateSignature(clientPublic, b, []byte(plainM3.Signature)) { + err = errors.New("hap: ValidateSignature") + return + } } // STEP M4. Response to iPhone @@ -161,15 +356,41 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co State: StateM4, } if body, err = tlv8.Marshal(payloadM4); err != nil { - return err + return } if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { - return err + return } - if conn, err = secure.Client(conn, sessionShared, false); err != nil { - return err - } + id = plainM3.Identifier + sessionKey = sessionShared - return s.Handler(conn) + return } + +func WriteResponse(w *bufio.Writer, statusCode int, contentType string, body []byte) error { + header := fmt.Sprintf( + "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n", + statusCode, http.StatusText(statusCode), contentType, len(body), + ) + body = append([]byte(header), body...) + if _, err := w.Write(body); err != nil { + return err + } + return w.Flush() +} + +//func WriteBackoff(rw *bufio.ReadWriter) error { +// plainM2 := struct { +// State byte `tlv8:"6"` +// Error byte `tlv8:"7"` +// }{ +// State: StateM2, +// Error: 3, // BackoffError +// } +// body, err := tlv8.Marshal(plainM2) +// if err != nil { +// return err +// } +// return WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body) +//} diff --git a/pkg/hap/server_pairing.go b/pkg/hap/server_pairing.go deleted file mode 100644 index 77895c10..00000000 --- a/pkg/hap/server_pairing.go +++ /dev/null @@ -1,252 +0,0 @@ -package hap - -import ( - "bufio" - "crypto/sha512" - "errors" - "fmt" - "io" - "net" - "net/http" - - "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" - "github.com/AlexxIT/go2rtc/pkg/hap/ed25519" - "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" - "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" - "github.com/tadglines/go-pkgs/crypto/srp" -) - -const ( - PairMethodSetup = iota - PairMethodSetupWithAuth - PairMethodVerify - PairMethodAdd - PairMethodRemove - PairMethodList -) - -func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter, conn net.Conn) error { - if req.Header.Get("Content-Type") != MimeTLV8 { - return errors.New("hap: wrong content type") - } - - // STEP 1. Request from iPhone - var plainM1 struct { - Method byte `tlv8:"0"` - State byte `tlv8:"6"` - Flags uint32 `tlv8:"19"` - } - if err := tlv8.UnmarshalReader(io.LimitReader(rw, req.ContentLength), &plainM1); err != nil { - return err - } - if plainM1.State != StateM1 { - return newRequestError(plainM1) - } - - username := []byte("Pair-Setup") - - // Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE) - pake, err := srp.NewSRP( - "rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username), - ) - if err != nil { - return err - } - - pake.SaltLength = 16 - - salt, verifier, err := pake.ComputeVerifier([]byte(s.Pin)) - - session := pake.NewServerSession(username, salt, verifier) - - // STEP 2. Response to iPhone - plainM2 := struct { - Salt string `tlv8:"2"` - PublicKey string `tlv8:"3"` - State byte `tlv8:"6"` - }{ - State: StateM2, - PublicKey: string(session.GetB()), - Salt: string(salt), - } - body, err := tlv8.Marshal(plainM2) - if err != nil { - return err - } - if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { - return err - } - - // STEP 3. Request from iPhone - if req, err = http.ReadRequest(rw.Reader); err != nil { - return err - } - - var plainM3 struct { - SessionKey string `tlv8:"3"` - Proof string `tlv8:"4"` - State byte `tlv8:"6"` - } - if err = tlv8.UnmarshalReader(req.Body, &plainM3); err != nil { - return err - } - if plainM3.State != StateM3 { - return newRequestError(plainM3) - } - - // important to compute key before verify client - sessionShared, err := session.ComputeKey([]byte(plainM3.SessionKey)) - if err != nil { - return err - } - - if !session.VerifyClientAuthenticator([]byte(plainM3.Proof)) { - return errors.New("hap: VerifyClientAuthenticator") - } - - proof := session.ComputeAuthenticator([]byte(plainM3.Proof)) // server proof - - // STEP 4. Response to iPhone - payloadM4 := struct { - Proof string `tlv8:"4"` - State byte `tlv8:"6"` - }{ - Proof: string(proof), - State: StateM4, - } - if body, err = tlv8.Marshal(payloadM4); err != nil { - return err - } - if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { - return err - } - - // STEP 5. Request from iPhone - if req, err = http.ReadRequest(rw.Reader); err != nil { - return err - } - var cipherM5 struct { - EncryptedData string `tlv8:"5"` - State byte `tlv8:"6"` - } - if err = tlv8.UnmarshalReader(req.Body, &cipherM5); err != nil { - return err - } - if cipherM5.State != StateM5 { - return newRequestError(cipherM5) - } - - // decrypt message using session shared - encryptKey, err := hkdf.Sha512(sessionShared, "Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info") - if err != nil { - return err - } - - b, err := chacha20poly1305.Decrypt(encryptKey, "PS-Msg05", []byte(cipherM5.EncryptedData)) - if err != nil { - return err - } - - // unpack message from TLV8 - var plainM5 struct { - Identifier string `tlv8:"1"` - PublicKey string `tlv8:"3"` - Signature string `tlv8:"10"` - } - if err = tlv8.Unmarshal(b, &plainM5); err != nil { - return err - } - - // 3. verify client ID and Public - remoteSign, err := hkdf.Sha512( - sessionShared, "Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info", - ) - if err != nil { - return err - } - - b = Append(remoteSign, plainM5.Identifier, plainM5.PublicKey) - if !ed25519.ValidateSignature([]byte(plainM5.PublicKey), b, []byte(plainM5.Signature)) { - return errors.New("hap: ValidateSignature") - } - - // 4. generate signature to our ID and Public - localSign, err := hkdf.Sha512( - sessionShared, "Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info", - ) - if err != nil { - return err - } - - b = Append(localSign, s.DeviceID, s.ServerPublic()) // ServerPublic - signature, err := ed25519.Signature(s.DevicePrivate, b) - if err != nil { - return err - } - - // 5. pack our ID and Public - plainM6 := struct { - Identifier string `tlv8:"1"` - PublicKey string `tlv8:"3"` - Signature string `tlv8:"10"` - }{ - Identifier: s.DeviceID, - PublicKey: string(s.ServerPublic()), - Signature: string(signature), - } - if b, err = tlv8.Marshal(plainM6); err != nil { - return err - } - - // 6. encrypt message - b, err = chacha20poly1305.Encrypt(encryptKey, "PS-Msg06", b) - if err != nil { - return err - } - - // STEP 6. Response to iPhone - cipherM6 := struct { - EncryptedData string `tlv8:"5"` - State byte `tlv8:"6"` - }{ - State: StateM6, - EncryptedData: string(b), - } - if body, err = tlv8.Marshal(cipherM6); err != nil { - return err - } - if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { - return err - } - - s.AddPair(conn, plainM5.Identifier, []byte(plainM5.PublicKey), PermissionAdmin) - - return nil -} - -func WriteResponse(w *bufio.Writer, statusCode int, contentType string, body []byte) error { - header := fmt.Sprintf( - "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n", - statusCode, http.StatusText(statusCode), contentType, len(body), - ) - body = append([]byte(header), body...) - if _, err := w.Write(body); err != nil { - return err - } - return w.Flush() -} - -func WriteBackoff(rw *bufio.ReadWriter) error { - plainM2 := struct { - State byte `tlv8:"6"` - Error byte `tlv8:"7"` - }{ - State: StateM2, - Error: 3, // BackoffError - } - body, err := tlv8.Marshal(plainM2) - if err != nil { - return err - } - return WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body) -} diff --git a/pkg/hap/setup/setup.go b/pkg/hap/setup/setup.go new file mode 100644 index 00000000..c5eeb51b --- /dev/null +++ b/pkg/hap/setup/setup.go @@ -0,0 +1,32 @@ +package setup + +import ( + "strconv" + "strings" +) + +const ( + FlagNFC = 1 + FlagIP = 2 + FlagBLE = 4 + FlagWAC = 8 // Wireless Accessory Configuration (WAC)/Apples MFi +) + +func GenerateSetupURI(category, pin, setupID string) string { + c, _ := strconv.Atoi(category) + p, _ := strconv.Atoi(strings.ReplaceAll(pin, "-", "")) + payload := int64(c&0xFF)<<31 | int64(FlagIP&0xF)<<27 | int64(p&0x7FFFFFF) + return "X-HM://" + FormatInt36(payload, 9) + setupID +} + +// FormatInt36 equal to strings.ToUpper(fmt.Sprintf("%0"+strconv.Itoa(n)+"s", strconv.FormatInt(value, 36))) +func FormatInt36(value int64, n int) string { + b := make([]byte, n) + for i := n - 1; 0 <= i; i-- { + b[i] = digits[value%36] + value /= 36 + } + return string(b) +} + +const digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" diff --git a/pkg/hap/setup/setup_test.go b/pkg/hap/setup/setup_test.go new file mode 100644 index 00000000..01672218 --- /dev/null +++ b/pkg/hap/setup/setup_test.go @@ -0,0 +1,18 @@ +package setup + +import ( + "fmt" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFormatAlphaNum(t *testing.T) { + value := int64(999) + n := 5 + s1 := strings.ToUpper(fmt.Sprintf("%0"+strconv.Itoa(n)+"s", strconv.FormatInt(value, 36))) + s2 := FormatInt36(value, n) + require.Equal(t, s1, s2) +} diff --git a/pkg/hap/tlv8/tlv8.go b/pkg/hap/tlv8/tlv8.go index 7af27ea4..7b397b99 100644 --- a/pkg/hap/tlv8/tlv8.go +++ b/pkg/hap/tlv8/tlv8.go @@ -112,6 +112,10 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) { v := value.Uint() return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil + case reflect.Uint64: + v := value.Uint() + return binary.LittleEndian.AppendUint64(append(b, tag, 8), v), nil + case reflect.Float32: v := math.Float32bits(float32(value.Float())) return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil @@ -170,11 +174,20 @@ func UnmarshalBase64(in any, out any) error { return Unmarshal(data, out) } -func UnmarshalReader(r io.Reader, v any) error { - data, err := io.ReadAll(r) +func UnmarshalReader(r io.Reader, n int64, v any) error { + var data []byte + var err error + + if n > 0 { + data = make([]byte, n) + _, err = io.ReadFull(r, data) + } else { + data, err = io.ReadAll(r) + } if err != nil { return err } + return Unmarshal(data, v) } @@ -301,6 +314,12 @@ func unmarshalValue(v []byte, value reflect.Value) error { } value.SetUint(uint64(v[0]) | uint64(v[1])<<8 | uint64(v[2])<<16 | uint64(v[3])<<24) + case reflect.Uint64: + if len(v) != 8 { + return errors.New("tlv8: wrong size: " + value.Type().Name()) + } + value.SetUint(binary.LittleEndian.Uint64(v)) + case reflect.Float32: f := math.Float32frombits(binary.LittleEndian.Uint32(v)) value.SetFloat(float64(f)) diff --git a/pkg/homekit/consumer.go b/pkg/homekit/consumer.go index ea83146f..c1be7447 100644 --- a/pkg/homekit/consumer.go +++ b/pkg/homekit/consumer.go @@ -49,7 +49,7 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer { Connection: core.Connection{ ID: core.NewID(), FormatName: "homekit", - Protocol: "udp", + Protocol: "rtp", RemoteAddr: conn.RemoteAddr().String(), Medias: medias, Transport: conn, @@ -59,7 +59,11 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer { } } -func (c *Consumer) SetOffer(offer *camera.SetupEndpoints) { +func (c *Consumer) SessionID() string { + return c.sessionID +} + +func (c *Consumer) SetOffer(offer *camera.SetupEndpointsRequest) { c.sessionID = offer.SessionID c.videoSession = &srtp.Session{ Remote: &srtp.Endpoint{ @@ -79,32 +83,32 @@ func (c *Consumer) SetOffer(offer *camera.SetupEndpoints) { } } -func (c *Consumer) GetAnswer() *camera.SetupEndpoints { +func (c *Consumer) GetAnswer() *camera.SetupEndpointsResponse { c.videoSession.Local = c.srtpEndpoint() c.audioSession.Local = c.srtpEndpoint() - return &camera.SetupEndpoints{ + return &camera.SetupEndpointsResponse{ SessionID: c.sessionID, - Status: []byte{0}, - Address: camera.Addr{ + Status: camera.StreamingStatusAvailable, + Address: camera.Address{ IPAddr: c.videoSession.Local.Addr, VideoRTPPort: c.videoSession.Local.Port, AudioRTPPort: c.audioSession.Local.Port, }, - VideoCrypto: camera.CryptoSuite{ + VideoCrypto: camera.SRTPCryptoSuite{ MasterKey: string(c.videoSession.Local.MasterKey), MasterSalt: string(c.videoSession.Local.MasterSalt), }, - AudioCrypto: camera.CryptoSuite{ + AudioCrypto: camera.SRTPCryptoSuite{ MasterKey: string(c.audioSession.Local.MasterKey), MasterSalt: string(c.audioSession.Local.MasterSalt), }, - VideoSSRC: []uint32{c.videoSession.Local.SSRC}, - AudioSSRC: []uint32{c.audioSession.Local.SSRC}, + VideoSSRC: c.videoSession.Local.SSRC, + AudioSSRC: c.audioSession.Local.SSRC, } } -func (c *Consumer) SetConfig(conf *camera.SelectedStreamConfig) bool { +func (c *Consumer) SetConfig(conf *camera.SelectedStreamConfiguration) bool { if c.sessionID != conf.Control.SessionID { return false } diff --git a/pkg/homekit/helpers.go b/pkg/homekit/helpers.go index f5a17319..625e3ab7 100644 --- a/pkg/homekit/helpers.go +++ b/pkg/homekit/helpers.go @@ -13,7 +13,7 @@ var videoCodecs = [...]string{core.CodecH264} var videoProfiles = [...]string{"4200", "4D00", "6400"} var videoLevels = [...]string{"1F", "20", "28"} -func videoToMedia(codecs []camera.VideoCodec) *core.Media { +func videoToMedia(codecs []camera.VideoCodecConfiguration) *core.Media { media := &core.Media{ Kind: core.KindVideo, Direction: core.DirectionRecvonly, } @@ -39,7 +39,7 @@ func videoToMedia(codecs []camera.VideoCodec) *core.Media { var audioCodecs = [...]string{core.CodecPCMU, core.CodecPCMA, core.CodecELD, core.CodecOpus} var audioSampleRates = [...]uint32{8000, 16000, 24000} -func audioToMedia(codecs []camera.AudioCodec) *core.Media { +func audioToMedia(codecs []camera.AudioCodecConfiguration) *core.Media { media := &core.Media{ Kind: core.KindAudio, Direction: core.DirectionRecvonly, } @@ -67,10 +67,10 @@ func audioToMedia(codecs []camera.AudioCodec) *core.Media { return media } -func trackToVideo(track *core.Receiver, video0 *camera.VideoCodec) *camera.VideoCodec { +func trackToVideo(track *core.Receiver, video0 *camera.VideoCodecConfiguration, maxWidth, maxHeight int) *camera.VideoCodecConfiguration { profileID := video0.CodecParams[0].ProfileID[0] level := video0.CodecParams[0].Level[0] - attrs := video0.VideoAttrs[0] + var attrs camera.VideoCodecAttributes if track != nil { profile := h264.GetProfileLevelID(track.Codec.FmtpLine) @@ -90,25 +90,28 @@ func trackToVideo(track *core.Receiver, video0 *camera.VideoCodec) *camera.Video } for _, s := range video0.VideoAttrs { + if (maxWidth > 0 && int(s.Width) > maxWidth) || (maxHeight > 0 && int(s.Height) > maxHeight) { + continue + } if s.Width > attrs.Width || s.Height > attrs.Height { attrs = s } } } - return &camera.VideoCodec{ + return &camera.VideoCodecConfiguration{ CodecType: video0.CodecType, - CodecParams: []camera.VideoParams{ + CodecParams: []camera.VideoCodecParameters{ { ProfileID: []byte{profileID}, Level: []byte{level}, }, }, - VideoAttrs: []camera.VideoAttrs{attrs}, + VideoAttrs: []camera.VideoCodecAttributes{attrs}, } } -func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodec) *camera.AudioCodec { +func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodecConfiguration) *camera.AudioCodecConfiguration { codecType := audio0.CodecType channels := audio0.CodecParams[0].Channels sampleRate := audio0.CodecParams[0].SampleRate[0] @@ -131,9 +134,9 @@ func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodec) *camera.Audio } } - return &camera.AudioCodec{ + return &camera.AudioCodecConfiguration{ CodecType: codecType, - CodecParams: []camera.AudioParams{ + CodecParams: []camera.AudioCodecParameters{ { Channels: channels, SampleRate: []byte{sampleRate}, diff --git a/pkg/homekit/log/debug.go b/pkg/homekit/log/debug.go new file mode 100644 index 00000000..1fb60be2 --- /dev/null +++ b/pkg/homekit/log/debug.go @@ -0,0 +1,45 @@ +package log + +import ( + "bytes" + "io" + "log" + "net/http" +) + +func Debug(v any) { + switch v := v.(type) { + case *http.Request: + if v == nil { + return + } + if v.ContentLength != 0 { + b, err := io.ReadAll(v.Body) + if err != nil { + panic(err) + } + v.Body = io.NopCloser(bytes.NewReader(b)) + log.Printf("[homekit] request: %s %s\n%s", v.Method, v.RequestURI, b) + } else { + log.Printf("[homekit] request: %s %s ", v.Method, v.RequestURI) + } + case *http.Response: + if v == nil { + return + } + if v.Header.Get("Content-Type") == "image/jpeg" { + log.Printf("[homekit] response: %d ", v.StatusCode) + return + } + if v.ContentLength != 0 { + b, err := io.ReadAll(v.Body) + if err != nil { + panic(err) + } + v.Body = io.NopCloser(bytes.NewReader(b)) + log.Printf("[homekit] response: %s %d\n%s", v.Proto, v.StatusCode, b) + } else { + log.Printf("[homekit] response: %s %d ", v.Proto, v.StatusCode) + } + } +} diff --git a/pkg/homekit/producer.go b/pkg/homekit/producer.go index 451b9882..81352a13 100644 --- a/pkg/homekit/producer.go +++ b/pkg/homekit/producer.go @@ -5,7 +5,6 @@ import ( "fmt" "math/rand" "net" - "net/url" "time" "github.com/AlexxIT/go2rtc/pkg/core" @@ -22,36 +21,25 @@ type Client struct { hap *hap.Client srtp *srtp.Server - videoConfig camera.SupportedVideoStreamConfig - audioConfig camera.SupportedAudioStreamConfig + videoConfig camera.SupportedVideoStreamConfiguration + audioConfig camera.SupportedAudioStreamConfiguration videoSession *srtp.Session audioSession *srtp.Session stream *camera.Stream - Bitrate int // in bits/s + MaxWidth int `json:"-"` + MaxHeight int `json:"-"` + Bitrate int `json:"-"` // in bits/s } func Dial(rawURL string, server *srtp.Server) (*Client, error) { - u, err := url.Parse(rawURL) + conn, err := hap.Dial(rawURL) if err != nil { return nil, err } - query := u.Query() - conn := &hap.Client{ - DeviceAddress: u.Host, - DeviceID: query.Get("device_id"), - DevicePublic: hap.DecodeKey(query.Get("device_public")), - ClientID: query.Get("client_id"), - ClientPrivate: hap.DecodeKey(query.Get("client_private")), - } - - if err = conn.Dial(); err != nil { - return nil, err - } - client := &Client{ Connection: core.Connection{ ID: core.NewID(), @@ -129,7 +117,7 @@ func (c *Client) Start() error { } videoTrack := c.trackByKind(core.KindVideo) - videoCodec := trackToVideo(videoTrack, &c.videoConfig.Codecs[0]) + videoCodec := trackToVideo(videoTrack, &c.videoConfig.Codecs[0], c.MaxWidth, c.MaxHeight) audioTrack := c.trackByKind(core.KindAudio) audioCodec := trackToAudio(audioTrack, &c.audioConfig.Codecs[0]) diff --git a/pkg/homekit/proxy.go b/pkg/homekit/proxy.go index be233042..2132266c 100644 --- a/pkg/homekit/proxy.go +++ b/pkg/homekit/proxy.go @@ -4,31 +4,30 @@ import ( "bufio" "bytes" "encoding/json" - "fmt" "io" "net" "net/http" + "time" "github.com/AlexxIT/go2rtc/pkg/hap" "github.com/AlexxIT/go2rtc/pkg/hap/camera" "github.com/AlexxIT/go2rtc/pkg/hap/hds" - "github.com/AlexxIT/go2rtc/pkg/hap/secure" "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" ) -func ProxyHandler(pair ServerPair, dial func() (net.Conn, error)) hap.HandlerFunc { +type ServerProxy interface { + ServerPair + AddConn(conn any) + DelConn(conn any) +} + +func ProxyHandler(srv ServerProxy, acc net.Conn) HandlerFunc { return func(con net.Conn) error { defer con.Close() - acc, err := dial() - if err != nil { - return err - } - defer acc.Close() - pr := &Proxy{ - con: con.(*secure.Conn), - acc: acc.(*secure.Conn), + con: con.(*hap.Conn), + acc: acc.(*hap.Conn), res: make(chan *http.Response), } @@ -36,17 +35,17 @@ func ProxyHandler(pair ServerPair, dial func() (net.Conn, error)) hap.HandlerFun go pr.handleAcc() // controller => accessory - return pr.handleCon(pair) + return pr.handleCon(srv) } } type Proxy struct { - con *secure.Conn - acc *secure.Conn + con *hap.Conn + acc *hap.Conn res chan *http.Response } -func (p *Proxy) handleCon(pair ServerPair) error { +func (p *Proxy) handleCon(srv ServerProxy) error { var hdsCharIID uint64 rd := bufio.NewReader(p.con) @@ -61,7 +60,7 @@ func (p *Proxy) handleCon(pair ServerPair) error { switch { case req.Method == "POST" && req.URL.Path == hap.PathPairings: var res *http.Response - if res, err = handlePairings(p.con, req, pair); err != nil { + if res, err = handlePairings(req, srv); err != nil { return err } if err = res.Write(p.con); err != nil { @@ -74,7 +73,7 @@ func (p *Proxy) handleCon(pair ServerPair) error { _ = json.Unmarshal(body, &v) for _, char := range v.Value { if char.IID == hdsCharIID { - var hdsReq camera.SetupDataStreamRequest + var hdsReq camera.SetupDataStreamTransportRequest _ = tlv8.UnmarshalBase64(char.Value, &hdsReq) hdsConSalt = hdsReq.ControllerKeySalt break @@ -110,14 +109,14 @@ func (p *Proxy) handleCon(pair ServerPair) error { _ = json.Unmarshal(body, &v) for i, char := range v.Value { if char.IID == hdsCharIID { - var hdsRes camera.SetupDataStreamResponse + var hdsRes camera.SetupDataStreamTransportResponse _ = tlv8.UnmarshalBase64(char.Value, &hdsRes) hdsAccSalt := hdsRes.AccessoryKeySalt hdsPort := int(hdsRes.TransportTypeSessionParameters.TCPListeningPort) // swtich accPort to conPort - hdsPort, err = p.listenHDS(hdsPort, hdsConSalt+hdsAccSalt) + hdsPort, err = p.listenHDS(srv, hdsPort, hdsConSalt+hdsAccSalt) if err != nil { return err } @@ -149,7 +148,7 @@ func (p *Proxy) handleAcc() error { } if res.Proto == hap.ProtoEvent { - if err = res.Write(p.con); err != nil { + if err = hap.WriteEvent(p.con, res); err != nil { return err } continue @@ -166,7 +165,8 @@ func (p *Proxy) handleAcc() error { } } -func (p *Proxy) listenHDS(accPort int, salt string) (int, error) { +func (p *Proxy) listenHDS(srv ServerProxy, accPort int, salt string) (int, error) { + // The TCP port range for HDS must be >= 32768. ln, err := net.ListenTCP("tcp", nil) if err != nil { return 0, err @@ -175,30 +175,36 @@ func (p *Proxy) listenHDS(accPort int, salt string) (int, error) { go func() { defer ln.Close() + _ = ln.SetDeadline(time.Now().Add(30 * time.Second)) + // raw controller conn - con, err := ln.Accept() + conn1, err := ln.Accept() if err != nil { return } - defer con.Close() + + defer conn1.Close() // secured controller conn (controlle=false because we are accessory) - con, err = hds.Client(con, p.con.SharedKey, salt, false) + con, err := hds.NewConn(conn1, p.con.SharedKey, salt, false) if err != nil { return } + srv.AddConn(con) + defer srv.DelConn(con) + accIP := p.acc.RemoteAddr().(*net.TCPAddr).IP // raw accessory conn - acc, err := net.Dial("tcp", fmt.Sprintf("%s:%d", accIP, accPort)) + conn2, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: accIP, Port: accPort}) if err != nil { return } - defer acc.Close() + defer conn2.Close() // secured accessory conn (controller=true because we are controller) - acc, err = hds.Client(acc, p.acc.SharedKey, salt, true) + acc, err := hds.NewConn(conn2, p.acc.SharedKey, salt, true) if err != nil { return } diff --git a/pkg/homekit/server.go b/pkg/homekit/server.go index 20cfc59d..75ba2a0f 100644 --- a/pkg/homekit/server.go +++ b/pkg/homekit/server.go @@ -15,15 +15,17 @@ import ( "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" ) +type HandlerFunc func(net.Conn) error + type Server interface { ServerPair ServerAccessory } type ServerPair interface { - GetPair(conn net.Conn, id string) []byte - AddPair(conn net.Conn, id string, public []byte, permissions byte) - DelPair(conn net.Conn, id string) + GetPair(id string) []byte + AddPair(id string, public []byte, permissions byte) + DelPair(id string) } type ServerAccessory interface { @@ -33,11 +35,11 @@ type ServerAccessory interface { GetImage(conn net.Conn, width, height int) []byte } -func ServerHandler(server Server) hap.HandlerFunc { +func ServerHandler(server Server) HandlerFunc { return handleRequest(func(conn net.Conn, req *http.Request) (*http.Response, error) { switch req.URL.Path { case hap.PathPairings: - return handlePairings(conn, req, server) + return handlePairings(req, server) case hap.PathAccessories: body := hap.JSONAccessories{Value: server.GetAccessories(conn)} @@ -103,7 +105,7 @@ func ServerHandler(server Server) hap.HandlerFunc { }) } -func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response, error)) hap.HandlerFunc { +func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response, error)) HandlerFunc { return func(conn net.Conn) error { rw := bufio.NewReaderSize(conn, 16*1024) wr := bufio.NewWriterSize(conn, 16*1024) @@ -130,7 +132,7 @@ func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response } } -func handlePairings(conn net.Conn, req *http.Request, pair ServerPair) (*http.Response, error) { +func handlePairings(req *http.Request, srv ServerPair) (*http.Response, error) { cmd := struct { Method byte `tlv8:"0"` Identifier string `tlv8:"1"` @@ -139,15 +141,15 @@ func handlePairings(conn net.Conn, req *http.Request, pair ServerPair) (*http.Re Permissions byte `tlv8:"11"` }{} - if err := tlv8.UnmarshalReader(req.Body, &cmd); err != nil { + if err := tlv8.UnmarshalReader(req.Body, req.ContentLength, &cmd); err != nil { return nil, err } switch cmd.Method { case 3: // add - pair.AddPair(conn, cmd.Identifier, []byte(cmd.PublicKey), cmd.Permissions) + srv.AddPair(cmd.Identifier, []byte(cmd.PublicKey), cmd.Permissions) case 4: // delete - pair.DelPair(conn, cmd.Identifier) + srv.DelPair(cmd.Identifier) } body := struct { @@ -190,40 +192,3 @@ func makeResponse(mime string, v any) (*http.Response, error) { } return res, nil } - -//func debug(v any) { -// switch v := v.(type) { -// case *http.Request: -// if v == nil { -// return -// } -// if v.ContentLength != 0 { -// b, err := io.ReadAll(v.Body) -// if err != nil { -// panic(err) -// } -// v.Body = io.NopCloser(bytes.NewReader(b)) -// log.Printf("[homekit] request: %s %s\n%s", v.Method, v.RequestURI, b) -// } else { -// log.Printf("[homekit] request: %s %s ", v.Method, v.RequestURI) -// } -// case *http.Response: -// if v == nil { -// return -// } -// if v.Header.Get("Content-Type") == "image/jpeg" { -// log.Printf("[homekit] response: %d ", v.StatusCode) -// return -// } -// if v.ContentLength != 0 { -// b, err := io.ReadAll(v.Body) -// if err != nil { -// panic(err) -// } -// v.Body = io.NopCloser(bytes.NewReader(b)) -// log.Printf("[homekit] response: %d\n%s", v.StatusCode, b) -// } else { -// log.Printf("[homekit] response: %d ", v.StatusCode) -// } -// } -//} diff --git a/pkg/mpegts/demuxer.go b/pkg/mpegts/demuxer.go index 08ccca39..3e78fceb 100644 --- a/pkg/mpegts/demuxer.go +++ b/pkg/mpegts/demuxer.go @@ -330,6 +330,7 @@ const ( StreamTypeH264 = 0x1B StreamTypeH265 = 0x24 StreamTypePCMATapo = 0x90 + StreamTypePCMUTapo = 0x91 StreamTypePrivateOPUS = 0xEB ) @@ -392,7 +393,7 @@ func (p *PES) GetPacket() (pkt *rtp.Packet) { //p.Timestamp += aac.RTPTimeSize(pkt.Payload) // update next timestamp! - case StreamTypePCMATapo: + case StreamTypePCMATapo, StreamTypePCMUTapo: p.Sequence++ pkt = &rtp.Packet{ diff --git a/pkg/multitrans/client.go b/pkg/multitrans/client.go new file mode 100644 index 00000000..d71269c1 --- /dev/null +++ b/pkg/multitrans/client.go @@ -0,0 +1,203 @@ +package multitrans + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "net" + "net/http" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/google/uuid" + "github.com/pion/rtp" +) + +type Client struct { + core.Connection + conn net.Conn + rd *bufio.Reader + closed core.Waiter +} + +func Dial(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + if u.Port() == "" { + u.Host += ":554" + } + + conn, err := net.DialTimeout("tcp", u.Host, core.ConnDialTimeout) + if err != nil { + return nil, err + } + + c := &Client{ + conn: conn, + rd: bufio.NewReader(conn), + } + + if err = c.handshake(u); err != nil { + _ = conn.Close() + return nil, err + } + + c.Connection = core.Connection{ + ID: core.NewID(), + FormatName: "multitrans", + Protocol: "rtsp", + RemoteAddr: conn.RemoteAddr().String(), + Source: rawURL, + Medias: []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{{Name: core.CodecPCMA, ClockRate: 8000, PayloadType: 8}}, + }, + }, + Transport: conn, + } + + return c, nil +} + +func (c *Client) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + sender.Handler = func(packet *rtp.Packet) { + clone := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: packet.Marker, + PayloadType: 8, + SequenceNumber: packet.SequenceNumber, + Timestamp: packet.Timestamp, + SSRC: packet.SSRC, + }, + Payload: packet.Payload, + } + + // Encapsulate in RTSP Interleaved Frame (Channel 1) + // $ + Channel(1 byte) + Length(2 bytes) + Packet + size := 12 + len(clone.Payload) + b := make([]byte, 4+size) + b[0] = '$' + b[1] = 1 // Channel 1 for audio + b[2] = byte(size >> 8) + b[3] = byte(size) + if _, err := clone.MarshalTo(b[4:]); err != nil { + return + } + if _, err := c.conn.Write(b); err != nil { + return + } + } + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Client) handshake(u *url.URL) error { + // Step 1: Get Challenge + uid := uuid.New().String() + + uri := fmt.Sprintf("rtsp://%s/multitrans", u.Host) + data := fmt.Sprintf("MULTITRANS %s RTSP/1.0\r\nCSeq: 0\r\nX-Client-UUID: %s\r\n\r\n", uri, uid) + + if _, err := c.conn.Write([]byte(data)); err != nil { + return err + } + + res, err := tcp.ReadResponse(c.rd) + if err != nil { + return err + } + + if res.StatusCode != http.StatusUnauthorized { + return errors.New("multitrans: expected 401, got " + res.Status) + } + + auth := res.Header.Get("WWW-Authenticate") + realm := tcp.Between(auth, `realm="`, `"`) + nonce := tcp.Between(auth, `nonce="`, `"`) + + // Step 2: Send Auth + user := u.User.Username() + pass, _ := u.User.Password() + + ha1 := tcp.HexMD5(user, realm, pass) + ha2 := tcp.HexMD5("MULTITRANS", uri) + response := tcp.HexMD5(ha1, nonce, ha2) + + authHeader := fmt.Sprintf(`Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`, + user, realm, nonce, uri, response) + + data = fmt.Sprintf("MULTITRANS %s RTSP/1.0\r\nCSeq: 1\r\nAuthorization: %s\r\nX-Client-UUID: %s\r\n\r\n", + uri, authHeader, uid) + + if _, err = c.conn.Write([]byte(data)); err != nil { + return err + } + + res, err = tcp.ReadResponse(c.rd) + if err != nil { + return err + } + + if res.StatusCode != http.StatusOK { + return errors.New("multitrans: auth failed: " + res.Status) + } + + // Session: 7116520596809429228 + session := res.Header.Get("Session") + if session == "" { + return errors.New("multitrans: no session") + } + + return c.openTalkChannel(uri, session) +} + +func (c *Client) openTalkChannel(uri, session string) error { + payload := `{"type":"request","seq":0,"params":{"method":"get","talk":{"mode":"full_duplex"}}}` + + data := fmt.Sprintf("MULTITRANS %s RTSP/1.0\r\nCSeq: 2\r\nSession: %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", + uri, session, len(payload), payload) + + if _, err := c.conn.Write([]byte(data)); err != nil { + return err + } + + res, err := tcp.ReadResponse(c.rd) + if err != nil { + return err + } + + if res.StatusCode != http.StatusOK { + return errors.New("multitrans: talkback failed: " + res.Status) + } + + // Python checks for "error_code":0 in body. + if !bytes.Contains(res.Body, []byte(`"error_code":0`)) { + return fmt.Errorf("multitrans: talkback error: %s", string(res.Body)) + } + + return nil +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (c *Client) Start() error { + _ = c.closed.Wait() + return nil +} + +func (c *Client) Stop() error { + c.closed.Done(nil) + return c.Connection.Stop() +} diff --git a/pkg/onvif/client.go b/pkg/onvif/client.go index 77bbe0ff..bad103c7 100644 --- a/pkg/onvif/client.go +++ b/pkg/onvif/client.go @@ -3,10 +3,9 @@ package onvif import ( "bytes" "errors" - "fmt" "html" "io" - "net" + "net/http" "net/url" "regexp" "strings" @@ -32,26 +31,18 @@ func NewClient(rawURL string) (*Client, error) { baseURL := "http://" + u.Host client := &Client{url: u} - if u.Path == "" { - client.deviceURL = baseURL + PathDevice - } else { - client.deviceURL = baseURL + u.Path - } + client.deviceURL = baseURL + GetPath(u.Path, PathDevice) b, err := client.DeviceRequest(DeviceGetCapabilities) if err != nil { return nil, err } - client.mediaURL = FindTagValue(b, "Media.+?XAddr") - if client.mediaURL == "" { - client.mediaURL = baseURL + "/onvif/media_service" - } + s := FindTagValue(b, "Media.+?XAddr") + client.mediaURL = baseURL + GetPath(s, "/onvif/media_service") - client.imaginURL = FindTagValue(b, "Imaging.+?XAddr") - if client.imaginURL == "" { - client.imaginURL = baseURL + "/onvif/imaging_service" - } + s = FindTagValue(b, "Imaging.+?XAddr") + client.imaginURL = baseURL + GetPath(s, "/onvif/imaging_service") return client, nil } @@ -183,62 +174,24 @@ func (c *Client) MediaRequest(operation string) ([]byte, error) { return c.Request(c.mediaURL, operation) } -func (c *Client) Request(rawUrl, body string) ([]byte, error) { - if rawUrl == "" { +func (c *Client) Request(url, body string) ([]byte, error) { + if url == "" { return nil, errors.New("onvif: unsupported service") } e := NewEnvelopeWithUser(c.url.User) e.Append(body) - u, err := url.Parse(rawUrl) + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Post(url, `application/soap+xml;charset=utf-8`, bytes.NewReader(e.Bytes())) if err != nil { return nil, err } + defer res.Body.Close() - host := u.Host - if u.Port() == "" { - host += ":80" + if res.StatusCode != http.StatusOK { + return nil, errors.New("onvif: wrong response " + res.Status) } - conn, err := net.DialTimeout("tcp", host, 5*time.Second) - if err != nil { - return nil, err - } - defer conn.Close() - - reqBody := e.Bytes() - rawReq := fmt.Appendf(nil, "POST %s HTTP/1.1\r\n"+ - "Host: %s\r\n"+ - "Content-Type: application/soap+xml;charset=utf-8\r\n"+ - "Content-Length: %d\r\n"+ - "Connection: close\r\n"+ - "\r\n", u.Path, u.Host, len(reqBody)) - rawReq = append(rawReq, reqBody...) - - if _, err = conn.Write(rawReq); err != nil { - return nil, err - } - - rawRes, err := io.ReadAll(conn) - if err != nil { - return nil, err - } - - // Look for XML in complete response - if i := bytes.Index(rawRes, []byte(" 0 { - return rawRes[i:], nil - } - - // No XML found - might be an error response - if i := bytes.Index(rawRes, []byte("\r\n\r\n")); i > 0 { - if bytes.Contains(rawRes[:i], []byte("chunked")) { - return nil, errors.New("onvif: TODO: support chunked encoding") - } - - // Return body after headers - return rawRes[i+4:], nil - } - - return rawRes, nil + return io.ReadAll(res.Body) } diff --git a/pkg/onvif/envelope.go b/pkg/onvif/envelope.go index f0e1b29c..76a41260 100644 --- a/pkg/onvif/envelope.go +++ b/pkg/onvif/envelope.go @@ -15,14 +15,9 @@ type Envelope struct { } const ( - prefix1 = ` - -` - prefix2 = ` -` - suffix = ` - -` + prefix1 = `` + prefix2 = `` + suffix = `` ) func NewEnvelope() *Envelope { @@ -54,8 +49,7 @@ func NewEnvelopeWithUser(user *url.Userinfo) *Envelope { %s - -`, +`, user.Username(), base64.StdEncoding.EncodeToString(h.Sum(nil)), base64.StdEncoding.EncodeToString([]byte(nonce)), diff --git a/pkg/onvif/helpers.go b/pkg/onvif/helpers.go index f240f2ec..8fac9ac4 100644 --- a/pkg/onvif/helpers.go +++ b/pkg/onvif/helpers.go @@ -3,6 +3,7 @@ package onvif import ( "fmt" "net" + "net/url" "regexp" "strconv" "strings" @@ -11,6 +12,12 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" ) +type DiscoveryDevice struct { + URL string + Name string + Hardware string +} + func FindTagValue(b []byte, tag string) string { re := regexp.MustCompile(`(?s)<(?:\w+:)?` + tag + `\b[^>]*>([^<]+)`) m := re.FindSubmatch(b) @@ -26,7 +33,8 @@ func UUID() string { return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:] } -func DiscoveryStreamingURLs() ([]string, error) { +// DiscoveryStreamingDevices return list of tuple (onvif_url, name, hardware) +func DiscoveryStreamingDevices() ([]DiscoveryDevice, error) { conn, err := net.ListenUDP("udp4", nil) if err != nil { return nil, err @@ -60,11 +68,9 @@ func DiscoveryStreamingURLs() ([]string, error) { return nil, err } - if err = conn.SetReadDeadline(time.Now().Add(time.Second * 3)); err != nil { - return nil, err - } + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) - var urls []string + var devices []DiscoveryDevice b := make([]byte, 8192) for { @@ -80,21 +86,35 @@ func DiscoveryStreamingURLs() ([]string, error) { continue } - url := FindTagValue(b[:n], "XAddrs") - if url == "" { + device := DiscoveryDevice{ + URL: FindTagValue(b[:n], "XAddrs"), + } + + if device.URL == "" { continue } // fix some buggy cameras // http://0.0.0.0:8080/onvif/device_service - if strings.HasPrefix(url, "http://0.0.0.0") { - url = "http://" + addr.IP.String() + url[14:] + if s, ok := strings.CutPrefix(device.URL, "http://0.0.0.0"); ok { + device.URL = "http://" + addr.IP.String() + s } - urls = append(urls, url) + // try to find the camera name and model (hardware) + scopes := FindTagValue(b[:n], "Scopes") + device.Name = findScope(scopes, "onvif://www.onvif.org/name/") + device.Hardware = findScope(scopes, "onvif://www.onvif.org/hardware/") + + devices = append(devices, device) } - return urls, nil + return devices, nil +} + +func findScope(s, prefix string) string { + s = core.Between(s, prefix, " ") + s, _ = url.QueryUnescape(s) + return s } func atoi(s string) int { @@ -129,3 +149,14 @@ func GetPosixTZ(current time.Time) string { return prefix + fmt.Sprintf("%02d:%02d", offset/60, offset%60) } + +func GetPath(urlOrPath, defPath string) string { + if urlOrPath == "" || urlOrPath[0] == '/' { + return defPath + } + u, err := url.Parse(urlOrPath) + if err != nil { + return defPath + } + return GetPath(u.Path, defPath) +} diff --git a/pkg/onvif/server.go b/pkg/onvif/server.go index 54272798..fe3ba8b3 100644 --- a/pkg/onvif/server.go +++ b/pkg/onvif/server.go @@ -21,21 +21,24 @@ const ( DeviceGetScopes = "GetScopes" DeviceGetServices = "GetServices" DeviceGetSystemDateAndTime = "GetSystemDateAndTime" + DeviceSetSystemDateAndTime = "SetSystemDateAndTime" DeviceSystemReboot = "SystemReboot" ) const ( - MediaGetAudioEncoderConfigurations = "GetAudioEncoderConfigurations" - MediaGetAudioSources = "GetAudioSources" - MediaGetAudioSourceConfigurations = "GetAudioSourceConfigurations" - MediaGetProfile = "GetProfile" - MediaGetProfiles = "GetProfiles" - MediaGetSnapshotUri = "GetSnapshotUri" - MediaGetStreamUri = "GetStreamUri" - MediaGetVideoEncoderConfigurations = "GetVideoEncoderConfigurations" - MediaGetVideoSources = "GetVideoSources" - MediaGetVideoSourceConfiguration = "GetVideoSourceConfiguration" - MediaGetVideoSourceConfigurations = "GetVideoSourceConfigurations" + MediaGetAudioEncoderConfigurations = "GetAudioEncoderConfigurations" + MediaGetAudioSources = "GetAudioSources" + MediaGetAudioSourceConfigurations = "GetAudioSourceConfigurations" + MediaGetProfile = "GetProfile" + MediaGetProfiles = "GetProfiles" + MediaGetSnapshotUri = "GetSnapshotUri" + MediaGetStreamUri = "GetStreamUri" + MediaGetVideoEncoderConfiguration = "GetVideoEncoderConfiguration" + MediaGetVideoEncoderConfigurations = "GetVideoEncoderConfigurations" + MediaGetVideoEncoderConfigurationOptions = "GetVideoEncoderConfigurationOptions" + MediaGetVideoSources = "GetVideoSources" + MediaGetVideoSourceConfiguration = "GetVideoSourceConfiguration" + MediaGetVideoSourceConfigurations = "GetVideoSourceConfigurations" ) func GetRequestAction(b []byte) string { @@ -54,13 +57,13 @@ func GetRequestAction(b []byte) string { func GetCapabilitiesResponse(host string) []byte { e := NewEnvelope() - e.Append(` + e.Appendf(` - http://`, host, `/onvif/device_service + http://%s/onvif/device_service - http://`, host, `/onvif/media_service + http://%s/onvif/media_service false false @@ -68,24 +71,24 @@ func GetCapabilitiesResponse(host string) []byte { -`) +`, host, host) return e.Bytes() } func GetServicesResponse(host string) []byte { e := NewEnvelope() - e.Append(` + e.Appendf(` http://www.onvif.org/ver10/device/wsdl - http://`, host, `/onvif/device_service + http://%s/onvif/device_service 25 http://www.onvif.org/ver10/media/wsdl - http://`, host, `/onvif/media_service + http://%s/onvif/media_service 25 -`) +`, host, host) return e.Bytes() } @@ -120,30 +123,19 @@ func GetSystemDateAndTimeResponse() []byte { func GetDeviceInformationResponse(manuf, model, firmware, serial string) []byte { e := NewEnvelope() - e.Append(` - `, manuf, ` - `, model, ` - `, firmware, ` - `, serial, ` + e.Appendf(` + %s + %s + %s + %s 1.00 -`) - return e.Bytes() -} - -func GetMediaServiceCapabilitiesResponse() []byte { - e := NewEnvelope() - e.Append(` - - - -`) +`, manuf, model, firmware, serial) return e.Bytes() } func GetProfilesResponse(names []string) []byte { e := NewEnvelope() - e.Append(` -`) + e.Append(``) for _, name := range names { appendProfile(e, "Profiles", name) } @@ -153,38 +145,40 @@ func GetProfilesResponse(names []string) []byte { func GetProfileResponse(name string) []byte { e := NewEnvelope() - e.Append(` -`) + e.Append(``) appendProfile(e, "Profile", name) e.Append(``) return e.Bytes() } func appendProfile(e *Envelope, tag, name string) { - // empty `RateControl` important for UniFi Protect - e.Append(` - `, name, ` - - VSC - `, name, ` - - - - VEC - H264 - 19201080 - - - -`) + // go2rtc name = ONVIF Profile Name = ONVIF Profile token + e.Appendf(``, tag, name) + e.Appendf(`%s`, name) + appendVideoSourceConfiguration(e, "VideoSourceConfiguration", name) + appendVideoEncoderConfiguration(e, "VideoEncoderConfiguration") + e.Appendf(``, tag) +} + +func GetVideoSourcesResponse(names []string) []byte { + // go2rtc name = ONVIF VideoSource token + e := NewEnvelope() + e.Append(``) + for _, name := range names { + e.Appendf(` + 30.000000 + 19201080 +`, name) + } + e.Append(``) + return e.Bytes() } func GetVideoSourceConfigurationsResponse(names []string) []byte { e := NewEnvelope() - e.Append(` -`) + e.Append(``) for _, name := range names { - appendProfile(e, "Configurations", name) + appendVideoSourceConfiguration(e, "Configurations", name) } e.Append(``) return e.Bytes() @@ -192,46 +186,60 @@ func GetVideoSourceConfigurationsResponse(names []string) []byte { func GetVideoSourceConfigurationResponse(name string) []byte { e := NewEnvelope() - e.Append(` -`) + e.Append(``) appendVideoSourceConfiguration(e, "Configuration", name) e.Append(``) return e.Bytes() } func appendVideoSourceConfiguration(e *Envelope, tag, name string) { - e.Append(` + // go2rtc name = ONVIF VideoSourceConfiguration token + e.Appendf(` VSC - `, name, ` + %s - -`) +`, tag, name, name, tag) } -func GetVideoSourcesResponse(names []string) []byte { +func GetVideoEncoderConfigurationsResponse() []byte { e := NewEnvelope() - e.Append(` -`) - for _, name := range names { - e.Append(` - 30.000000 - 19201080 - -`) - } - e.Append(``) + e.Append(``) + appendVideoEncoderConfiguration(e, "VideoEncoderConfigurations") + e.Append(``) return e.Bytes() } +func GetVideoEncoderConfigurationResponse() []byte { + e := NewEnvelope() + e.Append(``) + appendVideoEncoderConfiguration(e, "VideoEncoderConfiguration") + e.Append(``) + return e.Bytes() +} + +func appendVideoEncoderConfiguration(e *Envelope, tag string) { + // empty `RateControl` important for UniFi Protect + e.Appendf(` + VEC + 1 + H264 + 19201080 + 0 + 3018192 + 10Main + PT10S + `, tag, tag) +} + func GetStreamUriResponse(uri string) []byte { e := NewEnvelope() - e.Append(``, uri, ``) + e.Appendf(`%s`, uri) return e.Bytes() } func GetSnapshotUriResponse(uri string) []byte { e := NewEnvelope() - e.Append(``, uri, ``) + e.Appendf(`%s`, uri) return e.Bytes() } @@ -239,6 +247,10 @@ func StaticResponse(operation string) []byte { switch operation { case DeviceGetSystemDateAndTime: return GetSystemDateAndTimeResponse() + case MediaGetVideoEncoderConfiguration: + return GetVideoEncoderConfigurationResponse() + case MediaGetVideoEncoderConfigurations: + return GetVideoEncoderConfigurationsResponse() } e := NewEnvelope() @@ -247,11 +259,18 @@ func StaticResponse(operation string) []byte { } var responses = map[string]string{ + ServiceGetServiceCapabilities: ` + + + +`, + DeviceGetDiscoveryMode: `Discoverable`, DeviceGetDNS: ``, DeviceGetHostname: ``, DeviceGetNetworkDefaultGateway: ``, DeviceGetNTP: ``, + DeviceSetSystemDateAndTime: ``, DeviceSystemReboot: `OK`, DeviceGetNetworkInterfaces: ``, @@ -263,16 +282,20 @@ var responses = map[string]string{ Fixedonvif://www.onvif.org/type/Network_Video_Transmitter `, - MediaGetVideoEncoderConfigurations: ` - - VEC - H264 - 19201080 - - -`, - MediaGetAudioEncoderConfigurations: ``, MediaGetAudioSources: ``, MediaGetAudioSourceConfigurations: ``, + + MediaGetVideoEncoderConfigurationOptions: ` + + 16 + + 19201080 + 0100 + 130 + 1100 + Main + + +`, } diff --git a/pkg/opus/.opus.go b/pkg/opus/.opus.go deleted file mode 100644 index 42043977..00000000 --- a/pkg/opus/.opus.go +++ /dev/null @@ -1,68 +0,0 @@ -package opus - -import ( - "time" - - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/pion/rtp" -) - -func Log(handler core.HandlerFunc) core.HandlerFunc { - var ts uint32 - - return func(pkt *rtp.Packet) { - if ts == 0 { - ts = pkt.Timestamp - } - - toc := pkt.Payload[0] - //config := toc >> 3 - code := toc & 0b11 - - frame := parseFrameSize(toc) - rate := parseSampleRate(toc) - - log.Printf( - "[RTP/OPUS] frame=%s rate=%5d code=%d size=%6d ts=%10d dt=%5d pt=%2d ssrc=%d seq=%d mark=%t", - frame, rate, code, len(pkt.Payload), pkt.Timestamp, pkt.Timestamp-ts, pkt.PayloadType, pkt.SSRC, pkt.SequenceNumber, pkt.Marker, - ) - - ts = pkt.Timestamp - - handler(pkt) - } -} - -func parseFrameSize(toc byte) time.Duration { - switch toc >> 3 { - case 0, 4, 8, 12, 14, 18, 22, 26, 30: - return 10_000_000 - case 1, 5, 9, 13, 15, 19, 23, 27, 31: - return 20_000_000 - case 2, 6, 10: - return 40_000_000 - case 3, 7, 11: - return 60_000_000 - case 16, 20, 24, 28: - return 2_500_000 - case 17, 21, 25, 29: - return 5_000_000 - } - return 0 -} - -func parseSampleRate(toc byte) uint16 { - switch toc >> 3 { - case 0, 1, 2, 3, 16, 17, 18, 19: - return 8000 - case 4, 5, 6, 7: - return 12000 - case 8, 9, 10, 11, 20, 21, 22, 23: - return 16000 - case 12, 13, 24, 25, 26, 27: - return 24000 - case 14, 15, 28, 29, 30, 31: - return 48000 - } - return 0 -} diff --git a/pkg/opus/opus.go b/pkg/opus/opus.go new file mode 100644 index 00000000..fb67c66d --- /dev/null +++ b/pkg/opus/opus.go @@ -0,0 +1,118 @@ +package opus + +import ( + "time" +) + +type Header struct { + Mode string + SampleRate uint16 + FrameSize time.Duration + Channels byte + Frames byte +} + +func UnmarshalHeader(b []byte) *Header { + // https://datatracker.ietf.org/doc/html/rfc6716#section-3.1 + b0 := b[0] + config := b0 >> 3 + return &Header{ + Mode: parseMode(config), + SampleRate: parseSampleRate(config), + FrameSize: parseFrameSize(config), + Channels: parseChannels(b0 >> 2 & 0b1), + Frames: parseFrames(b0 & 0b11), + } +} + +func parseMode(config byte) string { + if config <= 11 { + return "silk" + } + if config <= 15 { + return "hybrid" + } + return "celt" +} + +func parseSampleRate(config byte) uint16 { + switch config { + case 0, 1, 2, 3, 16, 17, 18, 19: + return 8000 // NB (narrowband) + case 4, 5, 6, 7: + return 12000 // MB (medium-band) + case 8, 9, 10, 11, 20, 21, 22, 23: + return 16000 // WB (wideband) + case 12, 13, 24, 25, 26, 27: + return 24000 // SWB (super-wideband) + case 14, 15, 28, 29, 30, 31: + return 48000 // FB (fullband) + } + return 0 +} + +func parseFrameSize(config byte) time.Duration { + switch config { + case 0, 4, 8, 12, 14, 18, 22, 26, 30: + return 10_000_000 + case 1, 5, 9, 13, 15, 19, 23, 27, 31: + return 20_000_000 + case 2, 6, 10: + return 40_000_000 + case 3, 7, 11: + return 60_000_000 + case 16, 20, 24, 28: + return 2_500_000 + case 17, 21, 25, 29: + return 5_000_000 + } + return 0 +} + +func parseChannels(s byte) byte { + if s == 1 { + return 2 + } + return 1 +} + +func parseFrames(c byte) byte { + switch c { + case 0: + return 1 + case 1, 2: + return 2 + } + return 0xFF +} + +func JoinFrames(b1, b2 []byte) []byte { + // can't join + if b1[0]&0b11 != 0 || b2[0]&0b11 != 0 { + return append(b1, b2...) + } + + size1, size2 := len(b1)-1, len(b2)-1 + + // join same sizes + if size1 == size2 { + b := make([]byte, 1+size1+size2) + copy(b, b1) + copy(b[1+size1:], b2[1:]) + b[0] |= 0b01 + return b + } + + b := make([]byte, 1, 3+size1+size2) + b[0] = b1[0] | 0b10 + if size1 >= 252 { + b0 := 252 + byte(size1)&0b11 + b = append(b, b0, byte(size1/4)-b0) + } else { + b = append(b, byte(size1)) + } + + b = append(b, b1[1:]...) + b = append(b, b2[1:]...) + return b +} diff --git a/pkg/pinggy/pinggy.go b/pkg/pinggy/pinggy.go new file mode 100644 index 00000000..22ebfc91 --- /dev/null +++ b/pkg/pinggy/pinggy.go @@ -0,0 +1,137 @@ +package pinggy + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "time" + + "golang.org/x/crypto/ssh" +) + +type Client struct { + SSH *ssh.Client + TCP net.Listener + API *http.Client +} + +func NewClient(proto string) (*Client, error) { + switch proto { + case "http", "tcp", "tls", "tlstcp": + case "": + proto = "http" + default: + return nil, errors.New("pinggy: unsupported proto: " + proto) + } + + config := &ssh.ClientConfig{ + User: "auth+" + proto, + Auth: []ssh.AuthMethod{ssh.Password("nopass")}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + + client, err := ssh.Dial("tcp", "a.pinggy.io:443", config) + if err != nil { + return nil, err + } + + ln, err := client.Listen("tcp", "0.0.0.0:0") + if err != nil { + _ = client.Close() + return nil, err + } + + c := &Client{ + SSH: client, + TCP: ln, + API: &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return client.Dial(network, addr) + }, + }, + }, + } + + if proto == "http" { + if err = c.NewSession(); err != nil { + _ = client.Close() + return nil, err + } + } + + return c, nil +} + +func (c *Client) Close() error { + return errors.Join(c.SSH.Close(), c.TCP.Close()) +} + +func (c *Client) NewSession() error { + session, err := c.SSH.NewSession() + if err != nil { + return err + } + return session.Shell() +} + +func (c *Client) GetURLs() ([]string, error) { + res, err := c.API.Get("http://localhost:4300/urls") + if err != nil { + return nil, err + } + defer res.Body.Close() + + var v struct { + URLs []string `json:"urls"` + } + + if err = json.NewDecoder(res.Body).Decode(&v); err != nil { + return nil, err + } + + return v.URLs, nil +} + +func (c *Client) Proxy(address string) error { + defer c.TCP.Close() + + for { + conn, err := c.TCP.Accept() + if err != nil { + return err + } + go proxy(conn, address) + } +} + +func proxy(conn1 net.Conn, address string) { + defer conn1.Close() + + conn2, err := net.Dial("tcp", address) + if err != nil { + return + } + defer conn2.Close() + + go io.Copy(conn2, conn1) + io.Copy(conn1, conn2) +} + +// DialTLS like ssh.Dial but with TLS +//func DialTLS(network, addr, sni string, config *ssh.ClientConfig) (*ssh.Client, error) { +// conn, err := net.DialTimeout(network, addr, config.Timeout) +// if err != nil { +// return nil, err +// } +// conn = tls.Client(conn, &tls.Config{ServerName: sni, InsecureSkipVerify: sni == ""}) +// c, chans, reqs, err := ssh.NewClientConn(conn, addr, config) +// if err != nil { +// return nil, err +// } +// return ssh.NewClient(c, chans, reqs), nil +//} diff --git a/pkg/tapo/client.go b/pkg/tapo/client.go index c19267ff..e52250c3 100644 --- a/pkg/tapo/client.go +++ b/pkg/tapo/client.go @@ -20,6 +20,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mpegts" + "github.com/AlexxIT/go2rtc/pkg/pcm" "github.com/AlexxIT/go2rtc/pkg/tcp" ) @@ -140,6 +141,12 @@ func (c *Client) newDectypter(res *http.Response, brand, username, password stri 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)) @@ -158,8 +165,9 @@ func (c *Client) newDectypter(res *http.Response, brand, username, password stri cbc.CryptBlocks(b, b) // unpad - padSize := int(b[len(b)-1]) - return b[:len(b)-padSize] + n := len(b) + padSize := int(b[n-1]) + return b[:n-padSize] } } @@ -178,6 +186,8 @@ 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 { @@ -219,6 +229,23 @@ func (c *Client) Handle() error { 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) @@ -292,12 +319,12 @@ func dial(req *http.Request, brand, username, password string) (net.Conn, *http. return nil, nil, err } _, _ = io.Copy(io.Discard, res.Body) // discard leftovers - _ = res.Body.Close() // ignore response body + _ = 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) + return nil, nil, errors.New("tapo: wrond status: " + res.Status) } if brand == "tapo" && password == "" { diff --git a/pkg/tcp/request.go b/pkg/tcp/request.go index 13463cd7..74fd4132 100644 --- a/pkg/tcp/request.go +++ b/pkg/tcp/request.go @@ -8,7 +8,6 @@ import ( "net" "net/http" "strings" - "time" "github.com/AlexxIT/go2rtc/pkg/core" ) @@ -69,10 +68,7 @@ func Do(req *http.Request) (*http.Response, error) { return tlsConn, err } - client = &http.Client{ - Timeout: time.Second * 5000, - Transport: transport, - } + client = &http.Client{Transport: transport} } user := req.URL.User diff --git a/pkg/tutk/codec.go b/pkg/tutk/codec.go new file mode 100644 index 00000000..9ec7d8cb --- /dev/null +++ b/pkg/tutk/codec.go @@ -0,0 +1,59 @@ +package tutk + +// https://github.com/seydx/tutk_wyze#11-codec-reference +const ( + CodecMPEG4 byte = 0x4C + CodecH263 byte = 0x4D + CodecH264 byte = 0x4E + CodecMJPEG byte = 0x4F + CodecH265 byte = 0x50 +) + +const ( + CodecAACRaw byte = 0x86 + CodecAACADTS byte = 0x87 + CodecAACLATM byte = 0x88 + CodecPCMU byte = 0x89 + CodecPCMA byte = 0x8A + CodecADPCM byte = 0x8B + CodecPCML byte = 0x8C + CodecSPEEX byte = 0x8D + CodecMP3 byte = 0x8E + CodecG726 byte = 0x8F + CodecAACAlt byte = 0x90 + CodecOpus byte = 0x92 +) + +var sampleRates = [9]uint32{8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000} + +func GetSampleRateIndex(sampleRate uint32) uint8 { + for i, rate := range sampleRates { + if rate == sampleRate { + return uint8(i) + } + } + return 3 // default 16kHz +} + +func GetSamplesPerFrame(codecID byte) uint32 { + switch codecID { + case CodecAACRaw, CodecAACADTS, CodecAACLATM, CodecAACAlt: + return 1024 + case CodecPCMU, CodecPCMA, CodecPCML, CodecADPCM, CodecSPEEX, CodecG726: + return 160 + case CodecMP3: + return 1152 + case CodecOpus: + return 960 + default: + return 1024 + } +} + +func IsVideoCodec(id byte) bool { + return id >= CodecMPEG4 && id <= CodecH265 +} + +func IsAudioCodec(id byte) bool { + return id >= CodecAACRaw && id <= CodecOpus +} diff --git a/pkg/tutk/conn.go b/pkg/tutk/conn.go new file mode 100644 index 00000000..e0610690 --- /dev/null +++ b/pkg/tutk/conn.go @@ -0,0 +1,264 @@ +package tutk + +import ( + "fmt" + "io" + "net" + "sync" + "sync/atomic" + "time" +) + +func Dial(host, uid, username, password string) (*Conn, error) { + addr, err := net.ResolveUDPAddr("udp", host) + if err != nil { + // Default port for listening incoming LAN connections. + // Important. It's not using for real connection. + addr = &net.UDPAddr{IP: net.ParseIP(host), Port: 32761} + } + + udpConn, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, err + } + + c := &Conn{UDPConn: udpConn, addr: addr} + + sid := GenSessionID() + + _ = c.SetDeadline(time.Now().Add(5 * time.Second)) + + if addr.Port != 10001 { + err = c.connectDirect(uid, sid) + } else { + err = c.connectRemote(uid, sid) + } + if err != nil { + _ = c.Close() + return nil, err + } + + if c.ver[0] >= 25 { + c.session = NewSession25(c, sid) + } else { + c.session = NewSession16(c, sid) + } + + if err = c.clientStart(username, password); err != nil { + _ = c.Close() + return nil, err + } + + go c.worker() + + return c, nil +} + +type Conn struct { + *net.UDPConn + addr *net.UDPAddr + session Session + + ver []byte + err error + cmdMu sync.Mutex + cmdAck func() +} + +// Read overwrite net.Conn +func (c *Conn) Read(buf []byte) (n int, err error) { + for { + var addr *net.UDPAddr + if n, addr, err = c.UDPConn.ReadFromUDP(buf); err != nil { + return 0, err + } + + if string(c.addr.IP) != string(addr.IP) || n < 16 { + continue // skip messages from another IP + } + + if c.addr.Port != addr.Port { + c.addr.Port = addr.Port + } + + ReverseTransCodePartial(buf, buf[:n]) + //log.Printf("<- %x", buf[:n]) + return n, nil + } +} + +// Write overwrite net.Conn +func (c *Conn) Write(b []byte) (n int, err error) { + //log.Printf("-> %x", b) + return c.UDPConn.WriteToUDP(TransCodePartial(nil, b), c.addr) +} + +// RemoteAddr overwrite net.Conn +func (c *Conn) RemoteAddr() net.Addr { + return c.addr +} + +func (c *Conn) Protocol() string { + return "tutk+udp" +} + +func (c *Conn) Version() string { + if len(c.ver) == 1 { + return fmt.Sprintf("TUTK/%d", c.ver[0]) + } + return fmt.Sprintf("TUTK/%d SDK %d.%d.%d.%d", c.ver[0], c.ver[1], c.ver[2], c.ver[3], c.ver[4]) +} + +func (c *Conn) ReadCommand() (ctrlType uint32, ctrlData []byte, err error) { + return c.session.RecvIOCtrl() +} + +func (c *Conn) WriteCommand(ctrlType uint32, ctrlData []byte) error { + c.cmdMu.Lock() + defer c.cmdMu.Unlock() + + var repeat atomic.Int32 + repeat.Store(5) + + timeout := time.NewTicker(time.Second) + defer timeout.Stop() + + c.cmdAck = func() { + repeat.Store(0) + timeout.Reset(1) + } + + buf := c.session.SendIOCtrl(ctrlType, ctrlData) + + for { + if err := c.session.SessionWrite(0, buf); err != nil { + return err + } + <-timeout.C + r := repeat.Add(-1) + if r < 0 { + return nil + } + if r == 0 { + return fmt.Errorf("%s: can't send command %d", "tutk", ctrlType) + } + } +} + +func (c *Conn) ReadPacket() (hdr, payload []byte, err error) { + return c.session.RecvFrameData() +} + +func (c *Conn) WritePacket(hdr, payload []byte) error { + buf := c.session.SendFrameData(hdr, payload) + return c.session.SessionWrite(1, buf) +} + +func (c *Conn) Error() error { + if c.err != nil { + return c.err + } + return io.EOF +} + +func (c *Conn) worker() { + defer c.session.Close() + + buf := make([]byte, 1200) + + for { + n, err := c.Read(buf) + if err != nil { + c.err = fmt.Errorf("%s: %w", "tutk", err) + return + } + + switch c.handleMsg(buf[:n]) { + case msgUnknown: + fmt.Printf("tutk: unknown msg: %x\n", buf[:n]) + case msgError: + return + case msgCommandAck: + if c.cmdAck != nil { + c.cmdAck() + } + } + } +} + +const ( + msgUnknown = iota + msgError + msgPing + msgUnknownPing + msgClientStart + msgClientStart2 + msgClientStartAck2 + msgCommand + msgCommandAck + msgCounters + msgMediaChunk + msgMediaFrame + msgMediaReorder + msgMediaLost + msgCh5 + + msgUnknown0007 // time sync without data? + msgUnknown0008 // time sync with data? + msgUnknown0010 + msgUnknown0013 + msgUnknown0900 + msgUnknown0a08 + msgUnknownCh1c + msgDafang0012 +) + +func (c *Conn) handleMsg(msg []byte) int { + // off sample + // 0 0402 tutk magic + // 2 120a tutk version (120a, 190a...) + // 4 0800 msg size = len(b)-16 + // 6 0000 channel seq + // 8 28041200 msg type + // 14 0100 channel (not all msg) + // 28 0700 msg data (not all msg) + switch msg[8] { + case 0x08: + switch ch := msg[14]; ch { + case 0, 1: + return c.session.SessionRead(ch, msg[28:]) + case 5: + if len(msg) == 48 { + _, _ = c.Write(msgAckCh5(msg)) + return msgCh5 + } + case 0x1c: + return msgUnknownCh1c + } + case 0x18: + return msgUnknownPing + case 0x28: + if len(msg) == 24 { + _, _ = c.Write(msgAckPing(msg)) + return msgPing + } + } + return msgUnknown +} + +func msgAckPing(msg []byte) []byte { + // <- [24] 0402120a 08000000 28041200 000000005b0d4202070aa8c0 + // -> [24] 04021a0a 08000000 27042100 000000005b0d4202070aa8c0 + msg[8] = 0x27 + msg[10] = 0x21 + return msg +} + +func msgAckCh5(msg []byte) []byte { + // <- [48] 0402190a 20000400 07042100 7ecc05000c0000007ecc93c456c2561f 5a97c2f101050000000000000000000000010000 + // -> [48] 0402190a 20000400 08041200 7ecc05000c0000007ecc93c456c2561f 5a97c2f141050000000000000000000000010000 + msg[8] = 0x07 + msg[10] = 0x21 + msg[32] = 0x41 + return msg +} diff --git a/pkg/tutk/crypto.go b/pkg/tutk/crypto.go new file mode 100644 index 00000000..469bd2bc --- /dev/null +++ b/pkg/tutk/crypto.go @@ -0,0 +1,279 @@ +package tutk + +import ( + "encoding/binary" + "math/bits" +) + +// I'd like to say hello to Charlie. Your name is forever etched into the history of streaming software. +const charlie = "Charlie is the designer of P2P!!" + +func ReverseTransCodePartial(dst, src []byte) []byte { + n := len(src) + tmp := make([]byte, n) + if len(dst) < n { + dst = make([]byte, n) + } + + src16 := src + tmp16 := tmp + dst16 := dst + + for ; n >= 16; n -= 16 { + for i := 0; i != 16; i += 4 { + x := binary.LittleEndian.Uint32(src16[i:]) + binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, i+3)) + } + + swap(dst16, tmp16, 16) + + for i := 0; i != 16; i++ { + tmp16[i] = dst16[i] ^ charlie[i] + } + + for i := 0; i != 16; i += 4 { + x := binary.LittleEndian.Uint32(tmp16[i:]) + binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, i+1)) + } + + tmp16 = tmp16[16:] + dst16 = dst16[16:] + src16 = src16[16:] + } + + swap(tmp16, src16, n) + + for i := 0; i < n; i++ { + dst16[i] = tmp16[i] ^ charlie[i] + } + + return dst +} + +func ReverseTransCodeBlob(src []byte) []byte { + if len(src) < 16 { + return ReverseTransCodePartial(nil, src) + } + + dst := make([]byte, len(src)) + header := ReverseTransCodePartial(nil, src[:16]) + copy(dst, header) + + if len(src) > 16 { + if dst[3]&1 != 0 { // Partial encryption (check decrypted header) + remaining := len(src) - 16 + decryptLen := min(remaining, 48) + if decryptLen > 0 { + decrypted := ReverseTransCodePartial(nil, src[16:16+decryptLen]) + copy(dst[16:], decrypted) + } + if remaining > 48 { + copy(dst[64:], src[64:]) + } + } else { // Full decryption + decrypted := ReverseTransCodePartial(nil, src[16:]) + copy(dst[16:], decrypted) + } + } + return dst +} + +func TransCodePartial(dst, src []byte) []byte { + n := len(src) + tmp := make([]byte, n) + if len(dst) < n { + dst = make([]byte, n) + } + + src16 := src + tmp16 := tmp + dst16 := dst + + for ; n >= 16; n -= 16 { + for i := 0; i != 16; i += 4 { + x := binary.LittleEndian.Uint32(src16[i:]) + binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, -i-1)) + } + + for i := 0; i != 16; i++ { + dst16[i] = tmp16[i] ^ charlie[i] + } + + swap(tmp16, dst16, 16) + + for i := 0; i != 16; i += 4 { + x := binary.LittleEndian.Uint32(tmp16[i:]) + binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, -i-3)) + } + + tmp16 = tmp16[16:] + dst16 = dst16[16:] + src16 = src16[16:] + } + + for i := 0; i < n; i++ { + tmp16[i] = src16[i] ^ charlie[i] + } + + swap(dst16, tmp16, n) + + return dst +} + +func TransCodeBlob(src []byte) []byte { + if len(src) < 16 { + return TransCodePartial(nil, src) + } + + dst := make([]byte, len(src)) + header := TransCodePartial(nil, src[:16]) + copy(dst, header) + + if len(src) > 16 { + if src[3]&1 != 0 { // Partial encryption + remaining := len(src) - 16 + encryptLen := min(remaining, 48) + if encryptLen > 0 { + encrypted := TransCodePartial(nil, src[16:16+encryptLen]) + copy(dst[16:], encrypted) + } + if remaining > 48 { + copy(dst[64:], src[64:]) + } + } else { // Full encryption + encrypted := TransCodePartial(nil, src[16:]) + copy(dst[16:], encrypted) + } + } + return dst +} + +func swap(dst, src []byte, n int) { + switch n { + case 2: + _, _ = src[1], dst[1] + dst[0] = src[1] + dst[1] = src[0] + return + case 4: + _, _ = src[3], dst[3] + dst[0] = src[2] + dst[1] = src[3] + dst[2] = src[0] + dst[3] = src[1] + return + case 8: + _, _ = src[7], dst[7] + dst[0] = src[7] + dst[1] = src[4] + dst[2] = src[3] + dst[3] = src[2] + dst[4] = src[1] + dst[5] = src[6] + dst[6] = src[5] + dst[7] = src[0] + return + case 16: + _, _ = src[15], dst[15] + dst[0] = src[11] + dst[1] = src[9] + dst[2] = src[8] + dst[3] = src[15] + dst[4] = src[13] + dst[5] = src[10] + dst[6] = src[12] + dst[7] = src[14] + dst[8] = src[2] + dst[9] = src[1] + dst[10] = src[5] + dst[11] = src[0] + dst[12] = src[6] + dst[13] = src[4] + dst[14] = src[7] + dst[15] = src[3] + return + } + copy(dst, src[:n]) +} + +const delta = 0x9e3779b9 + +func XXTEADecrypt(dst, src, key []byte) { + const n = int8(4) // support only 16 bytes src + + var w, k [n]uint32 + for i := int8(0); i < n; i++ { + w[i] = binary.LittleEndian.Uint32(src) + k[i] = binary.LittleEndian.Uint32(key) + src = src[4:] + key = key[4:] + } + + rounds := 52/n + 6 + sum := uint32(rounds) * delta + for ; rounds > 0; rounds-- { + w0 := w[0] + i2 := int8((sum >> 2) & 3) + for i := n - 1; i >= 0; i-- { + wi := w[(i-1)&3] + ki := k[i^i2] + t1 := (w0 ^ sum) + (wi ^ ki) + t2 := (wi >> 5) ^ (w0 << 2) + t3 := (w0 >> 3) ^ (wi << 4) + w[i] -= t1 ^ (t2 + t3) + w0 = w[i] + } + sum -= delta + } + + for _, i := range w { + binary.LittleEndian.PutUint32(dst, i) + dst = dst[4:] + } +} + +func XXTEADecryptVar(data, key []byte) []byte { + if len(data) < 8 || len(key) < 16 { + return nil + } + + k := make([]uint32, 4) + for i := range 4 { + k[i] = binary.LittleEndian.Uint32(key[i*4:]) + } + + n := max(len(data)/4, 2) + v := make([]uint32, n) + for i := 0; i < len(data)/4; i++ { + v[i] = binary.LittleEndian.Uint32(data[i*4:]) + } + + rounds := 6 + 52/n + sum := uint32(rounds) * delta + y := v[0] + + for rounds > 0 { + e := (sum >> 2) & 3 + for p := n - 1; p > 0; p-- { + z := v[p-1] + v[p] -= xxteaMX(sum, y, z, p, e, k) + y = v[p] + } + z := v[n-1] + v[0] -= xxteaMX(sum, y, z, 0, e, k) + y = v[0] + sum -= delta + rounds-- + } + + result := make([]byte, n*4) + for i := range n { + binary.LittleEndian.PutUint32(result[i*4:], v[i]) + } + + return result[:len(data)] +} + +func xxteaMX(sum, y, z uint32, p int, e uint32, k []uint32) uint32 { + return ((z>>5 ^ y<<2) + (y>>3 ^ z<<4)) ^ ((sum ^ y) + (k[(p&3)^int(e)] ^ z)) +} diff --git a/pkg/tutk/crypto_test.go b/pkg/tutk/crypto_test.go new file mode 100644 index 00000000..1f1be3f2 --- /dev/null +++ b/pkg/tutk/crypto_test.go @@ -0,0 +1,14 @@ +package tutk + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestXXTEADecrypt(t *testing.T) { + buf := []byte("WERhJxb87WF3zgPa") + key := []byte("GAgDiwVPg2E4GMke") + XXTEADecrypt(buf, buf, key) + require.Equal(t, "\xc4\xa6\x2c\xa1\x10\x64\x17\xa5\xda\x02\xe1\x62\xa5\xf0\x62\x71", string(buf)) +} diff --git a/pkg/tutk/dtls/auth.go b/pkg/tutk/dtls/auth.go new file mode 100644 index 00000000..7354428d --- /dev/null +++ b/pkg/tutk/dtls/auth.go @@ -0,0 +1,35 @@ +package dtls + +import ( + "crypto/sha256" + "encoding/base64" + "strings" +) + +func CalculateAuthKey(enr, mac string) []byte { + data := enr + strings.ToUpper(mac) + hash := sha256.Sum256([]byte(data)) + b64 := base64.StdEncoding.EncodeToString(hash[:6]) + b64 = strings.ReplaceAll(b64, "+", "Z") + b64 = strings.ReplaceAll(b64, "/", "9") + b64 = strings.ReplaceAll(b64, "=", "A") + return []byte(b64) +} + +func DerivePSK(enr string) []byte { + // DerivePSK derives the DTLS PSK from ENR + // TUTK SDK treats the PSK as a NULL-terminated C string, so if SHA256(ENR) + // contains a 0x00 byte, the PSK is truncated at that position. + hash := sha256.Sum256([]byte(enr)) + pskLen := 32 + for i := range 32 { + if hash[i] == 0x00 { + pskLen = i + break + } + } + + psk := make([]byte, 32) + copy(psk[:pskLen], hash[:pskLen]) + return psk +} diff --git a/pkg/tutk/dtls/cipher.go b/pkg/tutk/dtls/cipher.go new file mode 100644 index 00000000..e987ff8e --- /dev/null +++ b/pkg/tutk/dtls/cipher.go @@ -0,0 +1,218 @@ +package dtls + +import ( + "crypto/cipher" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "hash" + "sync/atomic" + + "github.com/pion/dtls/v3" + "github.com/pion/dtls/v3/pkg/crypto/clientcertificate" + "github.com/pion/dtls/v3/pkg/crypto/prf" + "github.com/pion/dtls/v3/pkg/protocol" + "github.com/pion/dtls/v3/pkg/protocol/recordlayer" + "golang.org/x/crypto/chacha20poly1305" +) + +const CipherSuiteID_CCAC dtls.CipherSuiteID = 0xCCAC + +const ( + chachaTagLength = 16 + chachaNonceLength = 12 +) + +var ( + errDecryptPacket = &protocol.TemporaryError{Err: errors.New("failed to decrypt packet")} + errCipherSuiteNotInit = &protocol.TemporaryError{Err: errors.New("CipherSuite not initialized")} +) + +type ChaCha20Poly1305Cipher struct { + localCipher, remoteCipher cipher.AEAD + localWriteIV, remoteWriteIV []byte +} + +func NewChaCha20Poly1305Cipher(localKey, localWriteIV, remoteKey, remoteWriteIV []byte) (*ChaCha20Poly1305Cipher, error) { + localCipher, err := chacha20poly1305.New(localKey) + if err != nil { + return nil, err + } + + remoteCipher, err := chacha20poly1305.New(remoteKey) + if err != nil { + return nil, err + } + + return &ChaCha20Poly1305Cipher{ + localCipher: localCipher, + localWriteIV: localWriteIV, + remoteCipher: remoteCipher, + remoteWriteIV: remoteWriteIV, + }, nil +} + +func generateAEADAdditionalData(h *recordlayer.Header, payloadLen int) []byte { + var additionalData [13]byte + + binary.BigEndian.PutUint64(additionalData[:], h.SequenceNumber) + binary.BigEndian.PutUint16(additionalData[:], h.Epoch) + additionalData[8] = byte(h.ContentType) + additionalData[9] = h.Version.Major + additionalData[10] = h.Version.Minor + binary.BigEndian.PutUint16(additionalData[11:], uint16(payloadLen)) + + return additionalData[:] +} + +func computeNonce(iv []byte, epoch uint16, sequenceNumber uint64) []byte { + nonce := make([]byte, chachaNonceLength) + + binary.BigEndian.PutUint64(nonce[4:], sequenceNumber) + binary.BigEndian.PutUint16(nonce[4:], epoch) + + for i := range chachaNonceLength { + nonce[i] ^= iv[i] + } + + return nonce +} + +func (c *ChaCha20Poly1305Cipher) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error) { + payload := raw[pkt.Header.Size():] + raw = raw[:pkt.Header.Size()] + + nonce := computeNonce(c.localWriteIV, pkt.Header.Epoch, pkt.Header.SequenceNumber) + additionalData := generateAEADAdditionalData(&pkt.Header, len(payload)) + encryptedPayload := c.localCipher.Seal(nil, nonce, payload, additionalData) + + r := make([]byte, len(raw)+len(encryptedPayload)) + copy(r, raw) + copy(r[len(raw):], encryptedPayload) + + binary.BigEndian.PutUint16(r[pkt.Header.Size()-2:], uint16(len(r)-pkt.Header.Size())) + + return r, nil +} + +func (c *ChaCha20Poly1305Cipher) Decrypt(header recordlayer.Header, in []byte) ([]byte, error) { + err := header.Unmarshal(in) + switch { + case err != nil: + return nil, err + case header.ContentType == protocol.ContentTypeChangeCipherSpec: + return in, nil + case len(in) <= header.Size()+chachaTagLength: + return nil, fmt.Errorf("ciphertext too short: %d <= %d", len(in), header.Size()+chachaTagLength) + } + + nonce := computeNonce(c.remoteWriteIV, header.Epoch, header.SequenceNumber) + out := in[header.Size():] + additionalData := generateAEADAdditionalData(&header, len(out)-chachaTagLength) + + out, err = c.remoteCipher.Open(out[:0], nonce, out, additionalData) + if err != nil { + return nil, fmt.Errorf("%w: %v", errDecryptPacket, err) + } + + return append(in[:header.Size()], out...), nil +} + +type TLSEcdhePskWithChacha20Poly1305Sha256 struct { + aead atomic.Value +} + +func NewTLSEcdhePskWithChacha20Poly1305Sha256() *TLSEcdhePskWithChacha20Poly1305Sha256 { + return &TLSEcdhePskWithChacha20Poly1305Sha256{} +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) CertificateType() clientcertificate.Type { + return clientcertificate.Type(0) +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) KeyExchangeAlgorithm() dtls.CipherSuiteKeyExchangeAlgorithm { + return dtls.CipherSuiteKeyExchangeAlgorithmPsk | dtls.CipherSuiteKeyExchangeAlgorithmEcdhe +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) ECC() bool { + return true +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) ID() dtls.CipherSuiteID { + return CipherSuiteID_CCAC +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) String() string { + return "TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256" +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) HashFunc() func() hash.Hash { + return sha256.New +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) AuthenticationType() dtls.CipherSuiteAuthenticationType { + return dtls.CipherSuiteAuthenticationTypePreSharedKey +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) IsInitialized() bool { + return c.aead.Load() != nil +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) Init(masterSecret, clientRandom, serverRandom []byte, isClient bool) error { + const ( + prfMacLen = 0 + prfKeyLen = 32 + prfIvLen = 12 + ) + + keys, err := prf.GenerateEncryptionKeys( + masterSecret, clientRandom, serverRandom, + prfMacLen, prfKeyLen, prfIvLen, + c.HashFunc(), + ) + if err != nil { + return err + } + + var aead *ChaCha20Poly1305Cipher + if isClient { + aead, err = NewChaCha20Poly1305Cipher( + keys.ClientWriteKey, keys.ClientWriteIV, + keys.ServerWriteKey, keys.ServerWriteIV, + ) + } else { + aead, err = NewChaCha20Poly1305Cipher( + keys.ServerWriteKey, keys.ServerWriteIV, + keys.ClientWriteKey, keys.ClientWriteIV, + ) + } + if err != nil { + return err + } + + c.aead.Store(aead) + return nil +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error) { + aead, ok := c.aead.Load().(*ChaCha20Poly1305Cipher) + if !ok { + return nil, fmt.Errorf("%w: unable to encrypt", errCipherSuiteNotInit) + } + return aead.Encrypt(pkt, raw) +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) Decrypt(h recordlayer.Header, raw []byte) ([]byte, error) { + aead, ok := c.aead.Load().(*ChaCha20Poly1305Cipher) + if !ok { + return nil, fmt.Errorf("%w: unable to decrypt", errCipherSuiteNotInit) + } + return aead.Decrypt(h, raw) +} + +func CustomCipherSuites() []dtls.CipherSuite { + return []dtls.CipherSuite{ + NewTLSEcdhePskWithChacha20Poly1305Sha256(), + } +} diff --git a/pkg/tutk/dtls/conn_dtls.go b/pkg/tutk/dtls/conn_dtls.go new file mode 100644 index 00000000..c1d5f6ce --- /dev/null +++ b/pkg/tutk/dtls/conn_dtls.go @@ -0,0 +1,987 @@ +package dtls + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/binary" + "fmt" + "io" + "net" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/pion/dtls/v3" +) + +const ( + magicCC51 = "\x51\xcc" // (wyze specific?) + sdkVersion42 = "\x01\x01\x02\x04" // 4.2.1.1 + sdkVersion43 = "\x00\x08\x03\x04" // 4.3.8.0 +) + +const ( + cmdDiscoReq uint16 = 0x0601 + cmdDiscoRes uint16 = 0x0602 + cmdSessionReq uint16 = 0x0402 + cmdSessionRes uint16 = 0x0404 + cmdDataTX uint16 = 0x0407 + cmdDataRX uint16 = 0x0408 + cmdKeepaliveReq uint16 = 0x0427 + cmdKeepaliveRes uint16 = 0x0428 + + headerSize = 16 + discoBodySize = 72 + discoSize = headerSize + discoBodySize + sessionBody = 36 + sessionSize = headerSize + sessionBody +) + +const ( + cmdDiscoCC51 uint16 = 0x1002 + cmdKeepaliveCC51 uint16 = 0x1202 + cmdDTLSCC51 uint16 = 0x1502 + payloadSizeCC51 uint16 = 0x0028 + packetSizeCC51 = 52 + headerSizeCC51 = 28 + authSizeCC51 = 20 + keepaliveSizeCC51 = 48 +) + +const ( + magicAVLoginResp uint16 = 0x2100 + magicIOCtrl uint16 = 0x7000 + magicChannelMsg uint16 = 0x1000 + magicACK uint16 = 0x0009 + magicAVLogin1 uint16 = 0x0000 + magicAVLogin2 uint16 = 0x2000 +) + +const ( + protoVersion uint16 = 0x000c + defaultCaps uint32 = 0x001f07fb +) + +const ( + iotcChannelMain = 0 // Main AV (we = DTLS Client) + iotcChannelBack = 1 // Backchannel (we = DTLS Server) +) + +type DTLSConn struct { + conn *net.UDPConn + addr *net.UDPAddr + frames *tutk.FrameHandler + err error + verbose bool + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.RWMutex + + // DTLS + clientConn *dtls.Conn + serverConn *dtls.Conn + clientBuf chan []byte + serverBuf chan []byte + rawCmd chan []byte + + // Identity + uid string + authKey string + enr string + psk []byte + + // Session + sid []byte + ticket uint16 + hasTwoWayStreaming bool + + // Protocol + isCC51 bool + seq uint16 + seqCmd uint16 + avSeq uint32 + kaSeq uint32 + audioSeq uint32 + audioFrameNo uint32 + + // Ack + ackFlags uint16 + rxSeqStart uint16 + rxSeqEnd uint16 + rxSeqInit bool + cmdAck func() +} + +func DialDTLS(host string, port int, uid, authKey, enr string, verbose bool) (*DTLSConn, error) { + udp, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, err + } + + _ = udp.SetReadBuffer(2 * 1024 * 1024) + + ctx, cancel := context.WithCancel(context.Background()) + psk := DerivePSK(enr) + + if port == 0 { + port = 32761 + } + + c := &DTLSConn{ + conn: udp, + addr: &net.UDPAddr{IP: net.ParseIP(host), Port: port}, + uid: uid, + authKey: authKey, + enr: enr, + psk: psk, + verbose: verbose, + ctx: ctx, + cancel: cancel, + rxSeqStart: 0xffff, + rxSeqEnd: 0xffff, + } + + if err = c.discovery(); err != nil { + _ = c.Close() + return nil, err + } + + c.clientBuf = make(chan []byte, 64) + c.serverBuf = make(chan []byte, 64) + c.rawCmd = make(chan []byte, 16) + c.frames = tutk.NewFrameHandler(c.verbose) + + c.wg.Add(1) + go c.reader() + + if err = c.connect(); err != nil { + _ = c.Close() + return nil, err + } + + c.wg.Add(1) + go c.worker() + + return c, nil +} + +func (c *DTLSConn) AVClientStart(timeout time.Duration) error { + randomID := tutk.GenSessionID() + pkt1 := c.msgAVLogin(magicAVLogin1, 570, 0x0001, randomID) + pkt2 := c.msgAVLogin(magicAVLogin2, 572, 0x0000, randomID) + pkt2[20]++ // pkt2 has randomID incremented by 1 + + if _, err := c.clientConn.Write(pkt1); err != nil { + return fmt.Errorf("av login 1 failed: %w", err) + } + + time.Sleep(10 * time.Millisecond) + + if _, err := c.clientConn.Write(pkt2); err != nil { + return fmt.Errorf("av login 2 failed: %w", err) + } + + // Wait for response + timer := time.NewTimer(timeout) + defer timer.Stop() + for { + select { + case data, ok := <-c.rawCmd: + if !ok { + return io.EOF + } + if len(data) >= 32 && binary.LittleEndian.Uint16(data) == magicAVLoginResp { + c.hasTwoWayStreaming = data[31] == 1 + + ack := c.msgACK() + c.clientConn.Write(ack) + + // Start ACK sender for continuous streaming + c.wg.Add(1) + go func() { + defer c.wg.Done() + ackTicker := time.NewTicker(100 * time.Millisecond) + defer ackTicker.Stop() + + for { + select { + case <-c.ctx.Done(): + return + case <-ackTicker.C: + if c.clientConn != nil { + ack := c.msgACK() + c.clientConn.Write(ack) + } + } + } + }() + + return nil + } + case <-timer.C: + return context.DeadlineExceeded + } + } +} + +func (c *DTLSConn) AVServStart() error { + conn, err := NewDTLSServer(c.ctx, iotcChannelBack, c.addr, c.WriteDTLS, c.serverBuf, c.psk) + if err != nil { + return fmt.Errorf("dtls: server handshake failed: %w", err) + } + + if c.verbose { + fmt.Printf("[DTLS] Server handshake complete on channel %d\n", iotcChannelBack) + fmt.Printf("[SERVER] Waiting for AV Login request from camera...\n") + } + + // Wait for AV Login request from camera + buf := make([]byte, 1024) + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + n, err := conn.Read(buf) + if err != nil { + go conn.Close() + return fmt.Errorf("read av login: %w", err) + } + + if c.verbose { + fmt.Printf("[SERVER] AV Login request len=%d data:\n%s", n, hexDump(buf[:n])) + } + + if n < 24 { + go conn.Close() + return fmt.Errorf("av login too short: %d bytes", n) + } + + checksum := binary.LittleEndian.Uint32(buf[20:]) + resp := c.msgAVLoginResponse(checksum) + + if c.verbose { + fmt.Printf("[SERVER] Sending AV Login response: %d bytes\n", len(resp)) + } + + if _, err = conn.Write(resp); err != nil { + go conn.Close() + return fmt.Errorf("write av login response: %w", err) + } + + if c.verbose { + fmt.Printf("[SERVER] AV Login response sent, waiting for possible resend...\n") + } + + // Camera may resend, respond again + conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + if n, _ = conn.Read(buf); n > 0 { + if c.verbose { + fmt.Printf("[SERVER] Received AV Login resend: %d bytes\n", n) + } + conn.Write(resp) + } + + conn.SetReadDeadline(time.Time{}) + + if c.verbose { + fmt.Printf("[SERVER] AV Login complete, ready for two way streaming\n") + } + + c.mu.Lock() + c.serverConn = conn + c.mu.Unlock() + + return nil +} + +func (c *DTLSConn) AVServStop() error { + c.mu.Lock() + serverConn := c.serverConn + c.serverConn = nil + + // Reset audio TX state + c.audioSeq = 0 + c.audioFrameNo = 0 + c.mu.Unlock() + + if serverConn == nil { + return nil + } + + go serverConn.Close() + + return nil +} + +func (c *DTLSConn) AVRecvFrameData() (*tutk.Packet, error) { + select { + case pkt, ok := <-c.frames.Recv(): + if !ok { + return nil, c.Error() + } + return pkt, nil + case <-c.ctx.Done(): + return nil, c.Error() + } +} + +func (c *DTLSConn) AVSendAudioData(codec byte, payload []byte, timestampUS uint32, sampleRate uint32, channels uint8) error { + c.mu.Lock() + conn := c.serverConn + if conn == nil { + c.mu.Unlock() + return fmt.Errorf("av server not ready") + } + + frame := c.msgAudioFrame(payload, timestampUS, codec, sampleRate, channels) + + c.mu.Unlock() + + n, err := conn.Write(frame) + if c.verbose { + if err != nil { + fmt.Printf("[SERVER TX] DTLS Write ERROR: %v\n", err) + } else { + fmt.Printf("[SERVER TX] len=%d, data:\n%s", n, hexDump(frame)) + } + } + return err +} + +func (c *DTLSConn) Write(data []byte) error { + if c.isCC51 { + _, err := c.conn.WriteToUDP(data, c.addr) + return err + } + _, err := c.conn.WriteToUDP(tutk.TransCodeBlob(data), c.addr) + return err +} + +func (c *DTLSConn) WriteDTLS(payload []byte, channel byte) error { + var frame []byte + if c.isCC51 { + frame = c.msgTxDataCC51(payload, channel) + } else { + frame = c.msgTxData(payload, channel) + } + + return c.Write(frame) +} + +func (c *DTLSConn) WriteIOCtrl(payload []byte) error { + _, err := c.conn.Write(c.msgIOCtrl(payload)) + return err +} + +func (c *DTLSConn) WriteAndWait(req []byte, ok func(res []byte) bool) ([]byte, error) { + var t *time.Timer + t = time.AfterFunc(1, func() { + if err := c.Write(req); err == nil && t != nil { + t.Reset(time.Second) + } + }) + defer t.Stop() + + _ = c.conn.SetDeadline(time.Now().Add(5 * time.Second)) + defer c.conn.SetDeadline(time.Time{}) + + buf := make([]byte, 2048) + for { + n, addr, err := c.conn.ReadFromUDP(buf) + if err != nil { + return nil, err + } + if string(addr.IP) != string(c.addr.IP) || n < 16 { + continue + } + + var res []byte + if c.isCC51 { + res = buf[:n] + } else { + res = tutk.ReverseTransCodeBlob(buf[:n]) + } + + if ok(res) { + c.addr.Port = addr.Port + return res, nil + } + } +} + +func (c *DTLSConn) WriteAndWaitIOCtrl(payload []byte, match func([]byte) bool, timeout time.Duration) ([]byte, error) { + frame := c.msgIOCtrl(payload) + var t *time.Timer + t = time.AfterFunc(1, func() { + c.mu.RLock() + conn := c.clientConn + c.mu.RUnlock() + if conn != nil { + if _, err := conn.Write(frame); err == nil && t != nil { + t.Reset(time.Second) + } + } + }) + defer t.Stop() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case data, ok := <-c.rawCmd: + if !ok { + return nil, io.EOF + } + + ack := c.msgACK() + c.clientConn.Write(ack) + + if match(data) { + return data, nil + } + case <-timer.C: + return nil, fmt.Errorf("timeout waiting for response") + } + } +} + +func (c *DTLSConn) HasTwoWayStreaming() bool { + return c.hasTwoWayStreaming +} + +func (c *DTLSConn) IsBackchannelReady() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.serverConn != nil +} + +func (c *DTLSConn) RemoteAddr() *net.UDPAddr { + return c.addr +} + +func (c *DTLSConn) LocalAddr() *net.UDPAddr { + return c.conn.LocalAddr().(*net.UDPAddr) +} + +func (c *DTLSConn) SetDeadline(t time.Time) error { + return c.conn.SetDeadline(t) +} + +func (c *DTLSConn) Close() error { + c.cancel() + + c.mu.Lock() + if conn := c.serverConn; conn != nil { + c.serverConn = nil + go conn.Close() + } + if conn := c.clientConn; conn != nil { + c.clientConn = nil + go conn.Close() + } + if c.frames != nil { + c.frames.Close() + } + c.mu.Unlock() + + c.wg.Wait() + + return c.conn.Close() +} + +func (c *DTLSConn) Error() error { + if c.err != nil { + return c.err + } + return io.EOF +} + +func (c *DTLSConn) discovery() error { + c.sid = tutk.GenSessionID() + + pktIOTC := tutk.TransCodeBlob(c.msgDisco(1)) + pktCC51 := c.msgDiscoCC51(0, 0, false) + + buf := make([]byte, 2048) + deadline := time.Now().Add(5 * time.Second) + + for time.Now().Before(deadline) { + c.conn.WriteToUDP(pktIOTC, c.addr) + c.conn.WriteToUDP(pktCC51, c.addr) + + c.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, addr, err := c.conn.ReadFromUDP(buf) + if err != nil { + continue + } + if !addr.IP.Equal(c.addr.IP) { + continue + } + + // CC51 protocol + if n >= packetSizeCC51 && string(buf[:2]) == magicCC51 { + if binary.LittleEndian.Uint16(buf[4:]) == cmdDiscoCC51 { + c.addr, c.isCC51, c.ticket = addr, true, binary.LittleEndian.Uint16(buf[14:]) + if n >= 24 { + copy(c.sid, buf[16:24]) + } + return c.discoDoneCC51() + } + continue + } + + // IOTC Protocol (Basis) + data := tutk.ReverseTransCodeBlob(buf[:n]) + if len(data) >= 16 && binary.LittleEndian.Uint16(data[8:]) == cmdDiscoRes { + c.addr, c.isCC51 = addr, false + return c.discoDone() + } + } + + return fmt.Errorf("discovery timeout") +} + +func (c *DTLSConn) discoDone() error { + c.Write(c.msgDisco(2)) + time.Sleep(100 * time.Millisecond) + _, err := c.WriteAndWait(c.msgSession(), func(res []byte) bool { + return len(res) >= 16 && binary.LittleEndian.Uint16(res[8:]) == cmdSessionRes + }) + return err +} + +func (c *DTLSConn) discoDoneCC51() error { + _, err := c.WriteAndWait(c.msgDiscoCC51(2, c.ticket, false), func(res []byte) bool { + if len(res) < packetSizeCC51 || string(res[:2]) != magicCC51 { + return false + } + cmd := binary.LittleEndian.Uint16(res[4:]) + dir := binary.LittleEndian.Uint16(res[8:]) + seq := binary.LittleEndian.Uint16(res[12:]) + return cmd == cmdDiscoCC51 && dir == 0xFFFF && seq == 3 + }) + return err +} + +func (c *DTLSConn) connect() error { + conn, err := NewDTLSClient(c.ctx, iotcChannelMain, c.addr, c.WriteDTLS, c.clientBuf, c.psk) + if err != nil { + return fmt.Errorf("dtls: client handshake failed: %w", err) + } + + c.mu.Lock() + c.clientConn = conn + c.mu.Unlock() + + if c.verbose { + fmt.Printf("[DTLS] Client handshake complete on channel %d\n", iotcChannelMain) + } + + return nil +} + +func (c *DTLSConn) worker() { + defer c.wg.Done() + + buf := make([]byte, 2048) + + for { + select { + case <-c.ctx.Done(): + return + default: + } + + n, err := c.clientConn.Read(buf) + if err != nil { + c.err = err + return + } + + if n < 2 { + continue + } + + data := buf[:n] + magic := binary.LittleEndian.Uint16(data) + + if c.verbose { + fmt.Printf("[DTLS RX] magic=0x%04x len=%d\n", magic, n) + } + + switch magic { + case magicAVLoginResp: + c.queue(c.rawCmd, data) + + case magicIOCtrl, magicChannelMsg: + c.queue(c.rawCmd, data) + + case protoVersion: + // Seq-Tracking + if len(data) >= 8 { + seq := binary.LittleEndian.Uint16(data[4:]) + if !c.rxSeqInit { + c.rxSeqInit = true + } + if seq > c.rxSeqEnd || c.rxSeqEnd == 0xffff { + c.rxSeqEnd = seq + } + } + c.queue(c.rawCmd, data) + + case magicACK: + c.mu.RLock() + ack := c.cmdAck + c.mu.RUnlock() + if ack != nil { + ack() + } + + default: + channel := data[0] + if channel == tutk.ChannelAudio || channel == tutk.ChannelIVideo || channel == tutk.ChannelPVideo { + c.frames.Handle(data) + } + } + } +} + +func (c *DTLSConn) reader() { + defer c.wg.Done() + + buf := make([]byte, 2048) + + for { + select { + case <-c.ctx.Done(): + return + default: + } + + c.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, addr, err := c.conn.ReadFromUDP(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + return + } + + if !addr.IP.Equal(c.addr.IP) { + if c.verbose { + fmt.Printf("Ignored packet from unknown IP: %s\n", addr.IP.String()) + } + continue + } + if addr.Port != c.addr.Port { + c.addr.Port = addr.Port + } + + // CC51 Protocol + if c.isCC51 && n >= 12 && string(buf[:2]) == magicCC51 { + cmd := binary.LittleEndian.Uint16(buf[4:]) + switch cmd { + case cmdKeepaliveCC51: + if n >= keepaliveSizeCC51 { + _ = c.Write(c.msgKeepaliveCC51()) + } + case cmdDTLSCC51: + if n >= headerSizeCC51+authSizeCC51 { + ch := byte(binary.LittleEndian.Uint16(buf[12:]) >> 8) + dtlsData := buf[headerSizeCC51 : n-authSizeCC51] + switch ch { + case iotcChannelMain: + c.queue(c.clientBuf, dtlsData) + case iotcChannelBack: + c.queue(c.serverBuf, dtlsData) + } + } + } + continue + } + + // IOTC Protocol (Basis) + data := tutk.ReverseTransCodeBlob(buf[:n]) + if len(data) < 16 { + continue + } + + switch binary.LittleEndian.Uint16(data[8:]) { + case cmdKeepaliveRes: + if len(data) > 24 { + _ = c.Write(c.msgKeepalive(data[16:])) + } + case cmdDataRX: + if len(data) > 28 { + ch := data[14] + switch ch { + case iotcChannelMain: + c.queue(c.clientBuf, data[28:]) + case iotcChannelBack: + c.queue(c.serverBuf, data[28:]) + } + } + } + } +} + +func (c *DTLSConn) queue(ch chan []byte, data []byte) { + b := make([]byte, len(data)) + copy(b, data) + select { + case ch <- b: + default: + select { + case <-ch: + default: + } + ch <- b + } +} + +func (c *DTLSConn) msgDisco(stage byte) []byte { + b := make([]byte, discoSize) + copy(b, "\x04\x02\x1a\x02") // marker + mode + binary.LittleEndian.PutUint16(b[4:], discoBodySize) // body size + binary.LittleEndian.PutUint16(b[8:], cmdDiscoReq) // 0x0601 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + body := b[headerSize:] + copy(body[:20], c.uid) + copy(body[36:], sdkVersion42) // SDK 4.2.1.1 + copy(body[40:], c.sid) + body[48] = stage + if stage == 1 && len(c.authKey) > 0 { + copy(body[58:], c.authKey) + } + return b +} + +func (c *DTLSConn) msgDiscoCC51(seq, ticket uint16, isResponse bool) []byte { + b := make([]byte, packetSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdDiscoCC51) // 0x1002 + binary.LittleEndian.PutUint16(b[6:], payloadSizeCC51) // 40 bytes + if isResponse { + binary.LittleEndian.PutUint16(b[8:], 0xFFFF) // response + } + binary.LittleEndian.PutUint16(b[12:], seq) + binary.LittleEndian.PutUint16(b[14:], ticket) + copy(b[16:24], c.sid) + copy(b[24:28], sdkVersion43) // SDK 4.3.8.0 + b[28] = 0x1d // unknown field (capability/build flag?) + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) + h.Write(b[:32]) + copy(b[32:52], h.Sum(nil)) + return b +} + +func (c *DTLSConn) msgKeepaliveCC51() []byte { + c.kaSeq += 2 + b := make([]byte, keepaliveSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdKeepaliveCC51) // 0x1202 + binary.LittleEndian.PutUint16(b[6:], 0x0024) // 36 bytes payload + binary.LittleEndian.PutUint32(b[16:], c.kaSeq) // counter + copy(b[20:28], c.sid) // session ID + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) + h.Write(b[:28]) + copy(b[28:48], h.Sum(nil)) + return b +} + +func (c *DTLSConn) msgSession() []byte { + b := make([]byte, sessionSize) + copy(b, "\x04\x02\x1a\x02") // marker + mode + binary.LittleEndian.PutUint16(b[4:], sessionBody) // body size + binary.LittleEndian.PutUint16(b[8:], cmdSessionReq) // 0x0402 + binary.LittleEndian.PutUint16(b[10:], 0x0033) // flags + body := b[headerSize:] + copy(body[:20], c.uid) + copy(body[20:], c.sid) + binary.LittleEndian.PutUint32(body[32:], uint32(time.Now().Unix())) + return b +} + +func (c *DTLSConn) msgAVLogin(magic uint16, size int, flags uint16, randomID []byte) []byte { + b := make([]byte, size) + binary.LittleEndian.PutUint16(b, magic) + binary.LittleEndian.PutUint16(b[2:], protoVersion) + binary.LittleEndian.PutUint16(b[16:], uint16(size-24)) // payload size + binary.LittleEndian.PutUint16(b[18:], flags) + copy(b[20:], randomID[:4]) + copy(b[24:], "admin") // username + copy(b[280:], c.enr) // password/ENR + binary.LittleEndian.PutUint32(b[540:], 4) // security_mode ? + binary.LittleEndian.PutUint32(b[552:], defaultCaps) // capabilities + return b +} + +func (c *DTLSConn) msgAVLoginResponse(checksum uint32) []byte { + b := make([]byte, 60) + binary.LittleEndian.PutUint16(b, 0x2100) // magic + binary.LittleEndian.PutUint16(b[2:], 0x000c) // version + b[4] = 0x10 // success + binary.LittleEndian.PutUint32(b[16:], 0x24) // payload size + binary.LittleEndian.PutUint32(b[20:], checksum) // echo checksum + b[29] = 0x01 // enable flag + b[31] = 0x01 // two-way streaming + binary.LittleEndian.PutUint32(b[36:], 0x04) // buffer config + binary.LittleEndian.PutUint32(b[40:], defaultCaps) + binary.LittleEndian.PutUint16(b[54:], 0x0003) // channel info + binary.LittleEndian.PutUint16(b[56:], 0x0002) + return b +} + +func (c *DTLSConn) msgAudioFrame(payload []byte, timestampUS uint32, codec byte, sampleRate uint32, channels uint8) []byte { + c.audioSeq++ + c.audioFrameNo++ + prevFrame := uint32(0) + if c.audioFrameNo > 1 { + prevFrame = c.audioFrameNo - 1 + } + + totalPayload := len(payload) + 16 // payload + frameinfo + b := make([]byte, 36+totalPayload) + + // Outer header (36 bytes) + b[0] = tutk.ChannelAudio // 0x03 + b[1] = tutk.FrameTypeStartAlt // 0x09 + binary.LittleEndian.PutUint16(b[2:], protoVersion) + binary.LittleEndian.PutUint32(b[4:], c.audioSeq) + binary.LittleEndian.PutUint32(b[8:], timestampUS) + if c.audioFrameNo == 1 { + binary.LittleEndian.PutUint32(b[12:], 0x00000001) + } else { + binary.LittleEndian.PutUint32(b[12:], 0x00100001) + } + + // Inner header + b[16] = tutk.ChannelAudio + b[17] = tutk.FrameTypeEndSingle + binary.LittleEndian.PutUint16(b[18:], uint16(prevFrame)) + binary.LittleEndian.PutUint16(b[20:], 0x0001) // pkt_total + binary.LittleEndian.PutUint16(b[22:], 0x0010) // flags + binary.LittleEndian.PutUint32(b[24:], uint32(totalPayload)) + binary.LittleEndian.PutUint32(b[28:], prevFrame) + binary.LittleEndian.PutUint32(b[32:], c.audioFrameNo) + copy(b[36:], payload) // Payload + FrameInfo + fi := b[36+len(payload):] + fi[0] = codec // Codec ID (low byte) + fi[1] = 0 // Codec ID (high byte, unused) + // Audio flags: [3:2]=sampleRateIdx [1]=16bit [0]=stereo + srIdx := tutk.GetSampleRateIndex(sampleRate) + fi[2] = (srIdx << 2) | 0x02 // 16-bit always set + if channels == 2 { + fi[2] |= 0x01 + } + fi[4] = 1 // online + binary.LittleEndian.PutUint32(fi[12:], (c.audioFrameNo-1)*tutk.GetSamplesPerFrame(codec)*1000/sampleRate) + return b +} + +func (c *DTLSConn) msgTxData(payload []byte, channel byte) []byte { + bodySize := 12 + len(payload) + b := make([]byte, 16+bodySize) + copy(b, "\x04\x02\x1a\x0b") // marker + mode=data + binary.LittleEndian.PutUint16(b[4:], uint16(bodySize)) // body size + binary.LittleEndian.PutUint16(b[6:], c.seq) // sequence + c.seq++ + binary.LittleEndian.PutUint16(b[8:], cmdDataTX) // 0x0407 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + copy(b[12:], c.sid[:2]) // rid[0:2] + b[14] = channel // channel + b[15] = 0x01 // marker + binary.LittleEndian.PutUint32(b[16:], 0x0000000c) // const + copy(b[20:], c.sid[:8]) // rid + copy(b[28:], payload) + return b +} + +func (c *DTLSConn) msgTxDataCC51(payload []byte, channel byte) []byte { + payloadSize := uint16(16 + len(payload) + authSizeCC51) + b := make([]byte, headerSizeCC51+len(payload)+authSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdDTLSCC51) // 0x1502 + binary.LittleEndian.PutUint16(b[6:], payloadSize) + binary.LittleEndian.PutUint16(b[12:], uint16(0x0010)|(uint16(channel)<<8)) // channel in high byte + binary.LittleEndian.PutUint16(b[14:], c.ticket) + copy(b[16:24], c.sid) + binary.LittleEndian.PutUint32(b[24:], 1) // const + copy(b[headerSizeCC51:], payload) + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) + h.Write(b[:headerSizeCC51]) + copy(b[headerSizeCC51+len(payload):], h.Sum(nil)) + return b +} + +func (c *DTLSConn) msgACK() []byte { + c.ackFlags++ + b := make([]byte, 24) + binary.LittleEndian.PutUint16(b[0:], magicACK) // 0x0009 + binary.LittleEndian.PutUint16(b[2:], protoVersion) // 0x000c + binary.LittleEndian.PutUint32(b[4:], c.avSeq) // TX seq + c.avSeq++ + binary.LittleEndian.PutUint16(b[8:], c.rxSeqStart) // RX start (last acked) + binary.LittleEndian.PutUint16(b[10:], c.rxSeqEnd) // RX end (highest received) + if c.rxSeqInit { + c.rxSeqStart = c.rxSeqEnd + } + binary.LittleEndian.PutUint16(b[12:], c.ackFlags) // AckFlags + binary.LittleEndian.PutUint32(b[16:], uint32(c.ackFlags)<<16) // AckCounter + ts := uint32(time.Now().UnixMilli() & 0xFFFF) + binary.LittleEndian.PutUint16(b[20:], uint16(ts)) // Timestamp + return b +} + +func (c *DTLSConn) msgKeepalive(incoming []byte) []byte { + b := make([]byte, 24) + copy(b, "\x04\x02\x1a\x0a") // marker + mode + binary.LittleEndian.PutUint16(b[4:], 8) // body size + binary.LittleEndian.PutUint16(b[8:], cmdKeepaliveReq) // 0x0427 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + if len(incoming) >= 8 { + copy(b[16:], incoming[:8]) // echo payload + } + return b +} + +func (c *DTLSConn) msgIOCtrl(payload []byte) []byte { + b := make([]byte, 40+len(payload)) + binary.LittleEndian.PutUint16(b, protoVersion) // magic + binary.LittleEndian.PutUint16(b[2:], protoVersion) // version + binary.LittleEndian.PutUint32(b[4:], c.avSeq) // av seq + c.avSeq++ + binary.LittleEndian.PutUint16(b[16:], magicIOCtrl) // 0x7000 + binary.LittleEndian.PutUint16(b[18:], c.seqCmd) // sub channel + binary.LittleEndian.PutUint32(b[20:], 1) // ioctl seq + binary.LittleEndian.PutUint32(b[24:], uint32(len(payload)+4)) // payload size + binary.LittleEndian.PutUint32(b[28:], uint32(c.seqCmd)) // flag + b[37] = 0x01 + copy(b[40:], payload) + c.seqCmd++ + return b +} + +func hexDump(data []byte) string { + const maxBytes = 650 + totalLen := len(data) + truncated := totalLen > maxBytes + if truncated { + data = data[:maxBytes] + } + + var result string + for i := 0; i < len(data); i += 16 { + end := min(i+16, len(data)) + line := fmt.Sprintf(" %04x:", i) + for j := i; j < end; j++ { + line += fmt.Sprintf(" %02x", data[j]) + } + result += line + "\n" + } + + if truncated { + result += fmt.Sprintf(" ... (truncated, showing %d of %d bytes)\n", maxBytes, totalLen) + } + return result +} diff --git a/pkg/tutk/dtls/dtls.go b/pkg/tutk/dtls/dtls.go new file mode 100644 index 00000000..3b0573ae --- /dev/null +++ b/pkg/tutk/dtls/dtls.go @@ -0,0 +1,146 @@ +package dtls + +import ( + "context" + "net" + "sync" + "time" + + "github.com/pion/dtls/v3" +) + +func NewDTLSClient(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte) (*dtls.Conn, error) { + return dialDTLS(ctx, channel, addr, writeFn, readChan, psk, false) +} + +func NewDTLSServer(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte) (*dtls.Conn, error) { + return dialDTLS(ctx, channel, addr, writeFn, readChan, psk, true) +} + +func dialDTLS(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte, isServer bool) (*dtls.Conn, error) { + adapter := &channelAdapter{ + ctx: ctx, + channel: channel, + addr: addr, + writeFn: writeFn, + readChan: readChan, + } + + var conn *dtls.Conn + var err error + + if isServer { + conn, err = dtls.Server(adapter, addr, buildDTLSConfig(psk, true)) + } else { + conn, err = dtls.Client(adapter, addr, buildDTLSConfig(psk, false)) + } + if err != nil { + return nil, err + } + + timeout := 5 * time.Second + adapter.SetReadDeadline(time.Now().Add(timeout)) + hsCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + if err := conn.HandshakeContext(hsCtx); err != nil { + go conn.Close() + return nil, err + } + + adapter.SetReadDeadline(time.Time{}) + return conn, nil +} + +func buildDTLSConfig(psk []byte, isServer bool) *dtls.Config { + config := &dtls.Config{ + PSK: func(hint []byte) ([]byte, error) { + return psk, nil + }, + PSKIdentityHint: []byte("AUTHPWD_admin"), + InsecureSkipVerify: true, + InsecureSkipVerifyHello: true, + MTU: 1200, + FlightInterval: 300 * time.Millisecond, + ExtendedMasterSecret: dtls.DisableExtendedMasterSecret, + } + + if isServer { + config.CipherSuites = []dtls.CipherSuiteID{dtls.TLS_PSK_WITH_AES_128_CBC_SHA256} + } else { + config.CustomCipherSuites = CustomCipherSuites + } + + return config +} + +type channelAdapter struct { + ctx context.Context + channel uint8 + writeFn func([]byte, uint8) error + readChan chan []byte + addr net.Addr + mu sync.Mutex + readDeadline time.Time +} + +func (a *channelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + a.mu.Lock() + deadline := a.readDeadline + a.mu.Unlock() + + if !deadline.IsZero() { + timeout := time.Until(deadline) + if timeout <= 0 { + return 0, nil, &timeoutError{} + } + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case data := <-a.readChan: + return copy(p, data), a.addr, nil + case <-timer.C: + return 0, nil, &timeoutError{} + case <-a.ctx.Done(): + return 0, nil, net.ErrClosed + } + } + + select { + case data := <-a.readChan: + return copy(p, data), a.addr, nil + case <-a.ctx.Done(): + return 0, nil, net.ErrClosed + } +} + +func (a *channelAdapter) WriteTo(p []byte, _ net.Addr) (int, error) { + if err := a.writeFn(p, a.channel); err != nil { + return 0, err + } + return len(p), nil +} + +func (a *channelAdapter) Close() error { return nil } +func (a *channelAdapter) LocalAddr() net.Addr { return &net.UDPAddr{} } +func (a *channelAdapter) SetDeadline(t time.Time) error { + a.mu.Lock() + a.readDeadline = t + a.mu.Unlock() + return nil +} +func (a *channelAdapter) SetReadDeadline(t time.Time) error { + a.mu.Lock() + a.readDeadline = t + a.mu.Unlock() + return nil +} +func (a *channelAdapter) SetWriteDeadline(time.Time) error { return nil } + +type timeoutError struct{} + +func (e *timeoutError) Error() string { return "i/o timeout" } +func (e *timeoutError) Timeout() bool { return true } +func (e *timeoutError) Temporary() bool { return true } diff --git a/pkg/tutk/frame.go b/pkg/tutk/frame.go new file mode 100644 index 00000000..db5bf074 --- /dev/null +++ b/pkg/tutk/frame.go @@ -0,0 +1,571 @@ +package tutk + +import ( + "encoding/binary" + "encoding/hex" + "fmt" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/aac" +) + +const ( + FrameTypeStart uint8 = 0x08 // Extended start (36-byte header) + FrameTypeStartAlt uint8 = 0x09 // StartAlt (36-byte header) + FrameTypeCont uint8 = 0x00 // Continuation (28-byte header) + FrameTypeContAlt uint8 = 0x04 // Continuation alt + FrameTypeEndSingle uint8 = 0x01 // Single-packet frame (28-byte) + FrameTypeEndMulti uint8 = 0x05 // Multi-packet end (28-byte) + FrameTypeEndExt uint8 = 0x0d // Extended end (36-byte) +) + +const ( + ChannelIVideo uint8 = 0x05 + ChannelAudio uint8 = 0x03 + ChannelPVideo uint8 = 0x07 +) + +const frameInfoSize = 40 + +// FrameInfo - Wyze extended FRAMEINFO (40 bytes at end of packet) +// Video: 40 bytes, Audio: 16 bytes (uses same struct, fields 16+ are zero) +// +// Offset Size Field +// 0-1 2 CodecID - 0x4E=H264, 0x7B=H265, 0x90=AAC_WYZE +// 2 1 Flags - Video: 1=Keyframe, 0=P-frame | Audio: sample rate/bits/channels +// 3 1 CamIndex - Camera index +// 4 1 OnlineNum - Online number +// 5 1 FPS - Framerate (e.g. 20) +// 6 1 ResTier - Video: 1=Low(360P), 4=High(HD/2K) | Audio: 0 +// 7 1 Bitrate - Video: 30=360P, 100=HD, 200=2K | Audio: 1 +// 8-11 4 Timestamp - Timestamp (increases ~50000/frame for 20fps video) +// 12-15 4 SessionID - Session marker (constant per stream) +// 16-19 4 PayloadSize - Frame payload size in bytes +// 20-23 4 FrameNo - Global frame number +// 24-35 12 DeviceID - MAC address (ASCII) - video only +// 36-39 4 Padding - Always 0 - video only +type FrameInfo struct { + CodecID byte // 0 (only low byte used) + Flags uint8 // 2 + CamIndex uint8 // 3 + OnlineNum uint8 // 4 + FPS uint8 // 5: Framerate + ResTier uint8 // 6: Resolution tier (1=Low, 4=High) + Bitrate uint8 // 7: Bitrate index (30=360P, 100=HD, 200=2K) + Timestamp uint32 // 8-11: Timestamp + SessionID uint32 // 12-15: Session marker (constant) + PayloadSize uint32 // 16-19: Payload size + FrameNo uint32 // 20-23: Frame number +} + +func (fi *FrameInfo) IsKeyframe() bool { + return fi.Flags == 0x01 +} + +func (fi *FrameInfo) SampleRate() uint32 { + idx := (fi.Flags >> 2) & 0x0F + if idx < uint8(len(sampleRates)) { + return sampleRates[idx] + } + return 16000 +} + +func (fi *FrameInfo) Channels() uint8 { + if fi.Flags&0x01 == 1 { + return 2 + } + return 1 +} + +func ParseFrameInfo(data []byte) *FrameInfo { + if len(data) < frameInfoSize { + return nil + } + + offset := len(data) - frameInfoSize + fi := data[offset:] + + return &FrameInfo{ + CodecID: fi[0], + Flags: fi[2], + CamIndex: fi[3], + OnlineNum: fi[4], + FPS: fi[5], + ResTier: fi[6], + Bitrate: fi[7], + Timestamp: binary.LittleEndian.Uint32(fi[8:]), + SessionID: binary.LittleEndian.Uint32(fi[12:]), + PayloadSize: binary.LittleEndian.Uint32(fi[16:]), + FrameNo: binary.LittleEndian.Uint32(fi[20:]), + } +} + +type Packet struct { + Channel uint8 + Codec byte + Timestamp uint32 + Payload []byte + IsKeyframe bool + FrameNo uint32 + SampleRate uint32 + Channels uint8 +} + +type PacketHeader struct { + Channel byte + FrameType byte + HeaderSize int + FrameNo uint32 + PktIdx uint16 + PktTotal uint16 + PayloadSize uint16 + HasFrameInfo bool +} + +func ParsePacketHeader(data []byte) *PacketHeader { + if len(data) < 28 { + return nil + } + + frameType := data[1] + hdr := &PacketHeader{ + Channel: data[0], + FrameType: frameType, + } + + switch frameType { + case FrameTypeStart, FrameTypeStartAlt, FrameTypeEndExt: + hdr.HeaderSize = 36 + default: + hdr.HeaderSize = 28 + } + + if len(data) < hdr.HeaderSize { + return nil + } + + if hdr.HeaderSize == 28 { + hdr.PktTotal = binary.LittleEndian.Uint16(data[12:]) + pktIdxOrMarker := binary.LittleEndian.Uint16(data[14:]) + hdr.PayloadSize = binary.LittleEndian.Uint16(data[16:]) + hdr.FrameNo = binary.LittleEndian.Uint32(data[24:]) + + if pktIdxOrMarker == 0x0028 && (IsEndFrame(frameType) || hdr.PktTotal == 1) { + hdr.HasFrameInfo = true + if hdr.PktTotal > 0 { + hdr.PktIdx = hdr.PktTotal - 1 + } + } else { + hdr.PktIdx = pktIdxOrMarker + } + } else { + hdr.PktTotal = binary.LittleEndian.Uint16(data[20:]) + pktIdxOrMarker := binary.LittleEndian.Uint16(data[22:]) + hdr.PayloadSize = binary.LittleEndian.Uint16(data[24:]) + hdr.FrameNo = binary.LittleEndian.Uint32(data[32:]) + + if pktIdxOrMarker == 0x0028 && (IsEndFrame(frameType) || hdr.PktTotal == 1) { + hdr.HasFrameInfo = true + if hdr.PktTotal > 0 { + hdr.PktIdx = hdr.PktTotal - 1 + } + } else { + hdr.PktIdx = pktIdxOrMarker + } + } + + return hdr +} + +func IsStartFrame(frameType uint8) bool { + return frameType == FrameTypeStart || frameType == FrameTypeStartAlt +} + +func IsEndFrame(frameType uint8) bool { + return frameType == FrameTypeEndSingle || + frameType == FrameTypeEndMulti || + frameType == FrameTypeEndExt +} + +func IsContinuationFrame(frameType uint8) bool { + return frameType == FrameTypeCont || frameType == FrameTypeContAlt +} + +type channelState struct { + frameNo uint32 // current frame being assembled + pktTotal uint16 // expected total packets + waitSeq uint16 // next expected packet index (0, 1, 2, ...) + waitData []byte // accumulated payload data + frameInfo *FrameInfo // frame info (from end packet) + hasStarted bool // received first packet of frame + lastPktIdx uint16 // last received packet index (for OOO detection) +} + +func (cs *channelState) reset() { + cs.frameNo = 0 + cs.pktTotal = 0 + cs.waitSeq = 0 + cs.waitData = cs.waitData[:0] + cs.frameInfo = nil + cs.hasStarted = false + cs.lastPktIdx = 0 +} + +const tsWrapPeriod uint32 = 1000000 + +type tsTracker struct { + lastRawTS uint32 + accumUS uint64 + firstTS bool +} + +func (t *tsTracker) update(rawTS uint32) uint64 { + if !t.firstTS { + t.firstTS = true + t.lastRawTS = rawTS + return 0 + } + + var delta uint32 + if rawTS >= t.lastRawTS { + delta = rawTS - t.lastRawTS + } else { + // Wrapped: delta = (wrap - last) + new + delta = (tsWrapPeriod - t.lastRawTS) + rawTS + } + + t.accumUS += uint64(delta) + t.lastRawTS = rawTS + + return t.accumUS +} + +type FrameHandler struct { + channels map[byte]*channelState + videoTS tsTracker + audioTS tsTracker + output chan *Packet + verbose bool + closed bool + closeMu sync.Mutex +} + +func NewFrameHandler(verbose bool) *FrameHandler { + return &FrameHandler{ + channels: make(map[byte]*channelState), + output: make(chan *Packet, 128), + verbose: verbose, + } +} + +func (h *FrameHandler) Recv() <-chan *Packet { + return h.output +} + +func (h *FrameHandler) Close() { + h.closeMu.Lock() + defer h.closeMu.Unlock() + + if h.closed { + return + } + h.closed = true + close(h.output) +} + +func (h *FrameHandler) Handle(data []byte) { + hdr := ParsePacketHeader(data) + if hdr == nil { + return + } + + payload, fi := h.extractPayload(data, hdr.Channel) + if payload == nil { + return + } + + if h.verbose { + fiStr := "" + if hdr.HasFrameInfo { + fiStr = " +FI" + } + fmt.Printf("[RX] ch=0x%02x type=0x%02x #%d pkt=%d/%d data=%dB%s\n", + hdr.Channel, hdr.FrameType, + hdr.FrameNo, hdr.PktIdx, hdr.PktTotal, len(payload), fiStr) + } + + switch hdr.Channel { + case ChannelAudio: + h.handleAudio(payload, fi) + case ChannelIVideo, ChannelPVideo: + h.handleVideo(hdr.Channel, hdr, payload, fi) + } +} + +func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *FrameInfo) { + if len(data) < 2 { + return nil, nil + } + + frameType := data[1] + + headerSize := 28 + fiSize := 0 + + switch frameType { + case FrameTypeStart: + headerSize = 36 + case FrameTypeStartAlt: + headerSize = 36 + if len(data) >= 22 { + pktTotal := binary.LittleEndian.Uint16(data[20:]) + if pktTotal == 1 { + fiSize = frameInfoSize + } + } + case FrameTypeCont, FrameTypeContAlt: + headerSize = 28 + case FrameTypeEndSingle, FrameTypeEndMulti: + headerSize = 28 + fiSize = frameInfoSize + case FrameTypeEndExt: + headerSize = 36 + fiSize = frameInfoSize + default: + headerSize = 28 + } + + if len(data) < headerSize { + return nil, nil + } + + if fiSize == 0 { + return data[headerSize:], nil + } + + if len(data) < headerSize+fiSize { + return data[headerSize:], nil + } + + fi := ParseFrameInfo(data) + + validCodec := false + switch channel { + case ChannelIVideo, ChannelPVideo: + validCodec = IsVideoCodec(fi.CodecID) + case ChannelAudio: + validCodec = IsAudioCodec(fi.CodecID) + } + + if validCodec { + payload := data[headerSize : len(data)-fiSize] + return payload, fi + } + + return data[headerSize:], nil +} + +func (h *FrameHandler) handleVideo(channel byte, hdr *PacketHeader, payload []byte, fi *FrameInfo) { + cs := h.channels[channel] + if cs == nil { + cs = &channelState{} + h.channels[channel] = cs + } + + // New frame number - reset and start fresh + if hdr.FrameNo != cs.frameNo { + // Check if previous frame was incomplete + if cs.hasStarted && cs.waitSeq < cs.pktTotal { + fmt.Printf("[DROP] ch=0x%02x #%d INCOMPLETE: got %d/%d pkts\n", + channel, cs.frameNo, cs.waitSeq, cs.pktTotal) + } + cs.reset() + cs.frameNo = hdr.FrameNo + cs.pktTotal = hdr.PktTotal + } + + // If packet index doesn't match expected, reset (data loss) + if hdr.PktIdx != cs.waitSeq { + fmt.Printf("[OOO] ch=0x%02x #%d frameType=0x%02x pktTotal=%d expected pkt %d, got %d - reset\n", + channel, hdr.FrameNo, hdr.FrameType, hdr.PktTotal, cs.waitSeq, hdr.PktIdx) + cs.reset() + return + } + + // First packet - mark as started + if cs.waitSeq == 0 { + cs.hasStarted = true + } + + cs.waitData = append(cs.waitData, payload...) + cs.waitSeq++ + + // Store frame info if present + if fi != nil { + cs.frameInfo = fi + } + + // Check if frame is complete + if cs.waitSeq != cs.pktTotal || cs.frameInfo == nil { + return + } + + fi = cs.frameInfo + defer cs.reset() + + if fi.PayloadSize > 0 && uint32(len(cs.waitData)) != fi.PayloadSize { + fmt.Printf("[SIZE] ch=0x%02x #%d mismatch: expected %d, got %d\n", + channel, cs.frameNo, fi.PayloadSize, len(cs.waitData)) + return + } + + if len(cs.waitData) == 0 { + return + } + + accumUS := h.videoTS.update(fi.Timestamp) + rtpTS := uint32(accumUS * 90000 / 1000000) + + pkt := &Packet{ + Channel: channel, + Payload: append([]byte{}, cs.waitData...), + Codec: fi.CodecID, + Timestamp: rtpTS, + IsKeyframe: fi.IsKeyframe(), + FrameNo: fi.FrameNo, + } + + if h.verbose { + frameType := "P" + if fi.IsKeyframe() { + frameType = "KEY" + } + fmt.Printf("[OK] ch=0x%02x #%d codec=0x%02x %s size=%d\n", + channel, fi.FrameNo, fi.CodecID, frameType, len(pkt.Payload)) + fmt.Printf(" [0-1]codec=0x%02x [2]flags=0x%x [3]=%d [4]=%d\n", + fi.CodecID, fi.Flags, fi.CamIndex, fi.OnlineNum) + fmt.Printf(" [5]=%d [6]=%d [7]=%d [8-11]ts=%d\n", + fi.FPS, fi.ResTier, fi.Bitrate, fi.Timestamp) + fmt.Printf(" [12-15]=0x%x [16-19]payload=%d [20-23]frameNo=%d\n", + fi.SessionID, fi.PayloadSize, fi.FrameNo) + fmt.Printf(" rtp_ts=%d accum_us=%d\n", rtpTS, accumUS) + fmt.Printf(" hex: %s\n", dumpHex(fi)) + } + + h.queue(pkt) +} + +func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) { + if len(payload) == 0 || fi == nil { + return + } + + var sampleRate uint32 + var channels uint8 + + switch fi.CodecID { + case CodecAACRaw, CodecAACADTS, CodecAACLATM, CodecAACAlt: + sampleRate, channels = parseAudioParams(payload, fi) + default: + sampleRate = fi.SampleRate() + channels = fi.Channels() + } + + accumUS := h.audioTS.update(fi.Timestamp) + rtpTS := uint32(accumUS * uint64(sampleRate) / 1000000) + + payloadCopy := make([]byte, len(payload)) + copy(payloadCopy, payload) + + pkt := &Packet{ + Channel: ChannelAudio, + Payload: payloadCopy, + Codec: fi.CodecID, + Timestamp: rtpTS, + SampleRate: sampleRate, + Channels: channels, + FrameNo: fi.FrameNo, + } + + if h.verbose { + bits := 8 + if fi.Flags&0x02 != 0 { + bits = 16 + } + fmt.Printf("[OK] Audio #%d codec=0x%02x size=%d\n", + fi.FrameNo, fi.CodecID, len(payload)) + fmt.Printf(" [0-1]codec=0x%02x [2]flags=0x%x(%dHz/%dbit/%dch)\n", + fi.CodecID, fi.Flags, sampleRate, bits, channels) + fmt.Printf(" [8-11]ts=%d [12-15]=0x%x rtp_ts=%d\n", + fi.Timestamp, fi.SessionID, rtpTS) + fmt.Printf(" hex: %s\n", dumpHex(fi)) + } + + h.queue(pkt) +} + +func (h *FrameHandler) queue(pkt *Packet) { + h.closeMu.Lock() + defer h.closeMu.Unlock() + + if h.closed { + return + } + + select { + case h.output <- pkt: + default: + // Queue full - drop oldest + select { + case <-h.output: + default: + } + select { + case h.output <- pkt: + default: + // Queue still full, drop this packet + } + } +} + +func parseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channels uint8) { + if aac.IsADTS(payload) { + codec := aac.ADTSToCodec(payload) + if codec != nil { + return codec.ClockRate, codec.Channels + } + } + + if fi != nil { + return fi.SampleRate(), fi.Channels() + } + + return 16000, 1 +} + +func dumpHex(fi *FrameInfo) string { + b := make([]byte, frameInfoSize) + b[0] = fi.CodecID + b[1] = 0 // High byte (unused) + b[2] = fi.Flags + b[3] = fi.CamIndex + b[4] = fi.OnlineNum + b[5] = fi.FPS + b[6] = fi.ResTier + b[7] = fi.Bitrate + binary.LittleEndian.PutUint32(b[8:], fi.Timestamp) + binary.LittleEndian.PutUint32(b[12:], fi.SessionID) + binary.LittleEndian.PutUint32(b[16:], fi.PayloadSize) + binary.LittleEndian.PutUint32(b[20:], fi.FrameNo) + // Bytes 24-39 are DeviceID and Padding (not stored in struct) + + hexStr := hex.EncodeToString(b) + formatted := "" + for i := 0; i < len(hexStr); i += 2 { + if i > 0 { + formatted += " " + } + formatted += hexStr[i : i+2] + } + return formatted +} diff --git a/pkg/tutk/helpers.go b/pkg/tutk/helpers.go new file mode 100644 index 00000000..93bf4b5a --- /dev/null +++ b/pkg/tutk/helpers.go @@ -0,0 +1,71 @@ +package tutk + +import ( + "encoding/binary" + "time" +) + +func GenSessionID() []byte { + b := make([]byte, 8) + binary.LittleEndian.PutUint64(b, uint64(time.Now().UnixNano())) + return b +} + +func ICAM(cmd uint32, args ...byte) []byte { + // 0 4943414d ICAM + // 4 d807ff00 command + // 8 00000000000000 + // 15 02 args count + // 16 00000000000000 + // 23 0101 args + n := byte(len(args)) + b := make([]byte, 23+n) + copy(b, "ICAM") + binary.LittleEndian.PutUint32(b[4:], cmd) + b[15] = n + copy(b[23:], args) + return b +} + +func HL(cmdID uint16, payload []byte) []byte { + // 0-1 "HL" magic + // 2 version (typically 5) + // 3 reserved + // 4-5 cmdID command ID (uint16 LE) + // 6-7 payloadLen payload length (uint16 LE) + // 8-15 reserved + // 16+ payload + const headerSize = 16 + const version = 5 + + b := make([]byte, headerSize+len(payload)) + copy(b, "HL") + b[2] = version + binary.LittleEndian.PutUint16(b[4:], cmdID) + binary.LittleEndian.PutUint16(b[6:], uint16(len(payload))) + copy(b[headerSize:], payload) + return b +} + +func ParseHL(data []byte) (cmdID uint16, payload []byte, ok bool) { + if len(data) < 16 || data[0] != 'H' || data[1] != 'L' { + return 0, nil, false + } + cmdID = binary.LittleEndian.Uint16(data[4:]) + payloadLen := binary.LittleEndian.Uint16(data[6:]) + if len(data) >= 16+int(payloadLen) { + payload = data[16 : 16+payloadLen] + } else if len(data) > 16 { + payload = data[16:] + } + return cmdID, payload, true +} + +func FindHL(data []byte, offset int) []byte { + for i := offset; i+16 <= len(data); i++ { + if data[i] == 'H' && data[i+1] == 'L' { + return data[i:] + } + } + return nil +} diff --git a/pkg/tutk/session0.go b/pkg/tutk/session0.go new file mode 100644 index 00000000..6a1b2253 --- /dev/null +++ b/pkg/tutk/session0.go @@ -0,0 +1,157 @@ +package tutk + +import ( + "bytes" + "encoding/binary" + "net" + "time" +) + +func (c *Conn) connectDirect(uid string, sid []byte) error { + res, err := writeAndWait( + c, func(res []byte) bool { return bytes.Index(res, []byte("\x02\x06\x12\x00")) == 8 }, + ConnectByUID(stageBroadcast, uid, sid), + ) + if err != nil { + return err + } + + n := len(res) // should be 200 + c.ver = []byte{res[2], res[n-13], res[n-14], res[n-15], res[n-16]} + + _, err = c.Write(ConnectByUID(stageDirect, uid, sid)) + return err +} + +func (c *Conn) connectRemote(uid string, sid []byte) error { + res, err := writeAndWait( + c, func(res []byte) bool { return bytes.Index(res, []byte("\x01\x03\x43")) == 8 }, + ConnectByUID(stageGetRemoteIP, uid, sid), + ) + if err != nil { + return err + } + + // Read real IP from cloud server response. + // Important ot use net.IPv4 because slice will be 16 bytes. + c.addr.IP = net.IPv4(res[40], res[41], res[42], res[43]) + c.addr.Port = int(binary.BigEndian.Uint16(res[38:])) + + res, err = writeAndWait( + c, func(res []byte) bool { return bytes.Index(res, []byte("\x04\x04\x33")) == 8 }, + ConnectByUID(stageRemoteAck, uid, sid), + ) + if err != nil { + return err + } + + if len(res) == 52 { + c.ver = []byte{res[2], res[51], res[50], res[49], res[48]} + } else { + c.ver = []byte{res[2]} + } + + _, err = c.Write(ConnectByUID(stageRemoteOK, uid, sid)) + return err +} + +func (c *Conn) clientStart(username, password string) error { + _, err := writeAndWait( + c, func(res []byte) bool { + return len(res) >= 84 && res[28] == 0 && (res[29] == 0x14 || res[29] == 0x21) + }, + c.session.ClientStart(0, username, password), + c.session.ClientStart(1, username, password), + ) + return err +} + +func writeAndWait(conn net.Conn, ok func(res []byte) bool, req ...[]byte) ([]byte, error) { + var t *time.Timer + t = time.AfterFunc(1, func() { + for _, b := range req { + if _, err := conn.Write(b); err != nil { + return + } + } + if t != nil { + t.Reset(time.Second) + } + }) + defer t.Stop() + + buf := make([]byte, 1200) + + for { + n, err := conn.Read(buf) + if err != nil { + return nil, err + } + + if ok(buf[:n]) { + return buf[:n], nil + } + } +} + +const ( + magic = "\x04\x02\x19" // include version 0x19 + sdkVersion = "\x06\x00\x03\x03" // 3.3.0.6 +) + +const ( + stageBroadcast = iota + 1 + stageDirect + stageGetPublicIP + stageGetRemoteIP + stageRemoteReq + stageRemoteAck + stageRemoteOK +) + +func ConnectByUID(stage byte, uid string, sid8 []byte) []byte { + var b []byte + + switch stage { + case stageBroadcast, stageDirect: + b = make([]byte, 68) + copy(b[8:], "\x01\x06\x21") + copy(b[52:], sdkVersion) + copy(b[56:], sid8) + b[64] = stage // 1 or 2 + + case stageGetPublicIP: + b = make([]byte, 54) + copy(b[8:], "\x07\x10\x18") + + case stageGetRemoteIP: + b = make([]byte, 112) + copy(b[8:], "\x03\x02\x34") + copy(b[100:], sid8) + b[108] = stageDirect + + case stageRemoteReq: + b = make([]byte, 52) + copy(b[8:], "\x01\x04\x33") + copy(b[36:], sid8) + copy(b[48:], sdkVersion) + + case stageRemoteAck: + b = make([]byte, 44) + copy(b[8:], "\x02\x04\x33") + copy(b[36:], sid8) + + case stageRemoteOK: + b = make([]byte, 52) + copy(b[8:], "\x04\x04\x33") + copy(b[36:], sid8) + copy(b[48:], sdkVersion) + } + + copy(b, magic) + b[3] = 0x02 // connection stage + binary.LittleEndian.PutUint16(b[4:], uint16(len(b))-16) + copy(b[16:], uid) + + return b +} diff --git a/pkg/tutk/session16.go b/pkg/tutk/session16.go new file mode 100644 index 00000000..47110dd3 --- /dev/null +++ b/pkg/tutk/session16.go @@ -0,0 +1,378 @@ +package tutk + +import ( + "bytes" + "encoding/binary" + "io" + "net" + "time" +) + +type Session interface { + Close() error + + ClientStart(i byte, username, password string) []byte + + SendIOCtrl(ctrlType uint32, ctrlData []byte) []byte + SendFrameData(frameInfo, frameData []byte) []byte + + RecvIOCtrl() (ctrlType uint32, ctrlData []byte, err error) + RecvFrameData() (frameInfo, frameData []byte, err error) + + SessionRead(chID byte, buf []byte) int + SessionWrite(chID byte, buf []byte) error +} + +func NewSession16(conn net.Conn, sid8 []byte) *Session16 { + sid16 := make([]byte, 16) + copy(sid16[8:], sid8) + copy(sid16, sid8[:2]) + sid16[4] = 0x0c + + return &Session16{ + conn: conn, + sid16: sid16, + rawCmd: make(chan []byte, 10), + rawPkt: make(chan [2][]byte, 100), + } +} + +type Session16 struct { + conn net.Conn + sid16 []byte + + rawCmd chan []byte + rawPkt chan [2][]byte + + seqSendCh0 uint16 + seqSendCh1 uint16 + + seqSendCmd1 uint16 + seqSendAud uint16 + + waitSeq uint16 + waitSize int + waitData []byte +} + +func (s *Session16) Close() error { + close(s.rawCmd) + close(s.rawPkt) + return nil +} + +func (s *Session16) Msg(size uint16) []byte { + b := make([]byte, size) + copy(b, magic) + b[3] = 0x0a // connected stage + binary.LittleEndian.PutUint16(b[4:], size-16) + copy(b[8:], "\x07\x04\x21") // client request + copy(b[12:], s.sid16) + return b +} + +const ( + msgHhrSize = 28 + cmdHdrSize = 24 +) + +func (s *Session16) ClientStart(i byte, username, password string) []byte { + const size = 566 + 32 + msg := s.Msg(size) + + // 0 00000b0000000000000000000000000022020000fcfc7284 + // 24 4d69737300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + // 281 636c69656e740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + // 538 0100000004000000fb071f000000000000000000000003000000000001000000 + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x00\x0b\x00") + binary.LittleEndian.PutUint16(cmd[16:], size-52) + if i == 0 { + cmd[18] = 1 + } else { + cmd[1] = 0x20 + } + binary.LittleEndian.PutUint32(cmd[20:], uint32(time.Now().UnixMilli())) + + // important values for some cameras (not for df3) + data := cmd[cmdHdrSize:] + copy(data, username) + copy(data[257:], password) + + // 0100000004000000fb071f000000000000000000000003000000000001000000 + cfg := data[257+257:] + //cfg[0] = 1 // 0 - simple proto, 1 - complex proto with "0Cxx" commands + cfg[4] = 4 + copy(cfg[8:], "\xfb\x07\x1f\x00") + cfg[22] = 3 + //cfg[28] = 1 // unknown + return msg +} + +func (s *Session16) SendIOCtrl(ctrlType uint32, ctrlData []byte) []byte { + dataSize := 4 + uint16(len(ctrlData)) + msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x70\x0b\x00") + + s.seqSendCmd1++ // start from 1, important! + binary.LittleEndian.PutUint16(cmd[4:], s.seqSendCmd1) + + binary.LittleEndian.PutUint16(cmd[16:], dataSize) + binary.LittleEndian.PutUint32(cmd[20:], uint32(time.Now().UnixMilli())) + + data := cmd[cmdHdrSize:] + binary.LittleEndian.PutUint32(data, ctrlType) + copy(data[4:], ctrlData) + return msg +} + +func (s *Session16) SendFrameData(frameInfo, frameData []byte) []byte { + // -> 01030b001d0000008802000000002800b0020bf501000000 ... 4f4455412000000088020000030400001d000000000000000bf51f7a9b0100000000000000000000 + + n := uint16(len(frameData)) + dataSize := n + 8 + 32 + msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize) + + // 0 01030b00 command + version + // 4 1d000000 seq + // 8 8802 media size (648) + // 10 00000000 + // 14 2800 tail (pkt header) size? + // 16 b002 size (648 + 8 + 32) + // 18 0bf5 random msg id (unixms) + // 20 01000000 fixed + cmd := msg[msgHhrSize:] + copy(cmd, "\x01\x03\x0b\x00") + binary.LittleEndian.PutUint16(cmd[4:], s.seqSendAud) + s.seqSendAud++ + binary.LittleEndian.PutUint16(cmd[8:], n) + cmd[14] = 0x28 // important! + binary.LittleEndian.PutUint16(cmd[16:], dataSize) + binary.LittleEndian.PutUint16(cmd[18:], uint16(time.Now().UnixMilli())) + cmd[20] = 1 + + data := cmd[cmdHdrSize:] + copy(data, frameData) + copy(data[n:], "ODUA\x20\x00\x00\x00") + copy(data[n+8:], frameInfo) + + return msg +} + +func (s *Session16) RecvIOCtrl() (ctrlType uint32, ctrlData []byte, err error) { + buf, ok := <-s.rawCmd + if !ok { + return 0, nil, io.EOF + } + return binary.LittleEndian.Uint32(buf), buf[4:], nil +} + +func (s *Session16) RecvFrameData() (frameInfo, frameData []byte, err error) { + buf, ok := <-s.rawPkt + if !ok { + return nil, nil, io.EOF + } + return buf[0], buf[1], nil +} + +func (s *Session16) SessionRead(chID byte, cmd []byte) int { + if chID != 0 { + return s.handleCh1(cmd) + } + + // 0 01030800 command + version + // 4 00000000 frame num + // 8 ac880100 total size + // 12 6200 chunk seq + // 14 2000 tail (pkt header) size + // 16 cc00 size + // 18 0000 + // 20 01000000 fixed + + switch cmd[0] { + case 0x01: + var packetData [2][]byte + + switch cmd[1] { + case 0x03: + seq := binary.LittleEndian.Uint16(cmd[12:]) + if seq != s.waitSeq { + s.waitSeq = 0 + return msgMediaLost + } + if seq == 0 { + s.waitData = s.waitData[:0] + payloadSize := binary.LittleEndian.Uint32(cmd[8:]) + hdrSize := binary.LittleEndian.Uint16(cmd[14:]) + s.waitSize = int(hdrSize) + int(payloadSize) + } + + s.waitData = append(s.waitData, cmd[24:]...) + if n := len(s.waitData); n < s.waitSize { + s.waitSeq++ + return msgMediaChunk + } + + s.waitSeq = 0 + + payloadSize := binary.LittleEndian.Uint32(cmd[8:]) + packetData[0] = bytes.Clone(s.waitData[payloadSize:]) + packetData[1] = bytes.Clone(s.waitData[:payloadSize]) + + case 0x04: + data := cmd[24:] + hdrSize := binary.LittleEndian.Uint16(cmd[14:]) + packetData[0] = bytes.Clone(data[:hdrSize]) + packetData[1] = bytes.Clone(data[hdrSize:]) + + default: + return msgUnknown + } + + select { + case s.rawPkt <- packetData: + default: + return msgError + } + return msgMediaFrame + + case 0x00: + switch cmd[1] { + case 0x70: + _ = s.SessionWrite(0, s.msgAck0070(cmd)) + select { + case s.rawCmd <- append([]byte{}, cmd[24:]...): + default: + } + + return msgCommand + case 0x12: + _ = s.SessionWrite(0, s.msgAck0012(cmd)) + return msgDafang0012 + case 0x71: + return msgCommandAck + } + } + + return msgUnknown +} + +func (s *Session16) msgAck0070(msg28 []byte) []byte { + // <- 00700800010000000000000000000000340000007625a02f ... + // -> 00710800010000000000000000000000000000007625a02f + msg := s.Msg(msgHhrSize + cmdHdrSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x71") + copy(cmd[2:], msg28[2:6]) // same version and seq + copy(cmd[20:], msg28[20:24]) // same msg random + + return msg +} + +func (s *Session16) msgAck0012(msg28 []byte) []byte { + // <- 001208000000000000000000000000000c00000000000000 020000000100000001000000 + // -> 00130b000000000000000000000000001400000000000000 0200000001000000010000000000000000000000 + const dataSize = 20 + msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x13\x0b\x00") + cmd[16] = dataSize + + data := cmd[cmdHdrSize:] + copy(data, msg28[cmdHdrSize:]) + + return msg +} + +func (s *Session16) handleCh1(cmd []byte) int { + // Channel 1 used for two-way audio. It's important: + // - answer on 0000 command with exact config response (can't set simple proto) + // - send 0012 command at start + // - respond on every 0008 command for smooth playback + switch cid := string(cmd[:2]); cid { + case "\x00\x00": // client start + _ = s.SessionWrite(1, s.msgAck0000(cmd)) + _ = s.SessionWrite(1, s.msg0012()) + return msgClientStart + case "\x00\x07": // time sync without data + _ = s.SessionWrite(1, s.msgAck0007(cmd)) + return msgUnknown0007 + case "\x00\x08": // time sync with data + _ = s.SessionWrite(1, s.msgAck0008(cmd)) + return msgUnknown0008 + case "\x00\x13": // ack for 0012 + return msgUnknown0013 + } + return msgUnknown +} + +func (s *Session16) msgAck0000(msg28 []byte) []byte { + // <- 000008000000000000000000000000001a0200004f47c714 ... 00000000000000000100000004000000fb071f00000000000000000000000300 + // -> 00140b00000000000000000000000000200000004f47c714 00000000000000000100000004000000fb071f00000000000000000000000300 + const cmdDataSize = 32 + msg := s.Msg(msgHhrSize + cmdHdrSize + cmdDataSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x14\x0b\x00") + cmd[16] = cmdDataSize + copy(cmd[20:], msg28[20:24]) // request id (random) + + // Important to answer with same data. + data := cmd[cmdHdrSize:] + copy(data, msg28[len(msg28)-32:]) + return msg +} + +func (s *Session16) msg0012() []byte { + // -> 00120b000000000000000000000000000c00000000000000020000000100000001000000 + const dataSize = 12 + msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize) + cmd := msg[msgHhrSize:] + + copy(cmd, "\x00\x12\x0b\x00") + cmd[16] = dataSize + data := cmd[cmdHdrSize:] + + data[0] = 2 + data[4] = 1 + data[9] = 1 + return msg +} + +func (s *Session16) msgAck0007(msg28 []byte) []byte { + // <- 000708000000000000000000000000000c00000001000000000000001c551f7a00000000 + // -> 010a0b00000000000000000000000000000000000100000000000000 + msg := s.Msg(msgHhrSize + 28) + cmd := msg[msgHhrSize:] + copy(cmd, "\x01\x0a\x0b\x00") + cmd[20] = 1 + return msg +} + +func (s *Session16) msgAck0008(msg28 []byte) []byte { + // <- 000808000000000000000000000000000000f9f0010000000200000050f31f7a + // -> 01090b0000000000000000000000000000000000010000000200000050f31f7a + msg := s.Msg(msgHhrSize + 28) + cmd := msg[msgHhrSize:] + copy(cmd, "\x01\x09\x0b\x00") + copy(cmd[20:], msg28[20:]) + return msg +} + +func (s *Session16) SessionWrite(chID byte, buf []byte) error { + switch chID { + case 0: + binary.LittleEndian.PutUint16(buf[6:], s.seqSendCh0) + s.seqSendCh0++ + case 1: + binary.LittleEndian.PutUint16(buf[6:], s.seqSendCh1) + s.seqSendCh1++ + buf[14] = 1 // channel + } + _, err := s.conn.Write(buf) + return err +} diff --git a/pkg/tutk/session25.go b/pkg/tutk/session25.go new file mode 100644 index 00000000..fd1f16b4 --- /dev/null +++ b/pkg/tutk/session25.go @@ -0,0 +1,337 @@ +package tutk + +import ( + "bytes" + "encoding/binary" + "net" + "time" +) + +func NewSession25(conn net.Conn, sid []byte) *Session25 { + return &Session25{ + Session16: NewSession16(conn, sid), + rb: NewReorderBuffer(5), + } +} + +type Session25 struct { + *Session16 + + rb *ReorderBuffer + + seqSendCmd2 uint16 + seqSendCnt uint16 + + seqRecvPkt0 uint16 + seqRecvPkt1 uint16 + seqRecvCmd2 uint16 +} + +const cmdHdrSize25 = 28 + +func (s *Session25) SendIOCtrl(ctrlType uint32, ctrlData []byte) []byte { + size := msgHhrSize + cmdHdrSize25 + 4 + uint16(len(ctrlData)) + msg := s.Msg(size) + + // 0 0070 command + // 2 0b00 version + // 4 1000 seq + // 6 0076 ??? + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x70\x0b\x00") + binary.LittleEndian.PutUint16(cmd[4:], s.seqSendCmd1) + s.seqSendCmd1++ + + // 8 0070 command (second time) + // 10 0300 seq + // 12 0100 chunks count + // 14 0000 chunk seq (starts from 0) + // 16 5500 size + // 18 0000 random msg id (always 0) + // 20 03000000 seq (second time) + // 24 00000000 + // 28 01010000 ctrlType + cmd[9] = 0x70 + cmd[12] = 1 + binary.LittleEndian.PutUint16(cmd[16:], size-52) + + binary.LittleEndian.PutUint16(cmd[10:], s.seqSendCmd2) + binary.LittleEndian.PutUint16(cmd[20:], s.seqSendCmd2) + s.seqSendCmd2++ + + data := cmd[28:] + binary.LittleEndian.PutUint32(data, ctrlType) + copy(data[4:], ctrlData) + return msg +} + +func (s *Session25) SendFrameData(frameInfo, frameData []byte) []byte { + return nil +} + +func (s *Session25) SessionRead(chID byte, cmd []byte) (res int) { + if chID != 0 { + return s.handleCh1(cmd) + } + + switch cmd[0] { + case 0x03, 0x05, 0x07: + for i := 0; cmd != nil; i++ { + res = s.handleChunk(cmd, i == 0) + cmd = s.rb.Pop() + } + return + + case 0x00: + _ = s.SessionWrite(0, s.msgAckCounters()) + s.seqRecvCmd2 = binary.LittleEndian.Uint16(cmd[2:]) + + switch cmd[1] { + case 0x10: + return msgUnknown0010 // unknown + case 0x21: + return msgClientStartAck2 + case 0x70: + select { + case s.rawCmd <- cmd[28:]: + default: + } + return msgCommand // cmd from camera + case 0x71: + return msgCommandAck + } + + case 0x09: + // off sample + // 0 09000b00 cmd1 + // 4 0d000000 seqCmd1 + // 12 0000 seqRecvCmd2 + seq := binary.LittleEndian.Uint16(cmd[12:]) + if s.seqSendCmd1 > seq { + return msgCommandAck + } + return msgCounters + + case 0x0a: + // seq sample + // 0 0a080b00 + // 4 03000000 + // 8 e2043200 + // 12 01000000 + _ = s.SessionWrite(0, s.msgAck0A08(cmd)) + return msgUnknown0a08 + } + + return msgUnknown +} + +func (s *Session25) handleChunk(cmd []byte, checkSeq bool) int { + var cmd2 []byte + + flags := cmd[1] + if flags&0b1000 == 0 { + // off sample + // 0 0700 command + // 2 0b00 version + // 4 2700 seq + // 6 0000 ??? + // 8 0700 command (second time) + // 10 1400 seq + // 12 1300 chunks count per this frame + // 14 1100 chunk seq, starts from 0 (0x20 for last chunk) + // 16 0004 frame data size + // 18 0000 random msg id (always 0) + // 20 02000000 previous frame seq, starts from 0 + // 24 03000000 current frame seq, starts from 1 + cmd2 = cmd[8:] + } else { + // off sample + // 0 070d0b00 + // 4 30000000 + // 8 5c965500 ??? + // 12 ffff0000 ??? + // 16 0701 fixed command + // 18 190001002000a802000006000000070000000 + cmd2 = cmd[16:] + } + + seq := binary.LittleEndian.Uint16(cmd2[2:]) + + if checkSeq { + if s.rb.Check(seq) { + s.rb.Next() + } else { + s.rb.Push(seq, cmd) + return msgMediaReorder + } + } + + // Check if this is first chunk for frame. + // Handle protocol bug "0x20 chunk seq for last chunk" and sometimes + // "0x20 chunk seq for first chunk if only one chunk". + if binary.LittleEndian.Uint16(cmd2[6:]) == 0 || binary.LittleEndian.Uint16(cmd2[4:]) == 1 { + s.waitData = s.waitData[:0] + s.waitSeq = seq + } else if seq != s.waitSeq { + return msgMediaLost + } + + s.waitData = append(s.waitData, cmd2[20:]...) + + if flags&0b0001 == 0 { + s.waitSeq++ + return msgMediaChunk + } + + s.seqRecvPkt1 = seq + _ = s.SessionWrite(0, s.msgAckCounters()) + + n := len(s.waitData) - 32 + packetData := [2][]byte{bytes.Clone(s.waitData[n:]), bytes.Clone(s.waitData[:n])} + + select { + case s.rawPkt <- packetData: + default: + return msgError + } + return msgMediaFrame +} + +func (s *Session25) msgAckCounters() []byte { + msg := s.Msg(msgHhrSize + cmdHdrSize) + + // off sample + // 0 09000b00 cmd1 + // 4 2700 seqCmd1 + // 6 0000 + // 8 1300 seqRecvPkt0 + // 10 2600 seqRecvPkt1 + // 12 0400 seqRecvCmd2 + // 14 00000000 + // 18 1400 seqSendCnt + // 20 d91a random + // 22 0000 + cmd := msg[msgHhrSize:] + copy(cmd, "\x09\x00\x0b\x00") + + binary.LittleEndian.PutUint16(cmd[4:], s.seqSendCmd1) + s.seqSendCmd1++ + + // seqRecvPkt0 stores previous value of seqRecvPkt1 + // don't understand why this needs + binary.LittleEndian.PutUint16(cmd[8:], s.seqRecvPkt0) + s.seqRecvPkt0 = s.seqRecvPkt1 + binary.LittleEndian.PutUint16(cmd[10:], s.seqRecvPkt1) + binary.LittleEndian.PutUint16(cmd[12:], s.seqRecvCmd2) + + binary.LittleEndian.PutUint16(cmd[18:], s.seqSendCnt) + s.seqSendCnt++ + binary.LittleEndian.PutUint16(cmd[20:], uint16(time.Now().UnixMilli())) + return msg +} + +func (s *Session25) handleCh1(cmd []byte) int { + switch cid := string(cmd[:2]); cid { + case "\x00\x00": // client start + return msgClientStart + case "\x00\x07": // time sync without data + _ = s.SessionWrite(1, s.msgAck0007(cmd)) + return msgUnknown0007 + case "\x00\x20": // client start2 + _ = s.SessionWrite(1, s.msgAck0020(cmd)) + return msgClientStart2 + case "\x09\x00": + return msgUnknown0900 + case "\x0a\x08": + return msgUnknown0a08 + } + return msgUnknown +} + +func (s *Session25) msgAck0020(msg28 []byte) []byte { + const cmdDataSize = 36 + + msg := s.Msg(msgHhrSize + cmdHdrSize25 + cmdDataSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x21\x0b\x00") + cmd[16] = cmdDataSize + copy(cmd[20:], msg28[20:24]) // request id (random) + + // 0 00000000 + // 4 00010001 + // 8 01000000 + // 12 04000000 + // 16 fb071f00 + // 20 00000000 + // 24 00000000 + // 28 00000300 + // 32 01000000 + data := cmd[cmdHdrSize25:] + data[5] = 1 + data[7] = 1 + data[8] = 1 + data[12] = 4 + copy(data[16:], "\xfb\x07\x1f\x00") + data[30] = 3 + data[32] = 1 + return msg +} + +func (s *Session25) msgAck0A08(msg28 []byte) []byte { + // <- 0a080b005b0000000b51590002000000 + // -> 0b000b00000001000b5103000300000000000000 + msg := s.Msg(msgHhrSize + 20) + cmd := msg[msgHhrSize:] + copy(cmd, "\x0b\x00\x0b\x00") + copy(cmd[8:], msg28[8:10]) + return msg +} + +// ReorderBuffer used for UDP incoming data. Because the order of the packets may be mixed up. +type ReorderBuffer struct { + buf map[uint16][]byte + seq uint16 + size int +} + +func NewReorderBuffer(size int) *ReorderBuffer { + return &ReorderBuffer{buf: make(map[uint16][]byte), size: size} +} + +// Check return OK if this is the seq we are waiting for. +func (r *ReorderBuffer) Check(seq uint16) (ok bool) { + return seq == r.seq +} + +func (r *ReorderBuffer) Next() { + r.seq++ +} + +// Available return how much free slots is in the buffer. +func (r *ReorderBuffer) Available() int { + return r.size - len(r.buf) +} + +// Push new item to buffer. Important! There is no buffer full check here. +func (r *ReorderBuffer) Push(seq uint16, data []byte) { + //log.Printf("push seq=%d wait=%d", seq, r.seq) + r.buf[seq] = bytes.Clone(data) +} + +// Pop latest item from buffer. OK - if items wasn't dropped. +func (r *ReorderBuffer) Pop() []byte { + for { + if data := r.buf[r.seq]; data != nil { + delete(r.buf, r.seq) + r.Next() + //log.Printf("pop seq=%d", r.seq) + return data + } + if r.Available() > 0 { + return nil + } + //log.Printf("drop seq=%d", r.seq) + r.Next() // drop item + } +} diff --git a/pkg/tuya/README.md b/pkg/tuya/README.md new file mode 100644 index 00000000..f1936404 --- /dev/null +++ b/pkg/tuya/README.md @@ -0,0 +1,9 @@ +## Useful links + +- https://developer.tuya.com/en/docs/iot/webrtc?id=Kacsd4x2hl0se +- https://github.com/tuya/webrtc-demo-go +- https://github.com/bacco007/HomeAssistantConfig/blob/master/custom_components/xtend_tuya/multi_manager/tuya_iot/ipc/webrtc/xt_tuya_iot_webrtc_manager.py +- https://github.com/tuya/tuya-device-sharing-sdk +- https://github.com/make-all/tuya-local/blob/main/custom_components/tuya_local/cloud.py +- https://ipc-us.ismartlife.me/ +- https://protect-us.ismartlife.me/ \ No newline at end of file diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go new file mode 100644 index 00000000..3043a8d2 --- /dev/null +++ b/pkg/tuya/client.go @@ -0,0 +1,555 @@ +package tuya + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/pion/rtp" + pion "github.com/pion/webrtc/v4" +) + +type Client struct { + api TuyaAPI + conn *webrtc.Conn + pc *pion.PeerConnection + connected core.Waiter + closed bool + + // HEVC only: + dc *pion.DataChannel + videoSSRC *uint32 + audioSSRC *uint32 + streamType int + isHEVC bool + handlersMu sync.RWMutex + handlers map[uint32]func(*rtp.Packet) +} + +type DataChannelMessage struct { + Type string `json:"type"` // "codec", "start", "recv", "complete" + Msg string `json:"msg"` +} + +// RecvMessage contains SSRC values for video/audio streams +type RecvMessage struct { + Video struct { + SSRC uint32 `json:"ssrc"` + } `json:"video"` + Audio struct { + SSRC uint32 `json:"ssrc"` + } `json:"audio"` +} + +func Dial(rawURL string) (core.Producer, error) { + escapedURL := strings.ReplaceAll(rawURL, "#", "%23") + u, err := url.Parse(escapedURL) + if err != nil { + return nil, err + } + + query := u.Query() + + // Tuya Smart API + email := query.Get("email") + password := query.Get("password") + + // Tuya Cloud API + uid := query.Get("uid") + clientId := query.Get("client_id") + clientSecret := query.Get("client_secret") + + // Shared params + deviceId := query.Get("device_id") + + // Stream params + streamResolution := query.Get("resolution") + + useSmartApi := deviceId != "" && email != "" && password != "" + useCloudApi := deviceId != "" && uid != "" && clientId != "" && clientSecret != "" + + if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") { + streamResolution = "hd" + } + + if !useSmartApi && !useCloudApi { + return nil, errors.New("tuya: wrong query params") + } + + client := &Client{ + handlers: make(map[uint32]func(*rtp.Packet)), + } + + if useSmartApi { + if client.api, err = NewTuyaSmartApiClient(nil, u.Hostname(), email, password, deviceId); err != nil { + return nil, fmt.Errorf("tuya: %w", err) + } + } else { + if client.api, err = NewTuyaCloudApiClient(u.Hostname(), uid, deviceId, clientId, clientSecret); err != nil { + return nil, fmt.Errorf("tuya: %w", err) + } + } + + if err := client.api.Init(); err != nil { + return nil, fmt.Errorf("tuya: %w", err) + } + + client.streamType = client.api.GetStreamType(streamResolution) + client.isHEVC = client.api.IsHEVC(client.streamType) + + // Create a new PeerConnection + conf := pion.Configuration{ + ICEServers: client.api.GetICEServers(), + ICETransportPolicy: pion.ICETransportPolicyAll, + BundlePolicy: pion.BundlePolicyMaxBundle, + } + + api, err := webrtc.NewAPI() + if err != nil { + client.Close(err) + return nil, err + } + + client.pc, err = api.NewPeerConnection(conf) + if err != nil { + client.Close(err) + return nil, err + } + + // protect from sending ICE candidate before Offer + var sendOffer core.Waiter + + // protect from blocking on errors + defer sendOffer.Done(nil) + + // Create new WebRTC connection + client.conn = webrtc.NewConn(client.pc) + client.conn.FormatName = "tuya/webrtc" + client.conn.Mode = core.ModeActiveProducer + client.conn.Protocol = "mqtt" + + mqttClient := client.api.GetMqtt() + if mqttClient == nil { + err = errors.New("tuya: no mqtt client") + client.Close(err) + return nil, err + } + + // Set up MQTT handlers + mqttClient.handleAnswer = func(answer AnswerFrame) { + // fmt.Printf("tuya: answer: %s\n", answer.Sdp) + + desc := pion.SessionDescription{ + Type: pion.SDPTypePranswer, + SDP: answer.Sdp, + } + + if err = client.pc.SetRemoteDescription(desc); err != nil { + client.Close(err) + return + } + + if err = client.conn.SetAnswer(answer.Sdp); err != nil { + client.Close(err) + return + } + + if client.isHEVC { + // Tuya responds with H264/90000 even for HEVC streams + // So we need to replace video codecs with HEVC ones from API + for _, media := range client.conn.Medias { + if media.Kind == core.KindVideo { + codecs := client.api.GetVideoCodecs() + if codecs != nil { + media.Codecs = codecs + } + } + } + + // Audio codecs from API as well + // Tuya responds with multiple audio codecs (PCMU, PCMA) + // But the quality is bad if we use PCMU and skill only has PCMA + for _, media := range client.conn.Medias { + if media.Kind == core.KindAudio { + codecs := client.api.GetAudioCodecs() + if codecs != nil { + media.Codecs = codecs + } + } + } + } + } + + mqttClient.handleCandidate = func(candidate CandidateFrame) { + // fmt.Printf("tuya: candidate: %s\n", candidate.Candidate) + + if candidate.Candidate != "" { + client.conn.AddCandidate(candidate.Candidate) + if err != nil { + client.Close(err) + } + } + } + + mqttClient.handleDisconnect = func() { + // fmt.Println("tuya: disconnect") + client.Close(errors.New("mqtt: disconnect")) + } + + mqttClient.handleError = func(err error) { + // fmt.Printf("tuya: error: %s\n", err.Error()) + client.Close(err) + } + + if client.isHEVC { + maxRetransmits := uint16(5) + ordered := true + client.dc, err = client.pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{ + MaxRetransmits: &maxRetransmits, + Ordered: &ordered, + }) + + // DataChannel receives two types of messages: + // 1. String messages: Control messages (codec, recv) + // 2. Binary messages: RTP packets with video/audio + client.dc.OnMessage(func(msg pion.DataChannelMessage) { + if msg.IsString { + // Handle control messages (codec, recv, etc.) + if connected, err := client.probe(msg); err != nil { + client.Close(err) + } else if connected { + client.connected.Done(nil) + } + } else { + // Handle RTP packets - Route by SSRC retrieved from "recv" message + packet := &rtp.Packet{} + if err := packet.Unmarshal(msg.Data); err != nil { + // Skip invalid packets + return + } + + if handler, ok := client.getHandler(packet.SSRC); ok { + handler(packet) + } + } + }) + + client.dc.OnError(func(err error) { + // fmt.Printf("tuya: datachannel error: %s\n", err.Error()) + client.Close(err) + }) + + client.dc.OnClose(func() { + // fmt.Println("tuya: datachannel closed") + client.Close(errors.New("datachannel: closed")) + }) + + client.dc.OnOpen(func() { + // fmt.Println("tuya: datachannel opened") + + codecRequest, _ := json.Marshal(DataChannelMessage{ + Type: "codec", + Msg: "", + }) + + if err := client.sendMessageToDataChannel(codecRequest); err != nil { + client.Close(fmt.Errorf("failed to send codec request: %w", err)) + } + }) + } + + // Set up pc handler + client.conn.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendOffer.Wait() + if err := mqttClient.SendCandidate("a=" + msg.ToJSON().Candidate); err != nil { + client.Close(err) + } + + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateNew: + break + case pion.PeerConnectionStateConnecting: + break + case pion.PeerConnectionStateConnected: + // On HEVC, wait for DataChannel to be opened and camera to send codec info + if !client.isHEVC { + if streamResolution == "hd" { + _ = mqttClient.SendResolution(0) + } + client.connected.Done(nil) + } + case pion.PeerConnectionStateClosed: + client.Close(errors.New("webrtc: " + msg.String())) + default: + // client.Close(errors.New("webrtc: " + msg.String())) + } + } + }) + + // Audio first, otherwise tuya will send corrupt sdp + medias := []*core.Media{ + {Kind: core.KindAudio, Direction: core.DirectionSendRecv}, + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + } + + // Create offer + offer, err := client.conn.CreateOffer(medias) + if err != nil { + client.Close(err) + return nil, err + } + + // horter sdp, remove a=extmap... line, device ONLY allow 8KB json payload + // https://github.com/tuya/webrtc-demo-go/blob/04575054f18ccccb6bc9d82939dd46d449544e20/static/js/main.js#L224 + re := regexp.MustCompile(`\r\na=extmap[^\r\n]*`) + offer = re.ReplaceAllString(offer, "") + + // Send offer + if err := mqttClient.SendOffer(offer, streamResolution, client.streamType, client.isHEVC); err != nil { + err = fmt.Errorf("tuya: %w", err) + client.Close(err) + return nil, err + } + + sendOffer.Done(nil) + + // Wait for connection + if err = client.connected.Wait(); err != nil { + err = fmt.Errorf("tuya: %w", err) + client.Close(err) + return nil, err + } + + return client, nil +} + +func (c *Client) GetMedias() []*core.Media { + return c.conn.GetMedias() +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.conn.GetTrack(media, codec) +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + localTrack := c.conn.GetSenderTrack(media.ID) + if localTrack == nil { + return errors.New("webrtc: can't get track") + } + + // DISABLED: Speaker Protocol 312 command + // JavaScript client doesn't send this on first call either + // Only subsequent calls (when speakerChloron is set) send Protocol 312 + // mqttClient := c.api.GetMqtt() + // if mqttClient != nil { + // _ = mqttClient.SendSpeaker(1) + // } + + payloadType := codec.PayloadType + + sender := core.NewSender(media, codec) + + switch track.Codec.Name { + case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML: + // Frame size affects audio delay with Tuya cameras: + // Browser sends standard 20ms frames (160 bytes for G.711), but this causes + // up to 4s delay on some Tuya cameras. Increasing to 240 bytes (30ms) reduces + // delay to ~2s. Higher values (320+ bytes) don't work and cause issues. + // Using 240 bytes (30ms) as optimal balance between latency and stability. + frameSize := 240 + + var buf []byte + var seq uint16 + var ts uint32 + + sender.Handler = func(packet *rtp.Packet) { + buf = append(buf, packet.Payload...) + + for len(buf) >= frameSize { + payload := buf[:frameSize] + + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + PayloadType: payloadType, + SequenceNumber: seq, + Timestamp: ts, + SSRC: packet.SSRC, + }, + Payload: payload, + } + + seq++ + ts += uint32(frameSize) + buf = buf[frameSize:] + + c.conn.Send += pkt.MarshalSize() + _ = localTrack.WriteRTP(payloadType, pkt) + } + } + + default: + sender.Handler = func(packet *rtp.Packet) { + c.conn.Send += packet.MarshalSize() + _ = localTrack.WriteRTP(payloadType, packet) + } + } + + sender.HandleRTP(track) + c.conn.Senders = append(c.conn.Senders, sender) + + return nil +} + +func (c *Client) Start() error { + if len(c.conn.Receivers) == 0 { + return errors.New("tuya: no receivers") + } + + var video, audio *core.Receiver + for _, receiver := range c.conn.Receivers { + if receiver.Codec.IsVideo() { + video = receiver + } else if receiver.Codec.IsAudio() { + audio = receiver + } + } + + if c.videoSSRC != nil { + c.setHandler(*c.videoSSRC, func(packet *rtp.Packet) { + if video != nil { + video.WriteRTP(packet) + } + }) + } + + if c.audioSSRC != nil { + c.setHandler(*c.audioSSRC, func(packet *rtp.Packet) { + if audio != nil { + audio.WriteRTP(packet) + } + }) + } + + return c.conn.Start() +} + +func (c *Client) Stop() error { + if c.closed { + return nil + } + + c.closed = true + + c.clearHandlers() + + if c.conn != nil { + _ = c.conn.Stop() + } + + if c.api != nil { + c.api.Close() + } + + return nil +} + +func (c *Client) Close(err error) error { + c.connected.Done(err) + return c.Stop() +} + +func (c *Client) MarshalJSON() ([]byte, error) { + return c.conn.MarshalJSON() +} + +func (c *Client) setHandler(ssrc uint32, handler func(*rtp.Packet)) { + c.handlersMu.Lock() + defer c.handlersMu.Unlock() + c.handlers[ssrc] = handler +} + +func (c *Client) getHandler(ssrc uint32) (func(*rtp.Packet), bool) { + c.handlersMu.RLock() + defer c.handlersMu.RUnlock() + handler, ok := c.handlers[ssrc] + return handler, ok +} + +func (c *Client) clearHandlers() { + c.handlersMu.Lock() + defer c.handlersMu.Unlock() + for ssrc := range c.handlers { + delete(c.handlers, ssrc) + } +} + +func (c *Client) probe(msg pion.DataChannelMessage) (bool, error) { + // fmt.Printf("[tuya] Received string message: %s\n", string(msg.Data)) + + var message DataChannelMessage + if err := json.Unmarshal([]byte(msg.Data), &message); err != nil { + return false, err + } + + switch message.Type { + case "codec": + // Camera responded to our codec request - now request frame start + frameRequest, _ := json.Marshal(DataChannelMessage{ + Type: "start", + Msg: "frame", + }) + + err := c.sendMessageToDataChannel(frameRequest) + if err != nil { + return false, err + } + + case "recv": + // Camera sends SSRC values for video/audio streams + // We need these to route incoming RTP packets correctly + var recvMessage RecvMessage + if err := json.Unmarshal([]byte(message.Msg), &recvMessage); err != nil { + return false, err + } + + videoSSRC := recvMessage.Video.SSRC + audioSSRC := recvMessage.Audio.SSRC + c.videoSSRC = &videoSSRC + c.audioSSRC = &audioSSRC + + // Send "complete" to tell camera we're ready to receive RTP packets + completeMsg, _ := json.Marshal(DataChannelMessage{ + Type: "complete", + Msg: "", + }) + + err := c.sendMessageToDataChannel(completeMsg) + if err != nil { + return false, err + } + + return true, nil + } + + return false, nil +} + +func (c *Client) sendMessageToDataChannel(message []byte) error { + if c.dc != nil { + // fmt.Printf("[tuya] sending message to data channel: %s\n", message) + return c.dc.Send(message) + } + + return nil +} diff --git a/pkg/tuya/cloud_api.go b/pkg/tuya/cloud_api.go new file mode 100644 index 00000000..c34d0fe4 --- /dev/null +++ b/pkg/tuya/cloud_api.go @@ -0,0 +1,322 @@ +package tuya + +import ( + "bytes" + "crypto/md5" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/google/uuid" +) + +type Token struct { + UID string `json:"uid"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpireTime int64 `json:"expire_time"` +} + +type WebRTCConfigResponse struct { + Timestamp int64 `json:"t"` + Success bool `json:"success"` + Result WebRTCConfig `json:"result"` + Msg string `json:"msg,omitempty"` + Code int `json:"code,omitempty"` +} + +type TokenResponse struct { + Timestamp int64 `json:"t"` + Success bool `json:"success"` + Result Token `json:"result"` + Msg string `json:"msg,omitempty"` + Code int `json:"code,omitempty"` +} + +type OpenIoTHubConfigRequest struct { + UID string `json:"uid"` + UniqueID string `json:"unique_id"` + LinkType string `json:"link_type"` + Topics string `json:"topics"` +} + +type OpenIoTHubConfig struct { + Url string `json:"url"` + ClientID string `json:"client_id"` + Username string `json:"username"` + Password string `json:"password"` + SinkTopic struct { + IPC string `json:"ipc"` + } `json:"sink_topic"` + SourceSink struct { + IPC string `json:"ipc"` + } `json:"source_topic"` + ExpireTime int `json:"expire_time"` +} + +type OpenIoTHubConfigResponse struct { + Timestamp int `json:"t"` + Success bool `json:"success"` + Result OpenIoTHubConfig `json:"result"` + Msg string `json:"msg,omitempty"` + Code int `json:"code,omitempty"` +} + +type TuyaCloudApiClient struct { + TuyaClient + uid string + clientId string + clientSecret string + accessToken string + refreshToken string + refreshingToken bool +} + +func NewTuyaCloudApiClient(baseUrl, uid, deviceId, clientId, clientSecret string) (*TuyaCloudApiClient, error) { + mqttClient := NewTuyaMqttClient(deviceId) + + client := &TuyaCloudApiClient{ + TuyaClient: TuyaClient{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + mqtt: mqttClient, + deviceId: deviceId, + expireTime: 0, + baseUrl: baseUrl, + }, + uid: uid, + clientId: clientId, + clientSecret: clientSecret, + refreshingToken: false, + } + + return client, nil +} + +// WebRTC Flow +func (c *TuyaCloudApiClient) Init() error { + if err := c.initToken(); err != nil { + return fmt.Errorf("failed to initialize token: %w", err) + } + + webrtcConfig, err := c.loadWebrtcConfig() + if err != nil { + return fmt.Errorf("failed to load webrtc config: %w", err) + } + + hubConfig, err := c.loadHubConfig() + if err != nil { + return fmt.Errorf("failed to load hub config: %w", err) + } + + if err := c.mqtt.Start(hubConfig, webrtcConfig, c.skill.WebRTC); err != nil { + return fmt.Errorf("failed to start MQTT: %w", err) + } + + if c.skill.LowPower > 0 { + _ = c.mqtt.WakeUp(c.localKey) + } + + return nil +} + +func (c *TuyaCloudApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { + if err := c.initToken(); err != nil { + return "", fmt.Errorf("failed to initialize token: %w", err) + } + + url := fmt.Sprintf("https://%s/v1.0/devices/%s/stream/actions/allocate", c.baseUrl, c.deviceId) + + request := &AllocateRequest{ + Type: streamType, + } + + body, err := c.request("POST", url, request) + if err != nil { + return "", err + } + + var allocResponse AllocateResponse + err = json.Unmarshal(body, &allocResponse) + if err != nil { + return "", err + } + + if !allocResponse.Success { + return "", errors.New(allocResponse.Msg) + } + + return allocResponse.Result.URL, nil +} + +func (c *TuyaCloudApiClient) initToken() (err error) { + if c.refreshingToken { + return nil + } + + now := time.Now().Unix() + if (c.expireTime - 60) > now { + return nil + } + + c.refreshingToken = true + + url := fmt.Sprintf("https://%s/v1.0/token?grant_type=1", c.baseUrl) + + c.accessToken = "" + c.refreshToken = "" + + body, err := c.request("GET", url, nil) + if err != nil { + return err + } + + var tokenResponse TokenResponse + err = json.Unmarshal(body, &tokenResponse) + if err != nil { + return err + } + + if !tokenResponse.Success { + return errors.New(tokenResponse.Msg) + } + + c.accessToken = tokenResponse.Result.AccessToken + c.refreshToken = tokenResponse.Result.RefreshToken + c.expireTime = tokenResponse.Timestamp + tokenResponse.Result.ExpireTime + c.refreshingToken = false + + return nil +} + +func (c *TuyaCloudApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { + url := fmt.Sprintf("https://%s/v1.0/users/%s/devices/%s/webrtc-configs", c.baseUrl, c.uid, c.deviceId) + + body, err := c.request("GET", url, nil) + if err != nil { + return nil, err + } + + var webRTCConfigResponse WebRTCConfigResponse + err = json.Unmarshal(body, &webRTCConfigResponse) + if err != nil { + return nil, err + } + + if !webRTCConfigResponse.Success { + return nil, fmt.Errorf(webRTCConfigResponse.Msg) + } + + err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &c.skill) + if err != nil { + return nil, err + } + + // Store LocalKey (not sure if cloud api provides this, but we need it for low power cameras) + c.localKey = webRTCConfigResponse.Result.LocalKey + + iceServers, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) + if err != nil { + return nil, err + } + + c.iceServers, err = webrtc.UnmarshalICEServers(iceServers) + if err != nil { + return nil, err + } + + return &webRTCConfigResponse.Result, nil +} + +func (c *TuyaCloudApiClient) loadHubConfig() (config *MQTTConfig, err error) { + url := fmt.Sprintf("https://%s/v2.0/open-iot-hub/access/config", c.baseUrl) + + request := &OpenIoTHubConfigRequest{ + UID: c.uid, + UniqueID: uuid.New().String(), + LinkType: "mqtt", + Topics: "ipc", + } + + body, err := c.request("POST", url, request) + if err != nil { + return nil, err + } + + var openIoTHubConfigResponse OpenIoTHubConfigResponse + err = json.Unmarshal(body, &openIoTHubConfigResponse) + if err != nil { + return nil, err + } + + if !openIoTHubConfigResponse.Success { + return nil, fmt.Errorf(openIoTHubConfigResponse.Msg) + } + + return &MQTTConfig{ + Url: openIoTHubConfigResponse.Result.Url, + Username: openIoTHubConfigResponse.Result.Username, + Password: openIoTHubConfigResponse.Result.Password, + ClientID: openIoTHubConfigResponse.Result.ClientID, + PublishTopic: openIoTHubConfigResponse.Result.SinkTopic.IPC, + SubscribeTopic: openIoTHubConfigResponse.Result.SourceSink.IPC, + }, nil +} + +func (c *TuyaCloudApiClient) request(method string, url string, body any) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, err + } + + ts := time.Now().UnixNano() / 1000000 + sign := c.calBusinessSign(ts) + + req.Header.Set("Accept", "*") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Access-Control-Allow-Origin", "*") + req.Header.Set("Access-Control-Allow-Methods", "*") + req.Header.Set("Access-Control-Allow-Headers", "*") + req.Header.Set("mode", "no-cors") + req.Header.Set("client_id", c.clientId) + req.Header.Set("access_token", c.accessToken) + req.Header.Set("sign", sign) + req.Header.Set("t", strconv.FormatInt(ts, 10)) + + response, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + + res, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, err + } + + return res, nil +} + +func (c *TuyaCloudApiClient) calBusinessSign(ts int64) string { + data := fmt.Sprintf("%s%s%s%d", c.clientId, c.accessToken, c.clientSecret, ts) + val := md5.Sum([]byte(data)) + res := fmt.Sprintf("%X", val) + return res +} diff --git a/pkg/tuya/helper.go b/pkg/tuya/helper.go new file mode 100644 index 00000000..7c9eb410 --- /dev/null +++ b/pkg/tuya/helper.go @@ -0,0 +1,69 @@ +package tuya + +import ( + "crypto/md5" + cryptoRand "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "net/http" + "net/http/cookiejar" + "regexp" + "time" + + "golang.org/x/net/publicsuffix" +) + +func EncryptPassword(password, pbKey string) (string, error) { + // Hash password with MD5 + hasher := md5.New() + hasher.Write([]byte(password)) + hashedPassword := hex.EncodeToString(hasher.Sum(nil)) + + // Decode PEM public key + block, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\n" + pbKey + "\n-----END PUBLIC KEY-----")) + if block == nil { + return "", errors.New("failed to decode PEM block") + } + + pubKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return "", err + } + + rsaPubKey, ok := pubKey.(*rsa.PublicKey) + if !ok { + return "", errors.New("not an RSA public key") + } + + // Encrypt with RSA + encrypted, err := rsa.EncryptPKCS1v15(cryptoRand.Reader, rsaPubKey, []byte(hashedPassword)) + if err != nil { + return "", err + } + + // Convert to hex string + return hex.EncodeToString(encrypted), nil +} + +func IsEmailAddress(input string) bool { + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + return emailRegex.MatchString(input) +} + +func CreateHTTPClientWithSession() *http.Client { + jar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + + if err != nil { + return nil + } + + return &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + } +} diff --git a/pkg/tuya/interface.go b/pkg/tuya/interface.go new file mode 100644 index 00000000..25ba0ddd --- /dev/null +++ b/pkg/tuya/interface.go @@ -0,0 +1,270 @@ +package tuya + +import ( + "net/http" + + "github.com/AlexxIT/go2rtc/pkg/core" + pionWebrtc "github.com/pion/webrtc/v4" +) + +type TuyaAPI interface { + GetMqtt() *TuyaMqttClient + + GetStreamType(streamResolution string) int + IsHEVC(streamType int) bool + + GetVideoCodecs() []*core.Codec + GetAudioCodecs() []*core.Codec + + GetStreamUrl(streamUrl string) (string, error) + GetICEServers() []pionWebrtc.ICEServer + + Init() error + Close() +} + +type TuyaClient struct { + TuyaAPI + + httpClient *http.Client + mqtt *TuyaMqttClient + baseUrl string + expireTime int64 + deviceId string + localKey string + skill *Skill + iceServers []pionWebrtc.ICEServer +} + +type AudioAttributes struct { + CallMode []int `json:"call_mode"` // 1 = one way, 2 = two way + HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker +} + +type ICEServer struct { + Urls string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` + TTL int `json:"ttl,omitempty"` +} + +type WebICE struct { + Urls string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` +} + +type P2PConfig struct { + Ices []ICEServer `json:"ices"` +} + +type AudioSkill struct { + Channels int `json:"channels"` + DataBit int `json:"dataBit"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` +} + +type VideoSkill struct { + StreamType int `json:"streamType"` // 2 = main stream (HD), 4 = sub stream (SD) + CodecType int `json:"codecType"` // 2 = H264, 4 = H265 (HEVC) + Width int `json:"width"` + Height int `json:"height"` + SampleRate int `json:"sampleRate"` + ProfileId string `json:"profileId,omitempty"` +} + +type Skill struct { + WebRTC int `json:"webrtc"` // Bit flags: bit 4=speaker, bit 5=clarity, bit 6=record + LowPower int `json:"lowPower,omitempty"` // 1 = battery-powered camera + Audios []AudioSkill `json:"audios"` + Videos []VideoSkill `json:"videos"` +} + +type WebRTCConfig struct { + AudioAttributes AudioAttributes `json:"audio_attributes"` + Auth string `json:"auth"` + ID string `json:"id"` + LocalKey string `json:"local_key,omitempty"` + MotoID string `json:"moto_id"` + P2PConfig P2PConfig `json:"p2p_config"` + ProtocolVersion string `json:"protocol_version"` + Skill string `json:"skill"` + SupportsWebRTCRecord bool `json:"supports_webrtc_record"` + SupportsWebRTC bool `json:"supports_webrtc"` + VedioClaritiy int `json:"vedio_clarity"` + VideoClaritiy int `json:"video_clarity"` + VideoClarities []int `json:"video_clarities"` +} + +type MQTTConfig struct { + Url string `json:"url"` + PublishTopic string `json:"publish_topic"` + SubscribeTopic string `json:"subscribe_topic"` + ClientID string `json:"client_id"` + Username string `json:"username"` + Password string `json:"password"` +} + +type Allocate struct { + URL string `json:"url"` +} + +type AllocateRequest struct { + Type string `json:"type"` +} + +type AllocateResponse struct { + Success bool `json:"success"` + Result Allocate `json:"result"` + Msg string `json:"msg,omitempty"` +} + +func (c *TuyaClient) GetICEServers() []pionWebrtc.ICEServer { + return c.iceServers +} + +func (c *TuyaClient) GetMqtt() *TuyaMqttClient { + return c.mqtt +} + +// GetStreamType returns the Skill StreamType for the requested resolution +// Returns Skill values (2 or 4), not MQTT values (0 or 1) +// - "hd" → highest resolution streamType (usually 2 = mainStream) +// - "sd" → lowest resolution streamType (usually 4 = substream) +// +// These values must be mapped before sending to MQTT: +// - streamType 2 → MQTT stream_type 0 +// - streamType 4 → MQTT stream_type 1 +func (c *TuyaClient) GetStreamType(streamResolution string) int { + // Default streamType if nothing is found + defaultStreamType := 1 + + if c.skill == nil || len(c.skill.Videos) == 0 { + return defaultStreamType + } + + // Find the highest and lowest resolution based on pixel count + var highestResType = defaultStreamType + var highestRes = 0 + var lowestResType = defaultStreamType + var lowestRes = 0 + + for _, video := range c.skill.Videos { + res := video.Width * video.Height + + // Highest Resolution + if res > highestRes { + highestRes = res + highestResType = video.StreamType + } + + // Lower Resolution (or first if not set yet) + if lowestRes == 0 || res < lowestRes { + lowestRes = res + lowestResType = video.StreamType + } + } + + // Return the streamType based on the selection + switch streamResolution { + case "hd": + return highestResType + case "sd": + return lowestResType + default: + return defaultStreamType + } +} + +// IsHEVC checks if the given streamType uses H265 (HEVC) codec +// HEVC cameras use DataChannel, H264 cameras use RTP tracks +// - codecType 4 = H265 (HEVC) → DataChannel mode +// - codecType 2 = H264 → Normal RTP mode +func (c *TuyaClient) IsHEVC(streamType int) bool { + for _, video := range c.skill.Videos { + if video.StreamType == streamType { + return video.CodecType == 4 // 4 = H265/HEVC + } + } + + return false +} + +func (c *TuyaClient) GetVideoCodecs() []*core.Codec { + if len(c.skill.Videos) > 0 { + codecs := make([]*core.Codec, 0) + + for _, video := range c.skill.Videos { + name := core.CodecH264 + if c.IsHEVC(video.StreamType) { + name = core.CodecH265 + } + + codec := &core.Codec{ + Name: name, + ClockRate: uint32(video.SampleRate), + } + + codecs = append(codecs, codec) + } + + if len(codecs) > 0 { + return codecs + } + } + + return nil +} + +func (c *TuyaClient) GetAudioCodecs() []*core.Codec { + if len(c.skill.Audios) > 0 { + codecs := make([]*core.Codec, 0) + + for _, audio := range c.skill.Audios { + name := getAudioCodecName(&audio) + + codec := &core.Codec{ + Name: name, + ClockRate: uint32(audio.SampleRate), + Channels: uint8(audio.Channels), + } + codecs = append(codecs, codec) + } + + if len(codecs) > 0 { + return codecs + } + } + + return nil +} + +func (c *TuyaClient) Close() { + c.mqtt.Stop() + c.httpClient.CloseIdleConnections() +} + +// https://protect-us.ismartlife.me/ +func getAudioCodecName(audioSkill *AudioSkill) string { + switch audioSkill.CodecType { + // case 100: + // return "ADPCM" + case 101: + return core.CodecPCML + case 102, 103, 104: + return core.CodecAAC + case 105: + return core.CodecPCMU + case 106: + return core.CodecPCMA + // case 107: + // return "G726-32" + // case 108: + // return "SPEEX" + case 109: + return core.CodecMP3 + default: + return core.CodecPCML + } +} diff --git a/pkg/tuya/mqtt.go b/pkg/tuya/mqtt.go new file mode 100644 index 00000000..5f64ef48 --- /dev/null +++ b/pkg/tuya/mqtt.go @@ -0,0 +1,436 @@ +package tuya + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash/crc32" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +type TuyaMqttClient struct { + client mqtt.Client + waiter core.Waiter + wakeupWaiter core.Waiter + speakerWaiter core.Waiter + publishTopic string + subscribeTopic string + auth string + iceServers []ICEServer + uid string + motoId string + deviceId string + sessionId string + closed bool + webrtcVersion int + handleAnswer func(answer AnswerFrame) + handleCandidate func(candidate CandidateFrame) + handleDisconnect func() + handleError func(err error) +} + +type MqttFrameHeader struct { + Type string `json:"type"` + From string `json:"from"` + To string `json:"to"` + SubDevID string `json:"sub_dev_id"` + SessionID string `json:"sessionid"` + MotoID string `json:"moto_id"` + TransactionID string `json:"tid"` +} + +type MqttFrame struct { + Header MqttFrameHeader `json:"header"` + Message json.RawMessage `json:"msg"` +} + +type OfferFrame struct { + Mode string `json:"mode"` + Sdp string `json:"sdp"` + StreamType int `json:"stream_type"` // 0: mainStream(HD), 1: substream(SD) + Auth string `json:"auth"` + DatachannelEnable bool `json:"datachannel_enable"` // true for HEVC, false for H264 + Token []ICEServer `json:"token"` +} + +type AnswerFrame struct { + Mode string `json:"mode"` + Sdp string `json:"sdp"` +} + +type CandidateFrame struct { + Mode string `json:"mode"` + Candidate string `json:"candidate"` +} + +type ResolutionFrame struct { + Mode string `json:"mode"` + Value int `json:"cmdValue"` // 0: HD, 1: SD +} + +type SpeakerFrame struct { + Mode string `json:"mode"` + Value int `json:"cmdValue"` // 0: off, 1: on +} + +type DisconnectFrame struct { + Mode string `json:"mode"` +} + +type MqttLowPowerMessage struct { + Protocol int `json:"protocol"` + T int `json:"t"` + S int `json:"s,omitempty"` + Type string `json:"type,omitempty"` + Data struct { + DevID string `json:"devId,omitempty"` + Online bool `json:"online,omitempty"` + LastOnlineChangeTime int64 `json:"lastOnlineChangeTime,omitempty"` + GwID string `json:"gwId,omitempty"` + Cmd string `json:"cmd,omitempty"` + Dps map[string]interface{} `json:"dps,omitempty"` + } `json:"data"` +} + +type MqttMessage struct { + Protocol int `json:"protocol"` + Pv string `json:"pv"` + T int64 `json:"t"` + Data MqttFrame `json:"data"` +} + +func NewTuyaMqttClient(deviceId string) *TuyaMqttClient { + return &TuyaMqttClient{ + deviceId: deviceId, + sessionId: core.RandString(6, 62), + waiter: core.Waiter{}, + wakeupWaiter: core.Waiter{}, + } +} + +func (c *TuyaMqttClient) Start(hubConfig *MQTTConfig, webrtcConfig *WebRTCConfig, webrtcVersion int) error { + c.webrtcVersion = webrtcVersion + c.motoId = webrtcConfig.MotoID + c.auth = webrtcConfig.Auth + c.iceServers = webrtcConfig.P2PConfig.Ices + + c.publishTopic = hubConfig.PublishTopic + c.subscribeTopic = hubConfig.SubscribeTopic + + c.publishTopic = strings.Replace(c.publishTopic, "moto_id", c.motoId, 1) + c.publishTopic = strings.Replace(c.publishTopic, "{device_id}", c.deviceId, 1) + + parts := strings.Split(c.subscribeTopic, "/") + c.uid = parts[3] + + opts := mqtt.NewClientOptions().AddBroker(hubConfig.Url). + SetClientID(hubConfig.ClientID). + SetUsername(hubConfig.Username). + SetPassword(hubConfig.Password). + SetOnConnectHandler(c.onConnect). + SetAutoReconnect(true). + SetMaxReconnectInterval(30 * time.Second). + SetConnectTimeout(30 * time.Second). + SetKeepAlive(60 * time.Second). + SetPingTimeout(20 * time.Second) + + c.client = mqtt.NewClient(opts) + + if token := c.client.Connect(); token.Wait() && token.Error() != nil { + return token.Error() + } + + if err := c.waiter.Wait(); err != nil { + return err + } + + return nil +} + +func (c *TuyaMqttClient) Stop() { + c.waiter.Done(errors.New("mqtt: stopped")) + c.wakeupWaiter.Done(errors.New("mqtt: stopped")) + c.speakerWaiter.Done(errors.New("mqtt: stopped")) + + if c.client != nil { + _ = c.SendDisconnect() + c.client.Disconnect(100) + } + + c.closed = true +} + +// WakeUp sends a wake-up signal to battery-powered cameras (LowPower mode). +// The camera wakes up and starts responding immediately - we don't wait for dps[149]. +// Note: LowPower cameras sleep after ~3 minutes of inactivity. +func (c *TuyaMqttClient) WakeUp(localKey string) error { + // Calculate CRC32 of localKey as wake-up payload + crc := crc32.ChecksumIEEE([]byte(localKey)) + + // Convert to hex string + hexStr := fmt.Sprintf("%08x", crc) + + // Convert hex string to byte array (2 chars at a time) + payload := make([]byte, len(hexStr)/2) + for i := 0; i < len(hexStr); i += 2 { + b, err := hex.DecodeString(hexStr[i : i+2]) + if err != nil { + return fmt.Errorf("failed to decode hex: %w", err) + } + payload[i/2] = b[0] + } + + // Publish to wake-up topic: m/w/{deviceId} + wakeUpTopic := fmt.Sprintf("m/w/%s", c.deviceId) + token := c.client.Publish(wakeUpTopic, 1, false, payload) + if token.Wait() && token.Error() != nil { + return fmt.Errorf("failed to publish wake-up message: %w", token.Error()) + } + + // Subscribe to lowPower topic to receive dps[149] status updates + // (we don't wait for this signal - camera responds immediately) + lowPowerTopic := fmt.Sprintf("smart/decrypt/in/%s", c.deviceId) + if token := c.client.Subscribe(lowPowerTopic, 1, c.onLowPowerMessage); token.Wait() && token.Error() != nil { + return fmt.Errorf("failed to subscribe to lowPower topic: %w", token.Error()) + } + + return nil +} + +func (c *TuyaMqttClient) SendOffer(sdp string, streamResolution string, streamType int, isHEVC bool) error { + // Map Skill StreamType to MQTT stream_type values + // streamType comes from GetStreamType() and uses Skill StreamType values: + // - mainStream = 2 (HD) + // - substream = 4 (SD) + // + // But MQTT expects mapped stream_type values: + // - mainStream (2) → stream_type: 0 + // - substream (4) → stream_type: 1 + + mqttStreamType := streamType + switch streamType { + case 2: + mqttStreamType = 0 // mainStream (HD) + case 4: + mqttStreamType = 1 // substream (SD) + } + + return c.sendMqttMessage("offer", 302, "", OfferFrame{ + Mode: "webrtc", + Sdp: sdp, + StreamType: mqttStreamType, + Auth: c.auth, + DatachannelEnable: isHEVC, // must be true for HEVC + Token: c.iceServers, + }) +} + +func (c *TuyaMqttClient) SendCandidate(candidate string) error { + return c.sendMqttMessage("candidate", 302, "", CandidateFrame{ + Mode: "webrtc", + Candidate: candidate, + }) +} + +func (c *TuyaMqttClient) SendResolution(resolution int) error { + // Check if camera supports clarity switching + isClaritySupported := (c.webrtcVersion & (1 << 5)) != 0 + if !isClaritySupported { + return nil + } + + return c.sendMqttMessage("resolution", 312, "", ResolutionFrame{ + Mode: "webrtc", + Value: resolution, // 0: HD, 1: SD + }) +} + +func (c *TuyaMqttClient) SendSpeaker(speaker int) error { + if err := c.sendMqttMessage("speaker", 312, "", SpeakerFrame{ + Mode: "webrtc", + Value: speaker, // 0: off, 1: on + }); err != nil { + return err + } + + // Wait for camera response + if err := c.speakerWaiter.Wait(); err != nil { + return fmt.Errorf("speaker wait failed: %w", err) + } + + return nil +} + +func (c *TuyaMqttClient) SendDisconnect() error { + return c.sendMqttMessage("disconnect", 302, "", DisconnectFrame{ + Mode: "webrtc", + }) +} + +func (c *TuyaMqttClient) onConnect(client mqtt.Client) { + if token := client.Subscribe(c.subscribeTopic, 1, c.onMessage); token.Wait() && token.Error() != nil { + c.waiter.Done(token.Error()) + return + } + + c.waiter.Done(nil) +} + +func (c *TuyaMqttClient) onMessage(client mqtt.Client, msg mqtt.Message) { + var rmqtt MqttMessage + if err := json.Unmarshal(msg.Payload(), &rmqtt); err != nil { + c.onError(err) + return + } + + // Filter by session ID to prevent processing messages from other sessions + if rmqtt.Data.Header.SessionID != c.sessionId { + return + } + + switch rmqtt.Data.Header.Type { + case "answer": + c.onMqttAnswer(&rmqtt) + case "candidate": + c.onMqttCandidate(&rmqtt) + case "disconnect": + c.onMqttDisconnect() + case "speaker": + c.onMqttSpeaker(&rmqtt) + } +} + +func (c *TuyaMqttClient) onLowPowerMessage(client mqtt.Client, msg mqtt.Message) { + var message MqttLowPowerMessage + if err := json.Unmarshal(msg.Payload(), &message); err != nil { + return + } + + // Check if protocol is 4 and dps[149] is true + // https://developer.tuya.com/en/docs/iot-device-dev/doorbell_solution?id=Kayamyivh15ox#title-2-Battery + if message.Protocol == 4 { + if val, ok := message.Data.Dps["149"]; ok { + if ready, ok := val.(bool); ok && ready { + // Camera is now ready after wake-up (dps[149]:true received). + // However, we don't wait for this signal (like ismartlife.me doesn't either). + // The camera starts responding immediately after WakeUp() is called, + // so we proceed with the connection without blocking. + // This waiter is kept for potential future use. + c.wakeupWaiter.Done(nil) + } + } + } +} + +func (c *TuyaMqttClient) onMqttAnswer(msg *MqttMessage) { + var answerFrame AnswerFrame + if err := json.Unmarshal(msg.Data.Message, &answerFrame); err != nil { + c.onError(err) + return + } + + c.onAnswer(answerFrame) +} + +func (c *TuyaMqttClient) onMqttCandidate(msg *MqttMessage) { + var candidateFrame CandidateFrame + if err := json.Unmarshal(msg.Data.Message, &candidateFrame); err != nil { + c.onError(err) + return + } + + // fix candidates + candidateFrame.Candidate = strings.TrimPrefix(candidateFrame.Candidate, "a=") + candidateFrame.Candidate = strings.TrimSuffix(candidateFrame.Candidate, "\r\n") + + c.onCandidate(candidateFrame) +} + +func (c *TuyaMqttClient) onMqttDisconnect() { + c.closed = true + c.onDisconnect() +} + +func (c *TuyaMqttClient) onMqttSpeaker(msg *MqttMessage) { + var speakerResponse struct { + ResCode int `json:"resCode"` + } + + if err := json.Unmarshal(msg.Data.Message, &speakerResponse); err == nil { + if speakerResponse.ResCode != 0 { + c.speakerWaiter.Done(fmt.Errorf("speaker failed with resCode: %d", speakerResponse.ResCode)) + return + } + } + + c.speakerWaiter.Done(nil) +} + +func (c *TuyaMqttClient) onAnswer(answer AnswerFrame) { + if c.handleAnswer != nil { + c.handleAnswer(answer) + } +} + +func (c *TuyaMqttClient) onCandidate(candidate CandidateFrame) { + if c.handleCandidate != nil { + c.handleCandidate(candidate) + } +} + +func (c *TuyaMqttClient) onDisconnect() { + if c.handleDisconnect != nil { + c.handleDisconnect() + } +} + +func (c *TuyaMqttClient) onError(err error) { + if c.handleError != nil { + c.handleError(err) + } +} + +func (c *TuyaMqttClient) sendMqttMessage(messageType string, protocol int, transactionID string, data interface{}) error { + if c.closed { + return fmt.Errorf("mqtt client is closed, send mqtt message fail") + } + + jsonMessage, err := json.Marshal(data) + if err != nil { + return err + } + + msg := &MqttMessage{ + Protocol: protocol, + Pv: "2.2", + T: time.Now().Unix(), + Data: MqttFrame{ + Header: MqttFrameHeader{ + Type: messageType, + From: c.uid, + To: c.deviceId, + SessionID: c.sessionId, + MotoID: c.motoId, + TransactionID: transactionID, + }, + Message: jsonMessage, + }, + } + + payload, err := json.Marshal(msg) + if err != nil { + return err + } + + token := c.client.Publish(c.publishTopic, 1, false, payload) + if token.Wait() && token.Error() != nil { + return token.Error() + } + + return nil +} diff --git a/pkg/tuya/smart_api.go b/pkg/tuya/smart_api.go new file mode 100644 index 00000000..09615db4 --- /dev/null +++ b/pkg/tuya/smart_api.go @@ -0,0 +1,597 @@ +package tuya + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "time" + + "github.com/AlexxIT/go2rtc/pkg/webrtc" +) + +type LoginTokenRequest struct { + CountryCode string `json:"countryCode"` + Username string `json:"username"` + IsUid bool `json:"isUid"` +} + +type LoginTokenResponse struct { + Result LoginToken `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type LoginToken struct { + Token string `json:"token"` + Exponent string `json:"exponent"` + PublicKey string `json:"publicKey"` + PbKey string `json:"pbKey"` +} + +type PasswordLoginRequest struct { + CountryCode string `json:"countryCode"` + Email string `json:"email,omitempty"` + Mobile string `json:"mobile,omitempty"` + Passwd string `json:"passwd"` + Token string `json:"token"` + IfEncrypt int `json:"ifencrypt"` + Options string `json:"options"` +} + +type PasswordLoginResponse struct { + Result LoginResult `json:"result"` + Success bool `json:"success"` + Status string `json:"status"` + ErrorMsg string `json:"errorMsg,omitempty"` +} + +type LoginResult struct { + Attribute int `json:"attribute"` + ClientId string `json:"clientId"` + DataVersion int `json:"dataVersion"` + Domain Domain `json:"domain"` + Ecode string `json:"ecode"` + Email string `json:"email"` + Extras Extras `json:"extras"` + HeadPic string `json:"headPic"` + ImproveCompanyInfo bool `json:"improveCompanyInfo"` + Nickname string `json:"nickname"` + PartnerIdentity string `json:"partnerIdentity"` + PhoneCode string `json:"phoneCode"` + Receiver string `json:"receiver"` + RegFrom int `json:"regFrom"` + Sid string `json:"sid"` + SnsNickname string `json:"snsNickname"` + TempUnit int `json:"tempUnit"` + Timezone string `json:"timezone"` + TimezoneId string `json:"timezoneId"` + Uid string `json:"uid"` + UserType int `json:"userType"` + Username string `json:"username"` +} + +type Domain struct { + AispeechHttpsUrl string `json:"aispeechHttpsUrl"` + AispeechQuicUrl string `json:"aispeechQuicUrl"` + DeviceHttpUrl string `json:"deviceHttpUrl"` + DeviceHttpsPskUrl string `json:"deviceHttpsPskUrl"` + DeviceHttpsUrl string `json:"deviceHttpsUrl"` + DeviceMediaMqttUrl string `json:"deviceMediaMqttUrl"` + DeviceMediaMqttsUrl string `json:"deviceMediaMqttsUrl"` + DeviceMqttsPskUrl string `json:"deviceMqttsPskUrl"` + DeviceMqttsUrl string `json:"deviceMqttsUrl"` + GwApiUrl string `json:"gwApiUrl"` + GwMqttUrl string `json:"gwMqttUrl"` + HttpPort int `json:"httpPort"` + HttpsPort int `json:"httpsPort"` + HttpsPskPort int `json:"httpsPskPort"` + MobileApiUrl string `json:"mobileApiUrl"` + MobileMediaMqttUrl string `json:"mobileMediaMqttUrl"` + MobileMqttUrl string `json:"mobileMqttUrl"` + MobileMqttsUrl string `json:"mobileMqttsUrl"` + MobileQuicUrl string `json:"mobileQuicUrl"` + MqttPort int `json:"mqttPort"` + MqttQuicUrl string `json:"mqttQuicUrl"` + MqttsPort int `json:"mqttsPort"` + MqttsPskPort int `json:"mqttsPskPort"` + RegionCode string `json:"regionCode"` +} + +type Extras struct { + HomeId string `json:"homeId"` + SceneType string `json:"sceneType"` +} + +type AppInfoResponse struct { + Result AppInfo `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type AppInfo struct { + AppId int `json:"appId"` + AppName string `json:"appName"` + ClientId string `json:"clientId"` + Icon string `json:"icon"` +} + +type MQTTConfigResponse struct { + Result SmartApiMQTTConfig `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SmartApiMQTTConfig struct { + Msid string `json:"msid"` + Password string `json:"password"` +} + +type HomeListResponse struct { + Result []Home `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SharedHomeListResponse struct { + Result SharedHome `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SharedHome struct { + SecurityWebCShareInfoList []struct { + DeviceInfoList []Device `json:"deviceInfoList"` + Nickname string `json:"nickname"` + Username string `json:"username"` + } `json:"securityWebCShareInfoList"` +} + +type Home struct { + Admin bool `json:"admin"` + Background string `json:"background"` + DealStatus int `json:"dealStatus"` + DisplayOrder int `json:"displayOrder"` + GeoName string `json:"geoName"` + Gid int `json:"gid"` + GmtCreate int64 `json:"gmtCreate"` + GmtModified int64 `json:"gmtModified"` + GroupId int `json:"groupId"` + GroupUserId int `json:"groupUserId"` + Id int `json:"id"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + ManagementStatus bool `json:"managementStatus"` + Name string `json:"name"` + OwnerId string `json:"ownerId"` + Role int `json:"role"` + Status bool `json:"status"` + Uid string `json:"uid"` +} + +type RoomListRequest struct { + HomeId string `json:"homeId"` +} + +type RoomListResponse struct { + Result []Room `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type Room struct { + DeviceCount int `json:"deviceCount"` + DeviceList []Device `json:"deviceList"` + RoomId string `json:"roomId"` + RoomName string `json:"roomName"` +} + +type Device struct { + Category string `json:"category"` + DeviceId string `json:"deviceId"` + DeviceName string `json:"deviceName"` + P2pType int `json:"p2pType"` + ProductId string `json:"productId"` + SupportCloudStorage bool `json:"supportCloudStorage"` + Uuid string `json:"uuid"` +} + +type SmartApiWebRTCConfigRequest struct { + DevId string `json:"devId"` + ClientTraceId string `json:"clientTraceId"` +} + +type SmartApiWebRTCConfigResponse struct { + Result SmartApiWebRTCConfig `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SmartApiWebRTCConfig struct { + AudioAttributes AudioAttributes `json:"audioAttributes"` + Auth string `json:"auth"` + GatewayId string `json:"gatewayId"` + Id string `json:"id"` + LocalKey string `json:"localKey"` + MotoId string `json:"motoId"` + NodeId string `json:"nodeId"` + P2PConfig P2PConfig `json:"p2pConfig"` + ProtocolVersion string `json:"protocolVersion"` + Skill string `json:"skill"` + Sub bool `json:"sub"` + SupportWebrtcRecord bool `json:"supportWebrtcRecord"` + SupportsPtz bool `json:"supportsPtz"` + SupportsWebrtc bool `json:"supportsWebrtc"` + VedioClarity int `json:"vedioClarity"` + VedioClaritys []int `json:"vedioClaritys"` + VideoClarity int `json:"videoClarity"` +} + +type TuyaSmartApiClient struct { + TuyaClient + + email string + password string + countryCode string + mqttsUrl string +} + +type Region struct { + Name string `json:"name"` + Host string `json:"host"` + Description string `json:"description"` + Continent string `json:"continent"` +} + +var AvailableRegions = []Region{ + {"eu-central", "protect-eu.ismartlife.me", "Central Europe", "EU"}, + {"eu-east", "protect-we.ismartlife.me", "East Europe", "EU"}, + {"us-west", "protect-us.ismartlife.me", "West America", "AZ"}, + {"us-east", "protect-ue.ismartlife.me", "East America", "AZ"}, + {"china", "protect.ismartlife.me", "China", "AY"}, + {"india", "protect-in.ismartlife.me", "India", "IN"}, +} + +func NewTuyaSmartApiClient(httpClient *http.Client, baseUrl, email, password, deviceId string) (*TuyaSmartApiClient, error) { + var region *Region + for _, r := range AvailableRegions { + if r.Host == baseUrl { + region = &r + break + } + } + + if region == nil { + return nil, fmt.Errorf("invalid region: %s", baseUrl) + } + + if httpClient == nil { + httpClient = CreateHTTPClientWithSession() + } + + mqttClient := NewTuyaMqttClient(deviceId) + + client := &TuyaSmartApiClient{ + TuyaClient: TuyaClient{ + httpClient: httpClient, + mqtt: mqttClient, + deviceId: deviceId, + expireTime: 0, + baseUrl: baseUrl, + }, + email: email, + password: password, + countryCode: region.Continent, + } + + return client, nil +} + +// WebRTC Flow +func (c *TuyaSmartApiClient) Init() error { + if err := c.initToken(); err != nil { + return fmt.Errorf("failed to initialize token: %w", err) + } + + webrtcConfig, err := c.loadWebrtcConfig() + if err != nil { + return fmt.Errorf("failed to load webrtc config: %w", err) + } + + hubConfig, err := c.loadHubConfig() + if err != nil { + return fmt.Errorf("failed to load hub config: %w", err) + } + + if err := c.mqtt.Start(hubConfig, webrtcConfig, c.skill.WebRTC); err != nil { + return fmt.Errorf("failed to start MQTT: %w", err) + } + + if c.skill.LowPower > 0 { + _ = c.mqtt.WakeUp(c.localKey) + } + + return nil +} + +func (c *TuyaSmartApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { + return "", errors.New("not supported") +} + +func (c *TuyaSmartApiClient) GetAppInfo() (*AppInfoResponse, error) { + url := fmt.Sprintf("https://%s/api/customized/web/app/info", c.baseUrl) + + body, err := c.request("POST", url, nil) + if err != nil { + return nil, err + } + + var appInfoResponse AppInfoResponse + if err := json.Unmarshal(body, &appInfoResponse); err != nil { + return nil, err + } + + if !appInfoResponse.Success { + return nil, errors.New(appInfoResponse.Msg) + } + + return &appInfoResponse, nil +} + +func (c *TuyaSmartApiClient) GetHomeList() (*HomeListResponse, error) { + url := fmt.Sprintf("https://%s/api/new/common/homeList", c.baseUrl) + + body, err := c.request("POST", url, nil) + if err != nil { + return nil, err + } + + var homeListResponse HomeListResponse + if err := json.Unmarshal(body, &homeListResponse); err != nil { + return nil, err + } + + if !homeListResponse.Success { + return nil, errors.New(homeListResponse.Msg) + } + + return &homeListResponse, nil +} + +func (c *TuyaSmartApiClient) GetSharedHomeList() (*SharedHomeListResponse, error) { + url := fmt.Sprintf("https://%s/api/new/playback/shareList", c.baseUrl) + + body, err := c.request("POST", url, nil) + if err != nil { + return nil, err + } + + var sharedHomeListResponse SharedHomeListResponse + if err := json.Unmarshal(body, &sharedHomeListResponse); err != nil { + return nil, err + } + + if !sharedHomeListResponse.Success { + return nil, errors.New(sharedHomeListResponse.Msg) + } + + return &sharedHomeListResponse, nil +} + +func (c *TuyaSmartApiClient) GetRoomList(homeId string) (*RoomListResponse, error) { + url := fmt.Sprintf("https://%s/api/new/common/roomList", c.baseUrl) + + data := RoomListRequest{ + HomeId: homeId, + } + + body, err := c.request("POST", url, data) + if err != nil { + return nil, err + } + + var roomListResponse RoomListResponse + if err := json.Unmarshal(body, &roomListResponse); err != nil { + return nil, err + } + + if !roomListResponse.Success { + return nil, errors.New(roomListResponse.Msg) + } + + return &roomListResponse, nil +} + +func (c *TuyaSmartApiClient) initToken() error { + tokenUrl := fmt.Sprintf("https://%s/api/login/token", c.baseUrl) + + tokenReq := LoginTokenRequest{ + CountryCode: c.countryCode, + Username: c.email, + IsUid: false, + } + + body, err := c.request("POST", tokenUrl, tokenReq) + if err != nil { + return err + } + + var tokenResp LoginTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return err + } + + if !tokenResp.Success { + return errors.New(tokenResp.Msg) + } + + encryptedPassword, err := EncryptPassword(c.password, tokenResp.Result.PbKey) + if err != nil { + return fmt.Errorf("failed to encrypt password: %v", err) + } + var loginUrl string + + loginReq := PasswordLoginRequest{ + CountryCode: c.countryCode, + Passwd: encryptedPassword, + Token: tokenResp.Result.Token, + IfEncrypt: 1, + Options: `{"group":1}`, + } + + if IsEmailAddress(c.email) { + loginUrl = fmt.Sprintf("https://%s/api/private/email/login", c.baseUrl) + loginReq.Email = c.email + } else { + loginUrl = fmt.Sprintf("https://%s/api/private/phone/login", c.baseUrl) + loginReq.Mobile = c.email + } + + body, err = c.request("POST", loginUrl, loginReq) + if err != nil { + return err + } + + var loginResp *PasswordLoginResponse + if err := json.Unmarshal(body, &loginResp); err != nil { + return err + } + + if !loginResp.Success { + return errors.New(loginResp.ErrorMsg) + } + + c.mqttsUrl = fmt.Sprintf("ssl://%s:%d", loginResp.Result.Domain.MobileMqttsUrl, loginResp.Result.Domain.MqttsPort) + c.expireTime = time.Now().Unix() + 2*24*60*60 // 2 days in seconds + + return nil +} + +func (c *TuyaSmartApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { + url := fmt.Sprintf("https://%s/api/jarvis/config", c.baseUrl) + + data := SmartApiWebRTCConfigRequest{ + DevId: c.deviceId, + ClientTraceId: fmt.Sprintf("%x", rand.Int63()), + } + + body, err := c.request("POST", url, data) + if err != nil { + return nil, err + } + + var webRTCConfigResponse SmartApiWebRTCConfigResponse + err = json.Unmarshal(body, &webRTCConfigResponse) + if err != nil { + return nil, err + } + + if !webRTCConfigResponse.Success { + return nil, errors.New(webRTCConfigResponse.Msg) + } + + err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &c.skill) + if err != nil { + return nil, err + } + + // Store LocalKey + c.localKey = webRTCConfigResponse.Result.LocalKey + + iceServers, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) + if err != nil { + return nil, err + } + + c.iceServers, err = webrtc.UnmarshalICEServers(iceServers) + if err != nil { + return nil, err + } + + return &WebRTCConfig{ + AudioAttributes: webRTCConfigResponse.Result.AudioAttributes, + Auth: webRTCConfigResponse.Result.Auth, + ID: webRTCConfigResponse.Result.Id, + MotoID: webRTCConfigResponse.Result.MotoId, + P2PConfig: webRTCConfigResponse.Result.P2PConfig, + ProtocolVersion: webRTCConfigResponse.Result.ProtocolVersion, + Skill: webRTCConfigResponse.Result.Skill, + SupportsWebRTCRecord: webRTCConfigResponse.Result.SupportWebrtcRecord, + SupportsWebRTC: webRTCConfigResponse.Result.SupportsWebrtc, + VedioClaritiy: webRTCConfigResponse.Result.VedioClarity, + VideoClaritiy: webRTCConfigResponse.Result.VideoClarity, + VideoClarities: webRTCConfigResponse.Result.VedioClaritys, + }, nil +} + +func (c *TuyaSmartApiClient) loadHubConfig() (config *MQTTConfig, err error) { + mqttUrl := fmt.Sprintf("https://%s/api/jarvis/mqtt", c.baseUrl) + + mqttBody, err := c.request("POST", mqttUrl, nil) + if err != nil { + return nil, err + } + + var mqttConfigResponse MQTTConfigResponse + err = json.Unmarshal(mqttBody, &mqttConfigResponse) + if err != nil { + return nil, err + } + + if !mqttConfigResponse.Success { + return nil, errors.New(mqttConfigResponse.Msg) + } + + return &MQTTConfig{ + Url: c.mqttsUrl, + ClientID: fmt.Sprintf("web_%s", mqttConfigResponse.Result.Msid), + Username: fmt.Sprintf("web_%s", mqttConfigResponse.Result.Msid), + Password: mqttConfigResponse.Result.Password, + PublishTopic: "/av/moto/moto_id/u/{device_id}", + SubscribeTopic: fmt.Sprintf("/av/u/%s", mqttConfigResponse.Result.Msid), + }, nil +} + +func (c *TuyaSmartApiClient) request(method string, url string, body any) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", fmt.Sprintf("https://%s", c.baseUrl)) + + response, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + + res, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, err + } + + return res, nil +} diff --git a/pkg/webrtc/api.go b/pkg/webrtc/api.go index 79cf6d3c..5551d65e 100644 --- a/pkg/webrtc/api.go +++ b/pkg/webrtc/api.go @@ -1,7 +1,9 @@ package webrtc import ( + "fmt" "net" + "slices" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/xnet" @@ -27,6 +29,69 @@ type Filters struct { UDPPorts []uint16 `yaml:"udp_ports"` } +func (f *Filters) Network(protocol string) string { + if f == nil || f.Networks == nil { + return protocol + } + v4 := slices.Contains(f.Networks, protocol+"4") + v6 := slices.Contains(f.Networks, protocol+"6") + if v4 && v6 { + return protocol + } else if v4 { + return protocol + "4" + } else if v6 { + return protocol + "6" + } + return "" +} + +func (f *Filters) NetIPs() (ips []net.IP) { + itfs, _ := net.Interfaces() + for _, itf := range itfs { + if itf.Flags&net.FlagUp == 0 { + continue + } + if !f.IncludeLoopback() && itf.Flags&net.FlagLoopback != 0 { + continue + } + if !f.InterfaceFilter(itf.Name) { + continue + } + + addrs, _ := itf.Addrs() + for _, addr := range addrs { + ip := parseNetAddr(addr) + if ip == nil || !f.IPFilter(ip) { + continue + } + ips = append(ips, ip) + } + } + return +} + +func parseNetAddr(addr net.Addr) net.IP { + switch addr := addr.(type) { + case *net.IPNet: + return addr.IP + case *net.IPAddr: + return addr.IP + } + return nil +} + +func (f *Filters) IncludeLoopback() bool { + return f != nil && f.Loopback +} + +func (f *Filters) InterfaceFilter(name string) bool { + return f == nil || f.Interfaces == nil || slices.Contains(f.Interfaces, name) +} + +func (f *Filters) IPFilter(ip net.IP) bool { + return f == nil || f.IPs == nil || core.Contains(f.IPs, ip.String()) +} + func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error) { // for debug logs add to env: `PION_LOG_DEBUG=all` m := &webrtc.MediaEngine{} @@ -99,48 +164,17 @@ func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error _ = s.SetEphemeralUDPPortRange(filters.UDPPorts[0], filters.UDPPorts[1]) } - //if len(hosts) != 0 { - // // support only: host, srflx - // if candidateType, err := webrtc.NewICECandidateType(hosts[0]); err == nil { - // s.SetNAT1To1IPs(hosts[1:], candidateType) - // } else { - // s.SetNAT1To1IPs(hosts, 0) // 0 = host - // } - //} - + // If you don't specify an address, this won't cause an error. + // Connections can still be established using random UDP addresses. if address != "" { + // Both newMux functions respect filters and do not raise an error + // if the port cannot be listened on. if network == "" || network == "tcp" { - if ln, err := net.Listen("tcp", address); err == nil { - tcpMux := webrtc.NewICETCPMux(nil, ln, 8) - s.SetICETCPMux(tcpMux) - } + tcpMux := newTCPMux(address, filters) + s.SetICETCPMux(tcpMux) } - if network == "" || network == "udp" { - // UDPMuxDefault should not listening on unspecified address, use NewMultiUDPMuxFromPort instead - var udpMux ice.UDPMux - if port := xnet.ParseUnspecifiedPort(address); port != 0 { - var networks []ice.NetworkType - for _, ntype := range networkTypes { - networks = append(networks, ice.NetworkType(ntype)) - } - - var err error - if udpMux, err = ice.NewMultiUDPMuxFromPort( - port, - ice.UDPMuxFromPortWithInterfaceFilter(interfaceFilter), - ice.UDPMuxFromPortWithIPFilter(ipFilter), - ice.UDPMuxFromPortWithNetworks(networks...), - ); err != nil { - return nil, err - } - } else { - ln, err := net.ListenPacket("udp", address) - if err != nil { - return nil, err - } - udpMux = ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: ln}) - } + udpMux := newUDPMux(address, filters) s.SetICEUDPMux(udpMux) } } @@ -152,6 +186,57 @@ func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error ), nil } +// OnNewListener temporary ugly solution for log +var OnNewListener = func(ln any) {} + +func newTCPMux(address string, filters *Filters) ice.TCPMux { + networkTCP := filters.Network("tcp") // tcp or tcp4 or tcp6 + if ln, _ := net.Listen(networkTCP, address); ln != nil { + OnNewListener(ln) + return webrtc.NewICETCPMux(nil, ln, 8) + } + return nil +} + +func newUDPMux(address string, filters *Filters) ice.UDPMux { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil + } + + // UDPMux should not listening on unspecified address. + // So we will create a listener on all available interfaces. + // We can't use ice.NewMultiUDPMuxFromPort, because it sometimes crashes with an error: + // listen udp [***]:8555: bind: cannot assign requested address + var addrs []string + if host == "" { + for _, ip := range filters.NetIPs() { + addrs = append(addrs, fmt.Sprintf("%s:%s", ip, port)) + } + } else { + addrs = []string{address} + } + + networkUDP := filters.Network("udp") // udp or udp4 or udp6 + + var muxes []ice.UDPMux + for _, addr := range addrs { + if ln, _ := net.ListenPacket(networkUDP, addr); ln != nil { + OnNewListener(ln) + mux := ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: ln}) + muxes = append(muxes, mux) + } + } + + switch len(muxes) { + case 0: + return nil + case 1: + return muxes[0] + } + return ice.NewMultiUDPMuxDefault(muxes...) +} + func RegisterDefaultCodecs(m *webrtc.MediaEngine) error { for _, codec := range []webrtc.RTPCodecParameters{ { diff --git a/pkg/webrtc/client.go b/pkg/webrtc/client.go index 84e9e86b..bc2c4f87 100644 --- a/pkg/webrtc/client.go +++ b/pkg/webrtc/client.go @@ -63,12 +63,12 @@ func (c *Conn) SetAnswer(answer string) (err error) { SDP: fakeFormatsInAnswer(c.pc.LocalDescription().SDP, answer), } if err = c.pc.SetRemoteDescription(desc); err != nil { - return + return err } sd := &sdp.SessionDescription{} if err = sd.Unmarshal([]byte(answer)); err != nil { - return + return err } c.Medias = UnmarshalMedias(sd.MediaDescriptions) diff --git a/pkg/webrtc/conn.go b/pkg/webrtc/conn.go index 092b05c8..924fd550 100644 --- a/pkg/webrtc/conn.go +++ b/pkg/webrtc/conn.go @@ -161,16 +161,7 @@ func (c *Conn) AddCandidate(candidate string) error { return c.pc.AddICECandidate(webrtc.ICECandidateInit{Candidate: candidate}) } -func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver { - for _, tr := range c.pc.GetTransceivers() { - if tr.Mid() == mid { - return tr - } - } - return nil -} - -func (c *Conn) getSenderTrack(mid string) *Track { +func (c *Conn) GetSenderTrack(mid string) *Track { if tr := c.getTranseiver(mid); tr != nil { if s := tr.Sender(); s != nil { if t := s.Track().(*Track); t != nil { @@ -181,6 +172,15 @@ func (c *Conn) getSenderTrack(mid string) *Track { return nil } +func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver { + for _, tr := range c.pc.GetTransceivers() { + if tr.Mid() == mid { + return tr + } + } + return nil +} + func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Codec) { for _, tr := range c.pc.GetTransceivers() { // search Transeiver for this TrackRemote diff --git a/pkg/webrtc/consumer.go b/pkg/webrtc/consumer.go index ebc3a008..767394df 100644 --- a/pkg/webrtc/consumer.go +++ b/pkg/webrtc/consumer.go @@ -32,7 +32,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv panic(core.Caller()) } - localTrack := c.getSenderTrack(media.ID) + localTrack := c.GetSenderTrack(media.ID) if localTrack == nil { return errors.New("webrtc: can't get track") } diff --git a/pkg/webrtc/helpers.go b/pkg/webrtc/helpers.go index 766254a0..bac5087c 100644 --- a/pkg/webrtc/helpers.go +++ b/pkg/webrtc/helpers.go @@ -185,8 +185,8 @@ func LookupIP(address string) (string, error) { } // GetPublicIP example from https://github.com/pion/stun -func GetPublicIP() (net.IP, error) { - conn, err := net.Dial("udp", "stun.l.google.com:19302") +func GetPublicIP(address string) (net.IP, error) { + conn, err := net.Dial("udp", address) if err != nil { return nil, err } @@ -225,18 +225,19 @@ func GetPublicIP() (net.IP, error) { var cachedIP net.IP var cachedTS time.Time -func GetCachedPublicIP() (net.IP, error) { - now := time.Now() - if now.After(cachedTS) { - newIP, err := GetPublicIP() - if err == nil { - cachedIP = newIP - cachedTS = now.Add(time.Minute * 5) - } else if cachedIP == nil { - return nil, err +func GetCachedPublicIP(stuns ...string) (net.IP, error) { + if now := time.Now(); now.After(cachedTS) { + for _, addr := range stuns { + if ip, _ := GetPublicIP(addr); ip != nil { + cachedIP = ip + cachedTS = now.Add(time.Minute * 5) + return ip, nil + } } } - + if cachedIP == nil { + return nil, errors.New("webrtc: can't get public IP") + } return cachedIP, nil } diff --git a/pkg/wyze/backchannel.go b/pkg/wyze/backchannel.go new file mode 100644 index 00000000..37472c10 --- /dev/null +++ b/pkg/wyze/backchannel.go @@ -0,0 +1,55 @@ +package wyze + +import ( + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/pion/rtp" +) + +func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + if err := p.client.StartIntercom(); err != nil { + return fmt.Errorf("wyze: failed to enable intercom: %w", err) + } + + // Get the camera's audio codec info (what it sent us = what it accepts) + tutkCodec, sampleRate, channels := p.client.GetBackchannelCodec() + if tutkCodec == 0 { + return fmt.Errorf("wyze: no audio codec detected from camera") + } + + if p.client.verbose { + fmt.Printf("[Wyze] Intercom enabled, using codec=0x%04x rate=%d ch=%d\n", tutkCodec, sampleRate, channels) + } + + sender := core.NewSender(media, track.Codec) + + // Track our own timestamp - camera expects timestamps starting from 0 + // and incrementing by frame duration in microseconds + var timestamp uint32 = 0 + samplesPerFrame := tutk.GetSamplesPerFrame(tutkCodec) + frameDurationUS := samplesPerFrame * 1000000 / sampleRate + + sender.Handler = func(pkt *rtp.Packet) { + if err := p.client.WriteAudio(tutkCodec, pkt.Payload, timestamp, sampleRate, channels); err == nil { + p.Send += len(pkt.Payload) + } + timestamp += frameDurationUS + } + + switch track.Codec.Name { + case core.CodecAAC: + if track.Codec.IsRTP() { + sender.Handler = aac.RTPToADTS(codec, sender.Handler) + } else { + sender.Handler = aac.EncodeToADTS(codec, sender.Handler) + } + } + + sender.HandleRTP(track) + p.Senders = append(p.Senders, sender) + + return nil +} diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go new file mode 100644 index 00000000..0fe878ee --- /dev/null +++ b/pkg/wyze/client.go @@ -0,0 +1,618 @@ +package wyze + +import ( + "crypto/rand" + "encoding/binary" + "encoding/json" + "fmt" + "net" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/AlexxIT/go2rtc/pkg/tutk/dtls" +) + +const ( + FrameSize1080P = 0 + FrameSize360P = 1 + FrameSize720P = 2 + FrameSize2K = 3 + FrameSizeFloodlight = 4 +) + +const ( + BitrateMax uint16 = 0xF0 + BitrateSD uint16 = 0x3C +) + +const ( + MediaTypeVideo = 1 + MediaTypeAudio = 2 + MediaTypeReturnAudio = 3 + MediaTypeRDT = 4 +) + +const ( + KCmdAuth = 10000 + KCmdChallenge = 10001 + KCmdChallengeResp = 10002 + KCmdAuthResult = 10003 + KCmdControlChannel = 10010 + KCmdControlChannelResp = 10011 + KCmdSetResolutionDB = 10052 + KCmdSetResolutionDBRes = 10053 + KCmdSetResolution = 10056 + KCmdSetResolutionResp = 10057 +) + +type Client struct { + conn *dtls.DTLSConn + + host string + uid string + enr string + mac string + model string + + authKey string + verbose bool + + closed bool + closeMu sync.Mutex + + hasAudio bool + hasIntercom bool + + audioCodecID byte + audioSampleRate uint32 + audioChannels uint8 +} + +type AuthResponse struct { + ConnectionRes string `json:"connectionRes"` + CameraInfo map[string]any `json:"cameraInfo"` +} + +func Dial(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("wyze: invalid URL: %w", err) + } + + query := u.Query() + + if query.Get("dtls") != "true" { + return nil, fmt.Errorf("wyze: only DTLS cameras are supported") + } + + c := &Client{ + host: u.Host, + uid: query.Get("uid"), + enr: query.Get("enr"), + mac: query.Get("mac"), + model: query.Get("model"), + verbose: query.Get("verbose") == "true", + } + + c.authKey = string(dtls.CalculateAuthKey(c.enr, c.mac)) + + if c.verbose { + fmt.Printf("[Wyze] Connecting to %s (UID: %s)\n", c.host, c.uid) + } + + if err := c.connect(); err != nil { + c.Close() + return nil, err + } + + if err := c.doAVLogin(); err != nil { + c.Close() + return nil, err + } + + if err := c.doKAuth(); err != nil { + c.Close() + return nil, err + } + + if c.verbose { + fmt.Printf("[Wyze] Connection established\n") + } + + return c, nil +} + +func (c *Client) SupportsAudio() bool { + return c.hasAudio +} + +func (c *Client) SupportsIntercom() bool { + return c.hasIntercom +} + +func (c *Client) SetBackchannelCodec(codecID byte, sampleRate uint32, channels uint8) { + c.audioCodecID = codecID + c.audioSampleRate = sampleRate + c.audioChannels = channels +} + +func (c *Client) GetBackchannelCodec() (codecID byte, sampleRate uint32, channels uint8) { + return c.audioCodecID, c.audioSampleRate, c.audioChannels +} + +func (c *Client) SetResolution(quality byte) error { + var frameSize uint8 + var bitrate uint16 + + switch quality { + case 0: // Auto/HD - use model's best + frameSize = c.hdFrameSize() + bitrate = BitrateMax + case FrameSize360P: // 1 = SD/360P + frameSize = FrameSize360P + bitrate = BitrateSD + case FrameSize720P: // 2 = 720P + frameSize = FrameSize720P + bitrate = BitrateMax + case FrameSize2K: // 3 = 2K + if c.is2K() { + frameSize = FrameSize2K + } else { + frameSize = c.hdFrameSize() + } + bitrate = BitrateMax + case FrameSizeFloodlight: // 4 = Floodlight + frameSize = c.hdFrameSize() + bitrate = BitrateMax + default: + frameSize = quality + bitrate = BitrateMax + } + + if c.verbose { + fmt.Printf("[Wyze] SetResolution: quality=%d frameSize=%d bitrate=%d model=%s\n", quality, frameSize, bitrate, c.model) + } + + // Use K10052 (doorbell format) for certain models + if c.useDoorbellResolution() { + k10052 := c.buildK10052(frameSize, bitrate) + _, err := c.conn.WriteAndWaitIOCtrl(k10052, c.matchHL(KCmdSetResolutionDBRes), 5*time.Second) + return err + } + + k10056 := c.buildK10056(frameSize, bitrate) + _, err := c.conn.WriteAndWaitIOCtrl(k10056, c.matchHL(KCmdSetResolutionResp), 5*time.Second) + return err +} + +func (c *Client) StartVideo() error { + k10010 := c.buildK10010(MediaTypeVideo, true) + _, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second) + return err +} + +func (c *Client) StartAudio() error { + k10010 := c.buildK10010(MediaTypeAudio, true) + _, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second) + return err +} + +func (c *Client) StartIntercom() error { + if c.conn == nil { + return fmt.Errorf("connection is nil") + } + + if c.conn.IsBackchannelReady() { + return nil + } + + k10010 := c.buildK10010(MediaTypeReturnAudio, true) + if _, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second); err != nil { + return fmt.Errorf("enable return audio: %w", err) + } + + if c.verbose { + fmt.Printf("[Wyze] Speaker channel enabled, waiting for readiness...\n") + } + + return c.conn.AVServStart() +} + +func (c *Client) StopIntercom() error { + if c.conn == nil || !c.conn.IsBackchannelReady() { + return nil + } + + k10010 := c.buildK10010(MediaTypeReturnAudio, false) + c.conn.WriteIOCtrl(k10010) + + return c.conn.AVServStop() +} + +func (c *Client) ReadPacket() (*tutk.Packet, error) { + return c.conn.AVRecvFrameData() +} + +func (c *Client) WriteAudio(codec byte, payload []byte, timestamp uint32, sampleRate uint32, channels uint8) error { + if !c.conn.IsBackchannelReady() { + return fmt.Errorf("speaker channel not connected") + } + + if c.verbose { + fmt.Printf("[Wyze] WriteAudio: codec=0x%02x, payload=%d bytes, rate=%d, ch=%d\n", codec, len(payload), sampleRate, channels) + } + + return c.conn.AVSendAudioData(codec, payload, timestamp, sampleRate, channels) +} + +func (c *Client) SetDeadline(t time.Time) error { + if c.conn != nil { + return c.conn.SetDeadline(t) + } + return nil +} + +func (c *Client) Protocol() string { + return "wyze/dtls" +} + +func (c *Client) RemoteAddr() net.Addr { + if c.conn != nil { + return c.conn.RemoteAddr() + } + return nil +} + +func (c *Client) Close() error { + c.closeMu.Lock() + if c.closed { + c.closeMu.Unlock() + return nil + } + c.closed = true + c.closeMu.Unlock() + + if c.verbose { + fmt.Printf("[Wyze] Closing connection\n") + } + + c.StopIntercom() + + if c.conn != nil { + c.conn.Close() + } + + if c.verbose { + fmt.Printf("[Wyze] Connection closed\n") + } + + return nil +} + +func (c *Client) connect() error { + host := c.host + port := 0 + + if idx := strings.Index(host, ":"); idx > 0 { + if p, err := strconv.Atoi(host[idx+1:]); err == nil { + port = p + } + host = host[:idx] + } + + conn, err := dtls.DialDTLS(host, port, c.uid, c.authKey, c.enr, c.verbose) + if err != nil { + return fmt.Errorf("wyze: connect failed: %w", err) + } + + c.conn = conn + if c.verbose { + fmt.Printf("[Wyze] Connected to %s (IOTC + DTLS)\n", conn.RemoteAddr()) + } + + return nil +} + +func (c *Client) doAVLogin() error { + if c.verbose { + fmt.Printf("[Wyze] Sending AV Login\n") + } + + if err := c.conn.AVClientStart(5 * time.Second); err != nil { + return fmt.Errorf("wyze: av login failed: %w", err) + } + + if c.verbose { + fmt.Printf("[Wyze] AV Login response received\n") + } + return nil +} + +func (c *Client) doKAuth() error { + // Step 1: K10000 -> K10001 (Challenge) + data, err := c.conn.WriteAndWaitIOCtrl(c.buildK10000(), c.matchHL(KCmdChallenge), 5*time.Second) + if err != nil { + return fmt.Errorf("wyze: K10001 failed: %w", err) + } + + hlData := c.extractHL(data) + challenge, status, err := c.parseK10001(hlData) + if err != nil { + return fmt.Errorf("wyze: K10001 parse failed: %w", err) + } + + if c.verbose { + fmt.Printf("[Wyze] K10001 challenge received, status=%d\n", status) + } + + // Step 2: K10002 -> K10003 (Auth) + data, err = c.conn.WriteAndWaitIOCtrl(c.buildK10002(challenge, status), c.matchHL(KCmdAuthResult), 5*time.Second) + if err != nil { + return fmt.Errorf("wyze: K10002 failed: %w", err) + } + hlData = c.extractHL(data) + + // Parse K10003 response + authResp, err := c.parseK10003(hlData) + if err != nil { + return fmt.Errorf("wyze: K10003 parse failed: %w", err) + } + + if c.verbose && authResp != nil { + if jsonBytes, err := json.MarshalIndent(authResp, "", " "); err == nil { + fmt.Printf("[Wyze] K10003 response:\n%s\n", jsonBytes) + } + } + + // Extract audio capability from cameraInfo + if authResp != nil && authResp.CameraInfo != nil { + if channelResult, ok := authResp.CameraInfo["channelRequestResult"].(map[string]any); ok { + if audio, ok := channelResult["audio"].(string); ok { + c.hasAudio = audio == "1" + } else { + c.hasAudio = true + } + } else { + c.hasAudio = true + } + } else { + c.hasAudio = true + } + + if c.verbose { + fmt.Printf("[Wyze] K10003 auth success\n") + } + + c.hasIntercom = c.conn.HasTwoWayStreaming() + + if c.verbose { + fmt.Printf("[Wyze] K-auth complete\n") + } + + return nil +} + +func (c *Client) buildK10000() []byte { + json := []byte(`{"cameraInfo":{"audioEncoderList":[137,138,140]}}`) // 137=PCMU, 138=PCMA, 140=PCM + b := make([]byte, 16+len(json)) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdAuth) // 10000 + binary.LittleEndian.PutUint16(b[6:], uint16(len(json))) // payload len + copy(b[16:], json) + return b +} + +func (c *Client) buildK10002(challenge []byte, status byte) []byte { + resp := generateChallengeResponse(challenge, c.enr, status) + sessionID := make([]byte, 4) + rand.Read(sessionID) + b := make([]byte, 38) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdChallengeResp) // 10002 + b[6] = 22 // payload len + copy(b[16:], resp[:16]) // challenge response + copy(b[32:], sessionID) // random session ID + b[36] = 1 // video enabled/disabled + b[37] = 1 // audio enabled/disabled + return b +} + +func (c *Client) buildK10010(mediaType byte, enabled bool) []byte { + b := make([]byte, 18) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdControlChannel) // 10010 + binary.LittleEndian.PutUint16(b[6:], 2) // payload len + b[16] = mediaType // 1=video, 2=audio, 3=return audio + b[17] = 1 // 1=enable, 2=disable + if !enabled { + b[17] = 2 + } + return b +} + +func (c *Client) buildK10052(frameSize uint8, bitrate uint16) []byte { + b := make([]byte, 22) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdSetResolutionDB) // 10052 + binary.LittleEndian.PutUint16(b[6:], 6) // payload len + binary.LittleEndian.PutUint16(b[16:], bitrate) // bitrate (2 bytes) + b[18] = frameSize + 1 // frame size (1 byte) + // b[19] = fps, b[20:22] = zeros + return b +} + +func (c *Client) buildK10056(frameSize uint8, bitrate uint16) []byte { + b := make([]byte, 21) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdSetResolution) // 10056 + binary.LittleEndian.PutUint16(b[6:], 5) // payload len + b[16] = frameSize + 1 // frame size + binary.LittleEndian.PutUint16(b[17:], bitrate) // bitrate + // b[19:21] = FPS (0 = auto) + return b +} + +func (c *Client) parseK10001(data []byte) (challenge []byte, status byte, err error) { + if c.verbose { + fmt.Printf("[Wyze] parseK10001: received %d bytes\n", len(data)) + } + + if len(data) < 33 { + return nil, 0, fmt.Errorf("data too short: %d bytes", len(data)) + } + + if data[0] != 'H' || data[1] != 'L' { + return nil, 0, fmt.Errorf("invalid HL magic: %x %x", data[0], data[1]) + } + + cmdID := binary.LittleEndian.Uint16(data[4:]) + if cmdID != KCmdChallenge { + return nil, 0, fmt.Errorf("expected cmdID 10001, got %d", cmdID) + } + + status = data[16] + challenge = make([]byte, 16) + copy(challenge, data[17:33]) + + return challenge, status, nil +} + +func (c *Client) parseK10003(data []byte) (*AuthResponse, error) { + if c.verbose { + fmt.Printf("[Wyze] parseK10003: received %d bytes\n", len(data)) + } + + if len(data) < 16 { + return &AuthResponse{}, nil + } + + if data[0] != 'H' || data[1] != 'L' { + return &AuthResponse{}, nil + } + + cmdID := binary.LittleEndian.Uint16(data[4:]) + textLen := binary.LittleEndian.Uint16(data[6:]) + + if cmdID != KCmdAuthResult { + return &AuthResponse{}, nil + } + + if len(data) > 16 && textLen > 0 { + jsonData := data[16:] + for i := range jsonData { + if jsonData[i] == '{' { + var resp AuthResponse + if err := json.Unmarshal(jsonData[i:], &resp); err == nil { + if c.verbose { + fmt.Printf("[Wyze] parseK10003: parsed JSON\n") + } + return &resp, nil + } + break + } + } + } + + return &AuthResponse{}, nil +} + +func (c *Client) useDoorbellResolution() bool { + switch c.model { + case "WYZEDB3", "WVOD1", "HL_WCO2", "WYZEC1": + return true + } + return false +} + +func (c *Client) hdFrameSize() uint8 { + if c.isFloodlight() { + return FrameSizeFloodlight + } + if c.is2K() { + return FrameSize2K + } + return FrameSize1080P +} + +func (c *Client) is2K() bool { + switch c.model { + case "HL_CAM3P", "HL_PANP", "HL_CAM4", "HL_DB2", "HL_CFL2": + return true + } + return false +} + +func (c *Client) isFloodlight() bool { + return c.model == "HL_CFL2" +} + +func (c *Client) matchHL(expectCmd uint16) func([]byte) bool { + return func(data []byte) bool { + hlData := c.extractHL(data) + if hlData == nil { + return false + } + cmd, _, ok := tutk.ParseHL(hlData) + return ok && cmd == expectCmd + } +} + +func (c *Client) extractHL(data []byte) []byte { + // Try offset 32 (magicIOCtrl, protoVersion) + if hlData := tutk.FindHL(data, 32); hlData != nil { + return hlData + } + // Try offset 36 (magicChannelMsg) + if len(data) >= 36 && data[16] == 0x00 { + return tutk.FindHL(data, 36) + } + return nil +} + +const ( + statusDefault byte = 1 + statusENR16 byte = 3 + statusENR32 byte = 6 +) + +func generateChallengeResponse(challengeBytes []byte, enr string, status byte) []byte { + var secretKey []byte + + switch status { + case statusDefault: + secretKey = []byte("FFFFFFFFFFFFFFFF") + case statusENR16: + if len(enr) >= 16 { + secretKey = []byte(enr[:16]) + } else { + secretKey = make([]byte, 16) + copy(secretKey, enr) + } + case statusENR32: + if len(enr) >= 16 { + firstKey := []byte(enr[:16]) + challengeBytes = tutk.XXTEADecryptVar(challengeBytes, firstKey) + } + if len(enr) >= 32 { + secretKey = []byte(enr[16:32]) + } else if len(enr) > 16 { + secretKey = make([]byte, 16) + copy(secretKey, []byte(enr[16:])) + } else { + secretKey = []byte("FFFFFFFFFFFFFFFF") + } + default: + secretKey = []byte("FFFFFFFFFFFFFFFF") + } + + return tutk.XXTEADecryptVar(challengeBytes, secretKey) +} diff --git a/pkg/wyze/cloud.go b/pkg/wyze/cloud.go new file mode 100644 index 00000000..17f914a0 --- /dev/null +++ b/pkg/wyze/cloud.go @@ -0,0 +1,337 @@ +package wyze + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +const ( + baseURLAuth = "https://auth-prod.api.wyze.com" + baseURLAPI = "https://api.wyzecam.com" + appName = "com.hualai.WyzeCam" + appVersion = "2.50.0" +) + +type Cloud struct { + client *http.Client + apiKey string + keyID string + accessToken string + phoneID string + cameras []*Camera +} + +type Camera struct { + MAC string `json:"mac"` + P2PID string `json:"p2p_id"` + ENR string `json:"enr"` + IP string `json:"ip"` + Nickname string `json:"nickname"` + ProductModel string `json:"product_model"` + ProductType string `json:"product_type"` + DTLS int `json:"dtls"` + FirmwareVer string `json:"firmware_ver"` + IsOnline bool `json:"is_online"` +} + +type deviceListResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data struct { + DeviceList []deviceInfo `json:"device_list"` + } `json:"data"` +} + +type deviceInfo struct { + MAC string `json:"mac"` + ENR string `json:"enr"` + Nickname string `json:"nickname"` + ProductModel string `json:"product_model"` + ProductType string `json:"product_type"` + FirmwareVer string `json:"firmware_ver"` + ConnState int `json:"conn_state"` + DeviceParams deviceParams `json:"device_params"` +} + +type deviceParams struct { + P2PID string `json:"p2p_id"` + P2PType int `json:"p2p_type"` + IP string `json:"ip"` + DTLS int `json:"dtls"` +} + +type p2pInfoResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data map[string]any `json:"data"` +} + +type loginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + UserID string `json:"user_id"` + MFAOptions []string `json:"mfa_options"` + SMSSessionID string `json:"sms_session_id"` + EmailSessionID string `json:"email_session_id"` +} + +func NewCloud(apiKey, keyID string) *Cloud { + return &Cloud{ + client: &http.Client{Timeout: 30 * time.Second}, + phoneID: generatePhoneID(), + apiKey: apiKey, + keyID: keyID, + } +} + +func (c *Cloud) Login(email, password string) error { + payload := map[string]string{ + "email": strings.TrimSpace(email), + "password": hashPassword(password), + } + + jsonData, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", baseURLAuth+"/api/user/login", strings.NewReader(string(jsonData))) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Apikey", c.apiKey) + req.Header.Set("Keyid", c.keyID) + req.Header.Set("User-Agent", "go2rtc") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var errResp apiError + _ = json.Unmarshal(body, &errResp) + if errResp.hasError() { + return fmt.Errorf("wyze: login failed (code %s): %s", errResp.code(), errResp.message()) + } + + var result loginResponse + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("wyze: failed to parse login response: %w", err) + } + + if len(result.MFAOptions) > 0 { + return &AuthError{ + Message: "MFA required", + NeedsMFA: true, + MFAType: strings.Join(result.MFAOptions, ","), + } + } + + if result.AccessToken == "" { + return errors.New("wyze: no access token in response") + } + + c.accessToken = result.AccessToken + + return nil +} + +func (c *Cloud) GetCameraList() ([]*Camera, error) { + payload := map[string]any{ + "access_token": c.accessToken, + "phone_id": c.phoneID, + "app_name": appName, + "app_ver": appName + "___" + appVersion, + "app_version": appVersion, + "phone_system_type": 1, + "sc": "9f275790cab94a72bd206c8876429f3c", + "sv": "9d74946e652647e9b6c9d59326aef104", + "ts": time.Now().UnixMilli(), + } + + jsonData, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", baseURLAPI+"/app/v2/home_page/get_object_list", strings.NewReader(string(jsonData))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result deviceListResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("wyze: failed to parse device list: %w", err) + } + + if result.Code != "1" { + return nil, fmt.Errorf("wyze: API error: %s - %s", result.Code, result.Msg) + } + + c.cameras = nil + for _, dev := range result.Data.DeviceList { + if dev.ProductType != "Camera" { + continue + } + if dev.DeviceParams.IP == "" { + continue // skip cameras without IP (gwell protocol) + } + + c.cameras = append(c.cameras, &Camera{ + MAC: dev.MAC, + P2PID: dev.DeviceParams.P2PID, + ENR: dev.ENR, + IP: dev.DeviceParams.IP, + Nickname: dev.Nickname, + ProductModel: dev.ProductModel, + ProductType: dev.ProductType, + DTLS: dev.DeviceParams.DTLS, + FirmwareVer: dev.FirmwareVer, + IsOnline: dev.ConnState == 1, + }) + } + + return c.cameras, nil +} + +func (c *Cloud) GetCamera(id string) (*Camera, error) { + if c.cameras == nil { + if _, err := c.GetCameraList(); err != nil { + return nil, err + } + } + + id = strings.ToUpper(id) + for _, cam := range c.cameras { + if strings.ToUpper(cam.MAC) == id || strings.EqualFold(cam.Nickname, id) { + return cam, nil + } + } + + return nil, fmt.Errorf("wyze: camera not found: %s", id) +} + +func (c *Cloud) GetP2PInfo(mac string) (map[string]any, error) { + payload := map[string]any{ + "access_token": c.accessToken, + "phone_id": c.phoneID, + "device_mac": mac, + "app_name": appName, + "app_ver": appName + "___" + appVersion, + "app_version": appVersion, + "phone_system_type": 1, + "sc": "9f275790cab94a72bd206c8876429f3c", + "sv": "9d74946e652647e9b6c9d59326aef104", + "ts": time.Now().UnixMilli(), + } + + jsonData, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", baseURLAPI+"/app/v2/device/get_iotc_info", strings.NewReader(string(jsonData))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result p2pInfoResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + if result.Code != "1" { + return nil, fmt.Errorf("wyze: API error: %s - %s", result.Code, result.Msg) + } + + return result.Data, nil +} + +type apiError struct { + Code string `json:"code"` + ErrorCode int `json:"errorCode"` + Msg string `json:"msg"` + Description string `json:"description"` +} + +func (e *apiError) hasError() bool { + if e.Code == "1" || e.Code == "0" { + return false + } + if e.Code == "" && e.ErrorCode == 0 { + return false + } + return e.Code != "" || e.ErrorCode != 0 +} + +func (e *apiError) message() string { + if e.Msg != "" { + return e.Msg + } + return e.Description +} + +func (e *apiError) code() string { + if e.Code != "" { + return e.Code + } + return fmt.Sprintf("%d", e.ErrorCode) +} + +type AuthError struct { + Message string `json:"message"` + NeedsMFA bool `json:"needs_mfa,omitempty"` + MFAType string `json:"mfa_type,omitempty"` +} + +func (e *AuthError) Error() string { + return e.Message +} + +func generatePhoneID() string { + return core.RandString(16, 16) // 16 hex chars +} + +func hashPassword(password string) string { + encoded := strings.TrimSpace(password) + if strings.HasPrefix(strings.ToLower(encoded), "md5:") { + return encoded[4:] + } + for range 3 { + hash := md5.Sum([]byte(encoded)) + encoded = hex.EncodeToString(hash[:]) + } + return encoded +} diff --git a/pkg/wyze/producer.go b/pkg/wyze/producer.go new file mode 100644 index 00000000..16219c44 --- /dev/null +++ b/pkg/wyze/producer.go @@ -0,0 +1,277 @@ +package wyze + +import ( + "fmt" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + client *Client + model string +} + +func NewProducer(rawURL string) (*Producer, error) { + client, err := Dial(rawURL) + if err != nil { + return nil, err + } + + u, _ := url.Parse(rawURL) + query := u.Query() + + // 0 = HD (default), 1 = SD/360P, 2 = 720P, 3 = 2K, 4 = Floodlight + var quality byte + switch s := query.Get("subtype"); s { + case "", "hd": + quality = 0 + case "sd": + quality = FrameSize360P + default: + quality = core.ParseByte(s) + } + + medias, err := probe(client, quality) + if err != nil { + _ = client.Close() + return nil, err + } + + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "wyze", + Protocol: client.Protocol(), + RemoteAddr: client.RemoteAddr().String(), + Source: rawURL, + Medias: medias, + Transport: client, + }, + client: client, + model: query.Get("model"), + } + + return prod, nil +} + +func (p *Producer) Start() error { + for { + if p.client.verbose { + fmt.Println("[Wyze] Reading packet...") + } + + _ = p.client.SetDeadline(time.Now().Add(core.ConnDeadline)) + pkt, err := p.client.ReadPacket() + if err != nil { + return err + } + if pkt == nil { + continue + } + + var name string + var pkt2 *core.Packet + + switch codecID := pkt.Codec; codecID { + case tutk.CodecH264: + name = core.CodecH264 + pkt2 = &core.Packet{ + Header: rtp.Header{SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: annexb.EncodeToAVCC(pkt.Payload), + } + + case tutk.CodecH265: + name = core.CodecH265 + pkt2 = &core.Packet{ + Header: rtp.Header{SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: annexb.EncodeToAVCC(pkt.Payload), + } + + case tutk.CodecPCMU: + name = core.CodecPCMU + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecPCMA: + name = core.CodecPCMA + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecAACADTS, tutk.CodecAACAlt, tutk.CodecAACRaw, tutk.CodecAACLATM: + name = core.CodecAAC + payload := pkt.Payload + if aac.IsADTS(payload) { + payload = payload[aac.ADTSHeaderLen(payload):] + } + pkt2 = &core.Packet{ + Header: rtp.Header{Version: aac.RTPPacketVersionAAC, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: payload, + } + + case tutk.CodecOpus: + name = core.CodecOpus + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecPCML: + name = core.CodecPCML + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecMP3: + name = core.CodecMP3 + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecMJPEG: + name = core.CodecJPEG + pkt2 = &core.Packet{ + Header: rtp.Header{SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + default: + continue + } + + for _, recv := range p.Receivers { + if recv.Codec.Name == name { + recv.WriteRTP(pkt2) + break + } + } + } +} + +func probe(client *Client, quality byte) ([]*core.Media, error) { + client.SetResolution(quality) + client.SetDeadline(time.Now().Add(core.ProbeTimeout)) + + var vcodec, acodec *core.Codec + var tutkAudioCodec byte + + for { + if client.verbose { + fmt.Println("[Wyze] Probing for codecs...") + } + + pkt, err := client.ReadPacket() + if err != nil { + return nil, fmt.Errorf("wyze: probe: %w", err) + } + if pkt == nil || len(pkt.Payload) < 5 { + continue + } + + switch pkt.Codec { + case tutk.CodecH264: + if vcodec == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if len(buf) >= 5 && h264.NALUType(buf) == h264.NALUTypeSPS { + vcodec = h264.AVCCToCodec(buf) + } + } + case tutk.CodecH265: + if vcodec == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if len(buf) >= 5 && h265.NALUType(buf) == h265.NALUTypeVPS { + vcodec = h265.AVCCToCodec(buf) + } + } + case tutk.CodecPCMU: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMU, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecPCMA: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecAACAlt, tutk.CodecAACADTS, tutk.CodecAACRaw, tutk.CodecAACLATM: + if acodec == nil { + config := aac.EncodeConfig(aac.TypeAACLC, pkt.SampleRate, pkt.Channels, false) + acodec = aac.ConfigToCodec(config) + tutkAudioCodec = pkt.Codec + } + case tutk.CodecOpus: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecPCML: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCML, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecMP3: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecMP3, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecMJPEG: + if vcodec == nil { + vcodec = &core.Codec{Name: core.CodecJPEG, ClockRate: 90000, PayloadType: core.PayloadTypeRAW} + } + } + + if vcodec != nil && (acodec != nil || !client.SupportsAudio()) { + break + } + } + + _ = client.SetDeadline(time.Time{}) + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{vcodec}, + }, + } + + if acodec != nil { + medias = append(medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{acodec}, + }) + + if client.SupportsIntercom() { + client.SetBackchannelCodec(tutkAudioCodec, acodec.ClockRate, uint8(acodec.Channels)) + medias = append(medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{acodec.Clone()}, + }) + } + } + + if client.verbose { + fmt.Printf("[Wyze] Probed codecs: video=%s audio=%s\n", vcodec.Name, acodec.Name) + if client.SupportsIntercom() { + fmt.Printf("[Wyze] Intercom supported, audio send codec=%s\n", acodec.Name) + } + } + + return medias, nil +} diff --git a/pkg/xiaomi/cloud.go b/pkg/xiaomi/cloud.go new file mode 100644 index 00000000..0dcfd241 --- /dev/null +++ b/pkg/xiaomi/cloud.go @@ -0,0 +1,568 @@ +package xiaomi + +import ( + "bytes" + "crypto/md5" + "crypto/rand" + "crypto/rc4" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Cloud struct { + client *http.Client + + sid string + cookies string // for auth + ssecurity []byte // for encryption + + userID string + passToken string + + auth map[string]string +} + +func NewCloud(sid string) *Cloud { + return &Cloud{ + client: &http.Client{Timeout: 15 * time.Second}, + sid: sid, + } +} + +func (c *Cloud) Login(username, password string) error { + res, err := c.client.Get("https://account.xiaomi.com/pass/serviceLogin?_json=true&sid=" + c.sid) + if err != nil { + return err + } + + var v1 struct { + Qs string `json:"qs"` + Sign string `json:"_sign"` + Sid string `json:"sid"` + Callback string `json:"callback"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + hash := fmt.Sprintf("%X", md5.Sum([]byte(password))) + + form := url.Values{ + "_json": {"true"}, + "hash": {hash}, + "sid": {v1.Sid}, + "callback": {v1.Callback}, + "_sign": {v1.Sign}, + "qs": {v1.Qs}, + "user": {username}, + } + cookies := "deviceId=" + core.RandString(16, 62) + + // login after captcha + if c.auth != nil && c.auth["captcha_code"] != "" { + form.Set("captCode", c.auth["captcha_code"]) + cookies += "; ick=" + c.auth["ick"] + } + + req := Request{ + Method: "POST", + URL: "https://account.xiaomi.com/pass/serviceLoginAuth2", + Body: form, + RawCookies: cookies, + }.Encode() + + res, err = c.client.Do(req) + if err != nil { + return err + } + + var v2 struct { + Ssecurity []byte `json:"ssecurity"` + PassToken string `json:"passToken"` + Location string `json:"location"` + + CaptchaURL string `json:"captchaURL"` + NotificationURL string `json:"notificationUrl"` + } + body, err := readLoginResponse(res.Body, &v2) + if err != nil { + return err + } + + // save auth for two-step verification + c.auth = map[string]string{ + "username": username, + "password": password, + } + + if v2.CaptchaURL != "" { + return c.getCaptcha(v2.CaptchaURL) + } + + if v2.NotificationURL != "" { + return c.authStart(v2.NotificationURL) + } + + if v2.Location == "" { + return fmt.Errorf("xiaomi: %s", body) + } + + c.auth = nil + c.ssecurity = v2.Ssecurity + c.passToken = v2.PassToken + + return c.finishAuth(v2.Location) +} + +func (c *Cloud) LoginWithCaptcha(captcha string) error { + if c.auth == nil || c.auth["ick"] == "" { + panic("wrong login step") + } + + c.auth["captcha_code"] = captcha + + // check if captcha after verify + if c.auth["flag"] != "" { + return c.sendTicket() + } + + return c.Login(c.auth["username"], c.auth["password"]) +} + +func (c *Cloud) LoginWithVerify(ticket string) error { + if c.auth == nil || c.auth["flag"] == "" { + panic("wrong login step") + } + + req := Request{ + Method: "POST", + URL: "https://account.xiaomi.com/identity/auth/verify" + c.verifyName(), + RawParams: "_flag" + c.auth["flag"] + "&ticket=" + ticket + "&trust=false&_json=true", + RawCookies: "identity_session=" + c.auth["identity_session"], + }.Encode() + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Location string `json:"location"` + } + body, err := readLoginResponse(res.Body, &v1) + if err != nil { + return err + } + if v1.Location == "" { + return fmt.Errorf("xiaomi: %s", body) + } + + return c.finishAuth(v1.Location) +} + +func (c *Cloud) getCaptcha(captchaURL string) error { + res, err := c.client.Get("https://account.xiaomi.com" + captchaURL) + if err != nil { + return err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + c.auth["ick"] = findCookie(res, "ick") + + return &LoginError{ + Captcha: body, + } +} + +func (c *Cloud) authStart(notificationURL string) error { + rawURL := strings.Replace(notificationURL, "/fe/service/identity/authStart", "/identity/list", 1) + res, err := c.client.Get(rawURL) + if err != nil { + return err + } + + var v1 struct { + Code int `json:"code"` + Flag int `json:"flag"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + c.auth["flag"] = strconv.Itoa(v1.Flag) + c.auth["identity_session"] = findCookie(res, "identity_session") + + return c.sendTicket() +} + +func findCookie(res *http.Response, name string) string { + for _, cookie := range res.Cookies() { + if cookie.Name == name { + return cookie.Value + } + } + return "" +} + +func (c *Cloud) verifyName() string { + switch c.auth["flag"] { + case "4": + return "Phone" + case "8": + return "Email" + } + return "" +} + +func (c *Cloud) sendTicket() error { + name := c.verifyName() + cookies := "identity_session=" + c.auth["identity_session"] + + req := Request{ + URL: "https://account.xiaomi.com/identity/auth/verify" + name, + RawParams: "_flag=" + c.auth["flag"] + "&_json=true", + RawCookies: cookies, + }.Encode() + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Code int `json:"code"` + MaskedPhone string `json:"maskedPhone"` + MaskedEmail string `json:"maskedEmail"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + // verify after captcha + captCode := c.auth["captcha_code"] + if captCode != "" { + cookies += "; ick=" + c.auth["ick"] + } + + form := url.Values{ + "_json": {"true"}, + "icode": {captCode}, + "retry": {"0"}, + } + + req = Request{ + Method: "POST", + URL: "https://account.xiaomi.com/identity/auth/send" + name + "Ticket", + Body: form, + RawCookies: cookies, + }.Encode() + + res, err = c.client.Do(req) + if err != nil { + return err + } + + var v2 struct { + Code int `json:"code"` + CaptchaURL string `json:"captchaURL"` + } + body, err := readLoginResponse(res.Body, &v2) + if err != nil { + return err + } + + if v2.CaptchaURL != "" { + return c.getCaptcha(v2.CaptchaURL) + } + + if v2.Code != 0 { + return fmt.Errorf("xiaomi: %s", body) + } + + return &LoginError{ + VerifyPhone: v1.MaskedPhone, + VerifyEmail: v1.MaskedEmail, + } +} + +type LoginError struct { + Captcha []byte `json:"captcha,omitempty"` + VerifyPhone string `json:"verify_phone,omitempty"` + VerifyEmail string `json:"verify_email,omitempty"` +} + +func (l *LoginError) Error() string { + return "" +} + +func (c *Cloud) finishAuth(location string) error { + res, err := c.client.Get(location) + if err != nil { + return err + } + defer res.Body.Close() + + // LoginWithVerify + // - userId, cUserId, serviceToken from cookies + // - passToken from redirect cookies + // - ssecurity from extra header + // LoginWithToken + // - userId, cUserId, serviceToken from cookies + var cUserId, serviceToken string + + for res != nil { + for _, cookie := range res.Cookies() { + switch cookie.Name { + case "userId": + c.userID = cookie.Value + case "cUserId": + cUserId = cookie.Value + case "serviceToken": + serviceToken = cookie.Value + case "passToken": + c.passToken = cookie.Value + } + } + + if s := res.Header.Get("Extension-Pragma"); s != "" { + var v1 struct { + Ssecurity []byte `json:"ssecurity"` + } + if err = json.Unmarshal([]byte(s), &v1); err != nil { + return err + } + c.ssecurity = v1.Ssecurity + } + + res = res.Request.Response + } + + c.cookies = fmt.Sprintf("userId=%s; cUserId=%s; serviceToken=%s", c.userID, cUserId, serviceToken) + + return nil +} + +func (c *Cloud) LoginWithToken(userID, passToken string) error { + req, err := http.NewRequest("GET", "https://account.xiaomi.com/pass/serviceLogin?_json=true&sid="+c.sid, nil) + if err != nil { + return err + } + + req.Header.Set("Cookie", fmt.Sprintf("userId=%s; passToken=%s", userID, passToken)) + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Ssecurity []byte `json:"ssecurity"` + PassToken string `json:"passToken"` + Location string `json:"location"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + c.ssecurity = v1.Ssecurity + c.passToken = v1.PassToken + + return c.finishAuth(v1.Location) +} + +func (c *Cloud) UserToken() (string, string) { + return c.userID, c.passToken +} + +func (c *Cloud) Request(baseURL, apiURL, params string, headers map[string]string) ([]byte, error) { + form := url.Values{"data": {params}} + + nonce := genNonce() + signedNonce := genSignedNonce(c.ssecurity, nonce) + + // 1. gen hash for data param + form.Set("rc4_hash__", genSignature64("POST", apiURL, form, signedNonce)) + + // 2. encrypt data and hash params + for _, v := range form { + ciphertext, err := crypt(signedNonce, []byte(v[0])) + if err != nil { + return nil, err + } + v[0] = base64.StdEncoding.EncodeToString(ciphertext) + } + + // 3. add signature for encrypted data and hash params + form.Set("signature", genSignature64("POST", apiURL, form, signedNonce)) + + // 4. add nonce + form.Set("_nonce", base64.StdEncoding.EncodeToString(nonce)) + + req, err := http.NewRequest("POST", baseURL+apiURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Cookie", c.cookies) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + for k, v := range headers { + req.Header.Set(k, v) + } + + res, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, errors.New(res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + ciphertext, err := base64.StdEncoding.DecodeString(string(body)) + if err != nil { + return nil, err + } + + plaintext, err := crypt(signedNonce, ciphertext) + if err != nil { + return nil, err + } + + var res1 struct { + Code int `json:"code"` + Message string `json:"message"` + Result json.RawMessage `json:"result"` + } + if err = json.Unmarshal(plaintext, &res1); err != nil { + return nil, err + } + + if res1.Code != 0 { + return nil, errors.New("xiaomi: " + res1.Message) + } + + return res1.Result, nil +} + +func readLoginResponse(rc io.ReadCloser, v any) ([]byte, error) { + defer rc.Close() + + body, err := io.ReadAll(rc) + if err != nil { + return nil, err + } + + body, ok := bytes.CutPrefix(body, []byte("&&&START&&&")) + if !ok { + return nil, fmt.Errorf("xiaomi: %s", body) + } + + return body, json.Unmarshal(body, &v) +} + +func genNonce() []byte { + ts := time.Now().Unix() / 60 + + nonce := make([]byte, 12) + _, _ = rand.Read(nonce[:8]) + binary.BigEndian.PutUint32(nonce[8:], uint32(ts)) + return nonce +} + +func genSignedNonce(ssecurity, nonce []byte) []byte { + hasher := sha256.New() + hasher.Write(ssecurity) + hasher.Write(nonce) + return hasher.Sum(nil) +} + +func crypt(key, plaintext []byte) ([]byte, error) { + cipher, err := rc4.NewCipher(key) + if err != nil { + return nil, err + } + + tmp := make([]byte, 1024) + cipher.XORKeyStream(tmp, tmp) + + ciphertext := make([]byte, len(plaintext)) + cipher.XORKeyStream(ciphertext, plaintext) + + return ciphertext, nil +} + +func genSignature64(method, path string, values url.Values, signedNonce []byte) string { + s := method + "&" + path + "&data=" + values.Get("data") + if values.Has("rc4_hash__") { + s += "&rc4_hash__=" + values.Get("rc4_hash__") + } + s += "&" + base64.StdEncoding.EncodeToString(signedNonce) + + hasher := sha1.New() + hasher.Write([]byte(s)) + signature := hasher.Sum(nil) + + return base64.StdEncoding.EncodeToString(signature) +} + +type Request struct { + Method string + URL string + RawParams string + Body url.Values + Headers url.Values + RawCookies string +} + +func (r Request) Encode() *http.Request { + if r.RawParams != "" { + r.URL += "?" + r.RawParams + } + + var body io.Reader + if r.Body != nil { + body = strings.NewReader(r.Body.Encode()) + } + + req, err := http.NewRequest(r.Method, r.URL, body) + if err != nil { + return nil + } + + if r.Headers != nil { + req.Header = http.Header(r.Headers) + } + if r.Body != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + if r.RawCookies != "" { + req.Header.Set("Cookie", r.RawCookies) + } + + return req +} diff --git a/pkg/xiaomi/crypto/crypto.go b/pkg/xiaomi/crypto/crypto.go new file mode 100644 index 00000000..16d6d1bc --- /dev/null +++ b/pkg/xiaomi/crypto/crypto.go @@ -0,0 +1,68 @@ +package crypto + +import ( + "crypto/rand" + "encoding/hex" + + "golang.org/x/crypto/chacha20" + "golang.org/x/crypto/nacl/box" +) + +func GenerateKey() ([]byte, []byte, error) { + public, private, err := box.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, err + } + return public[:], private[:], err +} + +func CalcSharedKey(devicePublicB64, clientPrivateB64 string) ([]byte, error) { + var sharedKey, publicKey, privateKey [32]byte + if _, err := hex.Decode(publicKey[:], []byte(devicePublicB64)); err != nil { + return nil, err + } + if _, err := hex.Decode(privateKey[:], []byte(clientPrivateB64)); err != nil { + return nil, err + } + box.Precompute(&sharedKey, &publicKey, &privateKey) + return sharedKey[:], nil +} + +func Encode(src, key32 []byte) ([]byte, error) { + dst := make([]byte, len(src)+8) + + if _, err := rand.Read(dst[:8]); err != nil { + return nil, err + } + + nonce12 := make([]byte, 12) + copy(nonce12[4:], dst[:8]) + + c, err := chacha20.NewUnauthenticatedCipher(key32, nonce12) + if err != nil { + return nil, err + } + + c.XORKeyStream(dst[8:], src) + + return dst, nil +} + +func Decode(src, key32 []byte) ([]byte, error) { + return DecodeNonce(src[8:], src[:8], key32) +} + +func DecodeNonce(src, nonce8, key32 []byte) ([]byte, error) { + nonce12 := make([]byte, 12) + copy(nonce12[4:], nonce8) + + c, err := chacha20.NewUnauthenticatedCipher(key32, nonce12) + if err != nil { + return nil, err + } + + dst := make([]byte, len(src)) + c.XORKeyStream(dst, src) + + return dst, nil +} diff --git a/pkg/xiaomi/legacy/client.go b/pkg/xiaomi/legacy/client.go new file mode 100644 index 00000000..ab17e031 --- /dev/null +++ b/pkg/xiaomi/legacy/client.go @@ -0,0 +1,247 @@ +package legacy + +import ( + "encoding/binary" + "errors" + "fmt" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" +) + +func NewClient(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + model := query.Get("model") + + var username, password string + var key []byte + + if query.Has("sign") { + // Legacy with encryption + key, err = crypto.CalcSharedKey(query.Get("device_public"), query.Get("client_private")) + if err != nil { + return nil, err + } + + username = fmt.Sprintf( + `{"public_key":"%s","sign":"%s","account":"admin"}`, + query.Get("client_public"), query.Get("sign"), + ) + } else if model == ModelMijia || model == ModelXiaobai { + username = "admin" + password = query.Get("password") + } else if model == ModelXiaofang { + username = "admin" + } else { + return nil, fmt.Errorf("xiaomi: unsupported model: %s", model) + } + + conn, err := tutk.Dial(u.Host, query.Get("uid"), username, password) + if err != nil { + return nil, err + } + + if model == ModelXiaofang { + err = xiaofangLogin(conn, query.Get("password")) + if err != nil { + _ = conn.Close() + return nil, err + } + } + + c := &Client{ + Conn: conn, + key: key, + model: model, + } + + return c, nil +} + +func xiaofangLogin(conn *tutk.Conn, password string) error { + data := tutk.ICAM(0x0400be) // ask login + if err := conn.WriteCommand(0x0100, data); err != nil { + return err + } + + _, data, err := conn.ReadCommand() // login request + if err != nil { + return err + } + + enc := data[24:] // data[23] == 3 + tutk.XXTEADecrypt(enc, enc, []byte(password)) + + enc = append(enc, 0, 0, 0, 0, 1, 1, 1) + data = tutk.ICAM(0x0400c0, enc...) // login response + if err = conn.WriteCommand(0x0100, data); err != nil { + return err + } + + _, data, err = conn.ReadCommand() + return err +} + +type Client struct { + *tutk.Conn + key []byte + model string +} + +func (c *Client) Version() string { + return fmt.Sprintf("%s (%s)", c.Conn.Version(), c.model) +} + +func (c *Client) ReadPacket() (hdr, payload []byte, err error) { + hdr, payload, err = c.Conn.ReadPacket() + if err != nil { + return + } + if c.key != nil { + switch hdr[0] { + case tutk.CodecH264, tutk.CodecH265: + payload, err = DecodeVideo(payload, c.key) + case tutk.CodecAACLATM: + payload, err = crypto.Decode(payload, c.key) + } + } + return +} + +const ( + cmdVideoStart = 0x01ff + cmdVideoStop = 0x02ff + cmdAudioStart = 0x0300 + cmdAudioStop = 0x0301 + cmdStreamCtrlReq = 0x0320 +) + +var empty = []byte(`{}`) + +func (c *Client) StartMedia(video, audio string) error { + switch c.model { + case ModelAqaraG2: + return c.WriteCommand(cmdVideoStart, empty) + + case ModelMijia: + // 0 - auto, 1 - low, 3 - hd + switch video { + case "", "hd": + video = "3" + case "sd": + video = "1" // 2 is also low quality + case "auto": + video = "0" + } + s := fmt.Sprintf(`{"videoquality":%s}`, video) + + // quality after start + return errors.Join( + c.WriteCommand(cmdAudioStart, empty), + c.WriteCommand(cmdVideoStart, empty), + c.WriteCommand(cmdStreamCtrlReq, []byte(s)), + ) + + case ModelXiaobai: + // 00030000 7b7d audio on + // 01030000 7b7d audio off + // 20030000 0000000001000000 fhd (1920x1080) + // 20030000 0000000002000000 hd (1280x720) + // 20030000 0000000004000000 low (640x360) + // 20030000 00000000ff000000 auto (1920x1080) + // ff010000 7b7d video tart + // ff020000 7b7d video stop + + var b byte + switch video { + case "", "fhd": + b = 1 + case "hd": + b = 2 + case "sd": + b = 4 + case "auto": + b = 0xff + } + + // quality before start + return errors.Join( + c.WriteCommand(cmdAudioStart, empty), + c.WriteCommand(cmdStreamCtrlReq, []byte{0, 0, 0, 0, b, 0, 0, 0}), + c.WriteCommand(cmdVideoStart, empty), + ) + + case ModelXiaofang: + // 00010000 4943414d 95010400000000000000000600000000000000d20400005a07 - 90k bitrate + // 00010000 4943414d 95010400000000000000000600000000000000d20400001e07 - 30k bitrate + //var b byte + //switch video { + //case "", "hd": + // b = 0x5a // bitrate 90k + //case "sd": + // b = 0x1e // bitrate 30k + //} + //data := tutk.ICAM(0x040195, 0xd2, 4, 0, 0, b, 7) + //if err := c.WriteCommand(0x100, data); err != nil { + // return err + //} + } + + return nil +} + +func (c *Client) StopMedia() error { + return errors.Join( + c.WriteCommand(cmdVideoStop, empty), + c.WriteCommand(cmdVideoStop, make([]byte, 8)), + ) +} + +func DecodeVideo(data, key []byte) ([]byte, error) { + if string(data[:4]) == "\x00\x00\x00\x01" || data[8] == 0 { + return data, nil + } + + if data[8] != 1 { + // Support could be added, but I haven't seen such cameras. + return nil, fmt.Errorf("xiaomi: unsupported encryption") + } + + nonce8 := data[:8] + i1 := binary.LittleEndian.Uint16(data[9:]) + i2 := binary.LittleEndian.Uint16(data[13:]) + data = data[17:] + src := data[i1 : i1+i2] + + for i := 32; i+16 < len(src); i += 160 { + dst, err := crypto.DecodeNonce(src[i:i+16], nonce8, key) + if err != nil { + return nil, err + } + copy(src[i:], dst) // copy result in same place + } + + return data, nil +} + +const ( + ModelAqaraG2 = "lumi.camera.gwagl01" + ModelLoockV1 = "loock.cateye.v01" + ModelMijia = "chuangmi.camera.v2" // support miss format for new fw and legacy format for old fw + ModelXiaobai = "chuangmi.camera.xiaobai" + ModelXiaofang = "isa.camera.isc5" +) + +func Supported(model string) bool { + switch model { + case ModelAqaraG2, ModelLoockV1, ModelXiaobai, ModelXiaofang: + return true + } + return false +} diff --git a/pkg/xiaomi/legacy/producer.go b/pkg/xiaomi/legacy/producer.go new file mode 100644 index 00000000..92375faf --- /dev/null +++ b/pkg/xiaomi/legacy/producer.go @@ -0,0 +1,216 @@ +package legacy + +import ( + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/pion/rtp" +) + +func Dial(rawURL string) (*Producer, error) { + client, err := NewClient(rawURL) + if err != nil { + return nil, err + } + + u, _ := url.Parse(rawURL) + query := u.Query() + + err = client.StartMedia(query.Get("subtype"), "") + if err != nil { + _ = client.Close() + return nil, err + } + + medias, err := probe(client) + if err != nil { + _ = client.Close() + return nil, err + } + + c := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "xiaomi/legacy", + Protocol: "tutk+udp", + RemoteAddr: client.RemoteAddr().String(), + UserAgent: client.Version(), + Medias: medias, + Transport: client, + }, + client: client, + } + return c, nil +} + +type Producer struct { + core.Connection + client *Client +} + +const codecXiaobaiPCMA = 1 // chuangmi.camera.xiaobai + +func probe(client *Client) ([]*core.Media, error) { + _ = client.SetDeadline(time.Now().Add(15 * time.Second)) + + var vcodec, acodec *core.Codec + + for { + // 0 5000 codec + // 2 0000 codec params + // 4 01 active clients + // 5 34 unknown const + // 6 0600 unknown seq(s) + // 8 80026801 unknown fixed + // 12 ed8d5c69 time in sec + // 16 4c03 time in 1/1000 + // 18 0000 + hdr, payload, err := client.ReadPacket() + if err != nil { + return nil, err + } + + switch codec := hdr[0]; codec { + case tutk.CodecH264, tutk.CodecH265: + if vcodec == nil { + avcc := annexb.EncodeToAVCC(payload) + if codec == tutk.CodecH264 { + if h264.NALUType(avcc) == h264.NALUTypeSPS { + vcodec = h264.AVCCToCodec(avcc) + } + } else { + if h265.NALUType(avcc) == h265.NALUTypeVPS { + vcodec = h265.AVCCToCodec(avcc) + } + } + } + case tutk.CodecPCMA, codecXiaobaiPCMA: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: 8000} + } + case tutk.CodecPCML: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCML, ClockRate: 8000} + } + case tutk.CodecAACLATM: + if acodec == nil { + acodec = aac.ADTSToCodec(payload) + if acodec != nil { + acodec.PayloadType = core.PayloadTypeRAW + } + } + } + + if vcodec != nil && acodec != nil { + break + } + } + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{vcodec}, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{acodec}, + }, + } + return medias, nil +} + +func (c *Producer) Protocol() string { + return "tutk+udp" +} + +func (c *Producer) Start() error { + var audioTS uint32 + var videoSeq, audioSeq uint16 + + for { + _ = c.client.SetDeadline(time.Now().Add(5 * time.Second)) + hdr, payload, err := c.client.ReadPacket() + if err != nil { + return err + } + + n := len(payload) + c.Recv += n + + // TODO: rewrite this + var name string + var pkt *core.Packet + + switch codec := hdr[0]; codec { + case tutk.CodecH264, tutk.CodecH265: + pkt = &core.Packet{ + Header: rtp.Header{ + SequenceNumber: videoSeq, + Timestamp: core.Now90000(), + }, + Payload: annexb.EncodeToAVCC(payload), + } + videoSeq++ + + if codec == tutk.CodecH264 { + name = core.CodecH264 + } else { + name = core.CodecH265 + } + + case tutk.CodecPCMA, tutk.CodecPCML, codecXiaobaiPCMA: + pkt = &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: audioSeq, + Timestamp: audioTS, + }, + Payload: payload, + } + audioSeq++ + + switch codec { + case tutk.CodecPCMA, codecXiaobaiPCMA: + name = core.CodecPCMA + audioTS += uint32(n) + case tutk.CodecPCML: + name = core.CodecPCML + audioTS += uint32(n / 2) // because 16bit + } + + case tutk.CodecAACLATM: + pkt = &core.Packet{ + Header: rtp.Header{ + SequenceNumber: audioSeq, + Timestamp: audioTS, + }, + Payload: payload, + } + audioSeq++ + + name = core.CodecAAC + audioTS += 1024 + } + + for _, recv := range c.Receivers { + if recv.Codec.Name == name { + recv.WriteRTP(pkt) + break + } + } + } +} + +func (c *Producer) Stop() error { + _ = c.client.StopMedia() + return c.Connection.Stop() +} diff --git a/pkg/xiaomi/miss/backchannel.go b/pkg/xiaomi/miss/backchannel.go new file mode 100644 index 00000000..02ea3bb1 --- /dev/null +++ b/pkg/xiaomi/miss/backchannel.go @@ -0,0 +1,74 @@ +package miss + +import ( + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/opus" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/pion/rtp" +) + +func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + if err := p.client.StartSpeaker(); err != nil { + return err + } + // TODO: check this!!! + time.Sleep(time.Second) + + sender := core.NewSender(media, track.Codec) + + switch track.Codec.Name { + case core.CodecPCMA: + var buf []byte + + if p.client.SpeakerCodec() == codecPCM { + dst := &core.Codec{Name: core.CodecPCML, ClockRate: 8000} + transcode := pcm.Transcode(dst, track.Codec) + + sender.Handler = func(pkt *rtp.Packet) { + buf = append(buf, transcode(pkt.Payload)...) + const size = 2 * 8000 * 0.040 // 16bit 40ms + for len(buf) >= size { + p.Send += size + _ = p.client.WriteAudio(codecPCM, buf[:size]) + buf = buf[size:] + } + } + } else { + sender.Handler = func(pkt *rtp.Packet) { + buf = append(buf, pkt.Payload...) + const size = 8000 * 0.040 // 8bit 40 ms + for len(buf) >= size { + p.Send += size + _ = p.client.WriteAudio(codecPCMA, buf[:size]) + buf = buf[size:] + } + } + } + case core.CodecOpus: + if p.client.SpeakerCodec() == codecOPUS { + var buf []byte + sender.Handler = func(pkt *rtp.Packet) { + if buf == nil { + buf = pkt.Payload + } else { + // convert two 20ms to one 40ms + buf = opus.JoinFrames(buf, pkt.Payload) + p.Send += len(buf) + _ = p.client.WriteAudio(codecOPUS, buf) + buf = nil + } + } + } else { + sender.Handler = func(pkt *rtp.Packet) { + p.Send += len(pkt.Payload) + _ = p.client.WriteAudio(codecOPUS, pkt.Payload) + } + } + } + + sender.HandleRTP(track) + p.Senders = append(p.Senders, sender) + return nil +} diff --git a/pkg/xiaomi/miss/client.go b/pkg/xiaomi/miss/client.go new file mode 100644 index 00000000..6eaa06cf --- /dev/null +++ b/pkg/xiaomi/miss/client.go @@ -0,0 +1,316 @@ +package miss + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss/cs2" +) + +const ( + codecH264 = 4 + codecH265 = 5 + codecPCM = 1024 + codecPCMU = 1026 + codecPCMA = 1027 + codecOPUS = 1032 +) + +type Conn interface { + Protocol() string + Version() string + ReadCommand() (cmd uint32, data []byte, err error) + WriteCommand(cmd uint32, data []byte) error + ReadPacket() (hdr, payload []byte, err error) + WritePacket(hdr, payload []byte) error + RemoteAddr() net.Addr + SetDeadline(t time.Time) error + Close() error +} + +func NewClient(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + // 1. Check if we can create shared key. + query := u.Query() + key, err := crypto.CalcSharedKey(query.Get("device_public"), query.Get("client_private")) + if err != nil { + return nil, err + } + + model := query.Get("model") + + // 2. Check if this vendor supported. + var conn Conn + switch s := query.Get("vendor"); s { + case "cs2": + conn, err = cs2.Dial(u.Host, query.Get("transport")) + case "tutk": + conn, err = tutk.Dial(u.Host, query.Get("uid"), "Miss", "client") + default: + err = fmt.Errorf("miss: unsupported vendor %s", s) + } + + if err != nil { + return nil, err + } + + err = login(conn, query.Get("client_public"), query.Get("sign")) + if err != nil { + _ = conn.Close() + return nil, err + } + + return &Client{Conn: conn, key: key, model: model}, nil +} + +type Client struct { + Conn + key []byte + model string +} + +const ( + cmdAuthReq = 0x100 + cmdAuthRes = 0x101 + cmdVideoStart = 0x102 + cmdVideoStop = 0x103 + cmdAudioStart = 0x104 + cmdAudioStop = 0x105 + cmdSpeakerStartReq = 0x106 + cmdSpeakerStartRes = 0x107 + cmdSpeakerStop = 0x108 + cmdStreamCtrlReq = 0x109 + cmdStreamCtrlRes = 0x10A + cmdGetAudioFormatReq = 0x10B + cmdGetAudioFormatRes = 0x10C + cmdPlaybackReq = 0x10D + cmdPlaybackRes = 0x10E + cmdDevInfoReq = 0x110 + cmdDevInfoRes = 0x111 + cmdMotorReq = 0x112 + cmdMotorRes = 0x113 + cmdEncoded = 0x1001 +) + +func login(conn Conn, clientPublic, sign string) error { + s := fmt.Sprintf(`{"public_key":"%s","sign":"%s","uuid":"","support_encrypt":0}`, clientPublic, sign) + if err := conn.WriteCommand(cmdAuthReq, []byte(s)); err != nil { + return err + } + + _, data, err := conn.ReadCommand() + if err != nil { + return err + } + + if !bytes.Contains(data, []byte(`"result":"success"`)) { + return fmt.Errorf("miss: auth: %s", data) + } + + return nil +} + +func (c *Client) Version() string { + return fmt.Sprintf("%s (%s)", c.Conn.Version(), c.model) +} + +func (c *Client) WriteCommand(data []byte) error { + data, err := crypto.Encode(data, c.key) + if err != nil { + return err + } + return c.Conn.WriteCommand(cmdEncoded, data) +} + +const ( + ModelDafang = "isa.camera.df3" + ModelLoockV2 = "loock.cateye.v02" + ModelC200 = "chuangmi.camera.046c04" + ModelC300 = "chuangmi.camera.72ac1" +) + +func (c *Client) StartMedia(channel, quality, audio string) error { + switch c.model { + case ModelDafang: + var q, a byte + if quality == "sd" { + q = 1 // 0 - hd, 1 - sd, default - hd + } + if audio != "0" { + a = 1 // 0 - off, 1 - on, default - on + } + + return errors.Join( + c.WriteCommand(dafangVideoQuality(q)), + c.WriteCommand(dafangVideoStart(1, a)), + ) + } + + // 0 - auto, 1 - sd, 2 - hd, default - hd + switch quality { + case "", "hd": + // Some models have broken codec settings in quality 3. + // Some models have low quality in quality 2. + // Different models require different default quality settings. + switch c.model { + case ModelC200, ModelC300: + quality = "3" + default: + quality = "2" + } + case "sd": + quality = "1" + case "auto": + quality = "0" + } + + if audio == "" { + audio = "1" + } + + data := binary.BigEndian.AppendUint32(nil, cmdVideoStart) + if channel == "" { + data = fmt.Appendf(data, `{"videoquality":%s,"enableaudio":%s}`, quality, audio) + } else { + data = fmt.Appendf(data, `{"videoquality":-1,"videoquality2":%s,"enableaudio":%s}`, quality, audio) + } + return c.WriteCommand(data) +} + +func (c *Client) StopMedia() error { + data := binary.BigEndian.AppendUint32(nil, cmdVideoStop) + return c.WriteCommand(data) +} + +func (c *Client) StartAudio() error { + data := binary.BigEndian.AppendUint32(nil, cmdAudioStart) + return c.WriteCommand(data) +} + +func (c *Client) StartSpeaker() error { + data := binary.BigEndian.AppendUint32(nil, cmdSpeakerStartReq) + return c.WriteCommand(data) +} + +// SpeakerCodec if the camera model has a non-standard two-way codec. +func (c *Client) SpeakerCodec() uint32 { + switch c.model { + case ModelDafang, "isa.camera.hlc6": + return codecPCM + case "chuangmi.camera.72ac1": + return codecOPUS + } + return 0 +} + +const hdrSize = 32 + +func (c *Client) ReadPacket() (*Packet, error) { + hdr, payload, err := c.Conn.ReadPacket() + if err != nil { + return nil, fmt.Errorf("miss: read media: %w", err) + } + + if len(hdr) < hdrSize { + return nil, fmt.Errorf("miss: packet header too small") + } + + payload, err = crypto.Decode(payload, c.key) + if err != nil { + return nil, err + } + + pkt := &Packet{ + CodecID: binary.LittleEndian.Uint32(hdr[4:]), + Sequence: binary.LittleEndian.Uint32(hdr[8:]), + Flags: binary.LittleEndian.Uint32(hdr[12:]), + Payload: payload, + } + + switch c.model { + case ModelDafang, ModelLoockV2: + // Dafang has ts in sec + // LoockV2 has ts in msec for video, but zero ts for audio + pkt.Timestamp = uint64(time.Now().UnixMilli()) + default: + pkt.Timestamp = binary.LittleEndian.Uint64(hdr[16:]) + } + + return pkt, nil +} + +func (c *Client) WriteAudio(codecID uint32, payload []byte) error { + payload, err := crypto.Encode(payload, c.key) // new payload will have new size! + if err != nil { + return err + } + + n := uint32(len(payload)) + + header := make([]byte, hdrSize) + binary.LittleEndian.PutUint32(header, n) + binary.LittleEndian.PutUint32(header[4:], codecID) + binary.LittleEndian.PutUint64(header[16:], uint64(time.Now().UnixMilli())) // not really necessary + return c.Conn.WritePacket(header, payload) +} + +type Packet struct { + //Length uint32 + CodecID uint32 + Sequence uint32 + Flags uint32 + Timestamp uint64 // msec + //TimestampS uint32 + //Reserved uint32 + Payload []byte +} + +func dafangRaw(cmd uint32, args ...byte) []byte { + payload := tutk.ICAM(cmd, args...) + + data := make([]byte, 4+len(payload)*2) + copy(data, "\x7f\xff\xff\xff") + hex.Encode(data[4:], payload) + return data +} + +// DafangVideoQuality 0 - hd, 1 - sd +func dafangVideoQuality(quality uint8) []byte { + return dafangRaw(0xff07d5, quality) +} + +func dafangVideoStart(video, audio uint8) []byte { + return dafangRaw(0xff07d8, video, audio) +} + +//func dafangLeft() []byte { +// return dafangRaw(0xff2404, 2, 0, 5) +//} +// +//func dafangRight() []byte { +// return dafangRaw(0xff2404, 1, 0, 5) +//} +// +//func dafangUp() []byte { +// return dafangRaw(0xff2404, 0, 2, 5) +//} +// +//func dafangDown() []byte { +// return dafangRaw(0xff2404, 0, 1, 5) +//} +// +//func dafangStop() []byte { +// return dafangRaw(0xff2404, 0, 0, 5) +//} diff --git a/pkg/xiaomi/miss/cs2/conn.go b/pkg/xiaomi/miss/cs2/conn.go new file mode 100644 index 00000000..1bea07c6 --- /dev/null +++ b/pkg/xiaomi/miss/cs2/conn.go @@ -0,0 +1,505 @@ +package cs2 + +import ( + "bufio" + "bytes" + "encoding/binary" + "fmt" + "io" + "net" + "sync" + "sync/atomic" + "time" +) + +func Dial(host, transport string) (*Conn, error) { + conn, err := handshake(host, transport) + if err != nil { + return nil, err + } + + _, isTCP := conn.(*tcpConn) + + c := &Conn{ + Conn: conn, + isTCP: isTCP, + channels: [4]*dataChannel{ + newDataChannel(0, 10), nil, newDataChannel(250, 100), nil, + }, + } + go c.worker() + return c, nil +} + +type Conn struct { + net.Conn + isTCP bool + + err error + seqCh0 uint16 + seqCh3 uint16 + + channels [4]*dataChannel + + cmdMu sync.Mutex + cmdAck func() +} + +const ( + magic = 0xF1 + magicDrw = 0xD1 + magicTCP = 0x68 + msgLanSearch = 0x30 + msgPunchPkt = 0x41 + msgP2PRdyUDP = 0x42 + msgP2PRdyTCP = 0x43 + msgDrw = 0xD0 + msgDrwAck = 0xD1 + msgPing = 0xE0 + msgPong = 0xE1 + msgClose = 0xF1 +) + +func handshake(host, transport string) (net.Conn, error) { + conn, err := newUDPConn(host, 32108) + if err != nil { + return nil, err + } + + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + + req := []byte{magic, msgLanSearch, 0, 0} + res, err := conn.(*udpConn).WriteUntil(req, func(res []byte) bool { + return res[1] == msgPunchPkt + }) + if err != nil { + _ = conn.Close() + return nil, err + } + + var msgUDP, msgTCP byte + + if transport == "" || transport == "udp" { + msgUDP = msgP2PRdyUDP + } + if transport == "" || transport == "tcp" { + msgTCP = msgP2PRdyTCP + } + + res, err = conn.(*udpConn).WriteUntil(res, func(res []byte) bool { + return res[1] == msgUDP || res[1] == msgTCP + }) + if err != nil { + _ = conn.Close() + return nil, err + } + + _ = conn.SetDeadline(time.Time{}) + + if res[1] == msgTCP { + _ = conn.Close() + //host := fmt.Sprintf("%d.%d.%d.%d:%d", b[31], b[30], b[29], b[28], uint16(b[27])<<8|uint16(b[26])) + return newTCPConn(conn.RemoteAddr().String()) + } + + return conn, nil +} + +func (c *Conn) worker() { + defer func() { + c.channels[0].Close() + c.channels[2].Close() + }() + + var keepaliveTS time.Time // only for TCP + + buf := make([]byte, 1200) + + for { + n, err := c.Conn.Read(buf) + if err != nil { + c.err = fmt.Errorf("%s: %w", "cs2", err) + return + } + + // 0 f1d0 magic + // 2 005d size = total size + 4 + // 4 d1 magic + // 5 00 channel + // 6 0000 seq + switch buf[1] { + case msgDrw: + ch := buf[5] + channel := c.channels[ch] + + if c.isTCP { + // For TCP we should send ping every second to keep connection alive. + // Based on PCAP analysis: official Mi Home app sends PING every ~1s. + if now := time.Now(); now.After(keepaliveTS) { + _, _ = c.Conn.Write([]byte{magic, msgPing, 0, 0}) + keepaliveTS = now.Add(time.Second) + } + + err = channel.Push(buf[8:n]) + } else { + var pushed int + + seqHI, seqLO := buf[6], buf[7] + seq := uint16(seqHI)<<8 | uint16(seqLO) + pushed, err = channel.PushSeq(seq, buf[8:n]) + + if pushed >= 0 { + // For UDP we should send ACK. + ack := []byte{magic, msgDrwAck, 0, 6, magicDrw, ch, 0, 1, seqHI, seqLO} + _, _ = c.Conn.Write(ack) + } + } + + if err != nil { + c.err = fmt.Errorf("%s: %w", "cs2", err) + return + } + + case msgPing: + _, _ = c.Conn.Write([]byte{magic, msgPong, 0, 0}) + case msgPong, msgP2PRdyUDP, msgP2PRdyTCP, msgClose: // skip it + case msgDrwAck: // only for UDP + if c.cmdAck != nil { + c.cmdAck() + } + default: + fmt.Printf("%s: unknown msg: %x\n", "cs2", buf[:n]) + } + } +} + +func (c *Conn) Protocol() string { + if c.isTCP { + return "cs2+tcp" + } + return "cs2+udp" +} + +func (c *Conn) Version() string { + return "CS2" +} + +func (c *Conn) Error() error { + if c.err != nil { + return c.err + } + return io.EOF +} + +func (c *Conn) ReadCommand() (cmd uint32, data []byte, err error) { + buf, ok := c.channels[0].Pop() + if !ok { + return 0, nil, c.Error() + } + cmd = binary.LittleEndian.Uint32(buf) + data = buf[4:] + return +} + +func (c *Conn) WriteCommand(cmd uint32, data []byte) error { + c.cmdMu.Lock() + defer c.cmdMu.Unlock() + + req := marshalCmd(0, c.seqCh0, cmd, data) + c.seqCh0++ + + if c.isTCP { + _, err := c.Conn.Write(req) + return err + } + + var repeat atomic.Int32 + repeat.Store(5) + + timeout := time.NewTicker(time.Second) + defer timeout.Stop() + + c.cmdAck = func() { + repeat.Store(0) + timeout.Reset(1) + } + + for { + if _, err := c.Conn.Write(req); err != nil { + return err + } + <-timeout.C + r := repeat.Add(-1) + if r < 0 { + return nil + } + if r == 0 { + return fmt.Errorf("%s: can't send command %d", "cs2", cmd) + } + } +} + +const hdrSize = 32 + +func (c *Conn) ReadPacket() (hdr, payload []byte, err error) { + data, ok := c.channels[2].Pop() + if !ok { + return nil, nil, c.Error() + } + return data[:hdrSize], data[hdrSize:], nil +} + +func (c *Conn) WritePacket(hdr, payload []byte) error { + const offset = 12 + + n := hdrSize + uint32(len(payload)) + req := make([]byte, n+offset) + req[0] = magic + req[1] = msgDrw + binary.BigEndian.PutUint16(req[2:], uint16(n+8)) + + req[4] = magicDrw + req[5] = 3 // channel + binary.BigEndian.PutUint16(req[6:], c.seqCh3) + c.seqCh3++ + binary.BigEndian.PutUint32(req[8:], n) + copy(req[offset:], hdr) + copy(req[offset+hdrSize:], hdr) + + _, err := c.Conn.Write(req) + return err +} + +func marshalCmd(channel byte, seq uint16, cmd uint32, payload []byte) []byte { + size := len(payload) + req := make([]byte, 4+4+4+4+size) + + // 1. message header (4 bytes) + req[0] = magic + req[1] = msgDrw + binary.BigEndian.PutUint16(req[2:], uint16(4+4+4+size)) + + // 2. drw? header (4 bytes) + req[4] = magicDrw + req[5] = channel + binary.BigEndian.PutUint16(req[6:], seq) + + // 3. payload size (4 bytes) + binary.BigEndian.PutUint32(req[8:], uint32(4+size)) + + // 4. payload command (4 bytes) + binary.BigEndian.PutUint32(req[12:], cmd) + + // 5. payload + copy(req[16:], payload) + + return req +} + +func newUDPConn(host string, port int) (net.Conn, error) { + // We using raw net.UDPConn, because RemoteAddr should be changed during handshake. + conn, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, err + } + + addr, err := net.ResolveUDPAddr("udp", host) + if err != nil { + addr = &net.UDPAddr{IP: net.ParseIP(host), Port: port} + } + + return &udpConn{UDPConn: conn, addr: addr}, nil +} + +type udpConn struct { + *net.UDPConn + addr *net.UDPAddr +} + +func (c *udpConn) Read(b []byte) (n int, err error) { + var addr *net.UDPAddr + for { + n, addr, err = c.UDPConn.ReadFromUDP(b) + if err != nil { + return 0, err + } + + if string(addr.IP) == string(c.addr.IP) || n >= 8 { + //log.Printf("<- %x", b[:n]) + return + } + } +} + +func (c *udpConn) Write(b []byte) (n int, err error) { + //log.Printf("-> %x", b) + return c.UDPConn.WriteToUDP(b, c.addr) +} + +func (c *udpConn) RemoteAddr() net.Addr { + return c.addr +} + +func (c *udpConn) WriteUntil(req []byte, ok func(res []byte) bool) ([]byte, error) { + var t *time.Timer + t = time.AfterFunc(1, func() { + if _, err := c.Write(req); err == nil && t != nil { + t.Reset(time.Second) + } + }) + defer t.Stop() + + buf := make([]byte, 1200) + + for { + n, addr, err := c.UDPConn.ReadFromUDP(buf) + if err != nil { + return nil, err + } + + if string(addr.IP) != string(c.addr.IP) || n < 16 { + continue // skip messages from another IP + } + + if ok(buf[:n]) { + c.addr.Port = addr.Port + return buf[:n], nil + } + } +} + +func newTCPConn(addr string) (net.Conn, error) { + conn, err := net.DialTimeout("tcp", addr, 3*time.Second) + if err != nil { + return nil, err + } + return &tcpConn{conn.(*net.TCPConn), bufio.NewReader(conn)}, nil +} + +type tcpConn struct { + *net.TCPConn + rd *bufio.Reader +} + +func (c *tcpConn) Read(p []byte) (n int, err error) { + tmp := make([]byte, 8) + if _, err = io.ReadFull(c.rd, tmp); err != nil { + return + } + n = int(binary.BigEndian.Uint16(tmp)) + if len(p) < n { + return 0, fmt.Errorf("tcp: buffer too small") + } + _, err = io.ReadFull(c.rd, p[:n]) + //log.Printf("<- %x%x", tmp, p[:n]) + return +} + +func (c *tcpConn) Write(req []byte) (n int, err error) { + n = len(req) + buf := make([]byte, 8+n) + binary.BigEndian.PutUint16(buf, uint16(n)) + buf[2] = magicTCP + copy(buf[8:], req) + //log.Printf("-> %x", buf) + _, err = c.TCPConn.Write(buf) + return +} + +func newDataChannel(pushSize, popSize int) *dataChannel { + c := &dataChannel{} + if pushSize > 0 { + c.pushBuf = make(map[uint16][]byte, pushSize) + c.pushSize = pushSize + } + if popSize >= 0 { + c.popBuf = make(chan []byte, popSize) + } + return c +} + +type dataChannel struct { + waitSeq uint16 + pushBuf map[uint16][]byte + pushSize int + + waitData []byte + waitSize int + popBuf chan []byte +} + +func (c *dataChannel) Push(b []byte) error { + c.waitData = append(c.waitData, b...) + + for len(c.waitData) > 4 { + // Every new data starts with size. There can be several data inside one packet. + if c.waitSize == 0 { + c.waitSize = int(binary.BigEndian.Uint32(c.waitData)) + c.waitData = c.waitData[4:] + } + if c.waitSize > len(c.waitData) { + break + } + + select { + case c.popBuf <- c.waitData[:c.waitSize]: + default: + return fmt.Errorf("pop buffer is full") + } + + c.waitData = c.waitData[c.waitSize:] + c.waitSize = 0 + } + return nil +} + +func (c *dataChannel) Pop() ([]byte, bool) { + data, ok := <-c.popBuf + return data, ok +} + +func (c *dataChannel) Close() { + close(c.popBuf) +} + +// PushSeq returns how many seq were processed. +// Returns 0 if seq was saved or processed earlier. +// Returns -1 if seq could not be saved (buffer full or disabled). +func (c *dataChannel) PushSeq(seq uint16, data []byte) (int, error) { + diff := int16(seq - c.waitSeq) + // Check if this is seq from the future. + if diff > 0 { + // Support disabled buffer. + if c.pushSize == 0 { + return -1, nil // couldn't save seq + } + // Check if we don't have this seq in the buffer. + if c.pushBuf[seq] == nil { + // Check if there is enough space in the buffer. + if len(c.pushBuf) == c.pushSize { + return -1, nil // couldn't save seq + } + c.pushBuf[seq] = bytes.Clone(data) + //log.Printf("push buf wait=%d seq=%d len=%d", c.waitSeq, seq, len(c.pushBuf)) + } + return 0, nil + } + + // Check if this is seq from the past. + if diff < 0 { + return 0, nil + } + + for i := 1; ; i++ { + if err := c.Push(data); err != nil { + return i, err + } + c.waitSeq++ + // Check if we have next seq in the buffer. + if data = c.pushBuf[c.waitSeq]; data != nil { + delete(c.pushBuf, c.waitSeq) + } else { + return i, nil + } + } +} diff --git a/pkg/xiaomi/miss/producer.go b/pkg/xiaomi/miss/producer.go new file mode 100644 index 00000000..eeaa4969 --- /dev/null +++ b/pkg/xiaomi/miss/producer.go @@ -0,0 +1,204 @@ +package miss + +import ( + "fmt" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + client *Client +} + +func Dial(rawURL string) (core.Producer, error) { + client, err := NewClient(rawURL) + if err != nil { + return nil, err + } + + u, _ := url.Parse(rawURL) + query := u.Query() + + err = client.StartMedia(query.Get("channel"), query.Get("subtype"), query.Get("audio")) + if err != nil { + _ = client.Close() + return nil, err + } + + medias, err := probe(client, query.Get("audio") != "0") + if err != nil { + _ = client.Close() + return nil, err + } + + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "xiaomi/miss", + Protocol: client.Protocol(), + RemoteAddr: client.RemoteAddr().String(), + UserAgent: client.Version(), + Medias: medias, + Transport: client, + }, + client: client, + }, nil +} + +func probe(client *Client, audio bool) ([]*core.Media, error) { + _ = client.SetDeadline(time.Now().Add(15 * time.Second)) + + var vcodec, acodec *core.Codec + + for { + pkt, err := client.ReadPacket() + if err != nil { + if vcodec != nil { + err = fmt.Errorf("no audio") + } else if acodec != nil { + err = fmt.Errorf("no video") + } + return nil, fmt.Errorf("xiaomi: probe: %w", err) + } + + switch pkt.CodecID { + case codecH264: + if vcodec == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if h264.NALUType(buf) == h264.NALUTypeSPS { + vcodec = h264.AVCCToCodec(buf) + } + } + case codecH265: + if vcodec == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if h265.NALUType(buf) == h265.NALUTypeVPS { + vcodec = h265.AVCCToCodec(buf) + } + } + case codecPCMA: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: 8000} + } + case codecOPUS: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} + } + } + + if vcodec != nil && (acodec != nil || !audio) { + break + } + } + + _ = client.SetDeadline(time.Time{}) + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{vcodec}, + }, + } + + if acodec != nil { + medias = append(medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{acodec}, + }) + + medias = append(medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{acodec.Clone()}, + }) + } + + return medias, nil +} + +const timestamp40ms = 48000 * 0.040 + +func (p *Producer) Start() error { + var audioTS uint32 + + for { + _ = p.client.SetDeadline(time.Now().Add(10 * time.Second)) + pkt, err := p.client.ReadPacket() + if err != nil { + return err + } + + p.Recv += len(pkt.Payload) + + // TODO: rewrite this + var name string + var pkt2 *core.Packet + + switch pkt.CodecID { + case codecH264, codecH265: + pkt2 = &core.Packet{ + Header: rtp.Header{ + SequenceNumber: uint16(pkt.Sequence), + Timestamp: TimeToRTP(pkt.Timestamp, 90000), + }, + Payload: annexb.EncodeToAVCC(pkt.Payload), + } + if pkt.CodecID == codecH264 { + name = core.CodecH264 + } else { + name = core.CodecH265 + } + case codecPCMA: + name = core.CodecPCMA + pkt2 = &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: uint16(pkt.Sequence), + Timestamp: audioTS, + }, + Payload: pkt.Payload, + } + audioTS += uint32(len(pkt.Payload)) + case codecOPUS: + name = core.CodecOpus + pkt2 = &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: uint16(pkt.Sequence), + Timestamp: audioTS, + }, + Payload: pkt.Payload, + } + // known cameras sends packets with 40ms long + audioTS += timestamp40ms + } + + for _, recv := range p.Receivers { + if recv.Codec.Name == name { + recv.WriteRTP(pkt2) + break + } + } + } +} + +func (p *Producer) Stop() error { + _ = p.client.StopMedia() + return p.Connection.Stop() +} + +// TimeToRTP convert time in milliseconds to RTP time +func TimeToRTP(timeMS, clockRate uint64) uint32 { + return uint32(timeMS * clockRate / 1000) +} diff --git a/pkg/xiaomi/producer.go b/pkg/xiaomi/producer.go new file mode 100644 index 00000000..d0290f2f --- /dev/null +++ b/pkg/xiaomi/producer.go @@ -0,0 +1,23 @@ +package xiaomi + +import ( + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/legacy" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss" +) + +func Dial(rawURL string) (core.Producer, error) { + // Format: xiaomi/miss + if strings.Contains(rawURL, "vendor") { + return miss.Dial(rawURL) + } + + // Format: xiaomi/legacy + return legacy.Dial(rawURL) +} + +func IsLegacy(model string) bool { + return legacy.Supported(model) +} diff --git a/pkg/xnet/tls/tls.go b/pkg/xnet/tls/tls.go new file mode 100644 index 00000000..b4b6f60b --- /dev/null +++ b/pkg/xnet/tls/tls.go @@ -0,0 +1,63 @@ +package tls + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "time" +) + +func CreateCertificate() (*tls.Certificate, error) { + // 1. Generate an RSA private key + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + + // 2. Define the certificate template + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"home"}, + CommonName: "localhost", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), // Valid for 1 year + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + + // Add localhost as a valid IP and DNS name + IPAddresses: []net.IP{[]byte{127, 0, 0, 1}}, + DNSNames: []string{"localhost"}, + } + + // 3. Create a self-signed certificate + // The parent is the template itself, and we use the generated public and private keys. + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return nil, err + } + + derBytes = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + keyBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}) + + cert, err := tls.X509KeyPair(derBytes, keyBytes) + if err != nil { + return nil, err + } + + return &cert, nil +} diff --git a/website/index.html b/website/index.html index 443c925c..e339eb44 100644 --- a/website/index.html +++ b/website/index.html @@ -1,189 +1,28 @@ - - go2rtc - WebTorrent + + go2rtc + - -
- - - -
- - - +go2rtc +github.com/AlexxIT/go2rtc - \ No newline at end of file + diff --git a/website/manifest.json b/website/manifest.json index b33a6064..1bfd3571 100644 --- a/website/manifest.json +++ b/website/manifest.json @@ -2,12 +2,12 @@ "name": "go2rtc", "icons": [ { - "src": "https://alexxit.github.io/go2rtc/icons/android-chrome-192x192.png", + "src": "https://go2rtc.org/icons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "https://alexxit.github.io/go2rtc/icons/android-chrome-512x512.png", + "src": "https://go2rtc.org/icons/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } diff --git a/website/webtorrent/index.html b/website/webtorrent/index.html new file mode 100644 index 00000000..6a4b9057 --- /dev/null +++ b/website/webtorrent/index.html @@ -0,0 +1,189 @@ + + + + + webtorrent - go2rtc + + + + +
+ + + +
+ + + + + \ No newline at end of file diff --git a/www/README.md b/www/README.md index 4da49485..e4d36cd2 100644 --- a/www/README.md +++ b/www/README.md @@ -81,11 +81,11 @@ https://webrtc.org/getting-started/unified-plan-transition-guide?hl=en ```html - + - + - + ``` ## Useful links diff --git a/www/add.html b/www/add.html index 53d6b3dc..a2e0d85f 100644 --- a/www/add.html +++ b/www/add.html @@ -1,41 +1,37 @@ - go2rtc - Add Stream - - + + + add - go2rtc + + - - -
-
- - - -
-
- - - - -
-
-
- - - - -
-
- - - - -
-
- - -
-
-
- + const r = await fetch(url, {method: 'PUT'}); + alert(r.ok ? 'OK' : 'ERROR: ' + await r.text()); + }); + - -
-
-
- + +
+
+
+ - -
-
-
- + +
+
+ + + + +
+
+ + +
+
+
+ - - - -
-
- - - - - -
-
-
- - - -
-
- - - - -
-
- - -
-
-
- + + + +
+
+
+ + + + +
+
+
+ + + + +
+
+
+ + + + +
+
+ + + + + +
+
+
+ + + +
+
+
+ + + + +
+
+
+ + + + +
+
+ + +
+
+
+ + + + +
+
+ + + + +
+
+ + +
+
+
+ - - -
-
-
- + document.getElementById('ring-credentials-form').addEventListener('submit', handleRingAuth); + document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth); + - -
-
-
- + +
+
+ + + + +
+
+
+ - -
-
- - -
-
-
- + document.getElementById('roborock-form').addEventListener('submit', async ev => { + ev.preventDefault(); + const r = await fetch('api/roborock', {method: 'POST', body: new FormData(ev.target)}); + await getSources('roborock-table', r); + }); + - -
-
- - - -
- -
-
- + +
+
+
+ - -
-
-
- + +
+

+ API Key required: Get your API Key +

+
+ + + + + +
+
+ + +
+
+
+ - -
-
-
- + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + + + +
+
+
+ + - + \ No newline at end of file diff --git a/www/codecs.html b/www/codecs.html deleted file mode 100644 index cee77b9a..00000000 --- a/www/codecs.html +++ /dev/null @@ -1,65 +0,0 @@ - - - - - go2rtc - WebRTC - - - -
- - - \ No newline at end of file diff --git a/www/config.html b/www/config.html new file mode 100644 index 00000000..026b5beb --- /dev/null +++ b/www/config.html @@ -0,0 +1,1230 @@ + + + + + + config - go2rtc + + + + + + +
+
+ + +
+
+
+ + + + + + + diff --git a/www/editor.html b/www/editor.html deleted file mode 100644 index cb455f4d..00000000 --- a/www/editor.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - go2rtc - File Editor - - - - - - - -
- -
-
-
- - - diff --git a/www/hls.html b/www/hls.html index fa0f0067..70f5cdc4 100644 --- a/www/hls.html +++ b/www/hls.html @@ -2,7 +2,7 @@ - go2rtc - HLS + hls - go2rtc + -
-
- - - - - -
- - - - - - - - - - -
OnlineCommands
+ +
+
+ + modes + + + + +
+ + + + + + + + + + +
onlinecommands
+
+
+ + diff --git a/www/links.html b/www/links.html index 3b651762..13e08edf 100644 --- a/www/links.html +++ b/www/links.html @@ -1,27 +1,10 @@ - go2rtc - links - - + + + links - go2rtc - - - + +
+ + + }); + -
-

Play audio

-
-
-
-
- - send / cameras with two way audio support -
- + + + +
+

Play audio

+ + + +
+ + + / cameras with two way audio support +
+ + +
+

Publish stream

+
YouTube:  rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
 Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx
- - send / Telegram RTMPS server -
- + + + / Telegram RTMPS server + + -
-

WebRTC Magic

-
-
-
-
+
+

WebRTC Magic

+ + + + -
-
  • webrtc.html local WebRTC viewer
  • +
    +
  • webrtc.html local WebRTC viewer
  • -
  • - share link - copy link - delete - external WebRTC viewer -
  • -
    - + +
    diff --git a/www/log.html b/www/log.html index 84ec0675..5809c45f 100644 --- a/www/log.html +++ b/www/log.html @@ -1,69 +1,64 @@ - go2rtc - Logs - - + + + log - go2rtc + -
    - - - -
    -
    - - - - - - - - - - -
    TimeLevelMessage
    + +
    +
    + + + +
    + + + + + + + + + + +
    TimeLevelMessage
    +
    + + diff --git a/www/main.js b/www/main.js index 714c9127..d5629178 100644 --- a/www/main.js +++ b/www/main.js @@ -1,200 +1,135 @@ -// main menu -document.body.innerHTML = ` +document.head.innerHTML += ` - +`; + +document.body.innerHTML = ` +
    + +
    ` + document.body.innerHTML; - -const sunIcon = '☀️'; -const moonIcon = '🌕'; - -document.addEventListener('DOMContentLoaded', () => { - const darkModeToggle = document.getElementById('darkModeToggle'); - const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)'); - - const isDarkModeEnabled = () => document.body.classList.contains('dark-mode'); - - // Update the toggle button based on the dark mode state - const updateToggleButton = () => { - if (isDarkModeEnabled()) { - darkModeToggle.innerHTML = sunIcon; - darkModeToggle.setAttribute('aria-label', 'Enable light mode'); - } else { - darkModeToggle.innerHTML = moonIcon; - darkModeToggle.setAttribute('aria-label', 'Enable dark mode'); - } - }; - - const updateDarkMode = () => { - if (localStorage.getItem('darkMode') === 'enabled' || prefersDarkScheme.matches && localStorage.getItem('darkMode') !== 'disabled') { - document.body.classList.add('dark-mode'); - } else { - document.body.classList.remove('dark-mode'); - } - updateEditorTheme(); - updateToggleButton(); - }; - - // Update the editor theme based on the dark mode state - const updateEditorTheme = () => { - if (typeof editor !== 'undefined') { - editor.setTheme(isDarkModeEnabled() ? 'ace/theme/tomorrow_night_eighties' : 'ace/theme/github'); - } - }; - - // Initial update for dark mode and toggle button - updateDarkMode(); - - // Listen for changes in the system's color scheme preference - prefersDarkScheme.addEventListener('change', updateDarkMode); // Modern approach - - // Toggle dark mode and update local storage on button click - darkModeToggle.addEventListener('click', () => { - const enabled = document.body.classList.toggle('dark-mode'); - localStorage.setItem('darkMode', enabled ? 'enabled' : 'disabled'); - updateToggleButton(); // Update the button after toggling - updateEditorTheme(); - }); -}); diff --git a/www/network.html b/www/net.html similarity index 81% rename from www/network.html rename to www/net.html index 79875012..1c2a697f 100644 --- a/www/network.html +++ b/www/net.html @@ -2,31 +2,21 @@ - go2rtc - Network - + + net - go2rtc + -
    + + +
    + + diff --git a/website/schema.json b/www/schema.json similarity index 52% rename from website/schema.json rename to www/schema.json index 530616c6..d9c87e40 100644 --- a/website/schema.json +++ b/www/schema.json @@ -24,7 +24,26 @@ "debug", "info", "warn", - "error" + "error", + "fatal", + "panic", + "disabled" + ] + }, + "source": { + "type": "string", + "examples": [ + "rtsp://username:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif", + "rtsp://username:password@192.168.1.123/stream1", + "rtsp://username:password@192.168.1.123/h264Preview_01_main", + "rtmp://192.168.1.123/bcs/channel0_main.bcs?channel=0&stream=0&user=username&password=password", + "http://192.168.1.123/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password", + "http://username:password@192.168.1.123/cgi-bin/snapshot.cgi?channel=1", + "ffmpeg:media.mp4#video=h264#hardware#width=1920#height=1080#rotate=180#audio=copy", + "ffmpeg:virtual?video=testsrc&size=4K#video=h264#hardware#bitrate=50M", + "exec:ffmpeg -re -i media.mp4 -c copy -rtsp_transport tcp -f rtsp {output}", + "onvif://username:password@192.168.1.123:80?subtype=0", + "tapo://password@192.168.1.123:8800?channel=0&subtype=0" ] } }, @@ -33,13 +52,14 @@ "type": "object", "properties": { "listen": { + "type": "string", "default": ":1984", "examples": [ - "127.0.0.1:8080" - ], - "$ref": "#/definitions/listen" + "127.0.0.1:1984" + ] }, "username": { + "description": "Basic auth for WebUI", "type": "string", "examples": [ "admin" @@ -48,24 +68,35 @@ "password": { "type": "string" }, + "local_auth": { + "description": "Enable auth check for localhost requests", + "type": "boolean", + "default": false + }, "base_path": { + "description": "API prefix for serving on suburl (/api => /rtc/api)", "type": "string", "examples": [ - "/go2rtc" + "/rtc" ] }, "static_dir": { + "description": "Folder for static files (custom web interface)", "type": "string", "examples": [ - "/var/www" + "www" ] }, "origin": { + "description": "Allow CORS requests (only * supported)", "type": "string", - "const": "*" + "enum": [ + "*", + "" + ] }, "tls_listen": { - "$ref": "#/definitions/listen" + "type": "string" }, "tls_cert": { "type": "string", @@ -86,6 +117,111 @@ "examples": [ "/tmp/go2rtc.sock" ] + }, + "allow_paths": { + "description": "Allow only these HTTP paths (full paths, including base_path)", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "/api", + "/api/streams", + "/api/webrtc" + ] + ] + } + } + }, + "app": { + "type": "object", + "properties": { + "modules": { + "description": "Enable only these modules (empty / omitted means all)", + "type": "array", + "items": { + "type": "string", + "enum": [ + "api", + "ws", + "http", + "rtsp", + "webrtc", + "mp4", + "hls", + "mjpeg", + "hass", + "homekit", + "onvif", + "rtmp", + "webtorrent", + "wyoming", + "echo", + "exec", + "expr", + "ffmpeg", + "alsa", + "v4l2", + "bubble", + "doorbird", + "dvrip", + "eseecloud", + "flussonic", + "gopro", + "isapi", + "ivideon", + "mpegts", + "nest", + "ring", + "roborock", + "tapo", + "tuya", + "xiaomi", + "yandex", + "debug", + "ngrok", + "pinggy", + "srtp" + ] + } + } + } + }, + "env": { + "description": "Config variables that can be referenced as ${NAME} / ${NAME:default}", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "echo": { + "type": "object", + "properties": { + "allow_paths": { + "description": "Allow only these binaries for echo: URLs (exact cmd name/path)", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "exec": { + "type": "object", + "properties": { + "allow_paths": { + "description": "Allow only these binaries for exec: URLs (exact cmd name/path)", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "ffmpeg", + "/usr/bin/ffmpeg" + ] + ] } } }, @@ -95,6 +231,26 @@ "bin": { "type": "string", "default": "ffmpeg" + }, + "global": { + "type": "string", + "default": "-hide_banner" + }, + "file": { + "type": "string", + "default": "-re -i {input}" + }, + "http": { + "type": "string", + "default": "-fflags nobuffer -flags low_delay -i {input}" + }, + "rtsp": { + "type": "string", + "default": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i {input}" + }, + "rtsp/udp": { + "type": "string", + "default": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i {input}" } }, "additionalProperties": { @@ -117,12 +273,25 @@ "homekit": { "type": "object", "additionalProperties": { - "type": "object", + "type": [ + "object", + "null" + ], "properties": { "pin": { + "description": "HomeKit pairing PIN", "type": "string", "default": "19550224", - "pattern": "^[0-9]{8}$" + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9]{8}$" + }, + { + "type": "string", + "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{3}$" + } + ] }, "name": { "type": "string" @@ -133,6 +302,29 @@ "device_private": { "type": "string" }, + "category_id": { + "description": "Accessory category: `bridge`, `doorbell` or numeric ID", + "type": "string", + "default": "camera", + "anyOf": [ + { + "type": "string", + "enum": [ + "bridge", + "camera", + "doorbell" + ] + }, + { + "type": "string", + "pattern": "^[0-9]+$" + }, + { + "type": "string", + "const": "" + } + ] + }, "pairings": { "type": "array", "items": { @@ -146,9 +338,11 @@ "type": "object", "properties": { "format": { + "description": "Log format: color/json/text or empty for autodetect", "type": "string", "default": "color", "enum": [ + "", "color", "json", "text" @@ -160,12 +354,26 @@ "$ref": "#/definitions/log_level" }, "output": { + "description": "Log output: stdout/stderr/file[:path] or empty (memory only)", "type": "string", "default": "stdout", - "enum": [ - "", - "stdout", - "stderr" + "anyOf": [ + { + "type": "string", + "enum": [ + "", + "stdout", + "stderr" + ] + }, + { + "type": "string", + "pattern": "^file(:.+)?$", + "examples": [ + "file", + "file:go2rtc.log" + ] + } ] }, "time": { @@ -215,6 +423,9 @@ "homekit": { "$ref": "#/definitions/log_level" }, + "mjpeg": { + "$ref": "#/definitions/log_level" + }, "mp4": { "$ref": "#/definitions/log_level" }, @@ -238,6 +449,9 @@ }, "webtorrent": { "$ref": "#/definitions/log_level" + }, + "wyoming": { + "$ref": "#/definitions/log_level" } } }, @@ -253,6 +467,30 @@ } } }, + "pinggy": { + "type": "object", + "properties": { + "tunnel": { + "description": "Expose local address via Pinggy", + "type": "string", + "examples": [ + "http://127.0.0.1:1984", + "tcp://192.168.1.123:554" + ] + } + } + }, + "preload": { + "description": "Preload streams on startup (map stream name => probe query, default `video&audio`)", + "type": "object", + "additionalProperties": { + "type": "string", + "examples": [ + "video&audio", + "video" + ] + } + }, "publish": { "type": "object", "additionalProperties": { @@ -277,10 +515,10 @@ "type": "object", "properties": { "listen": { + "type": "string", "examples": [ ":1935" - ], - "$ref": "#/definitions/listen" + ] } } }, @@ -288,8 +526,8 @@ "type": "object", "properties": { "listen": { - "default": ":8554", - "$ref": "#/definitions/listen" + "type": "string", + "default": ":8554" }, "username": { "type": "string", @@ -314,75 +552,56 @@ "type": "object", "properties": { "listen": { - "default": ":8443", - "$ref": "#/definitions/listen" + "type": "string", + "default": ":8443" } } }, "streams": { "type": "object", "additionalProperties": { - "title": "Stream", "anyOf": [ { - "description": "Source", - "type": "string", - "examples": [ - "rtsp://username:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif", - "rtsp://username:password@192.168.1.123/stream1", - "rtsp://username:password@192.168.1.123/h264Preview_01_main", - "rtmp://192.168.1.123/bcs/channel0_main.bcs?channel=0&stream=0&user=username&password=password", - "http://192.168.1.123/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password", - "http://username:password@192.168.1.123/cgi-bin/snapshot.cgi?channel=1", - "ffmpeg:media.mp4#video=h264#hardware#width=1920#height=1080#rotate=180#audio=copy", - "ffmpeg:virtual?video=testsrc&size=4K#video=h264#hardware#bitrate=50M", - "bubble://username:password@192.168.1.123:34567/bubble/live?ch=0&stream=0", - "dvrip://username:password@192.168.1.123:34567?channel=0&subtype=0", - "exec:ffmpeg -re -i media.mp4 -c copy -rtsp_transport tcp -f rtsp {output}", - "isapi://username:password@192.168.1.123:80/", - "kasa://username:password@192.168.1.123:19443/https/stream/mixed", - "onvif://username:password@192.168.1.123:80?subtype=0", - "tapo://password@192.168.1.123:8800?channel=0&subtype=0", - "webtorrent:?share=xxx&pwd=xxx" - ] + "$ref": "#/definitions/source" }, { "type": "array", "items": { - "description": "Source", - "type": "string" + "$ref": "#/definitions/source" } + }, + { + "type": "null" } ] } }, + "xiaomi": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "webrtc": { "type": "object", "properties": { "listen": { - "default": ":8555/tcp", "type": "string", - "anyOf": [ - { - "type": "string", - "pattern": ":[0-9]{1,5}(/tcp|/udp)?$" - }, - { - "type": "string", - "const": "" - } + "default": ":8555", + "examples": [ + ":8555/udp" ] }, "candidates": { "type": "array", "items": { - "$ref": "#/definitions/listen/anyOf/0" - }, - "examples": [ - "216.58.210.174:8555", - "stun:8555", - "home.duckdns.org:8555" - ] + "type": "string", + "examples": [ + "216.58.210.174:8555", + "stun:8555", + "home.duckdns.org:8555" + ] + } }, "ice_servers": { "type": "array", @@ -436,13 +655,13 @@ "description": "Use only these network types", "type": "array", "items": { + "type": "string", "enum": [ "tcp4", "tcp6", "udp4", "udp6" - ], - "type": "string" + ] } }, "udp_ports": { @@ -472,7 +691,8 @@ "type": "object", "properties": { "pwd": { - "type": "string" + "type": "string", + "minLength": 4 }, "src": { "type": "string" @@ -481,6 +701,49 @@ } } } + }, + "wyoming": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "listen": { + "description": "Listen address for Wyoming server", + "type": "string" + }, + "name": { + "description": "Optional satellite name (default: stream name)", + "type": "string" + }, + "mode": { + "description": "Optional mode: mic / snd / default", + "type": "string", + "enum": [ + "", + "mic", + "snd" + ] + }, + "event": { + "description": "Event handlers (map event type => expr script)", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "wake_uri": { + "description": "Optional WAKE service URI (ex. tcp://host:port?name=...)", + "type": "string", + "examples": [ + "tcp://192.168.1.23:10400" + ] + }, + "vad_threshold": { + "description": "Optional VAD threshold (0.1..3.5 typical)", + "type": "number" + } + } + } } } } diff --git a/www/static.go b/www/static.go index 01f50906..064fec36 100644 --- a/www/static.go +++ b/www/static.go @@ -4,4 +4,5 @@ import "embed" //go:embed *.html //go:embed *.js +//go:embed *.json var Static embed.FS diff --git a/www/stream.html b/www/stream.html index a2284ba5..de7ad123 100644 --- a/www/stream.html +++ b/www/stream.html @@ -2,10 +2,10 @@ - - - - go2rtc - Stream + + + + stream - go2rtc