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 |
+10
-10
@@ -124,7 +124,7 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
name=${{ github.repository }},enable=${{ github.event.repository.fork == false }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
@@ -138,14 +138,14 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
name=${{ github.repository }},enable=${{ github.event.repository.fork == false }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
flavor: |
|
||||
suffix=-hardware,onlatest=true
|
||||
@@ -198,14 +198,14 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
name=${{ github.repository }},enable=${{ github.event.repository.fork == false }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
flavor: |
|
||||
suffix=-rockchip,onlatest=true
|
||||
@@ -253,14 +253,14 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
|
||||
@@ -9,6 +9,9 @@ go2rtc_linux*
|
||||
go2rtc_mac*
|
||||
go2rtc_win*
|
||||
|
||||
/go2rtc
|
||||
/go2rtc.exe
|
||||
|
||||
0_test.go
|
||||
|
||||
.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
|
||||
- auto-match client-supported codecs
|
||||
- [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)
|
||||
|
||||
**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)
|
||||
- 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)
|
||||
@@ -69,12 +71,14 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF
|
||||
* [Source: Hass](#source-hass)
|
||||
* [Source: ISAPI](#source-isapi)
|
||||
* [Source: Nest](#source-nest)
|
||||
* [Source: Ring](#source-ring)
|
||||
* [Source: Roborock](#source-roborock)
|
||||
* [Source: WebRTC](#source-webrtc)
|
||||
* [Source: WebTorrent](#source-webtorrent)
|
||||
* [Incoming sources](#incoming-sources)
|
||||
* [Stream to camera](#stream-to-camera)
|
||||
* [Publish stream](#publish-stream)
|
||||
* [Preload stream](#preload-stream)
|
||||
* [Module: API](#module-api)
|
||||
* [Module: RTSP](#module-rtsp)
|
||||
* [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
|
||||
|
||||
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
|
||||
|
||||
@@ -199,6 +203,7 @@ Available source types:
|
||||
- [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR
|
||||
- [dvrip](#source-dvrip) - streaming from DVR-IP NVR
|
||||
- [tapo](#source-tapo) - TP-Link Tapo cameras with [two way audio](#two-way-audio) support
|
||||
- [ring](#source-ring) - Ring cameras with [two way audio](#two-way-audio) support
|
||||
- [kasa](#source-tapo) - TP-Link Kasa cameras
|
||||
- [gopro](#source-gopro) - GoPro cameras
|
||||
- [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service
|
||||
@@ -220,6 +225,7 @@ Supported sources:
|
||||
- [Hikvision ISAPI](#source-isapi) cameras
|
||||
- [Roborock vacuums](#source-roborock) models with cameras
|
||||
- [Exec](#source-exec) audio on server
|
||||
- [Ring](#source-ring) cameras
|
||||
- [Any Browser](#incoming-browser) as IP-camera
|
||||
|
||||
Two-way audio can be used in browser with [WebRTC](#module-webrtc) technology. The browser will give access to the microphone only for HTTPS sites ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https)).
|
||||
@@ -646,6 +652,16 @@ streams:
|
||||
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
|
||||
|
||||
*[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**
|
||||
|
||||
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
|
||||
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-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-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.
|
||||
@@ -822,6 +838,26 @@ streams:
|
||||
- **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming.
|
||||
- **YouTube** > Create > Go live > Stream latency: Ultra low-latency > Copy: Stream URL + Stream key.
|
||||
|
||||
### Preload stream
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
username: "admin" # default "", Basic auth for WebUI
|
||||
password: "pass" # default "", Basic auth for WebUI
|
||||
local_auth: true # default false, Enable auth check for localhost requests
|
||||
base_path: "/rtc" # default "", API prefix for serving on suburl (/api => /rtc/api)
|
||||
static_dir: "www" # default "", folder for static files (custom web interface)
|
||||
origin: "*" # default "", allow CORS requests (only * supported)
|
||||
@@ -940,15 +977,6 @@ webrtc:
|
||||
- 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**
|
||||
|
||||
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
|
||||
|
||||
TODO: article on how it works...
|
||||
|
||||
### Module: ngrok
|
||||
|
||||
With ngrok integration, you can get external access to your streams in situations when you have Internet with a private IP address.
|
||||
|
||||
- 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.
|
||||
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)).
|
||||
|
||||
### Module: Hass
|
||||
|
||||
@@ -1221,7 +1195,6 @@ log:
|
||||
level: info # default level
|
||||
api: trace
|
||||
exec: debug
|
||||
ngrok: info
|
||||
rtsp: warn
|
||||
streams: error
|
||||
webrtc: fatal
|
||||
@@ -1229,6 +1202,27 @@ log:
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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}:
|
||||
get:
|
||||
summary: Get stream info in JSON format
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:labs
|
||||
|
||||
# 0. Prepare images
|
||||
ARG PYTHON_VERSION="3.11"
|
||||
ARG PYTHON_VERSION="3.13"
|
||||
ARG GO_VERSION="1.25"
|
||||
|
||||
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
module github.com/AlexxIT/go2rtc
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
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/gorilla/websocket v1.5.3
|
||||
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/interceptor v0.1.40
|
||||
github.com/pion/rtcp v1.2.15
|
||||
github.com/pion/rtp v1.8.20
|
||||
github.com/pion/sdp/v3 v3.0.14
|
||||
github.com/pion/srtp/v3 v3.0.6
|
||||
github.com/pion/interceptor v0.1.41
|
||||
github.com/pion/rtcp v1.2.16
|
||||
github.com/pion/rtp v1.8.24
|
||||
github.com/pion/sdp/v3 v3.0.16
|
||||
github.com/pion/srtp/v3 v3.0.8
|
||||
github.com/pion/stun/v3 v3.0.0
|
||||
github.com/pion/webrtc/v4 v4.1.3
|
||||
github.com/pion/webrtc/v4 v4.1.6
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
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
|
||||
)
|
||||
|
||||
@@ -32,18 +32,18 @@ require (
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.6 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.7 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/sctp v1.8.39 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||
github.com/pion/sctp v1.8.40 // indirect
|
||||
github.com/pion/transport/v3 v3.0.8 // indirect
|
||||
github.com/pion/turn/v4 v4.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
)
|
||||
|
||||
@@ -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.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.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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
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.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
|
||||
github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE=
|
||||
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
||||
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
||||
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
||||
github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q=
|
||||
github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8=
|
||||
github.com/pion/ice/v4 v4.0.9 h1:VKgU4MwA2LUDVLq+WBkpEHTcAb8c5iCvFMECeuPOZNk=
|
||||
github.com/pion/ice/v4 v4.0.9/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||
@@ -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.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||
github.com/pion/interceptor v0.1.41 h1:NpvX3HgWIukTf2yTBVjVGFXtpSpWgXjqz7IIpu7NsOw=
|
||||
github.com/pion/interceptor v0.1.41/go.mod h1:nEt4187unvRXJFyjiw00GKo+kIuXMWQI9K89fsosDLY=
|
||||
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
||||
github.com/pion/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/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
||||
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
||||
github.com/pion/rtp v1.8.13 h1:8uSUPpjSL4OlwZI8Ygqu7+h2p9NPFB+yAZ461Xn5sNg=
|
||||
github.com/pion/rtp v1.8.13/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4=
|
||||
github.com/pion/rtp v1.8.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI=
|
||||
github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
||||
github.com/pion/rtp v1.8.24 h1:+ICyZXUQDv95EsHN70RrA4XKJf5MGWyC6QQc1u6/ynI=
|
||||
github.com/pion/rtp v1.8.24/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||
github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs=
|
||||
github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||
github.com/pion/sctp v1.8.40 h1:bqbgWYOrUhsYItEnRObUYZuzvOMsVplS3oNgzedBlG8=
|
||||
github.com/pion/sctp v1.8.40/go.mod h1:SPBBUENXE6ThkEksN5ZavfAhFYll+h+66ZiG6IZQuzo=
|
||||
github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI=
|
||||
github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||
github.com/pion/sdp/v3 v3.0.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI=
|
||||
github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||
github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo=
|
||||
github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
|
||||
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
|
||||
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
|
||||
github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4=
|
||||
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY=
|
||||
github.com/pion/srtp/v3 v3.0.8 h1:RjRrjcIeQsilPzxvdaElN0CpuQZdMvcl9VZ5UY9suUM=
|
||||
github.com/pion/srtp/v3 v3.0.8/go.mod h1:2Sq6YnDH7/UDCvkSoHSDNDeyBcFgWL0sAVycVbAsXFg=
|
||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/transport/v3 v3.0.8 h1:oI3myyYnTKUSTthu/NZZ8eu2I5sHbxbUNNFW62olaYc=
|
||||
github.com/pion/transport/v3 v3.0.8/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
||||
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
||||
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
||||
github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc=
|
||||
github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8=
|
||||
github.com/pion/webrtc/v4 v4.0.14 h1:nyds/sFRR+HvmWoBa6wrL46sSfpArE0qR883MBW96lg=
|
||||
github.com/pion/webrtc/v4 v4.0.14/go.mod h1:R3+qTnQTS03UzwDarYecgioNf7DYgTsldxnCXB821Kk=
|
||||
github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c=
|
||||
github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM=
|
||||
github.com/pion/webrtc/v4 v4.1.6 h1:srHH2HwvCGwPba25EYJgUzgLqCQoXl1VCUnrGQMSzUw=
|
||||
github.com/pion/webrtc/v4 v4.1.6/go.mod h1:wKecGRlkl3ox/As/MYghJL+b/cVXMEhoPMJWPuGQFhU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
|
||||
@@ -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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
@@ -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.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
|
||||
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
|
||||
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
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/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
+17
-3
@@ -7,6 +7,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -23,6 +24,7 @@ func Init() {
|
||||
Listen string `yaml:"listen"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
LocalAuth bool `yaml:"local_auth"`
|
||||
BasePath string `yaml:"base_path"`
|
||||
StaticDir string `yaml:"static_dir"`
|
||||
Origin string `yaml:"origin"`
|
||||
@@ -30,6 +32,8 @@ func Init() {
|
||||
TLSCert string `yaml:"tls_cert"`
|
||||
TLSKey string `yaml:"tls_key"`
|
||||
UnixListen string `yaml:"unix_listen"`
|
||||
|
||||
AllowPaths []string `yaml:"allow_paths"`
|
||||
} `yaml:"api"`
|
||||
}
|
||||
|
||||
@@ -43,6 +47,7 @@ func Init() {
|
||||
return
|
||||
}
|
||||
|
||||
allowPaths = cfg.Mod.AllowPaths
|
||||
basePath = cfg.Mod.BasePath
|
||||
log = app.GetLogger("api")
|
||||
|
||||
@@ -61,7 +66,7 @@ func Init() {
|
||||
}
|
||||
|
||||
if cfg.Mod.Username != "" {
|
||||
Handler = middlewareAuth(cfg.Mod.Username, cfg.Mod.Password, Handler) // 2nd
|
||||
Handler = middlewareAuth(cfg.Mod.Username, cfg.Mod.Password, cfg.Mod.LocalAuth, Handler) // 2nd
|
||||
}
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
@@ -152,6 +157,10 @@ func HandleFunc(pattern string, handler http.HandlerFunc) {
|
||||
if len(pattern) == 0 || pattern[0] != '/' {
|
||||
pattern = basePath + "/" + pattern
|
||||
}
|
||||
if allowPaths != nil && !slices.Contains(allowPaths, pattern) {
|
||||
log.Trace().Str("path", pattern).Msg("[api] ignore path not in allow_paths")
|
||||
return
|
||||
}
|
||||
log.Trace().Str("path", pattern).Msg("[api] register path")
|
||||
http.HandleFunc(pattern, handler)
|
||||
}
|
||||
@@ -185,6 +194,7 @@ func Response(w http.ResponseWriter, body any, contentType string) {
|
||||
|
||||
const StreamNotFound = "stream not found"
|
||||
|
||||
var allowPaths []string
|
||||
var basePath string
|
||||
var log zerolog.Logger
|
||||
|
||||
@@ -195,9 +205,13 @@ func middlewareLog(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func middlewareAuth(username, password string, next http.Handler) http.Handler {
|
||||
func isLoopback(remoteAddr string) bool {
|
||||
return strings.HasPrefix(remoteAddr, "127.") || strings.HasPrefix(remoteAddr, "[::1]") || remoteAddr == "@"
|
||||
}
|
||||
|
||||
func middlewareAuth(username, password string, localAuth bool, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.RemoteAddr, "127.") && !strings.HasPrefix(r.RemoteAddr, "[::1]") && r.RemoteAddr != "@" {
|
||||
if localAuth || !isLoopback(r.RemoteAddr) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != username || pass != password {
|
||||
w.Header().Set("Www-Authenticate", `Basic realm="go2rtc"`)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
var (
|
||||
Version string
|
||||
Modules []string
|
||||
UserAgent string
|
||||
ConfigPath string
|
||||
Info = make(map[string]any)
|
||||
@@ -76,6 +77,16 @@ func Init() {
|
||||
if ConfigPath != "" {
|
||||
Logger.Info().Str("path", ConfigPath).Msg("config")
|
||||
}
|
||||
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
Modules []string `yaml:"modules"`
|
||||
} `yaml:"app"`
|
||||
}
|
||||
|
||||
LoadConfig(&cfg)
|
||||
|
||||
Modules = cfg.Mod.Modules
|
||||
}
|
||||
|
||||
func readRevisionTime() (revision, vcsTime string) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"github.com/AlexxIT/go2rtc/pkg/creds"
|
||||
"github.com/AlexxIT/go2rtc/pkg/yaml"
|
||||
)
|
||||
|
||||
@@ -71,13 +71,15 @@ func initConfig(confs flagConfig) {
|
||||
// config as file
|
||||
if ConfigPath == "" {
|
||||
ConfigPath = conf
|
||||
initStorage()
|
||||
}
|
||||
|
||||
if data, _ = os.ReadFile(conf); data == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
data = []byte(shell.ReplaceEnvVars(string(data)))
|
||||
loadEnv(data)
|
||||
data = creds.ReplaceVars(data)
|
||||
configs = append(configs, data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/creds"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
@@ -88,6 +89,8 @@ func initLogger() {
|
||||
writer = MemoryLog
|
||||
}
|
||||
|
||||
writer = creds.SecretWriter(writer)
|
||||
|
||||
lvl, _ := zerolog.ParseLevel(modules["level"])
|
||||
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"),
|
||||
|
||||
// webrtc/api.go
|
||||
[]byte("created by github.com/pion/ice/v2.NewTCPMuxDefault"),
|
||||
[]byte("created by github.com/pion/ice/v2.NewUDPMuxDefault"),
|
||||
[]byte("created by github.com/pion/ice/v4.NewTCPMuxDefault"),
|
||||
[]byte("created by github.com/pion/ice/v4.NewUDPMuxDefault"),
|
||||
}
|
||||
|
||||
func stackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -2,7 +2,9 @@ package echo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"slices"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
@@ -10,11 +12,25 @@ import (
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
AllowPaths []string `yaml:"allow_paths"`
|
||||
} `yaml:"echo"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
allowPaths := cfg.Mod.AllowPaths
|
||||
|
||||
log := app.GetLogger("echo")
|
||||
|
||||
streams.RedirectFunc("echo", func(url string) (string, error) {
|
||||
args := shell.QuoteSplit(url[5:])
|
||||
|
||||
if allowPaths != nil && !slices.Contains(allowPaths, args[0]) {
|
||||
return "", errors.New("echo: bin not in allow_paths: " + args[0])
|
||||
}
|
||||
|
||||
b, err := exec.Command(args[0], args[1:]...).Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -26,4 +42,5 @@ func Init() {
|
||||
|
||||
return string(b), nil
|
||||
})
|
||||
streams.MarkInsecure("echo")
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -26,6 +27,16 @@ import (
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
AllowPaths []string `yaml:"allow_paths"`
|
||||
} `yaml:"exec"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
allowPaths = cfg.Mod.AllowPaths
|
||||
|
||||
rtsp.HandleFunc(func(conn *pkg.Conn) bool {
|
||||
waitersMu.Lock()
|
||||
waiter := waiters[conn.URL.Path]
|
||||
@@ -45,10 +56,13 @@ func Init() {
|
||||
})
|
||||
|
||||
streams.HandleFunc("exec", execHandle)
|
||||
streams.MarkInsecure("exec")
|
||||
|
||||
log = app.GetLogger("exec")
|
||||
}
|
||||
|
||||
var allowPaths []string
|
||||
|
||||
func execHandle(rawURL string) (prod core.Producer, err error) {
|
||||
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
||||
query := streams.ParseQuery(rawQuery)
|
||||
@@ -73,6 +87,10 @@ func execHandle(rawURL string) (prod core.Producer, err error) {
|
||||
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 != "" {
|
||||
sig := syscall.Signal(core.Atoi(s))
|
||||
cmd.Cancel = func() error {
|
||||
|
||||
@@ -25,4 +25,5 @@ func Init() {
|
||||
|
||||
return url, nil
|
||||
})
|
||||
streams.MarkInsecure("expr")
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ var defaults = map[string]string{
|
||||
// `-profile high -level 4.1` - most used streaming profile
|
||||
// `-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",
|
||||
"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 -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}
|
||||
// 2. static link to Hass camera
|
||||
// 3. dynamic link to Hass camera
|
||||
if streams.Patch(v.Name, v.Channels.First.Url) != nil {
|
||||
if _, err := streams.Patch(v.Name, v.Channels.First.Url); err == nil {
|
||||
apiOK(w, r)
|
||||
} else {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// /stream/{id}/channel/0/webrtc
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
@@ -37,8 +38,13 @@ func Init() {
|
||||
api.HandleFunc("/streams", apiOK)
|
||||
api.HandleFunc("/stream/", apiStream)
|
||||
|
||||
streams.RedirectFunc("hass", func(url string) (string, error) {
|
||||
if location := entities[url[5:]]; location != "" {
|
||||
streams.RedirectFunc("hass", func(rawURL string) (string, error) {
|
||||
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
||||
|
||||
if location := entities[rawURL[5:]]; location != "" {
|
||||
if rawQuery != "" {
|
||||
return location + "#" + rawQuery, nil
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error {
|
||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
stream, _ := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
+76
-38
@@ -3,6 +3,7 @@ package homekit
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -14,56 +15,93 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
)
|
||||
|
||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func apiDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||
sources, err := discovery()
|
||||
if err != nil {
|
||||
api.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
urls := findHomeKitURLs()
|
||||
for id, u := range urls {
|
||||
deviceID := u.Query().Get("device_id")
|
||||
for _, source := range sources {
|
||||
if strings.Contains(source.URL, deviceID) {
|
||||
source.Location = id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, source := range sources {
|
||||
if source.Location == "" {
|
||||
source.Location = " "
|
||||
}
|
||||
}
|
||||
|
||||
api.ResponseSources(w, sources)
|
||||
}
|
||||
|
||||
func apiHomekit(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
sources, err := discovery()
|
||||
if err != nil {
|
||||
api.Error(w, err)
|
||||
return
|
||||
if id := r.Form.Get("id"); id != "" {
|
||||
api.ResponsePrettyJSON(w, servers[id])
|
||||
} else {
|
||||
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":
|
||||
if err := r.ParseMultipartForm(1024); err != nil {
|
||||
api.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := apiPair(r.Form.Get("id"), r.Form.Get("url")); err != nil {
|
||||
api.Error(w, err)
|
||||
id := r.Form.Get("id")
|
||||
rawURL := r.Form.Get("src") + "&pin=" + r.Form.Get("pin")
|
||||
if err := apiPair(id, rawURL); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
case "DELETE":
|
||||
if err := r.ParseMultipartForm(1024); err != nil {
|
||||
api.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := apiUnpair(r.Form.Get("id")); err != nil {
|
||||
api.Error(w, err)
|
||||
id := r.Form.Get("id")
|
||||
if err := apiUnpair(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func apiHomekitAccessories(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
stream := streams.Get(id)
|
||||
if stream == nil {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
rawURL := findHomeKitURL(stream.Sources())
|
||||
if rawURL == "" {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := hap.Dial(rawURL)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
res, err := client.Get(hap.PathAccessories)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", api.MimeJSON)
|
||||
_, _ = io.Copy(w, res.Body)
|
||||
}
|
||||
|
||||
func discovery() ([]*api.Source, error) {
|
||||
var sources []*api.Source
|
||||
|
||||
|
||||
+28
-52
@@ -2,8 +2,6 @@ package homekit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -35,12 +33,15 @@ func Init() {
|
||||
|
||||
streams.HandleFunc("homekit", streamHandler)
|
||||
|
||||
api.HandleFunc("api/homekit", apiHandler)
|
||||
api.HandleFunc("api/homekit", apiHomekit)
|
||||
api.HandleFunc("api/homekit/accessories", apiHomekitAccessories)
|
||||
api.HandleFunc("api/discovery/homekit", apiDiscovery)
|
||||
|
||||
if cfg.Mod == nil {
|
||||
return
|
||||
}
|
||||
|
||||
hosts = map[string]*server{}
|
||||
servers = map[string]*server{}
|
||||
var entries []*mdns.ServiceEntry
|
||||
|
||||
@@ -66,33 +67,14 @@ func Init() {
|
||||
|
||||
srv := &server{
|
||||
stream: id,
|
||||
srtp: srtp.Server,
|
||||
pairings: conf.Pairings,
|
||||
}
|
||||
|
||||
srv.hap = &hap.Server{
|
||||
Pin: pin,
|
||||
DeviceID: deviceID,
|
||||
DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id),
|
||||
GetPair: srv.GetPair,
|
||||
AddPair: srv.AddPair,
|
||||
Handler: homekit.ServerHandler(srv),
|
||||
}
|
||||
|
||||
if url := findHomeKitURL(stream.Sources()); url != "" {
|
||||
// 1. Act as transparent proxy for HomeKit camera
|
||||
dial := func() (net.Conn, error) {
|
||||
client, err := homekit.Dial(url, srtp.Server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Conn(), nil
|
||||
}
|
||||
srv.hap.Handler = homekit.ProxyHandler(srv, dial)
|
||||
} else {
|
||||
// 2. Act as basic HomeKit camera
|
||||
srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version)
|
||||
srv.hap.Handler = homekit.ServerHandler(srv)
|
||||
Pin: pin,
|
||||
DeviceID: deviceID,
|
||||
DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id),
|
||||
GetClientPublic: srv.GetPair,
|
||||
}
|
||||
|
||||
srv.mdns = &mdns.ServiceEntry{
|
||||
@@ -114,15 +96,24 @@ func Init() {
|
||||
|
||||
srv.UpdateStatus()
|
||||
|
||||
if url := findHomeKitURL(stream.Sources()); url != "" {
|
||||
// 1. Act as transparent proxy for HomeKit camera
|
||||
srv.proxyURL = url
|
||||
} else {
|
||||
// 2. Act as basic HomeKit camera
|
||||
srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version)
|
||||
}
|
||||
|
||||
host := srv.mdns.Host(mdns.ServiceHAP)
|
||||
servers[host] = srv
|
||||
hosts[host] = srv
|
||||
servers[id] = srv
|
||||
|
||||
log.Trace().Msgf("[homekit] new server: %s", srv.mdns)
|
||||
}
|
||||
|
||||
api.HandleFunc(hap.PathPairSetup, hapHandler)
|
||||
api.HandleFunc(hap.PathPairVerify, hapHandler)
|
||||
|
||||
log.Trace().Msgf("[homekit] mdns: %s", entries)
|
||||
|
||||
go func() {
|
||||
if err := mdns.Serve(mdns.ServiceHAP, entries); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
@@ -131,6 +122,7 @@ func Init() {
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
var hosts map[string]*server
|
||||
var servers map[string]*server
|
||||
|
||||
func streamHandler(rawURL string) (core.Producer, error) {
|
||||
@@ -142,6 +134,8 @@ func streamHandler(rawURL string) (core.Producer, error) {
|
||||
client, err := homekit.Dial(rawURL, srtp.Server)
|
||||
if client != nil && rawQuery != "" {
|
||||
query := streams.ParseQuery(rawQuery)
|
||||
client.MaxWidth = core.Atoi(query.Get("maxwidth"))
|
||||
client.MaxHeight = core.Atoi(query.Get("maxheight"))
|
||||
client.Bitrate = parseBitrate(query.Get("bitrate"))
|
||||
}
|
||||
|
||||
@@ -149,45 +143,27 @@ func streamHandler(rawURL string) (core.Producer, error) {
|
||||
}
|
||||
|
||||
func resolve(host string) *server {
|
||||
if len(servers) == 1 {
|
||||
for _, srv := range servers {
|
||||
if len(hosts) == 1 {
|
||||
for _, srv := range hosts {
|
||||
return srv
|
||||
}
|
||||
}
|
||||
if srv, ok := servers[host]; ok {
|
||||
if srv, ok := hosts[host]; ok {
|
||||
return srv
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hapHandler(w http.ResponseWriter, r *http.Request) {
|
||||
conn, rw, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
// Can support multiple HomeKit cameras on single port ONLY for Apple devices.
|
||||
// Doesn't support Home Assistant and any other open source projects
|
||||
// because they don't send the host header in requests.
|
||||
srv := resolve(r.Host)
|
||||
if srv == nil {
|
||||
log.Error().Msg("[homekit] unknown host: " + r.Host)
|
||||
_ = hap.WriteBackoff(rw)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.RequestURI {
|
||||
case hap.PathPairSetup:
|
||||
err = srv.hap.PairSetup(r, rw, conn)
|
||||
case hap.PathPairVerify:
|
||||
err = srv.hap.PairVerify(r, rw, conn)
|
||||
}
|
||||
|
||||
if err != nil && err != io.EOF {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
srv.Handle(w, r)
|
||||
}
|
||||
|
||||
func findHomeKitURL(sources []string) string {
|
||||
@@ -203,7 +179,7 @@ func findHomeKitURL(sources []string) string {
|
||||
if strings.HasPrefix(url, "hass") {
|
||||
location, _ := streams.Location(url)
|
||||
if strings.HasPrefix(location, "homekit") {
|
||||
return url
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+208
-95
@@ -4,10 +4,16 @@ import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||
@@ -16,23 +22,133 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
"github.com/AlexxIT/go2rtc/pkg/srtp"
|
||||
)
|
||||
|
||||
type server struct {
|
||||
stream string // stream name from YAML
|
||||
hap *hap.Server // server for HAP connection and encryption
|
||||
mdns *mdns.ServiceEntry
|
||||
srtp *srtp.Server
|
||||
accessory *hap.Accessory // HAP accessory
|
||||
pairings []string // pairings list
|
||||
hap *hap.Server // server for HAP connection and encryption
|
||||
mdns *mdns.ServiceEntry
|
||||
|
||||
streams map[string]*homekit.Consumer
|
||||
consumer *homekit.Consumer
|
||||
pairings []string // pairings list
|
||||
conns []any
|
||||
mu sync.Mutex
|
||||
|
||||
accessory *hap.Accessory // HAP accessory
|
||||
consumer *homekit.Consumer
|
||||
proxyURL string
|
||||
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() {
|
||||
@@ -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 {
|
||||
return []*hap.Accessory{s.accessory}
|
||||
}
|
||||
|
||||
func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
|
||||
log.Trace().Msgf("[homekit] %s: get char aid=%d iid=0x%x", conn.RemoteAddr(), aid, iid)
|
||||
log.Trace().Str("stream", s.stream).Msgf("[homekit] get char aid=%d iid=0x%x", aid, iid)
|
||||
|
||||
char := s.accessory.GetCharacterByID(iid)
|
||||
if char == nil {
|
||||
@@ -59,11 +231,12 @@ func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
|
||||
|
||||
switch char.Type {
|
||||
case camera.TypeSetupEndpoints:
|
||||
if s.consumer == nil {
|
||||
consumer := s.consumer
|
||||
if consumer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
answer := s.consumer.GetAnswer()
|
||||
answer := consumer.GetAnswer()
|
||||
v, err := tlv8.MarshalBase64(answer)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -76,7 +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) {
|
||||
log.Trace().Msgf("[homekit] %s: set char aid=%d iid=0x%x value=%v", conn.RemoteAddr(), aid, iid, value)
|
||||
log.Trace().Str("stream", s.stream).Msgf("[homekit] set char aid=%d iid=0x%x value=%v", aid, iid, value)
|
||||
|
||||
char := s.accessory.GetCharacterByID(iid)
|
||||
if char == nil {
|
||||
@@ -86,61 +259,64 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a
|
||||
|
||||
switch char.Type {
|
||||
case camera.TypeSetupEndpoints:
|
||||
var offer camera.SetupEndpoints
|
||||
var offer camera.SetupEndpointsRequest
|
||||
if err := tlv8.UnmarshalBase64(value, &offer); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.consumer = homekit.NewConsumer(conn, srtp2.Server)
|
||||
s.consumer.SetOffer(&offer)
|
||||
consumer := homekit.NewConsumer(conn, srtp2.Server)
|
||||
consumer.SetOffer(&offer)
|
||||
s.consumer = consumer
|
||||
|
||||
case camera.TypeSelectedStreamConfiguration:
|
||||
var conf camera.SelectedStreamConfig
|
||||
var conf camera.SelectedStreamConfiguration
|
||||
if err := tlv8.UnmarshalBase64(value, &conf); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[homekit] %s stream id=%x cmd=%d", conn.RemoteAddr(), conf.Control.SessionID, conf.Control.Command)
|
||||
log.Trace().Str("stream", s.stream).Msgf("[homekit] stream id=%x cmd=%d", conf.Control.SessionID, conf.Control.Command)
|
||||
|
||||
switch conf.Control.Command {
|
||||
case camera.SessionCommandEnd:
|
||||
if consumer := s.streams[conf.Control.SessionID]; consumer != nil {
|
||||
_ = consumer.Stop()
|
||||
for _, consumer := range s.conns {
|
||||
if consumer, ok := consumer.(*homekit.Consumer); ok {
|
||||
if consumer.SessionID() == conf.Control.SessionID {
|
||||
_ = consumer.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case camera.SessionCommandStart:
|
||||
if s.consumer == nil {
|
||||
consumer := s.consumer
|
||||
if consumer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !s.consumer.SetConfig(&conf) {
|
||||
if !consumer.SetConfig(&conf) {
|
||||
log.Warn().Msgf("[homekit] wrong config")
|
||||
return
|
||||
}
|
||||
|
||||
if s.streams == nil {
|
||||
s.streams = map[string]*homekit.Consumer{}
|
||||
}
|
||||
|
||||
s.streams[conf.Control.SessionID] = s.consumer
|
||||
s.AddConn(consumer)
|
||||
|
||||
stream := streams.Get(s.stream)
|
||||
if err := stream.AddConsumer(s.consumer); err != nil {
|
||||
if err := stream.AddConsumer(consumer); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, _ = s.consumer.WriteTo(nil)
|
||||
stream.RemoveConsumer(s.consumer)
|
||||
_, _ = consumer.WriteTo(nil)
|
||||
stream.RemoveConsumer(consumer)
|
||||
|
||||
delete(s.streams, conf.Control.SessionID)
|
||||
s.DelConn(consumer)
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) GetImage(conn net.Conn, width, height int) []byte {
|
||||
log.Trace().Msgf("[homekit] %s: get image width=%d height=%d", conn.RemoteAddr(), width, height)
|
||||
log.Trace().Str("stream", s.stream).Msgf("[homekit] get image width=%d height=%d", width, height)
|
||||
|
||||
stream := streams.Get(s.stream)
|
||||
cons := magic.NewKeyframe()
|
||||
@@ -166,69 +342,6 @@ func (s *server) GetImage(conn net.Conn, width, height int) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
func (s *server) GetPair(conn net.Conn, id string) []byte {
|
||||
log.Trace().Msgf("[homekit] %s: get pair id=%s", conn.RemoteAddr(), id)
|
||||
|
||||
for _, pairing := range s.pairings {
|
||||
if !strings.Contains(pairing, id) {
|
||||
continue
|
||||
}
|
||||
|
||||
query, err := url.ParseQuery(pairing)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if query.Get("client_id") != id {
|
||||
continue
|
||||
}
|
||||
|
||||
s := query.Get("client_public")
|
||||
b, _ := hex.DecodeString(s)
|
||||
return b
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) AddPair(conn net.Conn, id string, public []byte, permissions byte) {
|
||||
log.Trace().Msgf("[homekit] %s: add pair id=%s public=%x perm=%d", conn.RemoteAddr(), id, public, permissions)
|
||||
|
||||
query := url.Values{
|
||||
"client_id": []string{id},
|
||||
"client_public": []string{hex.EncodeToString(public)},
|
||||
"permissions": []string{string('0' + permissions)},
|
||||
}
|
||||
if s.GetPair(conn, id) == nil {
|
||||
s.pairings = append(s.pairings, query.Encode())
|
||||
s.UpdateStatus()
|
||||
s.PatchConfig()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) DelPair(conn net.Conn, id string) {
|
||||
log.Trace().Msgf("[homekit] %s: del pair id=%s", conn.RemoteAddr(), id)
|
||||
|
||||
id = "client_id=" + id
|
||||
for i, pairing := range s.pairings {
|
||||
if !strings.Contains(pairing, id) {
|
||||
continue
|
||||
}
|
||||
|
||||
s.pairings = append(s.pairings[:i], s.pairings[i+1:]...)
|
||||
s.UpdateStatus()
|
||||
s.PatchConfig()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) PatchConfig() {
|
||||
if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil {
|
||||
log.Error().Err(err).Msgf(
|
||||
"[homekit] can't save %s pairings=%v", s.stream, s.pairings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func calcName(name, seed string) string {
|
||||
if name != "" {
|
||||
return name
|
||||
|
||||
@@ -36,7 +36,7 @@ func Init() {
|
||||
var log zerolog.Logger
|
||||
|
||||
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
stream := streams.GetOrPatch(r.URL.Query())
|
||||
stream, _ := streams.GetOrPatch(r.URL.Query())
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
@@ -145,7 +145,7 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func handlerWS(tr *ws.Transport, _ *ws.Message) error {
|
||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
stream, _ := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stream := streams.GetOrPatch(query)
|
||||
stream, _ := streams.GetOrPatch(query)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
|
||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
stream, _ := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
|
||||
}
|
||||
|
||||
func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error {
|
||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
stream, _ := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
|
||||
if err = streams.Validate(uri); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return streams.GetProducer(uri)
|
||||
}
|
||||
|
||||
|
||||
+14
-10
@@ -1,10 +1,11 @@
|
||||
package ring
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
@@ -21,8 +22,7 @@ func Init() {
|
||||
|
||||
func apiRing(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
var ringAPI *ring.RingRestClient
|
||||
var err error
|
||||
var ringAPI *ring.RingApi
|
||||
|
||||
// Check auth method
|
||||
if email := query.Get("email"); email != "" {
|
||||
@@ -30,7 +30,8 @@ func apiRing(w http.ResponseWriter, r *http.Request) {
|
||||
password := query.Get("password")
|
||||
code := query.Get("code")
|
||||
|
||||
ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{
|
||||
var err error
|
||||
ringAPI, err = ring.NewRestClient(ring.EmailAuth{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}, nil)
|
||||
@@ -44,7 +45,7 @@ func apiRing(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err = ringAPI.GetAuth(code); err != nil {
|
||||
if ringAPI.Using2FA {
|
||||
// Return 2FA prompt
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
api.ResponseJSON(w, map[string]interface{}{
|
||||
"needs_2fa": true,
|
||||
"prompt": ringAPI.PromptFor2FA,
|
||||
})
|
||||
@@ -53,36 +54,39 @@ func apiRing(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
} else if refreshToken := query.Get("refresh_token"); refreshToken != "" {
|
||||
// Refresh Token Flow
|
||||
refreshToken := query.Get("refresh_token")
|
||||
if refreshToken == "" {
|
||||
http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ringAPI, err = ring.NewRingRestClient(ring.RefreshTokenAuth{
|
||||
var err error
|
||||
ringAPI, err = ring.NewRestClient(ring.RefreshTokenAuth{
|
||||
RefreshToken: refreshToken,
|
||||
}, nil)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "either email/password or refresh token is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch devices
|
||||
devices, err := ringAPI.FetchRingDevices()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create clean query with only required parameters
|
||||
cleanQuery := url.Values{}
|
||||
cleanQuery.Set("refresh_token", ringAPI.RefreshToken)
|
||||
|
||||
var items []*api.Source
|
||||
for _, camera := range devices.AllCameras {
|
||||
cleanQuery.Set("camera_id", fmt.Sprint(camera.ID))
|
||||
cleanQuery.Set("device_id", camera.DeviceID)
|
||||
|
||||
// Stream source
|
||||
|
||||
+63
-5
@@ -5,10 +5,14 @@ import (
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/creds"
|
||||
"github.com/AlexxIT/go2rtc/pkg/probe"
|
||||
)
|
||||
|
||||
func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||
w = creds.SecretResponse(w)
|
||||
|
||||
query := r.URL.Query()
|
||||
src := query.Get("src")
|
||||
|
||||
@@ -27,7 +31,7 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cons := probe.NewProbe(query)
|
||||
cons := probe.Create("probe", query)
|
||||
if len(cons.Medias) != 0 {
|
||||
cons.WithRequest(r)
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
@@ -48,8 +52,8 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||
name = src
|
||||
}
|
||||
|
||||
if New(name, query["src"]...) == nil {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
if _, err := New(name, query["src"]...); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,8 +69,8 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass
|
||||
if Patch(name, src) == nil {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
if _, err := Patch(name, src); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
case "POST":
|
||||
@@ -120,5 +124,59 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
dot = append(dot, '}')
|
||||
|
||||
dot = []byte(creds.SecretString(string(dot)))
|
||||
|
||||
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 (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
@@ -15,6 +16,21 @@ func HandleFunc(scheme string, handler Handler) {
|
||||
handlers[scheme] = handler
|
||||
}
|
||||
|
||||
func SupportedSchemes() []string {
|
||||
uniqueKeys := make(map[string]struct{}, len(handlers)+len(redirects))
|
||||
for scheme := range handlers {
|
||||
uniqueKeys[scheme] = struct{}{}
|
||||
}
|
||||
for scheme := range redirects {
|
||||
uniqueKeys[scheme] = struct{}{}
|
||||
}
|
||||
resultKeys := make([]string, 0, len(uniqueKeys))
|
||||
for key := range uniqueKeys {
|
||||
resultKeys = append(resultKeys, key)
|
||||
}
|
||||
return resultKeys
|
||||
}
|
||||
|
||||
func HasProducer(url string) bool {
|
||||
if i := strings.IndexByte(url, ':'); i > 0 {
|
||||
scheme := url[:i]
|
||||
@@ -95,3 +111,24 @@ func GetConsumer(url string) (core.Consumer, func(), error) {
|
||||
|
||||
return nil, nil, errors.New("streams: unsupported scheme: " + url)
|
||||
}
|
||||
|
||||
var insecure = map[string]bool{}
|
||||
|
||||
func MarkInsecure(scheme string) {
|
||||
insecure[scheme] = true
|
||||
}
|
||||
|
||||
var sanitize = regexp.MustCompile(`\s`)
|
||||
|
||||
func Validate(source string) error {
|
||||
// TODO: Review the entire logic of insecure sources
|
||||
if i := strings.IndexByte(source, ':'); i > 0 {
|
||||
if insecure[source[:i]] {
|
||||
return errors.New("streams: source from insecure producer")
|
||||
}
|
||||
}
|
||||
if sanitize.MatchString(source) {
|
||||
return errors.New("streams: source with spaces may be insecure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
"errors"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -14,8 +13,9 @@ import (
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Streams map[string]any `yaml:"streams"`
|
||||
Publish map[string]any `yaml:"publish"`
|
||||
Streams map[string]any `yaml:"streams"`
|
||||
Publish map[string]any `yaml:"publish"`
|
||||
Preload map[string]string `yaml:"preload"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
@@ -28,34 +28,36 @@ func Init() {
|
||||
|
||||
api.HandleFunc("api/streams", apiStreams)
|
||||
api.HandleFunc("api/streams.dot", apiStreamsDOT)
|
||||
api.HandleFunc("api/preload", apiPreload)
|
||||
api.HandleFunc("api/schemes", apiSchemes)
|
||||
|
||||
if cfg.Publish == nil {
|
||||
if cfg.Publish == nil && cfg.Preload == nil {
|
||||
return
|
||||
}
|
||||
|
||||
time.AfterFunc(time.Second, func() {
|
||||
// range for nil map is OK
|
||||
for name, dst := range cfg.Publish {
|
||||
if stream := Get(name); stream != nil {
|
||||
Publish(stream, dst)
|
||||
}
|
||||
}
|
||||
for name, rawQuery := range cfg.Preload {
|
||||
if stream := Get(name); stream != nil {
|
||||
Preload(stream, rawQuery)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var sanitize = regexp.MustCompile(`\s`)
|
||||
|
||||
// Validate - not allow creating dynamic streams with spaces in the source
|
||||
func Validate(source string) error {
|
||||
if sanitize.MatchString(source) {
|
||||
return errors.New("streams: invalid dynamic source")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func New(name string, sources ...string) *Stream {
|
||||
func New(name string, sources ...string) (*Stream, error) {
|
||||
for _, source := range sources {
|
||||
if Validate(source) != nil {
|
||||
return nil
|
||||
if !HasProducer(source) {
|
||||
return nil, errors.New("streams: source not supported")
|
||||
}
|
||||
|
||||
if err := Validate(source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,10 +67,10 @@ func New(name string, sources ...string) *Stream {
|
||||
streams[name] = stream
|
||||
streamsMu.Unlock()
|
||||
|
||||
return stream
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
func Patch(name string, source string) *Stream {
|
||||
func Patch(name string, source string) (*Stream, error) {
|
||||
streamsMu.Lock()
|
||||
defer streamsMu.Unlock()
|
||||
|
||||
@@ -80,7 +82,7 @@ func Patch(name string, source string) *Stream {
|
||||
// link (alias) streams[name] to streams[rtspName]
|
||||
streams[name] = stream
|
||||
}
|
||||
return stream
|
||||
return stream, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,46 +91,44 @@ func Patch(name string, source string) *Stream {
|
||||
// link (alias) streams[name] to streams[source]
|
||||
streams[name] = stream
|
||||
}
|
||||
return stream
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
// check if src has supported scheme
|
||||
if !HasProducer(source) {
|
||||
return nil
|
||||
return nil, errors.New("streams: source not supported")
|
||||
}
|
||||
|
||||
if Validate(source) != nil {
|
||||
return nil
|
||||
if err := Validate(source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// check an existing stream with this name
|
||||
if stream, ok := streams[name]; ok {
|
||||
stream.SetSource(source)
|
||||
return stream
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
// create new stream with this name
|
||||
stream := NewStream(source)
|
||||
streams[name] = stream
|
||||
return stream
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
func GetOrPatch(query url.Values) *Stream {
|
||||
func GetOrPatch(query url.Values) (*Stream, error) {
|
||||
// check if src param exists
|
||||
source := query.Get("src")
|
||||
if source == "" {
|
||||
return nil
|
||||
return nil, errors.New("streams: source empty")
|
||||
}
|
||||
|
||||
// check if src is stream name
|
||||
if stream := Get(source); stream != nil {
|
||||
return stream
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
// check if name param provided
|
||||
if name := query.Get("name"); name != "" {
|
||||
log.Info().Msgf("[streams] create new stream url=%s", source)
|
||||
|
||||
return Patch(name, source)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,12 @@ func switchbotClient(rawURL string, query url.Values) (core.Producer, error) {
|
||||
v.Resolution = 0
|
||||
case "sd":
|
||||
v.Resolution = 1
|
||||
case "auto":
|
||||
v.Resolution = 2
|
||||
}
|
||||
|
||||
v.PlayType = core.Atoi(query.Get("play_type")) // zero by default
|
||||
|
||||
return v, nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) (err error) {
|
||||
|
||||
query := tr.Request.URL.Query()
|
||||
if name := query.Get("src"); name != "" {
|
||||
stream = streams.GetOrPatch(query)
|
||||
stream, _ = streams.GetOrPatch(query)
|
||||
mode = core.ModePassiveConsumer
|
||||
log.Debug().Str("src", name).Msg("[webrtc] new consumer")
|
||||
} else if name = query.Get("dst"); name != "" {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/alsa"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
@@ -44,68 +46,67 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
ws.Init() // init WS API endpoint
|
||||
|
||||
streams.Init() // streams module
|
||||
|
||||
// 2. Main sources and servers
|
||||
|
||||
rtsp.Init() // rtsp source, RTSP server
|
||||
webrtc.Init() // webrtc source, WebRTC server
|
||||
|
||||
// 3. Main API
|
||||
|
||||
mp4.Init() // MP4 API
|
||||
hls.Init() // HLS API
|
||||
mjpeg.Init() // MJPEG API
|
||||
|
||||
// 4. Other sources and servers
|
||||
|
||||
hass.Init() // hass source, Hass API server
|
||||
onvif.Init() // onvif source, ONVIF API server
|
||||
webtorrent.Init() // webtorrent source, WebTorrent module
|
||||
wyoming.Init()
|
||||
|
||||
// 5. Other sources
|
||||
|
||||
rtmp.Init() // rtmp source
|
||||
exec.Init() // exec source
|
||||
ffmpeg.Init() // ffmpeg source
|
||||
echo.Init() // echo source
|
||||
ivideon.Init() // ivideon source
|
||||
http.Init() // http/tcp source
|
||||
dvrip.Init() // dvrip source
|
||||
tapo.Init() // tapo source
|
||||
isapi.Init() // isapi source
|
||||
mpegts.Init() // mpegts passive source
|
||||
roborock.Init() // roborock source
|
||||
homekit.Init() // homekit source
|
||||
ring.Init() // ring source
|
||||
nest.Init() // nest source
|
||||
bubble.Init() // bubble source
|
||||
expr.Init() // expr source
|
||||
gopro.Init() // gopro source
|
||||
doorbird.Init() // doorbird source
|
||||
v4l2.Init() // v4l2 source
|
||||
alsa.Init() // alsa source
|
||||
flussonic.Init()
|
||||
eseecloud.Init()
|
||||
yandex.Init()
|
||||
|
||||
// 6. Helper modules
|
||||
|
||||
ngrok.Init() // ngrok module
|
||||
srtp.Init() // SRTP server
|
||||
debug.Init() // debug API
|
||||
|
||||
// 7. Go
|
||||
for _, m := range modules {
|
||||
if app.Modules == nil || m.name == "" || slices.Contains(app.Modules, m.name) {
|
||||
m.init()
|
||||
}
|
||||
}
|
||||
|
||||
shell.RunUntilSignal()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
_, err = c.conn.Read(nil)
|
||||
// just block until c.conn closed
|
||||
b := make([]byte, 1)
|
||||
_, err = c.conn.Read(b)
|
||||
return
|
||||
}
|
||||
|
||||
+38
-12
@@ -9,11 +9,12 @@ import (
|
||||
)
|
||||
|
||||
func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
||||
//vps, sps, pps := GetParameterSet(codec.FmtpLine)
|
||||
//ps := h264.EncodeAVC(vps, sps, pps)
|
||||
vps, sps, pps := GetParameterSet(codec.FmtpLine)
|
||||
ps := h264.JoinNALU(vps, sps, pps)
|
||||
|
||||
buf := make([]byte, 0, 512*1024) // 512K
|
||||
var nuStart int
|
||||
var seqNum uint16
|
||||
|
||||
return func(packet *rtp.Packet) {
|
||||
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 {
|
||||
switch data[2] >> 6 {
|
||||
case 2: // begin
|
||||
case 0b10: // begin
|
||||
nuType = data[2] & 0x3F
|
||||
|
||||
// push PS data before keyframe
|
||||
//if len(buf) == 0 && nuType >= 19 && nuType <= 21 {
|
||||
// buf = append(buf, ps...)
|
||||
//}
|
||||
if len(buf) == 0 && nuType >= 19 && nuType <= 21 {
|
||||
buf = append(buf, ps...)
|
||||
}
|
||||
|
||||
nuStart = len(buf)
|
||||
buf = append(buf, 0, 0, 0, 0) // NAL unit size
|
||||
buf = append(buf, (data[0]&0x81)|(nuType<<1), data[1])
|
||||
buf = append(buf, data[3:]...)
|
||||
return
|
||||
case 0: // continue
|
||||
case 0b00: // continue
|
||||
if len(buf) == 0 {
|
||||
//log.Printf("broken H265 fragment")
|
||||
return
|
||||
}
|
||||
|
||||
buf = append(buf, data[3:]...)
|
||||
return
|
||||
case 1: // end
|
||||
case 0b01: // end
|
||||
if len(buf) == 0 {
|
||||
//log.Printf("broken H265 fragment")
|
||||
return
|
||||
}
|
||||
|
||||
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))
|
||||
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.,
|
||||
// the Start bit and End bit must not both be set to 1 in the same FU
|
||||
// header.
|
||||
@@ -65,10 +93,8 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
||||
buf = append(buf, data[3:]...)
|
||||
}
|
||||
} else {
|
||||
nuStart = len(buf)
|
||||
buf = append(buf, 0, 0, 0, 0) // NAL unit size
|
||||
buf = binary.BigEndian.AppendUint32(buf, uint32(len(data))) // NAL unit size
|
||||
buf = append(buf, data...)
|
||||
binary.BigEndian.PutUint32(buf[nuStart:], uint32(len(data)))
|
||||
}
|
||||
|
||||
// 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{
|
||||
Status: StreamingStatusAvailable,
|
||||
})
|
||||
val114, _ := tlv8.MarshalBase64(SupportedVideoStreamConfig{
|
||||
Codecs: []VideoCodec{
|
||||
val114, _ := tlv8.MarshalBase64(SupportedVideoStreamConfiguration{
|
||||
Codecs: []VideoCodecConfiguration{
|
||||
{
|
||||
CodecType: VideoCodecTypeH264,
|
||||
CodecParams: []VideoParams{
|
||||
CodecParams: []VideoCodecParameters{
|
||||
{
|
||||
ProfileID: []byte{VideoCodecProfileMain},
|
||||
Level: []byte{VideoCodecLevel31, VideoCodecLevel40},
|
||||
},
|
||||
},
|
||||
VideoAttrs: []VideoAttrs{
|
||||
VideoAttrs: []VideoCodecAttributes{
|
||||
{Width: 1920, Height: 1080, Framerate: 30},
|
||||
{Width: 1280, Height: 720, Framerate: 30}, // important for iPhones
|
||||
{Width: 320, Height: 240, Framerate: 15}, // apple watch
|
||||
@@ -67,23 +67,23 @@ func ServiceCameraRTPStreamManagement() *hap.Service {
|
||||
},
|
||||
},
|
||||
})
|
||||
val115, _ := tlv8.MarshalBase64(SupportedAudioStreamConfig{
|
||||
Codecs: []AudioCodec{
|
||||
val115, _ := tlv8.MarshalBase64(SupportedAudioStreamConfiguration{
|
||||
Codecs: []AudioCodecConfiguration{
|
||||
{
|
||||
CodecType: AudioCodecTypeOpus,
|
||||
CodecParams: []AudioParams{
|
||||
CodecParams: []AudioCodecParameters{
|
||||
{
|
||||
Channels: 1,
|
||||
Bitrate: AudioCodecBitrateVariable,
|
||||
SampleRate: []byte{AudioCodecSampleRate16Khz},
|
||||
Channels: 1,
|
||||
BitrateMode: AudioCodecBitrateVariable,
|
||||
SampleRate: []byte{AudioCodecSampleRate16Khz},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ComfortNoise: 0,
|
||||
ComfortNoiseSupport: 0,
|
||||
})
|
||||
val116, _ := tlv8.MarshalBase64(SupportedRTPConfig{
|
||||
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
|
||||
val116, _ := tlv8.MarshalBase64(SupportedRTPConfiguration{
|
||||
SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
|
||||
})
|
||||
|
||||
service := &hap.Service{
|
||||
|
||||
@@ -63,19 +63,19 @@ func TestAqaraG3(t *testing.T) {
|
||||
{
|
||||
name: "114",
|
||||
value: "AaoBAQACEQEBAQIBAAAAAgECAwEABAEAAwsBAoAHAgI4BAMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAkABAgK0AAMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAgAEAgIAAwMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAkABAgLwAAMBHg==",
|
||||
actual: &SupportedVideoStreamConfig{},
|
||||
expect: &SupportedVideoStreamConfig{
|
||||
Codecs: []VideoCodec{
|
||||
actual: &SupportedVideoStreamConfiguration{},
|
||||
expect: &SupportedVideoStreamConfiguration{
|
||||
Codecs: []VideoCodecConfiguration{
|
||||
{
|
||||
CodecType: VideoCodecTypeH264,
|
||||
CodecParams: []VideoParams{
|
||||
CodecParams: []VideoCodecParameters{
|
||||
{
|
||||
ProfileID: []byte{VideoCodecProfileMain},
|
||||
Level: []byte{VideoCodecLevel31, VideoCodecLevel40},
|
||||
CVOEnabled: []byte{0},
|
||||
},
|
||||
},
|
||||
VideoAttrs: []VideoAttrs{
|
||||
VideoAttrs: []VideoCodecAttributes{
|
||||
{Width: 1920, Height: 1080, Framerate: 30},
|
||||
{Width: 1280, Height: 720, Framerate: 30},
|
||||
{Width: 640, Height: 360, Framerate: 30},
|
||||
@@ -94,29 +94,29 @@ func TestAqaraG3(t *testing.T) {
|
||||
{
|
||||
name: "115",
|
||||
value: "AQ4BAQICCQEBAQIBAAMBAQIBAA==",
|
||||
actual: &SupportedAudioStreamConfig{},
|
||||
expect: &SupportedAudioStreamConfig{
|
||||
Codecs: []AudioCodec{
|
||||
actual: &SupportedAudioStreamConfiguration{},
|
||||
expect: &SupportedAudioStreamConfiguration{
|
||||
Codecs: []AudioCodecConfiguration{
|
||||
{
|
||||
CodecType: AudioCodecTypeAACELD,
|
||||
CodecParams: []AudioParams{
|
||||
CodecParams: []AudioCodecParameters{
|
||||
{
|
||||
Channels: 1,
|
||||
Bitrate: AudioCodecBitrateVariable,
|
||||
SampleRate: []byte{AudioCodecSampleRate16Khz},
|
||||
Channels: 1,
|
||||
BitrateMode: AudioCodecBitrateVariable,
|
||||
SampleRate: []byte{AudioCodecSampleRate16Khz},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ComfortNoise: 0,
|
||||
ComfortNoiseSupport: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "116",
|
||||
value: "AgEAAAACAQEAAAIBAg==",
|
||||
actual: &SupportedRTPConfig{},
|
||||
expect: &SupportedRTPConfig{
|
||||
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoAES_CM_256_HMAC_SHA1_80, CryptoNone},
|
||||
actual: &SupportedRTPConfiguration{},
|
||||
expect: &SupportedRTPConfiguration{
|
||||
SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoAES_CM_256_HMAC_SHA1_80, CryptoDisabled},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -130,18 +130,18 @@ func TestHomebridge(t *testing.T) {
|
||||
{
|
||||
name: "114",
|
||||
value: "AcUBAQACHQEBAAAAAQEBAAABAQICAQAAAAIBAQAAAgECAwEAAwsBAkABAgK0AAMBHgAAAwsBAkABAgLwAAMBDwAAAwsBAkABAgLwAAMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAoAHAgI4BAMBHgAAAwsBAkAGAgKwBAMBHg==",
|
||||
actual: &SupportedVideoStreamConfig{},
|
||||
expect: &SupportedVideoStreamConfig{
|
||||
Codecs: []VideoCodec{
|
||||
actual: &SupportedVideoStreamConfiguration{},
|
||||
expect: &SupportedVideoStreamConfiguration{
|
||||
Codecs: []VideoCodecConfiguration{
|
||||
{
|
||||
CodecType: VideoCodecTypeH264,
|
||||
CodecParams: []VideoParams{
|
||||
CodecParams: []VideoCodecParameters{
|
||||
{
|
||||
ProfileID: []byte{VideoCodecProfileConstrainedBaseline, VideoCodecProfileMain, VideoCodecProfileHigh},
|
||||
Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40},
|
||||
},
|
||||
},
|
||||
VideoAttrs: []VideoAttrs{
|
||||
VideoAttrs: []VideoCodecAttributes{
|
||||
|
||||
{Width: 320, Height: 180, Framerate: 30},
|
||||
{Width: 320, Height: 240, Framerate: 15},
|
||||
@@ -162,9 +162,9 @@ func TestHomebridge(t *testing.T) {
|
||||
{
|
||||
name: "116",
|
||||
value: "AgEA",
|
||||
actual: &SupportedRTPConfig{},
|
||||
expect: &SupportedRTPConfig{
|
||||
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
|
||||
actual: &SupportedRTPConfiguration{},
|
||||
expect: &SupportedRTPConfiguration{
|
||||
SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -178,18 +178,18 @@ func TestScrypted(t *testing.T) {
|
||||
{
|
||||
name: "114",
|
||||
value: "AVIBAQACEwEBAQIBAAAAAgEBAAACAQIDAQADCwECAA8CAnAIAwEeAAADCwECgAcCAjgEAwEeAAADCwECAAUCAtACAwEeAAADCwECQAECAvAAAwEP",
|
||||
actual: &SupportedVideoStreamConfig{},
|
||||
expect: &SupportedVideoStreamConfig{
|
||||
Codecs: []VideoCodec{
|
||||
actual: &SupportedVideoStreamConfiguration{},
|
||||
expect: &SupportedVideoStreamConfiguration{
|
||||
Codecs: []VideoCodecConfiguration{
|
||||
{
|
||||
CodecType: VideoCodecTypeH264,
|
||||
CodecParams: []VideoParams{
|
||||
CodecParams: []VideoCodecParameters{
|
||||
{
|
||||
ProfileID: []byte{VideoCodecProfileMain},
|
||||
Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40},
|
||||
},
|
||||
},
|
||||
VideoAttrs: []VideoAttrs{
|
||||
VideoAttrs: []VideoCodecAttributes{
|
||||
{Width: 3840, Height: 2160, Framerate: 30},
|
||||
{Width: 1920, Height: 1080, Framerate: 30},
|
||||
{Width: 1280, Height: 720, Framerate: 30},
|
||||
@@ -202,15 +202,15 @@ func TestScrypted(t *testing.T) {
|
||||
{
|
||||
name: "115",
|
||||
value: "AScBAQMCIgEBAQIBAAMBAAAAAwEAAAADAQEAAAMBAQAAAwECAAADAQICAQA=",
|
||||
actual: &SupportedAudioStreamConfig{},
|
||||
expect: &SupportedAudioStreamConfig{
|
||||
Codecs: []AudioCodec{
|
||||
actual: &SupportedAudioStreamConfiguration{},
|
||||
expect: &SupportedAudioStreamConfiguration{
|
||||
Codecs: []AudioCodecConfiguration{
|
||||
{
|
||||
CodecType: AudioCodecTypeOpus,
|
||||
CodecParams: []AudioParams{
|
||||
CodecParams: []AudioCodecParameters{
|
||||
{
|
||||
Channels: 1,
|
||||
Bitrate: AudioCodecBitrateVariable,
|
||||
Channels: 1,
|
||||
BitrateMode: AudioCodecBitrateVariable,
|
||||
SampleRate: []byte{
|
||||
AudioCodecSampleRate8Khz, AudioCodecSampleRate8Khz,
|
||||
AudioCodecSampleRate16Khz, AudioCodecSampleRate16Khz,
|
||||
@@ -220,15 +220,15 @@ func TestScrypted(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
ComfortNoise: 0,
|
||||
ComfortNoiseSupport: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "116",
|
||||
value: "AgEAAAACAQI=",
|
||||
actual: &SupportedRTPConfig{},
|
||||
expect: &SupportedRTPConfig{
|
||||
CryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoNone},
|
||||
actual: &SupportedRTPConfiguration{},
|
||||
expect: &SupportedRTPConfiguration{
|
||||
SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoDisabled},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ package camera
|
||||
|
||||
const TypeSupportedVideoStreamConfiguration = "114"
|
||||
|
||||
type SupportedVideoStreamConfig struct {
|
||||
Codecs []VideoCodec `tlv8:"1"`
|
||||
type SupportedVideoStreamConfiguration struct {
|
||||
Codecs []VideoCodecConfiguration `tlv8:"1"`
|
||||
}
|
||||
|
||||
type VideoCodec struct {
|
||||
CodecType byte `tlv8:"1"`
|
||||
CodecParams []VideoParams `tlv8:"2"`
|
||||
VideoAttrs []VideoAttrs `tlv8:"3"`
|
||||
RTPParams []RTPParams `tlv8:"4"`
|
||||
type VideoCodecConfiguration struct {
|
||||
CodecType byte `tlv8:"1"`
|
||||
CodecParams []VideoCodecParameters `tlv8:"2"`
|
||||
VideoAttrs []VideoCodecAttributes `tlv8:"3"`
|
||||
RTPParams []RTPParams `tlv8:"4"`
|
||||
}
|
||||
|
||||
//goland:noinspection ALL
|
||||
@@ -31,15 +31,15 @@ const (
|
||||
VideoCodecCvoSuppported = 1
|
||||
)
|
||||
|
||||
type VideoParams struct {
|
||||
type VideoCodecParameters struct {
|
||||
ProfileID []byte `tlv8:"1"` // 0 - baseline, 1 - main, 2 - high
|
||||
Level []byte `tlv8:"2"` // 0 - 3.1, 1 - 3.2, 2 - 4.0
|
||||
PacketizationMode byte `tlv8:"3"` // only 0 - non interleaved
|
||||
CVOEnabled []byte `tlv8:"4"` // 0 - not supported, 1 - supported
|
||||
CVOID []byte `tlv8:"5"` // ???
|
||||
CVOID []byte `tlv8:"5"` // ID for CVO RTP extensio
|
||||
}
|
||||
|
||||
type VideoAttrs struct {
|
||||
type VideoCodecAttributes struct {
|
||||
Width uint16 `tlv8:"1"`
|
||||
Height uint16 `tlv8:"2"`
|
||||
Framerate uint8 `tlv8:"3"`
|
||||
|
||||
@@ -2,9 +2,9 @@ package camera
|
||||
|
||||
const TypeSupportedAudioStreamConfiguration = "115"
|
||||
|
||||
type SupportedAudioStreamConfig struct {
|
||||
Codecs []AudioCodec `tlv8:"1"`
|
||||
ComfortNoise byte `tlv8:"2"`
|
||||
type SupportedAudioStreamConfiguration struct {
|
||||
Codecs []AudioCodecConfiguration `tlv8:"1"`
|
||||
ComfortNoiseSupport byte `tlv8:"2"`
|
||||
}
|
||||
|
||||
//goland:noinspection ALL
|
||||
@@ -31,16 +31,16 @@ const (
|
||||
RTPTimeAACLD24 = 40 // 24000/1000*40=960
|
||||
)
|
||||
|
||||
type AudioCodec struct {
|
||||
CodecType byte `tlv8:"1"`
|
||||
CodecParams []AudioParams `tlv8:"2"`
|
||||
RTPParams []RTPParams `tlv8:"3"`
|
||||
ComfortNoise []byte `tlv8:"4"`
|
||||
type AudioCodecConfiguration struct {
|
||||
CodecType byte `tlv8:"1"`
|
||||
CodecParams []AudioCodecParameters `tlv8:"2"`
|
||||
RTPParams []RTPParams `tlv8:"3"`
|
||||
ComfortNoise []byte `tlv8:"4"`
|
||||
}
|
||||
|
||||
type AudioParams struct {
|
||||
Channels uint8 `tlv8:"1"`
|
||||
Bitrate byte `tlv8:"2"` // 0 - variable, 1 - constant
|
||||
SampleRate []byte `tlv8:"3"` // 0 - 8000, 1 - 16000, 2 - 24000
|
||||
RTPTime []uint8 `tlv8:"4"` // 20, 30, 40, 60
|
||||
type AudioCodecParameters struct {
|
||||
Channels uint8 `tlv8:"1"`
|
||||
BitrateMode byte `tlv8:"2"` // 0 - variable, 1 - constant
|
||||
SampleRate []byte `tlv8:"3"` // 0 - 8000, 1 - 16000, 2 - 24000
|
||||
RTPTime []uint8 `tlv8:"4"` // 20, 30, 40, 60
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ const TypeSupportedRTPConfiguration = "116"
|
||||
const (
|
||||
CryptoAES_CM_128_HMAC_SHA1_80 = 0
|
||||
CryptoAES_CM_256_HMAC_SHA1_80 = 1
|
||||
CryptoNone = 2
|
||||
CryptoDisabled = 2
|
||||
)
|
||||
|
||||
type SupportedRTPConfig struct {
|
||||
CryptoType []byte `tlv8:"2"`
|
||||
type SupportedRTPConfiguration struct {
|
||||
SRTPCryptoType []byte `tlv8:"2"`
|
||||
}
|
||||
@@ -2,10 +2,10 @@ package camera
|
||||
|
||||
const TypeSelectedStreamConfiguration = "117"
|
||||
|
||||
type SelectedStreamConfig struct {
|
||||
Control SessionControl `tlv8:"1"`
|
||||
VideoCodec VideoCodec `tlv8:"2"`
|
||||
AudioCodec AudioCodec `tlv8:"3"`
|
||||
type SelectedStreamConfiguration struct {
|
||||
Control SessionControl `tlv8:"1"`
|
||||
VideoCodec VideoCodecConfiguration `tlv8:"2"`
|
||||
AudioCodec AudioCodecConfiguration `tlv8:"3"`
|
||||
}
|
||||
|
||||
//goland:noinspection ALL
|
||||
|
||||
@@ -2,25 +2,32 @@ package camera
|
||||
|
||||
const TypeSetupEndpoints = "118"
|
||||
|
||||
type SetupEndpoints struct {
|
||||
SessionID string `tlv8:"1"`
|
||||
Status []byte `tlv8:"2"`
|
||||
Address Addr `tlv8:"3"`
|
||||
VideoCrypto CryptoSuite `tlv8:"4"`
|
||||
AudioCrypto CryptoSuite `tlv8:"5"`
|
||||
VideoSSRC []uint32 `tlv8:"6"`
|
||||
AudioSSRC []uint32 `tlv8:"7"`
|
||||
type SetupEndpointsRequest struct {
|
||||
SessionID string `tlv8:"1"`
|
||||
Address Address `tlv8:"3"`
|
||||
VideoCrypto SRTPCryptoSuite `tlv8:"4"`
|
||||
AudioCrypto SRTPCryptoSuite `tlv8:"5"`
|
||||
}
|
||||
|
||||
type Addr struct {
|
||||
type SetupEndpointsResponse struct {
|
||||
SessionID string `tlv8:"1"`
|
||||
Status byte `tlv8:"2"`
|
||||
Address Address `tlv8:"3"`
|
||||
VideoCrypto SRTPCryptoSuite `tlv8:"4"`
|
||||
AudioCrypto SRTPCryptoSuite `tlv8:"5"`
|
||||
VideoSSRC uint32 `tlv8:"6"`
|
||||
AudioSSRC uint32 `tlv8:"7"`
|
||||
}
|
||||
|
||||
type Address struct {
|
||||
IPVersion byte `tlv8:"1"`
|
||||
IPAddr string `tlv8:"2"`
|
||||
VideoRTPPort uint16 `tlv8:"3"`
|
||||
AudioRTPPort uint16 `tlv8:"4"`
|
||||
}
|
||||
|
||||
type CryptoSuite struct {
|
||||
CryptoType byte `tlv8:"1"`
|
||||
MasterKey string `tlv8:"2"` // 16 (AES_CM_128) or 32 (AES_256_CM)
|
||||
MasterSalt string `tlv8:"3"` // 14 byte
|
||||
type SRTPCryptoSuite struct {
|
||||
CryptoSuite byte `tlv8:"1"`
|
||||
MasterKey string `tlv8:"2"` // 16 (AES_CM_128) or 32 (AES_256_CM)
|
||||
MasterSalt string `tlv8:"3"` // 14 byte
|
||||
}
|
||||
|
||||
@@ -9,6 +9,6 @@ type StreamingStatus struct {
|
||||
//goland:noinspection ALL
|
||||
const (
|
||||
StreamingStatusAvailable = 0
|
||||
StreamingStatusBusy = 1
|
||||
StreamingStatusInUse = 1
|
||||
StreamingStatusUnavailable = 2
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package camera
|
||||
|
||||
const TypeSupportedDataStreamTransportConfiguration = "130"
|
||||
|
||||
type SupportedDataStreamTransportConfiguration struct {
|
||||
Configs []TransferTransportConfiguration `tlv8:"1"`
|
||||
}
|
||||
|
||||
type TransferTransportConfiguration struct {
|
||||
TransportType byte `tlv8:"1"`
|
||||
}
|
||||
@@ -2,13 +2,13 @@ package camera
|
||||
|
||||
const TypeSetupDataStreamTransport = "131"
|
||||
|
||||
type SetupDataStreamRequest struct {
|
||||
type SetupDataStreamTransportRequest struct {
|
||||
SessionCommandType byte `tlv8:"1"`
|
||||
TransportType byte `tlv8:"2"`
|
||||
ControllerKeySalt string `tlv8:"3"`
|
||||
}
|
||||
|
||||
type SetupDataStreamResponse struct {
|
||||
type SetupDataStreamTransportResponse struct {
|
||||
Status byte `tlv8:"1"`
|
||||
TransportTypeSessionParameters struct {
|
||||
TCPListeningPort uint16 `tlv8:"1"`
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package camera
|
||||
|
||||
const TypeSupportedCameraRecordingConfiguration = "205"
|
||||
|
||||
type SupportedCameraRecordingConfiguration struct {
|
||||
PrebufferLength uint32 `tlv8:"1"`
|
||||
EventTriggerOptions uint64 `tlv8:"2"`
|
||||
MediaContainerConfigurations `tlv8:"3"`
|
||||
}
|
||||
|
||||
type MediaContainerConfigurations struct {
|
||||
MediaContainerType uint8 `tlv8:"1"`
|
||||
MediaContainerParameters `tlv8:"2"`
|
||||
}
|
||||
|
||||
type MediaContainerParameters struct {
|
||||
FragmentLength uint32 `tlv8:"1"`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package camera
|
||||
|
||||
const TypeSupportedVideoRecordingConfiguration = "206"
|
||||
|
||||
type SupportedVideoRecordingConfiguration struct {
|
||||
CodecConfigs []VideoRecordingCodecConfiguration `tlv8:"1"`
|
||||
}
|
||||
|
||||
type VideoRecordingCodecConfiguration struct {
|
||||
CodecType uint8 `tlv8:"1"`
|
||||
CodecParams VideoRecordingCodecParameters `tlv8:"2"`
|
||||
CodecAttrs VideoCodecAttributes `tlv8:"3"`
|
||||
}
|
||||
|
||||
type VideoRecordingCodecParameters struct {
|
||||
ProfileID uint8 `tlv8:"1"`
|
||||
Level uint8 `tlv8:"2"`
|
||||
Bitrate uint32 `tlv8:"3"`
|
||||
IFrameInterval uint32 `tlv8:"4"`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package camera
|
||||
|
||||
const TypeSupportedAudioRecordingConfiguration = "207"
|
||||
|
||||
type SupportedAudioRecordingConfiguration struct {
|
||||
CodecConfigs []AudioRecordingCodecConfiguration `tlv8:"1"`
|
||||
}
|
||||
|
||||
type AudioRecordingCodecConfiguration struct {
|
||||
CodecType byte `tlv8:"1"`
|
||||
CodecParams []AudioRecordingCodecParameters `tlv8:"2"`
|
||||
}
|
||||
|
||||
type AudioRecordingCodecParameters struct {
|
||||
Channels uint8 `tlv8:"1"`
|
||||
BitrateMode []byte `tlv8:"2"`
|
||||
SampleRate []byte `tlv8:"3"`
|
||||
MaxAudioBitrate []uint32 `tlv8:"4"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package camera
|
||||
|
||||
const TypeSelectedCameraRecordingConfiguration = "209"
|
||||
|
||||
type SelectedCameraRecordingConfiguration struct {
|
||||
GeneralConfig SupportedCameraRecordingConfiguration `tlv8:"1"`
|
||||
VideoConfig SupportedVideoRecordingConfiguration `tlv8:"2"`
|
||||
AudioConfig SupportedAudioRecordingConfiguration `tlv8:"3"`
|
||||
}
|
||||
+11
-11
@@ -15,7 +15,7 @@ type Stream struct {
|
||||
}
|
||||
|
||||
func NewStream(
|
||||
client *hap.Client, videoCodec *VideoCodec, audioCodec *AudioCodec,
|
||||
client *hap.Client, videoCodec *VideoCodecConfiguration, audioCodec *AudioCodecConfiguration,
|
||||
videoSession, audioSession *srtp.Session, bitrate int,
|
||||
) (*Stream, error) {
|
||||
stream := &Stream{
|
||||
@@ -58,7 +58,7 @@ func NewStream(
|
||||
}
|
||||
audioCodec.ComfortNoise = []byte{0}
|
||||
|
||||
config := &SelectedStreamConfig{
|
||||
config := &SelectedStreamConfiguration{
|
||||
Control: SessionControl{
|
||||
SessionID: stream.id,
|
||||
Command: SessionCommandStart,
|
||||
@@ -103,19 +103,19 @@ func (s *Stream) GetFreeStream() error {
|
||||
}
|
||||
|
||||
func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) error {
|
||||
req := SetupEndpoints{
|
||||
req := SetupEndpointsRequest{
|
||||
SessionID: s.id,
|
||||
Address: Addr{
|
||||
Address: Address{
|
||||
IPVersion: 0,
|
||||
IPAddr: videoSession.Local.Addr,
|
||||
VideoRTPPort: videoSession.Local.Port,
|
||||
AudioRTPPort: audioSession.Local.Port,
|
||||
},
|
||||
VideoCrypto: CryptoSuite{
|
||||
VideoCrypto: SRTPCryptoSuite{
|
||||
MasterKey: string(videoSession.Local.MasterKey),
|
||||
MasterSalt: string(videoSession.Local.MasterSalt),
|
||||
},
|
||||
AudioCrypto: CryptoSuite{
|
||||
AudioCrypto: SRTPCryptoSuite{
|
||||
MasterKey: string(audioSession.Local.MasterKey),
|
||||
MasterSalt: string(audioSession.Local.MasterSalt),
|
||||
},
|
||||
@@ -129,7 +129,7 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err
|
||||
return err
|
||||
}
|
||||
|
||||
var res SetupEndpoints
|
||||
var res SetupEndpointsResponse
|
||||
if err := s.client.GetCharacter(char); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err
|
||||
Port: res.Address.VideoRTPPort,
|
||||
MasterKey: []byte(res.VideoCrypto.MasterKey),
|
||||
MasterSalt: []byte(res.VideoCrypto.MasterSalt),
|
||||
SSRC: res.VideoSSRC[0],
|
||||
SSRC: res.VideoSSRC,
|
||||
}
|
||||
|
||||
audioSession.Remote = &srtp.Endpoint{
|
||||
@@ -150,13 +150,13 @@ func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) err
|
||||
Port: res.Address.AudioRTPPort,
|
||||
MasterKey: []byte(res.AudioCrypto.MasterKey),
|
||||
MasterSalt: []byte(res.AudioCrypto.MasterSalt),
|
||||
SSRC: res.AudioSSRC[0],
|
||||
SSRC: res.AudioSSRC,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Stream) SetStreamConfig(config *SelectedStreamConfig) error {
|
||||
func (s *Stream) SetStreamConfig(config *SelectedStreamConfiguration) error {
|
||||
char := s.service.GetCharacter(TypeSelectedStreamConfiguration)
|
||||
if err := char.Write(config); err != nil {
|
||||
return err
|
||||
@@ -169,7 +169,7 @@ func (s *Stream) SetStreamConfig(config *SelectedStreamConfig) error {
|
||||
}
|
||||
|
||||
func (s *Stream) Close() error {
|
||||
config := &SelectedStreamConfig{
|
||||
config := &SelectedStreamConfiguration{
|
||||
Control: SessionControl{
|
||||
SessionID: s.id,
|
||||
Command: SessionCommandEnd,
|
||||
|
||||
+11
-5
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/curve25519"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
)
|
||||
@@ -46,7 +45,7 @@ type Client struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func NewClient(rawURL string) (*Client, error) {
|
||||
func Dial(rawURL string) (*Client, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -61,6 +60,10 @@ func NewClient(rawURL string) (*Client, error) {
|
||||
ClientPrivate: DecodeKey(query.Get("client_private")),
|
||||
}
|
||||
|
||||
if err = c.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -96,6 +99,7 @@ func (c *Client) Dial() (err error) {
|
||||
return false
|
||||
})
|
||||
|
||||
// TODO: close conn on error
|
||||
if c.Conn, err = net.DialTimeout("tcp", c.DeviceAddress, ConnDialTimeout); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -124,7 +128,7 @@ func (c *Client) Dial() (err error) {
|
||||
EncryptedData string `tlv8:"5"`
|
||||
State byte `tlv8:"6"`
|
||||
}
|
||||
if err = tlv8.UnmarshalReader(res.Body, &cipherM2); err != nil {
|
||||
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &cipherM2); err != nil {
|
||||
return err
|
||||
}
|
||||
if cipherM2.State != StateM2 {
|
||||
@@ -209,15 +213,17 @@ func (c *Client) Dial() (err error) {
|
||||
var plainM4 struct {
|
||||
State byte `tlv8:"6"`
|
||||
}
|
||||
if err = tlv8.UnmarshalReader(res.Body, &plainM4); err != nil {
|
||||
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM4); err != nil {
|
||||
return
|
||||
}
|
||||
if plainM4.State != StateM4 {
|
||||
return newResponseError(cipherM3, plainM4)
|
||||
}
|
||||
|
||||
rw := bufio.NewReadWriter(c.reader, bufio.NewWriter(c.Conn))
|
||||
|
||||
// like tls.Client wrapper over net.Conn
|
||||
if c.Conn, err = secure.Client(c.Conn, sessionShared, true); err != nil {
|
||||
if c.Conn, err = NewConn(c.Conn, rw, sessionShared, true); err != nil {
|
||||
return
|
||||
}
|
||||
// new reader for new conn
|
||||
|
||||
@@ -82,3 +82,20 @@ func ReadResponse(r *bufio.Reader, req *http.Request) (*http.Response, error) {
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func WriteEvent(w io.Writer, res *http.Response) error {
|
||||
return res.Write(&eventWriter{w: w})
|
||||
}
|
||||
|
||||
type eventWriter struct {
|
||||
w io.Writer
|
||||
done bool
|
||||
}
|
||||
|
||||
func (e *eventWriter) Write(p []byte) (n int, err error) {
|
||||
if !e.done {
|
||||
p = append([]byte("EVENT/1.0"), p[8:]...)
|
||||
e.done = true
|
||||
}
|
||||
return e.w.Write(p)
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
||||
State byte `tlv8:"6"`
|
||||
Error byte `tlv8:"7"`
|
||||
}
|
||||
if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil {
|
||||
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil {
|
||||
return
|
||||
}
|
||||
if plainM2.State != StateM2 {
|
||||
@@ -121,9 +121,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
||||
username := []byte("Pair-Setup")
|
||||
|
||||
// Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE)
|
||||
pake, err := srp.NewSRP(
|
||||
"rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username),
|
||||
)
|
||||
pake, err := srp.NewSRP("rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -132,6 +130,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
||||
|
||||
// username: "Pair-Setup", password: PIN (with dashes)
|
||||
session := pake.NewClientSession(username, []byte(pin))
|
||||
|
||||
sessionShared, err := session.ComputeKey([]byte(plainM2.Salt), []byte(plainM2.SessionKey))
|
||||
if err != nil {
|
||||
return
|
||||
@@ -159,7 +158,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
||||
|
||||
EncryptedData string `tlv8:"5"` // skip EncryptedData validation (for MFi devices)
|
||||
}
|
||||
if err = tlv8.UnmarshalReader(res.Body, &plainM4); err != nil {
|
||||
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM4); err != nil {
|
||||
return
|
||||
}
|
||||
if plainM4.State != StateM4 {
|
||||
@@ -232,7 +231,7 @@ func (c *Client) Pair(feature, pin string) (err error) {
|
||||
State byte `tlv8:"6"`
|
||||
Error byte `tlv8:"7"`
|
||||
}{}
|
||||
if err = tlv8.UnmarshalReader(res.Body, &cipherM6); err != nil {
|
||||
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &cipherM6); err != nil {
|
||||
return
|
||||
}
|
||||
if cipherM6.State != StateM6 || cipherM6.Error != 0 {
|
||||
@@ -296,7 +295,7 @@ func (c *Client) ListPairings() error {
|
||||
State byte `tlv8:"6"`
|
||||
Permission byte `tlv8:"11"`
|
||||
}
|
||||
if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil {
|
||||
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -329,7 +328,7 @@ func (c *Client) PairingsAdd(clientID string, clientPublic []byte, admin bool) e
|
||||
State byte `tlv8:"6"`
|
||||
Unknown byte `tlv8:"7"`
|
||||
}
|
||||
if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil {
|
||||
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -354,7 +353,7 @@ func (c *Client) DeletePairing(id string) error {
|
||||
var plainM2 struct {
|
||||
State byte `tlv8:"6"`
|
||||
}
|
||||
if err = tlv8.UnmarshalReader(res.Body, &plainM2); err != nil {
|
||||
if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil {
|
||||
return err
|
||||
}
|
||||
if plainM2.State != StateM2 {
|
||||
|
||||
@@ -1,32 +1,50 @@
|
||||
package secure
|
||||
package hap
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
||||
)
|
||||
|
||||
type Conn struct {
|
||||
conn net.Conn
|
||||
|
||||
rd *bufio.Reader
|
||||
wr *bufio.Writer
|
||||
rw *bufio.ReadWriter
|
||||
wmu sync.Mutex
|
||||
|
||||
encryptKey []byte
|
||||
decryptKey []byte
|
||||
encryptCnt uint64
|
||||
decryptCnt uint64
|
||||
|
||||
//ClientID string
|
||||
SharedKey []byte
|
||||
|
||||
recv int
|
||||
send int
|
||||
}
|
||||
|
||||
func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) {
|
||||
func (c *Conn) MarshalJSON() ([]byte, error) {
|
||||
conn := core.Connection{
|
||||
ID: core.ID(c),
|
||||
FormatName: "homekit",
|
||||
Protocol: "hap",
|
||||
RemoteAddr: c.conn.RemoteAddr().String(),
|
||||
Recv: c.recv,
|
||||
Send: c.send,
|
||||
}
|
||||
return json.Marshal(conn)
|
||||
}
|
||||
|
||||
func NewConn(conn net.Conn, rw *bufio.ReadWriter, sharedKey []byte, isClient bool) (*Conn, error) {
|
||||
key1, err := hkdf.Sha512(sharedKey, "Control-Salt", "Control-Read-Encryption-Key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -39,8 +57,7 @@ func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) {
|
||||
|
||||
c := &Conn{
|
||||
conn: conn,
|
||||
rd: bufio.NewReaderSize(conn, 32*1024),
|
||||
wr: bufio.NewWriterSize(conn, 32*1024),
|
||||
rw: rw,
|
||||
|
||||
SharedKey: sharedKey,
|
||||
}
|
||||
@@ -55,8 +72,8 @@ func Client(conn net.Conn, sharedKey []byte, isClient bool) (net.Conn, error) {
|
||||
}
|
||||
|
||||
const (
|
||||
// PacketSizeMax is the max length of encrypted packets
|
||||
PacketSizeMax = 0x400
|
||||
// packetSizeMax is the max length of encrypted packets
|
||||
packetSizeMax = 0x400
|
||||
|
||||
VerifySize = 2
|
||||
NonceSize = 8
|
||||
@@ -64,19 +81,19 @@ const (
|
||||
)
|
||||
|
||||
func (c *Conn) Read(b []byte) (n int, err error) {
|
||||
if cap(b) < PacketSizeMax {
|
||||
if cap(b) < packetSizeMax {
|
||||
return 0, errors.New("hap: read buffer is too small")
|
||||
}
|
||||
|
||||
verify := make([]byte, 2) // verify = plain message size
|
||||
if _, err = io.ReadFull(c.rd, verify); err != nil {
|
||||
verify := make([]byte, VerifySize) // verify = plain message size
|
||||
if _, err = io.ReadFull(c.rw, verify); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
n = int(binary.LittleEndian.Uint16(verify))
|
||||
ciphertext := make([]byte, n+Overhead)
|
||||
|
||||
if _, err = io.ReadFull(c.rd, ciphertext); err != nil {
|
||||
ciphertext := make([]byte, n+Overhead)
|
||||
if _, err = io.ReadFull(c.rw, ciphertext); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -85,22 +102,27 @@ func (c *Conn) Read(b []byte) (n int, err error) {
|
||||
c.decryptCnt++
|
||||
|
||||
_, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, b[:0], nonce, ciphertext, verify)
|
||||
|
||||
c.recv += n
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
buf := make([]byte, 0, PacketSizeMax+Overhead)
|
||||
c.wmu.Lock()
|
||||
defer c.wmu.Unlock()
|
||||
|
||||
buf := make([]byte, 0, packetSizeMax+Overhead)
|
||||
nonce := make([]byte, NonceSize)
|
||||
verify := make([]byte, VerifySize)
|
||||
|
||||
for len(b) > 0 {
|
||||
size := len(b)
|
||||
if size > PacketSizeMax {
|
||||
size = PacketSizeMax
|
||||
if size > packetSizeMax {
|
||||
size = packetSizeMax
|
||||
}
|
||||
|
||||
binary.LittleEndian.PutUint16(verify, uint16(size))
|
||||
if _, err = c.wr.Write(verify); err != nil {
|
||||
if _, err = c.rw.Write(verify); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,7 +134,7 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = c.wr.Write(buf[:size+Overhead]); err != nil {
|
||||
if _, err = c.rw.Write(buf[:size+Overhead]); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,7 +142,9 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
n += size
|
||||
}
|
||||
|
||||
err = c.wr.Flush()
|
||||
err = c.rw.Flush()
|
||||
|
||||
c.send += n
|
||||
return
|
||||
}
|
||||
|
||||
+27
-6
@@ -4,16 +4,18 @@ package hds
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
|
||||
)
|
||||
|
||||
func Client(conn net.Conn, key []byte, salt string, controller bool) (*Conn, error) {
|
||||
func NewConn(conn net.Conn, key []byte, salt string, controller bool) (*Conn, error) {
|
||||
writeKey, err := hkdf.Sha512(key, salt, "HDS-Write-Encryption-Key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -49,6 +51,21 @@ type Conn struct {
|
||||
encryptKey []byte
|
||||
decryptCnt 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) {
|
||||
@@ -59,16 +76,18 @@ func (c *Conn) Read(p []byte) (n int, err error) {
|
||||
|
||||
n = int(binary.BigEndian.Uint32(verify) & 0xFFFFFF)
|
||||
|
||||
ciphertext := make([]byte, n+secure.Overhead)
|
||||
ciphertext := make([]byte, n+hap.Overhead)
|
||||
if _, err = io.ReadFull(c.rd, ciphertext); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
nonce := make([]byte, secure.NonceSize)
|
||||
nonce := make([]byte, hap.NonceSize)
|
||||
binary.LittleEndian.PutUint64(nonce, c.decryptCnt)
|
||||
c.decryptCnt++
|
||||
|
||||
_, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, p[:0], nonce, ciphertext, verify)
|
||||
|
||||
c.recv += n
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,11 +100,11 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
nonce := make([]byte, secure.NonceSize)
|
||||
nonce := make([]byte, hap.NonceSize)
|
||||
binary.LittleEndian.PutUint64(nonce, c.encryptCnt)
|
||||
c.encryptCnt++
|
||||
|
||||
buf := make([]byte, n+secure.Overhead)
|
||||
buf := make([]byte, n+hap.Overhead)
|
||||
if _, err = chacha20poly1305.EncryptAndSeal(c.encryptKey, buf[:0], nonce, b, verify); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -95,6 +114,8 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
}
|
||||
|
||||
err = c.wr.Flush()
|
||||
|
||||
c.send += n
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+273
-44
@@ -6,29 +6,23 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/curve25519"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||
"github.com/tadglines/go-pkgs/crypto/srp"
|
||||
)
|
||||
|
||||
type HandlerFunc func(net.Conn) error
|
||||
|
||||
type Server struct {
|
||||
Pin string
|
||||
DeviceID string
|
||||
DevicePrivate []byte
|
||||
|
||||
GetPair func(conn net.Conn, id string) []byte
|
||||
AddPair func(conn net.Conn, id string, public []byte, permissions byte)
|
||||
|
||||
Handler HandlerFunc
|
||||
// GetClientPublic may be nil, so client validation will be disabled
|
||||
GetClientPublic func(id string) []byte
|
||||
}
|
||||
|
||||
func (s *Server) ServerPublic() []byte {
|
||||
@@ -49,37 +43,240 @@ func (s *Server) SetupHash() string {
|
||||
return base64.StdEncoding.EncodeToString(b[:4])
|
||||
}
|
||||
|
||||
func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Conn) error {
|
||||
// Request from iPhone
|
||||
func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter) (id string, publicKey []byte, err error) {
|
||||
// STEP 1. Request from iPhone
|
||||
var plainM1 struct {
|
||||
PublicKey string `tlv8:"3"`
|
||||
State byte `tlv8:"6"`
|
||||
State byte `tlv8:"6"`
|
||||
Method byte `tlv8:"0"`
|
||||
Flags uint32 `tlv8:"19"`
|
||||
}
|
||||
if err := tlv8.UnmarshalReader(io.LimitReader(rw, req.ContentLength), &plainM1); err != nil {
|
||||
return err
|
||||
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil {
|
||||
return
|
||||
}
|
||||
if plainM1.State != StateM1 {
|
||||
return newRequestError(plainM1)
|
||||
err = newRequestError(plainM1)
|
||||
return
|
||||
}
|
||||
|
||||
username := []byte("Pair-Setup")
|
||||
|
||||
// Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE)
|
||||
pake, err := srp.NewSRP("rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
pake.SaltLength = 16
|
||||
|
||||
salt, verifier, err := pake.ComputeVerifier([]byte(s.Pin))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
session := pake.NewServerSession(username, salt, verifier)
|
||||
|
||||
// STEP 2. Response to iPhone
|
||||
plainM2 := struct {
|
||||
State byte `tlv8:"6"`
|
||||
PublicKey string `tlv8:"3"`
|
||||
Salt string `tlv8:"2"`
|
||||
}{
|
||||
State: StateM2,
|
||||
PublicKey: string(session.GetB()),
|
||||
Salt: string(salt),
|
||||
}
|
||||
body, err := tlv8.Marshal(plainM2)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP 3. Request from iPhone
|
||||
if req, err = http.ReadRequest(rw.Reader); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var plainM3 struct {
|
||||
State byte `tlv8:"6"`
|
||||
PublicKey string `tlv8:"3"`
|
||||
Proof string `tlv8:"4"`
|
||||
}
|
||||
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM3); err != nil {
|
||||
return
|
||||
}
|
||||
if plainM3.State != StateM3 {
|
||||
err = newRequestError(plainM3)
|
||||
return
|
||||
}
|
||||
|
||||
// important to compute key before verify client
|
||||
sessionShared, err := session.ComputeKey([]byte(plainM3.PublicKey))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !session.VerifyClientAuthenticator([]byte(plainM3.Proof)) {
|
||||
err = errors.New("hap: VerifyClientAuthenticator")
|
||||
return
|
||||
}
|
||||
|
||||
proof := session.ComputeAuthenticator([]byte(plainM3.Proof)) // server proof
|
||||
|
||||
// STEP 4. Response to iPhone
|
||||
payloadM4 := struct {
|
||||
State byte `tlv8:"6"`
|
||||
Proof string `tlv8:"4"`
|
||||
}{
|
||||
State: StateM4,
|
||||
Proof: string(proof),
|
||||
}
|
||||
if body, err = tlv8.Marshal(payloadM4); err != nil {
|
||||
return
|
||||
}
|
||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP 5. Request from iPhone
|
||||
if req, err = http.ReadRequest(rw.Reader); err != nil {
|
||||
return
|
||||
}
|
||||
var cipherM5 struct {
|
||||
State byte `tlv8:"6"`
|
||||
EncryptedData string `tlv8:"5"`
|
||||
}
|
||||
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM5); err != nil {
|
||||
return
|
||||
}
|
||||
if cipherM5.State != StateM5 {
|
||||
err = newRequestError(cipherM5)
|
||||
return
|
||||
}
|
||||
|
||||
// decrypt message using session shared
|
||||
encryptKey, err := hkdf.Sha512(sessionShared, "Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
b, err := chacha20poly1305.Decrypt(encryptKey, "PS-Msg05", []byte(cipherM5.EncryptedData))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// unpack message from TLV8
|
||||
var plainM5 struct {
|
||||
Identifier string `tlv8:"1"`
|
||||
PublicKey string `tlv8:"3"`
|
||||
Signature string `tlv8:"10"`
|
||||
}
|
||||
if err = tlv8.Unmarshal(b, &plainM5); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. verify client ID and Public
|
||||
remoteSign, err := hkdf.Sha512(
|
||||
sessionShared, "Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info",
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
b = Append(remoteSign, plainM5.Identifier, plainM5.PublicKey)
|
||||
if !ed25519.ValidateSignature([]byte(plainM5.PublicKey), b, []byte(plainM5.Signature)) {
|
||||
err = errors.New("hap: ValidateSignature")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. generate signature to our ID and Public
|
||||
localSign, err := hkdf.Sha512(
|
||||
sessionShared, "Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info",
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
b = Append(localSign, s.DeviceID, s.ServerPublic()) // ServerPublic
|
||||
signature, err := ed25519.Signature(s.DevicePrivate, b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 5. pack our ID and Public
|
||||
plainM6 := struct {
|
||||
Identifier string `tlv8:"1"`
|
||||
PublicKey string `tlv8:"3"`
|
||||
Signature string `tlv8:"10"`
|
||||
}{
|
||||
Identifier: s.DeviceID,
|
||||
PublicKey: string(s.ServerPublic()),
|
||||
Signature: string(signature),
|
||||
}
|
||||
if b, err = tlv8.Marshal(plainM6); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 6. encrypt message
|
||||
b, err = chacha20poly1305.Encrypt(encryptKey, "PS-Msg06", b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP 6. Response to iPhone
|
||||
cipherM6 := struct {
|
||||
State byte `tlv8:"6"`
|
||||
EncryptedData string `tlv8:"5"`
|
||||
}{
|
||||
State: StateM6,
|
||||
EncryptedData: string(b),
|
||||
}
|
||||
if body, err = tlv8.Marshal(cipherM6); err != nil {
|
||||
return
|
||||
}
|
||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
id = plainM5.Identifier
|
||||
publicKey = []byte(plainM5.PublicKey)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter) (id string, sessionKey []byte, err error) {
|
||||
// Request from iPhone
|
||||
var plainM1 struct {
|
||||
State byte `tlv8:"6"`
|
||||
PublicKey string `tlv8:"3"`
|
||||
}
|
||||
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil {
|
||||
return
|
||||
}
|
||||
if plainM1.State != StateM1 {
|
||||
err = newRequestError(plainM1)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the key pair
|
||||
sessionPublic, sessionPrivate := curve25519.GenerateKeyPair()
|
||||
sessionShared, err := curve25519.SharedSecret(sessionPrivate, []byte(plainM1.PublicKey))
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
encryptKey, err := hkdf.Sha512(
|
||||
sessionShared, "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
b := Append(sessionPublic, s.DeviceID, plainM1.PublicKey)
|
||||
signature, err := ed25519.Signature(s.DevicePrivate, b)
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
// STEP M2. Response to iPhone
|
||||
@@ -91,12 +288,12 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
|
||||
Signature: string(signature),
|
||||
}
|
||||
if b, err = tlv8.Marshal(plainM2); err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
b, err = chacha20poly1305.Encrypt(encryptKey, "PV-Msg02", b)
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
cipherM2 := struct {
|
||||
@@ -110,30 +307,32 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
|
||||
}
|
||||
body, err := tlv8.Marshal(cipherM2)
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
// STEP M3. Request from iPhone
|
||||
if req, err = http.ReadRequest(rw.Reader); err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
var cipherM3 struct {
|
||||
EncryptedData string `tlv8:"5"`
|
||||
State byte `tlv8:"6"`
|
||||
EncryptedData string `tlv8:"5"`
|
||||
}
|
||||
if err = tlv8.UnmarshalReader(req.Body, &cipherM3); err != nil {
|
||||
return err
|
||||
if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM3); err != nil {
|
||||
return
|
||||
}
|
||||
if cipherM3.State != StateM3 {
|
||||
return newRequestError(cipherM3)
|
||||
err = newRequestError(cipherM3)
|
||||
return
|
||||
}
|
||||
|
||||
if b, err = chacha20poly1305.Decrypt(encryptKey, "PV-Msg03", []byte(cipherM3.EncryptedData)); err != nil {
|
||||
return err
|
||||
b, err = chacha20poly1305.Decrypt(encryptKey, "PV-Msg03", []byte(cipherM3.EncryptedData))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var plainM3 struct {
|
||||
@@ -141,17 +340,21 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
|
||||
Signature string `tlv8:"10"`
|
||||
}
|
||||
if err = tlv8.Unmarshal(b, &plainM3); err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
clientPublic := s.GetPair(conn, plainM3.Identifier)
|
||||
if clientPublic == nil {
|
||||
return fmt.Errorf("hap: PairVerify from: %s, with unknown client_id: %s", conn.RemoteAddr(), plainM3.Identifier)
|
||||
}
|
||||
if s.GetClientPublic != nil {
|
||||
clientPublic := s.GetClientPublic(plainM3.Identifier)
|
||||
if clientPublic == nil {
|
||||
err = errors.New("hap: PairVerify with unknown client_id: " + plainM3.Identifier)
|
||||
return
|
||||
}
|
||||
|
||||
b = Append(plainM1.PublicKey, plainM3.Identifier, sessionPublic)
|
||||
if !ed25519.ValidateSignature(clientPublic, b, []byte(plainM3.Signature)) {
|
||||
return errors.New("new: ValidateSignature")
|
||||
b = Append(plainM1.PublicKey, plainM3.Identifier, sessionPublic)
|
||||
if !ed25519.ValidateSignature(clientPublic, b, []byte(plainM3.Signature)) {
|
||||
err = errors.New("hap: ValidateSignature")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// STEP M4. Response to iPhone
|
||||
@@ -161,15 +364,41 @@ func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter, conn net.Co
|
||||
State: StateM4,
|
||||
}
|
||||
if body, err = tlv8.Marshal(payloadM4); err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
if conn, err = secure.Client(conn, sessionShared, false); err != nil {
|
||||
return err
|
||||
}
|
||||
id = plainM3.Identifier
|
||||
sessionKey = sessionShared
|
||||
|
||||
return s.Handler(conn)
|
||||
return
|
||||
}
|
||||
|
||||
func WriteResponse(w *bufio.Writer, statusCode int, contentType string, body []byte) error {
|
||||
header := fmt.Sprintf(
|
||||
"HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n",
|
||||
statusCode, http.StatusText(statusCode), contentType, len(body),
|
||||
)
|
||||
body = append([]byte(header), body...)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
//func WriteBackoff(rw *bufio.ReadWriter) error {
|
||||
// plainM2 := struct {
|
||||
// State byte `tlv8:"6"`
|
||||
// Error byte `tlv8:"7"`
|
||||
// }{
|
||||
// State: StateM2,
|
||||
// Error: 3, // BackoffError
|
||||
// }
|
||||
// body, err := tlv8.Marshal(plainM2)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// return WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body)
|
||||
//}
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
package hap
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha512"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||
"github.com/tadglines/go-pkgs/crypto/srp"
|
||||
)
|
||||
|
||||
const (
|
||||
PairMethodSetup = iota
|
||||
PairMethodSetupWithAuth
|
||||
PairMethodVerify
|
||||
PairMethodAdd
|
||||
PairMethodRemove
|
||||
PairMethodList
|
||||
)
|
||||
|
||||
func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter, conn net.Conn) error {
|
||||
if req.Header.Get("Content-Type") != MimeTLV8 {
|
||||
return errors.New("hap: wrong content type")
|
||||
}
|
||||
|
||||
// STEP 1. Request from iPhone
|
||||
var plainM1 struct {
|
||||
Method byte `tlv8:"0"`
|
||||
State byte `tlv8:"6"`
|
||||
Flags uint32 `tlv8:"19"`
|
||||
}
|
||||
if err := tlv8.UnmarshalReader(io.LimitReader(rw, req.ContentLength), &plainM1); err != nil {
|
||||
return err
|
||||
}
|
||||
if plainM1.State != StateM1 {
|
||||
return newRequestError(plainM1)
|
||||
}
|
||||
|
||||
username := []byte("Pair-Setup")
|
||||
|
||||
// Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE)
|
||||
pake, err := srp.NewSRP(
|
||||
"rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pake.SaltLength = 16
|
||||
|
||||
salt, verifier, err := pake.ComputeVerifier([]byte(s.Pin))
|
||||
|
||||
session := pake.NewServerSession(username, salt, verifier)
|
||||
|
||||
// STEP 2. Response to iPhone
|
||||
plainM2 := struct {
|
||||
Salt string `tlv8:"2"`
|
||||
PublicKey string `tlv8:"3"`
|
||||
State byte `tlv8:"6"`
|
||||
}{
|
||||
State: StateM2,
|
||||
PublicKey: string(session.GetB()),
|
||||
Salt: string(salt),
|
||||
}
|
||||
body, err := tlv8.Marshal(plainM2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// STEP 3. Request from iPhone
|
||||
if req, err = http.ReadRequest(rw.Reader); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var plainM3 struct {
|
||||
SessionKey string `tlv8:"3"`
|
||||
Proof string `tlv8:"4"`
|
||||
State byte `tlv8:"6"`
|
||||
}
|
||||
if err = tlv8.UnmarshalReader(req.Body, &plainM3); err != nil {
|
||||
return err
|
||||
}
|
||||
if plainM3.State != StateM3 {
|
||||
return newRequestError(plainM3)
|
||||
}
|
||||
|
||||
// important to compute key before verify client
|
||||
sessionShared, err := session.ComputeKey([]byte(plainM3.SessionKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !session.VerifyClientAuthenticator([]byte(plainM3.Proof)) {
|
||||
return errors.New("hap: VerifyClientAuthenticator")
|
||||
}
|
||||
|
||||
proof := session.ComputeAuthenticator([]byte(plainM3.Proof)) // server proof
|
||||
|
||||
// STEP 4. Response to iPhone
|
||||
payloadM4 := struct {
|
||||
Proof string `tlv8:"4"`
|
||||
State byte `tlv8:"6"`
|
||||
}{
|
||||
Proof: string(proof),
|
||||
State: StateM4,
|
||||
}
|
||||
if body, err = tlv8.Marshal(payloadM4); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// STEP 5. Request from iPhone
|
||||
if req, err = http.ReadRequest(rw.Reader); err != nil {
|
||||
return err
|
||||
}
|
||||
var cipherM5 struct {
|
||||
EncryptedData string `tlv8:"5"`
|
||||
State byte `tlv8:"6"`
|
||||
}
|
||||
if err = tlv8.UnmarshalReader(req.Body, &cipherM5); err != nil {
|
||||
return err
|
||||
}
|
||||
if cipherM5.State != StateM5 {
|
||||
return newRequestError(cipherM5)
|
||||
}
|
||||
|
||||
// decrypt message using session shared
|
||||
encryptKey, err := hkdf.Sha512(sessionShared, "Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, err := chacha20poly1305.Decrypt(encryptKey, "PS-Msg05", []byte(cipherM5.EncryptedData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// unpack message from TLV8
|
||||
var plainM5 struct {
|
||||
Identifier string `tlv8:"1"`
|
||||
PublicKey string `tlv8:"3"`
|
||||
Signature string `tlv8:"10"`
|
||||
}
|
||||
if err = tlv8.Unmarshal(b, &plainM5); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. verify client ID and Public
|
||||
remoteSign, err := hkdf.Sha512(
|
||||
sessionShared, "Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b = Append(remoteSign, plainM5.Identifier, plainM5.PublicKey)
|
||||
if !ed25519.ValidateSignature([]byte(plainM5.PublicKey), b, []byte(plainM5.Signature)) {
|
||||
return errors.New("hap: ValidateSignature")
|
||||
}
|
||||
|
||||
// 4. generate signature to our ID and Public
|
||||
localSign, err := hkdf.Sha512(
|
||||
sessionShared, "Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b = Append(localSign, s.DeviceID, s.ServerPublic()) // ServerPublic
|
||||
signature, err := ed25519.Signature(s.DevicePrivate, b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. pack our ID and Public
|
||||
plainM6 := struct {
|
||||
Identifier string `tlv8:"1"`
|
||||
PublicKey string `tlv8:"3"`
|
||||
Signature string `tlv8:"10"`
|
||||
}{
|
||||
Identifier: s.DeviceID,
|
||||
PublicKey: string(s.ServerPublic()),
|
||||
Signature: string(signature),
|
||||
}
|
||||
if b, err = tlv8.Marshal(plainM6); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 6. encrypt message
|
||||
b, err = chacha20poly1305.Encrypt(encryptKey, "PS-Msg06", b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// STEP 6. Response to iPhone
|
||||
cipherM6 := struct {
|
||||
EncryptedData string `tlv8:"5"`
|
||||
State byte `tlv8:"6"`
|
||||
}{
|
||||
State: StateM6,
|
||||
EncryptedData: string(b),
|
||||
}
|
||||
if body, err = tlv8.Marshal(cipherM6); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.AddPair(conn, plainM5.Identifier, []byte(plainM5.PublicKey), PermissionAdmin)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteResponse(w *bufio.Writer, statusCode int, contentType string, body []byte) error {
|
||||
header := fmt.Sprintf(
|
||||
"HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n",
|
||||
statusCode, http.StatusText(statusCode), contentType, len(body),
|
||||
)
|
||||
body = append([]byte(header), body...)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func WriteBackoff(rw *bufio.ReadWriter) error {
|
||||
plainM2 := struct {
|
||||
State byte `tlv8:"6"`
|
||||
Error byte `tlv8:"7"`
|
||||
}{
|
||||
State: StateM2,
|
||||
Error: 3, // BackoffError
|
||||
}
|
||||
body, err := tlv8.Marshal(plainM2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body)
|
||||
}
|
||||
+21
-2
@@ -112,6 +112,10 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) {
|
||||
v := value.Uint()
|
||||
return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil
|
||||
|
||||
case reflect.Uint64:
|
||||
v := value.Uint()
|
||||
return binary.LittleEndian.AppendUint64(append(b, tag, 8), v), nil
|
||||
|
||||
case reflect.Float32:
|
||||
v := math.Float32bits(float32(value.Float()))
|
||||
return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil
|
||||
@@ -170,11 +174,20 @@ func UnmarshalBase64(in any, out any) error {
|
||||
return Unmarshal(data, out)
|
||||
}
|
||||
|
||||
func UnmarshalReader(r io.Reader, v any) error {
|
||||
data, err := io.ReadAll(r)
|
||||
func UnmarshalReader(r io.Reader, n int64, v any) error {
|
||||
var data []byte
|
||||
var err error
|
||||
|
||||
if n > 0 {
|
||||
data = make([]byte, n)
|
||||
_, err = io.ReadFull(r, data)
|
||||
} else {
|
||||
data, err = io.ReadAll(r)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return Unmarshal(data, v)
|
||||
}
|
||||
|
||||
@@ -301,6 +314,12 @@ func unmarshalValue(v []byte, value reflect.Value) error {
|
||||
}
|
||||
value.SetUint(uint64(v[0]) | uint64(v[1])<<8 | uint64(v[2])<<16 | uint64(v[3])<<24)
|
||||
|
||||
case reflect.Uint64:
|
||||
if len(v) != 8 {
|
||||
return errors.New("tlv8: wrong size: " + value.Type().Name())
|
||||
}
|
||||
value.SetUint(binary.LittleEndian.Uint64(v))
|
||||
|
||||
case reflect.Float32:
|
||||
f := math.Float32frombits(binary.LittleEndian.Uint32(v))
|
||||
value.SetFloat(float64(f))
|
||||
|
||||
+15
-11
@@ -49,7 +49,7 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer {
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "homekit",
|
||||
Protocol: "udp",
|
||||
Protocol: "rtp",
|
||||
RemoteAddr: conn.RemoteAddr().String(),
|
||||
Medias: medias,
|
||||
Transport: conn,
|
||||
@@ -59,7 +59,11 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Consumer) SetOffer(offer *camera.SetupEndpoints) {
|
||||
func (c *Consumer) SessionID() string {
|
||||
return c.sessionID
|
||||
}
|
||||
|
||||
func (c *Consumer) SetOffer(offer *camera.SetupEndpointsRequest) {
|
||||
c.sessionID = offer.SessionID
|
||||
c.videoSession = &srtp.Session{
|
||||
Remote: &srtp.Endpoint{
|
||||
@@ -79,32 +83,32 @@ func (c *Consumer) SetOffer(offer *camera.SetupEndpoints) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Consumer) GetAnswer() *camera.SetupEndpoints {
|
||||
func (c *Consumer) GetAnswer() *camera.SetupEndpointsResponse {
|
||||
c.videoSession.Local = c.srtpEndpoint()
|
||||
c.audioSession.Local = c.srtpEndpoint()
|
||||
|
||||
return &camera.SetupEndpoints{
|
||||
return &camera.SetupEndpointsResponse{
|
||||
SessionID: c.sessionID,
|
||||
Status: []byte{0},
|
||||
Address: camera.Addr{
|
||||
Status: camera.StreamingStatusAvailable,
|
||||
Address: camera.Address{
|
||||
IPAddr: c.videoSession.Local.Addr,
|
||||
VideoRTPPort: c.videoSession.Local.Port,
|
||||
AudioRTPPort: c.audioSession.Local.Port,
|
||||
},
|
||||
VideoCrypto: camera.CryptoSuite{
|
||||
VideoCrypto: camera.SRTPCryptoSuite{
|
||||
MasterKey: string(c.videoSession.Local.MasterKey),
|
||||
MasterSalt: string(c.videoSession.Local.MasterSalt),
|
||||
},
|
||||
AudioCrypto: camera.CryptoSuite{
|
||||
AudioCrypto: camera.SRTPCryptoSuite{
|
||||
MasterKey: string(c.audioSession.Local.MasterKey),
|
||||
MasterSalt: string(c.audioSession.Local.MasterSalt),
|
||||
},
|
||||
VideoSSRC: []uint32{c.videoSession.Local.SSRC},
|
||||
AudioSSRC: []uint32{c.audioSession.Local.SSRC},
|
||||
VideoSSRC: c.videoSession.Local.SSRC,
|
||||
AudioSSRC: c.audioSession.Local.SSRC,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Consumer) SetConfig(conf *camera.SelectedStreamConfig) bool {
|
||||
func (c *Consumer) SetConfig(conf *camera.SelectedStreamConfiguration) bool {
|
||||
if c.sessionID != conf.Control.SessionID {
|
||||
return false
|
||||
}
|
||||
|
||||
+13
-10
@@ -13,7 +13,7 @@ var videoCodecs = [...]string{core.CodecH264}
|
||||
var videoProfiles = [...]string{"4200", "4D00", "6400"}
|
||||
var videoLevels = [...]string{"1F", "20", "28"}
|
||||
|
||||
func videoToMedia(codecs []camera.VideoCodec) *core.Media {
|
||||
func videoToMedia(codecs []camera.VideoCodecConfiguration) *core.Media {
|
||||
media := &core.Media{
|
||||
Kind: core.KindVideo, Direction: core.DirectionRecvonly,
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func videoToMedia(codecs []camera.VideoCodec) *core.Media {
|
||||
var audioCodecs = [...]string{core.CodecPCMU, core.CodecPCMA, core.CodecELD, core.CodecOpus}
|
||||
var audioSampleRates = [...]uint32{8000, 16000, 24000}
|
||||
|
||||
func audioToMedia(codecs []camera.AudioCodec) *core.Media {
|
||||
func audioToMedia(codecs []camera.AudioCodecConfiguration) *core.Media {
|
||||
media := &core.Media{
|
||||
Kind: core.KindAudio, Direction: core.DirectionRecvonly,
|
||||
}
|
||||
@@ -67,10 +67,10 @@ func audioToMedia(codecs []camera.AudioCodec) *core.Media {
|
||||
return media
|
||||
}
|
||||
|
||||
func trackToVideo(track *core.Receiver, video0 *camera.VideoCodec) *camera.VideoCodec {
|
||||
func trackToVideo(track *core.Receiver, video0 *camera.VideoCodecConfiguration, maxWidth, maxHeight int) *camera.VideoCodecConfiguration {
|
||||
profileID := video0.CodecParams[0].ProfileID[0]
|
||||
level := video0.CodecParams[0].Level[0]
|
||||
attrs := video0.VideoAttrs[0]
|
||||
var attrs camera.VideoCodecAttributes
|
||||
|
||||
if track != nil {
|
||||
profile := h264.GetProfileLevelID(track.Codec.FmtpLine)
|
||||
@@ -90,25 +90,28 @@ func trackToVideo(track *core.Receiver, video0 *camera.VideoCodec) *camera.Video
|
||||
}
|
||||
|
||||
for _, s := range video0.VideoAttrs {
|
||||
if (maxWidth > 0 && int(s.Width) > maxWidth) || (maxHeight > 0 && int(s.Height) > maxHeight) {
|
||||
continue
|
||||
}
|
||||
if s.Width > attrs.Width || s.Height > attrs.Height {
|
||||
attrs = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &camera.VideoCodec{
|
||||
return &camera.VideoCodecConfiguration{
|
||||
CodecType: video0.CodecType,
|
||||
CodecParams: []camera.VideoParams{
|
||||
CodecParams: []camera.VideoCodecParameters{
|
||||
{
|
||||
ProfileID: []byte{profileID},
|
||||
Level: []byte{level},
|
||||
},
|
||||
},
|
||||
VideoAttrs: []camera.VideoAttrs{attrs},
|
||||
VideoAttrs: []camera.VideoCodecAttributes{attrs},
|
||||
}
|
||||
}
|
||||
|
||||
func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodec) *camera.AudioCodec {
|
||||
func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodecConfiguration) *camera.AudioCodecConfiguration {
|
||||
codecType := audio0.CodecType
|
||||
channels := audio0.CodecParams[0].Channels
|
||||
sampleRate := audio0.CodecParams[0].SampleRate[0]
|
||||
@@ -131,9 +134,9 @@ func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodec) *camera.Audio
|
||||
}
|
||||
}
|
||||
|
||||
return &camera.AudioCodec{
|
||||
return &camera.AudioCodecConfiguration{
|
||||
CodecType: codecType,
|
||||
CodecParams: []camera.AudioParams{
|
||||
CodecParams: []camera.AudioCodecParameters{
|
||||
{
|
||||
Channels: channels,
|
||||
SampleRate: []byte{sampleRate},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Debug(v any) {
|
||||
switch v := v.(type) {
|
||||
case *http.Request:
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if v.ContentLength != 0 {
|
||||
b, err := io.ReadAll(v.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
v.Body = io.NopCloser(bytes.NewReader(b))
|
||||
log.Printf("[homekit] request: %s %s\n%s", v.Method, v.RequestURI, b)
|
||||
} else {
|
||||
log.Printf("[homekit] request: %s %s <nobody>", v.Method, v.RequestURI)
|
||||
}
|
||||
case *http.Response:
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if v.Header.Get("Content-Type") == "image/jpeg" {
|
||||
log.Printf("[homekit] response: %d <jpeg>", v.StatusCode)
|
||||
return
|
||||
}
|
||||
if v.ContentLength != 0 {
|
||||
b, err := io.ReadAll(v.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
v.Body = io.NopCloser(bytes.NewReader(b))
|
||||
log.Printf("[homekit] response: %s %d\n%s", v.Proto, v.StatusCode, b)
|
||||
} else {
|
||||
log.Printf("[homekit] response: %s %d <nobody>", v.Proto, v.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-19
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
@@ -22,36 +21,25 @@ type Client struct {
|
||||
hap *hap.Client
|
||||
srtp *srtp.Server
|
||||
|
||||
videoConfig camera.SupportedVideoStreamConfig
|
||||
audioConfig camera.SupportedAudioStreamConfig
|
||||
videoConfig camera.SupportedVideoStreamConfiguration
|
||||
audioConfig camera.SupportedAudioStreamConfiguration
|
||||
|
||||
videoSession *srtp.Session
|
||||
audioSession *srtp.Session
|
||||
|
||||
stream *camera.Stream
|
||||
|
||||
Bitrate int // in bits/s
|
||||
MaxWidth int
|
||||
MaxHeight int
|
||||
Bitrate int // in bits/s
|
||||
}
|
||||
|
||||
func Dial(rawURL string, server *srtp.Server) (*Client, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
conn, err := hap.Dial(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
conn := &hap.Client{
|
||||
DeviceAddress: u.Host,
|
||||
DeviceID: query.Get("device_id"),
|
||||
DevicePublic: hap.DecodeKey(query.Get("device_public")),
|
||||
ClientID: query.Get("client_id"),
|
||||
ClientPrivate: hap.DecodeKey(query.Get("client_private")),
|
||||
}
|
||||
|
||||
if err = conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
@@ -129,7 +117,7 @@ func (c *Client) Start() error {
|
||||
}
|
||||
|
||||
videoTrack := c.trackByKind(core.KindVideo)
|
||||
videoCodec := trackToVideo(videoTrack, &c.videoConfig.Codecs[0])
|
||||
videoCodec := trackToVideo(videoTrack, &c.videoConfig.Codecs[0], c.MaxWidth, c.MaxHeight)
|
||||
|
||||
audioTrack := c.trackByKind(core.KindAudio)
|
||||
audioCodec := trackToAudio(audioTrack, &c.audioConfig.Codecs[0])
|
||||
|
||||
+33
-27
@@ -4,31 +4,30 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||
)
|
||||
|
||||
func ProxyHandler(pair ServerPair, dial func() (net.Conn, error)) hap.HandlerFunc {
|
||||
type ServerProxy interface {
|
||||
ServerPair
|
||||
AddConn(conn any)
|
||||
DelConn(conn any)
|
||||
}
|
||||
|
||||
func ProxyHandler(srv ServerProxy, acc net.Conn) HandlerFunc {
|
||||
return func(con net.Conn) error {
|
||||
defer con.Close()
|
||||
|
||||
acc, err := dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer acc.Close()
|
||||
|
||||
pr := &Proxy{
|
||||
con: con.(*secure.Conn),
|
||||
acc: acc.(*secure.Conn),
|
||||
con: con.(*hap.Conn),
|
||||
acc: acc.(*hap.Conn),
|
||||
res: make(chan *http.Response),
|
||||
}
|
||||
|
||||
@@ -36,17 +35,17 @@ func ProxyHandler(pair ServerPair, dial func() (net.Conn, error)) hap.HandlerFun
|
||||
go pr.handleAcc()
|
||||
|
||||
// controller => accessory
|
||||
return pr.handleCon(pair)
|
||||
return pr.handleCon(srv)
|
||||
}
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
con *secure.Conn
|
||||
acc *secure.Conn
|
||||
con *hap.Conn
|
||||
acc *hap.Conn
|
||||
res chan *http.Response
|
||||
}
|
||||
|
||||
func (p *Proxy) handleCon(pair ServerPair) error {
|
||||
func (p *Proxy) handleCon(srv ServerProxy) error {
|
||||
var hdsCharIID uint64
|
||||
|
||||
rd := bufio.NewReader(p.con)
|
||||
@@ -61,7 +60,7 @@ func (p *Proxy) handleCon(pair ServerPair) error {
|
||||
switch {
|
||||
case req.Method == "POST" && req.URL.Path == hap.PathPairings:
|
||||
var res *http.Response
|
||||
if res, err = handlePairings(p.con, req, pair); err != nil {
|
||||
if res, err = handlePairings(req, srv); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = res.Write(p.con); err != nil {
|
||||
@@ -74,7 +73,7 @@ func (p *Proxy) handleCon(pair ServerPair) error {
|
||||
_ = json.Unmarshal(body, &v)
|
||||
for _, char := range v.Value {
|
||||
if char.IID == hdsCharIID {
|
||||
var hdsReq camera.SetupDataStreamRequest
|
||||
var hdsReq camera.SetupDataStreamTransportRequest
|
||||
_ = tlv8.UnmarshalBase64(char.Value, &hdsReq)
|
||||
hdsConSalt = hdsReq.ControllerKeySalt
|
||||
break
|
||||
@@ -110,14 +109,14 @@ func (p *Proxy) handleCon(pair ServerPair) error {
|
||||
_ = json.Unmarshal(body, &v)
|
||||
for i, char := range v.Value {
|
||||
if char.IID == hdsCharIID {
|
||||
var hdsRes camera.SetupDataStreamResponse
|
||||
var hdsRes camera.SetupDataStreamTransportResponse
|
||||
_ = tlv8.UnmarshalBase64(char.Value, &hdsRes)
|
||||
|
||||
hdsAccSalt := hdsRes.AccessoryKeySalt
|
||||
hdsPort := int(hdsRes.TransportTypeSessionParameters.TCPListeningPort)
|
||||
|
||||
// swtich accPort to conPort
|
||||
hdsPort, err = p.listenHDS(hdsPort, hdsConSalt+hdsAccSalt)
|
||||
hdsPort, err = p.listenHDS(srv, hdsPort, hdsConSalt+hdsAccSalt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -149,7 +148,7 @@ func (p *Proxy) handleAcc() error {
|
||||
}
|
||||
|
||||
if res.Proto == hap.ProtoEvent {
|
||||
if err = res.Write(p.con); err != nil {
|
||||
if err = hap.WriteEvent(p.con, res); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
@@ -166,7 +165,8 @@ func (p *Proxy) handleAcc() error {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) listenHDS(accPort int, salt string) (int, error) {
|
||||
func (p *Proxy) listenHDS(srv ServerProxy, accPort int, salt string) (int, error) {
|
||||
// The TCP port range for HDS must be >= 32768.
|
||||
ln, err := net.ListenTCP("tcp", nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -175,30 +175,36 @@ func (p *Proxy) listenHDS(accPort int, salt string) (int, error) {
|
||||
go func() {
|
||||
defer ln.Close()
|
||||
|
||||
_ = ln.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
|
||||
// raw controller conn
|
||||
con, err := ln.Accept()
|
||||
conn1, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer con.Close()
|
||||
|
||||
defer conn1.Close()
|
||||
|
||||
// secured controller conn (controlle=false because we are accessory)
|
||||
con, err = hds.Client(con, p.con.SharedKey, salt, false)
|
||||
con, err := hds.NewConn(conn1, p.con.SharedKey, salt, false)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
srv.AddConn(con)
|
||||
defer srv.DelConn(con)
|
||||
|
||||
accIP := p.acc.RemoteAddr().(*net.TCPAddr).IP
|
||||
|
||||
// raw accessory conn
|
||||
acc, err := net.Dial("tcp", fmt.Sprintf("%s:%d", accIP, accPort))
|
||||
conn2, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: accIP, Port: accPort})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer acc.Close()
|
||||
defer conn2.Close()
|
||||
|
||||
// secured accessory conn (controller=true because we are controller)
|
||||
acc, err = hds.Client(acc, p.acc.SharedKey, salt, true)
|
||||
acc, err := hds.NewConn(conn2, p.acc.SharedKey, salt, true)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
+12
-47
@@ -15,15 +15,17 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||
)
|
||||
|
||||
type HandlerFunc func(net.Conn) error
|
||||
|
||||
type Server interface {
|
||||
ServerPair
|
||||
ServerAccessory
|
||||
}
|
||||
|
||||
type ServerPair interface {
|
||||
GetPair(conn net.Conn, id string) []byte
|
||||
AddPair(conn net.Conn, id string, public []byte, permissions byte)
|
||||
DelPair(conn net.Conn, id string)
|
||||
GetPair(id string) []byte
|
||||
AddPair(id string, public []byte, permissions byte)
|
||||
DelPair(id string)
|
||||
}
|
||||
|
||||
type ServerAccessory interface {
|
||||
@@ -33,11 +35,11 @@ type ServerAccessory interface {
|
||||
GetImage(conn net.Conn, width, height int) []byte
|
||||
}
|
||||
|
||||
func ServerHandler(server Server) hap.HandlerFunc {
|
||||
func ServerHandler(server Server) HandlerFunc {
|
||||
return handleRequest(func(conn net.Conn, req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case hap.PathPairings:
|
||||
return handlePairings(conn, req, server)
|
||||
return handlePairings(req, server)
|
||||
|
||||
case hap.PathAccessories:
|
||||
body := hap.JSONAccessories{Value: server.GetAccessories(conn)}
|
||||
@@ -103,7 +105,7 @@ func ServerHandler(server Server) hap.HandlerFunc {
|
||||
})
|
||||
}
|
||||
|
||||
func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response, error)) hap.HandlerFunc {
|
||||
func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response, error)) HandlerFunc {
|
||||
return func(conn net.Conn) error {
|
||||
rw := bufio.NewReaderSize(conn, 16*1024)
|
||||
wr := bufio.NewWriterSize(conn, 16*1024)
|
||||
@@ -130,7 +132,7 @@ func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response
|
||||
}
|
||||
}
|
||||
|
||||
func handlePairings(conn net.Conn, req *http.Request, pair ServerPair) (*http.Response, error) {
|
||||
func handlePairings(req *http.Request, srv ServerPair) (*http.Response, error) {
|
||||
cmd := struct {
|
||||
Method byte `tlv8:"0"`
|
||||
Identifier string `tlv8:"1"`
|
||||
@@ -139,15 +141,15 @@ func handlePairings(conn net.Conn, req *http.Request, pair ServerPair) (*http.Re
|
||||
Permissions byte `tlv8:"11"`
|
||||
}{}
|
||||
|
||||
if err := tlv8.UnmarshalReader(req.Body, &cmd); err != nil {
|
||||
if err := tlv8.UnmarshalReader(req.Body, req.ContentLength, &cmd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch cmd.Method {
|
||||
case 3: // add
|
||||
pair.AddPair(conn, cmd.Identifier, []byte(cmd.PublicKey), cmd.Permissions)
|
||||
srv.AddPair(cmd.Identifier, []byte(cmd.PublicKey), cmd.Permissions)
|
||||
case 4: // delete
|
||||
pair.DelPair(conn, cmd.Identifier)
|
||||
srv.DelPair(cmd.Identifier)
|
||||
}
|
||||
|
||||
body := struct {
|
||||
@@ -190,40 +192,3 @@ func makeResponse(mime string, v any) (*http.Response, error) {
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
//func debug(v any) {
|
||||
// switch v := v.(type) {
|
||||
// case *http.Request:
|
||||
// if v == nil {
|
||||
// return
|
||||
// }
|
||||
// if v.ContentLength != 0 {
|
||||
// b, err := io.ReadAll(v.Body)
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
// v.Body = io.NopCloser(bytes.NewReader(b))
|
||||
// log.Printf("[homekit] request: %s %s\n%s", v.Method, v.RequestURI, b)
|
||||
// } else {
|
||||
// log.Printf("[homekit] request: %s %s <nobody>", v.Method, v.RequestURI)
|
||||
// }
|
||||
// case *http.Response:
|
||||
// if v == nil {
|
||||
// return
|
||||
// }
|
||||
// if v.Header.Get("Content-Type") == "image/jpeg" {
|
||||
// log.Printf("[homekit] response: %d <jpeg>", v.StatusCode)
|
||||
// return
|
||||
// }
|
||||
// if v.ContentLength != 0 {
|
||||
// b, err := io.ReadAll(v.Body)
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
// v.Body = io.NopCloser(bytes.NewReader(b))
|
||||
// log.Printf("[homekit] response: %d\n%s", v.StatusCode, b)
|
||||
// } else {
|
||||
// log.Printf("[homekit] response: %d <nobody>", v.StatusCode)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
+10
-13
@@ -31,19 +31,18 @@ func NewClient(rawURL string) (*Client, error) {
|
||||
baseURL := "http://" + u.Host
|
||||
|
||||
client := &Client{url: u}
|
||||
if u.Path == "" {
|
||||
client.deviceURL = baseURL + PathDevice
|
||||
} else {
|
||||
client.deviceURL = baseURL + u.Path
|
||||
}
|
||||
client.deviceURL = baseURL + GetPath(u.Path, PathDevice)
|
||||
|
||||
b, err := client.DeviceRequest(DeviceGetCapabilities)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.mediaURL = FindTagValue(b, "Media.+?XAddr")
|
||||
client.imaginURL = FindTagValue(b, "Imaging.+?XAddr")
|
||||
s := FindTagValue(b, "Media.+?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
|
||||
}
|
||||
@@ -188,13 +187,11 @@ func (c *Client) Request(url, body string) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
// need to close body with eny response status
|
||||
b, err := io.ReadAll(res.Body)
|
||||
|
||||
if err == nil && res.StatusCode != http.StatusOK {
|
||||
err = errors.New("onvif: " + res.Status + " for " + url)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("onvif: wrong response " + res.Status)
|
||||
}
|
||||
|
||||
return b, err
|
||||
return io.ReadAll(res.Body)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package onvif
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -129,3 +130,14 @@ func GetPosixTZ(current time.Time) string {
|
||||
|
||||
return prefix + fmt.Sprintf("%02d:%02d", offset/60, offset%60)
|
||||
}
|
||||
|
||||
func GetPath(urlOrPath, defPath string) string {
|
||||
if urlOrPath == "" || urlOrPath[0] == '/' {
|
||||
return defPath
|
||||
}
|
||||
u, err := url.Parse(urlOrPath)
|
||||
if err != nil {
|
||||
return defPath
|
||||
}
|
||||
return GetPath(u.Path, defPath)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ type Probe struct {
|
||||
core.Connection
|
||||
}
|
||||
|
||||
func NewProbe(query url.Values) *Probe {
|
||||
func Create(name string, query url.Values) *Probe {
|
||||
medias := core.ParseQuery(query)
|
||||
|
||||
for _, value := range query["microphone"] {
|
||||
@@ -32,39 +32,22 @@ func NewProbe(query url.Values) *Probe {
|
||||
return &Probe{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "probe",
|
||||
FormatName: name,
|
||||
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 {
|
||||
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)
|
||||
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 {
|
||||
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"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var clientCache = map[string]*RingApi{}
|
||||
var cacheMutex sync.Mutex
|
||||
|
||||
type RefreshTokenAuth struct {
|
||||
RefreshToken string
|
||||
}
|
||||
@@ -23,13 +27,11 @@ type EmailAuth struct {
|
||||
Password string
|
||||
}
|
||||
|
||||
// AuthConfig represents the decoded refresh token data
|
||||
type AuthConfig struct {
|
||||
RT string `json:"rt"` // Refresh Token
|
||||
HID string `json:"hid"` // Hardware ID
|
||||
}
|
||||
|
||||
// AuthTokenResponse represents the response from the authentication endpoint
|
||||
type AuthTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
@@ -46,41 +48,50 @@ type Auth2faResponse struct {
|
||||
NextTimeInSecs int `json:"next_time_in_secs"`
|
||||
}
|
||||
|
||||
// SocketTicketRequest represents the request to get a socket ticket
|
||||
type SocketTicketResponse struct {
|
||||
Ticket string `json:"ticket"`
|
||||
ResponseTimestamp int64 `json:"response_timestamp"`
|
||||
}
|
||||
|
||||
// RingRestClient handles authentication and requests to Ring API
|
||||
type RingRestClient struct {
|
||||
type SessionResponse 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
|
||||
authConfig *AuthConfig
|
||||
hardwareID string
|
||||
authToken *AuthTokenResponse
|
||||
tokenExpiry time.Time
|
||||
Using2FA bool
|
||||
PromptFor2FA string
|
||||
RefreshToken string
|
||||
auth interface{} // EmailAuth or RefreshTokenAuth
|
||||
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
|
||||
|
||||
// CameraData contains common fields for all camera types
|
||||
type CameraData struct {
|
||||
ID float64 `json:"id"`
|
||||
Description string `json:"description"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Kind string `json:"kind"`
|
||||
LocationID string `json:"location_id"`
|
||||
ID int `json:"id"`
|
||||
Description string `json:"description"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Kind string `json:"kind"`
|
||||
LocationID string `json:"location_id"`
|
||||
}
|
||||
|
||||
// RingDeviceType represents different types of Ring devices
|
||||
type RingDeviceType string
|
||||
|
||||
// RingDevicesResponse represents the response from the Ring API
|
||||
type RingDevicesResponse struct {
|
||||
Doorbots []CameraData `json:"doorbots"`
|
||||
AuthorizedDoorbots []CameraData `json:"authorized_doorbots"`
|
||||
@@ -139,23 +150,49 @@ const (
|
||||
apiVersion = 11
|
||||
defaultTimeout = 20 * time.Second
|
||||
maxRetries = 3
|
||||
sessionValidTime = 12 * time.Hour
|
||||
)
|
||||
|
||||
// NewRingRestClient creates a new Ring client instance
|
||||
func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) {
|
||||
client := &RingRestClient{
|
||||
httpClient: &http.Client{Timeout: defaultTimeout},
|
||||
onTokenRefresh: onTokenRefresh,
|
||||
hardwareID: generateHardwareID(),
|
||||
auth: auth,
|
||||
}
|
||||
func NewRestClient(auth interface{}, onTokenRefresh func(string)) (*RingApi, error) {
|
||||
var cacheKey string
|
||||
|
||||
// Create cache key based on auth data
|
||||
switch a := auth.(type) {
|
||||
case RefreshTokenAuth:
|
||||
if a.RefreshToken == "" {
|
||||
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)
|
||||
if err != nil {
|
||||
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.hardwareID = config.HID
|
||||
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
|
||||
}
|
||||
|
||||
// Request makes an authenticated request to the Ring API
|
||||
func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, error) {
|
||||
// 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
|
||||
func ClientAPI(path string) string {
|
||||
return clientAPIBaseURL + path
|
||||
}
|
||||
|
||||
// ensureAuth ensures we have a valid auth token
|
||||
func (c *RingRestClient) ensureAuth() error {
|
||||
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
|
||||
func DeviceAPI(path string) string {
|
||||
return deviceAPIBaseURL + path
|
||||
}
|
||||
|
||||
// getAuth makes an authentication request to the Ring API
|
||||
func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) {
|
||||
func CommandsAPI(path string) string {
|
||||
return commandsAPIBaseURL + path
|
||||
}
|
||||
|
||||
func AppAPI(path string) string {
|
||||
return appAPIBaseURL + path
|
||||
}
|
||||
|
||||
func (c *RingApi) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) {
|
||||
var grantData map[string]string
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Refresh token and expiry
|
||||
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)
|
||||
|
||||
c.RefreshToken = encodeAuthConfig(c.authConfig)
|
||||
if c.onTokenRefresh != nil {
|
||||
c.onTokenRefresh(c.RefreshToken)
|
||||
}
|
||||
|
||||
// Refresh the cached client
|
||||
cacheMutex.Lock()
|
||||
clientCache[c.cacheKey] = c
|
||||
cacheMutex.Unlock()
|
||||
|
||||
return c.authToken, nil
|
||||
}
|
||||
|
||||
// Helper functions for auth config encoding/decoding
|
||||
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) {
|
||||
func (c *RingApi) FetchRingDevices() (*RingDevicesResponse, error) {
|
||||
response, err := c.Request("GET", ClientAPI("ring_devices"), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch ring devices: %w", err)
|
||||
@@ -509,7 +386,7 @@ func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch socket ticket: %w", err)
|
||||
@@ -523,6 +400,286 @@ func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) {
|
||||
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 {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("ring-client-go2rtc"))
|
||||
|
||||
+122
-308
@@ -5,103 +5,25 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
"strconv"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
pion "github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
api *RingRestClient
|
||||
ws *websocket.Conn
|
||||
api *RingApi
|
||||
wsClient *WSClient
|
||||
prod core.Producer
|
||||
camera *CameraData
|
||||
cameraID int
|
||||
dialogID string
|
||||
sessionID string
|
||||
wsMutex sync.Mutex
|
||||
done chan struct{}
|
||||
connected core.Waiter
|
||||
closed bool
|
||||
}
|
||||
|
||||
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) {
|
||||
// 1. Parse URL and validate basic params
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -109,70 +31,42 @@ func Dial(rawURL string) (*Client, error) {
|
||||
|
||||
query := u.Query()
|
||||
encodedToken := query.Get("refresh_token")
|
||||
cameraID := query.Get("camera_id")
|
||||
deviceID := query.Get("device_id")
|
||||
_, isSnapshot := query["snapshot"]
|
||||
|
||||
if encodedToken == "" || deviceID == "" {
|
||||
if encodedToken == "" || deviceID == "" || cameraID == "" {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ring: invalid refresh token encoding: %w", err)
|
||||
}
|
||||
|
||||
// Initialize Ring API client
|
||||
ringAPI, err := NewRingRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil)
|
||||
client.api, err = NewRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get camera details
|
||||
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
|
||||
// Snapshot Flow
|
||||
if isSnapshot {
|
||||
client.prod = NewSnapshotProducer(ringAPI, camera)
|
||||
client.prod = NewSnapshotProducer(client.api, client.cameraID)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// If not snapshot, continue with WebRTC setup
|
||||
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"},
|
||||
})
|
||||
client.wsClient, err = StartWebsocket(client.cameraID, client.api)
|
||||
if err != nil {
|
||||
client.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -196,13 +90,13 @@ func Dial(rawURL string) (*Client, error) {
|
||||
|
||||
api, err := webrtc.NewAPI()
|
||||
if err != nil {
|
||||
client.ws.Close()
|
||||
client.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pc, err := api.NewPeerConnection(conf)
|
||||
if err != nil {
|
||||
client.ws.Close()
|
||||
client.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -212,16 +106,27 @@ func Dial(rawURL string) (*Client, error) {
|
||||
// protect from blocking on errors
|
||||
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.FormatName = "ring/webrtc"
|
||||
prod.Mode = core.ModeActiveProducer
|
||||
prod.Protocol = "ws"
|
||||
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) {
|
||||
switch msg := msg.(type) {
|
||||
@@ -240,22 +145,28 @@ func Dial(rawURL string) (*Client, error) {
|
||||
"mlineindex": iceCandidate.SDPMLineIndex,
|
||||
}
|
||||
|
||||
if err = client.sendSessionMessage("ice", icePayload); err != nil {
|
||||
connState.Done(err)
|
||||
if err = client.wsClient.sendSessionMessage("ice", icePayload); err != nil {
|
||||
client.connected.Done(err)
|
||||
return
|
||||
}
|
||||
|
||||
case pion.PeerConnectionState:
|
||||
switch msg {
|
||||
case pion.PeerConnectionStateNew:
|
||||
break
|
||||
case pion.PeerConnectionStateConnecting:
|
||||
break
|
||||
case pion.PeerConnectionStateConnected:
|
||||
connState.Done(nil)
|
||||
client.connected.Done(nil)
|
||||
default:
|
||||
connState.Done(errors.New("ring: " + msg.String()))
|
||||
client.Stop()
|
||||
client.connected.Done(errors.New("ring: " + msg.String()))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
client.prod = prod
|
||||
|
||||
// Setup media configuration
|
||||
medias := []*core.Media{
|
||||
{
|
||||
@@ -297,186 +208,103 @@ func Dial(rawURL string) (*Client, error) {
|
||||
"sdp": offer,
|
||||
}
|
||||
|
||||
if err = client.sendSessionMessage("live_view", offerPayload); err != nil {
|
||||
if err = client.wsClient.sendSessionMessage("live_view", offerPayload); err != nil {
|
||||
client.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sendOffer.Done(nil)
|
||||
|
||||
// Ring expects a ping message every 5 seconds
|
||||
go client.startPingLoop(pc)
|
||||
go client.startMessageLoop(&connState)
|
||||
|
||||
if err = connState.Wait(); err != nil {
|
||||
if err = client.connected.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) startPingLoop(pc *pion.PeerConnection) {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
func (c *Client) onWSMessage(msg WSMessage) {
|
||||
rawMsg, _ := json.Marshal(msg)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if pc.ConnectionState() == pion.PeerConnectionStateConnected {
|
||||
if err := c.sendSessionMessage("ping", nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
// fmt.Printf("ring: onWSMessage: %s\n", string(rawMsg))
|
||||
|
||||
// check if "doorbot_id" is present
|
||||
if _, ok := msg.Body["doorbot_id"]; !ok {
|
||||
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) {
|
||||
var err error
|
||||
|
||||
// will be closed when conn will be closed
|
||||
defer func() {
|
||||
connState.Done(err)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
// check if the message is from the correct session
|
||||
if _, ok := msg.Body["session_id"]; ok {
|
||||
if msg.Body["session_id"].(string) != c.wsClient.sessionID {
|
||||
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.connected.Done(err)
|
||||
return
|
||||
}
|
||||
|
||||
// check if "doorbot_id" is present
|
||||
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":
|
||||
if err := prod.SetAnswer(msg.Body.SDP); err != nil {
|
||||
c.Stop()
|
||||
c.connected.Done(err)
|
||||
return
|
||||
}
|
||||
|
||||
case "pong":
|
||||
// Ignore
|
||||
continue
|
||||
if err := c.wsClient.activateSession(); err != nil {
|
||||
c.Stop()
|
||||
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 {
|
||||
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{}{
|
||||
"stealth_mode": false,
|
||||
}
|
||||
_ = c.sendSessionMessage("camera_options", speakerPayload)
|
||||
_ = c.wsClient.sendSessionMessage("camera_options", speakerPayload)
|
||||
}
|
||||
return webrtcProd.AddTrack(media, codec, track)
|
||||
}
|
||||
@@ -505,37 +333,23 @@ func (c *Client) Start() error {
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
select {
|
||||
case <-c.done:
|
||||
if c.closed {
|
||||
return nil
|
||||
default:
|
||||
close(c.done)
|
||||
}
|
||||
|
||||
c.closed = true
|
||||
|
||||
if c.prod != nil {
|
||||
_ = c.prod.Stop()
|
||||
}
|
||||
|
||||
if c.ws != nil {
|
||||
closePayload := map[string]interface{}{
|
||||
"reason": map[string]interface{}{
|
||||
"code": CloseReasonNormalClose,
|
||||
"text": "",
|
||||
},
|
||||
}
|
||||
|
||||
_ = c.sendSessionMessage("close", closePayload)
|
||||
_ = c.ws.Close()
|
||||
c.ws = nil
|
||||
if c.wsClient != nil {
|
||||
_ = c.wsClient.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
if webrtcProd, ok := c.prod.(*webrtc.Conn); ok {
|
||||
return webrtcProd.MarshalJSON()
|
||||
}
|
||||
|
||||
return json.Marshal(c.prod)
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
type SnapshotProducer struct {
|
||||
core.Connection
|
||||
|
||||
client *RingRestClient
|
||||
camera *CameraData
|
||||
client *RingApi
|
||||
cameraID int
|
||||
}
|
||||
|
||||
func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer {
|
||||
func NewSnapshotProducer(client *RingApi, cameraID int) *SnapshotProducer {
|
||||
return &SnapshotProducer{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
@@ -35,14 +35,13 @@ func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotPr
|
||||
},
|
||||
},
|
||||
},
|
||||
client: client,
|
||||
camera: camera,
|
||||
client: client,
|
||||
cameraID: cameraID,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *SnapshotProducer) Start() error {
|
||||
// Fetch snapshot
|
||||
response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", int(p.camera.ID)), nil)
|
||||
response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", p.cameraID), nil)
|
||||
if err != nil {
|
||||
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"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp/websocket"
|
||||
@@ -36,14 +37,22 @@ func (c *Conn) Dial() (err error) {
|
||||
|
||||
var conn net.Conn
|
||||
|
||||
if c.Transport == "" {
|
||||
timeout := core.ConnDialTimeout
|
||||
switch c.Transport {
|
||||
case "", "tcp", "udp":
|
||||
var timeout time.Duration
|
||||
if c.Timeout != 0 {
|
||||
timeout = time.Second * time.Duration(c.Timeout)
|
||||
} else {
|
||||
timeout = core.ConnDialTimeout
|
||||
}
|
||||
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)
|
||||
c.Protocol = "ws"
|
||||
}
|
||||
@@ -61,6 +70,9 @@ func (c *Conn) Dial() (err error) {
|
||||
c.sequence = 0
|
||||
c.state = StateConn
|
||||
|
||||
c.udpConn = nil
|
||||
c.udpAddr = nil
|
||||
|
||||
c.Connection.RemoteAddr = conn.RemoteAddr().String()
|
||||
c.Connection.Transport = conn
|
||||
c.Connection.URL = c.uri
|
||||
@@ -81,7 +93,35 @@ func (c *Conn) Do(req *tcp.Request) (*tcp.Response, error) {
|
||||
|
||||
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 {
|
||||
case tcp.AuthNone:
|
||||
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, nil
|
||||
return res, fmt.Errorf("wrong response on %s", req.Method)
|
||||
}
|
||||
|
||||
func (c *Conn) Options() error {
|
||||
@@ -218,15 +254,27 @@ func (c *Conn) Record() (err error) {
|
||||
func (c *Conn) SetupMedia(media *core.Media) (byte, error) {
|
||||
var transport string
|
||||
|
||||
// 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
|
||||
if c.Transport == "udp" {
|
||||
conn1, conn2, err := ListenUDPPair()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
c.udpConn = append(c.udpConn, conn1, conn2)
|
||||
|
||||
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
|
||||
|
||||
// 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
|
||||
// Parse server response
|
||||
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:
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
channel := core.Between(transport, "interleaved=", "-")
|
||||
i, err := strconv.Atoi(channel)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return byte(i), nil
|
||||
}
|
||||
|
||||
return byte(i), nil
|
||||
}
|
||||
|
||||
func (c *Conn) Play() (err error) {
|
||||
@@ -327,5 +401,56 @@ func (c *Conn) Close() error {
|
||||
if c.OnClose != nil {
|
||||
_ = c.OnClose()
|
||||
}
|
||||
for _, conn := range c.udpConn {
|
||||
_ = 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 (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -13,7 +14,6 @@ import (
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/pion/rtcp"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ type Conn struct {
|
||||
keepalive int
|
||||
mode core.Mode
|
||||
playOK bool
|
||||
playErr error
|
||||
reader *bufio.Reader
|
||||
sequence int
|
||||
session string
|
||||
@@ -47,6 +48,9 @@ type Conn struct {
|
||||
|
||||
state State
|
||||
stateMu sync.Mutex
|
||||
|
||||
udpConn []*net.UDPConn
|
||||
udpAddr []*net.UDPAddr
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -68,7 +72,6 @@ func (s State) String() string {
|
||||
case StateNone:
|
||||
return "NONE"
|
||||
case StateConn:
|
||||
|
||||
return "CONN"
|
||||
case StateSetup:
|
||||
return MethodSetup
|
||||
@@ -88,23 +91,25 @@ const (
|
||||
func (c *Conn) Handle() (err error) {
|
||||
var timeout time.Duration
|
||||
|
||||
var keepaliveDT time.Duration
|
||||
var keepaliveTS time.Time
|
||||
|
||||
switch c.mode {
|
||||
case core.ModeActiveProducer:
|
||||
var keepaliveDT time.Duration
|
||||
|
||||
if c.keepalive > 5 {
|
||||
keepaliveDT = time.Duration(c.keepalive-5) * time.Second
|
||||
} else {
|
||||
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 {
|
||||
// polling frames from remote RTSP Server (ex Camera)
|
||||
timeout = time.Second * 5
|
||||
|
||||
if len(c.Receivers) == 0 {
|
||||
if len(c.Receivers) == 0 || c.Transport == "udp" {
|
||||
// if we only send audio to camera
|
||||
// https://github.com/AlexxIT/go2rtc/issues/659
|
||||
timeout += keepaliveDT
|
||||
@@ -129,148 +134,190 @@ func (c *Conn) Handle() (err error) {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
if req.Proto == "" {
|
||||
req.Proto = ProtoRTSP
|
||||
|
||||
+23
-4
@@ -85,11 +85,8 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core.
|
||||
}
|
||||
|
||||
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))
|
||||
if _, err := c.conn.Write(buf[:n]); err == nil {
|
||||
if err := c.writeInterleavedData(buf[:n]); err != nil {
|
||||
c.Send += n
|
||||
}
|
||||
n = 0
|
||||
@@ -177,3 +174,25 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core.
|
||||
|
||||
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 (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
@@ -38,39 +36,6 @@ func QuoteSplit(s string) []string {
|
||||
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() {
|
||||
sigs := make(chan os.Signal, 1)
|
||||
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"
|
||||
}
|
||||
|
||||
if strings.Contains(exchange, `username="none"`) {
|
||||
// https://nvd.nist.gov/vuln/detail/CVE-2022-37255
|
||||
username = "none"
|
||||
password = "TPL075526460603"
|
||||
}
|
||||
|
||||
key := md5.Sum([]byte(nonce + ":" + password))
|
||||
iv := md5.Sum([]byte(username + ":" + nonce))
|
||||
|
||||
@@ -158,8 +164,9 @@ func (c *Client) newDectypter(res *http.Response, brand, username, password stri
|
||||
cbc.CryptBlocks(b, b)
|
||||
|
||||
// unpad
|
||||
padSize := int(b[len(b)-1])
|
||||
return b[:len(b)-padSize]
|
||||
n := len(b)
|
||||
padSize := int(b[n-1])
|
||||
return b[:n-padSize]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,12 +299,12 @@ func dial(req *http.Request, brand, username, password string) (net.Conn, *http.
|
||||
return nil, nil, err
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, res.Body) // discard leftovers
|
||||
_ = res.Body.Close() // ignore response body
|
||||
_ = res.Body.Close() // ignore response body
|
||||
|
||||
auth := res.Header.Get("WWW-Authenticate")
|
||||
|
||||
if res.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(auth, "Digest") {
|
||||
return nil, nil, fmt.Errorf("Expected StatusCode to be %d, received %d", http.StatusUnauthorized, res.StatusCode)
|
||||
return nil, nil, errors.New("tapo: wrond status: " + res.Status)
|
||||
}
|
||||
|
||||
if brand == "tapo" && password == "" {
|
||||
|
||||
@@ -112,6 +112,10 @@ func (a *Auth) ReadNone(res *Response) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Auth) UserInfo() *url.Userinfo {
|
||||
return url.UserPassword(a.user, a.pass)
|
||||
}
|
||||
|
||||
func Between(s, sub1, sub2 string) string {
|
||||
i := strings.Index(s, sub1)
|
||||
if i < 0 {
|
||||
|
||||
+31
-78
@@ -1,94 +1,47 @@
|
||||
#!/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() {
|
||||
if ! command -v $1 &> /dev/null
|
||||
if ! command -v "$1" >/dev/null
|
||||
then
|
||||
echo "Error: $1 could not be found. Please install it."
|
||||
exit 1
|
||||
echo "Error: $1 could not be found. Please install it." >&2
|
||||
return 1
|
||||
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 7z
|
||||
check_command upx
|
||||
|
||||
# Windows amd64
|
||||
export GOOS=windows
|
||||
export GOARCH=amd64
|
||||
FILENAME="go2rtc_win64.zip"
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc.exe
|
||||
export CGO_ENABLED=0
|
||||
|
||||
# Windows 386
|
||||
export GOOS=windows
|
||||
export GOARCH=386
|
||||
FILENAME="go2rtc_win32.zip"
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc.exe
|
||||
set -x # Print commands and their arguments as they are executed.
|
||||
|
||||
# Windows arm64
|
||||
export GOOS=windows
|
||||
export GOARCH=arm64
|
||||
FILENAME="go2rtc_win_arm64.zip"
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc.exe
|
||||
GOOS=windows GOARCH=amd64 build_zip go2rtc_win64.zip go2rtc.exe
|
||||
GOOS=windows GOARCH=386 build_zip go2rtc_win32.zip go2rtc.exe
|
||||
GOOS=windows GOARCH=arm64 build_zip go2rtc_win_arm64.zip go2rtc.exe
|
||||
|
||||
# Linux amd64
|
||||
export GOOS=linux
|
||||
export GOARCH=amd64
|
||||
FILENAME="go2rtc_linux_amd64"
|
||||
go build -ldflags "-s -w" -trimpath -o $FILENAME && upx --lzma --force-overwrite -q --no-progress $FILENAME
|
||||
GOOS=linux GOARCH=amd64 build_upx go2rtc_linux_amd64
|
||||
GOOS=linux GOARCH=386 build_upx go2rtc_linux_i386
|
||||
GOOS=linux GOARCH=arm64 build_upx go2rtc_linux_arm64
|
||||
GOOS=linux GOARCH=mipsle build_upx go2rtc_linux_mipsel
|
||||
GOOS=linux GOARCH=arm GOARM=7 build_upx go2rtc_linux_arm
|
||||
GOOS=linux GOARCH=arm GOARM=6 build_upx go2rtc_linux_armv6
|
||||
|
||||
# Linux 386
|
||||
export GOOS=linux
|
||||
export GOARCH=386
|
||||
FILENAME="go2rtc_linux_i386"
|
||||
go build -ldflags "-s -w" -trimpath -o $FILENAME && upx --lzma --force-overwrite -q --no-progress $FILENAME
|
||||
GOOS=darwin GOARCH=amd64 build_zip go2rtc_mac_amd64.zip go2rtc
|
||||
GOOS=darwin GOARCH=arm64 build_zip go2rtc_mac_arm64.zip go2rtc
|
||||
|
||||
# Linux arm64
|
||||
export GOOS=linux
|
||||
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
|
||||
GOOS=freebsd GOARCH=amd64 build_zip go2rtc_freebsd_amd64.zip go2rtc
|
||||
GOOS=freebsd GOARCH=arm64 build_zip go2rtc_freebsd_arm64.zip go2rtc
|
||||
|
||||
+320
-319
@@ -1,41 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
html, body {
|
||||
main > button {
|
||||
background-color: #444;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 14px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.module {
|
||||
main > div {
|
||||
display: none;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
table tbody td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<script src="main.js"></script>
|
||||
|
||||
<script>
|
||||
function drawTable(table, data) {
|
||||
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 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 tbody = data.sources.reduce((html, source) => `${html}${td(source)}`, '');
|
||||
@@ -57,325 +53,330 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<button id="stream">Temporary stream</button>
|
||||
<div class="module">
|
||||
<form id="stream-form" style="padding: 10px">
|
||||
<input type="text" name="name" placeholder="name">
|
||||
<input type="text" name="src" placeholder="url">
|
||||
<input type="submit" value="add">
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('stream').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
});
|
||||
|
||||
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>`;
|
||||
<main>
|
||||
<button id="stream">Temporary stream</button>
|
||||
<div>
|
||||
<form id="stream-form">
|
||||
<input type="text" name="name" placeholder="name">
|
||||
<input type="text" name="src" placeholder="url" required size="30">
|
||||
<button type="submit">add</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('stream').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('homekit').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
await reloadHomeKit();
|
||||
});
|
||||
document.getElementById('stream-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
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);
|
||||
|
||||
document.getElementById('homekit-pair').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const body = new FormData(ev.target);
|
||||
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>
|
||||
const r = await fetch(url, {method: 'PUT'});
|
||||
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="dvrip">DVRIP</button>
|
||||
<div class="module">
|
||||
<table id="dvrip-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('dvrip').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
await getSources('dvrip-table', 'api/dvrip');
|
||||
});
|
||||
</script>
|
||||
<button id="alsa">ALSA (Linux audio)</button>
|
||||
<div>
|
||||
<table id="alsa-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('alsa').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await getSources('alsa-table', 'api/alsa');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="devices">FFmpeg Devices (USB)</button>
|
||||
<div class="module">
|
||||
<table id="devices-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('devices').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
await getSources('devices-table', 'api/ffmpeg/devices');
|
||||
});
|
||||
</script>
|
||||
<button id="homekit">Apple HomeKit</button>
|
||||
<div>
|
||||
<form id="homekit-pair">
|
||||
<input type="text" name="id" placeholder="stream id" required>
|
||||
<input type="text" name="src" placeholder="src" required size="30">
|
||||
<input type="text" name="pin" placeholder="pin" required size="10">
|
||||
<button type="submit">pair</button>
|
||||
</form>
|
||||
<form id="homekit-unpair">
|
||||
<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');
|
||||
|
||||
|
||||
<button id="hardware">FFmpeg Hardware</button>
|
||||
<div class="module">
|
||||
<table id="hardware-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('hardware').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
await getSources('hardware-table', 'api/ffmpeg/hardware');
|
||||
});
|
||||
</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;
|
||||
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 += i > 0 ? `<td>${commands}</td>` : '<th>commands</th>';
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
table.innerText = data.error || 'Unknown error';
|
||||
return;
|
||||
table.innerText = 'loading...';
|
||||
|
||||
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');
|
||||
drawTable(table, data);
|
||||
}
|
||||
|
||||
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>
|
||||
document.getElementById('ring-credentials-form').addEventListener('submit', handleRingAuth);
|
||||
document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth);
|
||||
</script>
|
||||
|
||||
|
||||
<button id="hass">Home Assistant</button>
|
||||
<div class="module">
|
||||
<table id="hass-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('hass').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
await getSources('hass-table', 'api/hass');
|
||||
});
|
||||
</script>
|
||||
<button id="roborock">Roborock</button>
|
||||
<div>
|
||||
<form id="roborock-form">
|
||||
<input type="text" name="username" placeholder="username" required>
|
||||
<input type="password" name="password" placeholder="password" required>
|
||||
<button type="submit">login</button>
|
||||
</form>
|
||||
<table id="roborock-table">
|
||||
</table>
|
||||
</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>
|
||||
<div class="module">
|
||||
<form id="onvif-form" style="padding: 10px">
|
||||
<input type="text" name="src" placeholder="onvif://user:pass@192.168.1.123:80" size="50">
|
||||
<input type="submit" value="test">
|
||||
</form>
|
||||
<table id="onvif-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('onvif').addEventListener('click', async ev => {
|
||||
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="v4l2">V4L2 (Linux video)</button>
|
||||
<div>
|
||||
<table id="v4l2-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('v4l2').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await getSources('v4l2-table', 'api/v4l2');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="roborock">Roborock</button>
|
||||
<div class="module">
|
||||
<form id="roborock-form" style="margin-bottom: 10px">
|
||||
<input type="text" name="username" placeholder="username">
|
||||
<input type="password" name="password" placeholder="password">
|
||||
<input type="submit" value="Login">
|
||||
</form>
|
||||
<table id="roborock-table">
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
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>
|
||||
|
||||
<button id="webtorrent">WebTorrent Shares</button>
|
||||
<div>
|
||||
<table id="webtorrent-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('webtorrent').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await getSources('webtorrent-table', 'api/webtorrent');
|
||||
});
|
||||
</script>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,41 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>go2rtc - File Editor</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">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>go2rtc - Config</title>
|
||||
<script src="https://unpkg.com/ace-builds@1.33.1/src-min/ace.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
html, body, #config {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<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/');
|
||||
const editor = ace.edit('config', {
|
||||
mode: 'ace/mode/yaml',
|
||||
});
|
||||
|
||||
let dump;
|
||||
|
||||
document.getElementById('save').addEventListener('click', async () => {
|
||||
let r = await fetch('api/config', {cache: 'no-cache'});
|
||||
if (r.ok && dump !== await r.text()) {
|
||||
@@ -67,5 +62,6 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+34
-45
@@ -1,61 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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">
|
||||
<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">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>go2rtc</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
table tbody td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
label {
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.controls > label {
|
||||
margin-left: 10px;
|
||||
.info {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<script src="main.js"></script>
|
||||
<div class="info"></div>
|
||||
<div class="controls">
|
||||
<button>stream</button>
|
||||
<label><input type="checkbox" name="webrtc" checked>webrtc</label>
|
||||
<label><input type="checkbox" name="mse" checked>mse</label>
|
||||
<label><input type="checkbox" name="hls" checked>hls</label>
|
||||
<label><input type="checkbox" name="mjpeg" checked>mjpeg</label>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><label><input id="selectall" type="checkbox">Name</label></th>
|
||||
<th>Online</th>
|
||||
<th>Commands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="streams">
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<main>
|
||||
<div class="controls">
|
||||
<button>stream</button>
|
||||
modes
|
||||
<label><input type="checkbox" name="webrtc" checked>webrtc</label>
|
||||
<label><input type="checkbox" name="mse" checked>mse</label>
|
||||
<label><input type="checkbox" name="hls" checked>hls</label>
|
||||
<label><input type="checkbox" name="mjpeg" checked>mjpeg</label>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><label><input id="selectall" type="checkbox">name</label></th>
|
||||
<th>online</th>
|
||||
<th>commands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="streams">
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="info"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const templates = [
|
||||
'<a href="stream.html?src={name}">stream</a>',
|
||||
@@ -159,10 +147,11 @@
|
||||
const url = new URL('api', location.href);
|
||||
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||
const info = document.querySelector('.info');
|
||||
info.innerText = `Version: ${data.version}, Config: ${data.config_path}`;
|
||||
info.innerText = `version: ${data.version} / config: ${data.config_path}`;
|
||||
});
|
||||
|
||||
reload();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+168
-163
@@ -1,27 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
div > li {
|
||||
list-style-type: none;
|
||||
padding-left: 10px;
|
||||
@@ -36,28 +19,33 @@
|
||||
</style>
|
||||
</head>
|
||||
<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>
|
||||
<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>
|
||||
`;
|
||||
|
||||
const url = new URL('api', location.href);
|
||||
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||
let rtsp = location.host + ':8554';
|
||||
try {
|
||||
const host = data.host.match(/^[^:]+/)[0];
|
||||
const port = data.rtsp.listen.match(/[0-9]+$/)[0];
|
||||
rtsp = `${host}:${port}`;
|
||||
} catch (e) {
|
||||
}
|
||||
const url = new URL('api', location.href);
|
||||
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||
let rtsp = location.host + ':8554';
|
||||
try {
|
||||
const host = data.host.match(/^[^:]+/)[0];
|
||||
const port = data.rtsp.listen.match(/[0-9]+$/)[0];
|
||||
rtsp = `${host}:${port}`;
|
||||
} 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}?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>
|
||||
@@ -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/frame.jpeg?src=${src}">frame.jpeg</a> snapshot in JPEG-format / browsers: all / codecs: MJPEG, JPEG</li>
|
||||
`;
|
||||
});
|
||||
</script>
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<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="live">live - play remote live stream (radio, etc.)</label><br>
|
||||
<label><input type="radio" name="play" value="text">text - play Text To Speech (if your FFmpeg support this)</label><br>
|
||||
<br>
|
||||
<input id="play-url" type="text" placeholder="path / url / text">
|
||||
<a id="play-send" href="#">send</a> / cameras with two way audio support
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('play-send').addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
// 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>
|
||||
<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>
|
||||
<label><input type="radio" name="play" value="live">
|
||||
live - play remote live stream (radio, etc.)
|
||||
</label>
|
||||
<label><input type="radio" name="play" value="text">
|
||||
text - play Text To Speech (if your FFmpeg support this)
|
||||
</label>
|
||||
<br>
|
||||
<input id="play-url" type="text" placeholder="path / url / text">
|
||||
<button id="play-send">send</button>
|
||||
/ cameras with two way audio support
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('play-send').addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
// 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>
|
||||
<h2>Publish stream</h2>
|
||||
<pre>YouTube: rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
|
||||
<div>
|
||||
<h2>Publish stream</h2>
|
||||
<pre>YouTube: rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
|
||||
Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx</pre>
|
||||
<input id="pub-url" type="text" placeholder="url">
|
||||
<a id="pub-send" href="#">send</a> / Telegram RTMPS server
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('pub-send').addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
const url = new URL('api/streams', location.href);
|
||||
url.searchParams.set('src', src);
|
||||
url.searchParams.set('dst', document.getElementById('pub-url').value);
|
||||
fetch(url, {method: 'POST'});
|
||||
});
|
||||
</script>
|
||||
<input id="pub-url" type="text" placeholder="url">
|
||||
<button id="pub-send">send</button>
|
||||
/ Telegram RTMPS server
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('pub-send').addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
const url = new URL('api/streams', location.href);
|
||||
url.searchParams.set('src', src);
|
||||
url.searchParams.set('dst', document.getElementById('pub-url').value);
|
||||
fetch(url, {method: 'POST'});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="webrtc">
|
||||
<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+microphone">video+audio+microphone = two way audio from camera</label><br>
|
||||
<label><input type="radio" name="webrtc" value="camera+microphone">camera+microphone = stream from browser</label><br>
|
||||
<label><input type="radio" name="webrtc" value="display+speaker">display+speaker = broadcast software</label><br>
|
||||
<div id="webrtc">
|
||||
<h2>WebRTC Magic</h2>
|
||||
<label><input type="radio" name="webrtc" value="video+audio" checked>
|
||||
video+audio = simple viewer
|
||||
</label>
|
||||
<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>
|
||||
<li><a id="local" href="webrtc.html?src=">webrtc.html</a> local WebRTC viewer</li>
|
||||
<br>
|
||||
<li><a id="local" href="webrtc.html?src=">webrtc.html</a> local WebRTC viewer</li>
|
||||
|
||||
<li>
|
||||
<a id="shareadd" href="#">share link</a>
|
||||
<a id="shareget" href="#">copy link</a>
|
||||
<a id="sharedel" href="#">delete</a>
|
||||
external WebRTC viewer
|
||||
</li>
|
||||
</div>
|
||||
<script>
|
||||
function webrtcLinksUpdate() {
|
||||
const media = document.querySelector('input[name="webrtc"]:checked').value;
|
||||
<li>
|
||||
<a id="shareadd" href="#">share link</a>
|
||||
<a id="shareget" href="#">copy link</a>
|
||||
<a id="sharedel" href="#">delete</a>
|
||||
external WebRTC viewer
|
||||
</li>
|
||||
</div>
|
||||
<script>
|
||||
function webrtcLinksUpdate() {
|
||||
const media = document.querySelector('input[name="webrtc"]:checked').value;
|
||||
|
||||
const direction = media.indexOf('video') >= 0 || media === 'audio' ? 'src' : 'dst';
|
||||
document.getElementById('local').href = `webrtc.html?${direction}=${src}&media=${media}`;
|
||||
const direction = media.indexOf('video') >= 0 || media === 'audio' ? 'src' : 'dst';
|
||||
document.getElementById('local').href = `webrtc.html?${direction}=${src}&media=${media}`;
|
||||
|
||||
const share = document.getElementById('shareget');
|
||||
share.href = `https://alexxit.github.io/go2rtc/#${share.dataset.auth}&media=${media}`;
|
||||
}
|
||||
const share = document.getElementById('shareget');
|
||||
share.href = `https://alexxit.github.io/go2rtc/#${share.dataset.auth}&media=${media}`;
|
||||
}
|
||||
|
||||
function share(method) {
|
||||
const url = new URL('api/webtorrent', location.href);
|
||||
url.searchParams.set('src', src);
|
||||
return fetch(url, {method: method, cache: 'no-cache'});
|
||||
}
|
||||
function share(method) {
|
||||
const url = new URL('api/webtorrent', location.href);
|
||||
url.searchParams.set('src', src);
|
||||
return fetch(url, {method: method, cache: 'no-cache'});
|
||||
}
|
||||
|
||||
function onshareadd(r) {
|
||||
document.getElementById('shareget').dataset['auth'] = `share=${r.share}&pwd=${r.pwd}`;
|
||||
function onshareadd(r) {
|
||||
document.getElementById('shareget').dataset['auth'] = `share=${r.share}&pwd=${r.pwd}`;
|
||||
|
||||
document.getElementById('shareadd').style.display = 'none';
|
||||
document.getElementById('shareget').style.display = '';
|
||||
document.getElementById('sharedel').style.display = '';
|
||||
document.getElementById('shareadd').style.display = 'none';
|
||||
document.getElementById('shareget').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();
|
||||
}
|
||||
|
||||
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>
|
||||
</script>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+42
-46
@@ -1,69 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
main > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
table tbody td {
|
||||
table tbody {
|
||||
font-size: 13px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: #0174DF;
|
||||
}
|
||||
|
||||
.debug {
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #DF0101;
|
||||
}
|
||||
|
||||
.trace {
|
||||
color: #585858;
|
||||
color: #585858 !important;
|
||||
}
|
||||
|
||||
.debug {
|
||||
color: #808080 !important;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: #0174DF !important;
|
||||
}
|
||||
|
||||
.warn {
|
||||
color: #FF9966;
|
||||
color: #FF9966 !important;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #DF0101 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<script src="main.js"></script>
|
||||
<div>
|
||||
<button id="clean">Clean</button>
|
||||
<button id="update">Auto Update: ON</button>
|
||||
<button id="reverse">Reverse Log Order: OFF</button>
|
||||
</div>
|
||||
<br>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 100px">Time</th>
|
||||
<th style="width: 40px">Level</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="log">
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<main>
|
||||
<div>
|
||||
<button id="clean">Clean</button>
|
||||
<button id="update">Auto Update: ON</button>
|
||||
<button id="reverse">Reverse Log Order: OFF</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 100px">Time</th>
|
||||
<th style="width: 40px">Level</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="log">
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
document.getElementById('clean').addEventListener('click', async () => {
|
||||
const r = await fetch('api/log', {method: 'DELETE'});
|
||||
@@ -145,5 +140,6 @@
|
||||
if (autoUpdateEnabled) reload();
|
||||
}, 5000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+114
-180
@@ -1,200 +1,134 @@
|
||||
// main menu
|
||||
document.body.innerHTML = `
|
||||
document.head.innerHTML += `
|
||||
<style>
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
font-family: 'Lora', serif;
|
||||
transition: .5s linear;
|
||||
}
|
||||
/* navigation block */
|
||||
nav {
|
||||
background-color: #333;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 10px;
|
||||
}
|
||||
nav a {
|
||||
float: left;
|
||||
display: block;
|
||||
color: #f2f2f2;
|
||||
text-align: center;
|
||||
padding: 14px 16px;
|
||||
text-decoration: none;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: block;
|
||||
margin: 0 auto 10px;
|
||||
}
|
||||
nav a:hover {
|
||||
background-color: #ddd;
|
||||
color: black;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
padding: 1em 0;
|
||||
background: #ECDAD6;
|
||||
}
|
||||
/* main block */
|
||||
main {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
nav a {
|
||||
padding: 1em;
|
||||
background: rgba(177, 152, 145, .3);
|
||||
border-right: 1px solid #b19891;
|
||||
color: #695753;
|
||||
}
|
||||
/* checkbox */
|
||||
label {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
background: #b19891;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
nav li {
|
||||
display: inline;
|
||||
}
|
||||
/* form */
|
||||
form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: white;
|
||||
}
|
||||
table {
|
||||
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;
|
||||
}
|
||||
input[type="text"] {
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
body.dark-mode {
|
||||
background-color: #121212;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body.dark-mode nav ul {
|
||||
background: #333;
|
||||
}
|
||||
/* table */
|
||||
table {
|
||||
width: 100%;
|
||||
background-color: white;
|
||||
border-collapse: collapse;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.dark-mode a {
|
||||
background: rgba(45, 45, 45, .8);
|
||||
border-right: 1px solid #2c2c2c;
|
||||
color: #c7c7c7;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode a:hover {
|
||||
background: #555;
|
||||
}
|
||||
th {
|
||||
background-color: #444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
body.dark-mode a:visited {
|
||||
color: #999;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
body.dark-mode table {
|
||||
background-color: #222;
|
||||
color: #ddd;
|
||||
}
|
||||
tr:hover {
|
||||
background-color: #edf7ff;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
body.dark-mode table thead {
|
||||
background: linear-gradient(to bottom, #444 0%, #3d3d3d 66%, #333 100%);
|
||||
border-bottom: 3px solid #888;
|
||||
}
|
||||
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;
|
||||
}
|
||||
/* table on mobile */
|
||||
@media (max-width: 480px) {
|
||||
table, thead, tbody, th, td, tr {
|
||||
display: block;
|
||||
}
|
||||
|
||||
body.dark-mode button {
|
||||
background: rgba(255, 255, 255, .1);
|
||||
border: 1px solid #444;
|
||||
color: #ccc;
|
||||
}
|
||||
th, td {
|
||||
box-sizing: border-box;
|
||||
width: 100% !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
body.dark-mode input,
|
||||
body.dark-mode select,
|
||||
body.dark-mode textarea {
|
||||
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;
|
||||
}
|
||||
tr {
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="index.html">Streams</a></li>
|
||||
<li><a href="add.html">Add</a></li>
|
||||
<li><a href="editor.html">Config</a></li>
|
||||
<li><a href="log.html">Log</a></li>
|
||||
<li><a href="network.html">Net</a></li>
|
||||
<li><a href="#" id="darkModeToggle">
|
||||
🌙
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
`;
|
||||
|
||||
document.body.innerHTML = `
|
||||
<header>
|
||||
<nav>
|
||||
<a href="index.html"><b>go2rtc</b></a>
|
||||
<a href="add.html">add</a>
|
||||
<a href="config.html">config</a>
|
||||
<a href="log.html">log</a>
|
||||
<a href="net.html">net</a>
|
||||
</nav>
|
||||
</header>
|
||||
` + 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">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>go2rtc - Network</title>
|
||||
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
html, body, #network {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#network {
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="network"></div>
|
||||
|
||||
<script src="main.js"></script>
|
||||
|
||||
<div id="network"></div>
|
||||
|
||||
<script>
|
||||
/* global vis */
|
||||
window.addEventListener('load', () => {
|
||||
@@ -79,5 +69,6 @@
|
||||
update();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+19
-15
@@ -185,7 +185,7 @@ export class VideoRTC extends HTMLElement {
|
||||
/** @param {Function} isSupported */
|
||||
codecs(isSupported) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -350,23 +350,23 @@ export class VideoRTC extends HTMLElement {
|
||||
|
||||
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');
|
||||
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');
|
||||
this.onhls();
|
||||
} else if (this.mode.indexOf('mp4') >= 0) {
|
||||
} else if (this.mode.includes('mp4')) {
|
||||
modes.push('mp4');
|
||||
this.onmp4();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) {
|
||||
if (this.mode.includes('webrtc') && 'RTCPeerConnection' in window) {
|
||||
modes.push('webrtc');
|
||||
this.onwebrtc();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf('mjpeg') >= 0) {
|
||||
if (this.mode.includes('mjpeg')) {
|
||||
if (modes.length) {
|
||||
this.onmessage['mjpeg'] = msg => {
|
||||
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);
|
||||
|
||||
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 : '';
|
||||
this.send({type: 'webrtc/candidate', value: candidate});
|
||||
@@ -518,7 +518,7 @@ export class VideoRTC extends HTMLElement {
|
||||
this.onmessage['webrtc'] = msg => {
|
||||
switch (msg.type) {
|
||||
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 => {
|
||||
console.warn(er);
|
||||
@@ -530,7 +530,7 @@ export class VideoRTC extends HTMLElement {
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
if (msg.value.indexOf('webrtc/offer') < 0) return;
|
||||
if (!msg.value.includes('webrtc/offer')) return;
|
||||
pc.close();
|
||||
}
|
||||
};
|
||||
@@ -549,7 +549,7 @@ export class VideoRTC extends HTMLElement {
|
||||
*/
|
||||
async createOffer(pc) {
|
||||
try {
|
||||
if (this.media.indexOf('microphone') >= 0) {
|
||||
if (this.media.includes('microphone')) {
|
||||
const media = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||
media.getTracks().forEach(track => {
|
||||
pc.addTransceiver(track, {direction: 'sendonly'});
|
||||
@@ -560,7 +560,7 @@ export class VideoRTC extends HTMLElement {
|
||||
}
|
||||
|
||||
for (const kind of ['video', 'audio']) {
|
||||
if (this.media.indexOf(kind) >= 0) {
|
||||
if (this.media.includes(kind)) {
|
||||
pc.addTransceiver(kind, {direction: 'recvonly'});
|
||||
}
|
||||
}
|
||||
@@ -580,12 +580,16 @@ export class VideoRTC extends HTMLElement {
|
||||
|
||||
/** @type {MediaStream} */
|
||||
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 (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
|
||||
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
|
||||
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
|
||||
if (this.mseCodecs.includes('hvc1.')) msePriority += 0x230;
|
||||
if (this.mseCodecs.includes('avc1.')) msePriority += 0x210;
|
||||
if (this.mseCodecs.includes('mp4a.')) msePriority += 0x101;
|
||||
|
||||
if (rtcPriority >= msePriority) {
|
||||
this.video.srcObject = stream;
|
||||
|
||||
Reference in New Issue
Block a user