diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7950004d..c802df63 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 - with: { go-version: '1.24' } + with: { go-version: '1.25' } - name: Build go2rtc_win64 env: { GOOS: windows, GOARCH: amd64 } @@ -29,7 +29,7 @@ jobs: with: { name: go2rtc_win64, path: go2rtc.exe } - name: Build go2rtc_win32 - env: { GOOS: windows, GOARCH: 386, GOTOOLCHAIN: go1.20.14 } + env: { GOOS: windows, GOARCH: 386 } run: go build -ldflags "-s -w" -trimpath - name: Upload go2rtc_win32 uses: actions/upload-artifact@v4 @@ -85,7 +85,7 @@ jobs: with: { name: go2rtc_linux_mipsel, path: go2rtc } - name: Build go2rtc_mac_amd64 - env: { GOOS: darwin, GOARCH: amd64, GOTOOLCHAIN: go1.20.14 } + env: { GOOS: darwin, GOARCH: amd64 } run: go build -ldflags "-s -w" -trimpath - name: Upload go2rtc_mac_amd64 uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index 1c89513c..74c71a0b 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - [Fast start](#fast-start) - [go2rtc: Binary](#go2rtc-binary) - [go2rtc: Docker](#go2rtc-docker) - - [go2rtc: Home Assistant add-on](#go2rtc-home-assistant-add-on) + - [go2rtc: Home Assistant Add-on](#go2rtc-home-assistant-add-on) - [go2rtc: Home Assistant Integration](#go2rtc-home-assistant-integration) - [go2rtc: Dev version](#go2rtc-dev-version) - [Configuration](#configuration) @@ -69,14 +69,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) - - [Incoming: Browser](#incoming-browser) - - [Incoming: WebRTC/WHIP](#incoming-webrtcwhip) - [Stream to camera](#stream-to-camera) - - [Publish stream](#publish-stream) + - [Publish stream](#publish-stream) + - [Preload stream](#preload-stream) - [Module: API](#module-api) - [Module: RTSP](#module-rtsp) - [Module: RTMP](#module-rtmp) @@ -92,11 +92,10 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF - [Security](#security) - [Codecs filters](#codecs-filters) - [Codecs madness](#codecs-madness) -- [Built-in transcoding](#built-in-transcoding) - [Codecs negotiation](#codecs-negotiation) - [Projects using go2rtc](#projects-using-go2rtc) - [Camera experience](#camera-experience) -- [Tips](#tips) +- [TIPS](#tips) - [FAQ](#faq) ## Fast start @@ -119,7 +118,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF Download binary for your OS from [latest release](https://github.com/AlexxIT/go2rtc/releases/): - `go2rtc_win64.zip` - Windows 10+ 64-bit -- `go2rtc_win32.zip` - Windows 7+ 32-bit +- `go2rtc_win32.zip` - Windows 10+ 32-bit - `go2rtc_win_arm64.zip` - Windows ARM 64-bit - `go2rtc_linux_amd64` - Linux 64-bit - `go2rtc_linux_i386` - Linux 32-bit @@ -127,7 +126,7 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2 - `go2rtc_linux_arm` - Linux ARM 32-bit (ex. Raspberry 32-bit OS) - `go2rtc_linux_armv6` - Linux ARMv6 (for old Raspberry 1 and Zero) - `go2rtc_linux_mipsel` - Linux MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3), [Wyze cameras](https://github.com/gtxaspec/wz_mini_hacks)) -- `go2rtc_mac_amd64.zip` - macOS 10.13+ Intel 64-bit +- `go2rtc_mac_amd64.zip` - macOS 11+ Intel 64-bit - `go2rtc_mac_arm64.zip` - macOS ARM 64-bit - `go2rtc_freebsd_amd64.zip` - FreeBSD 64-bit - `go2rtc_freebsd_arm64.zip` - FreeBSD ARM 64-bit @@ -202,6 +201,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 @@ -223,6 +223,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)). @@ -534,7 +535,7 @@ streams: - stream quality is the same as [RTSP protocol](https://www.tapo.com/en/faq/34/) - use the **cloud password**, this is not the RTSP password! you do not need to add a login! -- you can also use UPPERCASE MD5 hash from your cloud password with `admin` username +- you can also use **UPPERCASE** MD5 hash from your cloud password with `admin` username - some new camera firmwares require SHA256 instead of MD5 ```yaml @@ -545,6 +546,10 @@ streams: camera2: tapo://admin:UPPERCASE-MD5@192.168.1.123 # admin username and UPPERCASE SHA256 cloud-password hash camera3: tapo://admin:UPPERCASE-SHA256@192.168.1.123 + # VGA stream (the so called substream, the lower resolution one) + camera4: tapo://cloud-password@192.168.1.123?subtype=1 + # HD stream (default) + camera5: tapo://cloud-password@192.168.1.123?subtype=0 ``` ```bash @@ -645,6 +650,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)* @@ -828,6 +843,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/`. @@ -1387,6 +1422,7 @@ streams: - [ioBroker.euSec](https://github.com/bropat/ioBroker.eusec) - [ioBroker](https://www.iobroker.net/) adapter for controlling Eufy security devices - [MMM-go2rtc](https://github.com/Anonym-tsk/MMM-go2rtc) - MagicMirror² module - [ring-mqtt](https://github.com/tsightler/ring-mqtt) - Ring-to-MQTT bridge +- [lightNVR](https://github.com/opensensor/lightNVR) **Distributions** @@ -1394,7 +1430,7 @@ streams: - [Arch User Repository](https://linux-packages.com/aur/package/go2rtc) - [Gentoo](https://github.com/inode64/inode64-overlay/tree/main/media-video/go2rtc) - [NixOS](https://search.nixos.org/packages?query=go2rtc) -- [Proxmox Helper Scripts](https://tteck.github.io/Proxmox/) +- [Proxmox Helper Scripts](https://github.com/community-scripts/ProxmoxVE/) - [QNAP](https://www.myqnap.org/product/go2rtc/) - [Synology NAS](https://synocommunity.com/package/go2rtc) - [Unraid](https://unraid.net/community/apps?q=go2rtc) diff --git a/api/openapi.yaml b/api/openapi.yaml index 618acb48..a2d66a87 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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 diff --git a/docker/Dockerfile b/docker/Dockerfile index 34a96757..854ea6c9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,7 +2,7 @@ # 0. Prepare images ARG PYTHON_VERSION="3.11" -ARG GO_VERSION="1.24" +ARG GO_VERSION="1.25" # 1. Build go2rtc binary diff --git a/docker/hardware.Dockerfile b/docker/hardware.Dockerfile index 03b7d496..a80d08d7 100644 --- a/docker/hardware.Dockerfile +++ b/docker/hardware.Dockerfile @@ -4,7 +4,7 @@ # only debian 13 (trixie) has latest ffmpeg # https://packages.debian.org/trixie/ffmpeg ARG DEBIAN_VERSION="trixie-slim" -ARG GO_VERSION="1.24-bookworm" +ARG GO_VERSION="1.25-bookworm" # 1. Build go2rtc binary diff --git a/docker/rockchip.Dockerfile b/docker/rockchip.Dockerfile index a7a1b450..949db83b 100644 --- a/docker/rockchip.Dockerfile +++ b/docker/rockchip.Dockerfile @@ -2,7 +2,7 @@ # 0. Prepare images ARG PYTHON_VERSION="3.13-slim-bookworm" -ARG GO_VERSION="1.24-bookworm" +ARG GO_VERSION="1.25-bookworm" # 1. Build go2rtc binary diff --git a/go.mod b/go.mod index 997737cf..7abf1edd 100644 --- a/go.mod +++ b/go.mod @@ -1,49 +1,49 @@ module github.com/AlexxIT/go2rtc -go 1.20 +go 1.23.0 require ( github.com/asticode/go-astits v1.13.0 - github.com/expr-lang/expr v1.17.2 + github.com/expr-lang/expr v1.17.5 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.63 - github.com/pion/ice/v4 v4.0.9 - github.com/pion/interceptor v0.1.37 + github.com/miekg/dns v1.1.66 + 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.13 - github.com/pion/sdp/v3 v3.0.11 - github.com/pion/srtp/v3 v3.0.4 + 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/stun/v3 v3.0.0 - github.com/pion/webrtc/v4 v4.0.14 + github.com/pion/webrtc/v4 v4.1.3 github.com/rs/zerolog v1.34.0 github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f github.com/stretchr/testify v1.10.0 github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 - golang.org/x/crypto v0.33.0 + golang.org/x/crypto v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/asticode/go-astikit v0.54.0 // indirect + github.com/asticode/go-astikit v0.56.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v3 v3.0.6 // indirect - github.com/pion/logging v0.2.3 // 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.37 // 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.0 // indirect + github.com/pion/turn/v4 v4.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/mod v0.20.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/tools v0.24.0 // 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 ) diff --git a/go.sum b/go.sum index c5a92c73..7e1b0cee 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= github.com/asticode/go-astikit v0.54.0 h1:uq9eurgisdkYwJU9vSWIQaPH4MH0cac82sQH00kmSNQ= github.com/asticode/go-astikit v0.54.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= +github.com/asticode/go-astikit v0.56.0 h1:DmD2p7YnvxiPdF0h+dRmos3bsejNEXbycENsY5JfBqw= +github.com/asticode/go-astikit v0.56.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c= github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -10,6 +12,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/expr-lang/expr v1.17.2 h1:o0A99O/Px+/DTjEnQiodAgOIK9PPxL8DtXhBRKC+Iso= github.com/expr-lang/expr v1.17.2/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= +github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/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= @@ -28,16 +32,24 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= +github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= +github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= github.com/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/ice/v4 v4.0.9 h1:VKgU4MwA2LUDVLq+WBkpEHTcAb8c5iCvFMECeuPOZNk= github.com/pion/ice/v4 v4.0.9/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= +github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= +github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= +github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= github.com/pion/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= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= @@ -46,20 +58,32 @@ github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= 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/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/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/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/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/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/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/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= @@ -84,19 +108,31 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/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/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/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/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/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= 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= diff --git a/internal/api/ws/ws.go b/internal/api/ws/ws.go index 1d945bfe..981d1b41 100644 --- a/internal/api/ws/ws.go +++ b/internal/api/ws/ws.go @@ -11,6 +11,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/gorilla/websocket" "github.com/rs/zerolog" ) @@ -132,7 +133,8 @@ func apiWS(w http.ResponseWriter, r *http.Request) { if handler := wsHandlers[msg.Type]; handler != nil { go func() { if err = handler(tr, msg); err != nil { - tr.Write(&Message{Type: "error", Value: msg.Type + ": " + err.Error()}) + errMsg := core.StripUserinfo(err.Error()) + tr.Write(&Message{Type: "error", Value: msg.Type + ": " + errMsg}) } }() } diff --git a/internal/app/config.go b/internal/app/config.go index 9d4480b7..f0eb36e0 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/AlexxIT/go2rtc/pkg/yaml" @@ -18,11 +19,16 @@ func LoadConfig(v any) { } } +var configMu sync.Mutex + func PatchConfig(path []string, value any) error { if ConfigPath == "" { return errors.New("config file disabled") } + configMu.Lock() + defer configMu.Unlock() + // empty config is OK b, _ := os.ReadFile(ConfigPath) diff --git a/internal/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go index e3b0c161..242c151d 100644 --- a/internal/ffmpeg/ffmpeg.go +++ b/internal/ffmpeg/ffmpeg.go @@ -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", diff --git a/internal/ring/ring.go b/internal/ring/ring.go index 673ea480..7fdb284f 100644 --- a/internal/ring/ring.go +++ b/internal/ring/ring.go @@ -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 diff --git a/internal/streams/api.go b/internal/streams/api.go index 061e61c2..d162cdf9 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -5,6 +5,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/probe" ) @@ -27,7 +28,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 { @@ -122,3 +123,51 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { 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) + } +} diff --git a/internal/streams/preload.go b/internal/streams/preload.go new file mode 100644 index 00000000..527746ac --- /dev/null +++ b/internal/streams/preload.go @@ -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") +} diff --git a/internal/streams/streams.go b/internal/streams/streams.go index dcbaba28..a0b1ed68 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -14,8 +14,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,17 +29,24 @@ func Init() { api.HandleFunc("api/streams", apiStreams) api.HandleFunc("api/streams.dot", apiStreamsDOT) + api.HandleFunc("api/preload", apiPreload) - 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) + } + } }) } diff --git a/main.go b/main.go index e85c5900..0cfc31fb 100644 --- a/main.go +++ b/main.go @@ -44,7 +44,7 @@ import ( ) func main() { - app.Version = "1.9.9" + app.Version = "1.9.10" // 1. Core modules: app, api/ws, streams diff --git a/pkg/core/core_test.go b/pkg/core/core_test.go index 4a05380a..e7845ca7 100644 --- a/pkg/core/core_test.go +++ b/pkg/core/core_test.go @@ -118,3 +118,17 @@ func TestName(t *testing.T) { // stage3 _ = prod2.Stop() } + +func TestStripUserinfo(t *testing.T) { + s := `streams: + test: + - ffmpeg:rtsp://username:password@10.1.2.3:554/stream1 + - ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy +` + s = StripUserinfo(s) + require.Equal(t, `streams: + test: + - ffmpeg:rtsp://***@10.1.2.3:554/stream1 + - ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy +`, s) +} diff --git a/pkg/core/helpers.go b/pkg/core/helpers.go index 72afe897..161a5504 100644 --- a/pkg/core/helpers.go +++ b/pkg/core/helpers.go @@ -2,6 +2,7 @@ package core import ( "crypto/rand" + "regexp" "runtime" "strconv" "strings" @@ -77,3 +78,14 @@ func Caller() string { _, file, line, _ := runtime.Caller(1) return file + ":" + strconv.Itoa(line) } + +const ( + unreserved = `A-Za-z0-9-._~` + subdelims = `!$&'()*+,;=` + userinfo = unreserved + subdelims + `%:` +) + +func StripUserinfo(s string) string { + sanitizer := regexp.MustCompile(`://[` + userinfo + `]+@`) + return sanitizer.ReplaceAllString(s, `://***@`) +} diff --git a/pkg/h264/avcc.go b/pkg/h264/avcc.go index d21e3ea3..dd3a5687 100644 --- a/pkg/h264/avcc.go +++ b/pkg/h264/avcc.go @@ -16,6 +16,11 @@ func RepairAVCC(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { ps := JoinNALU(sps, pps) return func(packet *rtp.Packet) { + // this can happen for FLV from FFmpeg + if NALUType(packet.Payload) == NALUTypeSEI { + size := int(binary.BigEndian.Uint32(packet.Payload)) + 4 + packet.Payload = packet.Payload[size:] + } if NALUType(packet.Payload) == NALUTypeIFrame { packet.Payload = Join(ps, packet.Payload) } diff --git a/pkg/h265/rtp.go b/pkg/h265/rtp.go index 7a55b408..72d2c02f 100644 --- a/pkg/h265/rtp.go +++ b/pkg/h265/rtp.go @@ -9,8 +9,8 @@ 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 @@ -40,9 +40,9 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { 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 diff --git a/pkg/hap/camera/accessory.go b/pkg/hap/camera/accessory.go index 42037d96..973983ec 100644 --- a/pkg/hap/camera/accessory.go +++ b/pkg/hap/camera/accessory.go @@ -12,7 +12,7 @@ func NewAccessory(manuf, model, name, serial, firmware string) *hap.Accessory { hap.ServiceAccessoryInformation(manuf, model, name, serial, firmware), ServiceCameraRTPStreamManagement(), //hap.ServiceHAPProtocolInformation(), - //ServiceMicrophone(), + ServiceMicrophone(), }, } acc.InitIID() @@ -30,17 +30,17 @@ func ServiceMicrophone() *hap.Service { Perms: hap.EVPRPW, //Descr: "Mute", }, - { - Type: "119", - Format: hap.FormatUInt8, - Value: 100, - Perms: hap.EVPRPW, - //Descr: "Volume", - //Unit: hap.UnitPercentage, - //MinValue: 0, - //MaxValue: 100, - //MinStep: 1, - }, + //{ + // Type: "119", + // Format: hap.FormatUInt8, + // Value: 100, + // Perms: hap.EVPRPW, + // //Descr: "Volume", + // //Unit: hap.UnitPercentage, + // //MinValue: 0, + // //MaxValue: 100, + // //MinStep: 1, + //}, }, } } @@ -62,7 +62,7 @@ func ServiceCameraRTPStreamManagement() *hap.Service { VideoAttrs: []VideoAttrs{ {Width: 1920, Height: 1080, Framerate: 30}, {Width: 1280, Height: 720, Framerate: 30}, // important for iPhones - {Width: 320, Height: 240, Framerate: 15}, // apple watch + {Width: 320, Height: 240, Framerate: 15}, // apple watch }, }, }, diff --git a/pkg/hap/helpers.go b/pkg/hap/helpers.go index d1400b84..3900f935 100644 --- a/pkg/hap/helpers.go +++ b/pkg/hap/helpers.go @@ -71,11 +71,17 @@ type JSONCharacter struct { Event any `json:"ev,omitempty"` } +// 4.2.1.2 Invalid Setup Codes +const insecurePINs = "00000000 11111111 22222222 33333333 44444444 55555555 66666666 77777777 88888888 99999999 12345678 87654321" + func SanitizePin(pin string) (string, error) { s := strings.ReplaceAll(pin, "-", "") if len(s) != 8 { return "", errors.New("hap: wrong PIN format: " + pin) } + if strings.Contains(insecurePINs, s) { + return "", errors.New("hap: insecure PIN: " + pin) + } // 123-45-678 return s[:3] + "-" + s[3:5] + "-" + s[5:], nil } diff --git a/pkg/hap/tlv8/tlv8.go b/pkg/hap/tlv8/tlv8.go index 068f21c3..7af27ea4 100644 --- a/pkg/hap/tlv8/tlv8.go +++ b/pkg/hap/tlv8/tlv8.go @@ -46,6 +46,8 @@ func Marshal(v any) ([]byte, error) { } switch kind { + case reflect.Slice: + return appendSlice(nil, value) case reflect.Struct: return appendStruct(nil, value) } @@ -53,6 +55,23 @@ func Marshal(v any) ([]byte, error) { return nil, errors.New("tlv8: not implemented: " + kind.String()) } +// separator the most confusing meaning in the documentation. +// It can have a value of 0x00 or 0xFF or even 0x05. +const separator = 0xFF + +func appendSlice(b []byte, value reflect.Value) ([]byte, error) { + for i := 0; i < value.Len(); i++ { + if i > 0 { + b = append(b, separator, 0) + } + var err error + if b, err = appendStruct(b, value.Index(i)); err != nil { + return nil, err + } + } + return b, nil +} + func appendStruct(b []byte, value reflect.Value) ([]byte, error) { valueType := value.Type() @@ -121,7 +140,7 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) { case reflect.Slice: for i := 0; i < value.Len(); i++ { if i > 0 { - b = append(b, 0, 0) + b = append(b, separator, 0) } if b, err = appendValue(b, tag, value.Index(i)); err != nil { return nil, err @@ -179,64 +198,86 @@ func Unmarshal(data []byte, v any) error { kind = value.Kind() } - if kind != reflect.Struct { - return errors.New("tlv8: not implemented: " + kind.String()) + switch kind { + case reflect.Slice: + return unmarshalSlice(data, value) + case reflect.Struct: + return unmarshalStruct(data, value) } - return unmarshalStruct(data, value) + return errors.New("tlv8: not implemented: " + kind.String()) } -func unmarshalStruct(b []byte, value reflect.Value) error { - var waitSlice bool +// unmarshalTLV can return two types of errors: +// - critical and then the value of []byte will be nil +// - not critical and then []byte will contain the value +func unmarshalTLV(b []byte, value reflect.Value) ([]byte, error) { + if len(b) < 2 { + return nil, errors.New("tlv8: wrong size: " + value.Type().Name()) + } - for len(b) >= 2 { - t := b[0] - l := int(b[1]) + t := b[0] + l := int(b[1]) - // array item divider - if t == 0 && l == 0 { - b = b[2:] - waitSlice = true - continue + // array item divider (t == 0x00 || t == 0xFF) + if l == 0 { + return b[2:], errors.New("tlv8: zero item") + } + + var v []byte + + for { + if len(b) < 2+l { + return nil, errors.New("tlv8: wrong size: " + value.Type().Name()) } - var v []byte + v = append(v, b[2:2+l]...) + b = b[2+l:] - for { - if len(b) < 2+l { - return errors.New("tlv8: wrong size: " + value.Type().Name()) + // if size == 255 and same tag - continue read big payload + if l < 255 || len(b) < 2 || b[0] != t { + break + } + + l = int(b[1]) + } + + tag := strconv.Itoa(int(t)) + + valueField, ok := getStructField(value, tag) + if !ok { + return b, fmt.Errorf("tlv8: can't find T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name()) + } + + if err := unmarshalValue(v, valueField); err != nil { + return nil, err + } + + return b, nil +} + +func unmarshalSlice(b []byte, value reflect.Value) error { + valueIndex := value.Index(growSlice(value)) + for len(b) > 0 { + var err error + if b, err = unmarshalTLV(b, valueIndex); err != nil { + if b != nil { + valueIndex = value.Index(growSlice(value)) + continue } - - v = append(v, b[2:2+l]...) - b = b[2+l:] - - // if size == 255 and same tag - continue read big payload - if l < 255 || len(b) < 2 || b[0] != t { - break - } - - l = int(b[1]) - } - - tag := strconv.Itoa(int(t)) - - valueField, ok := getStructField(value, tag) - if !ok { - return fmt.Errorf("tlv8: can't find T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name()) - } - - if waitSlice { - if valueField.Kind() != reflect.Slice { - return fmt.Errorf("tlv8: should be slice T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name()) - } - waitSlice = false - } - - if err := unmarshalValue(v, valueField); err != nil { return err } } + return nil +} +func unmarshalStruct(b []byte, value reflect.Value) error { + for len(b) > 0 { + var err error + if b, err = unmarshalTLV(b, value); b == nil && err != nil { + return err + } + } return nil } diff --git a/pkg/hap/tlv8/tlv8_test.go b/pkg/hap/tlv8/tlv8_test.go index 5ac41fec..bb44c981 100644 --- a/pkg/hap/tlv8/tlv8_test.go +++ b/pkg/hap/tlv8/tlv8_test.go @@ -2,6 +2,7 @@ package tlv8 import ( "encoding/hex" + "strings" "testing" "github.com/stretchr/testify/require" @@ -107,3 +108,49 @@ func TestInterface(t *testing.T) { require.Equal(t, src, dst) } + +func TestSlice1(t *testing.T) { + var v struct { + VideoAttrs []struct { + Width uint16 `tlv8:"1"` + Height uint16 `tlv8:"2"` + Framerate uint8 `tlv8:"3"` + } `tlv8:"3"` + } + + s := `030b010280070202380403011e ff00 030b010200050202d00203011e` + b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + require.NoError(t, err) + + err = Unmarshal(b1, &v) + require.NoError(t, err) + + require.Len(t, v.VideoAttrs, 2) + + b2, err := Marshal(v) + require.NoError(t, err) + + require.Equal(t, b1, b2) +} + +func TestSlice2(t *testing.T) { + var v []struct { + Width uint16 `tlv8:"1"` + Height uint16 `tlv8:"2"` + Framerate uint8 `tlv8:"3"` + } + + s := `010280070202380403011e ff00 010200050202d00203011e` + b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + require.NoError(t, err) + + err = Unmarshal(b1, &v) + require.NoError(t, err) + + require.Len(t, v, 2) + + b2, err := Marshal(v) + require.NoError(t, err) + + require.Equal(t, b1, b2) +} diff --git a/pkg/probe/producer.go b/pkg/probe/consumer.go similarity index 60% rename from pkg/probe/producer.go rename to pkg/probe/consumer.go index 1fbd3efb..c6aa4478 100644 --- a/pkg/probe/producer.go +++ b/pkg/probe/consumer.go @@ -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,18 @@ 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 -} diff --git a/pkg/ring/api.go b/pkg/ring/api.go index ed69465f..ea7c95ad 100644 --- a/pkg/ring/api.go +++ b/pkg/ring/api.go @@ -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")) diff --git a/pkg/ring/client.go b/pkg/ring/client.go index 18244a39..fb77e198 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -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) } diff --git a/pkg/ring/snapshot.go b/pkg/ring/snapshot.go index f64e4f79..b52eadac 100644 --- a/pkg/ring/snapshot.go +++ b/pkg/ring/snapshot.go @@ -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 } diff --git a/pkg/ring/ws.go b/pkg/ring/ws.go new file mode 100644 index 00000000..51e72fe6 --- /dev/null +++ b/pkg/ring/ws.go @@ -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() + } +} diff --git a/pkg/rtsp/helpers.go b/pkg/rtsp/helpers.go index d8ed1685..c73bd0a2 100644 --- a/pkg/rtsp/helpers.go +++ b/pkg/rtsp/helpers.go @@ -116,20 +116,39 @@ func findFmtpLine(payloadType uint8, descriptions []*sdp.MediaDescription) strin // urlParse fix bugs: // 1. Content-Base: rtsp://::ffff:192.168.1.123/onvif/profile.1/ // 2. Content-Base: rtsp://rtsp://turret2-cam.lan:554/stream1/ +// 3. Content-Base: 192.168.253.220:1935/ func urlParse(rawURL string) (*url.URL, error) { // fix https://github.com/AlexxIT/go2rtc/issues/830 if strings.HasPrefix(rawURL, "rtsp://rtsp://") { rawURL = rawURL[7:] } + // fix https://github.com/AlexxIT/go2rtc/issues/1852 + if !strings.Contains(rawURL, "://") { + rawURL = "rtsp://" + rawURL + } + u, err := url.Parse(rawURL) if err != nil && strings.HasSuffix(err.Error(), "after host") { - if i1 := strings.Index(rawURL, "://"); i1 > 0 { - if i2 := strings.IndexByte(rawURL[i1+3:], '/'); i2 > 0 { - return urlParse(rawURL[:i1+3+i2] + ":" + rawURL[i1+3+i2:]) - } + if i := indexN(rawURL, '/', 3); i > 0 { + return urlParse(rawURL[:i] + ":" + rawURL[i:]) } } return u, err } + +func indexN(s string, c byte, n int) int { + var offset int + for { + i := strings.IndexByte(s[offset:], c) + if i < 0 { + break + } + if n--; n == 0 { + return offset + i + } + offset += i + 1 + } + return -1 +} diff --git a/pkg/rtsp/rtsp_test.go b/pkg/rtsp/rtsp_test.go index 14c99803..282c04f8 100644 --- a/pkg/rtsp/rtsp_test.go +++ b/pkg/rtsp/rtsp_test.go @@ -11,14 +11,20 @@ func TestURLParse(t *testing.T) { // https://github.com/AlexxIT/WebRTC/issues/395 base := "rtsp://::ffff:192.168.1.123/onvif/profile.1/" u, err := urlParse(base) - assert.Empty(t, err) + assert.NoError(t, err) assert.Equal(t, "::ffff:192.168.1.123:", u.Host) // https://github.com/AlexxIT/go2rtc/issues/208 base = "rtsp://rtsp://turret2-cam.lan:554/stream1/" u, err = urlParse(base) - assert.Empty(t, err) + assert.NoError(t, err) assert.Equal(t, "turret2-cam.lan:554", u.Host) + + // https://github.com/AlexxIT/go2rtc/issues/1852 + base = "192.168.253.220:1935/" + u, err = urlParse(base) + assert.NoError(t, err) + assert.Equal(t, "192.168.253.220:1935", u.Host) } func TestBugSDP1(t *testing.T) { diff --git a/pkg/webrtc/api.go b/pkg/webrtc/api.go index fe49ef1e..79cf6d3c 100644 --- a/pkg/webrtc/api.go +++ b/pkg/webrtc/api.go @@ -125,13 +125,20 @@ func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error networks = append(networks, ice.NetworkType(ntype)) } - udpMux, _ = ice.NewMultiUDPMuxFromPort( + var err error + if udpMux, err = ice.NewMultiUDPMuxFromPort( port, ice.UDPMuxFromPortWithInterfaceFilter(interfaceFilter), ice.UDPMuxFromPortWithIPFilter(ipFilter), ice.UDPMuxFromPortWithNetworks(networks...), - ) - } else if ln, err := net.ListenPacket("udp", address); err == nil { + ); err != nil { + return nil, err + } + } else { + ln, err := net.ListenPacket("udp", address) + if err != nil { + return nil, err + } udpMux = ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: ln}) } s.SetICEUDPMux(udpMux) diff --git a/scripts/README.md b/scripts/README.md index 669fe2b2..5594915d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ ## Versions +**PS.** Unfortunately, due to the dependency on `pion/webrtc/v4 v4.1.3`, had to upgrade go to `1.23`. Everything described below is not relevant. + [Go 1.20](https://go.dev/doc/go1.20) is last version with support Windows 7 and macOS 10.13. Go 1.21 support only Windows 10 and macOS 10.15. diff --git a/scripts/build.cmd b/scripts/build.cmd index a543ea80..37ccd441 100644 --- a/scripts/build.cmd +++ b/scripts/build.cmd @@ -1,18 +1,15 @@ @ECHO OFF -@SET GOTOOLCHAIN= @SET GOOS=windows @SET GOARCH=amd64 @SET FILENAME=go2rtc_win64.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe -@SET GOTOOLCHAIN=go1.20.14 @SET GOOS=windows @SET GOARCH=386 @SET FILENAME=go2rtc_win32.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe -@SET GOTOOLCHAIN= @SET GOOS=windows @SET GOARCH=arm64 @SET FILENAME=go2rtc_win_arm64.zip @@ -50,13 +47,11 @@ go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME @SET FILENAME=go2rtc_linux_mipsel go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% -@SET GOTOOLCHAIN=go1.20.14 @SET GOOS=darwin @SET GOARCH=amd64 @SET FILENAME=go2rtc_mac_amd64.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc -@SET GOTOOLCHAIN= @SET GOOS=darwin @SET GOARCH=arm64 @SET FILENAME=go2rtc_mac_arm64.zip diff --git a/www/add.html b/www/add.html index c8808736..53d6b3dc 100644 --- a/www/add.html +++ b/www/add.html @@ -254,25 +254,30 @@ async function handleRingAuth(ev) { ev.preventDefault(); + + const table = document.getElementById('ring-table'); + 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; } - if (!r.ok) { - const table = document.getElementById('ring-table'); - table.innerText = data.error || 'Unknown error'; - return; - } - - const table = document.getElementById('ring-table'); drawTable(table, data); } diff --git a/www/video-rtc.js b/www/video-rtc.js index fb872b45..8ecbce72 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -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;