Compare commits

...

175 Commits

Author SHA1 Message Date
Alex X 2dc0d58ba7 Update version to 1.9.12 2025-11-16 19:08:34 +03:00
Alex X cb22ae7833 Add security notes to readme 2025-11-16 19:07:03 +03:00
Alex X c98b0a83c4 Merge pull request #1939 from edenhaus/supportedSchemas
Add api endpoint to return supported schemas
2025-11-16 19:04:24 +03:00
Alex X 0bae158e41 Code refactoring for #1939 2025-11-16 19:03:19 +03:00
Robert Resch e2b63a4f6c Remove duplicate code 2025-11-16 16:40:04 +01:00
Robert Resch 3897f10a4d Add api endpoint to return supported schema 2025-11-16 16:33:09 +01:00
Alex X ac80f1470e Add errors output to streams API 2025-11-16 18:20:53 +03:00
Alex X 1fe602679e Update WebUI design 2025-11-15 21:08:00 +03:00
Alex X e2c7d06730 Add check for insecure uri from onvif source 2025-11-11 17:34:01 +03:00
Alex X 2133f5323c Add insecure sources logic 2025-11-11 17:33:15 +03:00
Alex X c10a06d199 Fix wrong log message for streams module 2025-11-11 17:29:10 +03:00
Alex X d053d88ce9 Add support maxwidth/maxheight settings for homekit source 2025-11-11 15:48:12 +03:00
Alex X 2ce38b4486 Add trace log for ignored api paths 2025-11-11 15:43:49 +03:00
Alex X 44d59b1696 Add config local_auth for api module 2025-11-11 15:22:28 +03:00
Alex X 15ec995ecc Add config for the list of modules to init 2025-11-11 15:10:24 +03:00
Alex X 231cab36b2 Add config allow_paths for api module 2025-11-11 15:01:27 +03:00
Alex X 640db3029e Add config allow_paths for exec module 2025-11-11 15:00:58 +03:00
Alex X 2836fdae13 Add config allow_paths for echo module 2025-11-11 14:59:05 +03:00
Alex X 964bb225fa Add support custom params for hass source 2025-11-11 10:54:13 +03:00
Alex X 5cc32197b8 Fix HomeKit proxy for hass source 2025-11-10 15:35:07 +03:00
Alex X bc1a4ac8e4 Fix API /api/homekit/accessories 2025-11-10 15:34:39 +03:00
Alex X 158f9d3a08 Code refactoring for HomeKit server 2025-11-09 21:58:44 +03:00
Alex X 81cfcf877a Fix HomeKit proxy EVENTs 2025-11-09 21:28:53 +03:00
Alex X 96919bf9e3 Add support uint64 to tlv8 2025-11-09 21:25:23 +03:00
Alex X e4359ac217 Rename HomeKit structures according to specs 2025-11-09 21:24:47 +03:00
Alex X ff18283d11 Improve homekit secure conn buffers 2025-10-26 15:46:11 +03:00
Alex X 994e0dc526 Improve homekit tlv8 parsing 2025-10-26 15:46:11 +03:00
Alex X 7254bd4fbc Code refactoring for tapo source 2025-10-24 17:54:55 +03:00
Alex X 9f407a754d Fix tapo source for some cameras #1918 2025-10-24 17:54:37 +03:00
Alex X cc97bc33c4 Restore simple onvif client logic 2025-10-24 17:28:49 +03:00
Alex X 6db4dda535 Fix onvif client for some cameras 2025-10-23 14:42:38 +03:00
Alex X be80eb1ac9 Update version to 1.9.11 2025-10-21 15:32:37 +03:00
Alex X a8d2312cb0 Update dependencies 2025-10-21 15:22:46 +03:00
Alex X 5bbc2aaf2e Move ngrok module docs to another page. 2025-10-21 15:21:33 +03:00
Alex X f8c88cfbe0 Merge pull request #1641 from felipecrs/fix-build.sh
Improve build.sh
2025-10-15 18:08:09 +03:00
Alex X cf4acd5a8d Code refactoring for #1641 2025-10-15 18:07:18 +03:00
Felipe Santos 19226df7d6 Add go2rtc.exe to gitignore 2025-10-15 09:44:20 -03:00
Felipe Santos c415c8f2af Remove GOTOOLCHAIN like done in CI 2025-10-15 09:44:11 -03:00
Felipe Santos 2751f41afb Merge branch 'master' of https://github.com/AlexxIT/go2rtc into fix-build.sh 2025-10-15 09:37:27 -03:00
Alex X d59cb99f0d Support RTSP redirects #1909 by @eddielth 2025-10-15 14:09:07 +03:00
Alex X 007e8dbc75 Add caution note to readme 2025-10-14 17:20:54 +03:00
Alex X dae396a1ed Merge pull request #1910 from felipecrs/go2rtc-gitignore
Add compiled go2rtc to gitignore
2025-10-14 11:15:48 +03:00
Felipe Santos d894483166 Add go2rtc to gitignore 2025-10-13 08:42:06 -03:00
Alex X 60ef52f44b Merge pull request #1752 from felipecrs/python3.13
Update Python to 3.13 in docker image
2025-10-11 11:00:59 +03:00
Alex X 240e1960f8 Merge branch 'master' into python3.13 2025-10-11 11:00:52 +03:00
Alex X a107d13e61 Merge pull request #1761 from felipecrs/fix-docker-workflow
Fix docker build and push job when running from a fork
2025-10-11 10:49:35 +03:00
Alex X f911936d79 Merge pull request #1823 from hsakoh/feature/addSwitchBotDoorbellSupport
Add Support for SwitchBot VideoDoorbell
2025-10-10 17:22:55 +03:00
Alex X ea23957f2a Code refactoring for #1823 2025-10-10 17:22:18 +03:00
Alex X f1971cec7c Merge pull request #1589 from seydx/onvif-client
fix: ONVIF client
2025-10-10 16:39:36 +03:00
Alex X 7291c03cea Code refactoring for #1589 2025-10-10 16:35:54 +03:00
Alex X fdb3116027 Added checks for corrupted data to the H265 handler 2025-10-10 11:51:53 +03:00
Alex X c87885be48 Merge pull request #1758 from seydx/rtsp-udp
Add RTSP UDP transport support
2025-10-10 11:30:28 +03:00
Alex X 98f88d037e Remove UDP example from readme 2025-10-10 11:11:29 +03:00
Alex X fde1fdc592 Code refactoring for #1758 2025-10-09 21:10:54 +03:00
Alex X cca216ace5 Merge pull request #1744 from seydx/secrets-file
Secrets Management
2025-10-07 15:23:02 +03:00
Alex X e953e949ef Merge branch 'master' into secrets-file 2025-10-07 15:22:44 +03:00
Alex X fe2cc4b525 Code refactoring for #1744 2025-10-07 15:15:04 +03:00
Alex X 6a67fc3712 Merge pull request #1895 from oeiber/master
Fix connection issues in conjunction with doorbird backchannel
2025-10-05 16:01:25 +03:00
Alex X 94b7c33485 Update backchannel.go
Code refactoring for #1895
2025-10-05 16:00:58 +03:00
Oliver Eiber 887f0f4890 fix connection handling in conjunction with doorbird backchannel 2025-10-04 21:37:19 +02:00
Alex X ec08dfee9c Fix stack API for new pion version 2025-10-04 19:19:01 +03:00
Alex X 54b95dced4 Fix probing after #1762 2025-10-04 19:18:36 +03:00
Alex X 37d7409e82 Merge pull request #1762 from seydx/preload
Preload Streams
2025-10-01 17:22:19 +03:00
Alex X 4dd1f73a18 Update readme for #1762 2025-10-01 17:03:32 +03:00
Alex X 22cc8ac2c4 Code refactoring for #1762 2025-10-01 16:57:39 +03:00
seydx a667acad07 Merge branch 'master' of https://github.com/AlexxIT/go2rtc into rtsp-udp 2025-09-30 19:20:21 +02:00
seydx c196b82a72 Merge branch 'master' of https://github.com/AlexxIT/go2rtc into preload 2025-09-30 19:13:36 +02:00
seydx 670370056c Refactor secrets management 2025-09-30 15:35:32 +02:00
seydx 0c5a2bf02b Remove NewSecret function and related import from helpers.go 2025-09-30 15:21:30 +02:00
seydx 8f9e78be0c Merge branch 'master' of https://github.com/AlexxIT/go2rtc into secrets-file 2025-09-30 14:58:40 +02:00
seydx 50d5fa93b6 Set Content-Type header to MimeJSON in ResponsePrettyJSON function 2025-09-30 14:50:57 +02:00
seydx 3a0e4078fd Dont redact hass entry title 2025-09-30 14:48:58 +02:00
Alex X 549da0257e Merge pull request #1745 from seydx/optimize-ring
Optimize ring
2025-09-30 13:36:42 +03:00
Alex X 2b5f9429a8 Update FFmpeg command for encoding H265 (fix profile and level) 2025-09-30 12:17:41 +03:00
Alex X c7119f4403 Fix RTP processing for H265 codec (restore VPS,SPS,PPS) 2025-09-30 12:14:41 +03:00
Alex X 7d9862202a Code refactoring for video-rtc.js 2025-09-30 12:12:29 +03:00
Alex X dd7130d2d4 Merge pull request #1644 from seydx/check-h265
Add support for H.265 codec verification
2025-09-29 18:23:05 +03:00
Alex X d697bdcf05 Code refactoring for #1644 2025-09-29 18:21:36 +03:00
seydx abd61919cf Merge branch 'master' of https://github.com/AlexxIT/go2rtc into secrets-file 2025-09-26 15:15:59 +02:00
seydx ad2383de80 Merge branch 'AlexxIT:master' into check-h265 2025-09-26 15:10:14 +02:00
seydx 9f3ff98951 Merge branch 'AlexxIT:master' into onvif-client 2025-09-26 15:09:11 +02:00
seydx d286980548 Merge branch 'AlexxIT:master' into optimize-ring 2025-09-26 15:09:01 +02:00
seydx 87533ac5a1 Merge branch 'AlexxIT:master' into preload 2025-09-26 15:08:52 +02:00
seydx d05451416d Merge branch 'AlexxIT:master' into rtsp-udp 2025-09-26 15:08:17 +02:00
Oliver Eiber f2242e31c8 impove connection timeout to prevent reconnections after 30 seconds 2025-08-19 07:53:10 +02:00
Oliver Eiber 975a43d392 reduce audio delay by lowering buffer size 2025-07-31 21:07:45 +02:00
Oliver Eiber 3d38e5e567 fix unexpected close of backchannel streams 2025-07-30 23:37:06 +02:00
Oliver Eiber 7d2ad92c4b fix app crashes
remove orphaned streams
2025-07-28 22:27:38 +02:00
hsakoh b82023bc32 Add Support for SwitchBot VideoDoorbell 2025-07-26 01:40:27 +09:00
Oliver Eiber a92e04b6e0 added audio mixing capability to avoid device overload when multiple backchannel audio streams are connected 2025-07-22 20:54:24 +02:00
Oliver Eiber 56e61a85ee proper error handling
cleanup files
2025-07-16 21:07:34 +02:00
seydx 7d83b0d6c8 Merge branch 'AlexxIT:master' into check-h265 2025-07-10 16:17:06 +02:00
seydx 708277230a Merge branch 'AlexxIT:master' into onvif-client 2025-07-10 16:16:11 +02:00
seydx 06e6e31443 Merge branch 'AlexxIT:master' into optimize-ring 2025-07-10 16:16:02 +02:00
seydx 8474f2f571 Merge branch 'AlexxIT:master' into preload 2025-07-10 16:15:53 +02:00
seydx 9ddea7d9d6 Merge branch 'AlexxIT:master' into rtsp-udp 2025-07-10 16:15:17 +02:00
seydx c2fbd372b3 Merge branch 'AlexxIT:master' into secrets-file 2025-07-10 16:15:07 +02:00
Oliver Eiber e00d211619 ensure that doorbird errors where shown in logs 2025-07-06 22:33:25 +02:00
Oliver Eiber c68e3cafe4 fixes doorbird backchannel audio:
- proper session handling
- honor http status codes
- prevent device from being flooded by limiting concurrent audio channels
2025-07-03 23:35:58 +02:00
seydx 7bb0f0d2e6 readme 2025-06-19 10:29:56 +02:00
seydx 96d7066085 Merge branch 'AlexxIT:master' into secrets-file 2025-06-16 10:05:59 +02:00
seydx ae49946740 Merge branch 'AlexxIT:master' into optimize-ring 2025-06-16 10:05:31 +02:00
seydx fcc91c9b8a Merge branch 'AlexxIT:master' into onvif-client 2025-06-16 10:05:22 +02:00
seydx 994fd41826 Merge branch 'AlexxIT:master' into check-h265 2025-06-16 10:04:28 +02:00
seydx 3282b38900 Merge branch 'AlexxIT:master' into rtsp-udp 2025-06-16 10:03:19 +02:00
seydx 647b2acf48 cleanup 2025-06-16 09:58:55 +02:00
seydx ef318f663e fix preload queries 2025-06-16 09:32:07 +02:00
seydx 5771454400 use preload as format name 2025-06-16 00:50:48 +02:00
seydx 6732e726d5 update preload consumer to handle RTP packets 2025-06-16 00:33:16 +02:00
seydx 45503aa8c5 Merge branch 'AlexxIT:master' into preload 2025-06-14 00:40:06 +02:00
seydx b6579122d1 fix 2025-06-06 03:11:28 +02:00
seydx 42a67f8ad5 comments 2025-06-06 02:18:00 +02:00
seydx 91eeefec68 openapi: add preload endpoints 2025-06-05 16:01:49 +03:00
seydx 8ab7aeb8b2 update readme 2025-06-05 15:51:14 +03:00
seydx 493fa1ef6a add api endpoints and change config syntax 2025-06-05 11:33:03 +03:00
seydx 020549ef60 readme 2025-06-02 22:16:43 +03:00
seydx dfc1f45f97 support preloading streams 2025-06-02 22:06:47 +03:00
Felipe Santos 641e65ee95 Fix docker build and push job when running from a fork 2025-06-02 13:21:54 -03:00
seydx 24ca87e00d dont fallback to tcp if udp failes 2025-06-01 18:40:53 +03:00
seydx 859cd1cbe6 support rtsp udp transport 2025-06-01 01:44:01 +03:00
seydx 79656d1344 update readme 2025-05-26 23:10:57 +02:00
seydx 759f979182 dont redact config.env values 2025-05-26 22:23:24 +02:00
seydx 7c17e64090 format 2025-05-26 22:21:33 +02:00
seydx bf45f64a7e - refactor secrets
- add support for env in config
- redact sensitive information in logs/responses
2025-05-26 21:56:45 +02:00
Felipe Santos 72890d5983 Update Python to 3.13 in docker image 2025-05-26 14:01:26 -03:00
seydx e8e798d955 Merge branch 'AlexxIT:master' into secrets-file 2025-05-23 08:02:17 +02:00
seydx 8a8b379bfc Merge branch 'AlexxIT:master' into optimize-ring 2025-05-23 08:00:40 +02:00
seydx ca491def83 Merge branch 'AlexxIT:master' into onvif-client 2025-05-23 08:00:28 +02:00
seydx 5a597277a9 Merge branch 'AlexxIT:master' into check-h265 2025-05-23 07:59:11 +02:00
seydx c90fcd1ce1 refactor 2025-05-21 13:16:49 +02:00
seydx e0687db9e2 add template parsing 2025-05-20 23:07:04 +02:00
seydx 24310e2f7a remove parse 2025-05-20 22:44:07 +02:00
seydx a1f0b86ab3 format 2025-05-20 22:29:27 +02:00
seydx 7f87c6e478 refactor 2025-05-20 21:40:33 +02:00
seydx a0145b4b24 revert handlers 2025-05-20 15:52:26 +02:00
seydx 2fcbb1d836 refactor 2025-05-20 15:51:15 +02:00
seydx a2beea1bbd refactor 2025-05-20 13:59:46 +02:00
seydx e5e55b7a50 improve secret vars and parse url with secrets 2025-05-20 13:05:11 +02:00
seydx 0830d8342e add secret management functions 2025-05-20 12:07:46 +02:00
seydx adb1b21e81 format 2025-05-17 16:37:12 +02:00
seydx edfa09bb9f ring: update peer connection state handling and pass sdo to producer 2025-05-10 19:04:47 +02:00
seydx 2eef7bdbd3 ring: implement session management and caching 2025-05-08 17:38:09 +02:00
seydx 124556f4db ring: skip refetching cameras to increase loading speed and refactor ring url 2025-05-08 16:09:04 +02:00
seydx d528e167db Merge branch 'AlexxIT:master' into onvif-client 2025-05-02 17:56:08 +02:00
seydx f151969fe1 Merge branch 'AlexxIT:master' into check-h265 2025-05-02 17:55:23 +02:00
seydx 251686608a Merge branch 'AlexxIT:master' into onvif-client 2025-04-30 22:55:28 +02:00
seydx 993aa613fd Merge branch 'AlexxIT:master' into check-h265 2025-04-30 22:54:47 +02:00
seydx 2fdfec6f21 Merge branch 'AlexxIT:master' into onvif-client 2025-04-26 00:24:59 +02:00
seydx a0d8f3ae81 Merge branch 'AlexxIT:master' into check-h265 2025-04-26 00:24:18 +02:00
seydx fd5746a954 Merge branch 'AlexxIT:master' into onvif-client 2025-04-22 12:30:30 +02:00
seydx 2c3813deb9 Merge branch 'AlexxIT:master' into check-h265 2025-04-22 12:29:49 +02:00
seydx a8b51ad619 Merge branch 'AlexxIT:master' into onvif-client 2025-04-09 17:10:07 +02:00
seydx 49c4d45731 Merge branch 'AlexxIT:master' into check-h265 2025-04-09 17:09:19 +02:00
seydx 1282b23c57 Merge branch 'AlexxIT:master' into onvif-client 2025-04-01 22:12:55 +02:00
seydx 54afd0b50b Merge branch 'AlexxIT:master' into check-h265 2025-04-01 22:11:46 +02:00
seydx 6ee748a87a Merge branch 'AlexxIT:master' into onvif-client 2025-03-23 11:08:56 +01:00
seydx 0ebda76cc8 Merge branch 'AlexxIT:master' into check-h265 2025-03-23 11:08:06 +01:00
seydx 68b3dc6f37 Merge branch 'AlexxIT:master' into onvif-client 2025-03-22 14:56:10 +01:00
seydx 3c83102e91 Merge branch 'AlexxIT:master' into check-h265 2025-03-22 14:55:26 +01:00
seydx dd77c3e1f0 Merge branch 'AlexxIT:master' into onvif-client 2025-03-18 13:16:31 +01:00
seydx e5e1f6bd05 Merge branch 'AlexxIT:master' into check-h265 2025-03-18 13:15:45 +01:00
seydx ac96b64c64 change codec priority handling for h265 2025-03-16 14:16:05 +01:00
seydx fa8fd60ac6 Merge branch 'AlexxIT:master' into onvif-client 2025-03-13 14:36:54 +01:00
seydx 9c9393e0cf Merge branch 'AlexxIT:master' into check-h265 2025-03-13 14:35:57 +01:00
seydx 8b26f9309f Merge branch 'AlexxIT:master' into onvif-client 2025-03-12 22:19:50 +01:00
seydx 4027809f32 Merge branch 'AlexxIT:master' into check-h265 2025-03-12 22:19:17 +01:00
seydx b28ffa9543 indentation 2025-03-11 01:52:16 +01:00
seydx 7836f2e47f check h265 2025-03-11 01:50:41 +01:00
seydx 648873978c Merge branch 'AlexxIT:master' into onvif-client 2025-03-11 01:45:25 +01:00
Felipe Santos c51c13b4b8 Avoid export pollution 2025-03-10 19:49:04 -03:00
Felipe Santos 9a7c7d2a4b Fix GOTOOLCHAIN in build.sh 2025-03-10 19:45:13 -03:00
Felipe Santos 5f17474ff4 Fix build in linux amd64 2025-03-10 19:36:16 -03:00
Felipe Santos 3376bf8b99 Avoid ignoring errors 2025-03-10 19:34:18 -03:00
Felipe Santos ad1bea088e Fix check_command in build.sh 2025-03-10 19:18:04 -03:00
seydx d0ac99fc69 fix onvif client 2025-02-10 20:21:25 +01:00
95 changed files with 3898 additions and 2640 deletions
+10 -10
View File
@@ -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
+3
View File
@@ -9,6 +9,9 @@ go2rtc_linux*
go2rtc_mac*
go2rtc_win*
/go2rtc
/go2rtc.exe
0_test.go
.DS_Store
+64 -70
View File
@@ -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.
+48
View File
@@ -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
View File
@@ -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"
+20 -20
View File
@@ -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
)
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+4 -2
View File
@@ -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)
}
}
+3
View File
@@ -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)
+56
View File
@@ -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
}
+2 -2
View File
@@ -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) {
+17
View File
@@ -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")
}
+18
View File
@@ -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 {
+1
View File
@@ -25,4 +25,5 @@ func Init() {
return url, nil
})
streams.MarkInsecure("expr")
}
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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
+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+52
View File
@@ -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.
+4
View 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
View File
@@ -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
View File
@@ -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())
}
+66
View File
@@ -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")
}
+37
View File
@@ -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
}
+58
View File
@@ -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
View File
@@ -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)
}
+4
View File
@@ -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
})
}
+1 -1
View File
@@ -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 != "" {
+60 -59
View File
@@ -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()
}
+7
View File
@@ -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
+79
View File
@@ -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
})
}
+83
View File
@@ -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}
}
+15
View File
@@ -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)
}
+3 -1
View File
@@ -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
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
## Useful links
- https://github.com/bauer-andreas/secure-video-specification
+13 -13
View File
@@ -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{
+39 -39
View File
@@ -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},
},
},
}
+10 -10
View File
@@ -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"`
+13 -13
View File
@@ -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"`
}
+4 -4
View File
@@ -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
+20 -13
View File
@@ -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
}
+1 -1
View File
@@ -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 -2
View File
@@ -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"`
+18
View File
@@ -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"`
}
+20
View File
@@ -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"`
}
+19
View File
@@ -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"`
}
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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)
}
+8 -9
View File
@@ -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 {
+44 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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)
//}
-252
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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},
+45
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+6 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
-35
View File
@@ -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
View File
@@ -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 == "" {
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+17 -21
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">
&#127769;
</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 = '&#9728;&#65039;';
const moonIcon = '&#127765;';
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();
});
});
+6 -15
View File
@@ -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
View File
@@ -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;