Compare commits
175 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dc0d58ba7 | |||
| cb22ae7833 | |||
| c98b0a83c4 | |||
| 0bae158e41 | |||
| e2b63a4f6c | |||
| 3897f10a4d | |||
| ac80f1470e | |||
| 1fe602679e | |||
| e2c7d06730 | |||
| 2133f5323c | |||
| c10a06d199 | |||
| d053d88ce9 | |||
| 2ce38b4486 | |||
| 44d59b1696 | |||
| 15ec995ecc | |||
| 231cab36b2 | |||
| 640db3029e | |||
| 2836fdae13 | |||
| 964bb225fa | |||
| 5cc32197b8 | |||
| bc1a4ac8e4 | |||
| 158f9d3a08 | |||
| 81cfcf877a | |||
| 96919bf9e3 | |||
| e4359ac217 | |||
| ff18283d11 | |||
| 994e0dc526 | |||
| 7254bd4fbc | |||
| 9f407a754d | |||
| cc97bc33c4 | |||
| 6db4dda535 | |||
| be80eb1ac9 | |||
| a8d2312cb0 | |||
| 5bbc2aaf2e | |||
| f8c88cfbe0 | |||
| cf4acd5a8d | |||
| 19226df7d6 | |||
| c415c8f2af | |||
| 2751f41afb | |||
| d59cb99f0d | |||
| 007e8dbc75 | |||
| dae396a1ed | |||
| d894483166 | |||
| 60ef52f44b | |||
| 240e1960f8 | |||
| a107d13e61 | |||
| f911936d79 | |||
| ea23957f2a | |||
| f1971cec7c | |||
| 7291c03cea | |||
| fdb3116027 | |||
| c87885be48 | |||
| 98f88d037e | |||
| fde1fdc592 | |||
| cca216ace5 | |||
| e953e949ef | |||
| fe2cc4b525 | |||
| 6a67fc3712 | |||
| 94b7c33485 | |||
| 887f0f4890 | |||
| ec08dfee9c | |||
| 54b95dced4 | |||
| 37d7409e82 | |||
| 4dd1f73a18 | |||
| 22cc8ac2c4 | |||
| a667acad07 | |||
| c196b82a72 | |||
| 670370056c | |||
| 0c5a2bf02b | |||
| 8f9e78be0c | |||
| 50d5fa93b6 | |||
| 3a0e4078fd | |||
| 549da0257e | |||
| 2b5f9429a8 | |||
| c7119f4403 | |||
| 7d9862202a | |||
| dd7130d2d4 | |||
| d697bdcf05 | |||
| abd61919cf | |||
| ad2383de80 | |||
| 9f3ff98951 | |||
| d286980548 | |||
| 87533ac5a1 | |||
| d05451416d | |||
| f2242e31c8 | |||
| 975a43d392 | |||
| 3d38e5e567 | |||
| 7d2ad92c4b | |||
| b82023bc32 | |||
| a92e04b6e0 | |||
| 56e61a85ee | |||
| 7d83b0d6c8 | |||
| 708277230a | |||
| 06e6e31443 | |||
| 8474f2f571 | |||
| 9ddea7d9d6 | |||
| c2fbd372b3 | |||
| e00d211619 | |||
| c68e3cafe4 | |||
| 7bb0f0d2e6 | |||
| 96d7066085 | |||
| ae49946740 | |||
| fcc91c9b8a | |||
| 994fd41826 | |||
| 3282b38900 | |||
| 647b2acf48 | |||
| ef318f663e | |||
| 5771454400 | |||
| 6732e726d5 | |||
| 45503aa8c5 | |||
| b6579122d1 | |||
| 42a67f8ad5 | |||
| 91eeefec68 | |||
| 8ab7aeb8b2 | |||
| 493fa1ef6a | |||
| 020549ef60 | |||
| dfc1f45f97 | |||
| 641e65ee95 | |||
| 24ca87e00d | |||
| 859cd1cbe6 | |||
| 79656d1344 | |||
| 759f979182 | |||
| 7c17e64090 | |||
| bf45f64a7e | |||
| 72890d5983 | |||
| e8e798d955 | |||
| 8a8b379bfc | |||
| ca491def83 | |||
| 5a597277a9 | |||
| c90fcd1ce1 | |||
| e0687db9e2 | |||
| 24310e2f7a | |||
| a1f0b86ab3 | |||
| 7f87c6e478 | |||
| a0145b4b24 | |||
| 2fcbb1d836 | |||
| a2beea1bbd | |||
| e5e55b7a50 | |||
| 0830d8342e | |||
| adb1b21e81 | |||
| edfa09bb9f | |||
| 2eef7bdbd3 | |||
| 124556f4db | |||
| d528e167db | |||
| f151969fe1 | |||
| 251686608a | |||
| 993aa613fd | |||
| 2fdfec6f21 | |||
| a0d8f3ae81 | |||
| fd5746a954 | |||
| 2c3813deb9 | |||
| a8b51ad619 | |||
| 49c4d45731 | |||
| 1282b23c57 | |||
| 54afd0b50b | |||
| 6ee748a87a | |||
| 0ebda76cc8 | |||
| 68b3dc6f37 | |||
| 3c83102e91 | |||
| dd77c3e1f0 | |||
| e5e1f6bd05 | |||
| ac96b64c64 | |||
| fa8fd60ac6 | |||
| 9c9393e0cf | |||
| 8b26f9309f | |||
| 4027809f32 | |||
| b28ffa9543 | |||
| 7836f2e47f | |||
| 648873978c | |||
| c51c13b4b8 | |||
| 9a7c7d2a4b | |||
| 5f17474ff4 | |||
| 3376bf8b99 | |||
| ad1bea088e | |||
| d0ac99fc69 |
@@ -124,7 +124,7 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ github.repository }}
|
name=${{ github.repository }},enable=${{ github.event.repository.fork == false }}
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
@@ -138,14 +138,14 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name == 'push' && github.event.repository.fork == false
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
@@ -181,7 +181,7 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ github.repository }}
|
name=${{ github.repository }},enable=${{ github.event.repository.fork == false }}
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
flavor: |
|
flavor: |
|
||||||
suffix=-hardware,onlatest=true
|
suffix=-hardware,onlatest=true
|
||||||
@@ -198,14 +198,14 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name == 'push' && github.event.repository.fork == false
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
@@ -236,7 +236,7 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ github.repository }}
|
name=${{ github.repository }},enable=${{ github.event.repository.fork == false }}
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
flavor: |
|
flavor: |
|
||||||
suffix=-rockchip,onlatest=true
|
suffix=-rockchip,onlatest=true
|
||||||
@@ -253,14 +253,14 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name == 'push' && github.event.repository.fork == false
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ go2rtc_linux*
|
|||||||
go2rtc_mac*
|
go2rtc_mac*
|
||||||
go2rtc_win*
|
go2rtc_win*
|
||||||
|
|
||||||
|
/go2rtc
|
||||||
|
/go2rtc.exe
|
||||||
|
|
||||||
0_test.go
|
0_test.go
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
|
|||||||
- mixing tracks from different sources to single stream
|
- mixing tracks from different sources to single stream
|
||||||
- auto-match client-supported codecs
|
- auto-match client-supported codecs
|
||||||
- [2-way audio](#two-way-audio) for some cameras
|
- [2-way audio](#two-way-audio) for some cameras
|
||||||
- streaming from private networks via [ngrok](#module-ngrok)
|
|
||||||
- can be [integrated to](#module-api) any smart home platform or be used as [standalone app](#go2rtc-binary)
|
- can be [integrated to](#module-api) any smart home platform or be used as [standalone app](#go2rtc-binary)
|
||||||
|
|
||||||
**Inspired by:**
|
**Inspired by:**
|
||||||
@@ -39,6 +38,9 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
|
|||||||
- HomeKit Accessory Protocol from [@brutella](https://github.com/brutella/hap)
|
- HomeKit Accessory Protocol from [@brutella](https://github.com/brutella/hap)
|
||||||
- creator of the project's logo [@v_novoseltsev](https://www.instagram.com/v_novoseltsev)
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
* [Fast start](#fast-start)
|
* [Fast start](#fast-start)
|
||||||
@@ -69,12 +71,14 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
|
|||||||
* [Source: Hass](#source-hass)
|
* [Source: Hass](#source-hass)
|
||||||
* [Source: ISAPI](#source-isapi)
|
* [Source: ISAPI](#source-isapi)
|
||||||
* [Source: Nest](#source-nest)
|
* [Source: Nest](#source-nest)
|
||||||
|
* [Source: Ring](#source-ring)
|
||||||
* [Source: Roborock](#source-roborock)
|
* [Source: Roborock](#source-roborock)
|
||||||
* [Source: WebRTC](#source-webrtc)
|
* [Source: WebRTC](#source-webrtc)
|
||||||
* [Source: WebTorrent](#source-webtorrent)
|
* [Source: WebTorrent](#source-webtorrent)
|
||||||
* [Incoming sources](#incoming-sources)
|
* [Incoming sources](#incoming-sources)
|
||||||
* [Stream to camera](#stream-to-camera)
|
* [Stream to camera](#stream-to-camera)
|
||||||
* [Publish stream](#publish-stream)
|
* [Publish stream](#publish-stream)
|
||||||
|
* [Preload stream](#preload-stream)
|
||||||
* [Module: API](#module-api)
|
* [Module: API](#module-api)
|
||||||
* [Module: RTSP](#module-rtsp)
|
* [Module: RTSP](#module-rtsp)
|
||||||
* [Module: RTMP](#module-rtmp)
|
* [Module: RTMP](#module-rtmp)
|
||||||
@@ -133,7 +137,7 @@ Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac.
|
|||||||
|
|
||||||
### go2rtc: Docker
|
### 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), [ngrok](#module-ngrok), and [Python](#source-echo).
|
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
|
||||||
|
|
||||||
@@ -199,6 +203,7 @@ Available source types:
|
|||||||
- [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR
|
- [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR
|
||||||
- [dvrip](#source-dvrip) - streaming from DVR-IP NVR
|
- [dvrip](#source-dvrip) - streaming from DVR-IP NVR
|
||||||
- [tapo](#source-tapo) - TP-Link Tapo cameras with [two way audio](#two-way-audio) support
|
- [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
|
||||||
- [kasa](#source-tapo) - TP-Link Kasa cameras
|
- [kasa](#source-tapo) - TP-Link Kasa cameras
|
||||||
- [gopro](#source-gopro) - GoPro cameras
|
- [gopro](#source-gopro) - GoPro cameras
|
||||||
- [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service
|
- [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service
|
||||||
@@ -220,6 +225,7 @@ Supported sources:
|
|||||||
- [Hikvision ISAPI](#source-isapi) cameras
|
- [Hikvision ISAPI](#source-isapi) cameras
|
||||||
- [Roborock vacuums](#source-roborock) models with cameras
|
- [Roborock vacuums](#source-roborock) models with cameras
|
||||||
- [Exec](#source-exec) audio on server
|
- [Exec](#source-exec) audio on server
|
||||||
|
- [Ring](#source-ring) cameras
|
||||||
- [Any Browser](#incoming-browser) as IP-camera
|
- [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)).
|
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)).
|
||||||
@@ -646,6 +652,16 @@ streams:
|
|||||||
nest-doorbell: nest:?client_id=***&client_secret=***&refresh_token=***&project_id=***&device_id=***
|
nest-doorbell: nest:?client_id=***&client_secret=***&refresh_token=***&project_id=***&device_id=***
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 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.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
streams:
|
||||||
|
ring: ring:?device_id=XXX&refresh_token=XXX
|
||||||
|
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)*
|
*[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)*
|
||||||
@@ -688,7 +704,7 @@ Supports [Amazon Kinesis Video Streams](https://aws.amazon.com/kinesis/video-str
|
|||||||
|
|
||||||
**switchbot**
|
**switchbot**
|
||||||
|
|
||||||
Support connection to [SwitchBot](https://us.switch-bot.com/) cameras that are based on Kinesis Video Streams. Specifically, this includes [Pan/Tilt Cam Plus 2K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-2k) and [Pan/Tilt Cam Plus 3K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-3k). `Outdoor Spotlight Cam 1080P`, `Outdoor Spotlight Cam 2K`, `Pan/Tilt Cam`, `Pan/Tilt Cam 2K`, `Indoor Cam` are based on Tuya, so this feature is not available.
|
Support connection to [SwitchBot](https://us.switch-bot.com/) cameras that are based on Kinesis Video Streams. Specifically, this includes [Pan/Tilt Cam Plus 2K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-2k) and [Pan/Tilt Cam Plus 3K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-3k) and [Smart Video Doorbell](https://www.switchbot.jp/products/switchbot-smart-video-doorbell). `Outdoor Spotlight Cam 1080P`, `Outdoor Spotlight Cam 2K`, `Pan/Tilt Cam`, `Pan/Tilt Cam 2K`, `Indoor Cam` are based on Tuya, so this feature is not available.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
streams:
|
streams:
|
||||||
@@ -697,7 +713,7 @@ streams:
|
|||||||
webrtc-openipc: webrtc:ws://192.168.1.123/webrtc_ws#format=openipc#ice_servers=[{"urls":"stun:stun.kinesisvideo.eu-north-1.amazonaws.com:443"}]
|
webrtc-openipc: webrtc:ws://192.168.1.123/webrtc_ws#format=openipc#ice_servers=[{"urls":"stun:stun.kinesisvideo.eu-north-1.amazonaws.com:443"}]
|
||||||
webrtc-wyze: webrtc:http://192.168.1.123:5000/signaling/camera1?kvs#format=wyze
|
webrtc-wyze: webrtc:http://192.168.1.123:5000/signaling/camera1?kvs#format=wyze
|
||||||
webrtc-kinesis: webrtc:wss://...amazonaws.com/?...#format=kinesis#client_id=...#ice_servers=[{...},{...}]
|
webrtc-kinesis: webrtc:wss://...amazonaws.com/?...#format=kinesis#client_id=...#ice_servers=[{...},{...}]
|
||||||
webrtc-switchbot: webrtc:wss://...amazonaws.com/?...#format=switchbot#resolution=hd#client_id=...#ice_servers=[{...},{...}]
|
webrtc-switchbot: webrtc:wss://...amazonaws.com/?...#format=switchbot#resolution=hd#play_type=0#client_id=...#ice_servers=[{...},{...}]
|
||||||
```
|
```
|
||||||
|
|
||||||
**PS.** For `kinesis` sources, you can use [echo](#source-echo) to get connection params using `bash`, `python` or any other script language.
|
**PS.** For `kinesis` sources, you can use [echo](#source-echo) to get connection params using `bash`, `python` or any other script language.
|
||||||
@@ -822,6 +838,26 @@ streams:
|
|||||||
- **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming.
|
- **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.
|
- **YouTube** > Create > Go live > Stream latency: Ultra low-latency > Copy: Stream URL + Stream key.
|
||||||
|
|
||||||
|
### Preload stream
|
||||||
|
|
||||||
|
You can preload any stream on go2rtc start. This is useful for cameras that take a long time to start up.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
preload:
|
||||||
|
camera1: # default: video&audio = ANY
|
||||||
|
camera2: "video" # preload only video track
|
||||||
|
camera3: "video=h264&audio=opus" # preload H264 video and OPUS audio
|
||||||
|
|
||||||
|
streams:
|
||||||
|
camera1:
|
||||||
|
- rtsp://192.168.1.100/stream
|
||||||
|
camera2:
|
||||||
|
- rtsp://192.168.1.101/stream
|
||||||
|
camera3:
|
||||||
|
- rtsp://192.168.1.102/h265stream
|
||||||
|
- 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/`.
|
The HTTP API is the main part for interacting with the application. Default address: `http://localhost:1984/`.
|
||||||
@@ -843,6 +879,7 @@ api:
|
|||||||
listen: ":1984" # default ":1984", HTTP API port ("" - disabled)
|
listen: ":1984" # default ":1984", HTTP API port ("" - disabled)
|
||||||
username: "admin" # default "", Basic auth for WebUI
|
username: "admin" # default "", Basic auth for WebUI
|
||||||
password: "pass" # 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)
|
base_path: "/rtc" # default "", API prefix for serving on suburl (/api => /rtc/api)
|
||||||
static_dir: "www" # default "", folder for static files (custom web interface)
|
static_dir: "www" # default "", folder for static files (custom web interface)
|
||||||
origin: "*" # default "", allow CORS requests (only * supported)
|
origin: "*" # default "", allow CORS requests (only * supported)
|
||||||
@@ -940,15 +977,6 @@ webrtc:
|
|||||||
- stun:8555 # if you have a dynamic public IP address
|
- stun:8555 # if you have a dynamic public IP address
|
||||||
```
|
```
|
||||||
|
|
||||||
**Private IP**
|
|
||||||
|
|
||||||
- setup integration with [ngrok service](#module-ngrok)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
ngrok:
|
|
||||||
command: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hard tech way 1. Own TCP-tunnel**
|
**Hard tech way 1. Own TCP-tunnel**
|
||||||
|
|
||||||
If you have a personal [VPS](https://en.wikipedia.org/wiki/Virtual_private_server), you can create a TCP tunnel and setup in the same way as "Static public IP". But use your VPS IP address in the YAML config.
|
If you have a personal [VPS](https://en.wikipedia.org/wiki/Virtual_private_server), you can create a TCP tunnel and setup in the same way as "Static public IP". But use your VPS IP address in the YAML config.
|
||||||
@@ -1046,63 +1074,9 @@ webtorrent:
|
|||||||
|
|
||||||
Link example: https://alexxit.github.io/go2rtc/#share=02SNtgjKXY&pwd=wznEQqznxW&media=video+audio
|
Link example: https://alexxit.github.io/go2rtc/#share=02SNtgjKXY&pwd=wznEQqznxW&media=video+audio
|
||||||
|
|
||||||
TODO: article on how it works...
|
|
||||||
|
|
||||||
### Module: ngrok
|
### Module: ngrok
|
||||||
|
|
||||||
With ngrok integration, you can get external access to your streams in situations when you have Internet with a private IP address.
|
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](https://github.com/AlexxIT/go2rtc/blob/master/internal/ngrok/README.md)).
|
||||||
|
|
||||||
- ngrok is pre-installed for **Docker** and **Hass add-on** users
|
|
||||||
- you may need external access for two different things:
|
|
||||||
- WebRTC stream, so you need a tunnel WebRTC TCP port (ex. 8555)
|
|
||||||
- go2rtc web interface, so you need a tunnel API HTTP port (ex. 1984)
|
|
||||||
- ngrok supports authorization for your web interface
|
|
||||||
- ngrok automatically adds HTTPS to your web interface
|
|
||||||
|
|
||||||
The ngrok free subscription has the following limitations:
|
|
||||||
|
|
||||||
- You can reserve a free domain for serving the web interface, but the TCP address you get will always be random and change with each restart of the ngrok agent (not a problem for WebRTC stream)
|
|
||||||
- You can forward multiple ports from a single agent, but you can only run one ngrok agent on the free plan
|
|
||||||
|
|
||||||
go2rtc will automatically get your external TCP address (if you enable it in ngrok config) and use it with WebRTC connection (if you enable it in webrtc config).
|
|
||||||
|
|
||||||
You need to manually download the [ngrok agent app](https://ngrok.com/download) for your OS and register with the [ngrok service](https://ngrok.com/signup).
|
|
||||||
|
|
||||||
**Tunnel for only WebRTC Stream**
|
|
||||||
|
|
||||||
You need to add your [ngrok authtoken](https://dashboard.ngrok.com/get-started/your-authtoken) and WebRTC TCP port to YAML:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
ngrok:
|
|
||||||
command: ngrok tcp 8555 --authtoken eW91IHNoYWxsIG5vdCBwYXNzCnlvdSBzaGFsbCBub3QgcGFzcw
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tunnel for WebRTC and Web interface**
|
|
||||||
|
|
||||||
You need to create `ngrok.yaml` config file and add it to the go2rtc config:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
ngrok:
|
|
||||||
command: ngrok start --all --config ngrok.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
ngrok config example:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: "2"
|
|
||||||
authtoken: eW91IHNoYWxsIG5vdCBwYXNzCnlvdSBzaGFsbCBub3QgcGFzcw
|
|
||||||
tunnels:
|
|
||||||
api:
|
|
||||||
addr: 1984 # use the same port as in the go2rtc config
|
|
||||||
proto: http
|
|
||||||
basic_auth:
|
|
||||||
- admin:password # you can set login/pass for your web interface
|
|
||||||
webrtc:
|
|
||||||
addr: 8555 # use the same port as in the go2rtc config
|
|
||||||
proto: tcp
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [ngrok agent documentation](https://ngrok.com/docs/agent/config/) for more details on the ngrok configuration file.
|
|
||||||
|
|
||||||
### Module: Hass
|
### Module: Hass
|
||||||
|
|
||||||
@@ -1221,7 +1195,6 @@ log:
|
|||||||
level: info # default level
|
level: info # default level
|
||||||
api: trace
|
api: trace
|
||||||
exec: debug
|
exec: debug
|
||||||
ngrok: info
|
|
||||||
rtsp: warn
|
rtsp: warn
|
||||||
streams: error
|
streams: error
|
||||||
webrtc: fatal
|
webrtc: fatal
|
||||||
@@ -1229,6 +1202,27 @@ log:
|
|||||||
|
|
||||||
## 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.
|
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.
|
||||||
|
|
||||||
This is not a problem if you trust your local network as much as I do. But you can change this behaviour with a `go2rtc.yaml` config:
|
This is not a problem if you trust your local network as much as I do. But you can change this behaviour with a `go2rtc.yaml` config:
|
||||||
@@ -1249,7 +1243,7 @@ webrtc:
|
|||||||
- external access to WebRTC TCP port is not a problem, because it is used only for transmitting encrypted media data
|
- external access to WebRTC TCP port is not a problem, because it is used only for transmitting encrypted media data
|
||||||
- anyway you need to open this port to your local network and to the Internet for WebRTC to work
|
- anyway you need to open this port to your local network and to the Internet for WebRTC to work
|
||||||
|
|
||||||
If you need web interface protection without the Home Assistant add-on, you need to use a reverse proxy, like [Nginx](https://nginx.org/), [Caddy](https://caddyserver.com/), [ngrok](https://ngrok.com/), etc.
|
If you need web interface protection without the Home Assistant add-on, you need to use a reverse proxy, like [Nginx](https://nginx.org/), [Caddy](https://caddyserver.com/), etc.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,54 @@ paths:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/api/preload:
|
||||||
|
put:
|
||||||
|
summary: Preload new stream
|
||||||
|
tags: [ Streams list ]
|
||||||
|
parameters:
|
||||||
|
- name: src
|
||||||
|
in: query
|
||||||
|
description: Stream source (name)
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
example: "camera1"
|
||||||
|
- name: video
|
||||||
|
in: query
|
||||||
|
description: Video codecs filter
|
||||||
|
required: false
|
||||||
|
schema: { type: string }
|
||||||
|
example: all,h264,h265,...
|
||||||
|
- name: audio
|
||||||
|
in: query
|
||||||
|
description: Audio codecs filter
|
||||||
|
required: false
|
||||||
|
schema: { type: string }
|
||||||
|
example: all,aac,opus,...
|
||||||
|
- name: microphone
|
||||||
|
in: query
|
||||||
|
description: Microphone codecs filter
|
||||||
|
required: false
|
||||||
|
schema: { type: string }
|
||||||
|
example: all,aac,opus,...
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
description: Default response
|
||||||
|
delete:
|
||||||
|
summary: Delete preloaded stream
|
||||||
|
tags: [ Streams list ]
|
||||||
|
parameters:
|
||||||
|
- name: src
|
||||||
|
in: query
|
||||||
|
description: Stream source (name)
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
example: "camera1"
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
description: Default response
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/api/streams?src={src}:
|
/api/streams?src={src}:
|
||||||
get:
|
get:
|
||||||
summary: Get stream info in JSON format
|
summary: Get stream info in JSON format
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:labs
|
# syntax=docker/dockerfile:labs
|
||||||
|
|
||||||
# 0. Prepare images
|
# 0. Prepare images
|
||||||
ARG PYTHON_VERSION="3.11"
|
ARG PYTHON_VERSION="3.13"
|
||||||
ARG GO_VERSION="1.25"
|
ARG GO_VERSION="1.25"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
module github.com/AlexxIT/go2rtc
|
module github.com/AlexxIT/go2rtc
|
||||||
|
|
||||||
go 1.23.0
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/asticode/go-astits v1.13.0
|
github.com/asticode/go-astits v1.13.0
|
||||||
github.com/expr-lang/expr v1.17.5
|
github.com/expr-lang/expr v1.17.6
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/mattn/go-isatty v0.0.20
|
github.com/mattn/go-isatty v0.0.20
|
||||||
github.com/miekg/dns v1.1.66
|
github.com/miekg/dns v1.1.68
|
||||||
github.com/pion/ice/v4 v4.0.10
|
github.com/pion/ice/v4 v4.0.10
|
||||||
github.com/pion/interceptor v0.1.40
|
github.com/pion/interceptor v0.1.41
|
||||||
github.com/pion/rtcp v1.2.15
|
github.com/pion/rtcp v1.2.16
|
||||||
github.com/pion/rtp v1.8.20
|
github.com/pion/rtp v1.8.24
|
||||||
github.com/pion/sdp/v3 v3.0.14
|
github.com/pion/sdp/v3 v3.0.16
|
||||||
github.com/pion/srtp/v3 v3.0.6
|
github.com/pion/srtp/v3 v3.0.8
|
||||||
github.com/pion/stun/v3 v3.0.0
|
github.com/pion/stun/v3 v3.0.0
|
||||||
github.com/pion/webrtc/v4 v4.1.3
|
github.com/pion/webrtc/v4 v4.1.6
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
|
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
|
||||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
|
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
|
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
|
||||||
golang.org/x/crypto v0.39.0
|
golang.org/x/crypto v0.43.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,18 +32,18 @@ require (
|
|||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/pion/datachannel v1.5.10 // indirect
|
github.com/pion/datachannel v1.5.10 // indirect
|
||||||
github.com/pion/dtls/v3 v3.0.6 // indirect
|
github.com/pion/dtls/v3 v3.0.7 // indirect
|
||||||
github.com/pion/logging v0.2.4 // indirect
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
github.com/pion/sctp v1.8.39 // indirect
|
github.com/pion/sctp v1.8.40 // indirect
|
||||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
github.com/pion/transport/v3 v3.0.8 // indirect
|
||||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
github.com/pion/turn/v4 v4.1.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
golang.org/x/mod v0.29.0 // indirect
|
||||||
golang.org/x/net v0.41.0 // indirect
|
golang.org/x/net v0.46.0 // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/tools v0.34.0 // indirect
|
golang.org/x/tools v0.38.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ 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.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 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k=
|
||||||
github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||||
|
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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
@@ -34,10 +36,14 @@ 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.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
|
||||||
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
|
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.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/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
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/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 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
||||||
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
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 h1:VKgU4MwA2LUDVLq+WBkpEHTcAb8c5iCvFMECeuPOZNk=
|
||||||
github.com/pion/ice/v4 v4.0.9/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
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 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||||
@@ -46,6 +52,8 @@ github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVor
|
|||||||
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
|
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 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||||
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
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 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||||
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
||||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||||
@@ -56,34 +64,50 @@ 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/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 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
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 h1:8uSUPpjSL4OlwZI8Ygqu7+h2p9NPFB+yAZ461Xn5sNg=
|
||||||
github.com/pion/rtp v1.8.13/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4=
|
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 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI=
|
||||||
github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
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 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs=
|
||||||
github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
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 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||||
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
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 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI=
|
||||||
github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
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 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI=
|
||||||
github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||||
|
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 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
|
||||||
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
|
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 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4=
|
||||||
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY=
|
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 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
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 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
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 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||||
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
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 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.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 h1:nyds/sFRR+HvmWoBa6wrL46sSfpArE0qR883MBW96lg=
|
||||||
github.com/pion/webrtc/v4 v4.0.14/go.mod h1:R3+qTnQTS03UzwDarYecgioNf7DYgTsldxnCXB821Kk=
|
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 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.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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
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/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
|
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
|
||||||
@@ -102,6 +126,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
|
||||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE=
|
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 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
@@ -110,18 +136,26 @@ 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.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 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
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 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
|
||||||
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
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 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
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 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
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 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
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 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
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 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -129,10 +163,14 @@ 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.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 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
|
||||||
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
|
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 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
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=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
|||||||
+17
-3
@@ -7,6 +7,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -23,6 +24,7 @@ func Init() {
|
|||||||
Listen string `yaml:"listen"`
|
Listen string `yaml:"listen"`
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
|
LocalAuth bool `yaml:"local_auth"`
|
||||||
BasePath string `yaml:"base_path"`
|
BasePath string `yaml:"base_path"`
|
||||||
StaticDir string `yaml:"static_dir"`
|
StaticDir string `yaml:"static_dir"`
|
||||||
Origin string `yaml:"origin"`
|
Origin string `yaml:"origin"`
|
||||||
@@ -30,6 +32,8 @@ func Init() {
|
|||||||
TLSCert string `yaml:"tls_cert"`
|
TLSCert string `yaml:"tls_cert"`
|
||||||
TLSKey string `yaml:"tls_key"`
|
TLSKey string `yaml:"tls_key"`
|
||||||
UnixListen string `yaml:"unix_listen"`
|
UnixListen string `yaml:"unix_listen"`
|
||||||
|
|
||||||
|
AllowPaths []string `yaml:"allow_paths"`
|
||||||
} `yaml:"api"`
|
} `yaml:"api"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ func Init() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowPaths = cfg.Mod.AllowPaths
|
||||||
basePath = cfg.Mod.BasePath
|
basePath = cfg.Mod.BasePath
|
||||||
log = app.GetLogger("api")
|
log = app.GetLogger("api")
|
||||||
|
|
||||||
@@ -61,7 +66,7 @@ func Init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Mod.Username != "" {
|
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() {
|
if log.Trace().Enabled() {
|
||||||
@@ -152,6 +157,10 @@ func HandleFunc(pattern string, handler http.HandlerFunc) {
|
|||||||
if len(pattern) == 0 || pattern[0] != '/' {
|
if len(pattern) == 0 || pattern[0] != '/' {
|
||||||
pattern = basePath + "/" + pattern
|
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")
|
log.Trace().Str("path", pattern).Msg("[api] register path")
|
||||||
http.HandleFunc(pattern, handler)
|
http.HandleFunc(pattern, handler)
|
||||||
}
|
}
|
||||||
@@ -185,6 +194,7 @@ func Response(w http.ResponseWriter, body any, contentType string) {
|
|||||||
|
|
||||||
const StreamNotFound = "stream not found"
|
const StreamNotFound = "stream not found"
|
||||||
|
|
||||||
|
var allowPaths []string
|
||||||
var basePath string
|
var basePath string
|
||||||
var log zerolog.Logger
|
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) {
|
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()
|
user, pass, ok := r.BasicAuth()
|
||||||
if !ok || user != username || pass != password {
|
if !ok || user != username || pass != password {
|
||||||
w.Header().Set("Www-Authenticate", `Basic realm="go2rtc"`)
|
w.Header().Set("Www-Authenticate", `Basic realm="go2rtc"`)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
Version string
|
Version string
|
||||||
|
Modules []string
|
||||||
UserAgent string
|
UserAgent string
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
Info = make(map[string]any)
|
Info = make(map[string]any)
|
||||||
@@ -76,6 +77,16 @@ func Init() {
|
|||||||
if ConfigPath != "" {
|
if ConfigPath != "" {
|
||||||
Logger.Info().Str("path", ConfigPath).Msg("config")
|
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) {
|
func readRevisionTime() (revision, vcsTime string) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
"github.com/AlexxIT/go2rtc/pkg/creds"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/yaml"
|
"github.com/AlexxIT/go2rtc/pkg/yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,13 +71,15 @@ func initConfig(confs flagConfig) {
|
|||||||
// config as file
|
// config as file
|
||||||
if ConfigPath == "" {
|
if ConfigPath == "" {
|
||||||
ConfigPath = conf
|
ConfigPath = conf
|
||||||
|
initStorage()
|
||||||
}
|
}
|
||||||
|
|
||||||
if data, _ = os.ReadFile(conf); data == nil {
|
if data, _ = os.ReadFile(conf); data == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
data = []byte(shell.ReplaceEnvVars(string(data)))
|
loadEnv(data)
|
||||||
|
data = creds.ReplaceVars(data)
|
||||||
configs = append(configs, data)
|
configs = append(configs, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/pkg/creds"
|
||||||
"github.com/mattn/go-isatty"
|
"github.com/mattn/go-isatty"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
@@ -88,6 +89,8 @@ func initLogger() {
|
|||||||
writer = MemoryLog
|
writer = MemoryLog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writer = creds.SecretWriter(writer)
|
||||||
|
|
||||||
lvl, _ := zerolog.ParseLevel(modules["level"])
|
lvl, _ := zerolog.ParseLevel(modules["level"])
|
||||||
Logger = zerolog.New(writer).Level(lvl)
|
Logger = zerolog.New(writer).Level(lvl)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/pkg/creds"
|
||||||
|
"github.com/AlexxIT/go2rtc/pkg/yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
func initStorage() {
|
||||||
|
storage = &envStorage{data: make(map[string]string)}
|
||||||
|
creds.SetStorage(storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEnv(data []byte) {
|
||||||
|
var cfg struct {
|
||||||
|
Env map[string]string `yaml:"env"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.mu.Lock()
|
||||||
|
for name, value := range cfg.Env {
|
||||||
|
storage.data[name] = value
|
||||||
|
creds.AddSecret(value)
|
||||||
|
}
|
||||||
|
storage.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
var storage *envStorage
|
||||||
|
|
||||||
|
type envStorage struct {
|
||||||
|
data map[string]string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *envStorage) SetValue(name, value string) error {
|
||||||
|
if err := PatchConfig([]string{"env", name}, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.data[name] = value
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *envStorage) GetValue(name string) (value string, ok bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
value, ok = s.data[name]
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -29,8 +29,8 @@ var stackSkip = [][]byte{
|
|||||||
[]byte("created by github.com/AlexxIT/go2rtc/internal/homekit.Init"),
|
[]byte("created by github.com/AlexxIT/go2rtc/internal/homekit.Init"),
|
||||||
|
|
||||||
// webrtc/api.go
|
// webrtc/api.go
|
||||||
[]byte("created by github.com/pion/ice/v2.NewTCPMuxDefault"),
|
[]byte("created by github.com/pion/ice/v4.NewTCPMuxDefault"),
|
||||||
[]byte("created by github.com/pion/ice/v2.NewUDPMuxDefault"),
|
[]byte("created by github.com/pion/ice/v4.NewUDPMuxDefault"),
|
||||||
}
|
}
|
||||||
|
|
||||||
func stackHandler(w http.ResponseWriter, r *http.Request) {
|
func stackHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package echo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/app"
|
"github.com/AlexxIT/go2rtc/internal/app"
|
||||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||||
@@ -10,11 +12,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Init() {
|
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")
|
log := app.GetLogger("echo")
|
||||||
|
|
||||||
streams.RedirectFunc("echo", func(url string) (string, error) {
|
streams.RedirectFunc("echo", func(url string) (string, error) {
|
||||||
args := shell.QuoteSplit(url[5:])
|
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()
|
b, err := exec.Command(args[0], args[1:]...).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -26,4 +42,5 @@ func Init() {
|
|||||||
|
|
||||||
return string(b), nil
|
return string(b), nil
|
||||||
})
|
})
|
||||||
|
streams.MarkInsecure("echo")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -26,6 +27,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Init() {
|
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 {
|
rtsp.HandleFunc(func(conn *pkg.Conn) bool {
|
||||||
waitersMu.Lock()
|
waitersMu.Lock()
|
||||||
waiter := waiters[conn.URL.Path]
|
waiter := waiters[conn.URL.Path]
|
||||||
@@ -45,10 +56,13 @@ func Init() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
streams.HandleFunc("exec", execHandle)
|
streams.HandleFunc("exec", execHandle)
|
||||||
|
streams.MarkInsecure("exec")
|
||||||
|
|
||||||
log = app.GetLogger("exec")
|
log = app.GetLogger("exec")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var allowPaths []string
|
||||||
|
|
||||||
func execHandle(rawURL string) (prod core.Producer, err error) {
|
func execHandle(rawURL string) (prod core.Producer, err error) {
|
||||||
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
||||||
query := streams.ParseQuery(rawQuery)
|
query := streams.ParseQuery(rawQuery)
|
||||||
@@ -73,6 +87,10 @@ func execHandle(rawURL string) (prod core.Producer, err error) {
|
|||||||
debug: log.Debug().Enabled(),
|
debug: log.Debug().Enabled(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if allowPaths != nil && !slices.Contains(allowPaths, cmd.Args[0]) {
|
||||||
|
return nil, errors.New("exec: bin not in allow_paths: " + cmd.Args[0])
|
||||||
|
}
|
||||||
|
|
||||||
if s := query.Get("killsignal"); s != "" {
|
if s := query.Get("killsignal"); s != "" {
|
||||||
sig := syscall.Signal(core.Atoi(s))
|
sig := syscall.Signal(core.Atoi(s))
|
||||||
cmd.Cancel = func() error {
|
cmd.Cancel = func() error {
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ func Init() {
|
|||||||
|
|
||||||
return url, nil
|
return url, nil
|
||||||
})
|
})
|
||||||
|
streams.MarkInsecure("expr")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ var defaults = map[string]string{
|
|||||||
// `-profile high -level 4.1` - most used streaming profile
|
// `-profile high -level 4.1` - most used streaming profile
|
||||||
// `-pix_fmt:v yuv420p` - important for Telegram
|
// `-pix_fmt:v yuv420p` - important for Telegram
|
||||||
"h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p",
|
"h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p",
|
||||||
"h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p",
|
"h265": "-c:v libx265 -g 50 -profile:v main -x265-params level=5.1:high-tier=0 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p",
|
||||||
"mjpeg": "-c:v mjpeg",
|
"mjpeg": "-c:v mjpeg",
|
||||||
//"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p",
|
//"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p",
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ func apiStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 1. link to go2rtc stream: rtsp://...:8554/{stream_name}
|
// 1. link to go2rtc stream: rtsp://...:8554/{stream_name}
|
||||||
// 2. static link to Hass camera
|
// 2. static link to Hass camera
|
||||||
// 3. dynamic 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)
|
apiOK(w, r)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, "", http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// /stream/{id}/channel/0/webrtc
|
// /stream/{id}/channel/0/webrtc
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/api"
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
@@ -37,8 +38,13 @@ func Init() {
|
|||||||
api.HandleFunc("/streams", apiOK)
|
api.HandleFunc("/streams", apiOK)
|
||||||
api.HandleFunc("/stream/", apiStream)
|
api.HandleFunc("/stream/", apiStream)
|
||||||
|
|
||||||
streams.RedirectFunc("hass", func(url string) (string, error) {
|
streams.RedirectFunc("hass", func(rawURL string) (string, error) {
|
||||||
if location := entities[url[5:]]; location != "" {
|
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
||||||
|
|
||||||
|
if location := entities[rawURL[5:]]; location != "" {
|
||||||
|
if rawQuery != "" {
|
||||||
|
return location + "#" + rawQuery, nil
|
||||||
|
}
|
||||||
return location, nil
|
return location, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error {
|
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 {
|
if stream == nil {
|
||||||
return errors.New(api.StreamNotFound)
|
return errors.New(api.StreamNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-38
@@ -3,6 +3,7 @@ package homekit
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,56 +15,93 @@ import (
|
|||||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
"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 {
|
switch r.Method {
|
||||||
case "GET":
|
case "GET":
|
||||||
sources, err := discovery()
|
if id := r.Form.Get("id"); id != "" {
|
||||||
if err != nil {
|
api.ResponsePrettyJSON(w, servers[id])
|
||||||
api.Error(w, err)
|
} else {
|
||||||
return
|
api.ResponsePrettyJSON(w, servers)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
case "POST":
|
case "POST":
|
||||||
if err := r.ParseMultipartForm(1024); err != nil {
|
id := r.Form.Get("id")
|
||||||
api.Error(w, err)
|
rawURL := r.Form.Get("src") + "&pin=" + r.Form.Get("pin")
|
||||||
return
|
if err := apiPair(id, rawURL); err != nil {
|
||||||
}
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
||||||
if err := apiPair(r.Form.Get("id"), r.Form.Get("url")); err != nil {
|
|
||||||
api.Error(w, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
if err := r.ParseMultipartForm(1024); err != nil {
|
id := r.Form.Get("id")
|
||||||
api.Error(w, err)
|
if err := apiUnpair(id); err != nil {
|
||||||
return
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
|
||||||
|
|
||||||
if err := apiUnpair(r.Form.Get("id")); err != nil {
|
|
||||||
api.Error(w, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func discovery() ([]*api.Source, error) {
|
||||||
var sources []*api.Source
|
var sources []*api.Source
|
||||||
|
|
||||||
|
|||||||
+28
-52
@@ -2,8 +2,6 @@ package homekit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -35,12 +33,15 @@ func Init() {
|
|||||||
|
|
||||||
streams.HandleFunc("homekit", streamHandler)
|
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 {
|
if cfg.Mod == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hosts = map[string]*server{}
|
||||||
servers = map[string]*server{}
|
servers = map[string]*server{}
|
||||||
var entries []*mdns.ServiceEntry
|
var entries []*mdns.ServiceEntry
|
||||||
|
|
||||||
@@ -66,33 +67,14 @@ func Init() {
|
|||||||
|
|
||||||
srv := &server{
|
srv := &server{
|
||||||
stream: id,
|
stream: id,
|
||||||
srtp: srtp.Server,
|
|
||||||
pairings: conf.Pairings,
|
pairings: conf.Pairings,
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.hap = &hap.Server{
|
srv.hap = &hap.Server{
|
||||||
Pin: pin,
|
Pin: pin,
|
||||||
DeviceID: deviceID,
|
DeviceID: deviceID,
|
||||||
DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id),
|
DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id),
|
||||||
GetPair: srv.GetPair,
|
GetClientPublic: 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.mdns = &mdns.ServiceEntry{
|
srv.mdns = &mdns.ServiceEntry{
|
||||||
@@ -114,15 +96,24 @@ func Init() {
|
|||||||
|
|
||||||
srv.UpdateStatus()
|
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)
|
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.PathPairSetup, hapHandler)
|
||||||
api.HandleFunc(hap.PathPairVerify, hapHandler)
|
api.HandleFunc(hap.PathPairVerify, hapHandler)
|
||||||
|
|
||||||
log.Trace().Msgf("[homekit] mdns: %s", entries)
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := mdns.Serve(mdns.ServiceHAP, entries); err != nil {
|
if err := mdns.Serve(mdns.ServiceHAP, entries); err != nil {
|
||||||
log.Error().Err(err).Caller().Send()
|
log.Error().Err(err).Caller().Send()
|
||||||
@@ -131,6 +122,7 @@ func Init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var log zerolog.Logger
|
var log zerolog.Logger
|
||||||
|
var hosts map[string]*server
|
||||||
var servers map[string]*server
|
var servers map[string]*server
|
||||||
|
|
||||||
func streamHandler(rawURL string) (core.Producer, error) {
|
func streamHandler(rawURL string) (core.Producer, error) {
|
||||||
@@ -142,6 +134,8 @@ func streamHandler(rawURL string) (core.Producer, error) {
|
|||||||
client, err := homekit.Dial(rawURL, srtp.Server)
|
client, err := homekit.Dial(rawURL, srtp.Server)
|
||||||
if client != nil && rawQuery != "" {
|
if client != nil && rawQuery != "" {
|
||||||
query := streams.ParseQuery(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"))
|
client.Bitrate = parseBitrate(query.Get("bitrate"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,45 +143,27 @@ func streamHandler(rawURL string) (core.Producer, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolve(host string) *server {
|
func resolve(host string) *server {
|
||||||
if len(servers) == 1 {
|
if len(hosts) == 1 {
|
||||||
for _, srv := range servers {
|
for _, srv := range hosts {
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if srv, ok := servers[host]; ok {
|
if srv, ok := hosts[host]; ok {
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hapHandler(w http.ResponseWriter, r *http.Request) {
|
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.
|
// Can support multiple HomeKit cameras on single port ONLY for Apple devices.
|
||||||
// Doesn't support Home Assistant and any other open source projects
|
// Doesn't support Home Assistant and any other open source projects
|
||||||
// because they don't send the host header in requests.
|
// because they don't send the host header in requests.
|
||||||
srv := resolve(r.Host)
|
srv := resolve(r.Host)
|
||||||
if srv == nil {
|
if srv == nil {
|
||||||
log.Error().Msg("[homekit] unknown host: " + r.Host)
|
log.Error().Msg("[homekit] unknown host: " + r.Host)
|
||||||
_ = hap.WriteBackoff(rw)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
srv.Handle(w, r)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func findHomeKitURL(sources []string) string {
|
func findHomeKitURL(sources []string) string {
|
||||||
@@ -203,7 +179,7 @@ func findHomeKitURL(sources []string) string {
|
|||||||
if strings.HasPrefix(url, "hass") {
|
if strings.HasPrefix(url, "hass") {
|
||||||
location, _ := streams.Location(url)
|
location, _ := streams.Location(url)
|
||||||
if strings.HasPrefix(location, "homekit") {
|
if strings.HasPrefix(location, "homekit") {
|
||||||
return url
|
return location
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+208
-95
@@ -4,10 +4,16 @@ import (
|
|||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/sha512"
|
"crypto/sha512"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/app"
|
"github.com/AlexxIT/go2rtc/internal/app"
|
||||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||||
@@ -16,23 +22,133 @@ import (
|
|||||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
"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/hap/tlv8"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/srtp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
stream string // stream name from YAML
|
hap *hap.Server // server for HAP connection and encryption
|
||||||
hap *hap.Server // server for HAP connection and encryption
|
mdns *mdns.ServiceEntry
|
||||||
mdns *mdns.ServiceEntry
|
|
||||||
srtp *srtp.Server
|
|
||||||
accessory *hap.Accessory // HAP accessory
|
|
||||||
pairings []string // pairings list
|
|
||||||
|
|
||||||
streams map[string]*homekit.Consumer
|
pairings []string // pairings list
|
||||||
consumer *homekit.Consumer
|
conns []any
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
accessory *hap.Accessory // HAP accessory
|
||||||
|
consumer *homekit.Consumer
|
||||||
|
proxyURL 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"`
|
||||||
|
Conns []any `json:"connections"`
|
||||||
|
}{
|
||||||
|
Name: s.mdns.Name,
|
||||||
|
DeviceID: s.mdns.Info[hap.TXTDeviceID],
|
||||||
|
Paired: len(s.pairings),
|
||||||
|
Conns: s.conns,
|
||||||
|
}
|
||||||
|
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() {
|
func (s *server) UpdateStatus() {
|
||||||
@@ -44,12 +160,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 {
|
func (s *server) GetAccessories(_ net.Conn) []*hap.Accessory {
|
||||||
return []*hap.Accessory{s.accessory}
|
return []*hap.Accessory{s.accessory}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
|
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)
|
char := s.accessory.GetCharacterByID(iid)
|
||||||
if char == nil {
|
if char == nil {
|
||||||
@@ -59,11 +231,12 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
|
|||||||
|
|
||||||
switch char.Type {
|
switch char.Type {
|
||||||
case camera.TypeSetupEndpoints:
|
case camera.TypeSetupEndpoints:
|
||||||
if s.consumer == nil {
|
consumer := s.consumer
|
||||||
|
if consumer == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
answer := s.consumer.GetAnswer()
|
answer := consumer.GetAnswer()
|
||||||
v, err := tlv8.MarshalBase64(answer)
|
v, err := tlv8.MarshalBase64(answer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -76,7 +249,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) {
|
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)
|
char := s.accessory.GetCharacterByID(iid)
|
||||||
if char == nil {
|
if char == nil {
|
||||||
@@ -86,61 +259,64 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a
|
|||||||
|
|
||||||
switch char.Type {
|
switch char.Type {
|
||||||
case camera.TypeSetupEndpoints:
|
case camera.TypeSetupEndpoints:
|
||||||
var offer camera.SetupEndpoints
|
var offer camera.SetupEndpointsRequest
|
||||||
if err := tlv8.UnmarshalBase64(value, &offer); err != nil {
|
if err := tlv8.UnmarshalBase64(value, &offer); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.consumer = homekit.NewConsumer(conn, srtp2.Server)
|
consumer := homekit.NewConsumer(conn, srtp2.Server)
|
||||||
s.consumer.SetOffer(&offer)
|
consumer.SetOffer(&offer)
|
||||||
|
s.consumer = consumer
|
||||||
|
|
||||||
case camera.TypeSelectedStreamConfiguration:
|
case camera.TypeSelectedStreamConfiguration:
|
||||||
var conf camera.SelectedStreamConfig
|
var conf camera.SelectedStreamConfiguration
|
||||||
if err := tlv8.UnmarshalBase64(value, &conf); err != nil {
|
if err := tlv8.UnmarshalBase64(value, &conf); err != nil {
|
||||||
return
|
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 {
|
switch conf.Control.Command {
|
||||||
case camera.SessionCommandEnd:
|
case camera.SessionCommandEnd:
|
||||||
if consumer := s.streams[conf.Control.SessionID]; consumer != nil {
|
for _, consumer := range s.conns {
|
||||||
_ = consumer.Stop()
|
if consumer, ok := consumer.(*homekit.Consumer); ok {
|
||||||
|
if consumer.SessionID() == conf.Control.SessionID {
|
||||||
|
_ = consumer.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case camera.SessionCommandStart:
|
case camera.SessionCommandStart:
|
||||||
if s.consumer == nil {
|
consumer := s.consumer
|
||||||
|
if consumer == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.consumer.SetConfig(&conf) {
|
if !consumer.SetConfig(&conf) {
|
||||||
log.Warn().Msgf("[homekit] wrong config")
|
log.Warn().Msgf("[homekit] wrong config")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.streams == nil {
|
s.AddConn(consumer)
|
||||||
s.streams = map[string]*homekit.Consumer{}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.streams[conf.Control.SessionID] = s.consumer
|
|
||||||
|
|
||||||
stream := streams.Get(s.stream)
|
stream := streams.Get(s.stream)
|
||||||
if err := stream.AddConsumer(s.consumer); err != nil {
|
if err := stream.AddConsumer(consumer); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
_, _ = s.consumer.WriteTo(nil)
|
_, _ = consumer.WriteTo(nil)
|
||||||
stream.RemoveConsumer(s.consumer)
|
stream.RemoveConsumer(consumer)
|
||||||
|
|
||||||
delete(s.streams, conf.Control.SessionID)
|
s.DelConn(consumer)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) GetImage(conn net.Conn, width, height int) []byte {
|
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)
|
stream := streams.Get(s.stream)
|
||||||
cons := magic.NewKeyframe()
|
cons := magic.NewKeyframe()
|
||||||
@@ -166,69 +342,6 @@ func (s *server) GetImage(conn net.Conn, width, height int) []byte {
|
|||||||
return b
|
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 {
|
func calcName(name, seed string) string {
|
||||||
if name != "" {
|
if name != "" {
|
||||||
return name
|
return name
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func Init() {
|
|||||||
var log zerolog.Logger
|
var log zerolog.Logger
|
||||||
|
|
||||||
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||||
stream := streams.GetOrPatch(r.URL.Query())
|
stream, _ := streams.GetOrPatch(r.URL.Query())
|
||||||
if stream == nil {
|
if stream == nil {
|
||||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
@@ -145,7 +145,7 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handlerWS(tr *ws.Transport, _ *ws.Message) error {
|
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 {
|
if stream == nil {
|
||||||
return errors.New(api.StreamNotFound)
|
return errors.New(api.StreamNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -91,7 +91,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stream := streams.GetOrPatch(query)
|
stream, _ := streams.GetOrPatch(query)
|
||||||
if stream == nil {
|
if stream == nil {
|
||||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
|
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 {
|
if stream == nil {
|
||||||
return errors.New(api.StreamNotFound)
|
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 {
|
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 {
|
if stream == nil {
|
||||||
return errors.New(api.StreamNotFound)
|
return errors.New(api.StreamNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
With ngrok integration, you can get external access to your streams in situations when you have Internet with a private IP address.
|
||||||
|
|
||||||
|
- you may need external access for two different things:
|
||||||
|
- WebRTC stream, so you need a tunnel WebRTC TCP port (ex. 8555)
|
||||||
|
- go2rtc web interface, so you need a tunnel API HTTP port (ex. 1984)
|
||||||
|
- ngrok supports authorization for your web interface
|
||||||
|
- ngrok automatically adds HTTPS to your web interface
|
||||||
|
|
||||||
|
The ngrok free subscription has the following limitations:
|
||||||
|
|
||||||
|
- You can reserve a free domain for serving the web interface, but the TCP address you get will always be random and change with each restart of the ngrok agent (not a problem for WebRTC stream)
|
||||||
|
- You can forward multiple ports from a single agent, but you can only run one ngrok agent on the free plan
|
||||||
|
|
||||||
|
go2rtc will automatically get your external TCP address (if you enable it in ngrok config) and use it with WebRTC connection (if you enable it in webrtc config).
|
||||||
|
|
||||||
|
You need to manually download the [ngrok agent app](https://ngrok.com/download) for your OS and register with the [ngrok service](https://ngrok.com/signup).
|
||||||
|
|
||||||
|
**Tunnel for only WebRTC Stream**
|
||||||
|
|
||||||
|
You need to add your [ngrok authtoken](https://dashboard.ngrok.com/get-started/your-authtoken) and WebRTC TCP port to YAML:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ngrok:
|
||||||
|
command: ngrok tcp 8555 --authtoken eW91IHNoYWxsIG5vdCBwYXNzCnlvdSBzaGFsbCBub3QgcGFzcw
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tunnel for WebRTC and Web interface**
|
||||||
|
|
||||||
|
You need to create `ngrok.yaml` config file and add it to the go2rtc config:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ngrok:
|
||||||
|
command: ngrok start --all --config ngrok.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
ngrok config example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "2"
|
||||||
|
authtoken: eW91IHNoYWxsIG5vdCBwYXNzCnlvdSBzaGFsbCBub3QgcGFzcw
|
||||||
|
tunnels:
|
||||||
|
api:
|
||||||
|
addr: 1984 # use the same port as in the go2rtc config
|
||||||
|
proto: http
|
||||||
|
basic_auth:
|
||||||
|
- admin:password # you can set login/pass for your web interface
|
||||||
|
webrtc:
|
||||||
|
addr: 8555 # use the same port as in the go2rtc config
|
||||||
|
proto: tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [ngrok agent documentation](https://ngrok.com/docs/agent/config/) for more details on the ngrok configuration file.
|
||||||
@@ -45,6 +45,10 @@ func streamOnvif(rawURL string) (core.Producer, error) {
|
|||||||
|
|
||||||
log.Debug().Msgf("[onvif] new uri=%s", uri)
|
log.Debug().Msgf("[onvif] new uri=%s", uri)
|
||||||
|
|
||||||
|
if err = streams.Validate(uri); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return streams.GetProducer(uri)
|
return streams.GetProducer(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-10
@@ -1,10 +1,11 @@
|
|||||||
package ring
|
package ring
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/api"
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
@@ -21,8 +22,7 @@ func Init() {
|
|||||||
|
|
||||||
func apiRing(w http.ResponseWriter, r *http.Request) {
|
func apiRing(w http.ResponseWriter, r *http.Request) {
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
var ringAPI *ring.RingRestClient
|
var ringAPI *ring.RingApi
|
||||||
var err error
|
|
||||||
|
|
||||||
// Check auth method
|
// Check auth method
|
||||||
if email := query.Get("email"); email != "" {
|
if email := query.Get("email"); email != "" {
|
||||||
@@ -30,7 +30,8 @@ func apiRing(w http.ResponseWriter, r *http.Request) {
|
|||||||
password := query.Get("password")
|
password := query.Get("password")
|
||||||
code := query.Get("code")
|
code := query.Get("code")
|
||||||
|
|
||||||
ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{
|
var err error
|
||||||
|
ringAPI, err = ring.NewRestClient(ring.EmailAuth{
|
||||||
Email: email,
|
Email: email,
|
||||||
Password: password,
|
Password: password,
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -44,7 +45,7 @@ func apiRing(w http.ResponseWriter, r *http.Request) {
|
|||||||
if _, err = ringAPI.GetAuth(code); err != nil {
|
if _, err = ringAPI.GetAuth(code); err != nil {
|
||||||
if ringAPI.Using2FA {
|
if ringAPI.Using2FA {
|
||||||
// Return 2FA prompt
|
// Return 2FA prompt
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
api.ResponseJSON(w, map[string]interface{}{
|
||||||
"needs_2fa": true,
|
"needs_2fa": true,
|
||||||
"prompt": ringAPI.PromptFor2FA,
|
"prompt": ringAPI.PromptFor2FA,
|
||||||
})
|
})
|
||||||
@@ -53,36 +54,39 @@ func apiRing(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else if refreshToken := query.Get("refresh_token"); refreshToken != "" {
|
||||||
// Refresh Token Flow
|
// Refresh Token Flow
|
||||||
refreshToken := query.Get("refresh_token")
|
|
||||||
if refreshToken == "" {
|
if refreshToken == "" {
|
||||||
http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest)
|
http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ringAPI, err = ring.NewRingRestClient(ring.RefreshTokenAuth{
|
var err error
|
||||||
|
ringAPI, err = ring.NewRestClient(ring.RefreshTokenAuth{
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
http.Error(w, "either email/password or refresh token is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch devices
|
|
||||||
devices, err := ringAPI.FetchRingDevices()
|
devices, err := ringAPI.FetchRingDevices()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create clean query with only required parameters
|
|
||||||
cleanQuery := url.Values{}
|
cleanQuery := url.Values{}
|
||||||
cleanQuery.Set("refresh_token", ringAPI.RefreshToken)
|
cleanQuery.Set("refresh_token", ringAPI.RefreshToken)
|
||||||
|
|
||||||
var items []*api.Source
|
var items []*api.Source
|
||||||
for _, camera := range devices.AllCameras {
|
for _, camera := range devices.AllCameras {
|
||||||
|
cleanQuery.Set("camera_id", fmt.Sprint(camera.ID))
|
||||||
cleanQuery.Set("device_id", camera.DeviceID)
|
cleanQuery.Set("device_id", camera.DeviceID)
|
||||||
|
|
||||||
// Stream source
|
// Stream source
|
||||||
|
|||||||
+63
-5
@@ -5,10 +5,14 @@ import (
|
|||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/api"
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
"github.com/AlexxIT/go2rtc/internal/app"
|
"github.com/AlexxIT/go2rtc/internal/app"
|
||||||
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
|
"github.com/AlexxIT/go2rtc/pkg/creds"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/probe"
|
"github.com/AlexxIT/go2rtc/pkg/probe"
|
||||||
)
|
)
|
||||||
|
|
||||||
func apiStreams(w http.ResponseWriter, r *http.Request) {
|
func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w = creds.SecretResponse(w)
|
||||||
|
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
src := query.Get("src")
|
src := query.Get("src")
|
||||||
|
|
||||||
@@ -27,7 +31,7 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cons := probe.NewProbe(query)
|
cons := probe.Create("probe", query)
|
||||||
if len(cons.Medias) != 0 {
|
if len(cons.Medias) != 0 {
|
||||||
cons.WithRequest(r)
|
cons.WithRequest(r)
|
||||||
if err := stream.AddConsumer(cons); err != nil {
|
if err := stream.AddConsumer(cons); err != nil {
|
||||||
@@ -48,8 +52,8 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
|||||||
name = src
|
name = src
|
||||||
}
|
}
|
||||||
|
|
||||||
if New(name, query["src"]...) == nil {
|
if _, err := New(name, query["src"]...); err != nil {
|
||||||
http.Error(w, "", http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +69,8 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass
|
// support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass
|
||||||
if Patch(name, src) == nil {
|
if _, err := Patch(name, src); err != nil {
|
||||||
http.Error(w, "", http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "POST":
|
case "POST":
|
||||||
@@ -120,5 +124,59 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
dot = append(dot, '}')
|
dot = append(dot, '}')
|
||||||
|
|
||||||
|
dot = []byte(creds.SecretString(string(dot)))
|
||||||
|
|
||||||
api.Response(w, dot, "text/vnd.graphviz")
|
api.Response(w, dot, "text/vnd.graphviz")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case "PUT":
|
||||||
|
// it's safe to delete from map while iterating
|
||||||
|
for k := range query {
|
||||||
|
switch k {
|
||||||
|
case core.KindVideo, core.KindAudio, "microphone":
|
||||||
|
default:
|
||||||
|
delete(query, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawQuery := query.Encode()
|
||||||
|
|
||||||
|
if err := AddPreload(stream, rawQuery); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.PatchConfig([]string{"preload", src}, rawQuery); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DELETE":
|
||||||
|
if err := DelPreload(stream); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.PatchConfig([]string{"preload", src}, nil); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
@@ -15,6 +16,21 @@ func HandleFunc(scheme string, handler Handler) {
|
|||||||
handlers[scheme] = 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 {
|
func HasProducer(url string) bool {
|
||||||
if i := strings.IndexByte(url, ':'); i > 0 {
|
if i := strings.IndexByte(url, ':'); i > 0 {
|
||||||
scheme := url[:i]
|
scheme := url[:i]
|
||||||
@@ -95,3 +111,24 @@ func GetConsumer(url string) (core.Consumer, func(), error) {
|
|||||||
|
|
||||||
return nil, nil, errors.New("streams: unsupported scheme: " + url)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package streams
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddPreload(stream *Stream, rawQuery string) error {
|
||||||
|
if rawQuery == "" {
|
||||||
|
rawQuery = "video&audio"
|
||||||
|
}
|
||||||
|
|
||||||
|
query, err := url.ParseQuery(rawQuery)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
preloadsMu.Lock()
|
||||||
|
defer preloadsMu.Unlock()
|
||||||
|
|
||||||
|
if cons := preloads[stream]; cons != nil {
|
||||||
|
stream.RemoveConsumer(cons)
|
||||||
|
}
|
||||||
|
|
||||||
|
cons := probe.Create("preload", query)
|
||||||
|
|
||||||
|
if err = stream.AddConsumer(cons); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
preloads[stream] = cons
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DelPreload(stream *Stream) error {
|
||||||
|
preloadsMu.Lock()
|
||||||
|
defer preloadsMu.Unlock()
|
||||||
|
|
||||||
|
if cons := preloads[stream]; cons != nil {
|
||||||
|
stream.RemoveConsumer(cons)
|
||||||
|
delete(preloads, stream)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("streams: preload not found")
|
||||||
|
}
|
||||||
+31
-31
@@ -3,7 +3,6 @@ package streams
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,8 +13,9 @@ import (
|
|||||||
|
|
||||||
func Init() {
|
func Init() {
|
||||||
var cfg struct {
|
var cfg struct {
|
||||||
Streams map[string]any `yaml:"streams"`
|
Streams map[string]any `yaml:"streams"`
|
||||||
Publish map[string]any `yaml:"publish"`
|
Publish map[string]any `yaml:"publish"`
|
||||||
|
Preload map[string]string `yaml:"preload"`
|
||||||
}
|
}
|
||||||
|
|
||||||
app.LoadConfig(&cfg)
|
app.LoadConfig(&cfg)
|
||||||
@@ -28,34 +28,36 @@ func Init() {
|
|||||||
|
|
||||||
api.HandleFunc("api/streams", apiStreams)
|
api.HandleFunc("api/streams", apiStreams)
|
||||||
api.HandleFunc("api/streams.dot", apiStreamsDOT)
|
api.HandleFunc("api/streams.dot", apiStreamsDOT)
|
||||||
|
api.HandleFunc("api/preload", apiPreload)
|
||||||
|
api.HandleFunc("api/schemes", apiSchemes)
|
||||||
|
|
||||||
if cfg.Publish == nil {
|
if cfg.Publish == nil && cfg.Preload == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
time.AfterFunc(time.Second, func() {
|
time.AfterFunc(time.Second, func() {
|
||||||
|
// range for nil map is OK
|
||||||
for name, dst := range cfg.Publish {
|
for name, dst := range cfg.Publish {
|
||||||
if stream := Get(name); stream != nil {
|
if stream := Get(name); stream != nil {
|
||||||
Publish(stream, dst)
|
Publish(stream, dst)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for name, rawQuery := range cfg.Preload {
|
||||||
|
if stream := Get(name); stream != nil {
|
||||||
|
Preload(stream, rawQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var sanitize = regexp.MustCompile(`\s`)
|
func New(name string, sources ...string) (*Stream, error) {
|
||||||
|
|
||||||
// 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 {
|
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
if Validate(source) != nil {
|
if !HasProducer(source) {
|
||||||
return nil
|
return nil, errors.New("streams: source not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Validate(source); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,10 +67,10 @@ func New(name string, sources ...string) *Stream {
|
|||||||
streams[name] = stream
|
streams[name] = stream
|
||||||
streamsMu.Unlock()
|
streamsMu.Unlock()
|
||||||
|
|
||||||
return stream
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Patch(name string, source string) *Stream {
|
func Patch(name string, source string) (*Stream, error) {
|
||||||
streamsMu.Lock()
|
streamsMu.Lock()
|
||||||
defer streamsMu.Unlock()
|
defer streamsMu.Unlock()
|
||||||
|
|
||||||
@@ -80,7 +82,7 @@ func Patch(name string, source string) *Stream {
|
|||||||
// link (alias) streams[name] to streams[rtspName]
|
// link (alias) streams[name] to streams[rtspName]
|
||||||
streams[name] = stream
|
streams[name] = stream
|
||||||
}
|
}
|
||||||
return stream
|
return stream, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,46 +91,44 @@ func Patch(name string, source string) *Stream {
|
|||||||
// link (alias) streams[name] to streams[source]
|
// link (alias) streams[name] to streams[source]
|
||||||
streams[name] = stream
|
streams[name] = stream
|
||||||
}
|
}
|
||||||
return stream
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if src has supported scheme
|
// check if src has supported scheme
|
||||||
if !HasProducer(source) {
|
if !HasProducer(source) {
|
||||||
return nil
|
return nil, errors.New("streams: source not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
if Validate(source) != nil {
|
if err := Validate(source); err != nil {
|
||||||
return nil
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// check an existing stream with this name
|
// check an existing stream with this name
|
||||||
if stream, ok := streams[name]; ok {
|
if stream, ok := streams[name]; ok {
|
||||||
stream.SetSource(source)
|
stream.SetSource(source)
|
||||||
return stream
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// create new stream with this name
|
// create new stream with this name
|
||||||
stream := NewStream(source)
|
stream := NewStream(source)
|
||||||
streams[name] = stream
|
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
|
// check if src param exists
|
||||||
source := query.Get("src")
|
source := query.Get("src")
|
||||||
if source == "" {
|
if source == "" {
|
||||||
return nil
|
return nil, errors.New("streams: source empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if src is stream name
|
// check if src is stream name
|
||||||
if stream := Get(source); stream != nil {
|
if stream := Get(source); stream != nil {
|
||||||
return stream
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if name param provided
|
// check if name param provided
|
||||||
if name := query.Get("name"); name != "" {
|
if name := query.Get("name"); name != "" {
|
||||||
log.Info().Msgf("[streams] create new stream url=%s", source)
|
|
||||||
|
|
||||||
return Patch(name, source)
|
return Patch(name, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,12 @@ func switchbotClient(rawURL string, query url.Values) (core.Producer, error) {
|
|||||||
v.Resolution = 0
|
v.Resolution = 0
|
||||||
case "sd":
|
case "sd":
|
||||||
v.Resolution = 1
|
v.Resolution = 1
|
||||||
|
case "auto":
|
||||||
|
v.Resolution = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
v.PlayType = core.Atoi(query.Get("play_type")) // zero by default
|
||||||
|
|
||||||
return v, nil
|
return v, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) (err error) {
|
|||||||
|
|
||||||
query := tr.Request.URL.Query()
|
query := tr.Request.URL.Query()
|
||||||
if name := query.Get("src"); name != "" {
|
if name := query.Get("src"); name != "" {
|
||||||
stream = streams.GetOrPatch(query)
|
stream, _ = streams.GetOrPatch(query)
|
||||||
mode = core.ModePassiveConsumer
|
mode = core.ModePassiveConsumer
|
||||||
log.Debug().Str("src", name).Msg("[webrtc] new consumer")
|
log.Debug().Str("src", name).Msg("[webrtc] new consumer")
|
||||||
} else if name = query.Get("dst"); name != "" {
|
} else if name = query.Get("dst"); name != "" {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/alsa"
|
"github.com/AlexxIT/go2rtc/internal/alsa"
|
||||||
"github.com/AlexxIT/go2rtc/internal/api"
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||||
@@ -44,68 +46,67 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app.Version = "1.9.10"
|
app.Version = "1.9.12"
|
||||||
|
|
||||||
// 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},
|
||||||
|
{"nest", nest.Init},
|
||||||
|
{"ring", ring.Init},
|
||||||
|
{"roborock", roborock.Init},
|
||||||
|
{"tapo", tapo.Init},
|
||||||
|
{"yandex", yandex.Init},
|
||||||
|
// Helper modules
|
||||||
|
{"debug", debug.Init},
|
||||||
|
{"ngrok", ngrok.Init},
|
||||||
|
{"srtp", srtp.Init},
|
||||||
|
}
|
||||||
|
|
||||||
api.Init() // init API before all others
|
for _, m := range modules {
|
||||||
ws.Init() // init WS API endpoint
|
if app.Modules == nil || m.name == "" || slices.Contains(app.Modules, m.name) {
|
||||||
|
m.init()
|
||||||
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
|
|
||||||
|
|
||||||
shell.RunUntilSignal()
|
shell.RunUntilSignal()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Credentials
|
||||||
|
|
||||||
|
This module allows you to get variables:
|
||||||
|
|
||||||
|
- from custom storage (ex. config file)
|
||||||
|
- from [credential files](https://systemd.io/CREDENTIALS/)
|
||||||
|
- from environment variables
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package creds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Storage interface {
|
||||||
|
SetValue(name, value string) error
|
||||||
|
GetValue(name string) (string, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
var storage Storage
|
||||||
|
|
||||||
|
func SetStorage(s Storage) {
|
||||||
|
storage = s
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetValue(name, value string) error {
|
||||||
|
if storage == nil {
|
||||||
|
return errors.New("credentials: storage not initialized")
|
||||||
|
}
|
||||||
|
if err := storage.SetValue(name, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
AddSecret(value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetValue(name string) (value string, ok bool) {
|
||||||
|
value, ok = getValue(name)
|
||||||
|
AddSecret(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func getValue(name string) (string, bool) {
|
||||||
|
if storage != nil {
|
||||||
|
if value, ok := storage.GetValue(name); ok {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if dir, ok := os.LookupEnv("CREDENTIALS_DIRECTORY"); ok {
|
||||||
|
if value, _ := os.ReadFile(filepath.Join(dir, name)); value != nil {
|
||||||
|
return strings.TrimSpace(string(value)), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.LookupEnv(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceVars - support format ${CAMERA_PASSWORD} and ${RTSP_USER:admin}
|
||||||
|
func ReplaceVars(data []byte) []byte {
|
||||||
|
re := regexp.MustCompile(`\${([^}{]+)}`)
|
||||||
|
return re.ReplaceAllFunc(data, func(match []byte) []byte {
|
||||||
|
key := string(match[2 : len(match)-1])
|
||||||
|
|
||||||
|
var def string
|
||||||
|
var defok bool
|
||||||
|
|
||||||
|
if i := strings.IndexByte(key, ':'); i > 0 {
|
||||||
|
key, def = key[:i], key[i+1:]
|
||||||
|
defok = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, ok := GetValue(key); ok {
|
||||||
|
return []byte(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if defok {
|
||||||
|
return []byte(def)
|
||||||
|
}
|
||||||
|
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package creds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddSecret(value string) {
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
secretsMu.Lock()
|
||||||
|
defer secretsMu.Unlock()
|
||||||
|
|
||||||
|
if slices.Contains(secrets, value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
secrets = append(secrets, value)
|
||||||
|
secretsReplacer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var secrets []string
|
||||||
|
var secretsMu sync.Mutex
|
||||||
|
var secretsReplacer *strings.Replacer
|
||||||
|
|
||||||
|
func getReplacer() *strings.Replacer {
|
||||||
|
secretsMu.Lock()
|
||||||
|
defer secretsMu.Unlock()
|
||||||
|
|
||||||
|
if secretsReplacer == nil {
|
||||||
|
oldnew := make([]string, 0, 2*len(secrets))
|
||||||
|
for _, s := range secrets {
|
||||||
|
oldnew = append(oldnew, s, "***")
|
||||||
|
}
|
||||||
|
secretsReplacer = strings.NewReplacer(oldnew...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return secretsReplacer
|
||||||
|
}
|
||||||
|
|
||||||
|
func SecretString(s string) string {
|
||||||
|
re := getReplacer()
|
||||||
|
return re.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SecretWriter(w io.Writer) io.Writer {
|
||||||
|
return &secretWriter{w}
|
||||||
|
}
|
||||||
|
|
||||||
|
type secretWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *secretWriter) Write(b []byte) (int, error) {
|
||||||
|
re := getReplacer()
|
||||||
|
return re.WriteString(s.w, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
type secretResponse struct {
|
||||||
|
w http.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *secretResponse) Header() http.Header {
|
||||||
|
return s.w.Header()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *secretResponse) Write(b []byte) (int, error) {
|
||||||
|
re := getReplacer()
|
||||||
|
return re.WriteString(s.w, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *secretResponse) WriteHeader(statusCode int) {
|
||||||
|
s.w.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SecretResponse(w http.ResponseWriter) http.ResponseWriter {
|
||||||
|
return &secretResponse{w}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package creds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestString(t *testing.T) {
|
||||||
|
AddSecret("admin")
|
||||||
|
AddSecret("pa$$word")
|
||||||
|
|
||||||
|
s := SecretString("rtsp://admin:pa$$word@192.168.1.123/stream1")
|
||||||
|
require.Equal(t, "rtsp://***:***@192.168.1.123/stream1", s)
|
||||||
|
}
|
||||||
@@ -88,6 +88,8 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Start() (err error) {
|
func (c *Client) Start() (err error) {
|
||||||
_, err = c.conn.Read(nil)
|
// just block until c.conn closed
|
||||||
|
b := make([]byte, 1)
|
||||||
|
_, err = c.conn.Read(b)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-12
@@ -9,11 +9,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
||||||
//vps, sps, pps := GetParameterSet(codec.FmtpLine)
|
vps, sps, pps := GetParameterSet(codec.FmtpLine)
|
||||||
//ps := h264.EncodeAVC(vps, sps, pps)
|
ps := h264.JoinNALU(vps, sps, pps)
|
||||||
|
|
||||||
buf := make([]byte, 0, 512*1024) // 512K
|
buf := make([]byte, 0, 512*1024) // 512K
|
||||||
var nuStart int
|
var nuStart int
|
||||||
|
var seqNum uint16
|
||||||
|
|
||||||
return func(packet *rtp.Packet) {
|
return func(packet *rtp.Packet) {
|
||||||
data := packet.Payload
|
data := packet.Payload
|
||||||
@@ -34,28 +35,55 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// when we collect data into one buffer, we need to make sure
|
||||||
|
// that all of it falls into the same sequence
|
||||||
|
if len(buf) > 0 && packet.SequenceNumber-seqNum != 1 {
|
||||||
|
//log.Printf("broken H265 sequence")
|
||||||
|
buf = buf[:0] // drop data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seqNum = packet.SequenceNumber
|
||||||
|
|
||||||
if nuType == NALUTypeFU {
|
if nuType == NALUTypeFU {
|
||||||
switch data[2] >> 6 {
|
switch data[2] >> 6 {
|
||||||
case 2: // begin
|
case 0b10: // begin
|
||||||
nuType = data[2] & 0x3F
|
nuType = data[2] & 0x3F
|
||||||
|
|
||||||
// push PS data before keyframe
|
// push PS data before keyframe
|
||||||
//if len(buf) == 0 && nuType >= 19 && nuType <= 21 {
|
if len(buf) == 0 && nuType >= 19 && nuType <= 21 {
|
||||||
// buf = append(buf, ps...)
|
buf = append(buf, ps...)
|
||||||
//}
|
}
|
||||||
|
|
||||||
nuStart = len(buf)
|
nuStart = len(buf)
|
||||||
buf = append(buf, 0, 0, 0, 0) // NAL unit size
|
buf = append(buf, 0, 0, 0, 0) // NAL unit size
|
||||||
buf = append(buf, (data[0]&0x81)|(nuType<<1), data[1])
|
buf = append(buf, (data[0]&0x81)|(nuType<<1), data[1])
|
||||||
buf = append(buf, data[3:]...)
|
buf = append(buf, data[3:]...)
|
||||||
return
|
return
|
||||||
case 0: // continue
|
case 0b00: // continue
|
||||||
|
if len(buf) == 0 {
|
||||||
|
//log.Printf("broken H265 fragment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
buf = append(buf, data[3:]...)
|
buf = append(buf, data[3:]...)
|
||||||
return
|
return
|
||||||
case 1: // end
|
case 0b01: // end
|
||||||
|
if len(buf) == 0 {
|
||||||
|
//log.Printf("broken H265 fragment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
buf = append(buf, data[3:]...)
|
buf = append(buf, data[3:]...)
|
||||||
|
|
||||||
|
if nuStart > len(buf)+4 {
|
||||||
|
//log.Printf("broken H265 fragment")
|
||||||
|
buf = buf[:0] // drop data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
binary.BigEndian.PutUint32(buf[nuStart:], uint32(len(buf)-nuStart-4))
|
binary.BigEndian.PutUint32(buf[nuStart:], uint32(len(buf)-nuStart-4))
|
||||||
case 3: // wrong RFC 7798 realisation from OpenIPC project
|
case 0b11: // wrong RFC 7798 realisation from OpenIPC project
|
||||||
// A non-fragmented NAL unit MUST NOT be transmitted in one FU; i.e.,
|
// A non-fragmented NAL unit MUST NOT be transmitted in one FU; i.e.,
|
||||||
// the Start bit and End bit must not both be set to 1 in the same FU
|
// the Start bit and End bit must not both be set to 1 in the same FU
|
||||||
// header.
|
// header.
|
||||||
@@ -65,10 +93,8 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
|||||||
buf = append(buf, data[3:]...)
|
buf = append(buf, data[3:]...)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
nuStart = len(buf)
|
buf = binary.BigEndian.AppendUint32(buf, uint32(len(data))) // NAL unit size
|
||||||
buf = append(buf, 0, 0, 0, 0) // NAL unit size
|
|
||||||
buf = append(buf, data...)
|
buf = append(buf, data...)
|
||||||
binary.BigEndian.PutUint32(buf[nuStart:], uint32(len(data)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// collect all NAL Units for Access Unit
|
// collect all NAL Units for Access Unit
|
||||||
|
|||||||
@@ -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{
|
val120, _ := tlv8.MarshalBase64(StreamingStatus{
|
||||||
Status: StreamingStatusAvailable,
|
Status: StreamingStatusAvailable,
|
||||||
})
|
})
|
||||||
val114, _ := tlv8.MarshalBase64(SupportedVideoStreamConfig{
|
val114, _ := tlv8.MarshalBase64(SupportedVideoStreamConfiguration{
|
||||||
Codecs: []VideoCodec{
|
Codecs: []VideoCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: VideoCodecTypeH264,
|
CodecType: VideoCodecTypeH264,
|
||||||
CodecParams: []VideoParams{
|
CodecParams: []VideoCodecParameters{
|
||||||
{
|
{
|
||||||
ProfileID: []byte{VideoCodecProfileMain},
|
ProfileID: []byte{VideoCodecProfileMain},
|
||||||
Level: []byte{VideoCodecLevel31, VideoCodecLevel40},
|
Level: []byte{VideoCodecLevel31, VideoCodecLevel40},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
VideoAttrs: []VideoAttrs{
|
VideoAttrs: []VideoCodecAttributes{
|
||||||
{Width: 1920, Height: 1080, Framerate: 30},
|
{Width: 1920, Height: 1080, Framerate: 30},
|
||||||
{Width: 1280, Height: 720, Framerate: 30}, // important for iPhones
|
{Width: 1280, Height: 720, Framerate: 30}, // important for iPhones
|
||||||
{Width: 320, Height: 240, Framerate: 15}, // apple watch
|
{Width: 320, Height: 240, Framerate: 15}, // apple watch
|
||||||
@@ -67,23 +67,23 @@ func ServiceCameraRTPStreamManagement() *hap.Service {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
val115, _ := tlv8.MarshalBase64(SupportedAudioStreamConfig{
|
val115, _ := tlv8.MarshalBase64(SupportedAudioStreamConfiguration{
|
||||||
Codecs: []AudioCodec{
|
Codecs: []AudioCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: AudioCodecTypeOpus,
|
CodecType: AudioCodecTypeOpus,
|
||||||
CodecParams: []AudioParams{
|
CodecParams: []AudioCodecParameters{
|
||||||
{
|
{
|
||||||
Channels: 1,
|
Channels: 1,
|
||||||
Bitrate: AudioCodecBitrateVariable,
|
BitrateMode: AudioCodecBitrateVariable,
|
||||||
SampleRate: []byte{AudioCodecSampleRate16Khz},
|
SampleRate: []byte{AudioCodecSampleRate16Khz},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ComfortNoise: 0,
|
ComfortNoiseSupport: 0,
|
||||||
})
|
})
|
||||||
val116, _ := tlv8.MarshalBase64(SupportedRTPConfig{
|
val116, _ := tlv8.MarshalBase64(SupportedRTPConfiguration{
|
||||||
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
|
SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
|
||||||
})
|
})
|
||||||
|
|
||||||
service := &hap.Service{
|
service := &hap.Service{
|
||||||
|
|||||||
@@ -63,19 +63,19 @@ func TestAqaraG3(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "114",
|
name: "114",
|
||||||
value: "AaoBAQACEQEBAQIBAAAAAgECAwEABAEAAwsBAoAHAgI4BAMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAkABAgK0AAMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAgAEAgIAAwMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAkABAgLwAAMBHg==",
|
value: "AaoBAQACEQEBAQIBAAAAAgECAwEABAEAAwsBAoAHAgI4BAMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAkABAgK0AAMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAgAEAgIAAwMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAkABAgLwAAMBHg==",
|
||||||
actual: &SupportedVideoStreamConfig{},
|
actual: &SupportedVideoStreamConfiguration{},
|
||||||
expect: &SupportedVideoStreamConfig{
|
expect: &SupportedVideoStreamConfiguration{
|
||||||
Codecs: []VideoCodec{
|
Codecs: []VideoCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: VideoCodecTypeH264,
|
CodecType: VideoCodecTypeH264,
|
||||||
CodecParams: []VideoParams{
|
CodecParams: []VideoCodecParameters{
|
||||||
{
|
{
|
||||||
ProfileID: []byte{VideoCodecProfileMain},
|
ProfileID: []byte{VideoCodecProfileMain},
|
||||||
Level: []byte{VideoCodecLevel31, VideoCodecLevel40},
|
Level: []byte{VideoCodecLevel31, VideoCodecLevel40},
|
||||||
CVOEnabled: []byte{0},
|
CVOEnabled: []byte{0},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
VideoAttrs: []VideoAttrs{
|
VideoAttrs: []VideoCodecAttributes{
|
||||||
{Width: 1920, Height: 1080, Framerate: 30},
|
{Width: 1920, Height: 1080, Framerate: 30},
|
||||||
{Width: 1280, Height: 720, Framerate: 30},
|
{Width: 1280, Height: 720, Framerate: 30},
|
||||||
{Width: 640, Height: 360, Framerate: 30},
|
{Width: 640, Height: 360, Framerate: 30},
|
||||||
@@ -94,29 +94,29 @@ func TestAqaraG3(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "115",
|
name: "115",
|
||||||
value: "AQ4BAQICCQEBAQIBAAMBAQIBAA==",
|
value: "AQ4BAQICCQEBAQIBAAMBAQIBAA==",
|
||||||
actual: &SupportedAudioStreamConfig{},
|
actual: &SupportedAudioStreamConfiguration{},
|
||||||
expect: &SupportedAudioStreamConfig{
|
expect: &SupportedAudioStreamConfiguration{
|
||||||
Codecs: []AudioCodec{
|
Codecs: []AudioCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: AudioCodecTypeAACELD,
|
CodecType: AudioCodecTypeAACELD,
|
||||||
CodecParams: []AudioParams{
|
CodecParams: []AudioCodecParameters{
|
||||||
{
|
{
|
||||||
Channels: 1,
|
Channels: 1,
|
||||||
Bitrate: AudioCodecBitrateVariable,
|
BitrateMode: AudioCodecBitrateVariable,
|
||||||
SampleRate: []byte{AudioCodecSampleRate16Khz},
|
SampleRate: []byte{AudioCodecSampleRate16Khz},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ComfortNoise: 0,
|
ComfortNoiseSupport: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "116",
|
name: "116",
|
||||||
value: "AgEAAAACAQEAAAIBAg==",
|
value: "AgEAAAACAQEAAAIBAg==",
|
||||||
actual: &SupportedRTPConfig{},
|
actual: &SupportedRTPConfiguration{},
|
||||||
expect: &SupportedRTPConfig{
|
expect: &SupportedRTPConfiguration{
|
||||||
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoAES_CM_256_HMAC_SHA1_80, CryptoNone},
|
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",
|
name: "114",
|
||||||
value: "AcUBAQACHQEBAAAAAQEBAAABAQICAQAAAAIBAQAAAgECAwEAAwsBAkABAgK0AAMBHgAAAwsBAkABAgLwAAMBDwAAAwsBAkABAgLwAAMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAoAHAgI4BAMBHgAAAwsBAkAGAgKwBAMBHg==",
|
value: "AcUBAQACHQEBAAAAAQEBAAABAQICAQAAAAIBAQAAAgECAwEAAwsBAkABAgK0AAMBHgAAAwsBAkABAgLwAAMBDwAAAwsBAkABAgLwAAMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAoAHAgI4BAMBHgAAAwsBAkAGAgKwBAMBHg==",
|
||||||
actual: &SupportedVideoStreamConfig{},
|
actual: &SupportedVideoStreamConfiguration{},
|
||||||
expect: &SupportedVideoStreamConfig{
|
expect: &SupportedVideoStreamConfiguration{
|
||||||
Codecs: []VideoCodec{
|
Codecs: []VideoCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: VideoCodecTypeH264,
|
CodecType: VideoCodecTypeH264,
|
||||||
CodecParams: []VideoParams{
|
CodecParams: []VideoCodecParameters{
|
||||||
{
|
{
|
||||||
ProfileID: []byte{VideoCodecProfileConstrainedBaseline, VideoCodecProfileMain, VideoCodecProfileHigh},
|
ProfileID: []byte{VideoCodecProfileConstrainedBaseline, VideoCodecProfileMain, VideoCodecProfileHigh},
|
||||||
Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40},
|
Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
VideoAttrs: []VideoAttrs{
|
VideoAttrs: []VideoCodecAttributes{
|
||||||
|
|
||||||
{Width: 320, Height: 180, Framerate: 30},
|
{Width: 320, Height: 180, Framerate: 30},
|
||||||
{Width: 320, Height: 240, Framerate: 15},
|
{Width: 320, Height: 240, Framerate: 15},
|
||||||
@@ -162,9 +162,9 @@ func TestHomebridge(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "116",
|
name: "116",
|
||||||
value: "AgEA",
|
value: "AgEA",
|
||||||
actual: &SupportedRTPConfig{},
|
actual: &SupportedRTPConfiguration{},
|
||||||
expect: &SupportedRTPConfig{
|
expect: &SupportedRTPConfiguration{
|
||||||
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
|
SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -178,18 +178,18 @@ func TestScrypted(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "114",
|
name: "114",
|
||||||
value: "AVIBAQACEwEBAQIBAAAAAgEBAAACAQIDAQADCwECAA8CAnAIAwEeAAADCwECgAcCAjgEAwEeAAADCwECAAUCAtACAwEeAAADCwECQAECAvAAAwEP",
|
value: "AVIBAQACEwEBAQIBAAAAAgEBAAACAQIDAQADCwECAA8CAnAIAwEeAAADCwECgAcCAjgEAwEeAAADCwECAAUCAtACAwEeAAADCwECQAECAvAAAwEP",
|
||||||
actual: &SupportedVideoStreamConfig{},
|
actual: &SupportedVideoStreamConfiguration{},
|
||||||
expect: &SupportedVideoStreamConfig{
|
expect: &SupportedVideoStreamConfiguration{
|
||||||
Codecs: []VideoCodec{
|
Codecs: []VideoCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: VideoCodecTypeH264,
|
CodecType: VideoCodecTypeH264,
|
||||||
CodecParams: []VideoParams{
|
CodecParams: []VideoCodecParameters{
|
||||||
{
|
{
|
||||||
ProfileID: []byte{VideoCodecProfileMain},
|
ProfileID: []byte{VideoCodecProfileMain},
|
||||||
Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40},
|
Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
VideoAttrs: []VideoAttrs{
|
VideoAttrs: []VideoCodecAttributes{
|
||||||
{Width: 3840, Height: 2160, Framerate: 30},
|
{Width: 3840, Height: 2160, Framerate: 30},
|
||||||
{Width: 1920, Height: 1080, Framerate: 30},
|
{Width: 1920, Height: 1080, Framerate: 30},
|
||||||
{Width: 1280, Height: 720, Framerate: 30},
|
{Width: 1280, Height: 720, Framerate: 30},
|
||||||
@@ -202,15 +202,15 @@ func TestScrypted(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "115",
|
name: "115",
|
||||||
value: "AScBAQMCIgEBAQIBAAMBAAAAAwEAAAADAQEAAAMBAQAAAwECAAADAQICAQA=",
|
value: "AScBAQMCIgEBAQIBAAMBAAAAAwEAAAADAQEAAAMBAQAAAwECAAADAQICAQA=",
|
||||||
actual: &SupportedAudioStreamConfig{},
|
actual: &SupportedAudioStreamConfiguration{},
|
||||||
expect: &SupportedAudioStreamConfig{
|
expect: &SupportedAudioStreamConfiguration{
|
||||||
Codecs: []AudioCodec{
|
Codecs: []AudioCodecConfiguration{
|
||||||
{
|
{
|
||||||
CodecType: AudioCodecTypeOpus,
|
CodecType: AudioCodecTypeOpus,
|
||||||
CodecParams: []AudioParams{
|
CodecParams: []AudioCodecParameters{
|
||||||
{
|
{
|
||||||
Channels: 1,
|
Channels: 1,
|
||||||
Bitrate: AudioCodecBitrateVariable,
|
BitrateMode: AudioCodecBitrateVariable,
|
||||||
SampleRate: []byte{
|
SampleRate: []byte{
|
||||||
AudioCodecSampleRate8Khz, AudioCodecSampleRate8Khz,
|
AudioCodecSampleRate8Khz, AudioCodecSampleRate8Khz,
|
||||||
AudioCodecSampleRate16Khz, AudioCodecSampleRate16Khz,
|
AudioCodecSampleRate16Khz, AudioCodecSampleRate16Khz,
|
||||||
@@ -220,15 +220,15 @@ func TestScrypted(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ComfortNoise: 0,
|
ComfortNoiseSupport: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "116",
|
name: "116",
|
||||||
value: "AgEAAAACAQI=",
|
value: "AgEAAAACAQI=",
|
||||||
actual: &SupportedRTPConfig{},
|
actual: &SupportedRTPConfiguration{},
|
||||||
expect: &SupportedRTPConfig{
|
expect: &SupportedRTPConfiguration{
|
||||||
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoNone},
|
SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoDisabled},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ package camera
|
|||||||
|
|
||||||
const TypeSupportedVideoStreamConfiguration = "114"
|
const TypeSupportedVideoStreamConfiguration = "114"
|
||||||
|
|
||||||
type SupportedVideoStreamConfig struct {
|
type SupportedVideoStreamConfiguration struct {
|
||||||
Codecs []VideoCodec `tlv8:"1"`
|
Codecs []VideoCodecConfiguration `tlv8:"1"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoCodec struct {
|
type VideoCodecConfiguration struct {
|
||||||
CodecType byte `tlv8:"1"`
|
CodecType byte `tlv8:"1"`
|
||||||
CodecParams []VideoParams `tlv8:"2"`
|
CodecParams []VideoCodecParameters `tlv8:"2"`
|
||||||
VideoAttrs []VideoAttrs `tlv8:"3"`
|
VideoAttrs []VideoCodecAttributes `tlv8:"3"`
|
||||||
RTPParams []RTPParams `tlv8:"4"`
|
RTPParams []RTPParams `tlv8:"4"`
|
||||||
}
|
}
|
||||||
|
|
||||||
//goland:noinspection ALL
|
//goland:noinspection ALL
|
||||||
@@ -31,15 +31,15 @@ const (
|
|||||||
VideoCodecCvoSuppported = 1
|
VideoCodecCvoSuppported = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
type VideoParams struct {
|
type VideoCodecParameters struct {
|
||||||
ProfileID []byte `tlv8:"1"` // 0 - baseline, 1 - main, 2 - high
|
ProfileID []byte `tlv8:"1"` // 0 - baseline, 1 - main, 2 - high
|
||||||
Level []byte `tlv8:"2"` // 0 - 3.1, 1 - 3.2, 2 - 4.0
|
Level []byte `tlv8:"2"` // 0 - 3.1, 1 - 3.2, 2 - 4.0
|
||||||
PacketizationMode byte `tlv8:"3"` // only 0 - non interleaved
|
PacketizationMode byte `tlv8:"3"` // only 0 - non interleaved
|
||||||
CVOEnabled []byte `tlv8:"4"` // 0 - not supported, 1 - supported
|
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"`
|
Width uint16 `tlv8:"1"`
|
||||||
Height uint16 `tlv8:"2"`
|
Height uint16 `tlv8:"2"`
|
||||||
Framerate uint8 `tlv8:"3"`
|
Framerate uint8 `tlv8:"3"`
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package camera
|
|||||||
|
|
||||||
const TypeSupportedAudioStreamConfiguration = "115"
|
const TypeSupportedAudioStreamConfiguration = "115"
|
||||||
|
|
||||||
type SupportedAudioStreamConfig struct {
|
type SupportedAudioStreamConfiguration struct {
|
||||||
Codecs []AudioCodec `tlv8:"1"`
|
Codecs []AudioCodecConfiguration `tlv8:"1"`
|
||||||
ComfortNoise byte `tlv8:"2"`
|
ComfortNoiseSupport byte `tlv8:"2"`
|
||||||
}
|
}
|
||||||
|
|
||||||
//goland:noinspection ALL
|
//goland:noinspection ALL
|
||||||
@@ -31,16 +31,16 @@ const (
|
|||||||
RTPTimeAACLD24 = 40 // 24000/1000*40=960
|
RTPTimeAACLD24 = 40 // 24000/1000*40=960
|
||||||
)
|
)
|
||||||
|
|
||||||
type AudioCodec struct {
|
type AudioCodecConfiguration struct {
|
||||||
CodecType byte `tlv8:"1"`
|
CodecType byte `tlv8:"1"`
|
||||||
CodecParams []AudioParams `tlv8:"2"`
|
CodecParams []AudioCodecParameters `tlv8:"2"`
|
||||||
RTPParams []RTPParams `tlv8:"3"`
|
RTPParams []RTPParams `tlv8:"3"`
|
||||||
ComfortNoise []byte `tlv8:"4"`
|
ComfortNoise []byte `tlv8:"4"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AudioParams struct {
|
type AudioCodecParameters struct {
|
||||||
Channels uint8 `tlv8:"1"`
|
Channels uint8 `tlv8:"1"`
|
||||||
Bitrate byte `tlv8:"2"` // 0 - variable, 1 - constant
|
BitrateMode byte `tlv8:"2"` // 0 - variable, 1 - constant
|
||||||
SampleRate []byte `tlv8:"3"` // 0 - 8000, 1 - 16000, 2 - 24000
|
SampleRate []byte `tlv8:"3"` // 0 - 8000, 1 - 16000, 2 - 24000
|
||||||
RTPTime []uint8 `tlv8:"4"` // 20, 30, 40, 60
|
RTPTime []uint8 `tlv8:"4"` // 20, 30, 40, 60
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ const TypeSupportedRTPConfiguration = "116"
|
|||||||
const (
|
const (
|
||||||
CryptoAES_CM_128_HMAC_SHA1_80 = 0
|
CryptoAES_CM_128_HMAC_SHA1_80 = 0
|
||||||
CryptoAES_CM_256_HMAC_SHA1_80 = 1
|
CryptoAES_CM_256_HMAC_SHA1_80 = 1
|
||||||
CryptoNone = 2
|
CryptoDisabled = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
type SupportedRTPConfig struct {
|
type SupportedRTPConfiguration struct {
|
||||||
CryptoType []byte `tlv8:"2"`
|
SRTPCryptoType []byte `tlv8:"2"`
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,10 @@ package camera
|
|||||||
|
|
||||||
const TypeSelectedStreamConfiguration = "117"
|
const TypeSelectedStreamConfiguration = "117"
|
||||||
|
|
||||||
type SelectedStreamConfig struct {
|
type SelectedStreamConfiguration struct {
|
||||||
Control SessionControl `tlv8:"1"`
|
Control SessionControl `tlv8:"1"`
|
||||||
VideoCodec VideoCodec `tlv8:"2"`
|
VideoCodec VideoCodecConfiguration `tlv8:"2"`
|
||||||
AudioCodec AudioCodec `tlv8:"3"`
|
AudioCodec AudioCodecConfiguration `tlv8:"3"`
|
||||||
}
|
}
|
||||||
|
|
||||||
//goland:noinspection ALL
|
//goland:noinspection ALL
|
||||||
|
|||||||
@@ -2,25 +2,32 @@ package camera
|
|||||||
|
|
||||||
const TypeSetupEndpoints = "118"
|
const TypeSetupEndpoints = "118"
|
||||||
|
|
||||||
type SetupEndpoints struct {
|
type SetupEndpointsRequest struct {
|
||||||
SessionID string `tlv8:"1"`
|
SessionID string `tlv8:"1"`
|
||||||
Status []byte `tlv8:"2"`
|
Address Address `tlv8:"3"`
|
||||||
Address Addr `tlv8:"3"`
|
VideoCrypto SRTPCryptoSuite `tlv8:"4"`
|
||||||
VideoCrypto CryptoSuite `tlv8:"4"`
|
AudioCrypto SRTPCryptoSuite `tlv8:"5"`
|
||||||
AudioCrypto CryptoSuite `tlv8:"5"`
|
|
||||||
VideoSSRC []uint32 `tlv8:"6"`
|
|
||||||
AudioSSRC []uint32 `tlv8:"7"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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"`
|
IPVersion byte `tlv8:"1"`
|
||||||
IPAddr string `tlv8:"2"`
|
IPAddr string `tlv8:"2"`
|
||||||
VideoRTPPort uint16 `tlv8:"3"`
|
VideoRTPPort uint16 `tlv8:"3"`
|
||||||
AudioRTPPort uint16 `tlv8:"4"`
|
AudioRTPPort uint16 `tlv8:"4"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CryptoSuite struct {
|
type SRTPCryptoSuite struct {
|
||||||
CryptoType byte `tlv8:"1"`
|
CryptoSuite byte `tlv8:"1"`
|
||||||
MasterKey string `tlv8:"2"` // 16 (AES_CM_128) or 32 (AES_256_CM)
|
MasterKey string `tlv8:"2"` // 16 (AES_CM_128) or 32 (AES_256_CM)
|
||||||
MasterSalt string `tlv8:"3"` // 14 byte
|
MasterSalt string `tlv8:"3"` // 14 byte
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ type StreamingStatus struct {
|
|||||||
//goland:noinspection ALL
|
//goland:noinspection ALL
|
||||||
const (
|
const (
|
||||||
StreamingStatusAvailable = 0
|
StreamingStatusAvailable = 0
|
||||||
StreamingStatusBusy = 1
|
StreamingStatusInUse = 1
|
||||||
StreamingStatusUnavailable = 2
|
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"
|
const TypeSetupDataStreamTransport = "131"
|
||||||
|
|
||||||
type SetupDataStreamRequest struct {
|
type SetupDataStreamTransportRequest struct {
|
||||||
SessionCommandType byte `tlv8:"1"`
|
SessionCommandType byte `tlv8:"1"`
|
||||||
TransportType byte `tlv8:"2"`
|
TransportType byte `tlv8:"2"`
|
||||||
ControllerKeySalt string `tlv8:"3"`
|
ControllerKeySalt string `tlv8:"3"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetupDataStreamResponse struct {
|
type SetupDataStreamTransportResponse struct {
|
||||||
Status byte `tlv8:"1"`
|
Status byte `tlv8:"1"`
|
||||||
TransportTypeSessionParameters struct {
|
TransportTypeSessionParameters struct {
|
||||||
TCPListeningPort uint16 `tlv8:"1"`
|
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(
|
func NewStream(
|
||||||
client *hap.Client, videoCodec *VideoCodec, audioCodec *AudioCodec,
|
client *hap.Client, videoCodec *VideoCodecConfiguration, audioCodec *AudioCodecConfiguration,
|
||||||
videoSession, audioSession *srtp.Session, bitrate int,
|
videoSession, audioSession *srtp.Session, bitrate int,
|
||||||
) (*Stream, error) {
|
) (*Stream, error) {
|
||||||
stream := &Stream{
|
stream := &Stream{
|
||||||
@@ -58,7 +58,7 @@ func NewStream(
|
|||||||
}
|
}
|
||||||
audioCodec.ComfortNoise = []byte{0}
|
audioCodec.ComfortNoise = []byte{0}
|
||||||
|
|
||||||
config := &SelectedStreamConfig{
|
config := &SelectedStreamConfiguration{
|
||||||
Control: SessionControl{
|
Control: SessionControl{
|
||||||
SessionID: stream.id,
|
SessionID: stream.id,
|
||||||
Command: SessionCommandStart,
|
Command: SessionCommandStart,
|
||||||
@@ -103,19 +103,19 @@ func (s *Stream) GetFreeStream() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) error {
|
func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) error {
|
||||||
req := SetupEndpoints{
|
req := SetupEndpointsRequest{
|
||||||
SessionID: s.id,
|
SessionID: s.id,
|
||||||
Address: Addr{
|
Address: Address{
|
||||||
IPVersion: 0,
|
IPVersion: 0,
|
||||||
IPAddr: videoSession.Local.Addr,
|
IPAddr: videoSession.Local.Addr,
|
||||||
VideoRTPPort: videoSession.Local.Port,
|
VideoRTPPort: videoSession.Local.Port,
|
||||||
AudioRTPPort: audioSession.Local.Port,
|
AudioRTPPort: audioSession.Local.Port,
|
||||||
},
|
},
|
||||||
VideoCrypto: CryptoSuite{
|
VideoCrypto: SRTPCryptoSuite{
|
||||||
MasterKey: string(videoSession.Local.MasterKey),
|
MasterKey: string(videoSession.Local.MasterKey),
|
||||||
MasterSalt: string(videoSession.Local.MasterSalt),
|
MasterSalt: string(videoSession.Local.MasterSalt),
|
||||||
},
|
},
|
||||||
AudioCrypto: CryptoSuite{
|
AudioCrypto: SRTPCryptoSuite{
|
||||||
MasterKey: string(audioSession.Local.MasterKey),
|
MasterKey: string(audioSession.Local.MasterKey),
|
||||||
MasterSalt: string(audioSession.Local.MasterSalt),
|
MasterSalt: string(audioSession.Local.MasterSalt),
|
||||||
},
|
},
|
||||||
@@ -129,7 +129,7 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var res SetupEndpoints
|
var res SetupEndpointsResponse
|
||||||
if err := s.client.GetCharacter(char); err != nil {
|
if err := s.client.GetCharacter(char); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err
|
|||||||
Port: res.Address.VideoRTPPort,
|
Port: res.Address.VideoRTPPort,
|
||||||
MasterKey: []byte(res.VideoCrypto.MasterKey),
|
MasterKey: []byte(res.VideoCrypto.MasterKey),
|
||||||
MasterSalt: []byte(res.VideoCrypto.MasterSalt),
|
MasterSalt: []byte(res.VideoCrypto.MasterSalt),
|
||||||
SSRC: res.VideoSSRC[0],
|
SSRC: res.VideoSSRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
audioSession.Remote = &srtp.Endpoint{
|
audioSession.Remote = &srtp.Endpoint{
|
||||||
@@ -150,13 +150,13 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err
|
|||||||
Port: res.Address.AudioRTPPort,
|
Port: res.Address.AudioRTPPort,
|
||||||
MasterKey: []byte(res.AudioCrypto.MasterKey),
|
MasterKey: []byte(res.AudioCrypto.MasterKey),
|
||||||
MasterSalt: []byte(res.AudioCrypto.MasterSalt),
|
MasterSalt: []byte(res.AudioCrypto.MasterSalt),
|
||||||
SSRC: res.AudioSSRC[0],
|
SSRC: res.AudioSSRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) SetStreamConfig(config *SelectedStreamConfig) error {
|
func (s *Stream) SetStreamConfig(config *SelectedStreamConfiguration) error {
|
||||||
char := s.service.GetCharacter(TypeSelectedStreamConfiguration)
|
char := s.service.GetCharacter(TypeSelectedStreamConfiguration)
|
||||||
if err := char.Write(config); err != nil {
|
if err := char.Write(config); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -169,7 +169,7 @@ func (s *Stream) SetStreamConfig(config *SelectedStreamConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) Close() error {
|
func (s *Stream) Close() error {
|
||||||
config := &SelectedStreamConfig{
|
config := &SelectedStreamConfiguration{
|
||||||
Control: SessionControl{
|
Control: SessionControl{
|
||||||
SessionID: s.id,
|
SessionID: s.id,
|
||||||
Command: SessionCommandEnd,
|
Command: SessionCommandEnd,
|
||||||
|
|||||||
+11
-5
@@ -18,7 +18,6 @@ import (
|
|||||||
"github.com/AlexxIT/go2rtc/pkg/hap/curve25519"
|
"github.com/AlexxIT/go2rtc/pkg/hap/curve25519"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
"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/hap/tlv8"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||||
)
|
)
|
||||||
@@ -46,7 +45,7 @@ type Client struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(rawURL string) (*Client, error) {
|
func Dial(rawURL string) (*Client, error) {
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -61,6 +60,10 @@ func NewClient(rawURL string) (*Client, error) {
|
|||||||
ClientPrivate: DecodeKey(query.Get("client_private")),
|
ClientPrivate: DecodeKey(query.Get("client_private")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = c.Dial(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +99,7 @@ func (c *Client) Dial() (err error) {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TODO: close conn on error
|
||||||
if c.Conn, err = net.DialTimeout("tcp", c.DeviceAddress, ConnDialTimeout); err != nil {
|
if c.Conn, err = net.DialTimeout("tcp", c.DeviceAddress, ConnDialTimeout); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,7 +128,7 @@ func (c *Client) Dial() (err error) {
|
|||||||
EncryptedData string `tlv8:"5"`
|
EncryptedData string `tlv8:"5"`
|
||||||
State byte `tlv8:"6"`
|
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
|
return err
|
||||||
}
|
}
|
||||||
if cipherM2.State != StateM2 {
|
if cipherM2.State != StateM2 {
|
||||||
@@ -209,15 +213,17 @@ func (c *Client) Dial() (err error) {
|
|||||||
var plainM4 struct {
|
var plainM4 struct {
|
||||||
State byte `tlv8:"6"`
|
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
|
return
|
||||||
}
|
}
|
||||||
if plainM4.State != StateM4 {
|
if plainM4.State != StateM4 {
|
||||||
return newResponseError(cipherM3, plainM4)
|
return newResponseError(cipherM3, plainM4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rw := bufio.NewReadWriter(c.reader, bufio.NewWriter(c.Conn))
|
||||||
|
|
||||||
// like tls.Client wrapper over net.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
|
return
|
||||||
}
|
}
|
||||||
// new reader for new conn
|
// new reader for new conn
|
||||||
|
|||||||
@@ -82,3 +82,20 @@ func ReadResponse(r *bufio.Reader, req *http.Request) (*http.Response, error) {
|
|||||||
|
|
||||||
return res, nil
|
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"`
|
State byte `tlv8:"6"`
|
||||||
Error byte `tlv8:"7"`
|
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
|
return
|
||||||
}
|
}
|
||||||
if plainM2.State != StateM2 {
|
if plainM2.State != StateM2 {
|
||||||
@@ -121,9 +121,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
|||||||
username := []byte("Pair-Setup")
|
username := []byte("Pair-Setup")
|
||||||
|
|
||||||
// Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE)
|
// Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE)
|
||||||
pake, err := srp.NewSRP(
|
pake, err := srp.NewSRP("rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username))
|
||||||
"rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -132,6 +130,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
|||||||
|
|
||||||
// username: "Pair-Setup", password: PIN (with dashes)
|
// username: "Pair-Setup", password: PIN (with dashes)
|
||||||
session := pake.NewClientSession(username, []byte(pin))
|
session := pake.NewClientSession(username, []byte(pin))
|
||||||
|
|
||||||
sessionShared, err := session.ComputeKey([]byte(plainM2.Salt), []byte(plainM2.SessionKey))
|
sessionShared, err := session.ComputeKey([]byte(plainM2.Salt), []byte(plainM2.SessionKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -159,7 +158,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
|||||||
|
|
||||||
EncryptedData string `tlv8:"5"` // skip EncryptedData validation (for MFi devices)
|
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
|
return
|
||||||
}
|
}
|
||||||
if plainM4.State != StateM4 {
|
if plainM4.State != StateM4 {
|
||||||
@@ -232,7 +231,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
|||||||
State byte `tlv8:"6"`
|
State byte `tlv8:"6"`
|
||||||
Error byte `tlv8:"7"`
|
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
|
return
|
||||||
}
|
}
|
||||||
if cipherM6.State != StateM6 || cipherM6.Error != 0 {
|
if cipherM6.State != StateM6 || cipherM6.Error != 0 {
|
||||||
@@ -296,7 +295,7 @@ func (c *Client) ListPairings() error {
|
|||||||
State byte `tlv8:"6"`
|
State byte `tlv8:"6"`
|
||||||
Permission byte `tlv8:"11"`
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +328,7 @@ func (c *Client) PairingsAdd(clientID string, clientPublic []byte, admin bool) e
|
|||||||
State byte `tlv8:"6"`
|
State byte `tlv8:"6"`
|
||||||
Unknown byte `tlv8:"7"`
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +353,7 @@ func (c *Client) DeletePairing(id string) error {
|
|||||||
var plainM2 struct {
|
var plainM2 struct {
|
||||||
State byte `tlv8:"6"`
|
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
|
return err
|
||||||
}
|
}
|
||||||
if plainM2.State != StateM2 {
|
if plainM2.State != StateM2 {
|
||||||
|
|||||||
@@ -1,32 +1,50 @@
|
|||||||
package secure
|
package hap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Conn struct {
|
type Conn struct {
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
|
rw *bufio.ReadWriter
|
||||||
rd *bufio.Reader
|
wmu sync.Mutex
|
||||||
wr *bufio.Writer
|
|
||||||
|
|
||||||
encryptKey []byte
|
encryptKey []byte
|
||||||
decryptKey []byte
|
decryptKey []byte
|
||||||
encryptCnt uint64
|
encryptCnt uint64
|
||||||
decryptCnt uint64
|
decryptCnt uint64
|
||||||
|
|
||||||
|
//ClientID string
|
||||||
SharedKey []byte
|
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")
|
key1, err := hkdf.Sha512(sharedKey, "Control-Salt", "Control-Read-Encryption-Key")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -39,8 +57,7 @@ func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) {
|
|||||||
|
|
||||||
c := &Conn{
|
c := &Conn{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
rd: bufio.NewReaderSize(conn, 32*1024),
|
rw: rw,
|
||||||
wr: bufio.NewWriterSize(conn, 32*1024),
|
|
||||||
|
|
||||||
SharedKey: sharedKey,
|
SharedKey: sharedKey,
|
||||||
}
|
}
|
||||||
@@ -55,8 +72,8 @@ func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// PacketSizeMax is the max length of encrypted packets
|
// packetSizeMax is the max length of encrypted packets
|
||||||
PacketSizeMax = 0x400
|
packetSizeMax = 0x400
|
||||||
|
|
||||||
VerifySize = 2
|
VerifySize = 2
|
||||||
NonceSize = 8
|
NonceSize = 8
|
||||||
@@ -64,19 +81,19 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (c *Conn) Read(b []byte) (n int, err error) {
|
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")
|
return 0, errors.New("hap: read buffer is too small")
|
||||||
}
|
}
|
||||||
|
|
||||||
verify := make([]byte, 2) // verify = plain message size
|
verify := make([]byte, VerifySize) // verify = plain message size
|
||||||
if _, err = io.ReadFull(c.rd, verify); err != nil {
|
if _, err = io.ReadFull(c.rw, verify); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
n = int(binary.LittleEndian.Uint16(verify))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,22 +102,27 @@ func (c *Conn) Read(b []byte) (n int, err error) {
|
|||||||
c.decryptCnt++
|
c.decryptCnt++
|
||||||
|
|
||||||
_, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, b[:0], nonce, ciphertext, verify)
|
_, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, b[:0], nonce, ciphertext, verify)
|
||||||
|
|
||||||
|
c.recv += n
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Conn) Write(b []byte) (n int, err error) {
|
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)
|
nonce := make([]byte, NonceSize)
|
||||||
verify := make([]byte, VerifySize)
|
verify := make([]byte, VerifySize)
|
||||||
|
|
||||||
for len(b) > 0 {
|
for len(b) > 0 {
|
||||||
size := len(b)
|
size := len(b)
|
||||||
if size > PacketSizeMax {
|
if size > packetSizeMax {
|
||||||
size = PacketSizeMax
|
size = packetSizeMax
|
||||||
}
|
}
|
||||||
|
|
||||||
binary.LittleEndian.PutUint16(verify, uint16(size))
|
binary.LittleEndian.PutUint16(verify, uint16(size))
|
||||||
if _, err = c.wr.Write(verify); err != nil {
|
if _, err = c.rw.Write(verify); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +134,7 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = c.wr.Write(buf[:size+Overhead]); err != nil {
|
if _, err = c.rw.Write(buf[:size+Overhead]); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +142,9 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
|||||||
n += size
|
n += size
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.wr.Flush()
|
err = c.rw.Flush()
|
||||||
|
|
||||||
|
c.send += n
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
+27
-6
@@ -4,16 +4,18 @@ package hds
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
"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/chacha20poly1305"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
"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")
|
writeKey, err := hkdf.Sha512(key, salt, "HDS-Write-Encryption-Key")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -49,6 +51,21 @@ type Conn struct {
|
|||||||
encryptKey []byte
|
encryptKey []byte
|
||||||
decryptCnt uint64
|
decryptCnt uint64
|
||||||
encryptCnt uint64
|
encryptCnt uint64
|
||||||
|
|
||||||
|
recv int
|
||||||
|
send int
|
||||||
|
}
|
||||||
|
|
||||||
|
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(p []byte) (n int, err error) {
|
func (c *Conn) Read(p []byte) (n int, err error) {
|
||||||
@@ -59,16 +76,18 @@ func (c *Conn) Read(p []byte) (n int, err error) {
|
|||||||
|
|
||||||
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 {
|
if _, err = io.ReadFull(c.rd, ciphertext); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
nonce := make([]byte, secure.NonceSize)
|
nonce := make([]byte, hap.NonceSize)
|
||||||
binary.LittleEndian.PutUint64(nonce, c.decryptCnt)
|
binary.LittleEndian.PutUint64(nonce, c.decryptCnt)
|
||||||
c.decryptCnt++
|
c.decryptCnt++
|
||||||
|
|
||||||
_, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, p[:0], nonce, ciphertext, verify)
|
_, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, p[:0], nonce, ciphertext, verify)
|
||||||
|
|
||||||
|
c.recv += n
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,11 +100,11 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
nonce := make([]byte, secure.NonceSize)
|
nonce := make([]byte, hap.NonceSize)
|
||||||
binary.LittleEndian.PutUint64(nonce, c.encryptCnt)
|
binary.LittleEndian.PutUint64(nonce, c.encryptCnt)
|
||||||
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 {
|
if _, err = chacha20poly1305.EncryptAndSeal(c.encryptKey, buf[:0], nonce, b, verify); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -95,6 +114,8 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = c.wr.Flush()
|
err = c.wr.Flush()
|
||||||
|
|
||||||
|
c.send += n
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+273
-44
@@ -6,29 +6,23 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/curve25519"
|
"github.com/AlexxIT/go2rtc/pkg/hap/curve25519"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
"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/hap/tlv8"
|
||||||
|
"github.com/tadglines/go-pkgs/crypto/srp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HandlerFunc func(net.Conn) error
|
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Pin string
|
Pin string
|
||||||
DeviceID string
|
DeviceID string
|
||||||
DevicePrivate []byte
|
DevicePrivate []byte
|
||||||
|
|
||||||
GetPair func(conn net.Conn, id string) []byte
|
// GetClientPublic may be nil, so client validation will be disabled
|
||||||
AddPair func(conn net.Conn, id string, public []byte, permissions byte)
|
GetClientPublic func(id string) []byte
|
||||||
|
|
||||||
Handler HandlerFunc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ServerPublic() []byte {
|
func (s *Server) ServerPublic() []byte {
|
||||||
@@ -49,37 +43,240 @@ func (s *Server) SetupHash() string {
|
|||||||
return base64.StdEncoding.EncodeToString(b[:4])
|
return base64.StdEncoding.EncodeToString(b[:4])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Conn) error {
|
func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter) (id string, publicKey []byte, err error) {
|
||||||
// Request from iPhone
|
// STEP 1. Request from iPhone
|
||||||
var plainM1 struct {
|
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 {
|
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
if plainM1.State != StateM1 {
|
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
|
// Generate the key pair
|
||||||
sessionPublic, sessionPrivate := curve25519.GenerateKeyPair()
|
sessionPublic, sessionPrivate := curve25519.GenerateKeyPair()
|
||||||
sessionShared, err := curve25519.SharedSecret(sessionPrivate, []byte(plainM1.PublicKey))
|
sessionShared, err := curve25519.SharedSecret(sessionPrivate, []byte(plainM1.PublicKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
encryptKey, err := hkdf.Sha512(
|
encryptKey, err := hkdf.Sha512(
|
||||||
sessionShared, "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info",
|
sessionShared, "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
b := Append(sessionPublic, s.DeviceID, plainM1.PublicKey)
|
b := Append(sessionPublic, s.DeviceID, plainM1.PublicKey)
|
||||||
signature, err := ed25519.Signature(s.DevicePrivate, b)
|
signature, err := ed25519.Signature(s.DevicePrivate, b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP M2. Response to iPhone
|
// STEP M2. Response to iPhone
|
||||||
@@ -91,12 +288,12 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
|
|||||||
Signature: string(signature),
|
Signature: string(signature),
|
||||||
}
|
}
|
||||||
if b, err = tlv8.Marshal(plainM2); err != nil {
|
if b, err = tlv8.Marshal(plainM2); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err = chacha20poly1305.Encrypt(encryptKey, "PV-Msg02", b)
|
b, err = chacha20poly1305.Encrypt(encryptKey, "PV-Msg02", b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cipherM2 := struct {
|
cipherM2 := struct {
|
||||||
@@ -110,30 +307,32 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
|
|||||||
}
|
}
|
||||||
body, err := tlv8.Marshal(cipherM2)
|
body, err := tlv8.Marshal(cipherM2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP M3. Request from iPhone
|
// STEP M3. Request from iPhone
|
||||||
if req, err = http.ReadRequest(rw.Reader); err != nil {
|
if req, err = http.ReadRequest(rw.Reader); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var cipherM3 struct {
|
var cipherM3 struct {
|
||||||
EncryptedData string `tlv8:"5"`
|
|
||||||
State byte `tlv8:"6"`
|
State byte `tlv8:"6"`
|
||||||
|
EncryptedData string `tlv8:"5"`
|
||||||
}
|
}
|
||||||
if err = tlv8.UnmarshalReader(req.Body, &cipherM3); err != nil {
|
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM3); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
if cipherM3.State != StateM3 {
|
if cipherM3.State != StateM3 {
|
||||||
return newRequestError(cipherM3)
|
err = newRequestError(cipherM3)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if b, err = chacha20poly1305.Decrypt(encryptKey, "PV-Msg03", []byte(cipherM3.EncryptedData)); err != nil {
|
b, err = chacha20poly1305.Decrypt(encryptKey, "PV-Msg03", []byte(cipherM3.EncryptedData))
|
||||||
return err
|
if err != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var plainM3 struct {
|
var plainM3 struct {
|
||||||
@@ -141,17 +340,21 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
|
|||||||
Signature string `tlv8:"10"`
|
Signature string `tlv8:"10"`
|
||||||
}
|
}
|
||||||
if err = tlv8.Unmarshal(b, &plainM3); err != nil {
|
if err = tlv8.Unmarshal(b, &plainM3); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clientPublic := s.GetPair(conn, plainM3.Identifier)
|
if s.GetClientPublic != nil {
|
||||||
if clientPublic == nil {
|
clientPublic := s.GetClientPublic(plainM3.Identifier)
|
||||||
return fmt.Errorf("hap: PairVerify from: %s, with unknown client_id: %s", conn.RemoteAddr(), plainM3.Identifier)
|
if clientPublic == nil {
|
||||||
}
|
err = errors.New("hap: PairVerify with unknown client_id: " + plainM3.Identifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
b = Append(plainM1.PublicKey, plainM3.Identifier, sessionPublic)
|
b = Append(plainM1.PublicKey, plainM3.Identifier, sessionPublic)
|
||||||
if !ed25519.ValidateSignature(clientPublic, b, []byte(plainM3.Signature)) {
|
if !ed25519.ValidateSignature(clientPublic, b, []byte(plainM3.Signature)) {
|
||||||
return errors.New("new: ValidateSignature")
|
err = errors.New("hap: ValidateSignature")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP M4. Response to iPhone
|
// STEP M4. Response to iPhone
|
||||||
@@ -161,15 +364,41 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
|
|||||||
State: StateM4,
|
State: StateM4,
|
||||||
}
|
}
|
||||||
if body, err = tlv8.Marshal(payloadM4); err != nil {
|
if body, err = tlv8.Marshal(payloadM4); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if conn, err = secure.Client(conn, sessionShared, false); err != nil {
|
id = plainM3.Identifier
|
||||||
return err
|
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)
|
|
||||||
}
|
|
||||||
+21
-2
@@ -112,6 +112,10 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) {
|
|||||||
v := value.Uint()
|
v := value.Uint()
|
||||||
return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil
|
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:
|
case reflect.Float32:
|
||||||
v := math.Float32bits(float32(value.Float()))
|
v := math.Float32bits(float32(value.Float()))
|
||||||
return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil
|
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)
|
return Unmarshal(data, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func UnmarshalReader(r io.Reader, v any) error {
|
func UnmarshalReader(r io.Reader, n int64, v any) error {
|
||||||
data, err := io.ReadAll(r)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return Unmarshal(data, v)
|
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)
|
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:
|
case reflect.Float32:
|
||||||
f := math.Float32frombits(binary.LittleEndian.Uint32(v))
|
f := math.Float32frombits(binary.LittleEndian.Uint32(v))
|
||||||
value.SetFloat(float64(f))
|
value.SetFloat(float64(f))
|
||||||
|
|||||||
+15
-11
@@ -49,7 +49,7 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer {
|
|||||||
Connection: core.Connection{
|
Connection: core.Connection{
|
||||||
ID: core.NewID(),
|
ID: core.NewID(),
|
||||||
FormatName: "homekit",
|
FormatName: "homekit",
|
||||||
Protocol: "udp",
|
Protocol: "rtp",
|
||||||
RemoteAddr: conn.RemoteAddr().String(),
|
RemoteAddr: conn.RemoteAddr().String(),
|
||||||
Medias: medias,
|
Medias: medias,
|
||||||
Transport: conn,
|
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.sessionID = offer.SessionID
|
||||||
c.videoSession = &srtp.Session{
|
c.videoSession = &srtp.Session{
|
||||||
Remote: &srtp.Endpoint{
|
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.videoSession.Local = c.srtpEndpoint()
|
||||||
c.audioSession.Local = c.srtpEndpoint()
|
c.audioSession.Local = c.srtpEndpoint()
|
||||||
|
|
||||||
return &camera.SetupEndpoints{
|
return &camera.SetupEndpointsResponse{
|
||||||
SessionID: c.sessionID,
|
SessionID: c.sessionID,
|
||||||
Status: []byte{0},
|
Status: camera.StreamingStatusAvailable,
|
||||||
Address: camera.Addr{
|
Address: camera.Address{
|
||||||
IPAddr: c.videoSession.Local.Addr,
|
IPAddr: c.videoSession.Local.Addr,
|
||||||
VideoRTPPort: c.videoSession.Local.Port,
|
VideoRTPPort: c.videoSession.Local.Port,
|
||||||
AudioRTPPort: c.audioSession.Local.Port,
|
AudioRTPPort: c.audioSession.Local.Port,
|
||||||
},
|
},
|
||||||
VideoCrypto: camera.CryptoSuite{
|
VideoCrypto: camera.SRTPCryptoSuite{
|
||||||
MasterKey: string(c.videoSession.Local.MasterKey),
|
MasterKey: string(c.videoSession.Local.MasterKey),
|
||||||
MasterSalt: string(c.videoSession.Local.MasterSalt),
|
MasterSalt: string(c.videoSession.Local.MasterSalt),
|
||||||
},
|
},
|
||||||
AudioCrypto: camera.CryptoSuite{
|
AudioCrypto: camera.SRTPCryptoSuite{
|
||||||
MasterKey: string(c.audioSession.Local.MasterKey),
|
MasterKey: string(c.audioSession.Local.MasterKey),
|
||||||
MasterSalt: string(c.audioSession.Local.MasterSalt),
|
MasterSalt: string(c.audioSession.Local.MasterSalt),
|
||||||
},
|
},
|
||||||
VideoSSRC: []uint32{c.videoSession.Local.SSRC},
|
VideoSSRC: c.videoSession.Local.SSRC,
|
||||||
AudioSSRC: []uint32{c.audioSession.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 {
|
if c.sessionID != conf.Control.SessionID {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-10
@@ -13,7 +13,7 @@ var videoCodecs = [...]string{core.CodecH264}
|
|||||||
var videoProfiles = [...]string{"4200", "4D00", "6400"}
|
var videoProfiles = [...]string{"4200", "4D00", "6400"}
|
||||||
var videoLevels = [...]string{"1F", "20", "28"}
|
var videoLevels = [...]string{"1F", "20", "28"}
|
||||||
|
|
||||||
func videoToMedia(codecs []camera.VideoCodec) *core.Media {
|
func videoToMedia(codecs []camera.VideoCodecConfiguration) *core.Media {
|
||||||
media := &core.Media{
|
media := &core.Media{
|
||||||
Kind: core.KindVideo, Direction: core.DirectionRecvonly,
|
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 audioCodecs = [...]string{core.CodecPCMU, core.CodecPCMA, core.CodecELD, core.CodecOpus}
|
||||||
var audioSampleRates = [...]uint32{8000, 16000, 24000}
|
var audioSampleRates = [...]uint32{8000, 16000, 24000}
|
||||||
|
|
||||||
func audioToMedia(codecs []camera.AudioCodec) *core.Media {
|
func audioToMedia(codecs []camera.AudioCodecConfiguration) *core.Media {
|
||||||
media := &core.Media{
|
media := &core.Media{
|
||||||
Kind: core.KindAudio, Direction: core.DirectionRecvonly,
|
Kind: core.KindAudio, Direction: core.DirectionRecvonly,
|
||||||
}
|
}
|
||||||
@@ -67,10 +67,10 @@ func audioToMedia(codecs []camera.AudioCodec) *core.Media {
|
|||||||
return 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]
|
profileID := video0.CodecParams[0].ProfileID[0]
|
||||||
level := video0.CodecParams[0].Level[0]
|
level := video0.CodecParams[0].Level[0]
|
||||||
attrs := video0.VideoAttrs[0]
|
var attrs camera.VideoCodecAttributes
|
||||||
|
|
||||||
if track != nil {
|
if track != nil {
|
||||||
profile := h264.GetProfileLevelID(track.Codec.FmtpLine)
|
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 {
|
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 {
|
if s.Width > attrs.Width || s.Height > attrs.Height {
|
||||||
attrs = s
|
attrs = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &camera.VideoCodec{
|
return &camera.VideoCodecConfiguration{
|
||||||
CodecType: video0.CodecType,
|
CodecType: video0.CodecType,
|
||||||
CodecParams: []camera.VideoParams{
|
CodecParams: []camera.VideoCodecParameters{
|
||||||
{
|
{
|
||||||
ProfileID: []byte{profileID},
|
ProfileID: []byte{profileID},
|
||||||
Level: []byte{level},
|
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
|
codecType := audio0.CodecType
|
||||||
channels := audio0.CodecParams[0].Channels
|
channels := audio0.CodecParams[0].Channels
|
||||||
sampleRate := audio0.CodecParams[0].SampleRate[0]
|
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,
|
CodecType: codecType,
|
||||||
CodecParams: []camera.AudioParams{
|
CodecParams: []camera.AudioCodecParameters{
|
||||||
{
|
{
|
||||||
Channels: channels,
|
Channels: channels,
|
||||||
SampleRate: []byte{sampleRate},
|
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"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
@@ -22,36 +21,25 @@ type Client struct {
|
|||||||
hap *hap.Client
|
hap *hap.Client
|
||||||
srtp *srtp.Server
|
srtp *srtp.Server
|
||||||
|
|
||||||
videoConfig camera.SupportedVideoStreamConfig
|
videoConfig camera.SupportedVideoStreamConfiguration
|
||||||
audioConfig camera.SupportedAudioStreamConfig
|
audioConfig camera.SupportedAudioStreamConfiguration
|
||||||
|
|
||||||
videoSession *srtp.Session
|
videoSession *srtp.Session
|
||||||
audioSession *srtp.Session
|
audioSession *srtp.Session
|
||||||
|
|
||||||
stream *camera.Stream
|
stream *camera.Stream
|
||||||
|
|
||||||
Bitrate int // in bits/s
|
MaxWidth int
|
||||||
|
MaxHeight int
|
||||||
|
Bitrate int // in bits/s
|
||||||
}
|
}
|
||||||
|
|
||||||
func Dial(rawURL string, server *srtp.Server) (*Client, error) {
|
func Dial(rawURL string, server *srtp.Server) (*Client, error) {
|
||||||
u, err := url.Parse(rawURL)
|
conn, err := hap.Dial(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{
|
client := &Client{
|
||||||
Connection: core.Connection{
|
Connection: core.Connection{
|
||||||
ID: core.NewID(),
|
ID: core.NewID(),
|
||||||
@@ -129,7 +117,7 @@ func (c *Client) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
videoTrack := c.trackByKind(core.KindVideo)
|
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)
|
audioTrack := c.trackByKind(core.KindAudio)
|
||||||
audioCodec := trackToAudio(audioTrack, &c.audioConfig.Codecs[0])
|
audioCodec := trackToAudio(audioTrack, &c.audioConfig.Codecs[0])
|
||||||
|
|||||||
+33
-27
@@ -4,31 +4,30 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
"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 {
|
return func(con net.Conn) error {
|
||||||
defer con.Close()
|
defer con.Close()
|
||||||
|
|
||||||
acc, err := dial()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer acc.Close()
|
|
||||||
|
|
||||||
pr := &Proxy{
|
pr := &Proxy{
|
||||||
con: con.(*secure.Conn),
|
con: con.(*hap.Conn),
|
||||||
acc: acc.(*secure.Conn),
|
acc: acc.(*hap.Conn),
|
||||||
res: make(chan *http.Response),
|
res: make(chan *http.Response),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,17 +35,17 @@ func ProxyHandler(pair ServerPair, dial func() (net.Conn, error)) hap.HandlerFun
|
|||||||
go pr.handleAcc()
|
go pr.handleAcc()
|
||||||
|
|
||||||
// controller => accessory
|
// controller => accessory
|
||||||
return pr.handleCon(pair)
|
return pr.handleCon(srv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Proxy struct {
|
type Proxy struct {
|
||||||
con *secure.Conn
|
con *hap.Conn
|
||||||
acc *secure.Conn
|
acc *hap.Conn
|
||||||
res chan *http.Response
|
res chan *http.Response
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Proxy) handleCon(pair ServerPair) error {
|
func (p *Proxy) handleCon(srv ServerProxy) error {
|
||||||
var hdsCharIID uint64
|
var hdsCharIID uint64
|
||||||
|
|
||||||
rd := bufio.NewReader(p.con)
|
rd := bufio.NewReader(p.con)
|
||||||
@@ -61,7 +60,7 @@ func (p *Proxy) handleCon(pair ServerPair) error {
|
|||||||
switch {
|
switch {
|
||||||
case req.Method == "POST" && req.URL.Path == hap.PathPairings:
|
case req.Method == "POST" && req.URL.Path == hap.PathPairings:
|
||||||
var res *http.Response
|
var res *http.Response
|
||||||
if res, err = handlePairings(p.con, req, pair); err != nil {
|
if res, err = handlePairings(req, srv); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = res.Write(p.con); err != nil {
|
if err = res.Write(p.con); err != nil {
|
||||||
@@ -74,7 +73,7 @@ func (p *Proxy) handleCon(pair ServerPair) error {
|
|||||||
_ = json.Unmarshal(body, &v)
|
_ = json.Unmarshal(body, &v)
|
||||||
for _, char := range v.Value {
|
for _, char := range v.Value {
|
||||||
if char.IID == hdsCharIID {
|
if char.IID == hdsCharIID {
|
||||||
var hdsReq camera.SetupDataStreamRequest
|
var hdsReq camera.SetupDataStreamTransportRequest
|
||||||
_ = tlv8.UnmarshalBase64(char.Value, &hdsReq)
|
_ = tlv8.UnmarshalBase64(char.Value, &hdsReq)
|
||||||
hdsConSalt = hdsReq.ControllerKeySalt
|
hdsConSalt = hdsReq.ControllerKeySalt
|
||||||
break
|
break
|
||||||
@@ -110,14 +109,14 @@ func (p *Proxy) handleCon(pair ServerPair) error {
|
|||||||
_ = json.Unmarshal(body, &v)
|
_ = json.Unmarshal(body, &v)
|
||||||
for i, char := range v.Value {
|
for i, char := range v.Value {
|
||||||
if char.IID == hdsCharIID {
|
if char.IID == hdsCharIID {
|
||||||
var hdsRes camera.SetupDataStreamResponse
|
var hdsRes camera.SetupDataStreamTransportResponse
|
||||||
_ = tlv8.UnmarshalBase64(char.Value, &hdsRes)
|
_ = tlv8.UnmarshalBase64(char.Value, &hdsRes)
|
||||||
|
|
||||||
hdsAccSalt := hdsRes.AccessoryKeySalt
|
hdsAccSalt := hdsRes.AccessoryKeySalt
|
||||||
hdsPort := int(hdsRes.TransportTypeSessionParameters.TCPListeningPort)
|
hdsPort := int(hdsRes.TransportTypeSessionParameters.TCPListeningPort)
|
||||||
|
|
||||||
// swtich accPort to conPort
|
// swtich accPort to conPort
|
||||||
hdsPort, err = p.listenHDS(hdsPort, hdsConSalt+hdsAccSalt)
|
hdsPort, err = p.listenHDS(srv, hdsPort, hdsConSalt+hdsAccSalt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -149,7 +148,7 @@ func (p *Proxy) handleAcc() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if res.Proto == hap.ProtoEvent {
|
if res.Proto == hap.ProtoEvent {
|
||||||
if err = res.Write(p.con); err != nil {
|
if err = hap.WriteEvent(p.con, res); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
continue
|
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)
|
ln, err := net.ListenTCP("tcp", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -175,30 +175,36 @@ func (p *Proxy) listenHDS(accPort int, salt string) (int, error) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer ln.Close()
|
defer ln.Close()
|
||||||
|
|
||||||
|
_ = ln.SetDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
|
||||||
// raw controller conn
|
// raw controller conn
|
||||||
con, err := ln.Accept()
|
conn1, err := ln.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer con.Close()
|
|
||||||
|
defer conn1.Close()
|
||||||
|
|
||||||
// secured controller conn (controlle=false because we are accessory)
|
// 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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
srv.AddConn(con)
|
||||||
|
defer srv.DelConn(con)
|
||||||
|
|
||||||
accIP := p.acc.RemoteAddr().(*net.TCPAddr).IP
|
accIP := p.acc.RemoteAddr().(*net.TCPAddr).IP
|
||||||
|
|
||||||
// raw accessory conn
|
// 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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer acc.Close()
|
defer conn2.Close()
|
||||||
|
|
||||||
// secured accessory conn (controller=true because we are controller)
|
// 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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-47
@@ -15,15 +15,17 @@ import (
|
|||||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type HandlerFunc func(net.Conn) error
|
||||||
|
|
||||||
type Server interface {
|
type Server interface {
|
||||||
ServerPair
|
ServerPair
|
||||||
ServerAccessory
|
ServerAccessory
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerPair interface {
|
type ServerPair interface {
|
||||||
GetPair(conn net.Conn, id string) []byte
|
GetPair(id string) []byte
|
||||||
AddPair(conn net.Conn, id string, public []byte, permissions byte)
|
AddPair(id string, public []byte, permissions byte)
|
||||||
DelPair(conn net.Conn, id string)
|
DelPair(id string)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerAccessory interface {
|
type ServerAccessory interface {
|
||||||
@@ -33,11 +35,11 @@ type ServerAccessory interface {
|
|||||||
GetImage(conn net.Conn, width, height int) []byte
|
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) {
|
return handleRequest(func(conn net.Conn, req *http.Request) (*http.Response, error) {
|
||||||
switch req.URL.Path {
|
switch req.URL.Path {
|
||||||
case hap.PathPairings:
|
case hap.PathPairings:
|
||||||
return handlePairings(conn, req, server)
|
return handlePairings(req, server)
|
||||||
|
|
||||||
case hap.PathAccessories:
|
case hap.PathAccessories:
|
||||||
body := hap.JSONAccessories{Value: server.GetAccessories(conn)}
|
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 {
|
return func(conn net.Conn) error {
|
||||||
rw := bufio.NewReaderSize(conn, 16*1024)
|
rw := bufio.NewReaderSize(conn, 16*1024)
|
||||||
wr := bufio.NewWriterSize(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 {
|
cmd := struct {
|
||||||
Method byte `tlv8:"0"`
|
Method byte `tlv8:"0"`
|
||||||
Identifier string `tlv8:"1"`
|
Identifier string `tlv8:"1"`
|
||||||
@@ -139,15 +141,15 @@ func handlePairings(conn net.Conn, req *http.Request, pair ServerPair) (*http.Re
|
|||||||
Permissions byte `tlv8:"11"`
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
switch cmd.Method {
|
switch cmd.Method {
|
||||||
case 3: // add
|
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
|
case 4: // delete
|
||||||
pair.DelPair(conn, cmd.Identifier)
|
srv.DelPair(cmd.Identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := struct {
|
body := struct {
|
||||||
@@ -190,40 +192,3 @@ func makeResponse(mime string, v any) (*http.Response, error) {
|
|||||||
}
|
}
|
||||||
return res, nil
|
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)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|||||||
+10
-13
@@ -31,19 +31,18 @@ func NewClient(rawURL string) (*Client, error) {
|
|||||||
baseURL := "http://" + u.Host
|
baseURL := "http://" + u.Host
|
||||||
|
|
||||||
client := &Client{url: u}
|
client := &Client{url: u}
|
||||||
if u.Path == "" {
|
client.deviceURL = baseURL + GetPath(u.Path, PathDevice)
|
||||||
client.deviceURL = baseURL + PathDevice
|
|
||||||
} else {
|
|
||||||
client.deviceURL = baseURL + u.Path
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := client.DeviceRequest(DeviceGetCapabilities)
|
b, err := client.DeviceRequest(DeviceGetCapabilities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
client.mediaURL = FindTagValue(b, "Media.+?XAddr")
|
s := FindTagValue(b, "Media.+?XAddr")
|
||||||
client.imaginURL = FindTagValue(b, "Imaging.+?XAddr")
|
client.mediaURL = baseURL + GetPath(s, "/onvif/media_service")
|
||||||
|
|
||||||
|
s = FindTagValue(b, "Imaging.+?XAddr")
|
||||||
|
client.imaginURL = baseURL + GetPath(s, "/onvif/imaging_service")
|
||||||
|
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
@@ -188,13 +187,11 @@ func (c *Client) Request(url, body string) ([]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
// need to close body with eny response status
|
if res.StatusCode != http.StatusOK {
|
||||||
b, err := io.ReadAll(res.Body)
|
return nil, errors.New("onvif: wrong response " + res.Status)
|
||||||
|
|
||||||
if err == nil && res.StatusCode != http.StatusOK {
|
|
||||||
err = errors.New("onvif: " + res.Status + " for " + url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return b, err
|
return io.ReadAll(res.Body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package onvif
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -129,3 +130,14 @@ func GetPosixTZ(current time.Time) string {
|
|||||||
|
|
||||||
return prefix + fmt.Sprintf("%02d:%02d", offset/60, offset%60)
|
return prefix + fmt.Sprintf("%02d:%02d", offset/60, offset%60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetPath(urlOrPath, defPath string) string {
|
||||||
|
if urlOrPath == "" || urlOrPath[0] == '/' {
|
||||||
|
return defPath
|
||||||
|
}
|
||||||
|
u, err := url.Parse(urlOrPath)
|
||||||
|
if err != nil {
|
||||||
|
return defPath
|
||||||
|
}
|
||||||
|
return GetPath(u.Path, defPath)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type Probe struct {
|
|||||||
core.Connection
|
core.Connection
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProbe(query url.Values) *Probe {
|
func Create(name string, query url.Values) *Probe {
|
||||||
medias := core.ParseQuery(query)
|
medias := core.ParseQuery(query)
|
||||||
|
|
||||||
for _, value := range query["microphone"] {
|
for _, value := range query["microphone"] {
|
||||||
@@ -32,39 +32,22 @@ func NewProbe(query url.Values) *Probe {
|
|||||||
return &Probe{
|
return &Probe{
|
||||||
Connection: core.Connection{
|
Connection: core.Connection{
|
||||||
ID: core.NewID(),
|
ID: core.NewID(),
|
||||||
FormatName: "probe",
|
FormatName: name,
|
||||||
Medias: medias,
|
Medias: medias,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Probe) GetMedias() []*core.Media {
|
|
||||||
return p.Medias
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Probe) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
func (p *Probe) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||||
sender := core.NewSender(media, track.Codec)
|
sender := core.NewSender(media, track.Codec)
|
||||||
sender.Bind(track)
|
sender.Handler = func(pkt *core.Packet) {
|
||||||
|
p.Send += len(pkt.Payload)
|
||||||
|
}
|
||||||
|
sender.HandleRTP(track)
|
||||||
p.Senders = append(p.Senders, sender)
|
p.Senders = append(p.Senders, sender)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Probe) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
|
||||||
receiver := core.NewReceiver(media, codec)
|
|
||||||
p.Receivers = append(p.Receivers, receiver)
|
|
||||||
return receiver, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Probe) Start() error {
|
func (p *Probe) Start() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Probe) Stop() error {
|
|
||||||
for _, receiver := range p.Receivers {
|
|
||||||
receiver.Close()
|
|
||||||
}
|
|
||||||
for _, sender := range p.Senders {
|
|
||||||
sender.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
+365
-208
@@ -11,9 +11,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var clientCache = map[string]*RingApi{}
|
||||||
|
var cacheMutex sync.Mutex
|
||||||
|
|
||||||
type RefreshTokenAuth struct {
|
type RefreshTokenAuth struct {
|
||||||
RefreshToken string
|
RefreshToken string
|
||||||
}
|
}
|
||||||
@@ -23,13 +27,11 @@ type EmailAuth struct {
|
|||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthConfig represents the decoded refresh token data
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
RT string `json:"rt"` // Refresh Token
|
RT string `json:"rt"` // Refresh Token
|
||||||
HID string `json:"hid"` // Hardware ID
|
HID string `json:"hid"` // Hardware ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthTokenResponse represents the response from the authentication endpoint
|
|
||||||
type AuthTokenResponse struct {
|
type AuthTokenResponse struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
ExpiresIn int `json:"expires_in"`
|
ExpiresIn int `json:"expires_in"`
|
||||||
@@ -46,41 +48,50 @@ type Auth2faResponse struct {
|
|||||||
NextTimeInSecs int `json:"next_time_in_secs"`
|
NextTimeInSecs int `json:"next_time_in_secs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SocketTicketRequest represents the request to get a socket ticket
|
|
||||||
type SocketTicketResponse struct {
|
type SocketTicketResponse struct {
|
||||||
Ticket string `json:"ticket"`
|
Ticket string `json:"ticket"`
|
||||||
ResponseTimestamp int64 `json:"response_timestamp"`
|
ResponseTimestamp int64 `json:"response_timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RingRestClient handles authentication and requests to Ring API
|
type SessionResponse struct {
|
||||||
type RingRestClient struct {
|
Profile struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
} `json:"profile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RingApi struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
authConfig *AuthConfig
|
authConfig *AuthConfig
|
||||||
hardwareID string
|
hardwareID string
|
||||||
authToken *AuthTokenResponse
|
authToken *AuthTokenResponse
|
||||||
|
tokenExpiry time.Time
|
||||||
Using2FA bool
|
Using2FA bool
|
||||||
PromptFor2FA string
|
PromptFor2FA string
|
||||||
RefreshToken string
|
RefreshToken string
|
||||||
auth interface{} // EmailAuth or RefreshTokenAuth
|
auth interface{} // EmailAuth or RefreshTokenAuth
|
||||||
onTokenRefresh func(string)
|
onTokenRefresh func(string)
|
||||||
|
authMutex sync.Mutex
|
||||||
|
session *SessionResponse
|
||||||
|
sessionExpiry time.Time
|
||||||
|
sessionMutex sync.Mutex
|
||||||
|
cacheKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CameraKind represents the different types of Ring cameras
|
|
||||||
type CameraKind string
|
type CameraKind string
|
||||||
|
|
||||||
// CameraData contains common fields for all camera types
|
|
||||||
type CameraData struct {
|
type CameraData struct {
|
||||||
ID float64 `json:"id"`
|
ID int `json:"id"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
LocationID string `json:"location_id"`
|
LocationID string `json:"location_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RingDeviceType represents different types of Ring devices
|
|
||||||
type RingDeviceType string
|
type RingDeviceType string
|
||||||
|
|
||||||
// RingDevicesResponse represents the response from the Ring API
|
|
||||||
type RingDevicesResponse struct {
|
type RingDevicesResponse struct {
|
||||||
Doorbots []CameraData `json:"doorbots"`
|
Doorbots []CameraData `json:"doorbots"`
|
||||||
AuthorizedDoorbots []CameraData `json:"authorized_doorbots"`
|
AuthorizedDoorbots []CameraData `json:"authorized_doorbots"`
|
||||||
@@ -139,23 +150,49 @@ const (
|
|||||||
apiVersion = 11
|
apiVersion = 11
|
||||||
defaultTimeout = 20 * time.Second
|
defaultTimeout = 20 * time.Second
|
||||||
maxRetries = 3
|
maxRetries = 3
|
||||||
|
sessionValidTime = 12 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewRingRestClient creates a new Ring client instance
|
func NewRestClient(auth interface{}, onTokenRefresh func(string)) (*RingApi, error) {
|
||||||
func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) {
|
var cacheKey string
|
||||||
client := &RingRestClient{
|
|
||||||
httpClient: &http.Client{Timeout: defaultTimeout},
|
|
||||||
onTokenRefresh: onTokenRefresh,
|
|
||||||
hardwareID: generateHardwareID(),
|
|
||||||
auth: auth,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Create cache key based on auth data
|
||||||
switch a := auth.(type) {
|
switch a := auth.(type) {
|
||||||
case RefreshTokenAuth:
|
case RefreshTokenAuth:
|
||||||
if a.RefreshToken == "" {
|
if a.RefreshToken == "" {
|
||||||
return nil, fmt.Errorf("refresh token is required")
|
return nil, fmt.Errorf("refresh token is required")
|
||||||
}
|
}
|
||||||
|
cacheKey = "refresh:" + a.RefreshToken
|
||||||
|
case EmailAuth:
|
||||||
|
if a.Email == "" || a.Password == "" {
|
||||||
|
return nil, fmt.Errorf("email and password are required")
|
||||||
|
}
|
||||||
|
cacheKey = "email:" + a.Email + ":" + a.Password
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid auth type")
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheMutex.Lock()
|
||||||
|
defer cacheMutex.Unlock()
|
||||||
|
|
||||||
|
if cachedClient, ok := clientCache[cacheKey]; ok {
|
||||||
|
// Check if token is not nil and not expired
|
||||||
|
if cachedClient.authToken != nil && time.Now().Before(cachedClient.tokenExpiry) {
|
||||||
|
cachedClient.onTokenRefresh = onTokenRefresh
|
||||||
|
return cachedClient, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &RingApi{
|
||||||
|
httpClient: &http.Client{Timeout: defaultTimeout},
|
||||||
|
onTokenRefresh: onTokenRefresh,
|
||||||
|
hardwareID: generateHardwareID(),
|
||||||
|
auth: auth,
|
||||||
|
cacheKey: cacheKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch a := auth.(type) {
|
||||||
|
case RefreshTokenAuth:
|
||||||
config, err := parseAuthConfig(a.RefreshToken)
|
config, err := parseAuthConfig(a.RefreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse refresh token: %w", err)
|
return nil, fmt.Errorf("failed to parse refresh token: %w", err)
|
||||||
@@ -164,160 +201,30 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest
|
|||||||
client.authConfig = config
|
client.authConfig = config
|
||||||
client.hardwareID = config.HID
|
client.hardwareID = config.HID
|
||||||
client.RefreshToken = a.RefreshToken
|
client.RefreshToken = a.RefreshToken
|
||||||
case EmailAuth:
|
|
||||||
if a.Email == "" || a.Password == "" {
|
|
||||||
return nil, fmt.Errorf("email and password are required")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("invalid auth type")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientCache[cacheKey] = client
|
||||||
|
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request makes an authenticated request to the Ring API
|
func ClientAPI(path string) string {
|
||||||
func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, error) {
|
return clientAPIBaseURL + path
|
||||||
// Ensure we have a valid auth token
|
|
||||||
if err := c.ensureAuth(); err != nil {
|
|
||||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var bodyReader io.Reader
|
|
||||||
if body != nil {
|
|
||||||
jsonBody, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
|
||||||
}
|
|
||||||
bodyReader = bytes.NewReader(jsonBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create request
|
|
||||||
req, err := http.NewRequest(method, url, bodyReader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set headers
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
req.Header.Set("hardware_id", c.hardwareID)
|
|
||||||
req.Header.Set("User-Agent", "android:com.ringapp")
|
|
||||||
|
|
||||||
// Make request with retries
|
|
||||||
var resp *http.Response
|
|
||||||
var responseBody []byte
|
|
||||||
|
|
||||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
|
||||||
resp, err = c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
if attempt == maxRetries {
|
|
||||||
return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err)
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
responseBody, err = io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle 401 by refreshing auth and retrying
|
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
|
||||||
c.authToken = nil // Force token refresh
|
|
||||||
if attempt == maxRetries {
|
|
||||||
return nil, fmt.Errorf("authentication failed after %d retries", maxRetries)
|
|
||||||
}
|
|
||||||
if err := c.ensureAuth(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to refresh authentication: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle other error status codes
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseBody, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureAuth ensures we have a valid auth token
|
func DeviceAPI(path string) string {
|
||||||
func (c *RingRestClient) ensureAuth() error {
|
return deviceAPIBaseURL + path
|
||||||
if c.authToken != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var grantData = map[string]string{
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"refresh_token": c.authConfig.RT,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add common fields
|
|
||||||
grantData["client_id"] = "ring_official_android"
|
|
||||||
grantData["scope"] = "client"
|
|
||||||
|
|
||||||
// Make auth request
|
|
||||||
body, err := json.Marshal(grantData)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal auth request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create auth request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
req.Header.Set("hardware_id", c.hardwareID)
|
|
||||||
req.Header.Set("User-Agent", "android:com.ringapp")
|
|
||||||
req.Header.Set("2fa-support", "true")
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("auth request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusPreconditionFailed {
|
|
||||||
return fmt.Errorf("2FA required. Please see documentation for handling 2FA")
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var authResp AuthTokenResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
|
|
||||||
return fmt.Errorf("failed to decode auth response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update auth config and refresh token
|
|
||||||
c.authToken = &authResp
|
|
||||||
c.authConfig = &AuthConfig{
|
|
||||||
RT: authResp.RefreshToken,
|
|
||||||
HID: c.hardwareID,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode and notify about new refresh token
|
|
||||||
if c.onTokenRefresh != nil {
|
|
||||||
newRefreshToken := encodeAuthConfig(c.authConfig)
|
|
||||||
c.onTokenRefresh(newRefreshToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getAuth makes an authentication request to the Ring API
|
func CommandsAPI(path string) string {
|
||||||
func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) {
|
return commandsAPIBaseURL + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func AppAPI(path string) string {
|
||||||
|
return appAPIBaseURL + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RingApi) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) {
|
||||||
var grantData map[string]string
|
var grantData map[string]string
|
||||||
|
|
||||||
if c.authConfig != nil && twoFactorAuthCode == "" {
|
if c.authConfig != nil && twoFactorAuthCode == "" {
|
||||||
@@ -404,60 +311,30 @@ func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse,
|
|||||||
return nil, fmt.Errorf("failed to decode auth response: %w", err)
|
return nil, fmt.Errorf("failed to decode auth response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh token and expiry
|
||||||
c.authToken = &authResp
|
c.authToken = &authResp
|
||||||
c.authConfig = &AuthConfig{
|
c.authConfig = &AuthConfig{
|
||||||
RT: authResp.RefreshToken,
|
RT: authResp.RefreshToken,
|
||||||
HID: c.hardwareID,
|
HID: c.hardwareID,
|
||||||
}
|
}
|
||||||
|
// Set token expiry (1 minute before actual expiry)
|
||||||
|
expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second
|
||||||
|
c.tokenExpiry = time.Now().Add(expiresIn)
|
||||||
|
|
||||||
c.RefreshToken = encodeAuthConfig(c.authConfig)
|
c.RefreshToken = encodeAuthConfig(c.authConfig)
|
||||||
if c.onTokenRefresh != nil {
|
if c.onTokenRefresh != nil {
|
||||||
c.onTokenRefresh(c.RefreshToken)
|
c.onTokenRefresh(c.RefreshToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh the cached client
|
||||||
|
cacheMutex.Lock()
|
||||||
|
clientCache[c.cacheKey] = c
|
||||||
|
cacheMutex.Unlock()
|
||||||
|
|
||||||
return c.authToken, nil
|
return c.authToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for auth config encoding/decoding
|
func (c *RingApi) FetchRingDevices() (*RingDevicesResponse, error) {
|
||||||
func parseAuthConfig(refreshToken string) (*AuthConfig, error) {
|
|
||||||
decoded, err := base64.StdEncoding.DecodeString(refreshToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var config AuthConfig
|
|
||||||
if err := json.Unmarshal(decoded, &config); err != nil {
|
|
||||||
// Handle legacy format where refresh token is the raw token
|
|
||||||
return &AuthConfig{RT: refreshToken}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func encodeAuthConfig(config *AuthConfig) string {
|
|
||||||
jsonBytes, _ := json.Marshal(config)
|
|
||||||
return base64.StdEncoding.EncodeToString(jsonBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// API URL helpers
|
|
||||||
func ClientAPI(path string) string {
|
|
||||||
return clientAPIBaseURL + path
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeviceAPI(path string) string {
|
|
||||||
return deviceAPIBaseURL + path
|
|
||||||
}
|
|
||||||
|
|
||||||
func CommandsAPI(path string) string {
|
|
||||||
return commandsAPIBaseURL + path
|
|
||||||
}
|
|
||||||
|
|
||||||
func AppAPI(path string) string {
|
|
||||||
return appAPIBaseURL + path
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchRingDevices gets all Ring devices and categorizes them
|
|
||||||
func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) {
|
|
||||||
response, err := c.Request("GET", ClientAPI("ring_devices"), nil)
|
response, err := c.Request("GET", ClientAPI("ring_devices"), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch ring devices: %w", err)
|
return nil, fmt.Errorf("failed to fetch ring devices: %w", err)
|
||||||
@@ -509,7 +386,7 @@ func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) {
|
|||||||
return &devices, nil
|
return &devices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) {
|
func (c *RingApi) GetSocketTicket() (*SocketTicketResponse, error) {
|
||||||
response, err := c.Request("POST", AppAPI("clap/ticket/request/signalsocket"), nil)
|
response, err := c.Request("POST", AppAPI("clap/ticket/request/signalsocket"), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch socket ticket: %w", err)
|
return nil, fmt.Errorf("failed to fetch socket ticket: %w", err)
|
||||||
@@ -523,6 +400,286 @@ func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) {
|
|||||||
return &ticket, nil
|
return &ticket, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *RingApi) Request(method, url string, body interface{}) ([]byte, error) {
|
||||||
|
// Ensure we have a valid session
|
||||||
|
if err := c.ensureSession(); err != nil {
|
||||||
|
return nil, fmt.Errorf("session validation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
jsonBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(jsonBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, err := http.NewRequest(method, url, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("hardware_id", c.hardwareID)
|
||||||
|
req.Header.Set("User-Agent", "android:com.ringapp")
|
||||||
|
|
||||||
|
// Make request with retries
|
||||||
|
var resp *http.Response
|
||||||
|
var responseBody []byte
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
|
resp, err = c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if attempt == maxRetries {
|
||||||
|
return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err)
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
responseBody, err = io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 401 by refreshing auth and retrying
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
// Reset token to force refresh
|
||||||
|
c.authMutex.Lock()
|
||||||
|
c.authToken = nil
|
||||||
|
c.tokenExpiry = time.Time{} // Reset token expiry
|
||||||
|
c.authMutex.Unlock()
|
||||||
|
|
||||||
|
if attempt == maxRetries {
|
||||||
|
return nil, fmt.Errorf("authentication failed after %d retries", maxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// By 401 with Auth AND Session start over
|
||||||
|
c.sessionMutex.Lock()
|
||||||
|
c.session = nil
|
||||||
|
c.sessionExpiry = time.Time{} // Reset session expiry
|
||||||
|
c.sessionMutex.Unlock()
|
||||||
|
|
||||||
|
if err := c.ensureSession(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to refresh session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 404 error with hardware_id reference - session issue
|
||||||
|
if resp.StatusCode == 404 && strings.Contains(url, clientAPIBaseURL) {
|
||||||
|
var errorBody map[string]interface{}
|
||||||
|
if err := json.Unmarshal(responseBody, &errorBody); err == nil {
|
||||||
|
if errorStr, ok := errorBody["error"].(string); ok && strings.Contains(errorStr, c.hardwareID) {
|
||||||
|
// Session with hardware_id not found, refresh session
|
||||||
|
c.sessionMutex.Lock()
|
||||||
|
c.session = nil
|
||||||
|
c.sessionExpiry = time.Time{} // Reset session expiry
|
||||||
|
c.sessionMutex.Unlock()
|
||||||
|
|
||||||
|
if attempt == maxRetries {
|
||||||
|
return nil, fmt.Errorf("session refresh failed after %d retries", maxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ensureSession(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to refresh session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other error status codes
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseBody, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RingApi) ensureSession() error {
|
||||||
|
c.sessionMutex.Lock()
|
||||||
|
defer c.sessionMutex.Unlock()
|
||||||
|
|
||||||
|
// If session is still valid, use it
|
||||||
|
if c.session != nil && time.Now().Before(c.sessionExpiry) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure we have a valid auth token
|
||||||
|
if err := c.ensureAuth(); err != nil {
|
||||||
|
return fmt.Errorf("authentication failed while creating session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionPayload := map[string]interface{}{
|
||||||
|
"device": map[string]interface{}{
|
||||||
|
"hardware_id": c.hardwareID,
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"api_version": apiVersion,
|
||||||
|
"device_model": "ring-client-go",
|
||||||
|
},
|
||||||
|
"os": "android",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(sessionPayload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal session request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", ClientAPI("session"), bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken)
|
||||||
|
req.Header.Set("hardware_id", c.hardwareID)
|
||||||
|
req.Header.Set("User-Agent", "android:com.ringapp")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("session request failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionResp SessionResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode session response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.session = &sessionResp
|
||||||
|
c.sessionExpiry = time.Now().Add(sessionValidTime)
|
||||||
|
|
||||||
|
// Aktualisiere den gecachten Client
|
||||||
|
cacheMutex.Lock()
|
||||||
|
clientCache[c.cacheKey] = c
|
||||||
|
cacheMutex.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RingApi) ensureAuth() error {
|
||||||
|
c.authMutex.Lock()
|
||||||
|
defer c.authMutex.Unlock()
|
||||||
|
|
||||||
|
// If token exists and is not expired, use it
|
||||||
|
if c.authToken != nil && time.Now().Before(c.tokenExpiry) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var grantData = map[string]string{
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": c.authConfig.RT,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add common fields
|
||||||
|
grantData["client_id"] = "ring_official_android"
|
||||||
|
grantData["scope"] = "client"
|
||||||
|
|
||||||
|
// Make auth request
|
||||||
|
body, err := json.Marshal(grantData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal auth request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create auth request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("hardware_id", c.hardwareID)
|
||||||
|
req.Header.Set("User-Agent", "android:com.ringapp")
|
||||||
|
req.Header.Set("2fa-support", "true")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusPreconditionFailed {
|
||||||
|
return fmt.Errorf("2FA required. Please see documentation for handling 2FA")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var authResp AuthTokenResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode auth response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update auth config and refresh token
|
||||||
|
c.authToken = &authResp
|
||||||
|
c.authConfig = &AuthConfig{
|
||||||
|
RT: authResp.RefreshToken,
|
||||||
|
HID: c.hardwareID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set token expiry (1 minute before actual expiry)
|
||||||
|
expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second
|
||||||
|
c.tokenExpiry = time.Now().Add(expiresIn)
|
||||||
|
|
||||||
|
// Encode and notify about new refresh token
|
||||||
|
if c.onTokenRefresh != nil {
|
||||||
|
newRefreshToken := encodeAuthConfig(c.authConfig)
|
||||||
|
c.onTokenRefresh(newRefreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refreshn the token in the client
|
||||||
|
c.RefreshToken = encodeAuthConfig(c.authConfig)
|
||||||
|
|
||||||
|
// Refresh the cached client
|
||||||
|
cacheMutex.Lock()
|
||||||
|
clientCache[c.cacheKey] = c
|
||||||
|
cacheMutex.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAuthConfig(refreshToken string) (*AuthConfig, error) {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var config AuthConfig
|
||||||
|
if err := json.Unmarshal(decoded, &config); err != nil {
|
||||||
|
// Handle legacy format where refresh token is the raw token
|
||||||
|
return &AuthConfig{RT: refreshToken}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeAuthConfig(config *AuthConfig) string {
|
||||||
|
jsonBytes, _ := json.Marshal(config)
|
||||||
|
return base64.StdEncoding.EncodeToString(jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
func generateHardwareID() string {
|
func generateHardwareID() string {
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
h.Write([]byte("ring-client-go2rtc"))
|
h.Write([]byte("ring-client-go2rtc"))
|
||||||
|
|||||||
+122
-308
@@ -5,103 +5,25 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sync"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
pion "github.com/pion/webrtc/v4"
|
pion "github.com/pion/webrtc/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
api *RingRestClient
|
api *RingApi
|
||||||
ws *websocket.Conn
|
wsClient *WSClient
|
||||||
prod core.Producer
|
prod core.Producer
|
||||||
camera *CameraData
|
cameraID int
|
||||||
dialogID string
|
dialogID string
|
||||||
sessionID string
|
connected core.Waiter
|
||||||
wsMutex sync.Mutex
|
closed bool
|
||||||
done chan struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionBody struct {
|
|
||||||
DoorbotID int `json:"doorbot_id"`
|
|
||||||
SessionID string `json:"session_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AnswerMessage struct {
|
|
||||||
Method string `json:"method"` // "sdp"
|
|
||||||
Body struct {
|
|
||||||
SessionBody
|
|
||||||
SDP string `json:"sdp"`
|
|
||||||
Type string `json:"type"` // "answer"
|
|
||||||
} `json:"body"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type IceCandidateMessage struct {
|
|
||||||
Method string `json:"method"` // "ice"
|
|
||||||
Body struct {
|
|
||||||
SessionBody
|
|
||||||
Ice string `json:"ice"`
|
|
||||||
MLineIndex int `json:"mlineindex"`
|
|
||||||
} `json:"body"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionMessage struct {
|
|
||||||
Method string `json:"method"` // "session_created" or "session_started"
|
|
||||||
Body SessionBody `json:"body"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PongMessage struct {
|
|
||||||
Method string `json:"method"` // "pong"
|
|
||||||
Body SessionBody `json:"body"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type NotificationMessage struct {
|
|
||||||
Method string `json:"method"` // "notification"
|
|
||||||
Body struct {
|
|
||||||
SessionBody
|
|
||||||
IsOK bool `json:"is_ok"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
} `json:"body"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type StreamInfoMessage struct {
|
|
||||||
Method string `json:"method"` // "stream_info"
|
|
||||||
Body struct {
|
|
||||||
SessionBody
|
|
||||||
Transcoding bool `json:"transcoding"`
|
|
||||||
TranscodingReason string `json:"transcoding_reason"`
|
|
||||||
} `json:"body"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CloseMessage struct {
|
|
||||||
Method string `json:"method"` // "close"
|
|
||||||
Body struct {
|
|
||||||
SessionBody
|
|
||||||
Reason struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
} `json:"reason"`
|
|
||||||
} `json:"body"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BaseMessage struct {
|
|
||||||
Method string `json:"method"`
|
|
||||||
Body map[string]any `json:"body"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close reason codes
|
|
||||||
const (
|
|
||||||
CloseReasonNormalClose = 0
|
|
||||||
CloseReasonAuthenticationFailed = 5
|
|
||||||
CloseReasonTimeout = 6
|
|
||||||
)
|
|
||||||
|
|
||||||
func Dial(rawURL string) (*Client, error) {
|
func Dial(rawURL string) (*Client, error) {
|
||||||
// 1. Parse URL and validate basic params
|
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -109,70 +31,42 @@ func Dial(rawURL string) (*Client, error) {
|
|||||||
|
|
||||||
query := u.Query()
|
query := u.Query()
|
||||||
encodedToken := query.Get("refresh_token")
|
encodedToken := query.Get("refresh_token")
|
||||||
|
cameraID := query.Get("camera_id")
|
||||||
deviceID := query.Get("device_id")
|
deviceID := query.Get("device_id")
|
||||||
_, isSnapshot := query["snapshot"]
|
_, isSnapshot := query["snapshot"]
|
||||||
|
|
||||||
if encodedToken == "" || deviceID == "" {
|
if encodedToken == "" || deviceID == "" || cameraID == "" {
|
||||||
return nil, errors.New("ring: wrong query")
|
return nil, errors.New("ring: wrong query")
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL-decode the refresh token
|
client := &Client{
|
||||||
|
dialogID: uuid.NewString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
client.cameraID, err = strconv.Atoi(cameraID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ring: invalid camera_id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
refreshToken, err := url.QueryUnescape(encodedToken)
|
refreshToken, err := url.QueryUnescape(encodedToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ring: invalid refresh token encoding: %w", err)
|
return nil, fmt.Errorf("ring: invalid refresh token encoding: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Ring API client
|
client.api, err = NewRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil)
|
||||||
ringAPI, err := NewRingRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get camera details
|
// Snapshot Flow
|
||||||
devices, err := ringAPI.FetchRingDevices()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var camera *CameraData
|
|
||||||
for _, cam := range devices.AllCameras {
|
|
||||||
if fmt.Sprint(cam.DeviceID) == deviceID {
|
|
||||||
camera = &cam
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if camera == nil {
|
|
||||||
return nil, errors.New("ring: camera not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create base client
|
|
||||||
client := &Client{
|
|
||||||
api: ringAPI,
|
|
||||||
camera: camera,
|
|
||||||
dialogID: uuid.NewString(),
|
|
||||||
done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if snapshot request
|
|
||||||
if isSnapshot {
|
if isSnapshot {
|
||||||
client.prod = NewSnapshotProducer(ringAPI, camera)
|
client.prod = NewSnapshotProducer(client.api, client.cameraID)
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not snapshot, continue with WebRTC setup
|
client.wsClient, err = StartWebsocket(client.cameraID, client.api)
|
||||||
ticket, err := ringAPI.GetSocketTicket()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create WebSocket connection
|
|
||||||
wsURL := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s",
|
|
||||||
uuid.NewString(), url.QueryEscape(ticket.Ticket))
|
|
||||||
|
|
||||||
client.ws, _, err = websocket.DefaultDialer.Dial(wsURL, map[string][]string{
|
|
||||||
"User-Agent": {"android:com.ringapp"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
client.Stop()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,13 +90,13 @@ func Dial(rawURL string) (*Client, error) {
|
|||||||
|
|
||||||
api, err := webrtc.NewAPI()
|
api, err := webrtc.NewAPI()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
client.ws.Close()
|
client.Stop()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
pc, err := api.NewPeerConnection(conf)
|
pc, err := api.NewPeerConnection(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
client.ws.Close()
|
client.Stop()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,16 +106,27 @@ func Dial(rawURL string) (*Client, error) {
|
|||||||
// protect from blocking on errors
|
// protect from blocking on errors
|
||||||
defer sendOffer.Done(nil)
|
defer sendOffer.Done(nil)
|
||||||
|
|
||||||
// waiter will wait PC error or WS error or nil (connection OK)
|
|
||||||
var connState core.Waiter
|
|
||||||
|
|
||||||
prod := webrtc.NewConn(pc)
|
prod := webrtc.NewConn(pc)
|
||||||
prod.FormatName = "ring/webrtc"
|
prod.FormatName = "ring/webrtc"
|
||||||
prod.Mode = core.ModeActiveProducer
|
prod.Mode = core.ModeActiveProducer
|
||||||
prod.Protocol = "ws"
|
prod.Protocol = "ws"
|
||||||
prod.URL = rawURL
|
prod.URL = rawURL
|
||||||
|
|
||||||
client.prod = prod
|
client.wsClient.onMessage = func(msg WSMessage) {
|
||||||
|
client.onWSMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.wsClient.onError = func(err error) {
|
||||||
|
// fmt.Printf("ring: error: %s\n", err.Error())
|
||||||
|
client.Stop()
|
||||||
|
client.connected.Done(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.wsClient.onClose = func() {
|
||||||
|
// fmt.Println("ring: disconnect")
|
||||||
|
client.Stop()
|
||||||
|
client.connected.Done(errors.New("ring: disconnect"))
|
||||||
|
}
|
||||||
|
|
||||||
prod.Listen(func(msg any) {
|
prod.Listen(func(msg any) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
@@ -240,22 +145,28 @@ func Dial(rawURL string) (*Client, error) {
|
|||||||
"mlineindex": iceCandidate.SDPMLineIndex,
|
"mlineindex": iceCandidate.SDPMLineIndex,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = client.sendSessionMessage("ice", icePayload); err != nil {
|
if err = client.wsClient.sendSessionMessage("ice", icePayload); err != nil {
|
||||||
connState.Done(err)
|
client.connected.Done(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case pion.PeerConnectionState:
|
case pion.PeerConnectionState:
|
||||||
switch msg {
|
switch msg {
|
||||||
|
case pion.PeerConnectionStateNew:
|
||||||
|
break
|
||||||
case pion.PeerConnectionStateConnecting:
|
case pion.PeerConnectionStateConnecting:
|
||||||
|
break
|
||||||
case pion.PeerConnectionStateConnected:
|
case pion.PeerConnectionStateConnected:
|
||||||
connState.Done(nil)
|
client.connected.Done(nil)
|
||||||
default:
|
default:
|
||||||
connState.Done(errors.New("ring: " + msg.String()))
|
client.Stop()
|
||||||
|
client.connected.Done(errors.New("ring: " + msg.String()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
client.prod = prod
|
||||||
|
|
||||||
// Setup media configuration
|
// Setup media configuration
|
||||||
medias := []*core.Media{
|
medias := []*core.Media{
|
||||||
{
|
{
|
||||||
@@ -297,186 +208,103 @@ func Dial(rawURL string) (*Client, error) {
|
|||||||
"sdp": offer,
|
"sdp": offer,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = client.sendSessionMessage("live_view", offerPayload); err != nil {
|
if err = client.wsClient.sendSessionMessage("live_view", offerPayload); err != nil {
|
||||||
client.Stop()
|
client.Stop()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sendOffer.Done(nil)
|
sendOffer.Done(nil)
|
||||||
|
|
||||||
// Ring expects a ping message every 5 seconds
|
if err = client.connected.Wait(); err != nil {
|
||||||
go client.startPingLoop(pc)
|
|
||||||
go client.startMessageLoop(&connState)
|
|
||||||
|
|
||||||
if err = connState.Wait(); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) startPingLoop(pc *pion.PeerConnection) {
|
func (c *Client) onWSMessage(msg WSMessage) {
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
rawMsg, _ := json.Marshal(msg)
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
// fmt.Printf("ring: onWSMessage: %s\n", string(rawMsg))
|
||||||
select {
|
|
||||||
case <-c.done:
|
// check if "doorbot_id" is present
|
||||||
return
|
if _, ok := msg.Body["doorbot_id"]; !ok {
|
||||||
case <-ticker.C:
|
return
|
||||||
if pc.ConnectionState() == pion.PeerConnectionStateConnected {
|
}
|
||||||
if err := c.sendSessionMessage("ping", nil); err != nil {
|
|
||||||
return
|
// check if the message is from the correct doorbot
|
||||||
}
|
doorbotID := msg.Body["doorbot_id"].(float64)
|
||||||
}
|
if int(doorbotID) != c.cameraID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Method == "session_created" || msg.Method == "session_started" {
|
||||||
|
if _, ok := msg.Body["session_id"]; ok && c.wsClient.sessionID == "" {
|
||||||
|
c.wsClient.sessionID = msg.Body["session_id"].(string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) startMessageLoop(connState *core.Waiter) {
|
// check if the message is from the correct session
|
||||||
var err error
|
if _, ok := msg.Body["session_id"]; ok {
|
||||||
|
if msg.Body["session_id"].(string) != c.wsClient.sessionID {
|
||||||
// will be closed when conn will be closed
|
|
||||||
defer func() {
|
|
||||||
connState.Done(err)
|
|
||||||
}()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-c.done:
|
|
||||||
return
|
return
|
||||||
default:
|
}
|
||||||
var res BaseMessage
|
}
|
||||||
if err = c.ws.ReadJSON(&res); err != nil {
|
|
||||||
select {
|
|
||||||
case <-c.done:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
|
switch msg.Method {
|
||||||
|
case "sdp":
|
||||||
|
if prod, ok := c.prod.(*webrtc.Conn); ok {
|
||||||
|
// Get answer
|
||||||
|
var msg AnswerMessage
|
||||||
|
if err := json.Unmarshal(rawMsg, &msg); err != nil {
|
||||||
c.Stop()
|
c.Stop()
|
||||||
|
c.connected.Done(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if "doorbot_id" is present
|
if err := prod.SetAnswer(msg.Body.SDP); err != nil {
|
||||||
if _, ok := res.Body["doorbot_id"]; !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if the message is from the correct doorbot
|
|
||||||
doorbotID := res.Body["doorbot_id"].(float64)
|
|
||||||
if doorbotID != float64(c.camera.ID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if the message is from the correct session
|
|
||||||
if res.Method == "session_created" || res.Method == "session_started" {
|
|
||||||
if _, ok := res.Body["session_id"]; ok && c.sessionID == "" {
|
|
||||||
c.sessionID = res.Body["session_id"].(string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := res.Body["session_id"]; ok {
|
|
||||||
if res.Body["session_id"].(string) != c.sessionID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rawMsg, _ := json.Marshal(res)
|
|
||||||
|
|
||||||
switch res.Method {
|
|
||||||
case "sdp":
|
|
||||||
if prod, ok := c.prod.(*webrtc.Conn); ok {
|
|
||||||
// Get answer
|
|
||||||
var msg AnswerMessage
|
|
||||||
if err = json.Unmarshal(rawMsg, &msg); err != nil {
|
|
||||||
c.Stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = prod.SetAnswer(msg.Body.SDP); err != nil {
|
|
||||||
c.Stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = c.activateSession(); err != nil {
|
|
||||||
c.Stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "ice":
|
|
||||||
if prod, ok := c.prod.(*webrtc.Conn); ok {
|
|
||||||
// Continue to receiving candidates
|
|
||||||
var msg IceCandidateMessage
|
|
||||||
if err = json.Unmarshal(rawMsg, &msg); err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for empty ICE candidate
|
|
||||||
if msg.Body.Ice == "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = prod.AddCandidate(msg.Body.Ice); err != nil {
|
|
||||||
c.Stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "close":
|
|
||||||
c.Stop()
|
c.Stop()
|
||||||
|
c.connected.Done(err)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
case "pong":
|
if err := c.wsClient.activateSession(); err != nil {
|
||||||
// Ignore
|
c.Stop()
|
||||||
continue
|
c.connected.Done(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prod.SDP = msg.Body.SDP
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ice":
|
||||||
|
if prod, ok := c.prod.(*webrtc.Conn); ok {
|
||||||
|
var msg IceCandidateMessage
|
||||||
|
if err := json.Unmarshal(rawMsg, &msg); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip empty candidates
|
||||||
|
if msg.Body.Ice == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := prod.AddCandidate(msg.Body.Ice); err != nil {
|
||||||
|
c.Stop()
|
||||||
|
c.connected.Done(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "close":
|
||||||
|
c.Stop()
|
||||||
|
c.connected.Done(errors.New("ring: close"))
|
||||||
|
|
||||||
|
case "pong":
|
||||||
|
// Ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) activateSession() error {
|
|
||||||
if err := c.sendSessionMessage("activate_session", nil); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
streamPayload := map[string]interface{}{
|
|
||||||
"audio_enabled": true,
|
|
||||||
"video_enabled": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.sendSessionMessage("stream_options", streamPayload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) sendSessionMessage(method string, body map[string]interface{}) error {
|
|
||||||
c.wsMutex.Lock()
|
|
||||||
defer c.wsMutex.Unlock()
|
|
||||||
|
|
||||||
if body == nil {
|
|
||||||
body = make(map[string]interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
body["doorbot_id"] = c.camera.ID
|
|
||||||
if c.sessionID != "" {
|
|
||||||
body["session_id"] = c.sessionID
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := map[string]interface{}{
|
|
||||||
"method": method,
|
|
||||||
"dialog_id": c.dialogID,
|
|
||||||
"body": body,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ws.WriteJSON(msg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetMedias() []*core.Media {
|
func (c *Client) GetMedias() []*core.Media {
|
||||||
return c.prod.GetMedias()
|
return c.prod.GetMedias()
|
||||||
}
|
}
|
||||||
@@ -492,7 +320,7 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece
|
|||||||
speakerPayload := map[string]interface{}{
|
speakerPayload := map[string]interface{}{
|
||||||
"stealth_mode": false,
|
"stealth_mode": false,
|
||||||
}
|
}
|
||||||
_ = c.sendSessionMessage("camera_options", speakerPayload)
|
_ = c.wsClient.sendSessionMessage("camera_options", speakerPayload)
|
||||||
}
|
}
|
||||||
return webrtcProd.AddTrack(media, codec, track)
|
return webrtcProd.AddTrack(media, codec, track)
|
||||||
}
|
}
|
||||||
@@ -505,37 +333,23 @@ func (c *Client) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Stop() error {
|
func (c *Client) Stop() error {
|
||||||
select {
|
if c.closed {
|
||||||
case <-c.done:
|
|
||||||
return nil
|
return nil
|
||||||
default:
|
|
||||||
close(c.done)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.closed = true
|
||||||
|
|
||||||
if c.prod != nil {
|
if c.prod != nil {
|
||||||
_ = c.prod.Stop()
|
_ = c.prod.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.ws != nil {
|
if c.wsClient != nil {
|
||||||
closePayload := map[string]interface{}{
|
_ = c.wsClient.Close()
|
||||||
"reason": map[string]interface{}{
|
|
||||||
"code": CloseReasonNormalClose,
|
|
||||||
"text": "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = c.sendSessionMessage("close", closePayload)
|
|
||||||
_ = c.ws.Close()
|
|
||||||
c.ws = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||||
if webrtcProd, ok := c.prod.(*webrtc.Conn); ok {
|
|
||||||
return webrtcProd.MarshalJSON()
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Marshal(c.prod)
|
return json.Marshal(c.prod)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
type SnapshotProducer struct {
|
type SnapshotProducer struct {
|
||||||
core.Connection
|
core.Connection
|
||||||
|
|
||||||
client *RingRestClient
|
client *RingApi
|
||||||
camera *CameraData
|
cameraID int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer {
|
func NewSnapshotProducer(client *RingApi, cameraID int) *SnapshotProducer {
|
||||||
return &SnapshotProducer{
|
return &SnapshotProducer{
|
||||||
Connection: core.Connection{
|
Connection: core.Connection{
|
||||||
ID: core.NewID(),
|
ID: core.NewID(),
|
||||||
@@ -35,14 +35,13 @@ func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotPr
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
client: client,
|
client: client,
|
||||||
camera: camera,
|
cameraID: cameraID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SnapshotProducer) Start() error {
|
func (p *SnapshotProducer) Start() error {
|
||||||
// Fetch snapshot
|
response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", p.cameraID), nil)
|
||||||
response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", int(p.camera.ID)), nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
package ring
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SessionBody struct {
|
||||||
|
DoorbotID int `json:"doorbot_id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnswerMessage struct {
|
||||||
|
Method string `json:"method"` // "sdp"
|
||||||
|
Body struct {
|
||||||
|
SessionBody
|
||||||
|
SDP string `json:"sdp"`
|
||||||
|
Type string `json:"type"` // "answer"
|
||||||
|
} `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IceCandidateMessage struct {
|
||||||
|
Method string `json:"method"` // "ice"
|
||||||
|
Body struct {
|
||||||
|
SessionBody
|
||||||
|
Ice string `json:"ice"`
|
||||||
|
MLineIndex int `json:"mlineindex"`
|
||||||
|
} `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionMessage struct {
|
||||||
|
Method string `json:"method"` // "session_created" or "session_started"
|
||||||
|
Body SessionBody `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PongMessage struct {
|
||||||
|
Method string `json:"method"` // "pong"
|
||||||
|
Body SessionBody `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationMessage struct {
|
||||||
|
Method string `json:"method"` // "notification"
|
||||||
|
Body struct {
|
||||||
|
SessionBody
|
||||||
|
IsOK bool `json:"is_ok"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamInfoMessage struct {
|
||||||
|
Method string `json:"method"` // "stream_info"
|
||||||
|
Body struct {
|
||||||
|
SessionBody
|
||||||
|
Transcoding bool `json:"transcoding"`
|
||||||
|
TranscodingReason string `json:"transcoding_reason"`
|
||||||
|
} `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CloseRequest struct {
|
||||||
|
Method string `json:"method"` // "close"
|
||||||
|
Body struct {
|
||||||
|
SessionBody
|
||||||
|
Reason struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"reason"`
|
||||||
|
} `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WSMessage struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Body map[string]any `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WSClient struct {
|
||||||
|
ws *websocket.Conn
|
||||||
|
api *RingApi
|
||||||
|
wsMutex sync.Mutex
|
||||||
|
cameraID int
|
||||||
|
dialogID string
|
||||||
|
sessionID string
|
||||||
|
|
||||||
|
onMessage func(msg WSMessage)
|
||||||
|
onError func(err error)
|
||||||
|
onClose func()
|
||||||
|
|
||||||
|
closed chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
CloseReasonNormalClose = 0
|
||||||
|
CloseReasonAuthenticationFailed = 5
|
||||||
|
CloseReasonTimeout = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartWebsocket(cameraID int, api *RingApi) (*WSClient, error) {
|
||||||
|
client := &WSClient{
|
||||||
|
api: api,
|
||||||
|
cameraID: cameraID,
|
||||||
|
dialogID: uuid.NewString(),
|
||||||
|
closed: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket, err := client.api.GetSocketTicket()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s",
|
||||||
|
uuid.NewString(), url.QueryEscape(ticket.Ticket))
|
||||||
|
|
||||||
|
httpHeader := http.Header{}
|
||||||
|
httpHeader.Set("User-Agent", "android:com.ringapp")
|
||||||
|
|
||||||
|
client.ws, _, err = websocket.DefaultDialer.Dial(url, httpHeader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client.ws.SetCloseHandler(func(code int, text string) error {
|
||||||
|
client.onWsClose()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
go client.startPingLoop()
|
||||||
|
go client.startMessageLoop()
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) Close() error {
|
||||||
|
select {
|
||||||
|
case <-c.closed:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
close(c.closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
closePayload := map[string]interface{}{
|
||||||
|
"reason": map[string]interface{}{
|
||||||
|
"code": CloseReasonNormalClose,
|
||||||
|
"text": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = c.sendSessionMessage("close", closePayload)
|
||||||
|
|
||||||
|
return c.ws.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) startPingLoop() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.closed:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.sendSessionMessage("ping", nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) startMessageLoop() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.closed:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
var res WSMessage
|
||||||
|
if err := c.ws.ReadJSON(&res); err != nil {
|
||||||
|
select {
|
||||||
|
case <-c.closed:
|
||||||
|
// Ignore error if closed
|
||||||
|
default:
|
||||||
|
c.onWsError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.onWsMessage(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) activateSession() error {
|
||||||
|
if err := c.sendSessionMessage("activate_session", nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
streamPayload := map[string]interface{}{
|
||||||
|
"audio_enabled": true,
|
||||||
|
"video_enabled": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.sendSessionMessage("stream_options", streamPayload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) sendSessionMessage(method string, payload map[string]interface{}) error {
|
||||||
|
select {
|
||||||
|
case <-c.closed:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
// continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.wsMutex.Lock()
|
||||||
|
defer c.wsMutex.Unlock()
|
||||||
|
|
||||||
|
if payload == nil {
|
||||||
|
payload = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload["doorbot_id"] = c.cameraID
|
||||||
|
if c.sessionID != "" {
|
||||||
|
payload["session_id"] = c.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"method": method,
|
||||||
|
"dialog_id": c.dialogID,
|
||||||
|
"body": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawMsg, _ := json.Marshal(msg)
|
||||||
|
// fmt.Printf("ring: sendSessionMessage: %s: %s\n", method, string(rawMsg))
|
||||||
|
|
||||||
|
if err := c.ws.WriteJSON(msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) onWsMessage(msg WSMessage) {
|
||||||
|
if c.onMessage != nil {
|
||||||
|
c.onMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) onWsError(err error) {
|
||||||
|
if c.onError != nil {
|
||||||
|
c.onError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WSClient) onWsClose() {
|
||||||
|
if c.onClose != nil {
|
||||||
|
c.onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
+158
-33
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/tcp/websocket"
|
"github.com/AlexxIT/go2rtc/pkg/tcp/websocket"
|
||||||
@@ -36,14 +37,22 @@ func (c *Conn) Dial() (err error) {
|
|||||||
|
|
||||||
var conn net.Conn
|
var conn net.Conn
|
||||||
|
|
||||||
if c.Transport == "" {
|
switch c.Transport {
|
||||||
timeout := core.ConnDialTimeout
|
case "", "tcp", "udp":
|
||||||
|
var timeout time.Duration
|
||||||
if c.Timeout != 0 {
|
if c.Timeout != 0 {
|
||||||
timeout = time.Second * time.Duration(c.Timeout)
|
timeout = time.Second * time.Duration(c.Timeout)
|
||||||
|
} else {
|
||||||
|
timeout = core.ConnDialTimeout
|
||||||
}
|
}
|
||||||
conn, err = tcp.Dial(c.URL, timeout)
|
conn, err = tcp.Dial(c.URL, timeout)
|
||||||
c.Protocol = "rtsp+tcp"
|
|
||||||
} else {
|
if c.Transport != "udp" {
|
||||||
|
c.Protocol = "rtsp+tcp"
|
||||||
|
} else {
|
||||||
|
c.Protocol = "rtsp+udp"
|
||||||
|
}
|
||||||
|
default:
|
||||||
conn, err = websocket.Dial(c.Transport)
|
conn, err = websocket.Dial(c.Transport)
|
||||||
c.Protocol = "ws"
|
c.Protocol = "ws"
|
||||||
}
|
}
|
||||||
@@ -61,6 +70,9 @@ func (c *Conn) Dial() (err error) {
|
|||||||
c.sequence = 0
|
c.sequence = 0
|
||||||
c.state = StateConn
|
c.state = StateConn
|
||||||
|
|
||||||
|
c.udpConn = nil
|
||||||
|
c.udpAddr = nil
|
||||||
|
|
||||||
c.Connection.RemoteAddr = conn.RemoteAddr().String()
|
c.Connection.RemoteAddr = conn.RemoteAddr().String()
|
||||||
c.Connection.Transport = conn
|
c.Connection.Transport = conn
|
||||||
c.Connection.URL = c.uri
|
c.Connection.URL = c.uri
|
||||||
@@ -81,7 +93,35 @@ func (c *Conn) Do(req *tcp.Request) (*tcp.Response, error) {
|
|||||||
|
|
||||||
c.Fire(res)
|
c.Fire(res)
|
||||||
|
|
||||||
if res.StatusCode == http.StatusUnauthorized {
|
switch res.StatusCode {
|
||||||
|
case http.StatusOK:
|
||||||
|
return res, nil
|
||||||
|
|
||||||
|
case http.StatusMovedPermanently, http.StatusFound:
|
||||||
|
rawURL := res.Header.Get("Location")
|
||||||
|
|
||||||
|
var u *url.URL
|
||||||
|
if u, err = url.Parse(rawURL); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.User == nil {
|
||||||
|
u.User = c.auth.UserInfo() // restore auth if we don't have it in the new URL
|
||||||
|
}
|
||||||
|
|
||||||
|
c.uri = u.String() // so auth will be saved on reconnect
|
||||||
|
|
||||||
|
_ = c.conn.Close()
|
||||||
|
|
||||||
|
if err = c.Dial(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.URL = c.URL // because path was changed
|
||||||
|
|
||||||
|
return c.Do(req)
|
||||||
|
|
||||||
|
case http.StatusUnauthorized:
|
||||||
switch c.auth.Method {
|
switch c.auth.Method {
|
||||||
case tcp.AuthNone:
|
case tcp.AuthNone:
|
||||||
if c.auth.ReadNone(res) {
|
if c.auth.ReadNone(res) {
|
||||||
@@ -97,11 +137,7 @@ func (c *Conn) Do(req *tcp.Request) (*tcp.Response, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if res.StatusCode != http.StatusOK {
|
return res, fmt.Errorf("wrong response on %s", req.Method)
|
||||||
return res, fmt.Errorf("wrong response on %s", req.Method)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Conn) Options() error {
|
func (c *Conn) Options() error {
|
||||||
@@ -218,15 +254,27 @@ func (c *Conn) Record() (err error) {
|
|||||||
func (c *Conn) SetupMedia(media *core.Media) (byte, error) {
|
func (c *Conn) SetupMedia(media *core.Media) (byte, error) {
|
||||||
var transport string
|
var transport string
|
||||||
|
|
||||||
// try to use media position as channel number
|
if c.Transport == "udp" {
|
||||||
for i, m := range c.Medias {
|
conn1, conn2, err := ListenUDPPair()
|
||||||
if m.Equal(media) {
|
if err != nil {
|
||||||
transport = fmt.Sprintf(
|
return 0, err
|
||||||
// i - RTP (data channel)
|
}
|
||||||
// i+1 - RTCP (control channel)
|
|
||||||
"RTP/AVP/TCP;unicast;interleaved=%d-%d", i*2, i*2+1,
|
c.udpConn = append(c.udpConn, conn1, conn2)
|
||||||
)
|
|
||||||
break
|
port := conn1.LocalAddr().(*net.UDPAddr).Port
|
||||||
|
transport = fmt.Sprintf("RTP/AVP;unicast;client_port=%d-%d", port, port+1)
|
||||||
|
} else {
|
||||||
|
// try to use media position as channel number
|
||||||
|
for i, m := range c.Medias {
|
||||||
|
if m.Equal(media) {
|
||||||
|
transport = fmt.Sprintf(
|
||||||
|
// i - RTP (data channel)
|
||||||
|
// i+1 - RTCP (control channel)
|
||||||
|
"RTP/AVP/TCP;unicast;interleaved=%d-%d", i*2, i*2+1,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,27 +334,53 @@ func (c *Conn) SetupMedia(media *core.Media) (byte, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// we send our `interleaved`, but camera can answer with another
|
// Parse server response
|
||||||
|
|
||||||
// Transport: RTP/AVP/TCP;unicast;interleaved=10-11;ssrc=10117CB7
|
|
||||||
// Transport: RTP/AVP/TCP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0
|
|
||||||
// Transport: RTP/AVP/TCP;ssrc=22345682;interleaved=0-1
|
|
||||||
transport = res.Header.Get("Transport")
|
transport = res.Header.Get("Transport")
|
||||||
if !strings.HasPrefix(transport, "RTP/AVP/TCP;") {
|
|
||||||
|
if c.Transport == "udp" {
|
||||||
|
channel := byte(len(c.udpConn) - 2)
|
||||||
|
|
||||||
|
// Dahua: RTP/AVP/UDP;unicast;client_port=49292-49293;server_port=43670-43671;ssrc=7CB694B4
|
||||||
|
// OpenIPC: RTP/AVP/UDP;unicast;client_port=59612-59613
|
||||||
|
if s := core.Between(transport, "server_port=", ";"); s != "" {
|
||||||
|
s1, s2, _ := strings.Cut(s, "-")
|
||||||
|
port1 := core.Atoi(s1)
|
||||||
|
port2 := core.Atoi(s2)
|
||||||
|
// TODO: more smart handling empty server ports
|
||||||
|
if port1 > 0 && port2 > 0 {
|
||||||
|
remoteIP := c.conn.RemoteAddr().(*net.TCPAddr).IP
|
||||||
|
c.udpAddr = append(c.udpAddr,
|
||||||
|
&net.UDPAddr{IP: remoteIP, Port: port1},
|
||||||
|
&net.UDPAddr{IP: remoteIP, Port: port2},
|
||||||
|
)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Try to open a hole in the NAT router (to allow incoming UDP packets)
|
||||||
|
// by send a UDP packet for RTP and RTCP to the remote RTSP server.
|
||||||
|
// https://github.com/FFmpeg/FFmpeg/blob/aa91ae25b88e195e6af4248e0ab30605735ca1cd/libavformat/rtpdec.c#L416-L438
|
||||||
|
_, _ = c.WriteToUDP([]byte{0x80, 0x00, 0x00, 0x00}, channel)
|
||||||
|
_, _ = c.WriteToUDP([]byte{0x80, 0xC8, 0x00, 0x01}, channel+1)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel, nil
|
||||||
|
} else {
|
||||||
|
// we send our `interleaved`, but camera can answer with another
|
||||||
|
|
||||||
|
// Transport: RTP/AVP/TCP;unicast;interleaved=10-11;ssrc=10117CB7
|
||||||
|
// Transport: RTP/AVP/TCP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0
|
||||||
|
// Transport: RTP/AVP/TCP;ssrc=22345682;interleaved=0-1
|
||||||
// Escam Q6 has a bug:
|
// Escam Q6 has a bug:
|
||||||
// Transport: RTP/AVP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0-1
|
// Transport: RTP/AVP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0-1
|
||||||
if !strings.Contains(transport, ";interleaved=") {
|
s := core.Between(transport, "interleaved=", "-")
|
||||||
|
i, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
return 0, fmt.Errorf("wrong transport: %s", transport)
|
return 0, fmt.Errorf("wrong transport: %s", transport)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
channel := core.Between(transport, "interleaved=", "-")
|
return byte(i), nil
|
||||||
i, err := strconv.Atoi(channel)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return byte(i), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Conn) Play() (err error) {
|
func (c *Conn) Play() (err error) {
|
||||||
@@ -327,5 +401,56 @@ func (c *Conn) Close() error {
|
|||||||
if c.OnClose != nil {
|
if c.OnClose != nil {
|
||||||
_ = c.OnClose()
|
_ = c.OnClose()
|
||||||
}
|
}
|
||||||
|
for _, conn := range c.udpConn {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
return c.conn.Close()
|
return c.conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Conn) WriteToUDP(b []byte, channel byte) (int, error) {
|
||||||
|
return c.udpConn[channel].WriteToUDP(b, c.udpAddr[channel])
|
||||||
|
}
|
||||||
|
|
||||||
|
const listenUDPAttemps = 10
|
||||||
|
|
||||||
|
var listenUDPMu sync.Mutex
|
||||||
|
|
||||||
|
func ListenUDPPair() (*net.UDPConn, *net.UDPConn, error) {
|
||||||
|
listenUDPMu.Lock()
|
||||||
|
defer listenUDPMu.Unlock()
|
||||||
|
|
||||||
|
for i := 0; i < listenUDPAttemps; i++ {
|
||||||
|
// Get a random even port from the OS
|
||||||
|
ln1, err := net.ListenUDP("udp", &net.UDPAddr{IP: nil, Port: 0})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var port1 = ln1.LocalAddr().(*net.UDPAddr).Port
|
||||||
|
var port2 int
|
||||||
|
|
||||||
|
// 11. RTP over Network and Transport Protocols (https://www.ietf.org/rfc/rfc3550.txt)
|
||||||
|
// For UDP and similar protocols,
|
||||||
|
// RTP SHOULD use an even destination port number and the corresponding
|
||||||
|
// RTCP stream SHOULD use the next higher (odd) destination port number
|
||||||
|
if port1&1 > 0 {
|
||||||
|
port2 = port1 - 1
|
||||||
|
} else {
|
||||||
|
port2 = port1 + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ln2, err := net.ListenUDP("udp", &net.UDPAddr{IP: nil, Port: port2})
|
||||||
|
if err != nil {
|
||||||
|
_ = ln1.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if port1 < port2 {
|
||||||
|
return ln1, ln2, nil
|
||||||
|
} else {
|
||||||
|
return ln2, ln1, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, fmt.Errorf("can't open two UDP ports")
|
||||||
|
}
|
||||||
|
|||||||
+186
-139
@@ -2,6 +2,7 @@ package rtsp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -13,7 +14,6 @@ import (
|
|||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||||
"github.com/pion/rtcp"
|
|
||||||
"github.com/pion/rtp"
|
"github.com/pion/rtp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ type Conn struct {
|
|||||||
keepalive int
|
keepalive int
|
||||||
mode core.Mode
|
mode core.Mode
|
||||||
playOK bool
|
playOK bool
|
||||||
|
playErr error
|
||||||
reader *bufio.Reader
|
reader *bufio.Reader
|
||||||
sequence int
|
sequence int
|
||||||
session string
|
session string
|
||||||
@@ -47,6 +48,9 @@ type Conn struct {
|
|||||||
|
|
||||||
state State
|
state State
|
||||||
stateMu sync.Mutex
|
stateMu sync.Mutex
|
||||||
|
|
||||||
|
udpConn []*net.UDPConn
|
||||||
|
udpAddr []*net.UDPAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -68,7 +72,6 @@ func (s State) String() string {
|
|||||||
case StateNone:
|
case StateNone:
|
||||||
return "NONE"
|
return "NONE"
|
||||||
case StateConn:
|
case StateConn:
|
||||||
|
|
||||||
return "CONN"
|
return "CONN"
|
||||||
case StateSetup:
|
case StateSetup:
|
||||||
return MethodSetup
|
return MethodSetup
|
||||||
@@ -88,23 +91,25 @@ const (
|
|||||||
func (c *Conn) Handle() (err error) {
|
func (c *Conn) Handle() (err error) {
|
||||||
var timeout time.Duration
|
var timeout time.Duration
|
||||||
|
|
||||||
var keepaliveDT time.Duration
|
|
||||||
var keepaliveTS time.Time
|
|
||||||
|
|
||||||
switch c.mode {
|
switch c.mode {
|
||||||
case core.ModeActiveProducer:
|
case core.ModeActiveProducer:
|
||||||
|
var keepaliveDT time.Duration
|
||||||
|
|
||||||
if c.keepalive > 5 {
|
if c.keepalive > 5 {
|
||||||
keepaliveDT = time.Duration(c.keepalive-5) * time.Second
|
keepaliveDT = time.Duration(c.keepalive-5) * time.Second
|
||||||
} else {
|
} else {
|
||||||
keepaliveDT = 25 * time.Second
|
keepaliveDT = 25 * time.Second
|
||||||
}
|
}
|
||||||
keepaliveTS = time.Now().Add(keepaliveDT)
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go c.handleKeepalive(ctx, keepaliveDT)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
if c.Timeout == 0 {
|
if c.Timeout == 0 {
|
||||||
// polling frames from remote RTSP Server (ex Camera)
|
// polling frames from remote RTSP Server (ex Camera)
|
||||||
timeout = time.Second * 5
|
timeout = time.Second * 5
|
||||||
|
|
||||||
if len(c.Receivers) == 0 {
|
if len(c.Receivers) == 0 || c.Transport == "udp" {
|
||||||
// if we only send audio to camera
|
// if we only send audio to camera
|
||||||
// https://github.com/AlexxIT/go2rtc/issues/659
|
// https://github.com/AlexxIT/go2rtc/issues/659
|
||||||
timeout += keepaliveDT
|
timeout += keepaliveDT
|
||||||
@@ -129,148 +134,190 @@ func (c *Conn) Handle() (err error) {
|
|||||||
return fmt.Errorf("wrong RTSP conn mode: %d", c.mode)
|
return fmt.Errorf("wrong RTSP conn mode: %d", c.mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(c.udpConn); i++ {
|
||||||
|
go c.handleUDPData(byte(i))
|
||||||
|
}
|
||||||
|
|
||||||
for c.state != StateNone {
|
for c.state != StateNone {
|
||||||
ts := time.Now()
|
ts := time.Now()
|
||||||
|
|
||||||
if err = c.conn.SetReadDeadline(ts.Add(timeout)); err != nil {
|
_ = c.conn.SetReadDeadline(ts.Add(timeout))
|
||||||
|
|
||||||
|
if err = c.handleTCPData(); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// we can read:
|
|
||||||
// 1. RTP interleaved: `$` + 1B channel number + 2B size
|
|
||||||
// 2. RTSP response: RTSP/1.0 200 OK
|
|
||||||
// 3. RTSP request: OPTIONS ...
|
|
||||||
var buf4 []byte // `$` + 1B channel number + 2B size
|
|
||||||
buf4, err = c.reader.Peek(4)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var channelID byte
|
|
||||||
var size uint16
|
|
||||||
|
|
||||||
if buf4[0] != '$' {
|
|
||||||
switch string(buf4) {
|
|
||||||
case "RTSP":
|
|
||||||
var res *tcp.Response
|
|
||||||
if res, err = c.ReadResponse(); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Fire(res)
|
|
||||||
// for playing backchannel only after OK response on play
|
|
||||||
c.playOK = true
|
|
||||||
continue
|
|
||||||
|
|
||||||
case "OPTI", "TEAR", "DESC", "SETU", "PLAY", "PAUS", "RECO", "ANNO", "GET_", "SET_":
|
|
||||||
var req *tcp.Request
|
|
||||||
if req, err = c.ReadRequest(); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Fire(req)
|
|
||||||
if req.Method == MethodOptions {
|
|
||||||
res := &tcp.Response{Request: req}
|
|
||||||
if err = c.WriteResponse(res); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
|
|
||||||
default:
|
|
||||||
c.Fire("RTSP wrong input")
|
|
||||||
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
// search next start symbol
|
|
||||||
if _, err = c.reader.ReadBytes('$'); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if channelID, err = c.reader.ReadByte(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: better check maximum good channel ID
|
|
||||||
if channelID >= 20 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
buf4 = make([]byte, 2)
|
|
||||||
if _, err = io.ReadFull(c.reader, buf4); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if size good for RTP
|
|
||||||
size = binary.BigEndian.Uint16(buf4)
|
|
||||||
if size <= 1500 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10 tries to find good packet
|
|
||||||
if i >= 10 {
|
|
||||||
return fmt.Errorf("RTSP wrong input")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// hope that the odd channels are always RTCP
|
|
||||||
channelID = buf4[1]
|
|
||||||
|
|
||||||
// get data size
|
|
||||||
size = binary.BigEndian.Uint16(buf4[2:])
|
|
||||||
|
|
||||||
// skip 4 bytes from c.reader.Peek
|
|
||||||
if _, err = c.reader.Discard(4); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// init memory for data
|
|
||||||
buf := make([]byte, size)
|
|
||||||
if _, err = io.ReadFull(c.reader, buf); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Recv += int(size)
|
|
||||||
|
|
||||||
if channelID&1 == 0 {
|
|
||||||
packet := &rtp.Packet{}
|
|
||||||
if err = packet.Unmarshal(buf); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, receiver := range c.Receivers {
|
|
||||||
if receiver.ID == channelID {
|
|
||||||
receiver.WriteRTP(packet)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
msg := &RTCP{Channel: channelID}
|
|
||||||
|
|
||||||
if err = msg.Header.Unmarshal(buf); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.Packets, err = rtcp.Unmarshal(buf)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Fire(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
if keepaliveDT != 0 && ts.After(keepaliveTS) {
|
|
||||||
req := &tcp.Request{Method: MethodOptions, URL: c.URL}
|
|
||||||
if err = c.WriteRequest(req); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
keepaliveTS = ts.Add(keepaliveDT)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Conn) handleKeepalive(ctx context.Context, d time.Duration) {
|
||||||
|
ticker := time.NewTicker(d)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
req := &tcp.Request{Method: MethodOptions, URL: c.URL}
|
||||||
|
if err := c.WriteRequest(req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) handleUDPData(channel byte) {
|
||||||
|
// TODO: handle timeouts and drop TCP connection after any error
|
||||||
|
conn := c.udpConn[channel]
|
||||||
|
|
||||||
|
for {
|
||||||
|
// TP-Link Tapo camera has crazy 10000 bytes packet size
|
||||||
|
buf := make([]byte, 10240)
|
||||||
|
|
||||||
|
n, _, err := conn.ReadFromUDP(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = c.handleRawPacket(channel, buf[:n]); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) handleTCPData() error {
|
||||||
|
// we can read:
|
||||||
|
// 1. RTP interleaved: `$` + 1B channel number + 2B size
|
||||||
|
// 2. RTSP response: RTSP/1.0 200 OK
|
||||||
|
// 3. RTSP request: OPTIONS ...
|
||||||
|
var buf4 []byte // `$` + 1B channel number + 2B size
|
||||||
|
var err error
|
||||||
|
|
||||||
|
buf4, err = c.reader.Peek(4)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var channel byte
|
||||||
|
var size uint16
|
||||||
|
|
||||||
|
if buf4[0] != '$' {
|
||||||
|
switch string(buf4) {
|
||||||
|
case "RTSP":
|
||||||
|
var res *tcp.Response
|
||||||
|
if res, err = c.ReadResponse(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Fire(res)
|
||||||
|
// for playing backchannel only after OK response on play
|
||||||
|
c.playOK = true
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "OPTI", "TEAR", "DESC", "SETU", "PLAY", "PAUS", "RECO", "ANNO", "GET_", "SET_":
|
||||||
|
var req *tcp.Request
|
||||||
|
if req, err = c.ReadRequest(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Fire(req)
|
||||||
|
if req.Method == MethodOptions {
|
||||||
|
res := &tcp.Response{Request: req}
|
||||||
|
if err = c.WriteResponse(res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
c.Fire("RTSP wrong input")
|
||||||
|
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
// search next start symbol
|
||||||
|
if _, err = c.reader.ReadBytes('$'); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if channel, err = c.reader.ReadByte(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: better check maximum good channel ID
|
||||||
|
if channel >= 20 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
buf4 = make([]byte, 2)
|
||||||
|
if _, err = io.ReadFull(c.reader, buf4); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if size good for RTP
|
||||||
|
size = binary.BigEndian.Uint16(buf4)
|
||||||
|
if size <= 1500 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10 tries to find good packet
|
||||||
|
if i >= 10 {
|
||||||
|
return fmt.Errorf("RTSP wrong input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// hope that the odd channels are always RTCP
|
||||||
|
channel = buf4[1]
|
||||||
|
|
||||||
|
// get data size
|
||||||
|
size = binary.BigEndian.Uint16(buf4[2:])
|
||||||
|
|
||||||
|
// skip 4 bytes from c.reader.Peek
|
||||||
|
if _, err = c.reader.Discard(4); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// init memory for data
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if _, err = io.ReadFull(c.reader, buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Recv += int(size)
|
||||||
|
|
||||||
|
return c.handleRawPacket(channel, buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) handleRawPacket(channel byte, buf []byte) error {
|
||||||
|
if channel&1 == 0 {
|
||||||
|
packet := &rtp.Packet{}
|
||||||
|
if err := packet.Unmarshal(buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, receiver := range c.Receivers {
|
||||||
|
if receiver.ID == channel {
|
||||||
|
receiver.WriteRTP(packet)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
msg := &RTCP{Channel: channel}
|
||||||
|
|
||||||
|
if err := msg.Header.Unmarshal(buf); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//var err error
|
||||||
|
//msg.Packets, err = rtcp.Unmarshal(buf)
|
||||||
|
//if err != nil {
|
||||||
|
// return nil
|
||||||
|
//}
|
||||||
|
|
||||||
|
c.Fire(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Conn) WriteRequest(req *tcp.Request) error {
|
func (c *Conn) WriteRequest(req *tcp.Request) error {
|
||||||
if req.Proto == "" {
|
if req.Proto == "" {
|
||||||
req.Proto = ProtoRTSP
|
req.Proto = ProtoRTSP
|
||||||
|
|||||||
+23
-4
@@ -85,11 +85,8 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core.
|
|||||||
}
|
}
|
||||||
|
|
||||||
flushBuf := func() {
|
flushBuf := func() {
|
||||||
if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
//log.Printf("[rtsp] channel:%2d write_size:%6d buffer_size:%6d", channel, n, len(buf))
|
//log.Printf("[rtsp] channel:%2d write_size:%6d buffer_size:%6d", channel, n, len(buf))
|
||||||
if _, err := c.conn.Write(buf[:n]); err == nil {
|
if err := c.writeInterleavedData(buf[:n]); err != nil {
|
||||||
c.Send += n
|
c.Send += n
|
||||||
}
|
}
|
||||||
n = 0
|
n = 0
|
||||||
@@ -177,3 +174,25 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core.
|
|||||||
|
|
||||||
return handlerFunc
|
return handlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Conn) writeInterleavedData(data []byte) error {
|
||||||
|
if c.Transport != "udp" {
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(Timeout))
|
||||||
|
_, err := c.conn.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(data) >= 4 && data[0] == '$' {
|
||||||
|
channel := data[1]
|
||||||
|
size := uint16(data[2])<<8 | uint16(data[3])
|
||||||
|
rtpData := data[4 : 4+size]
|
||||||
|
|
||||||
|
if _, err := c.WriteToUDP(rtpData, channel); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data = data[4+size:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package shell
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
@@ -38,39 +36,6 @@ func QuoteSplit(s string) []string {
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceEnvVars - support format ${CAMERA_PASSWORD} and ${RTSP_USER:admin}
|
|
||||||
func ReplaceEnvVars(text string) string {
|
|
||||||
re := regexp.MustCompile(`\${([^}{]+)}`)
|
|
||||||
return re.ReplaceAllStringFunc(text, func(match string) string {
|
|
||||||
key := match[2 : len(match)-1]
|
|
||||||
|
|
||||||
var def string
|
|
||||||
var dok bool
|
|
||||||
|
|
||||||
if i := strings.IndexByte(key, ':'); i > 0 {
|
|
||||||
key, def = key[:i], key[i+1:]
|
|
||||||
dok = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if dir, vok := os.LookupEnv("CREDENTIALS_DIRECTORY"); vok {
|
|
||||||
value, err := os.ReadFile(filepath.Join(dir, key))
|
|
||||||
if err == nil {
|
|
||||||
return strings.TrimSpace(string(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if value, vok := os.LookupEnv(key); vok {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
if dok {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
return match
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func RunUntilSignal() {
|
func RunUntilSignal() {
|
||||||
sigs := make(chan os.Signal, 1)
|
sigs := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|||||||
+11
-4
@@ -140,6 +140,12 @@ func (c *Client) newDectypter(res *http.Response, brand, username, password stri
|
|||||||
username = "admin"
|
username = "admin"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(exchange, `username="none"`) {
|
||||||
|
// https://nvd.nist.gov/vuln/detail/CVE-2022-37255
|
||||||
|
username = "none"
|
||||||
|
password = "TPL075526460603"
|
||||||
|
}
|
||||||
|
|
||||||
key := md5.Sum([]byte(nonce + ":" + password))
|
key := md5.Sum([]byte(nonce + ":" + password))
|
||||||
iv := md5.Sum([]byte(username + ":" + nonce))
|
iv := md5.Sum([]byte(username + ":" + nonce))
|
||||||
|
|
||||||
@@ -158,8 +164,9 @@ func (c *Client) newDectypter(res *http.Response, brand, username, password stri
|
|||||||
cbc.CryptBlocks(b, b)
|
cbc.CryptBlocks(b, b)
|
||||||
|
|
||||||
// unpad
|
// unpad
|
||||||
padSize := int(b[len(b)-1])
|
n := len(b)
|
||||||
return b[:len(b)-padSize]
|
padSize := int(b[n-1])
|
||||||
|
return b[:n-padSize]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,12 +299,12 @@ func dial(req *http.Request, brand, username, password string) (net.Conn, *http.
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
_, _ = io.Copy(io.Discard, res.Body) // discard leftovers
|
_, _ = io.Copy(io.Discard, res.Body) // discard leftovers
|
||||||
_ = res.Body.Close() // ignore response body
|
_ = res.Body.Close() // ignore response body
|
||||||
|
|
||||||
auth := res.Header.Get("WWW-Authenticate")
|
auth := res.Header.Get("WWW-Authenticate")
|
||||||
|
|
||||||
if res.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(auth, "Digest") {
|
if res.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(auth, "Digest") {
|
||||||
return nil, nil, fmt.Errorf("Expected StatusCode to be %d, received %d", http.StatusUnauthorized, res.StatusCode)
|
return nil, nil, errors.New("tapo: wrond status: " + res.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if brand == "tapo" && password == "" {
|
if brand == "tapo" && password == "" {
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ func (a *Auth) ReadNone(res *Response) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Auth) UserInfo() *url.Userinfo {
|
||||||
|
return url.UserPassword(a.user, a.pass)
|
||||||
|
}
|
||||||
|
|
||||||
func Between(s, sub1, sub2 string) string {
|
func Between(s, sub1, sub2 string) string {
|
||||||
i := strings.Index(s, sub1)
|
i := strings.Index(s, sub1)
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
|
|||||||
+31
-78
@@ -1,94 +1,47 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -e # Exit immediately if a command exits with a non-zero status.
|
||||||
|
set -u # Treat unset variables as an error when substituting.
|
||||||
|
|
||||||
check_command() {
|
check_command() {
|
||||||
if ! command -v $1 &> /dev/null
|
if ! command -v "$1" >/dev/null
|
||||||
then
|
then
|
||||||
echo "Error: $1 could not be found. Please install it."
|
echo "Error: $1 could not be found. Please install it." >&2
|
||||||
exit 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check for required commands
|
build_zip() {
|
||||||
|
go build -ldflags "-s -w" -trimpath -o $2
|
||||||
|
7z a -mx9 -sdel $1 $2
|
||||||
|
}
|
||||||
|
|
||||||
|
build_upx() {
|
||||||
|
go build -ldflags "-s -w" -trimpath -o $1
|
||||||
|
upx --best --lzma $1
|
||||||
|
}
|
||||||
|
|
||||||
check_command go
|
check_command go
|
||||||
check_command 7z
|
check_command 7z
|
||||||
check_command upx
|
check_command upx
|
||||||
|
|
||||||
# Windows amd64
|
export CGO_ENABLED=0
|
||||||
export GOOS=windows
|
|
||||||
export GOARCH=amd64
|
|
||||||
FILENAME="go2rtc_win64.zip"
|
|
||||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc.exe
|
|
||||||
|
|
||||||
# Windows 386
|
set -x # Print commands and their arguments as they are executed.
|
||||||
export GOOS=windows
|
|
||||||
export GOARCH=386
|
|
||||||
FILENAME="go2rtc_win32.zip"
|
|
||||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc.exe
|
|
||||||
|
|
||||||
# Windows arm64
|
GOOS=windows GOARCH=amd64 build_zip go2rtc_win64.zip go2rtc.exe
|
||||||
export GOOS=windows
|
GOOS=windows GOARCH=386 build_zip go2rtc_win32.zip go2rtc.exe
|
||||||
export GOARCH=arm64
|
GOOS=windows GOARCH=arm64 build_zip go2rtc_win_arm64.zip go2rtc.exe
|
||||||
FILENAME="go2rtc_win_arm64.zip"
|
|
||||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc.exe
|
|
||||||
|
|
||||||
# Linux amd64
|
GOOS=linux GOARCH=amd64 build_upx go2rtc_linux_amd64
|
||||||
export GOOS=linux
|
GOOS=linux GOARCH=386 build_upx go2rtc_linux_i386
|
||||||
export GOARCH=amd64
|
GOOS=linux GOARCH=arm64 build_upx go2rtc_linux_arm64
|
||||||
FILENAME="go2rtc_linux_amd64"
|
GOOS=linux GOARCH=mipsle build_upx go2rtc_linux_mipsel
|
||||||
go build -ldflags "-s -w" -trimpath -o $FILENAME && upx --lzma --force-overwrite -q --no-progress $FILENAME
|
GOOS=linux GOARCH=arm GOARM=7 build_upx go2rtc_linux_arm
|
||||||
|
GOOS=linux GOARCH=arm GOARM=6 build_upx go2rtc_linux_armv6
|
||||||
|
|
||||||
# Linux 386
|
GOOS=darwin GOARCH=amd64 build_zip go2rtc_mac_amd64.zip go2rtc
|
||||||
export GOOS=linux
|
GOOS=darwin GOARCH=arm64 build_zip go2rtc_mac_arm64.zip go2rtc
|
||||||
export GOARCH=386
|
|
||||||
FILENAME="go2rtc_linux_i386"
|
|
||||||
go build -ldflags "-s -w" -trimpath -o $FILENAME && upx --lzma --force-overwrite -q --no-progress $FILENAME
|
|
||||||
|
|
||||||
# Linux arm64
|
GOOS=freebsd GOARCH=amd64 build_zip go2rtc_freebsd_amd64.zip go2rtc
|
||||||
export GOOS=linux
|
GOOS=freebsd GOARCH=arm64 build_zip go2rtc_freebsd_arm64.zip go2rtc
|
||||||
export GOARCH=arm64
|
|
||||||
FILENAME="go2rtc_linux_arm64"
|
|
||||||
go build -ldflags "-s -w" -trimpath -o $FILENAME && upx --lzma --force-overwrite -q --no-progress $FILENAME
|
|
||||||
|
|
||||||
# Linux arm v7
|
|
||||||
export GOOS=linux
|
|
||||||
export GOARCH=arm
|
|
||||||
export GOARM=7
|
|
||||||
FILENAME="go2rtc_linux_arm"
|
|
||||||
go build -ldflags "-s -w" -trimpath -o $FILENAME && upx --lzma --force-overwrite -q --no-progress $FILENAME
|
|
||||||
|
|
||||||
# Linux arm v6
|
|
||||||
export GOOS=linux
|
|
||||||
export GOARCH=arm
|
|
||||||
export GOARM=6
|
|
||||||
FILENAME="go2rtc_linux_armv6"
|
|
||||||
go build -ldflags "-s -w" -trimpath -o $FILENAME && upx --lzma --force-overwrite -q --no-progress $FILENAME
|
|
||||||
|
|
||||||
# Linux mipsle
|
|
||||||
export GOOS=linux
|
|
||||||
export GOARCH=mipsle
|
|
||||||
FILENAME="go2rtc_linux_mipsel"
|
|
||||||
go build -ldflags "-s -w" -trimpath -o $FILENAME && upx --lzma --force-overwrite -q --no-progress $FILENAME
|
|
||||||
|
|
||||||
# Darwin amd64
|
|
||||||
export GOOS=darwin
|
|
||||||
export GOARCH=amd64
|
|
||||||
FILENAME="go2rtc_mac_amd64.zip"
|
|
||||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc
|
|
||||||
|
|
||||||
# Darwin arm64
|
|
||||||
export GOOS=darwin
|
|
||||||
export GOARCH=arm64
|
|
||||||
FILENAME="go2rtc_mac_arm64.zip"
|
|
||||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc
|
|
||||||
|
|
||||||
# FreeBSD amd64
|
|
||||||
export GOOS=freebsd
|
|
||||||
export GOARCH=amd64
|
|
||||||
FILENAME="go2rtc_freebsd_amd64.zip"
|
|
||||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc
|
|
||||||
|
|
||||||
# FreeBSD arm64
|
|
||||||
export GOOS=freebsd
|
|
||||||
export GOARCH=arm64
|
|
||||||
FILENAME="go2rtc_freebsd_arm64.zip"
|
|
||||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc
|
|
||||||
|
|||||||
+320
-319
@@ -1,41 +1,37 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>go2rtc - Add Stream</title>
|
<title>go2rtc - Add Stream</title>
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
main > button {
|
||||||
margin: 0;
|
background-color: #444;
|
||||||
padding: 0;
|
color: white;
|
||||||
display: flex;
|
cursor: pointer;
|
||||||
flex-direction: column;
|
padding: 14px;
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module {
|
main > div {
|
||||||
display: none;
|
display: none;
|
||||||
padding: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
table tbody td {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<script src="main.js"></script>
|
<script src="main.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function drawTable(table, data) {
|
function drawTable(table, data) {
|
||||||
const cols = ['id', 'name', 'info', 'url', 'location'];
|
const cols = ['id', 'name', 'info', 'url', 'location'];
|
||||||
const th = (row) => cols.reduce((html, k) => k in row ? `${html}<th>${k}</th>` : html, '<tr>') + '</tr>';
|
const th = (row) => cols.reduce((html, k) => k in row ? `${html}<th>${k}</th>` : html, '<tr>') + '</tr>';
|
||||||
const td = (row) => cols.reduce((html, k) => k in row ? `${html}<td style="word-break: break-word;white-space: normal;">${row[k]}</td>` : html, '<tr>') + '</tr>';
|
const td = (row) => cols.reduce((html, k) => k in row ? `${html}<td style="word-break: break-word; white-space: normal;">${row[k]}</td>` : html, '<tr>') + '</tr>';
|
||||||
|
|
||||||
const thead = th(data.sources[0]);
|
const thead = th(data.sources[0]);
|
||||||
const tbody = data.sources.reduce((html, source) => `${html}${td(source)}`, '');
|
const tbody = data.sources.reduce((html, source) => `${html}${td(source)}`, '');
|
||||||
@@ -57,325 +53,330 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
<button id="stream">Temporary stream</button>
|
<button id="stream">Temporary stream</button>
|
||||||
<div class="module">
|
<div>
|
||||||
<form id="stream-form" style="padding: 10px">
|
<form id="stream-form">
|
||||||
<input type="text" name="name" placeholder="name">
|
<input type="text" name="name" placeholder="name">
|
||||||
<input type="text" name="src" placeholder="url">
|
<input type="text" name="src" placeholder="url" required size="30">
|
||||||
<input type="submit" value="add">
|
<button type="submit">add</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('stream').addEventListener('click', async ev => {
|
document.getElementById('stream').addEventListener('click', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('stream-form').addEventListener('submit', async ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
const url = new URL('api/streams', location.href);
|
|
||||||
url.searchParams.set('name', ev.target.elements['name'].value);
|
|
||||||
url.searchParams.set('src', ev.target.elements['src'].value);
|
|
||||||
|
|
||||||
const r = await fetch(url, {method: 'PUT'});
|
|
||||||
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="alsa">ALSA (Linux audio)</button>
|
|
||||||
<div class="module">
|
|
||||||
<table id="alsa-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
document.getElementById('alsa').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
await getSources('alsa-table', 'api/alsa');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="homekit">Apple HomeKit</button>
|
|
||||||
<div class="module">
|
|
||||||
<form id="homekit-pair" style="margin-bottom: 10px">
|
|
||||||
<input type="text" name="id" placeholder="stream id" size="20">
|
|
||||||
<input type="text" name="url" placeholder="url" size="40">
|
|
||||||
<input type="text" name="pin" placeholder="pin" size="10">
|
|
||||||
<input type="submit" value="Pair">
|
|
||||||
</form>
|
|
||||||
<form id="homekit-unpair" style="margin-bottom: 10px">
|
|
||||||
<input type="text" name="id" placeholder="stream id" size="20">
|
|
||||||
<input type="submit" value="Unpair">
|
|
||||||
</form>
|
|
||||||
<table id="homekit-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
async function reloadHomeKit() {
|
|
||||||
await getSources('homekit-table', 'api/homekit');
|
|
||||||
|
|
||||||
const rows = document.querySelectorAll('#homekit-table tr');
|
|
||||||
rows.forEach((row, i) => {
|
|
||||||
let commands = '';
|
|
||||||
if (row.children[2].innerText.indexOf('status=1') > 0) {
|
|
||||||
commands += '<a href="#">pair</a>';
|
|
||||||
} else if (i > 0 && row.children[3].innerText) {
|
|
||||||
commands += '<a href="#">unpair</a>';
|
|
||||||
}
|
|
||||||
row.innerHTML += `<td>${commands}</td>`;
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('homekit').addEventListener('click', async ev => {
|
document.getElementById('stream-form').addEventListener('submit', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.preventDefault();
|
||||||
await reloadHomeKit();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('homekit-table').addEventListener('click', ev => {
|
const url = new URL('api/streams', location.href);
|
||||||
if (ev.target.innerText === 'pair') {
|
url.searchParams.set('name', ev.target.elements['name'].value);
|
||||||
const form = document.querySelector('#homekit-pair');
|
url.searchParams.set('src', ev.target.elements['src'].value);
|
||||||
const row = ev.target.closest('tr');
|
|
||||||
form.children[0].value = row.children[0].innerText;
|
|
||||||
form.children[1].value = row.children[2].innerText;
|
|
||||||
} else if (ev.target.innerText === 'unpair') {
|
|
||||||
const form = document.querySelector('#homekit-unpair');
|
|
||||||
const row = ev.target.closest('tr');
|
|
||||||
form.children[0].value = row.children[3].innerText;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('homekit-pair').addEventListener('submit', async ev => {
|
const r = await fetch(url, {method: 'PUT'});
|
||||||
ev.preventDefault();
|
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
||||||
|
});
|
||||||
const body = new FormData(ev.target);
|
</script>
|
||||||
body.set('url', body.get('url') + '&pin=' + body.get('pin'));
|
|
||||||
body.delete('pin');
|
|
||||||
|
|
||||||
const r = await fetch('api/homekit', {method: 'POST', body: body});
|
|
||||||
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
|
||||||
|
|
||||||
await reloadHomeKit();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('homekit-unpair').addEventListener('submit', async ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const r = await fetch('api/homekit', {method: 'DELETE', body: new FormData(ev.target)});
|
|
||||||
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
|
||||||
|
|
||||||
await reloadHomeKit();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="dvrip">DVRIP</button>
|
<button id="alsa">ALSA (Linux audio)</button>
|
||||||
<div class="module">
|
<div>
|
||||||
<table id="dvrip-table"></table>
|
<table id="alsa-table"></table>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('dvrip').addEventListener('click', async ev => {
|
document.getElementById('alsa').addEventListener('click', async ev => {
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
await getSources('dvrip-table', 'api/dvrip');
|
await getSources('alsa-table', 'api/alsa');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<button id="devices">FFmpeg Devices (USB)</button>
|
<button id="homekit">Apple HomeKit</button>
|
||||||
<div class="module">
|
<div>
|
||||||
<table id="devices-table"></table>
|
<form id="homekit-pair">
|
||||||
</div>
|
<input type="text" name="id" placeholder="stream id" required>
|
||||||
<script>
|
<input type="text" name="src" placeholder="src" required size="30">
|
||||||
document.getElementById('devices').addEventListener('click', async ev => {
|
<input type="text" name="pin" placeholder="pin" required size="10">
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
<button type="submit">pair</button>
|
||||||
await getSources('devices-table', 'api/ffmpeg/devices');
|
</form>
|
||||||
});
|
<form id="homekit-unpair">
|
||||||
</script>
|
<input type="text" name="id" placeholder="stream id" required>
|
||||||
|
<button type="submit">unpair</button>
|
||||||
|
</form>
|
||||||
|
<table id="homekit-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
async function reloadHomeKit() {
|
||||||
|
await getSources('homekit-table', 'api/discovery/homekit');
|
||||||
|
|
||||||
|
const rows = document.querySelectorAll('#homekit-table tr');
|
||||||
<button id="hardware">FFmpeg Hardware</button>
|
rows.forEach((row, i) => {
|
||||||
<div class="module">
|
let commands = '';
|
||||||
<table id="hardware-table"></table>
|
if (row.children[2].innerText.indexOf('status=1') > 0) {
|
||||||
</div>
|
commands += '<a href="#">pair</a>';
|
||||||
<script>
|
} else if (i > 0 && row.children[3].innerText) {
|
||||||
document.getElementById('hardware').addEventListener('click', async ev => {
|
commands += '<a href="#">unpair</a>';
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
}
|
||||||
await getSources('hardware-table', 'api/ffmpeg/hardware');
|
row.innerHTML += i > 0 ? `<td>${commands}</td>` : '<th>commands</th>';
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="nest">Google Nest</button>
|
|
||||||
<div class="module">
|
|
||||||
<form id="nest-form" style="margin-bottom: 10px">
|
|
||||||
<input type="text" name="client_id" placeholder="client_id">
|
|
||||||
<input type="text" name="client_secret" placeholder="client_secret">
|
|
||||||
<input type="text" name="refresh_token" placeholder="refresh_token">
|
|
||||||
<input type="text" name="project_id" placeholder="project_id">
|
|
||||||
<input type="submit" value="Login">
|
|
||||||
</form>
|
|
||||||
<table id="nest-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
document.getElementById('nest').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('nest-form').addEventListener('submit', async ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
const query = new URLSearchParams(new FormData(ev.target));
|
|
||||||
const url = new URL('api/nest?' + query.toString(), location.href);
|
|
||||||
|
|
||||||
const r = await fetch(url, {cache: 'no-cache'});
|
|
||||||
await getSources('nest-table', r);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button id="ring">Ring</button>
|
|
||||||
<div class="module">
|
|
||||||
<form id="ring-credentials-form" style="margin-bottom: 10px">
|
|
||||||
<input type="email" name="email" placeholder="email">
|
|
||||||
<input type="password" name="password" placeholder="password">
|
|
||||||
<div id="tfa-field" style="display: none">
|
|
||||||
<input type="text" name="code" placeholder="2FA code">
|
|
||||||
<div id="tfa-prompt"></div>
|
|
||||||
</div>
|
|
||||||
<input type="submit" value="Login">
|
|
||||||
</form>
|
|
||||||
<form id="ring-token-form" style="margin-bottom: 10px">
|
|
||||||
<input type="text" name="refresh_token" placeholder="refresh_token">
|
|
||||||
<input type="submit" value="Login">
|
|
||||||
</form>
|
|
||||||
<table id="ring-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
document.getElementById('ring').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleRingAuth(ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
const query = new URLSearchParams(new FormData(ev.target));
|
|
||||||
const url = new URL('api/ring?' + query.toString(), location.href);
|
|
||||||
|
|
||||||
const r = await fetch(url, {cache: 'no-cache'});
|
|
||||||
const data = await r.json();
|
|
||||||
|
|
||||||
if (data.needs_2fa) {
|
|
||||||
document.getElementById('tfa-field').style.display = 'block';
|
|
||||||
document.getElementById('tfa-prompt').textContent = data.prompt || 'Enter 2FA code';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!r.ok) {
|
document.getElementById('homekit').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await reloadHomeKit();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('homekit-table').addEventListener('click', ev => {
|
||||||
|
if (ev.target.innerText === 'pair') {
|
||||||
|
const form = document.querySelector('#homekit-pair');
|
||||||
|
const row = ev.target.closest('tr');
|
||||||
|
form.children[0].value = row.children[0].innerText;
|
||||||
|
form.children[1].value = row.children[2].innerText;
|
||||||
|
} else if (ev.target.innerText === 'unpair') {
|
||||||
|
const form = document.querySelector('#homekit-unpair');
|
||||||
|
const row = ev.target.closest('tr');
|
||||||
|
form.children[0].value = row.children[3].innerText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('homekit-pair').addEventListener('submit', async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const params = new URLSearchParams(new FormData(ev.target));
|
||||||
|
const r = await fetch('api/homekit', {method: 'POST', body: params});
|
||||||
|
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
||||||
|
|
||||||
|
await reloadHomeKit();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('homekit-unpair').addEventListener('submit', async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const params = new URLSearchParams(new FormData(ev.target));
|
||||||
|
const r = await fetch('api/homekit?' + params.toString(), {method: 'DELETE'});
|
||||||
|
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
||||||
|
|
||||||
|
await reloadHomeKit();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="dvrip">DVRIP</button>
|
||||||
|
<div>
|
||||||
|
<table id="dvrip-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('dvrip').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('dvrip-table', 'api/dvrip');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="devices">FFmpeg Devices (USB)</button>
|
||||||
|
<div>
|
||||||
|
<table id="devices-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('devices').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('devices-table', 'api/ffmpeg/devices');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="hardware">FFmpeg Hardware</button>
|
||||||
|
<div>
|
||||||
|
<table id="hardware-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('hardware').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('hardware-table', 'api/ffmpeg/hardware');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="nest">Google Nest</button>
|
||||||
|
<div>
|
||||||
|
<form id="nest-form">
|
||||||
|
<input type="text" name="client_id" placeholder="client_id" required>
|
||||||
|
<input type="text" name="client_secret" placeholder="client_secret" required>
|
||||||
|
<input type="text" name="refresh_token" placeholder="refresh_token" required>
|
||||||
|
<input type="text" name="project_id" placeholder="project_id" required>
|
||||||
|
<button type="submit">login</button>
|
||||||
|
</form>
|
||||||
|
<table id="nest-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('nest').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('nest-form').addEventListener('submit', async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const query = new URLSearchParams(new FormData(ev.target));
|
||||||
|
const url = new URL('api/nest?' + query.toString(), location.href);
|
||||||
|
|
||||||
|
const r = await fetch(url, {cache: 'no-cache'});
|
||||||
|
await getSources('nest-table', r);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button id="gopro">GoPro</button>
|
||||||
|
<div>
|
||||||
|
<table id="gopro-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('gopro').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('gopro-table', 'api/gopro');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="hass">Home Assistant</button>
|
||||||
|
<div>
|
||||||
|
<table id="hass-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('hass').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('hass-table', 'api/hass');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="onvif">ONVIF</button>
|
||||||
|
<div>
|
||||||
|
<form id="onvif-form">
|
||||||
|
<input type="text" name="src" placeholder="onvif://user:pass@192.168.1.123:80" required size="30">
|
||||||
|
<button type="submit">test</button>
|
||||||
|
</form>
|
||||||
|
<table id="onvif-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('onvif').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('onvif-table', 'api/onvif');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('onvif-form').addEventListener('submit', async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const url = new URL('api/onvif', location.href);
|
||||||
|
url.searchParams.set('src', ev.target.elements['src'].value);
|
||||||
|
|
||||||
|
await getSources('onvif-table', url.toString());
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="ring">Ring</button>
|
||||||
|
<div>
|
||||||
|
<form id="ring-credentials-form">
|
||||||
|
<input type="email" name="email" placeholder="email" required>
|
||||||
|
<input type="password" name="password" placeholder="password" required>
|
||||||
|
<div id="tfa-field" style="display: none">
|
||||||
|
<input type="text" name="code" placeholder="2FA code">
|
||||||
|
<div id="tfa-prompt"></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit">login</button>
|
||||||
|
</form>
|
||||||
|
<form id="ring-token-form">
|
||||||
|
<input type="text" name="refresh_token" placeholder="refresh_token" required>
|
||||||
|
<button type="submit">login</button>
|
||||||
|
</form>
|
||||||
|
<table id="ring-table"></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('ring').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleRingAuth(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
const table = document.getElementById('ring-table');
|
const table = document.getElementById('ring-table');
|
||||||
table.innerText = data.error || 'Unknown error';
|
table.innerText = 'loading...';
|
||||||
return;
|
|
||||||
|
const query = new URLSearchParams(new FormData(ev.target));
|
||||||
|
const url = new URL('api/ring?' + query.toString(), location.href);
|
||||||
|
|
||||||
|
const r = await fetch(url, {cache: 'no-cache'});
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
table.innerText = (await r.text()) || 'Unknown error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await r.json();
|
||||||
|
|
||||||
|
table.innerText = '';
|
||||||
|
|
||||||
|
if (data.needs_2fa) {
|
||||||
|
document.getElementById('tfa-field').style.display = 'block';
|
||||||
|
document.getElementById('tfa-prompt').textContent = data.prompt || 'Enter 2FA code';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTable(table, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const table = document.getElementById('ring-table');
|
document.getElementById('ring-credentials-form').addEventListener('submit', handleRingAuth);
|
||||||
drawTable(table, data);
|
document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth);
|
||||||
}
|
</script>
|
||||||
|
|
||||||
document.getElementById('ring-credentials-form').addEventListener('submit', handleRingAuth);
|
|
||||||
document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button id="gopro">GoPro</button>
|
|
||||||
<div class="module">
|
|
||||||
<table id="gopro-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
document.getElementById('gopro').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
await getSources('gopro-table', 'api/gopro');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="hass">Home Assistant</button>
|
<button id="roborock">Roborock</button>
|
||||||
<div class="module">
|
<div>
|
||||||
<table id="hass-table"></table>
|
<form id="roborock-form">
|
||||||
</div>
|
<input type="text" name="username" placeholder="username" required>
|
||||||
<script>
|
<input type="password" name="password" placeholder="password" required>
|
||||||
document.getElementById('hass').addEventListener('click', async ev => {
|
<button type="submit">login</button>
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
</form>
|
||||||
await getSources('hass-table', 'api/hass');
|
<table id="roborock-table">
|
||||||
});
|
</table>
|
||||||
</script>
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('roborock').addEventListener('click', async ev => {
|
||||||
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
|
await getSources('roborock-table', 'api/roborock');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('roborock-form').addEventListener('submit', async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const r = await fetch('api/roborock', {method: 'POST', body: new FormData(ev.target)});
|
||||||
|
await getSources('roborock-table', r);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<button id="onvif">ONVIF</button>
|
<button id="v4l2">V4L2 (Linux video)</button>
|
||||||
<div class="module">
|
<div>
|
||||||
<form id="onvif-form" style="padding: 10px">
|
<table id="v4l2-table"></table>
|
||||||
<input type="text" name="src" placeholder="onvif://user:pass@192.168.1.123:80" size="50">
|
</div>
|
||||||
<input type="submit" value="test">
|
<script>
|
||||||
</form>
|
document.getElementById('v4l2').addEventListener('click', async ev => {
|
||||||
<table id="onvif-table"></table>
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
</div>
|
await getSources('v4l2-table', 'api/v4l2');
|
||||||
<script>
|
});
|
||||||
document.getElementById('onvif').addEventListener('click', async ev => {
|
</script>
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
await getSources('onvif-table', 'api/onvif');
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('onvif-form').addEventListener('submit', async ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
const url = new URL('api/onvif', location.href);
|
|
||||||
url.searchParams.set('src', ev.target.elements['src'].value);
|
|
||||||
|
|
||||||
await getSources('onvif-table', url.toString());
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="roborock">Roborock</button>
|
<button id="webtorrent">WebTorrent Shares</button>
|
||||||
<div class="module">
|
<div>
|
||||||
<form id="roborock-form" style="margin-bottom: 10px">
|
<table id="webtorrent-table"></table>
|
||||||
<input type="text" name="username" placeholder="username">
|
</div>
|
||||||
<input type="password" name="password" placeholder="password">
|
<script>
|
||||||
<input type="submit" value="Login">
|
document.getElementById('webtorrent').addEventListener('click', async ev => {
|
||||||
</form>
|
ev.target.nextElementSibling.style.display = 'grid';
|
||||||
<table id="roborock-table">
|
await getSources('webtorrent-table', 'api/webtorrent');
|
||||||
</table>
|
});
|
||||||
</div>
|
</script>
|
||||||
<script>
|
</main>
|
||||||
document.getElementById('roborock').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
await getSources('roborock-table', 'api/roborock');
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('roborock-form').addEventListener('submit', async ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const r = await fetch('api/roborock', {method: 'POST', body: new FormData(ev.target)});
|
|
||||||
await getSources('roborock-table', r);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="v4l2">V4L2 (Linux video)</button>
|
|
||||||
<div class="module">
|
|
||||||
<table id="v4l2-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
document.getElementById('v4l2').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
await getSources('v4l2-table', 'api/v4l2');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<button id="webtorrent">WebTorrent Shares</button>
|
|
||||||
<div class="module">
|
|
||||||
<table id="webtorrent-table"></table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
document.getElementById('webtorrent').addEventListener('click', async ev => {
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
|
||||||
await getSources('webtorrent-table', 'api/webtorrent');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,41 +1,36 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>go2rtc - File Editor</title>
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
<title>go2rtc - Config</title>
|
||||||
<script src="https://unpkg.com/ace-builds@1.33.1/src-min/ace.js"></script>
|
<script src="https://unpkg.com/ace-builds@1.33.1/src-min/ace.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
background-color: white;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body, #config {
|
html, body, #config {
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="main.js"></script>
|
|
||||||
<div>
|
|
||||||
<button id="save">Save & Restart</button>
|
|
||||||
</div>
|
|
||||||
<br>
|
|
||||||
<div id="config"></div>
|
|
||||||
<script>
|
|
||||||
let dump;
|
|
||||||
|
|
||||||
|
<script src="main.js"></script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div>
|
||||||
|
<button id="save">Save & Restart</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<div id="config"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* global ace */
|
||||||
ace.config.set('basePath', 'https://unpkg.com/ace-builds@1.33.1/src-min/');
|
ace.config.set('basePath', 'https://unpkg.com/ace-builds@1.33.1/src-min/');
|
||||||
const editor = ace.edit('config', {
|
const editor = ace.edit('config', {
|
||||||
mode: 'ace/mode/yaml',
|
mode: 'ace/mode/yaml',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let dump;
|
||||||
|
|
||||||
document.getElementById('save').addEventListener('click', async () => {
|
document.getElementById('save').addEventListener('click', async () => {
|
||||||
let r = await fetch('api/config', {cache: 'no-cache'});
|
let r = await fetch('api/config', {cache: 'no-cache'});
|
||||||
if (r.ok && dump !== await r.text()) {
|
if (r.ok && dump !== await r.text()) {
|
||||||
@@ -67,5 +62,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+34
-45
@@ -1,61 +1,49 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
||||||
<link rel="apple-touch-icon" href="https://alexxit.github.io/go2rtc/icons/apple-touch-icon-180x180.png" sizes="180x180">
|
|
||||||
<link rel="icon" href="https://alexxit.github.io/go2rtc/icons/favicon.ico">
|
|
||||||
<link rel="manifest" href="https://alexxit.github.io/go2rtc/manifest.json">
|
|
||||||
<title>go2rtc</title>
|
<title>go2rtc</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
.controls {
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
table tbody td {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.info {
|
||||||
display: flex;
|
color: #888;
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls > label {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<script src="main.js"></script>
|
<script src="main.js"></script>
|
||||||
<div class="info"></div>
|
|
||||||
<div class="controls">
|
<main>
|
||||||
<button>stream</button>
|
<div class="controls">
|
||||||
<label><input type="checkbox" name="webrtc" checked>webrtc</label>
|
<button>stream</button>
|
||||||
<label><input type="checkbox" name="mse" checked>mse</label>
|
modes
|
||||||
<label><input type="checkbox" name="hls" checked>hls</label>
|
<label><input type="checkbox" name="webrtc" checked>webrtc</label>
|
||||||
<label><input type="checkbox" name="mjpeg" checked>mjpeg</label>
|
<label><input type="checkbox" name="mse" checked>mse</label>
|
||||||
</div>
|
<label><input type="checkbox" name="hls" checked>hls</label>
|
||||||
<table>
|
<label><input type="checkbox" name="mjpeg" checked>mjpeg</label>
|
||||||
<thead>
|
</div>
|
||||||
<tr>
|
<table>
|
||||||
<th><label><input id="selectall" type="checkbox">Name</label></th>
|
<thead>
|
||||||
<th>Online</th>
|
<tr>
|
||||||
<th>Commands</th>
|
<th><label><input id="selectall" type="checkbox">name</label></th>
|
||||||
</tr>
|
<th>online</th>
|
||||||
</thead>
|
<th>commands</th>
|
||||||
<tbody id="streams">
|
</tr>
|
||||||
</tbody>
|
</thead>
|
||||||
</table>
|
<tbody id="streams">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="info"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const templates = [
|
const templates = [
|
||||||
'<a href="stream.html?src={name}">stream</a>',
|
'<a href="stream.html?src={name}">stream</a>',
|
||||||
@@ -159,10 +147,11 @@
|
|||||||
const url = new URL('api', location.href);
|
const url = new URL('api', location.href);
|
||||||
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||||
const info = document.querySelector('.info');
|
const info = document.querySelector('.info');
|
||||||
info.innerText = `Version: ${data.version}, Config: ${data.config_path}`;
|
info.innerText = `version: ${data.version} / config: ${data.config_path}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
reload();
|
reload();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+168
-163
@@ -1,27 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>go2rtc - links</title>
|
<title>go2rtc - links</title>
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
div {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div > li {
|
div > li {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
@@ -36,28 +19,33 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="main.js"></script>
|
|
||||||
<div id="links"></div>
|
|
||||||
<script>
|
|
||||||
const src = new URLSearchParams(location.search).get('src').replace(/[<">]/g, ''); // sanitize
|
|
||||||
|
|
||||||
document.getElementById('links').innerHTML = `
|
<script src="main.js"></script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="links"></div>
|
||||||
|
<script>
|
||||||
|
const src = new URLSearchParams(location.search).get('src').replace(/[<">]/g, ''); // sanitize
|
||||||
|
|
||||||
|
const links = document.getElementById('links');
|
||||||
|
|
||||||
|
links.innerHTML = `
|
||||||
<h2>Any codec in source</h2>
|
<h2>Any codec in source</h2>
|
||||||
<li><a href="stream.html?src=${src}">stream.html</a> with auto-select mode / browsers: all / codecs: H264, H265*, MJPEG, JPEG, AAC, PCMU, PCMA, OPUS</li>
|
<li><a href="stream.html?src=${src}">stream.html</a> with auto-select mode / browsers: all / codecs: H264, H265*, MJPEG, JPEG, AAC, PCMU, PCMA, OPUS</li>
|
||||||
<li><a href="api/streams?src=${src}">info.json</a> page with active connections</li>
|
<li><a href="api/streams?src=${src}">info.json</a> page with active connections</li>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const url = new URL('api', location.href);
|
const url = new URL('api', location.href);
|
||||||
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||||
let rtsp = location.host + ':8554';
|
let rtsp = location.host + ':8554';
|
||||||
try {
|
try {
|
||||||
const host = data.host.match(/^[^:]+/)[0];
|
const host = data.host.match(/^[^:]+/)[0];
|
||||||
const port = data.rtsp.listen.match(/[0-9]+$/)[0];
|
const port = data.rtsp.listen.match(/[0-9]+$/)[0];
|
||||||
rtsp = `${host}:${port}`;
|
rtsp = `${host}:${port}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('links').innerHTML += `
|
links.innerHTML += `
|
||||||
<li><a href="rtsp://${rtsp}/${src}">rtsp</a> with only one video and one audio / codecs: any</li>
|
<li><a href="rtsp://${rtsp}/${src}">rtsp</a> with only one video and one audio / codecs: any</li>
|
||||||
<li><a href="rtsp://${rtsp}/${src}?mp4">rtsp</a> for MP4 recording (Hass or Frigate) / codecs: H264, H265, AAC</li>
|
<li><a href="rtsp://${rtsp}/${src}?mp4">rtsp</a> for MP4 recording (Hass or Frigate) / codecs: H264, H265, AAC</li>
|
||||||
<li><a href="rtsp://${rtsp}/${src}?video=all&audio=all">rtsp</a> with all tracks / codecs: any</li>
|
<li><a href="rtsp://${rtsp}/${src}?video=all&audio=all">rtsp</a> with all tracks / codecs: any</li>
|
||||||
@@ -80,148 +68,165 @@
|
|||||||
<li><a href="api/stream.mjpeg?src=${src}">stream.mjpeg</a> MJPEG stream / browsers: all / codecs: MJPEG, JPEG</li>
|
<li><a href="api/stream.mjpeg?src=${src}">stream.mjpeg</a> MJPEG stream / browsers: all / codecs: MJPEG, JPEG</li>
|
||||||
<li><a href="api/frame.jpeg?src=${src}">frame.jpeg</a> snapshot in JPEG-format / browsers: all / codecs: MJPEG, JPEG</li>
|
<li><a href="api/frame.jpeg?src=${src}">frame.jpeg</a> snapshot in JPEG-format / browsers: all / codecs: MJPEG, JPEG</li>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2>Play audio</h2>
|
<h2>Play audio</h2>
|
||||||
<label><input type="radio" name="play" value="file" checked>file - play remote (https://example.com/song.mp3) or local (/media/song.mp3) file</label><br>
|
<label><input type="radio" name="play" value="file" checked>
|
||||||
<label><input type="radio" name="play" value="live">live - play remote live stream (radio, etc.)</label><br>
|
file - play remote (https://example.com/song.mp3) or local (/media/song.mp3) file
|
||||||
<label><input type="radio" name="play" value="text">text - play Text To Speech (if your FFmpeg support this)</label><br>
|
</label>
|
||||||
<br>
|
<label><input type="radio" name="play" value="live">
|
||||||
<input id="play-url" type="text" placeholder="path / url / text">
|
live - play remote live stream (radio, etc.)
|
||||||
<a id="play-send" href="#">send</a> / cameras with two way audio support
|
</label>
|
||||||
</div>
|
<label><input type="radio" name="play" value="text">
|
||||||
<script>
|
text - play Text To Speech (if your FFmpeg support this)
|
||||||
document.getElementById('play-send').addEventListener('click', ev => {
|
</label>
|
||||||
ev.preventDefault();
|
<br>
|
||||||
// action - file / live / text
|
<input id="play-url" type="text" placeholder="path / url / text">
|
||||||
const action = document.querySelector('input[name="play"]:checked').value;
|
<button id="play-send">send</button>
|
||||||
const url = new URL('api/ffmpeg', location.href);
|
/ cameras with two way audio support
|
||||||
url.searchParams.set('dst', src);
|
</div>
|
||||||
url.searchParams.set(action, document.getElementById('play-url').value);
|
<script>
|
||||||
fetch(url, {method: 'POST'});
|
document.getElementById('play-send').addEventListener('click', ev => {
|
||||||
});
|
ev.preventDefault();
|
||||||
</script>
|
// action - file / live / text
|
||||||
|
const action = document.querySelector('input[name="play"]:checked').value;
|
||||||
|
const url = new URL('api/ffmpeg', location.href);
|
||||||
|
url.searchParams.set('dst', src);
|
||||||
|
url.searchParams.set(action, document.getElementById('play-url').value);
|
||||||
|
fetch(url, {method: 'POST'});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2>Publish stream</h2>
|
<h2>Publish stream</h2>
|
||||||
<pre>YouTube: rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
|
<pre>YouTube: rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
|
||||||
Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx</pre>
|
Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx</pre>
|
||||||
<input id="pub-url" type="text" placeholder="url">
|
<input id="pub-url" type="text" placeholder="url">
|
||||||
<a id="pub-send" href="#">send</a> / Telegram RTMPS server
|
<button id="pub-send">send</button>
|
||||||
</div>
|
/ Telegram RTMPS server
|
||||||
<script>
|
</div>
|
||||||
document.getElementById('pub-send').addEventListener('click', ev => {
|
<script>
|
||||||
ev.preventDefault();
|
document.getElementById('pub-send').addEventListener('click', ev => {
|
||||||
const url = new URL('api/streams', location.href);
|
ev.preventDefault();
|
||||||
url.searchParams.set('src', src);
|
const url = new URL('api/streams', location.href);
|
||||||
url.searchParams.set('dst', document.getElementById('pub-url').value);
|
url.searchParams.set('src', src);
|
||||||
fetch(url, {method: 'POST'});
|
url.searchParams.set('dst', document.getElementById('pub-url').value);
|
||||||
});
|
fetch(url, {method: 'POST'});
|
||||||
</script>
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<div id="webrtc">
|
<div id="webrtc">
|
||||||
<h2>WebRTC Magic</h2>
|
<h2>WebRTC Magic</h2>
|
||||||
<label><input type="radio" name="webrtc" value="video+audio" checked>video+audio = simple viewer</label><br>
|
<label><input type="radio" name="webrtc" value="video+audio" checked>
|
||||||
<label><input type="radio" name="webrtc" value="video+audio+microphone">video+audio+microphone = two way audio from camera</label><br>
|
video+audio = simple viewer
|
||||||
<label><input type="radio" name="webrtc" value="camera+microphone">camera+microphone = stream from browser</label><br>
|
</label>
|
||||||
<label><input type="radio" name="webrtc" value="display+speaker">display+speaker = broadcast software</label><br>
|
<label><input type="radio" name="webrtc" value="video+audio+microphone">
|
||||||
|
video+audio+microphone = two way audio from camera
|
||||||
|
</label>
|
||||||
|
<label><input type="radio" name="webrtc" value="camera+microphone">
|
||||||
|
camera+microphone = stream from browser
|
||||||
|
</label>
|
||||||
|
<label><input type="radio" name="webrtc" value="display+speaker">
|
||||||
|
display+speaker = broadcast software
|
||||||
|
</label>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
<li><a id="local" href="webrtc.html?src=">webrtc.html</a> local WebRTC viewer</li>
|
<li><a id="local" href="webrtc.html?src=">webrtc.html</a> local WebRTC viewer</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a id="shareadd" href="#">share link</a>
|
<a id="shareadd" href="#">share link</a>
|
||||||
<a id="shareget" href="#">copy link</a>
|
<a id="shareget" href="#">copy link</a>
|
||||||
<a id="sharedel" href="#">delete</a>
|
<a id="sharedel" href="#">delete</a>
|
||||||
external WebRTC viewer
|
external WebRTC viewer
|
||||||
</li>
|
</li>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
function webrtcLinksUpdate() {
|
function webrtcLinksUpdate() {
|
||||||
const media = document.querySelector('input[name="webrtc"]:checked').value;
|
const media = document.querySelector('input[name="webrtc"]:checked').value;
|
||||||
|
|
||||||
const direction = media.indexOf('video') >= 0 || media === 'audio' ? 'src' : 'dst';
|
const direction = media.indexOf('video') >= 0 || media === 'audio' ? 'src' : 'dst';
|
||||||
document.getElementById('local').href = `webrtc.html?${direction}=${src}&media=${media}`;
|
document.getElementById('local').href = `webrtc.html?${direction}=${src}&media=${media}`;
|
||||||
|
|
||||||
const share = document.getElementById('shareget');
|
const share = document.getElementById('shareget');
|
||||||
share.href = `https://alexxit.github.io/go2rtc/#${share.dataset.auth}&media=${media}`;
|
share.href = `https://alexxit.github.io/go2rtc/#${share.dataset.auth}&media=${media}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function share(method) {
|
function share(method) {
|
||||||
const url = new URL('api/webtorrent', location.href);
|
const url = new URL('api/webtorrent', location.href);
|
||||||
url.searchParams.set('src', src);
|
url.searchParams.set('src', src);
|
||||||
return fetch(url, {method: method, cache: 'no-cache'});
|
return fetch(url, {method: method, cache: 'no-cache'});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onshareadd(r) {
|
function onshareadd(r) {
|
||||||
document.getElementById('shareget').dataset['auth'] = `share=${r.share}&pwd=${r.pwd}`;
|
document.getElementById('shareget').dataset['auth'] = `share=${r.share}&pwd=${r.pwd}`;
|
||||||
|
|
||||||
document.getElementById('shareadd').style.display = 'none';
|
document.getElementById('shareadd').style.display = 'none';
|
||||||
document.getElementById('shareget').style.display = '';
|
document.getElementById('shareget').style.display = '';
|
||||||
document.getElementById('sharedel').style.display = '';
|
document.getElementById('sharedel').style.display = '';
|
||||||
|
|
||||||
|
webrtcLinksUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onsharedel() {
|
||||||
|
document.getElementById('shareadd').style.display = '';
|
||||||
|
document.getElementById('shareget').style.display = 'none';
|
||||||
|
document.getElementById('sharedel').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyTextToClipboard(text) {
|
||||||
|
// https://web.dev/patterns/clipboard/copy-text
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text).catch(err => {
|
||||||
|
console.error(err.name, err.message);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.name, err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('shareadd').addEventListener('click', ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
share('POST').then(r => r.json()).then(r => onshareadd(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('shareget').addEventListener('click', ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
copyTextToClipboard(ev.target.href);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sharedel').addEventListener('click', ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
share('DELETE').then(() => onsharedel());
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('webrtc').addEventListener('click', ev => {
|
||||||
|
if (ev.target.tagName === 'INPUT') webrtcLinksUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
share('GET').then(r => {
|
||||||
|
if (r.ok) r.json().then(r => onshareadd(r));
|
||||||
|
else onsharedel();
|
||||||
|
});
|
||||||
|
|
||||||
webrtcLinksUpdate();
|
webrtcLinksUpdate();
|
||||||
}
|
</script>
|
||||||
|
</main>
|
||||||
function onsharedel() {
|
|
||||||
document.getElementById('shareadd').style.display = '';
|
|
||||||
document.getElementById('shareget').style.display = 'none';
|
|
||||||
document.getElementById('sharedel').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyTextToClipboard(text) {
|
|
||||||
// https://web.dev/patterns/clipboard/copy-text
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
|
||||||
navigator.clipboard.writeText(text).catch(err => {
|
|
||||||
console.error(err.name, err.message);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.style.opacity = '0';
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
|
|
||||||
textarea.focus();
|
|
||||||
textarea.select();
|
|
||||||
|
|
||||||
try {
|
|
||||||
document.execCommand('copy');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err.name, err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('shareadd').addEventListener('click', ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
share('POST').then(r => r.json()).then(r => onshareadd(r));
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('shareget').addEventListener('click', ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
copyTextToClipboard(ev.target.href);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('sharedel').addEventListener('click', ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
share('DELETE').then(() => onsharedel());
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('webrtc').addEventListener('click', ev => {
|
|
||||||
if (ev.target.tagName === 'INPUT') webrtcLinksUpdate();
|
|
||||||
});
|
|
||||||
|
|
||||||
share('GET').then(r => {
|
|
||||||
if (r.ok) r.json().then(r => onshareadd(r));
|
|
||||||
else onsharedel();
|
|
||||||
});
|
|
||||||
|
|
||||||
webrtcLinksUpdate();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+42
-46
@@ -1,69 +1,64 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>go2rtc - Logs</title>
|
<title>go2rtc - Logs</title>
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
main > div {
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
background-color: white;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
table tbody {
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
table tbody td {
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
color: #0174DF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug {
|
|
||||||
color: #808080;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #DF0101;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.trace {
|
.trace {
|
||||||
color: #585858;
|
color: #585858 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug {
|
||||||
|
color: #808080 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
color: #0174DF !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.warn {
|
.warn {
|
||||||
color: #FF9966;
|
color: #FF9966 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #DF0101 !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<script src="main.js"></script>
|
<script src="main.js"></script>
|
||||||
<div>
|
|
||||||
<button id="clean">Clean</button>
|
<main>
|
||||||
<button id="update">Auto Update: ON</button>
|
<div>
|
||||||
<button id="reverse">Reverse Log Order: OFF</button>
|
<button id="clean">Clean</button>
|
||||||
</div>
|
<button id="update">Auto Update: ON</button>
|
||||||
<br>
|
<button id="reverse">Reverse Log Order: OFF</button>
|
||||||
<table>
|
</div>
|
||||||
<thead>
|
<table>
|
||||||
<tr>
|
<thead>
|
||||||
<th style="width: 100px">Time</th>
|
<tr>
|
||||||
<th style="width: 40px">Level</th>
|
<th style="width: 100px">Time</th>
|
||||||
<th>Message</th>
|
<th style="width: 40px">Level</th>
|
||||||
</tr>
|
<th>Message</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody id="log">
|
</thead>
|
||||||
</tbody>
|
<tbody id="log">
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('clean').addEventListener('click', async () => {
|
document.getElementById('clean').addEventListener('click', async () => {
|
||||||
const r = await fetch('api/log', {method: 'DELETE'});
|
const r = await fetch('api/log', {method: 'DELETE'});
|
||||||
@@ -145,5 +140,6 @@
|
|||||||
if (autoUpdateEnabled) reload();
|
if (autoUpdateEnabled) reload();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+114
-180
@@ -1,200 +1,134 @@
|
|||||||
// main menu
|
document.head.innerHTML += `
|
||||||
document.body.innerHTML = `
|
|
||||||
<style>
|
<style>
|
||||||
ul {
|
body {
|
||||||
list-style: none;
|
display: flex;
|
||||||
margin: 0 auto;
|
flex-direction: column;
|
||||||
}
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
/* navigation block */
|
||||||
text-decoration: none;
|
nav {
|
||||||
font-family: 'Lora', serif;
|
background-color: #333;
|
||||||
transition: .5s linear;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
i {
|
nav a {
|
||||||
margin-right: 10px;
|
float: left;
|
||||||
}
|
display: block;
|
||||||
|
color: #f2f2f2;
|
||||||
|
text-align: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
nav {
|
nav a:hover {
|
||||||
display: block;
|
background-color: #ddd;
|
||||||
margin: 0 auto 10px;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav ul {
|
/* main block */
|
||||||
padding: 1em 0;
|
main {
|
||||||
background: #ECDAD6;
|
padding: 10px;
|
||||||
}
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
nav a {
|
/* checkbox */
|
||||||
padding: 1em;
|
label {
|
||||||
background: rgba(177, 152, 145, .3);
|
display: flex;
|
||||||
border-right: 1px solid #b19891;
|
gap: 5px;
|
||||||
color: #695753;
|
align-items: center;
|
||||||
}
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
nav a:hover {
|
input[type="checkbox"] {
|
||||||
background: #b19891;
|
width: 18px;
|
||||||
}
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
nav li {
|
/* form */
|
||||||
display: inline;
|
form {
|
||||||
}
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
input[type="text"] {
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
padding: 10px;
|
||||||
background-color: white;
|
border: 1px solid #ccc;
|
||||||
}
|
border-radius: 4px;
|
||||||
table {
|
font-size: 16px;
|
||||||
background-color: white;
|
}
|
||||||
text-align: left;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
table thead {
|
|
||||||
background: #CFCFCF;
|
|
||||||
background: linear-gradient(to bottom, #dbdbdb 0%, #d3d3d3 66%, #CFCFCF 100%);
|
|
||||||
border-bottom: 3px solid black;
|
|
||||||
}
|
|
||||||
table thead th {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: black;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
table td, table th {
|
|
||||||
border: 1px solid black;
|
|
||||||
padding: 5px 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode styles */
|
button {
|
||||||
body.dark-mode {
|
padding: 10px 20px;
|
||||||
background-color: #121212;
|
border: 1px solid #ccc;
|
||||||
color: #e0e0e0;
|
border-radius: 4px;
|
||||||
}
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode nav ul {
|
/* table */
|
||||||
background: #333;
|
table {
|
||||||
}
|
width: 100%;
|
||||||
|
background-color: white;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode a {
|
th, td {
|
||||||
background: rgba(45, 45, 45, .8);
|
padding: 12px 15px;
|
||||||
border-right: 1px solid #2c2c2c;
|
text-align: left;
|
||||||
color: #c7c7c7;
|
border-bottom: 1px solid #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode a:hover {
|
th {
|
||||||
background: #555;
|
background-color: #444;
|
||||||
}
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode a:visited {
|
tr:nth-child(even) {
|
||||||
color: #999;
|
background-color: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode table {
|
tr:hover {
|
||||||
background-color: #222;
|
background-color: #edf7ff;
|
||||||
color: #ddd;
|
transition: background-color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode table thead {
|
/* table on mobile */
|
||||||
background: linear-gradient(to bottom, #444 0%, #3d3d3d 66%, #333 100%);
|
@media (max-width: 480px) {
|
||||||
border-bottom: 3px solid #888;
|
table, thead, tbody, th, td, tr {
|
||||||
}
|
display: block;
|
||||||
body.dark-mode table thead th {
|
}
|
||||||
font-size: 15px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #ddd;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
body.dark-mode table td, body.dark-mode table th {
|
|
||||||
border: 1px solid #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode button {
|
th, td {
|
||||||
background: rgba(255, 255, 255, .1);
|
box-sizing: border-box;
|
||||||
border: 1px solid #444;
|
width: 100% !important;
|
||||||
color: #ccc;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode input,
|
tr {
|
||||||
body.dark-mode select,
|
margin-bottom: 10px;
|
||||||
body.dark-mode textarea {
|
border-radius: 4px;
|
||||||
background-color: #333;
|
}
|
||||||
color: #e0e0e0;
|
}
|
||||||
border: 1px solid #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode input::placeholder,
|
|
||||||
body.dark-mode textarea::placeholder {
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode hr {
|
|
||||||
border-top: 1px solid #444;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
<nav>
|
`;
|
||||||
<ul>
|
|
||||||
<li><a href="index.html">Streams</a></li>
|
document.body.innerHTML = `
|
||||||
<li><a href="add.html">Add</a></li>
|
<header>
|
||||||
<li><a href="editor.html">Config</a></li>
|
<nav>
|
||||||
<li><a href="log.html">Log</a></li>
|
<a href="index.html"><b>go2rtc</b></a>
|
||||||
<li><a href="network.html">Net</a></li>
|
<a href="add.html">add</a>
|
||||||
<li><a href="#" id="darkModeToggle">
|
<a href="config.html">config</a>
|
||||||
🌙
|
<a href="log.html">log</a>
|
||||||
</a>
|
<a href="net.html">net</a>
|
||||||
</li>
|
</nav>
|
||||||
</ul>
|
</header>
|
||||||
</nav>
|
|
||||||
` + document.body.innerHTML;
|
` + document.body.innerHTML;
|
||||||
|
|
||||||
const sunIcon = '☀️';
|
|
||||||
const moonIcon = '🌕';
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const darkModeToggle = document.getElementById('darkModeToggle');
|
|
||||||
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
|
|
||||||
const isDarkModeEnabled = () => document.body.classList.contains('dark-mode');
|
|
||||||
|
|
||||||
// Update the toggle button based on the dark mode state
|
|
||||||
const updateToggleButton = () => {
|
|
||||||
if (isDarkModeEnabled()) {
|
|
||||||
darkModeToggle.innerHTML = sunIcon;
|
|
||||||
darkModeToggle.setAttribute('aria-label', 'Enable light mode');
|
|
||||||
} else {
|
|
||||||
darkModeToggle.innerHTML = moonIcon;
|
|
||||||
darkModeToggle.setAttribute('aria-label', 'Enable dark mode');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateDarkMode = () => {
|
|
||||||
if (localStorage.getItem('darkMode') === 'enabled' || prefersDarkScheme.matches && localStorage.getItem('darkMode') !== 'disabled') {
|
|
||||||
document.body.classList.add('dark-mode');
|
|
||||||
} else {
|
|
||||||
document.body.classList.remove('dark-mode');
|
|
||||||
}
|
|
||||||
updateEditorTheme();
|
|
||||||
updateToggleButton();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update the editor theme based on the dark mode state
|
|
||||||
const updateEditorTheme = () => {
|
|
||||||
if (typeof editor !== 'undefined') {
|
|
||||||
editor.setTheme(isDarkModeEnabled() ? 'ace/theme/tomorrow_night_eighties' : 'ace/theme/github');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial update for dark mode and toggle button
|
|
||||||
updateDarkMode();
|
|
||||||
|
|
||||||
// Listen for changes in the system's color scheme preference
|
|
||||||
prefersDarkScheme.addEventListener('change', updateDarkMode); // Modern approach
|
|
||||||
|
|
||||||
// Toggle dark mode and update local storage on button click
|
|
||||||
darkModeToggle.addEventListener('click', () => {
|
|
||||||
const enabled = document.body.classList.toggle('dark-mode');
|
|
||||||
localStorage.setItem('darkMode', enabled ? 'enabled' : 'disabled');
|
|
||||||
updateToggleButton(); // Update the button after toggling
|
|
||||||
updateEditorTheme();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -2,31 +2,21 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>go2rtc - Network</title>
|
<title>go2rtc - Network</title>
|
||||||
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
background-color: white;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body, #network {
|
html, body, #network {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#network {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="network"></div>
|
|
||||||
<script src="main.js"></script>
|
<script src="main.js"></script>
|
||||||
|
|
||||||
|
<div id="network"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/* global vis */
|
/* global vis */
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
@@ -79,5 +69,6 @@
|
|||||||
update();
|
update();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+19
-15
@@ -185,7 +185,7 @@ export class VideoRTC extends HTMLElement {
|
|||||||
/** @param {Function} isSupported */
|
/** @param {Function} isSupported */
|
||||||
codecs(isSupported) {
|
codecs(isSupported) {
|
||||||
return this.CODECS
|
return this.CODECS
|
||||||
.filter(codec => this.media.indexOf(codec.indexOf('vc1') > 0 ? 'video' : 'audio') >= 0)
|
.filter(codec => this.media.includes(codec.includes('vc1') ? 'video' : 'audio'))
|
||||||
.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
|
.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,23 +350,23 @@ export class VideoRTC extends HTMLElement {
|
|||||||
|
|
||||||
const modes = [];
|
const modes = [];
|
||||||
|
|
||||||
if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
|
if (this.mode.includes('mse') && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
|
||||||
modes.push('mse');
|
modes.push('mse');
|
||||||
this.onmse();
|
this.onmse();
|
||||||
} else if (this.mode.indexOf('hls') >= 0 && this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
} else if (this.mode.includes('hls') && this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
modes.push('hls');
|
modes.push('hls');
|
||||||
this.onhls();
|
this.onhls();
|
||||||
} else if (this.mode.indexOf('mp4') >= 0) {
|
} else if (this.mode.includes('mp4')) {
|
||||||
modes.push('mp4');
|
modes.push('mp4');
|
||||||
this.onmp4();
|
this.onmp4();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) {
|
if (this.mode.includes('webrtc') && 'RTCPeerConnection' in window) {
|
||||||
modes.push('webrtc');
|
modes.push('webrtc');
|
||||||
this.onwebrtc();
|
this.onwebrtc();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.mode.indexOf('mjpeg') >= 0) {
|
if (this.mode.includes('mjpeg')) {
|
||||||
if (modes.length) {
|
if (modes.length) {
|
||||||
this.onmessage['mjpeg'] = msg => {
|
this.onmessage['mjpeg'] = msg => {
|
||||||
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
|
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
|
||||||
@@ -490,7 +490,7 @@ export class VideoRTC extends HTMLElement {
|
|||||||
const pc = new RTCPeerConnection(this.pcConfig);
|
const pc = new RTCPeerConnection(this.pcConfig);
|
||||||
|
|
||||||
pc.addEventListener('icecandidate', ev => {
|
pc.addEventListener('icecandidate', ev => {
|
||||||
if (ev.candidate && this.mode.indexOf('webrtc/tcp') >= 0 && ev.candidate.protocol === 'udp') return;
|
if (ev.candidate && this.mode.includes('webrtc/tcp') && ev.candidate.protocol === 'udp') return;
|
||||||
|
|
||||||
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
|
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
|
||||||
this.send({type: 'webrtc/candidate', value: candidate});
|
this.send({type: 'webrtc/candidate', value: candidate});
|
||||||
@@ -518,7 +518,7 @@ export class VideoRTC extends HTMLElement {
|
|||||||
this.onmessage['webrtc'] = msg => {
|
this.onmessage['webrtc'] = msg => {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'webrtc/candidate':
|
case 'webrtc/candidate':
|
||||||
if (this.mode.indexOf('webrtc/tcp') >= 0 && msg.value.indexOf(' udp ') > 0) return;
|
if (this.mode.includes('webrtc/tcp') && msg.value.includes(' udp ')) return;
|
||||||
|
|
||||||
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => {
|
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => {
|
||||||
console.warn(er);
|
console.warn(er);
|
||||||
@@ -530,7 +530,7 @@ export class VideoRTC extends HTMLElement {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
if (msg.value.indexOf('webrtc/offer') < 0) return;
|
if (!msg.value.includes('webrtc/offer')) return;
|
||||||
pc.close();
|
pc.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -549,7 +549,7 @@ export class VideoRTC extends HTMLElement {
|
|||||||
*/
|
*/
|
||||||
async createOffer(pc) {
|
async createOffer(pc) {
|
||||||
try {
|
try {
|
||||||
if (this.media.indexOf('microphone') >= 0) {
|
if (this.media.includes('microphone')) {
|
||||||
const media = await navigator.mediaDevices.getUserMedia({audio: true});
|
const media = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||||
media.getTracks().forEach(track => {
|
media.getTracks().forEach(track => {
|
||||||
pc.addTransceiver(track, {direction: 'sendonly'});
|
pc.addTransceiver(track, {direction: 'sendonly'});
|
||||||
@@ -560,7 +560,7 @@ export class VideoRTC extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const kind of ['video', 'audio']) {
|
for (const kind of ['video', 'audio']) {
|
||||||
if (this.media.indexOf(kind) >= 0) {
|
if (this.media.includes(kind)) {
|
||||||
pc.addTransceiver(kind, {direction: 'recvonly'});
|
pc.addTransceiver(kind, {direction: 'recvonly'});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,12 +580,16 @@ export class VideoRTC extends HTMLElement {
|
|||||||
|
|
||||||
/** @type {MediaStream} */
|
/** @type {MediaStream} */
|
||||||
const stream = video2.srcObject;
|
const stream = video2.srcObject;
|
||||||
if (stream.getVideoTracks().length > 0) rtcPriority += 0x220;
|
if (stream.getVideoTracks().length > 0) {
|
||||||
|
// not the best, but a pretty simple way to check a codec
|
||||||
|
const isH265Supported = this.pc.remoteDescription.sdp.includes('H265/90000');
|
||||||
|
rtcPriority += isH265Supported ? 0x240 : 0x220;
|
||||||
|
}
|
||||||
if (stream.getAudioTracks().length > 0) rtcPriority += 0x102;
|
if (stream.getAudioTracks().length > 0) rtcPriority += 0x102;
|
||||||
|
|
||||||
if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
|
if (this.mseCodecs.includes('hvc1.')) msePriority += 0x230;
|
||||||
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
|
if (this.mseCodecs.includes('avc1.')) msePriority += 0x210;
|
||||||
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
|
if (this.mseCodecs.includes('mp4a.')) msePriority += 0x101;
|
||||||
|
|
||||||
if (rtcPriority >= msePriority) {
|
if (rtcPriority >= msePriority) {
|
||||||
this.video.srcObject = stream;
|
this.video.srcObject = stream;
|
||||||
|
|||||||
Reference in New Issue
Block a user