mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2026-04-22 15:47:06 +08:00
Merge branch 'master' of https://github.com/AlexxIT/go2rtc into update-readme
This commit is contained in:
@@ -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/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://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+ <br/> - Desktop Edge <br/> - Android Chrome 107+ | H264 <br/> PCMU, PCMA <br/> OPUS | H264, H265* <br/> AAC, FLAC* <br/> OPUS | H264, H265* <br/> AAC, FLAC* <br/> OPUS, MP3 | no |
|
||||
| Desktop Firefox | H264 <br/> PCMU, PCMA <br/> OPUS | H264 <br/> AAC, FLAC* <br/> OPUS | H264 <br/> AAC, FLAC* <br/> OPUS | no |
|
||||
| - Desktop Safari 14+ <br/> - iPad Safari 14+ <br/> - iPhone Safari 17.1+ | H264, H265* <br/> PCMU, PCMA <br/> OPUS | H264, H265 <br/> AAC, FLAC* | **no!** | H264, H265 <br/> AAC, FLAC* |
|
||||
| iPhone Safari 14+ | H264, H265* <br/> PCMU, PCMA <br/> OPUS | **no!** | **no!** | H264, H265 <br/> AAC, FLAC* |
|
||||
| macOS [Home Assistant App][1] | no | no | no | H264, H265 <br/> AAC, FLAC* |
|
||||
| Device | WebRTC | MSE | HTTP* | HLS |
|
||||
|--------------------------------------------------------------------|-----------------------------------------|-----------------------------------------|----------------------------------------------|-----------------------------|
|
||||
| *latency* | best | medium | bad | bad |
|
||||
| Desktop Chrome 136+ <br/> Desktop Edge <br/> Android Chrome 136+ | H264, H265* <br/> PCMU, PCMA <br/> OPUS | H264, H265* <br/> AAC, FLAC* <br/> OPUS | H264, H265* <br/> AAC, FLAC* <br/> OPUS, MP3 | no |
|
||||
| Desktop Firefox | H264 <br/> PCMU, PCMA <br/> OPUS | H264 <br/> AAC, FLAC* <br/> OPUS | H264 <br/> AAC, FLAC* <br/> OPUS | no |
|
||||
| Desktop Safari 14+ <br/> iPad Safari 14+ <br/> iPhone Safari 17.1+ | H264, H265* <br/> PCMU, PCMA <br/> OPUS | H264, H265 <br/> AAC, FLAC* | **no!** | H264, H265 <br/> AAC, FLAC* |
|
||||
| iPhone Safari 14+ | H264, H265* <br/> PCMU, PCMA <br/> OPUS | **no!** | **no!** | H264, H265 <br/> AAC, FLAC* |
|
||||
| macOS [Hass App][1] | no | no | no | H264, H265 <br/> 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).
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
|
||||
+694
-82
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
+17
-3
@@ -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"`)
|
||||
|
||||
+22
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+31
-5
@@ -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")
|
||||
|
||||
+71
-11
@@ -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.
|
||||
|
||||
@@ -25,4 +25,5 @@ func Init() {
|
||||
|
||||
return url, nil
|
||||
})
|
||||
streams.MarkInsecure("expr")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
+79
-37
@@ -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
|
||||
|
||||
|
||||
+33
-54
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+235
-95
@@ -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
|
||||
}
|
||||
|
||||
@@ -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://www.youtube.com/watch?v=sHj_3h_sX7M)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
+26
-14
@@ -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)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+16
-13
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+28
-17
@@ -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)
|
||||
}
|
||||
|
||||
+22
-30
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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._
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+60
-46
@@ -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
|
||||
|
||||
|
||||
+20
-2
@@ -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) {
|
||||
|
||||
+25
-11
@@ -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,
|
||||
|
||||
+7
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+18
-3
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+108
-73
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+17
-8
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
## Useful links
|
||||
|
||||
- https://github.com/bauer-andreas/secure-video-specification
|
||||
+13
-13
@@ -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{
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -9,6 +9,6 @@ type StreamingStatus struct {
|
||||
//goland:noinspection ALL
|
||||
const (
|
||||
StreamingStatusAvailable = 0
|
||||
StreamingStatusBusy = 1
|
||||
StreamingStatusInUse = 1
|
||||
StreamingStatusUnavailable = 2
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package camera
|
||||
|
||||
const TypeSupportedDataStreamTransportConfiguration = "130"
|
||||
|
||||
type SupportedDataStreamTransportConfiguration struct {
|
||||
Configs []TransferTransportConfiguration `tlv8:"1"`
|
||||
}
|
||||
|
||||
type TransferTransportConfiguration struct {
|
||||
TransportType byte `tlv8:"1"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package camera
|
||||
|
||||
const TypeSelectedCameraRecordingConfiguration = "209"
|
||||
|
||||
type SelectedCameraRecordingConfiguration struct {
|
||||
GeneralConfig SupportedCameraRecordingConfiguration `tlv8:"1"`
|
||||
VideoConfig SupportedVideoRecordingConfiguration `tlv8:"2"`
|
||||
AudioConfig SupportedAudioRecordingConfiguration `tlv8:"3"`
|
||||
}
|
||||
+11
-11
@@ -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,
|
||||
|
||||
+11
-5
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+62
-9
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+273
-52
@@ -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)
|
||||
//}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
+21
-2
@@ -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))
|
||||
|
||||
+15
-11
@@ -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
|
||||
}
|
||||
|
||||
+13
-10
@@ -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},
|
||||
|
||||
@@ -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 <nobody>", v.Method, v.RequestURI)
|
||||
}
|
||||
case *http.Response:
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if v.Header.Get("Content-Type") == "image/jpeg" {
|
||||
log.Printf("[homekit] response: %d <jpeg>", 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 <nobody>", v.Proto, v.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-19
@@ -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])
|
||||
|
||||
+33
-27
@@ -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
|
||||
}
|
||||
|
||||
+12
-47
@@ -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 <nobody>", v.Method, v.RequestURI)
|
||||
// }
|
||||
// case *http.Response:
|
||||
// if v == nil {
|
||||
// return
|
||||
// }
|
||||
// if v.Header.Get("Content-Type") == "image/jpeg" {
|
||||
// log.Printf("[homekit] response: %d <jpeg>", 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 <nobody>", v.StatusCode)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user