Compare commits

..

1 Commits

Author SHA1 Message Date
aler9 1368d442f5 temp 3 years ago
  1. 1
      .dockerignore
  2. 16
      .github/DISCUSSION_TEMPLATE/questions.yml
  3. 2
      .github/ISSUE_TEMPLATE/bug.md
  4. 2
      .github/ISSUE_TEMPLATE/config.yml
  5. 4
      .github/ISSUE_TEMPLATE/feature.md
  6. 65
      .github/workflows/bump_hls_js.yml
  7. 4
      .github/workflows/issue-lint.yml
  8. 4
      .github/workflows/issue-lock.yml
  9. 18
      .github/workflows/lint.yml
  10. 18
      .github/workflows/nightly_binaries.yml
  11. 51
      .github/workflows/release.yml
  12. 8
      .github/workflows/test.yml
  13. 1
      .gitignore
  14. 3
      .gitmodules
  15. 49
      .golangci.yml
  16. 12
      Makefile
  17. 1236
      README.md
  18. 3
      SECURITY.md
  19. 1609
      apidocs/openapi.yaml
  20. 5
      bench/proxy/Dockerfile
  21. 20
      bench/proxy/start.sh
  22. 5
      bench/publish/Dockerfile
  23. 4
      bench/publish/start.sh
  24. 5
      bench/read/Dockerfile
  25. 6
      bench/read/start.sh
  26. 73
      go.mod
  27. 155
      go.sum
  28. 1186
      internal/api/api.go
  29. 701
      internal/api/api_test.go
  30. 139
      internal/api/record.go
  31. 76
      internal/asyncwriter/async_writer.go
  32. 22
      internal/asyncwriter/async_writer_test.go
  33. 327
      internal/auth/manager.go
  34. 309
      internal/auth/manager_test.go
  35. 52
      internal/conf/auth_action.go
  36. 15
      internal/conf/auth_internal_users.go
  37. 67
      internal/conf/auth_method.go
  38. 757
      internal/conf/conf.go
  39. 207
      internal/conf/conf_test.go
  40. 88
      internal/conf/credential.go
  41. 151
      internal/conf/credential_test.go
  42. 13
      internal/conf/encryption.go
  43. 134
      internal/conf/env/env.go
  44. 250
      internal/conf/env/env_test.go
  45. 41
      internal/conf/global.go
  46. 11
      internal/conf/hls_variant.go
  47. 84
      internal/conf/ip_networks.go
  48. 57
      internal/conf/ips_or_cidrs.go
  49. 11
      internal/conf/log_destination.go
  50. 11
      internal/conf/log_level.go
  51. 60
      internal/conf/optional_global.go
  52. 70
      internal/conf/optional_path.go
  53. 617
      internal/conf/path.go
  54. 13
      internal/conf/protocol.go
  55. 56
      internal/conf/record_format.go
  56. 63
      internal/conf/rtsp_auth_methods.go
  57. 43
      internal/conf/rtsp_range_type.go
  58. 21
      internal/conf/source_protocol.go
  59. 6
      internal/conf/string_duration.go
  60. 6
      internal/conf/string_size.go
  61. 7
      internal/conf/webrtc_ice_server.go
  62. 14
      internal/confwatcher/confwatcher.go
  63. 24
      internal/confwatcher/confwatcher_test.go
  64. 857
      internal/core/api.go
  65. 106
      internal/core/api_defs.go
  66. 1018
      internal/core/api_test.go
  67. 184
      internal/core/authentication.go
  68. 858
      internal/core/core.go
  69. 407
      internal/core/core_test.go
  70. 186
      internal/core/hls_http_server.go
  71. 68
      internal/core/hls_index.html
  72. 323
      internal/core/hls_manager.go
  73. 225
      internal/core/hls_manager_test.go
  74. 603
      internal/core/hls_muxer.go
  75. 189
      internal/core/hls_source.go
  76. 294
      internal/core/hls_source_test.go
  77. 58
      internal/core/http_logger.go
  78. 94
      internal/core/http_server.go
  79. 10
      internal/core/http_server_header.go
  80. 15
      internal/core/http_set_trusted_proxies.go
  81. 23
      internal/core/ip.go
  82. 257
      internal/core/metrics.go
  83. 377
      internal/core/metrics_test.go
  84. 1264
      internal/core/path.go
  85. 581
      internal/core/path_manager.go
  86. 84
      internal/core/path_manager_test.go
  87. 797
      internal/core/path_test.go
  88. 56
      internal/core/pprof.go
  89. 7
      internal/core/publisher.go
  90. 7
      internal/core/reader.go
  91. 17
      internal/core/restrict_network.go
  92. 137
      internal/core/rpicamera_source.go
  93. 913
      internal/core/rtmp_conn.go
  94. 331
      internal/core/rtmp_server.go
  95. 390
      internal/core/rtmp_server_test.go
  96. 195
      internal/core/rtmp_source.go
  97. 153
      internal/core/rtmp_source_test.go
  98. 222
      internal/core/rtsp_conn.go
  99. 454
      internal/core/rtsp_server.go
  100. 422
      internal/core/rtsp_server_test.go
  101. Some files were not shown because too many files have changed in this diff Show More

1
.dockerignore

@ -3,4 +3,3 @@ @@ -3,4 +3,3 @@
/binaries
/coverage*.txt
/apidocs/*.html
**/hls.min.js

16
.github/DISCUSSION_TEMPLATE/questions.yml

@ -1,16 +0,0 @@ @@ -1,16 +0,0 @@
body:
- type: textarea
attributes:
label: Question
validations:
required: true
- type: markdown
attributes:
value: |
Note: If you are asking for help because you're having trouble doing something, provide enough informations to replicate the problem. In particular, include in the question:
* the server version you are using
* precise instructions on how to replicate the problem
* server logs with setting `logLevel` set to `debug`

2
.github/ISSUE_TEMPLATE/bug.md

@ -63,7 +63,7 @@ yes / no @@ -63,7 +63,7 @@ yes / no
<!--
If the bug arises when using MediaMTX with an external hardware or software, the most helpful content you can provide is a dump of the data exchanged between the server and the target (network dump), that can be generated in this way:
1) Download wireshark (https://www.wireshark.org/)
2) Start capturing on the interface used for exchanging packets (if the server and the external hardware or software are both installed on your pc, the interface is probably "loopback", otherwise it's the one of your network card)
2) Start capturing on the interface used for exchanging RTSP (if the server and the target software are both installed on your pc, the interface is probably "loopback", otherwise it's the one of your network card)
3) Start the server and replicate the issue
4) Stop capturing, save the result in .pcap format
5) Attach

2
.github/ISSUE_TEMPLATE/config.yml

@ -2,5 +2,5 @@ blank_issues_enabled: false @@ -2,5 +2,5 @@ blank_issues_enabled: false
contact_links:
- name: Question
url: https://github.com/bluenviron/mediamtx/discussions/new?category=questions
url: https://github.com/bluenviron/mediamtx/discussions/new
about: Ask the community for help

4
.github/ISSUE_TEMPLATE/feature.md

@ -7,10 +7,6 @@ assignees: '' @@ -7,10 +7,6 @@ assignees: ''
---
<!--
Please create a request FOR EACH feature. Do not report multiple features in a single request, otherwise they'll probably never get ALL implemented.
-->
## Describe the feature
Description

65
.github/workflows/bump_hls_js.yml

@ -1,65 +0,0 @@ @@ -1,65 +0,0 @@
name: bump_hls_js
on:
schedule:
- cron: '4 5 * * *'
workflow_dispatch:
jobs:
bump_hls_js:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- run: >
git config user.name mediamtx-bot
&& git config user.email bot@mediamtx
&& ((git checkout deps/hlsjs && git rebase ${GITHUB_REF_NAME}) || git checkout -b deps/hlsjs)
- run: >
VERSION=$(curl -s https://api.github.com/repos/video-dev/hls.js/releases?per_page=1 | grep tag_name | sed 's/\s\+"tag_name": "\(.\+\)",/\1/')
&& echo $VERSION > internal/servers/hls/hlsjsdownloader/VERSION
&& echo VERSION=$VERSION >> $GITHUB_ENV
- id: check_repo
run: >
echo "clean=$(git status --porcelain)" >> "$GITHUB_OUTPUT"
- if: ${{ steps.check_repo.outputs.clean != '' }}
run: >
git reset ${GITHUB_REF_NAME}
&& git add .
&& git commit -m "bump hls.js to ${VERSION}"
&& git push --set-upstream origin deps/hlsjs --force
- if: ${{ steps.check_repo.outputs.clean != '' }}
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const prs = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: `${context.repo.owner}:deps/hlsjs`,
state: 'open',
});
if (prs.data.length == 0) {
await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
head: 'deps/hlsjs',
base: context.ref.slice('refs/heads/'.length),
title: `bump hls-js to ${process.env.VERSION}`,
});
} else {
github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prs.data[0].number,
title: `bump hls-js to ${process.env.VERSION}`,
});
}

4
.github/workflows/issue_lint.yml → .github/workflows/issue-lint.yml

@ -1,11 +1,11 @@ @@ -1,11 +1,11 @@
name: issue_lint
name: issue-lint
on:
issues:
types: [opened]
jobs:
issue_lint:
issue-lint:
runs-on: ubuntu-latest
steps:

4
.github/workflows/issue_lock.yml → .github/workflows/issue-lock.yml

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
name: issue_lock
name: issue-lock
on:
schedule:
@ -6,7 +6,7 @@ on: @@ -6,7 +6,7 @@ on:
workflow_dispatch:
jobs:
issue_lock:
issue-lock:
runs-on: ubuntu-latest
steps:

18
.github/workflows/code_lint.yml → .github/workflows/lint.yml

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
name: code_lint
name: lint
on:
push:
@ -7,7 +7,7 @@ on: @@ -7,7 +7,7 @@ on:
branches: [ main ]
jobs:
golangci_lint:
code:
runs-on: ubuntu-22.04
steps:
@ -15,15 +15,13 @@ jobs: @@ -15,15 +15,13 @@ jobs:
- uses: actions/setup-go@v3
with:
go-version: "1.21"
go-version: "1.19"
- run: touch internal/servers/hls/hls.min.js
- uses: golangci/golangci-lint-action@v4
- uses: golangci/golangci-lint-action@v3
with:
version: v1.55.0
version: v1.52.2
mod_tidy:
mod-tidy:
runs-on: ubuntu-22.04
steps:
@ -31,13 +29,13 @@ jobs: @@ -31,13 +29,13 @@ jobs:
- uses: actions/setup-go@v2
with:
go-version: "1.21"
go-version: "1.20"
- run: |
go mod tidy
git diff --exit-code
api_docs:
apidocs:
runs-on: ubuntu-22.04
steps:

18
.github/workflows/nightly_binaries.yml

@ -1,18 +0,0 @@ @@ -1,18 +0,0 @@
name: nightly_binaries
on:
workflow_dispatch:
jobs:
nightly_binaries:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- run: make binaries
- uses: actions/upload-artifact@v3
with:
name: binaries
path: binaries

51
.github/workflows/release.yml

@ -19,11 +19,13 @@ jobs: @@ -19,11 +19,13 @@ jobs:
name: binaries
path: binaries
github_release:
github:
needs: binaries
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/download-artifact@v3
with:
name: binaries
@ -56,51 +58,6 @@ jobs: @@ -56,51 +58,6 @@ jobs:
});
}
github_notify_issues:
needs: github_release
runs-on: ubuntu-22.04
steps:
- uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { repo: { owner, repo } } = context;
const tags = await github.rest.repos.listTags({
owner,
repo,
});
const curTag = tags.data[0];
const prevTag = tags.data[1];
const diff = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${prevTag.commit.sha}...${curTag.commit.sha}`,
});
const issues = {};
for (const commit of diff.data.commits) {
for (const match of commit.commit.message.matchAll(/(^| |\()#([0-9]+)( |\)|$)/g)) {
issues[match[2]] = 1;
}
}
for (const issue in issues) {
try {
await github.rest.issues.createComment({
owner,
repo,
issue_number: parseInt(issue),
body: `This issue is mentioned in release ${curTag.name} 🚀\n`
+ `Check out the entire changelog by [clicking here](https://github.com/${owner}/${repo}/releases/tag/${curTag.name})`,
});
} catch (exc) {}
}
dockerhub:
needs: binaries
runs-on: ubuntu-22.04
@ -130,7 +87,7 @@ jobs: @@ -130,7 +87,7 @@ jobs:
DOCKER_USER_LEGACY: ${{ secrets.DOCKER_USER_LEGACY }}
DOCKER_PASSWORD_LEGACY: ${{ secrets.DOCKER_PASSWORD_LEGACY }}
api_docs:
apidocs:
needs: binaries
runs-on: ubuntu-22.04

8
.github/workflows/code_test.yml → .github/workflows/test.yml

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
name: code_test
name: test
on:
push:
@ -7,7 +7,7 @@ on: @@ -7,7 +7,7 @@ on:
branches: [ main ]
jobs:
test_64:
test64:
runs-on: ubuntu-22.04
steps:
@ -19,7 +19,7 @@ jobs: @@ -19,7 +19,7 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
test_32:
test32:
runs-on: ubuntu-22.04
steps:
@ -35,6 +35,6 @@ jobs: @@ -35,6 +35,6 @@ jobs:
- uses: actions/setup-go@v2
with:
go-version: "1.21"
go-version: "1.20"
- run: make test-highlevel-nodocker

1
.gitignore vendored

@ -2,4 +2,3 @@ @@ -2,4 +2,3 @@
/binaries
/coverage*.txt
/apidocs/*.html
**/hls.min.js

3
.gitmodules vendored

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
[submodule "internal/rpicamera/exe/libcamera"]
path = internal/rpicamera/exe/libcamera
url = https://git.libcamera.org/libcamera/libcamera.git

49
.golangci.yml

@ -1,68 +1,25 @@ @@ -1,68 +1,25 @@
linters:
enable:
- asciicheck
- bidichk
- bodyclose
- dupl
- errorlint
- exportloopref
- gochecknoinits
- gocritic
- gofmt
- gofumpt
- lll
- misspell
- nilerr
- lll
- prealloc
- revive
- usestdlibvars
- unconvert
- tparallel
- wastedassign
- whitespace
disable:
- errcheck
issues:
exclude-use-default: false
linters-settings:
errcheck:
exclude-functions:
- io.Copy
- (io.Closer).Close
- (io.Writer).Write
- (hash.Hash).Write
- (net.Conn).Close
- (net.Conn).SetReadDeadline
- (net.Conn).SetWriteDeadline
- (*net.TCPConn).SetKeepAlive
- (*net.TCPConn).SetKeepAlivePeriod
- (*net.TCPConn).SetNoDelay
- (net.Listener).Close
- (net.PacketConn).Close
- (net.PacketConn).SetReadDeadline
- (net.PacketConn).SetWriteDeadline
- (net/http.ResponseWriter).Write
- (*net/http.Server).Serve
- (*net/http.Server).ServeTLS
- (*net/http.Server).Shutdown
- os.Chdir
- os.Mkdir
- os.MkdirAll
- os.Remove
- os.RemoveAll
- os.Setenv
- os.Unsetenv
- (*os.File).WriteString
- (*os.File).Close
- (github.com/datarhei/gosrt.Conn).Close
- (github.com/datarhei/gosrt.Conn).SetReadDeadline
- (github.com/datarhei/gosrt.Conn).SetWriteDeadline
- (*github.com/bluenviron/gortsplib/v4.Client).Close
- (*github.com/bluenviron/gortsplib/v4.Server).Close
- (*github.com/bluenviron/gortsplib/v4.ServerSession).Close
- (*github.com/bluenviron/gortsplib/v4.ServerStream).Close
- (*github.com/bluenviron/gortsplib/v4.ServerConn).Close
govet:
enable-all: true
disable:

12
Makefile

@ -1,9 +1,9 @@ @@ -1,9 +1,9 @@
BASE_IMAGE = golang:1.21-alpine3.19
LINT_IMAGE = golangci/golangci-lint:v1.56.2
NODE_IMAGE = node:20-alpine3.19
ALPINE_IMAGE = alpine:3.19
RPI32_IMAGE = balenalib/raspberry-pi:bullseye-run-20230712
RPI64_IMAGE = balenalib/raspberrypi3-64:bullseye-run-20230530
BASE_IMAGE = golang:1.20-alpine3.17
LINT_IMAGE = golangci/golangci-lint:v1.52.2
NODE_IMAGE = node:16-alpine3.17
ALPINE_IMAGE = alpine:3.17
RPI32_IMAGE = balenalib/raspberry-pi:bullseye-run
RPI64_IMAGE = balenalib/raspberrypi3-64:bullseye-run
.PHONY: $(shell ls)

1236
README.md

File diff suppressed because it is too large Load Diff

3
SECURITY.md

@ -1,3 +0,0 @@ @@ -1,3 +0,0 @@
# Security Policy
Vulnerabilities can be reported privately by using the [Security Advisory](https://github.com/bluenviron/mediamtx/security/advisories/new) feature of GitHub.

1609
apidocs/openapi.yaml

File diff suppressed because it is too large Load Diff

5
bench/proxy/Dockerfile

@ -1,10 +1,9 @@ @@ -1,10 +1,9 @@
ARG BASE_IMAGE
FROM $BASE_IMAGE
FROM golang:1.20-alpine3.17
RUN apk add --no-cache \
ffmpeg
RUN wget -O /video.mkv http://www.larmoire.info/jellyfish/media/jellyfish-10-mbps-hd-h264.mkv
RUN wget -O /video.mkv http://jell.yfish.us/media/jellyfish-10-mbps-hd-h264.mkv
WORKDIR /s

20
bench/proxy/start.sh

@ -1,24 +1,21 @@ @@ -1,24 +1,21 @@
#!/bin/sh -e
PROXY_COUNT=50
PROXY_COUNT=20
PROXY_PROTOCOL=tcp
#####################################################
# source
CONF=""
CONF="${CONF}rtmp: no\n"
CONF="${CONF}hls: no\n"
CONF="${CONF}webrtc: no\n"
CONF="${CONF}srt: no\n"
CONF="${CONF}hlsDisable: yes\n"
CONF="${CONF}rtspAddress: :8555\n"
CONF="${CONF}rtpAddress: :8002\n"
CONF="${CONF}rtcpAddress: :8003\n"
CONF="${CONF}paths:\n"
CONF="${CONF} all_others:\n"
CONF="${CONF} all:\n"
echo -e "$CONF" > /source.conf
/mediamtx /source.conf &
RTSP_RTMPDISABLE=yes /mediamtx /source.conf &
sleep 1
@ -31,20 +28,17 @@ sleep 1 @@ -31,20 +28,17 @@ sleep 1
# proxy
CONF=""
CONF="${CONF}rtmp: no\n"
CONF="${CONF}hls: no\n"
CONF="${CONF}webrtc: no\n"
CONF="${CONF}srt: no\n"
CONF="${CONF}hlsDisable: yes\n"
CONF="${CONF}pprof: yes\n"
CONF="${CONF}paths:\n"
for i in $(seq 1 $PROXY_COUNT); do
CONF="${CONF} proxy$i:\n"
CONF="${CONF} source: rtsp://localhost:8555/source\n"
CONF="${CONF} rtspTransport: $PROXY_PROTOCOL\n"
CONF="${CONF} sourceProtocol: $PROXY_PROTOCOL\n"
done
echo -e "$CONF" > /proxy.conf
/mediamtx /proxy.conf &
RTSP_RTMPDISABLE=yes /mediamtx /proxy.conf &
sleep 5

5
bench/publish/Dockerfile

@ -1,10 +1,9 @@ @@ -1,10 +1,9 @@
ARG BASE_IMAGE
FROM $BASE_IMAGE
FROM golang:1.20-alpine3.17
RUN apk add --no-cache \
ffmpeg
RUN wget -O /video.mkv http://www.larmoire.info/jellyfish/media/jellyfish-10-mbps-hd-h264.mkv
RUN wget -O /video.mkv http://jell.yfish.us/media/jellyfish-10-mbps-hd-h264.mkv
WORKDIR /s

4
bench/publish/start.sh

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
#!/bin/sh -e
PUBLISHER_COUNT=50
PUBLISHER_COUNT=20
PUBLISHER_PROTOCOL=tcp
#####################################################
@ -9,7 +9,7 @@ PUBLISHER_PROTOCOL=tcp @@ -9,7 +9,7 @@ PUBLISHER_PROTOCOL=tcp
CONF=""
CONF="${CONF}pprof: yes\n"
CONF="${CONF}paths:\n"
CONF="${CONF} all_others:\n"
CONF="${CONF} all:\n"
echo -e "$CONF" > /source.conf
/mediamtx /source.conf &

5
bench/read/Dockerfile

@ -1,10 +1,9 @@ @@ -1,10 +1,9 @@
ARG BASE_IMAGE
FROM $BASE_IMAGE
FROM golang:1.20-alpine3.17
RUN apk add --no-cache \
ffmpeg
RUN wget -O /video.mkv http://www.larmoire.info/jellyfish/media/jellyfish-10-mbps-hd-h264.mkv
RUN wget -O /video.mkv http://jell.yfish.us/media/jellyfish-10-mbps-hd-h264.mkv
WORKDIR /s

6
bench/read/start.sh

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
#!/bin/sh -e
READER_COUNT=50
READER_COUNT=20
READER_PROTOCOL=tcp
#####################################################
@ -9,7 +9,7 @@ READER_PROTOCOL=tcp @@ -9,7 +9,7 @@ READER_PROTOCOL=tcp
CONF=""
CONF="${CONF}pprof: yes\n"
CONF="${CONF}paths:\n"
CONF="${CONF} all_others:\n"
CONF="${CONF} all:\n"
echo -e "$CONF" > /source.conf
/mediamtx /source.conf &
@ -26,7 +26,7 @@ sleep 1 @@ -26,7 +26,7 @@ sleep 1
for i in $(seq 1 $READER_COUNT); do
ffmpeg -hide_banner -loglevel error \
-rtsp_transport $READER_PROTOCOL \
-i rtsp://localhost:8554/source -c copy -f null - &
-i rtsp://localhost:8554/source -c copy -f mpegts -y /dev/null &
done
sleep 5

73
go.mod

@ -1,43 +1,38 @@ @@ -1,43 +1,38 @@
module github.com/bluenviron/mediamtx
go 1.21
go 1.20
require (
code.cloudfoundry.org/bytefmt v0.0.0
github.com/MicahParks/keyfunc/v3 v3.3.2
github.com/abema/go-mp4 v1.2.0
github.com/alecthomas/kong v0.9.0
github.com/bluenviron/gohlslib v1.3.0
github.com/bluenviron/gortsplib/v4 v4.8.0
github.com/bluenviron/mediacommon v1.9.2
github.com/datarhei/gosrt v0.6.0
github.com/fsnotify/fsnotify v1.7.0
github.com/abema/go-mp4 v0.10.1
github.com/alecthomas/kong v0.8.0
github.com/asticode/go-astits v1.11.0
github.com/bluenviron/gohlslib v0.2.7
github.com/bluenviron/gortsplib/v3 v3.8.1
github.com/bluenviron/mediacommon v0.5.1
github.com/fsnotify/fsnotify v1.6.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/gookit/color v1.5.4
github.com/gorilla/websocket v1.5.1
github.com/google/uuid v1.3.0
github.com/gookit/color v1.5.3
github.com/gorilla/websocket v1.5.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/matthewhartstonge/argon2 v1.0.0
github.com/pion/ice/v2 v2.3.11
github.com/pion/interceptor v0.1.27
github.com/pion/logging v0.2.2
github.com/pion/rtcp v1.2.14
github.com/pion/rtp v1.8.5
github.com/pion/sdp/v3 v3.0.9
github.com/pion/webrtc/v3 v3.2.22
github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.22.0
golang.org/x/sys v0.19.0
golang.org/x/term v0.19.0
github.com/notedit/rtmp v0.0.2
github.com/pion/ice/v2 v2.3.9
github.com/pion/interceptor v0.1.17
github.com/pion/rtcp v1.2.10
github.com/pion/rtp v1.7.13
github.com/pion/sdp/v3 v3.0.6
github.com/pion/webrtc/v3 v3.2.12
github.com/stretchr/testify v1.8.4
golang.org/x/crypto v0.11.0
golang.org/x/net v0.12.0
golang.org/x/term v0.10.0
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/MicahParks/jwkset v0.5.17 // indirect
github.com/aler9/writerseeker v0.0.0-20220601075008-6f0e685b9c82 // indirect
github.com/asticode/go-astikit v0.30.0 // indirect
github.com/asticode/go-astits v1.13.0 // indirect
github.com/benburkert/openpgp v0.0.0-20160410205803-c2471f86866c // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@ -56,27 +51,23 @@ require ( @@ -56,27 +51,23 @@ require (
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pion/datachannel v1.5.5 // indirect
github.com/pion/dtls/v2 v2.2.7 // indirect
github.com/pion/mdns v0.0.9 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/mdns v0.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/sctp v1.8.8 // indirect
github.com/pion/srtp/v2 v2.0.18 // indirect
github.com/pion/sctp v1.8.7 // indirect
github.com/pion/srtp/v2 v2.0.15 // indirect
github.com/pion/stun v0.6.1 // indirect
github.com/pion/transport/v2 v2.2.3 // indirect
github.com/pion/turn/v2 v2.1.3 // indirect
github.com/pion/transport/v2 v2.2.1 // indirect
github.com/pion/turn/v2 v2.1.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/text v0.11.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace code.cloudfoundry.org/bytefmt => github.com/cloudfoundry/bytefmt v0.0.0-20211005130812-5bb3c17173e5
replace github.com/pion/ice/v2 => github.com/aler9/ice/v2 v2.0.0-20231112223552-32d34dfcf3a1
replace github.com/pion/webrtc/v3 => github.com/aler9/webrtc/v3 v3.0.0-20231112223655-e402ed2689c6

155
go.sum

@ -1,31 +1,21 @@ @@ -1,31 +1,21 @@
github.com/MicahParks/jwkset v0.5.17 h1:DrcwyKwSP5adD0G2XJTvDulnWXjD6gbjROMgMXDbkKA=
github.com/MicahParks/jwkset v0.5.17/go.mod h1:q8ptTGn/Z9c4MwbcfeCDssADeVQb3Pk7PnVxrvi+2QY=
github.com/MicahParks/keyfunc/v3 v3.3.2 h1:YTtwc4dxalBZKFqHhqctBWN6VhbLdGhywmne9u5RQVM=
github.com/MicahParks/keyfunc/v3 v3.3.2/go.mod h1:GJBeEjnv25OnD9y2OYQa7ELU6gYahEMBNXINZb+qm34=
github.com/abema/go-mp4 v1.2.0 h1:gi4X8xg/m179N/J15Fn5ugywN9vtI6PLk6iLldHGLAk=
github.com/abema/go-mp4 v1.2.0/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU=
github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA=
github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aler9/ice/v2 v2.0.0-20231112223552-32d34dfcf3a1 h1:fD6eZt+3/t8bzFn6ZZA2eP63xBP06v3EPfPJu8DO8ys=
github.com/aler9/ice/v2 v2.0.0-20231112223552-32d34dfcf3a1/go.mod h1:lT3kv5uUIlHfXHU/ZRD7uKD/ufM202+eTa3C/umgGf4=
github.com/aler9/webrtc/v3 v3.0.0-20231112223655-e402ed2689c6 h1:wMd3D1mLghoYYh31STig8Kwm2qi8QyQKUy09qUUZrVw=
github.com/aler9/webrtc/v3 v3.0.0-20231112223655-e402ed2689c6/go.mod h1:1CaT2fcZzZ6VZA+O1i9yK2DU4EOcXVvSbWG9pr5jefs=
github.com/abema/go-mp4 v0.10.1 h1:wOhZgNxjduc8r4FJdwPa5x/gdBSSX+8MTnfNj/xkJaE=
github.com/abema/go-mp4 v0.10.1/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0=
github.com/alecthomas/kong v0.8.0 h1:ryDCzutfIqJPnNn0omnrgHLbAggDQM2VWHikE1xqK7s=
github.com/alecthomas/kong v0.8.0/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U=
github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE=
github.com/aler9/writerseeker v0.0.0-20220601075008-6f0e685b9c82 h1:9WgSzBLo3a9ToSVV7sRTBYZ1GGOZUpq4+5H3SN0UZq4=
github.com/aler9/writerseeker v0.0.0-20220601075008-6f0e685b9c82/go.mod h1:qsMrZCbeBf/mCLOeF16KDkPu4gktn/pOWyaq1aYQE7U=
github.com/asticode/go-astikit v0.30.0 h1:DkBkRQRIxYcknlaU7W7ksNfn4gMFsB0tqMJflxkRsZA=
github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c=
github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
github.com/benburkert/openpgp v0.0.0-20160410205803-c2471f86866c h1:8XZeJrs4+ZYhJeJ2aZxADI2tGADS15AzIF8MQ8XAhT4=
github.com/benburkert/openpgp v0.0.0-20160410205803-c2471f86866c/go.mod h1:x1vxHcL/9AVzuk5HOloOEPrtJY0MaalYr78afXZ+pWI=
github.com/bluenviron/gohlslib v1.3.0 h1:I9t1Nba6VJKg5rLoXSzQFPkZZYBUwBqCU2Divp0oU2I=
github.com/bluenviron/gohlslib v1.3.0/go.mod h1:wD8ysO6HB90d17sxoIQXGHINo2KYj/mZirMnPtKLJZQ=
github.com/bluenviron/gortsplib/v4 v4.8.0 h1:nvFp6rHALcSep3G9uBFI0uogS9stVZLNq/92TzGZdQg=
github.com/bluenviron/gortsplib/v4 v4.8.0/go.mod h1:+d+veuyvhvikUNp0GRQkk6fEbd/DtcXNidMRm7FQRaA=
github.com/bluenviron/mediacommon v1.9.2 h1:EHcvoC5YMXRcFE010bTNf07ZiSlB/e/AdZyG7GsEYN0=
github.com/bluenviron/mediacommon v1.9.2/go.mod h1:lt8V+wMyPw8C69HAqDWV5tsAwzN9u2Z+ca8B6C//+n0=
github.com/asticode/go-astits v1.11.0 h1:GTHUXht0ZXAJXsVbsLIcyfHr1Bchi4QQwMARw2ZWAng=
github.com/asticode/go-astits v1.11.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
github.com/bluenviron/gohlslib v0.2.7 h1:wV4ky3PbozwWORWd9pURktMnPIT7j/mBK3ivVljgVkk=
github.com/bluenviron/gohlslib v0.2.7/go.mod h1:x5XU60uvx91bQkRI8lrKNEltUtudR0WzaONBOhH+nzQ=
github.com/bluenviron/gortsplib/v3 v3.8.1 h1:E+B8UWFJpqRWbaVnhQwQiAjEOeJjnueFhZQxSxY9Gps=
github.com/bluenviron/gortsplib/v3 v3.8.1/go.mod h1:CoUNAUuXl1SrxT1lxFE5HptAj2g8auR1rhSjTapM06k=
github.com/bluenviron/mediacommon v0.5.1 h1:eNLc3SQp1pOe5lwsFgVUzZwf/NPkQbWGnGpeS8EQMeU=
github.com/bluenviron/mediacommon v0.5.1/go.mod h1:t0dqPsWUTchyvib0MhixIwXEgvDX4V9G+I0GzWLQRb8=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
@ -35,15 +25,13 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583j @@ -35,15 +25,13 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583j
github.com/cloudfoundry/bytefmt v0.0.0-20211005130812-5bb3c17173e5 h1:xB7KkA98BcUdzVcwyZxb5R0FGIHxNPHgZOzkjPEY5gM=
github.com/cloudfoundry/bytefmt v0.0.0-20211005130812-5bb3c17173e5/go.mod h1:v4VVB6oBMz/c9fRY6vZrwr5xKRWOH5NPDjQZlPk0Gbs=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/datarhei/gosrt v0.6.0 h1:HrrXAw90V78ok4WMIhX6se1aTHPCn82Sg2hj+PhdmGc=
github.com/datarhei/gosrt v0.6.0/go.mod h1:fsOWdLSHUHShHjgi/46h6wjtdQrtnSdAQFnlas8ONxs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@ -51,7 +39,6 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm @@ -51,7 +39,6 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
@ -61,8 +48,6 @@ github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX @@ -61,8 +48,6 @@ github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@ -79,15 +64,13 @@ github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= @@ -79,15 +64,13 @@ github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/gookit/color v1.5.3 h1:twfIhZs4QLCtimkP7MOxlF3A0U/5cDPseRT9M/+2SCE=
github.com/gookit/color v1.5.3/go.mod h1:NUzwzeehUfl7GIb36pqId+UGmRfQcU/WiiyTTeNjHtE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@ -104,8 +87,6 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= @@ -104,8 +87,6 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/matthewhartstonge/argon2 v1.0.0 h1:e65fkae6O8Na6YTy2HAccUbXR+GQHOnpQxeWGqWCRIw=
github.com/matthewhartstonge/argon2 v1.0.0/go.mod h1:Fm4FHZxdxCM6hg21Jkz3YZVKnU7VnTlqDQ3ghS/Myok=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -113,6 +94,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w @@ -113,6 +94,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/notedit/rtmp v0.0.2 h1:5+to4yezKATiJgnrcETu9LbV5G/QsWkOV9Ts2M/p33w=
github.com/notedit/rtmp v0.0.2/go.mod h1:vzuE21rowz+lT1NGsWbreIvYulgBpCGnQyeTyFblUHc=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
@ -134,46 +117,40 @@ github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew @@ -134,46 +117,40 @@ github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew
github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0=
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/interceptor v0.1.25/go.mod h1:wkbPYAak5zKsfpVDYMtEfWEy8D4zL+rpxCxPImLOg3Y=
github.com/pion/interceptor v0.1.27 h1:mZ01OiGiukwRxezmDGzYjjokCVlDOk4T6BfaL5qrtGo=
github.com/pion/interceptor v0.1.27/go.mod h1:/vVaqLwDjGv4GRbgmChIKZIT5EXFDijwmj4WmIYy9bI=
github.com/pion/ice/v2 v2.3.9 h1:7yZpHf3PhPxJGT4JkMj1Y8Rl5cQ6fB709iz99aeMd/U=
github.com/pion/ice/v2 v2.3.9/go.mod h1:lT3kv5uUIlHfXHU/ZRD7uKD/ufM202+eTa3C/umgGf4=
github.com/pion/interceptor v0.1.17 h1:prJtgwFh/gB8zMqGZoOgJPHivOwVAp61i2aG61Du/1w=
github.com/pion/interceptor v0.1.17/go.mod h1:SY8kpmfVBvrbUzvj2bsXz7OJt5JvmVNZ+4Kjq7FcwrI=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/mdns v0.0.7 h1:P0UB4Sr6xDWEox0kTVxF0LmQihtCbSAdW0H2nEgkA3U=
github.com/pion/mdns v0.0.7/go.mod h1:4iP2UbeFhLI/vWju/bw6ZfwjJzk0z8DNValjGxR/dD8=
github.com/pion/mdns v0.0.9 h1:7Ue5KZsqq8EuqStnpPWV33vYYEH0+skdDN5L7EiEsI4=
github.com/pion/mdns v0.0.9/go.mod h1:2JA5exfxwzXiCihmxpTKgFUpiQws2MnipoPK09vecIc=
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.10 h1:nkr3uj+8Sp97zyItdN60tE/S6vk4al5CPRR6Gejsdjc=
github.com/pion/rtcp v1.2.10/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I=
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtp v1.8.2/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtp v1.8.4/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtp v1.8.5 h1:uYzINfaK+9yWs7r537z/Rc1SvT8ILjBcmDOpJcTB+OU=
github.com/pion/rtp v1.8.5/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtp v1.7.13 h1:qcHwlmtiI50t1XivvoawdCGTP4Uiypzfrsap+bijcoA=
github.com/pion/rtp v1.7.13/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko=
github.com/pion/sctp v1.8.5/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0=
github.com/pion/sctp v1.8.8 h1:5EdnnKI4gpyR1a1TwbiS/wxEgcUWBHsc7ILAjARJB+U=
github.com/pion/sctp v1.8.8/go.mod h1:igF9nZBrjh5AtmKc7U30jXltsFHicFCXSmWA2GWRaWs=
github.com/pion/sctp v1.8.7 h1:JnABvFakZueGAn4KU/4PSKg+GWbF6QWbKTWZOSGJjXw=
github.com/pion/sctp v1.8.7/go.mod h1:g1Ul+ARqZq5JEmoFy87Q/4CePtKnTJ1QCL9dBBdN6AU=
github.com/pion/sdp/v3 v3.0.6 h1:WuDLhtuFUUVpTfus9ILC4HRyHsW6TdugjEX/QY9OiUw=
github.com/pion/sdp/v3 v3.0.6/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
github.com/pion/srtp/v2 v2.0.18 h1:vKpAXfawO9RtTRKZJbG4y0v1b11NZxQnxRl85kGuUlo=
github.com/pion/srtp/v2 v2.0.18/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
github.com/pion/srtp/v2 v2.0.15 h1:+tqRtXGsGwHC0G0IUIAzRmdkHvriF79IHVfZGfHrQoA=
github.com/pion/srtp/v2 v2.0.15/go.mod h1:b/pQOlDrbB0HEH5EUAQXzSYxikFbNcNuKmF8tM0hCtw=
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
github.com/pion/transport v0.14.1 h1:XSM6olwW+o8J4SCmOBb/BpwZypkHeyM0PGFCxNQBr40=
github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI=
github.com/pion/transport/v2 v2.0.0/go.mod h1:HS2MEBJTwD+1ZI2eSXSvHJx/HnzQqRy2/LXxt6eVMHc=
github.com/pion/transport/v2 v2.1.0/go.mod h1:AdSw4YBZVDkZm8fpoz+fclXyQwANWmZAlDuQdctTThQ=
github.com/pion/transport/v2 v2.2.0/go.mod h1:AdSw4YBZVDkZm8fpoz+fclXyQwANWmZAlDuQdctTThQ=
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v2 v2.2.3 h1:XcOE3/x41HOSKbl1BfyY1TF1dERx7lVvlMCbXU7kfvA=
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pion/turn/v2 v2.1.2 h1:wj0cAoGKltaZ790XEGW9HwoUewqjliwmhtxCuB2ApyM=
github.com/pion/turn/v2 v2.1.2/go.mod h1:1kjnPkBcex3dhCU2Am+AAmxDcGhLX3WnMfmkNpvSTQU=
github.com/pion/turn/v2 v2.1.3 h1:pYxTVWG2gpC97opdRc5IGsQ1lJ9O/IlNhkzj7MMrGAA=
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/webrtc/v3 v3.2.12 h1:pVqz5NdtTqyhKIhMcXR8bPp709kCf9blyAhDjoVRLvA=
github.com/pion/webrtc/v3 v3.2.12/go.mod h1:/Oz6K95CGWaN+3No+Z0NYvgOPOr3aY8UyTlMm/dec3A=
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -181,7 +158,6 @@ github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZ @@ -181,7 +158,6 @@ github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZ
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
@ -191,9 +167,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO @@ -191,9 +167,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
@ -212,10 +187,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh @@ -212,10 +187,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -230,13 +203,12 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug @@ -230,13 +203,12 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -258,6 +230,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc @@ -258,6 +230,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -266,22 +239,19 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -266,22 +239,19 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -289,14 +259,11 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -289,14 +259,11 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
@ -315,8 +282,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi @@ -315,8 +282,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=

1186
internal/api/api.go

File diff suppressed because it is too large Load Diff

701
internal/api/api_test.go

@ -1,701 +0,0 @@ @@ -1,701 +0,0 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"testing"
"time"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/test"
"github.com/stretchr/testify/require"
)
type testParent struct{}
func (testParent) Log(_ logger.Level, _ string, _ ...interface{}) {
}
func (testParent) APIConfigSet(_ *conf.Conf) {}
func tempConf(t *testing.T, cnt string) *conf.Conf {
fi, err := test.CreateTempFile([]byte(cnt))
require.NoError(t, err)
defer os.Remove(fi)
cnf, _, err := conf.Load(fi, nil)
require.NoError(t, err)
return cnf
}
func httpRequest(t *testing.T, hc *http.Client, method string, ur string, in interface{}, out interface{}) {
buf := func() io.Reader {
if in == nil {
return nil
}
byts, err := json.Marshal(in)
require.NoError(t, err)
return bytes.NewBuffer(byts)
}()
req, err := http.NewRequest(method, ur, buf)
require.NoError(t, err)
res, err := hc.Do(req)
require.NoError(t, err)
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("bad status code: %d", res.StatusCode)
}
if out == nil {
return
}
err = json.NewDecoder(res.Body).Decode(out)
require.NoError(t, err)
}
func checkError(t *testing.T, msg string, body io.Reader) {
var resErr map[string]interface{}
err := json.NewDecoder(body).Decode(&resErr)
require.NoError(t, err)
require.Equal(t, map[string]interface{}{"error": msg}, resErr)
}
func TestPaginate(t *testing.T) {
items := make([]int, 5)
for i := 0; i < 5; i++ {
items[i] = i
}
pageCount, err := paginate(&items, "1", "1")
require.NoError(t, err)
require.Equal(t, 5, pageCount)
require.Equal(t, []int{1}, items)
items = make([]int, 5)
for i := 0; i < 5; i++ {
items[i] = i
}
pageCount, err = paginate(&items, "3", "2")
require.NoError(t, err)
require.Equal(t, 2, pageCount)
require.Equal(t, []int{}, items)
items = make([]int, 6)
for i := 0; i < 6; i++ {
items[i] = i
}
pageCount, err = paginate(&items, "4", "1")
require.NoError(t, err)
require.Equal(t, 2, pageCount)
require.Equal(t, []int{4, 5}, items)
}
var authManager = &auth.Manager{
Method: conf.AuthMethodInternal,
InternalUsers: []conf.AuthInternalUser{
{
User: "myuser",
Pass: "mypass",
Permissions: []conf.AuthInternalUserPermission{
{
Action: conf.AuthActionAPI,
},
},
},
},
RTSPAuthMethods: nil,
}
func TestConfigGlobalGet(t *testing.T) {
cnf := tempConf(t, "api: yes\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
var out map[string]interface{}
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/global/get", nil, &out)
require.Equal(t, true, out["api"])
}
func TestConfigGlobalPatch(t *testing.T) {
cnf := tempConf(t, "api: yes\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
httpRequest(t, hc, http.MethodPatch, "http://myuser:mypass@localhost:9997/v3/config/global/patch",
map[string]interface{}{
"rtmp": false,
"readTimeout": "7s",
"protocols": []string{"tcp"},
"readBufferCount": 4096, // test setting a deprecated parameter
}, nil)
time.Sleep(500 * time.Millisecond)
var out map[string]interface{}
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/global/get", nil, &out)
require.Equal(t, false, out["rtmp"])
require.Equal(t, "7s", out["readTimeout"])
require.Equal(t, []interface{}{"tcp"}, out["protocols"])
require.Equal(t, float64(4096), out["readBufferCount"])
}
func TestAPIConfigGlobalPatchUnknownField(t *testing.T) { //nolint:dupl
cnf := tempConf(t, "api: yes\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
b := map[string]interface{}{
"test": "asd",
}
byts, err := json.Marshal(b)
require.NoError(t, err)
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
req, err := http.NewRequest(http.MethodPatch, "http://myuser:mypass@localhost:9997/v3/config/global/patch",
bytes.NewReader(byts))
require.NoError(t, err)
res, err := hc.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
checkError(t, "json: unknown field \"test\"", res.Body)
}
func TestAPIConfigPathDefaultsGet(t *testing.T) {
cnf := tempConf(t, "api: yes\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
var out map[string]interface{}
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/pathdefaults/get", nil, &out)
require.Equal(t, "publisher", out["source"])
}
func TestAPIConfigPathDefaultsPatch(t *testing.T) {
cnf := tempConf(t, "api: yes\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
httpRequest(t, hc, http.MethodPatch, "http://myuser:mypass@localhost:9997/v3/config/pathdefaults/patch",
map[string]interface{}{
"readUser": "myuser",
"readPass": "mypass",
}, nil)
time.Sleep(500 * time.Millisecond)
var out map[string]interface{}
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/pathdefaults/get", nil, &out)
require.Equal(t, "myuser", out["readUser"])
require.Equal(t, "mypass", out["readPass"])
}
func TestAPIConfigPathsList(t *testing.T) {
cnf := tempConf(t, "api: yes\n"+
"paths:\n"+
" path1:\n"+
" readUser: myuser1\n"+
" readPass: mypass1\n"+
" path2:\n"+
" readUser: myuser2\n"+
" readPass: mypass2\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
type pathConfig map[string]interface{}
type listRes struct {
ItemCount int `json:"itemCount"`
PageCount int `json:"pageCount"`
Items []pathConfig `json:"items"`
}
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
var out listRes
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/paths/list", nil, &out)
require.Equal(t, 2, out.ItemCount)
require.Equal(t, 1, out.PageCount)
require.Equal(t, "path1", out.Items[0]["name"])
require.Equal(t, "myuser1", out.Items[0]["readUser"])
require.Equal(t, "mypass1", out.Items[0]["readPass"])
require.Equal(t, "path2", out.Items[1]["name"])
require.Equal(t, "myuser2", out.Items[1]["readUser"])
require.Equal(t, "mypass2", out.Items[1]["readPass"])
}
func TestAPIConfigPathsGet(t *testing.T) {
cnf := tempConf(t, "api: yes\n"+
"paths:\n"+
" my/path:\n"+
" readUser: myuser\n"+
" readPass: mypass\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
var out map[string]interface{}
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/paths/get/my/path", nil, &out)
require.Equal(t, "my/path", out["name"])
require.Equal(t, "myuser", out["readUser"])
}
func TestAPIConfigPathsAdd(t *testing.T) {
cnf := tempConf(t, "api: yes\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
httpRequest(t, hc, http.MethodPost, "http://myuser:mypass@localhost:9997/v3/config/paths/add/my/path",
map[string]interface{}{
"source": "rtsp://127.0.0.1:9999/mypath",
"sourceOnDemand": true,
"disablePublisherOverride": true, // test setting a deprecated parameter
"rpiCameraVFlip": true,
}, nil)
var out map[string]interface{}
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/paths/get/my/path", nil, &out)
require.Equal(t, "rtsp://127.0.0.1:9999/mypath", out["source"])
require.Equal(t, true, out["sourceOnDemand"])
require.Equal(t, true, out["disablePublisherOverride"])
require.Equal(t, true, out["rpiCameraVFlip"])
}
func TestAPIConfigPathsAddUnknownField(t *testing.T) { //nolint:dupl
cnf := tempConf(t, "api: yes\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
b := map[string]interface{}{
"test": "asd",
}
byts, err := json.Marshal(b)
require.NoError(t, err)
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
req, err := http.NewRequest(http.MethodPost,
"http://myuser:mypass@localhost:9997/v3/config/paths/add/my/path", bytes.NewReader(byts))
require.NoError(t, err)
res, err := hc.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
checkError(t, "json: unknown field \"test\"", res.Body)
}
func TestAPIConfigPathsPatch(t *testing.T) { //nolint:dupl
cnf := tempConf(t, "api: yes\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
httpRequest(t, hc, http.MethodPost, "http://myuser:mypass@localhost:9997/v3/config/paths/add/my/path",
map[string]interface{}{
"source": "rtsp://127.0.0.1:9999/mypath",
"sourceOnDemand": true,
"disablePublisherOverride": true, // test setting a deprecated parameter
"rpiCameraVFlip": true,
}, nil)
httpRequest(t, hc, http.MethodPatch, "http://myuser:mypass@localhost:9997/v3/config/paths/patch/my/path",
map[string]interface{}{
"source": "rtsp://127.0.0.1:9998/mypath",
"sourceOnDemand": true,
}, nil)
var out map[string]interface{}
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/paths/get/my/path", nil, &out)
require.Equal(t, "rtsp://127.0.0.1:9998/mypath", out["source"])
require.Equal(t, true, out["sourceOnDemand"])
require.Equal(t, true, out["disablePublisherOverride"])
require.Equal(t, true, out["rpiCameraVFlip"])
}
func TestAPIConfigPathsReplace(t *testing.T) { //nolint:dupl
cnf := tempConf(t, "api: yes\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
httpRequest(t, hc, http.MethodPost, "http://myuser:mypass@localhost:9997/v3/config/paths/add/my/path",
map[string]interface{}{
"source": "rtsp://127.0.0.1:9999/mypath",
"sourceOnDemand": true,
"disablePublisherOverride": true, // test setting a deprecated parameter
"rpiCameraVFlip": true,
}, nil)
httpRequest(t, hc, http.MethodPost, "http://myuser:mypass@localhost:9997/v3/config/paths/replace/my/path",
map[string]interface{}{
"source": "rtsp://127.0.0.1:9998/mypath",
"sourceOnDemand": true,
}, nil)
var out map[string]interface{}
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/paths/get/my/path", nil, &out)
require.Equal(t, "rtsp://127.0.0.1:9998/mypath", out["source"])
require.Equal(t, true, out["sourceOnDemand"])
require.Equal(t, nil, out["disablePublisherOverride"])
require.Equal(t, false, out["rpiCameraVFlip"])
}
func TestAPIConfigPathsDelete(t *testing.T) {
cnf := tempConf(t, "api: yes\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
httpRequest(t, hc, http.MethodPost, "http://myuser:mypass@localhost:9997/v3/config/paths/add/my/path",
map[string]interface{}{
"source": "rtsp://127.0.0.1:9999/mypath",
"sourceOnDemand": true,
}, nil)
httpRequest(t, hc, http.MethodDelete, "http://myuser:mypass@localhost:9997/v3/config/paths/delete/my/path", nil, nil)
req, err := http.NewRequest(http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/paths/get/my/path", nil)
require.NoError(t, err)
res, err := hc.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
checkError(t, "path configuration not found", res.Body)
}
func TestRecordingsList(t *testing.T) {
dir, err := os.MkdirTemp("", "mediamtx-playback")
require.NoError(t, err)
defer os.RemoveAll(dir)
cnf := tempConf(t, "pathDefaults:\n"+
" recordPath: "+filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f")+"\n"+
"paths:\n"+
" all_others:\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err = api.Initialize()
require.NoError(t, err)
defer api.Close()
err = os.Mkdir(filepath.Join(dir, "mypath1"), 0o755)
require.NoError(t, err)
err = os.Mkdir(filepath.Join(dir, "mypath2"), 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "mypath1", "2008-11-07_11-22-00-500000.mp4"), []byte(""), 0o644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "mypath1", "2009-11-07_11-22-00-900000.mp4"), []byte(""), 0o644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "mypath2", "2009-11-07_11-22-00-900000.mp4"), []byte(""), 0o644)
require.NoError(t, err)
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
var out interface{}
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/recordings/list", nil, &out)
require.Equal(t, map[string]interface{}{
"itemCount": float64(2),
"pageCount": float64(1),
"items": []interface{}{
map[string]interface{}{
"name": "mypath1",
"segments": []interface{}{
map[string]interface{}{
"start": time.Date(2008, 11, 0o7, 11, 22, 0, 500000000, time.Local).Format(time.RFC3339Nano),
},
map[string]interface{}{
"start": time.Date(2009, 11, 0o7, 11, 22, 0, 900000000, time.Local).Format(time.RFC3339Nano),
},
},
},
map[string]interface{}{
"name": "mypath2",
"segments": []interface{}{
map[string]interface{}{
"start": time.Date(2009, 11, 0o7, 11, 22, 0, 900000000, time.Local).Format(time.RFC3339Nano),
},
},
},
},
}, out)
}
func TestRecordingsGet(t *testing.T) {
dir, err := os.MkdirTemp("", "mediamtx-playback")
require.NoError(t, err)
defer os.RemoveAll(dir)
cnf := tempConf(t, "pathDefaults:\n"+
" recordPath: "+filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f")+"\n"+
"paths:\n"+
" all_others:\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err = api.Initialize()
require.NoError(t, err)
defer api.Close()
err = os.Mkdir(filepath.Join(dir, "mypath1"), 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "mypath1", "2008-11-07_11-22-00-000000.mp4"), []byte(""), 0o644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "mypath1", "2009-11-07_11-22-00-900000.mp4"), []byte(""), 0o644)
require.NoError(t, err)
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
var out interface{}
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/recordings/get/mypath1", nil, &out)
require.Equal(t, map[string]interface{}{
"name": "mypath1",
"segments": []interface{}{
map[string]interface{}{
"start": time.Date(2008, 11, 0o7, 11, 22, 0, 0, time.Local).Format(time.RFC3339Nano),
},
map[string]interface{}{
"start": time.Date(2009, 11, 0o7, 11, 22, 0, 900000000, time.Local).Format(time.RFC3339Nano),
},
},
}, out)
}
func TestRecordingsDeleteSegment(t *testing.T) {
dir, err := os.MkdirTemp("", "mediamtx-playback")
require.NoError(t, err)
defer os.RemoveAll(dir)
cnf := tempConf(t, "pathDefaults:\n"+
" recordPath: "+filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f")+"\n"+
"paths:\n"+
" all_others:\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err = api.Initialize()
require.NoError(t, err)
defer api.Close()
err = os.Mkdir(filepath.Join(dir, "mypath1"), 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "mypath1", "2008-11-07_11-22-00-900000.mp4"), []byte(""), 0o644)
require.NoError(t, err)
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
u, err := url.Parse("http://myuser:mypass@localhost:9997/v3/recordings/deletesegment")
require.NoError(t, err)
v := url.Values{}
v.Set("path", "mypath1")
v.Set("start", time.Date(2008, 11, 0o7, 11, 22, 0, 900000000, time.Local).Format(time.RFC3339Nano))
u.RawQuery = v.Encode()
req, err := http.NewRequest(http.MethodDelete, u.String(), nil)
require.NoError(t, err)
res, err := hc.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
}

139
internal/api/record.go

@ -1,139 +0,0 @@ @@ -1,139 +0,0 @@
package api
import (
"errors"
"io/fs"
"path/filepath"
"sort"
"strings"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/playback"
"github.com/bluenviron/mediamtx/internal/record"
)
var errFound = errors.New("found")
func fixedPathHasRecordings(pathConf *conf.Path) bool {
recordPath := record.PathAddExtension(
strings.ReplaceAll(pathConf.RecordPath, "%path", pathConf.Name),
pathConf.RecordFormat,
)
// we have to convert to absolute paths
// otherwise, recordPath and fpath inside Walk() won't have common elements
recordPath, _ = filepath.Abs(recordPath)
commonPath := record.CommonPath(recordPath)
err := filepath.Walk(commonPath, func(fpath string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
var pa record.Path
ok := pa.Decode(recordPath, fpath)
if ok {
return errFound
}
}
return nil
})
if err != nil && !errors.Is(err, errFound) {
return false
}
return errors.Is(err, errFound)
}
func regexpPathGetRecordings(pathConf *conf.Path) []string {
recordPath := record.PathAddExtension(
pathConf.RecordPath,
pathConf.RecordFormat,
)
// we have to convert to absolute paths
// otherwise, recordPath and fpath inside Walk() won't have common elements
recordPath, _ = filepath.Abs(recordPath)
commonPath := record.CommonPath(recordPath)
var ret []string
filepath.Walk(commonPath, func(fpath string, info fs.FileInfo, err error) error { //nolint:errcheck
if err != nil {
return err
}
if !info.IsDir() {
var pa record.Path
ok := pa.Decode(recordPath, fpath)
if ok && pathConf.Regexp.FindStringSubmatch(pa.Path) != nil {
ret = append(ret, pa.Path)
}
}
return nil
})
return ret
}
func removeDuplicatesAndSort(in []string) []string {
ma := make(map[string]struct{}, len(in))
for _, i := range in {
ma[i] = struct{}{}
}
out := []string{}
for k := range ma {
out = append(out, k)
}
sort.Strings(out)
return out
}
func getAllPathsWithRecordings(paths map[string]*conf.Path) []string {
pathNames := []string{}
for _, pathConf := range paths {
if pathConf.Playback {
if pathConf.Regexp == nil {
if fixedPathHasRecordings(pathConf) {
pathNames = append(pathNames, pathConf.Name)
}
} else {
pathNames = append(pathNames, regexpPathGetRecordings(pathConf)...)
}
}
}
return removeDuplicatesAndSort(pathNames)
}
func recordingEntry(
pathConf *conf.Path,
pathName string,
) *defs.APIRecording {
ret := &defs.APIRecording{
Name: pathName,
}
segments, _ := playback.FindSegments(pathConf, pathName)
ret.Segments = make([]*defs.APIRecordingSegment, len(segments))
for i, seg := range segments {
ret.Segments[i] = &defs.APIRecordingSegment{
Start: seg.Start,
}
}
return ret
}

76
internal/asyncwriter/async_writer.go

@ -1,76 +0,0 @@ @@ -1,76 +0,0 @@
// Package asyncwriter contains an asynchronous writer.
package asyncwriter
import (
"fmt"
"github.com/bluenviron/gortsplib/v4/pkg/ringbuffer"
"github.com/bluenviron/mediamtx/internal/logger"
)
// Writer is an asynchronous writer.
type Writer struct {
writeErrLogger logger.Writer
buffer *ringbuffer.RingBuffer
// out
err chan error
}
// New allocates a Writer.
func New(
queueSize int,
parent logger.Writer,
) *Writer {
buffer, _ := ringbuffer.New(uint64(queueSize))
return &Writer{
writeErrLogger: logger.NewLimitedLogger(parent),
buffer: buffer,
err: make(chan error),
}
}
// Start starts the writer routine.
func (w *Writer) Start() {
go w.run()
}
// Stop stops the writer routine.
func (w *Writer) Stop() {
w.buffer.Close()
<-w.err
}
// Error returns whenever there's an error.
func (w *Writer) Error() chan error {
return w.err
}
func (w *Writer) run() {
w.err <- w.runInner()
close(w.err)
}
func (w *Writer) runInner() error {
for {
cb, ok := w.buffer.Pull()
if !ok {
return fmt.Errorf("terminated")
}
err := cb.(func() error)()
if err != nil {
return err
}
}
}
// Push appends an element to the queue.
func (w *Writer) Push(cb func() error) {
ok := w.buffer.Push(cb)
if !ok {
w.writeErrLogger.Log(logger.Warn, "write queue is full")
}
}

22
internal/asyncwriter/async_writer_test.go

@ -1,22 +0,0 @@ @@ -1,22 +0,0 @@
package asyncwriter
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
func TestAsyncWriter(t *testing.T) {
w := New(512, nil)
w.Start()
defer w.Stop()
w.Push(func() error {
return fmt.Errorf("testerror")
})
err := <-w.Error()
require.EqualError(t, err, "testerror")
}

327
internal/auth/manager.go

@ -1,327 +0,0 @@ @@ -1,327 +0,0 @@
// Package auth contains the authentication system.
package auth
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/bluenviron/gortsplib/v4/pkg/auth"
"github.com/bluenviron/gortsplib/v4/pkg/base"
"github.com/bluenviron/gortsplib/v4/pkg/headers"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const (
// PauseAfterError is the pause to apply after an authentication failure.
PauseAfterError = 2 * time.Second
rtspAuthRealm = "IPCAM"
jwtRefreshPeriod = 60 * 60 * time.Second
)
// Protocol is a protocol.
type Protocol string
// protocols.
const (
ProtocolRTSP Protocol = "rtsp"
ProtocolRTMP Protocol = "rtmp"
ProtocolHLS Protocol = "hls"
ProtocolWebRTC Protocol = "webrtc"
ProtocolSRT Protocol = "srt"
)
// Request is an authentication request.
type Request struct {
User string
Pass string
IP net.IP
Action conf.AuthAction
// only for ActionPublish, ActionRead, ActionPlayback
Path string
Protocol Protocol
ID *uuid.UUID
Query string
RTSPRequest *base.Request
RTSPBaseURL *base.URL
RTSPNonce string
}
// Error is a authentication error.
type Error struct {
Message string
}
// Error implements the error interface.
func (e Error) Error() string {
return "authentication failed: " + e.Message
}
func matchesPermission(perms []conf.AuthInternalUserPermission, req *Request) bool {
for _, perm := range perms {
if perm.Action == req.Action {
if perm.Action == conf.AuthActionPublish ||
perm.Action == conf.AuthActionRead ||
perm.Action == conf.AuthActionPlayback {
switch {
case perm.Path == "":
return true
case strings.HasPrefix(perm.Path, "~"):
regexp, err := regexp.Compile(perm.Path[1:])
if err == nil && regexp.MatchString(req.Path) {
return true
}
case perm.Path == req.Path:
return true
}
} else {
return true
}
}
}
return false
}
type customClaims struct {
jwt.RegisteredClaims
MediaMTXPermissions []conf.AuthInternalUserPermission `json:"mediamtx_permissions"`
}
// Manager is the authentication manager.
type Manager struct {
Method conf.AuthMethod
InternalUsers []conf.AuthInternalUser
HTTPAddress string
HTTPExclude []conf.AuthInternalUserPermission
JWTJWKS string
ReadTimeout time.Duration
RTSPAuthMethods []headers.AuthMethod
mutex sync.RWMutex
jwtHTTPClient *http.Client
jwtLastRefresh time.Time
jwtKeyFunc keyfunc.Keyfunc
}
// ReloadInternalUsers reloads InternalUsers.
func (m *Manager) ReloadInternalUsers(u []conf.AuthInternalUser) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.InternalUsers = u
}
// Authenticate authenticates a request.
func (m *Manager) Authenticate(req *Request) error {
err := m.authenticateInner(req)
if err != nil {
return Error{Message: err.Error()}
}
return nil
}
func (m *Manager) authenticateInner(req *Request) error {
// if this is a RTSP request, fill username and password
var rtspAuthHeader headers.Authorization
if req.RTSPRequest != nil {
err := rtspAuthHeader.Unmarshal(req.RTSPRequest.Header["Authorization"])
if err == nil {
switch rtspAuthHeader.Method {
case headers.AuthBasic:
req.User = rtspAuthHeader.BasicUser
req.Pass = rtspAuthHeader.BasicPass
case headers.AuthDigestMD5:
req.User = rtspAuthHeader.Username
default:
return fmt.Errorf("unsupported RTSP authentication method")
}
}
}
switch m.Method {
case conf.AuthMethodInternal:
return m.authenticateInternal(req, &rtspAuthHeader)
case conf.AuthMethodHTTP:
return m.authenticateHTTP(req)
default:
return m.authenticateJWT(req)
}
}
func (m *Manager) authenticateInternal(req *Request, rtspAuthHeader *headers.Authorization) error {
m.mutex.RLock()
defer m.mutex.RUnlock()
for _, u := range m.InternalUsers {
if err := m.authenticateWithUser(req, rtspAuthHeader, &u); err == nil {
return nil
}
}
return fmt.Errorf("authentication failed")
}
func (m *Manager) authenticateWithUser(
req *Request,
rtspAuthHeader *headers.Authorization,
u *conf.AuthInternalUser,
) error {
if u.User != "any" && !u.User.Check(req.User) {
return fmt.Errorf("wrong user")
}
if len(u.IPs) != 0 && !u.IPs.Contains(req.IP) {
return fmt.Errorf("IP not allowed")
}
if !matchesPermission(u.Permissions, req) {
return fmt.Errorf("user doesn't have permission to perform action")
}
if u.User != "any" {
if req.RTSPRequest != nil && rtspAuthHeader.Method == headers.AuthDigestMD5 {
err := auth.Validate(
req.RTSPRequest,
string(u.User),
string(u.Pass),
req.RTSPBaseURL,
m.RTSPAuthMethods,
rtspAuthRealm,
req.RTSPNonce)
if err != nil {
return err
}
} else if !u.Pass.Check(req.Pass) {
return fmt.Errorf("invalid credentials")
}
}
return nil
}
func (m *Manager) authenticateHTTP(req *Request) error {
if matchesPermission(m.HTTPExclude, req) {
return nil
}
enc, _ := json.Marshal(struct {
IP string `json:"ip"`
User string `json:"user"`
Password string `json:"password"`
Action string `json:"action"`
Path string `json:"path"`
Protocol string `json:"protocol"`
ID *uuid.UUID `json:"id"`
Query string `json:"query"`
}{
IP: req.IP.String(),
User: req.User,
Password: req.Pass,
Action: string(req.Action),
Path: req.Path,
Protocol: string(req.Protocol),
ID: req.ID,
Query: req.Query,
})
res, err := http.Post(m.HTTPAddress, "application/json", bytes.NewReader(enc))
if err != nil {
return fmt.Errorf("HTTP request failed: %w", err)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode > 299 {
if resBody, err := io.ReadAll(res.Body); err == nil && len(resBody) != 0 {
return fmt.Errorf("server replied with code %d: %s", res.StatusCode, string(resBody))
}
return fmt.Errorf("server replied with code %d", res.StatusCode)
}
return nil
}
func (m *Manager) authenticateJWT(req *Request) error {
keyfunc, err := m.pullJWTJWKS()
if err != nil {
return err
}
v, err := url.ParseQuery(req.Query)
if err != nil {
return err
}
if len(v["jwt"]) != 1 {
return fmt.Errorf("JWT not provided")
}
var customClaims customClaims
_, err = jwt.ParseWithClaims(v["jwt"][0], &customClaims, keyfunc)
if err != nil {
return err
}
if !matchesPermission(customClaims.MediaMTXPermissions, req) {
return fmt.Errorf("user doesn't have permission to perform action")
}
return nil
}
func (m *Manager) pullJWTJWKS() (jwt.Keyfunc, error) {
now := time.Now()
m.mutex.Lock()
defer m.mutex.Unlock()
if now.Sub(m.jwtLastRefresh) >= jwtRefreshPeriod {
if m.jwtHTTPClient == nil {
m.jwtHTTPClient = &http.Client{
Timeout: (m.ReadTimeout),
Transport: &http.Transport{},
}
}
res, err := m.jwtHTTPClient.Get(m.JWTJWKS)
if err != nil {
return nil, err
}
defer res.Body.Close()
var raw json.RawMessage
err = json.NewDecoder(res.Body).Decode(&raw)
if err != nil {
return nil, err
}
tmp, err := keyfunc.NewJWKSetJSON(raw)
if err != nil {
return nil, err
}
m.jwtKeyFunc = tmp
m.jwtLastRefresh = now
}
return m.jwtKeyFunc.Keyfunc, nil
}

309
internal/auth/manager_test.go

@ -1,309 +0,0 @@ @@ -1,309 +0,0 @@
package auth
import (
"context"
"encoding/json"
"net"
"net/http"
"testing"
"github.com/bluenviron/gortsplib/v4/pkg/auth"
"github.com/bluenviron/gortsplib/v4/pkg/base"
"github.com/bluenviron/gortsplib/v4/pkg/headers"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/stretchr/testify/require"
)
func mustParseCIDR(v string) net.IPNet {
_, ne, err := net.ParseCIDR(v)
if err != nil {
panic(err)
}
if ipv4 := ne.IP.To4(); ipv4 != nil {
return net.IPNet{IP: ipv4, Mask: ne.Mask[len(ne.Mask)-4 : len(ne.Mask)]}
}
return *ne
}
type testHTTPAuthenticator struct {
*http.Server
}
func (ts *testHTTPAuthenticator) initialize(t *testing.T, protocol string, action string) {
firstReceived := false
ts.Server = &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/auth", r.URL.Path)
var in struct {
IP string `json:"ip"`
User string `json:"user"`
Password string `json:"password"`
Path string `json:"path"`
Protocol string `json:"protocol"`
ID string `json:"id"`
Action string `json:"action"`
Query string `json:"query"`
}
err := json.NewDecoder(r.Body).Decode(&in)
require.NoError(t, err)
var user string
if action == "publish" {
user = "testpublisher"
} else {
user = "testreader"
}
if in.IP != "127.0.0.1" ||
in.User != user ||
in.Password != "testpass" ||
in.Path != "teststream" ||
in.Protocol != protocol ||
(firstReceived && in.ID == "") ||
in.Action != action ||
(in.Query != "user=testreader&pass=testpass&param=value" &&
in.Query != "user=testpublisher&pass=testpass&param=value" &&
in.Query != "param=value") {
w.WriteHeader(http.StatusBadRequest)
return
}
firstReceived = true
}),
}
ln, err := net.Listen("tcp", "127.0.0.1:9120")
require.NoError(t, err)
go ts.Server.Serve(ln)
}
func (ts *testHTTPAuthenticator) close() {
ts.Server.Shutdown(context.Background())
}
func TestAuthInternal(t *testing.T) {
for _, outcome := range []string{
"ok",
"wrong user",
"wrong pass",
"wrong ip",
"wrong action",
"wrong path",
} {
for _, encryption := range []string{
"plain",
"sha256",
"argon2",
} {
t.Run(outcome+" "+encryption, func(t *testing.T) {
m := Manager{
Method: conf.AuthMethodInternal,
InternalUsers: []conf.AuthInternalUser{
{
IPs: conf.IPNetworks{mustParseCIDR("127.1.1.1/32")},
Permissions: []conf.AuthInternalUserPermission{
{
Action: conf.AuthActionPublish,
Path: "mypath",
},
},
},
},
HTTPAddress: "",
RTSPAuthMethods: nil,
}
switch encryption {
case "plain":
m.InternalUsers[0].User = conf.Credential("testuser")
m.InternalUsers[0].Pass = conf.Credential("testpass")
case "sha256":
m.InternalUsers[0].User = conf.Credential("sha256:rl3rgi4NcZkpAEcacZnQ2VuOfJ0FxAqCRaKB/SwdZoQ=")
m.InternalUsers[0].Pass = conf.Credential("sha256:E9JJ8stBJ7QM+nV4ZoUCeHk/gU3tPFh/5YieiJp6n2w=")
case "argon2":
m.InternalUsers[0].User = conf.Credential(
"argon2:$argon2id$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$Ux/LWeTgJQPyfMMJo1myR64+o8rALHoPmlE1i/TR+58")
m.InternalUsers[0].Pass = conf.Credential(
"argon2:$argon2i$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$/mrZ42TiTv1mcPnpMUera5oi0SFYbbyueAbdx5sUvWo")
}
switch outcome {
case "ok":
err := m.Authenticate(&Request{
User: "testuser",
Pass: "testpass",
IP: net.ParseIP("127.1.1.1"),
Action: conf.AuthActionPublish,
Path: "mypath",
})
require.NoError(t, err)
case "wrong user":
err := m.Authenticate(&Request{
User: "wrong",
Pass: "testpass",
IP: net.ParseIP("127.1.1.1"),
Action: conf.AuthActionPublish,
Path: "mypath",
})
require.Error(t, err)
case "wrong pass":
err := m.Authenticate(&Request{
User: "testuser",
Pass: "wrong",
IP: net.ParseIP("127.1.1.1"),
Action: conf.AuthActionPublish,
Path: "mypath",
})
require.Error(t, err)
case "wrong ip":
err := m.Authenticate(&Request{
User: "testuser",
Pass: "testpass",
IP: net.ParseIP("127.1.1.2"),
Action: conf.AuthActionPublish,
Path: "mypath",
})
require.Error(t, err)
case "wrong action":
err := m.Authenticate(&Request{
User: "testuser",
Pass: "testpass",
IP: net.ParseIP("127.1.1.1"),
Action: conf.AuthActionRead,
Path: "mypath",
})
require.Error(t, err)
case "wrong path":
err := m.Authenticate(&Request{
User: "testuser",
Pass: "testpass",
IP: net.ParseIP("127.1.1.1"),
Action: conf.AuthActionPublish,
Path: "wrong",
})
require.Error(t, err)
}
})
}
}
}
func TestAuthInternalRTSPDigest(t *testing.T) {
m := Manager{
Method: conf.AuthMethodInternal,
InternalUsers: []conf.AuthInternalUser{
{
User: "myuser",
Pass: "mypass",
IPs: conf.IPNetworks{mustParseCIDR("127.1.1.1/32")},
Permissions: []conf.AuthInternalUserPermission{
{
Action: conf.AuthActionPublish,
Path: "mypath",
},
},
},
},
HTTPAddress: "",
RTSPAuthMethods: []headers.AuthMethod{headers.AuthDigestMD5},
}
u, err := base.ParseURL("rtsp://127.0.0.1:8554/mypath")
require.NoError(t, err)
s, err := auth.NewSender(
auth.GenerateWWWAuthenticate([]headers.AuthMethod{headers.AuthDigestMD5}, "IPCAM", "mynonce"),
"myuser",
"mypass",
)
require.NoError(t, err)
req := &base.Request{
Method: "ANNOUNCE",
URL: u,
}
s.AddAuthorization(req)
err = m.Authenticate(&Request{
IP: net.ParseIP("127.1.1.1"),
Action: conf.AuthActionPublish,
Path: "mypath",
RTSPRequest: req,
RTSPNonce: "mynonce",
})
require.NoError(t, err)
}
func TestAuthHTTP(t *testing.T) {
for _, outcome := range []string{"ok", "fail"} {
t.Run(outcome, func(t *testing.T) {
m := Manager{
Method: conf.AuthMethodHTTP,
HTTPAddress: "http://127.0.0.1:9120/auth",
RTSPAuthMethods: nil,
}
au := &testHTTPAuthenticator{}
au.initialize(t, "rtsp", "publish")
defer au.close()
if outcome == "ok" {
err := m.Authenticate(&Request{
User: "testpublisher",
Pass: "testpass",
IP: net.ParseIP("127.0.0.1"),
Action: conf.AuthActionPublish,
Path: "teststream",
Protocol: ProtocolRTSP,
Query: "param=value",
})
require.NoError(t, err)
} else {
err := m.Authenticate(&Request{
User: "invalid",
Pass: "testpass",
IP: net.ParseIP("127.0.0.1"),
Action: conf.AuthActionPublish,
Path: "teststream",
Protocol: ProtocolRTSP,
Query: "param=value",
})
require.Error(t, err)
}
})
}
}
func TestAuthHTTPExclude(t *testing.T) {
m := Manager{
Method: conf.AuthMethodHTTP,
HTTPAddress: "http://not-to-be-used:9120/auth",
HTTPExclude: []conf.AuthInternalUserPermission{{
Action: conf.AuthActionPublish,
}},
RTSPAuthMethods: nil,
}
err := m.Authenticate(&Request{
User: "",
Pass: "",
IP: net.ParseIP("127.0.0.1"),
Action: conf.AuthActionPublish,
Path: "teststream",
Protocol: ProtocolRTSP,
Query: "param=value",
})
require.NoError(t, err)
}

52
internal/conf/auth_action.go

@ -1,52 +0,0 @@ @@ -1,52 +0,0 @@
package conf
import (
"encoding/json"
"fmt"
)
// AuthAction is an authentication action.
type AuthAction string
// auth actions
const (
AuthActionPublish AuthAction = "publish"
AuthActionRead AuthAction = "read"
AuthActionPlayback AuthAction = "playback"
AuthActionAPI AuthAction = "api"
AuthActionMetrics AuthAction = "metrics"
AuthActionPprof AuthAction = "pprof"
)
// MarshalJSON implements json.Marshaler.
func (d AuthAction) MarshalJSON() ([]byte, error) {
return json.Marshal(string(d))
}
// UnmarshalJSON implements json.Unmarshaler.
func (d *AuthAction) UnmarshalJSON(b []byte) error {
var in string
if err := json.Unmarshal(b, &in); err != nil {
return err
}
switch in {
case string(AuthActionPublish),
string(AuthActionRead),
string(AuthActionPlayback),
string(AuthActionAPI),
string(AuthActionMetrics),
string(AuthActionPprof):
*d = AuthAction(in)
default:
return fmt.Errorf("invalid auth action: '%s'", in)
}
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *AuthAction) UnmarshalEnv(_ string, v string) error {
return d.UnmarshalJSON([]byte(`"` + v + `"`))
}

15
internal/conf/auth_internal_users.go

@ -1,15 +0,0 @@ @@ -1,15 +0,0 @@
package conf
// AuthInternalUserPermission is a permission of a user.
type AuthInternalUserPermission struct {
Action AuthAction `json:"action"`
Path string `json:"path"`
}
// AuthInternalUser is an user.
type AuthInternalUser struct {
User Credential `json:"user"`
Pass Credential `json:"pass"`
IPs IPNetworks `json:"ips"`
Permissions []AuthInternalUserPermission `json:"permissions"`
}

67
internal/conf/auth_method.go

@ -3,61 +3,64 @@ package conf @@ -3,61 +3,64 @@ package conf
import (
"encoding/json"
"fmt"
)
// AuthMethod is an authentication method.
type AuthMethod int
"sort"
"strings"
// authentication methods.
const (
AuthMethodInternal AuthMethod = iota
AuthMethodHTTP
AuthMethodJWT
"github.com/bluenviron/gortsplib/v3/pkg/headers"
)
// AuthMethods is the authMethods parameter.
type AuthMethods []headers.AuthMethod
// MarshalJSON implements json.Marshaler.
func (d AuthMethod) MarshalJSON() ([]byte, error) {
var out string
func (d AuthMethods) MarshalJSON() ([]byte, error) {
out := make([]string, len(d))
switch d {
case AuthMethodInternal:
out = "internal"
for i, v := range d {
switch v {
case headers.AuthBasic:
out[i] = "basic"
case AuthMethodHTTP:
out = "http"
case headers.AuthDigest:
out[i] = "digest"
default:
out = "jwt"
default:
return nil, fmt.Errorf("invalid authentication method: %v", v)
}
}
sort.Strings(out)
return json.Marshal(out)
}
// UnmarshalJSON implements json.Unmarshaler.
func (d *AuthMethod) UnmarshalJSON(b []byte) error {
var in string
func (d *AuthMethods) UnmarshalJSON(b []byte) error {
var in []string
if err := json.Unmarshal(b, &in); err != nil {
return err
}
switch in {
case "internal":
*d = AuthMethodInternal
*d = nil
case "http":
*d = AuthMethodHTTP
for _, v := range in {
switch v {
case "basic":
*d = append(*d, headers.AuthBasic)
case "jwt":
*d = AuthMethodJWT
case "digest":
*d = append(*d, headers.AuthDigest)
default:
return fmt.Errorf("invalid authMethod: '%s'", in)
default:
return fmt.Errorf("invalid authentication method: '%s'", v)
}
}
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *AuthMethod) UnmarshalEnv(_ string, v string) error {
return d.UnmarshalJSON([]byte(`"` + v + `"`))
// UnmarshalEnv implements envUnmarshaler.
func (d *AuthMethods) UnmarshalEnv(s string) error {
byts, _ := json.Marshal(strings.Split(s, ","))
return d.UnmarshalJSON(byts)
}

757
internal/conf/conf.go

@ -6,16 +6,14 @@ import ( @@ -6,16 +6,14 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"os"
"reflect"
"sort"
"strings"
"time"
"github.com/bluenviron/gohlslib"
"github.com/bluenviron/gortsplib/v4"
"github.com/bluenviron/gortsplib/v4/pkg/headers"
"github.com/bluenviron/gortsplib/v3"
"github.com/bluenviron/gortsplib/v3/pkg/headers"
"github.com/bluenviron/mediamtx/internal/conf/decrypt"
"github.com/bluenviron/mediamtx/internal/conf/env"
@ -23,10 +21,7 @@ import ( @@ -23,10 +21,7 @@ import (
"github.com/bluenviron/mediamtx/internal/logger"
)
// ErrPathNotFound is returned when a path is not found.
var ErrPathNotFound = errors.New("path not found")
func sortedKeys(paths map[string]*OptionalPath) []string {
func getSortedKeys(paths map[string]*PathConf) []string {
ret := make([]string, len(paths))
i := 0
for name := range paths {
@ -37,83 +32,55 @@ func sortedKeys(paths map[string]*OptionalPath) []string { @@ -37,83 +32,55 @@ func sortedKeys(paths map[string]*OptionalPath) []string {
return ret
}
func firstThatExists(paths []string) string {
for _, pa := range paths {
_, err := os.Stat(pa)
if err == nil {
return pa
func loadFromFile(fpath string, conf *Conf) (bool, error) {
if fpath == "mediamtx.yml" {
// give priority to the legacy configuration file, in order not to break
// existing setups
if _, err := os.Stat("rtsp-simple-server.yml"); err == nil {
fpath = "rtsp-simple-server.yml"
}
}
return ""
}
func contains(list []headers.AuthMethod, item headers.AuthMethod) bool {
for _, i := range list {
if i == item {
return true
// mediamtx.yml is optional
// other configuration files are not
if fpath == "mediamtx.yml" || fpath == "rtsp-simple-server.yml" {
if _, err := os.Stat(fpath); errors.Is(err, os.ErrNotExist) {
conf.UnmarshalJSON(nil) // load defaults
return false, nil
}
}
return false
}
func copyStructFields(dest interface{}, source interface{}) {
rvsource := reflect.ValueOf(source).Elem()
rvdest := reflect.ValueOf(dest)
nf := rvsource.NumField()
var zero reflect.Value
for i := 0; i < nf; i++ {
fnew := rvsource.Field(i)
f := rvdest.Elem().FieldByName(rvsource.Type().Field(i).Name)
if f == zero {
continue
byts, err := os.ReadFile(fpath)
if err != nil {
return true, err
}
if key, ok := os.LookupEnv("RTSP_CONFKEY"); ok { // legacy format
byts, err = decrypt.Decrypt(key, byts)
if err != nil {
return true, err
}
}
if fnew.Kind() == reflect.Pointer {
if !fnew.IsNil() {
if f.Kind() == reflect.Ptr {
f.Set(fnew)
} else {
f.Set(fnew.Elem())
}
}
} else {
f.Set(fnew)
if key, ok := os.LookupEnv("MTX_CONFKEY"); ok {
byts, err = decrypt.Decrypt(key, byts)
if err != nil {
return true, err
}
}
}
func mustParseCIDR(v string) net.IPNet {
_, ne, err := net.ParseCIDR(v)
err = yaml.Load(byts, conf)
if err != nil {
panic(err)
return true, err
}
if ipv4 := ne.IP.To4(); ipv4 != nil {
return net.IPNet{IP: ipv4, Mask: ne.Mask[len(ne.Mask)-4 : len(ne.Mask)]}
}
return *ne
}
func credentialIsNotEmpty(c *Credential) bool {
return c != nil && *c != ""
return true, nil
}
func ipNetworkIsNotEmpty(i *IPNetworks) bool {
return i != nil && len(*i) != 0
}
func anyPathHasDeprecatedCredentials(paths map[string]*OptionalPath) bool {
for _, pa := range paths {
if pa != nil {
rva := reflect.ValueOf(pa.Values).Elem()
if credentialIsNotEmpty(rva.FieldByName("PublishUser").Interface().(*Credential)) ||
credentialIsNotEmpty(rva.FieldByName("PublishPass").Interface().(*Credential)) ||
ipNetworkIsNotEmpty(rva.FieldByName("PublishIPs").Interface().(*IPNetworks)) ||
credentialIsNotEmpty(rva.FieldByName("ReadUser").Interface().(*Credential)) ||
credentialIsNotEmpty(rva.FieldByName("ReadPass").Interface().(*Credential)) ||
ipNetworkIsNotEmpty(rva.FieldByName("ReadIPs").Interface().(*IPNetworks)) {
return true
}
func contains(list []headers.AuthMethod, item headers.AuthMethod) bool {
for _, i := range list {
if i == item {
return true
}
}
return false
@ -121,68 +88,49 @@ func anyPathHasDeprecatedCredentials(paths map[string]*OptionalPath) bool { @@ -121,68 +88,49 @@ func anyPathHasDeprecatedCredentials(paths map[string]*OptionalPath) bool {
// Conf is a configuration.
type Conf struct {
// General
LogLevel LogLevel `json:"logLevel"`
LogDestinations LogDestinations `json:"logDestinations"`
LogFile string `json:"logFile"`
ReadTimeout StringDuration `json:"readTimeout"`
WriteTimeout StringDuration `json:"writeTimeout"`
ReadBufferCount *int `json:"readBufferCount,omitempty"` // deprecated
WriteQueueSize int `json:"writeQueueSize"`
UDPMaxPayloadSize int `json:"udpMaxPayloadSize"`
Metrics bool `json:"metrics"`
MetricsAddress string `json:"metricsAddress"`
PPROF bool `json:"pprof"`
PPROFAddress string `json:"pprofAddress"`
RunOnConnect string `json:"runOnConnect"`
RunOnConnectRestart bool `json:"runOnConnectRestart"`
RunOnDisconnect string `json:"runOnDisconnect"`
// Authentication
AuthMethod AuthMethod `json:"authMethod"`
AuthInternalUsers []AuthInternalUser `json:"authInternalUsers"`
AuthHTTPAddress string `json:"authHTTPAddress"`
ExternalAuthenticationURL *string `json:"externalAuthenticationURL,omitempty"` // deprecated
AuthHTTPExclude []AuthInternalUserPermission `json:"authHTTPExclude"`
AuthJWTJWKS string `json:"authJWTJWKS"`
// API
API bool `json:"api"`
APIAddress string `json:"apiAddress"`
// Playback
Playback bool `json:"playback"`
PlaybackAddress string `json:"playbackAddress"`
// RTSP server
RTSP bool `json:"rtsp"`
RTSPDisable *bool `json:"rtspDisable,omitempty"` // deprecated
Protocols Protocols `json:"protocols"`
Encryption Encryption `json:"encryption"`
RTSPAddress string `json:"rtspAddress"`
RTSPSAddress string `json:"rtspsAddress"`
RTPAddress string `json:"rtpAddress"`
RTCPAddress string `json:"rtcpAddress"`
MulticastIPRange string `json:"multicastIPRange"`
MulticastRTPPort int `json:"multicastRTPPort"`
MulticastRTCPPort int `json:"multicastRTCPPort"`
ServerKey string `json:"serverKey"`
ServerCert string `json:"serverCert"`
AuthMethods *RTSPAuthMethods `json:"authMethods,omitempty"` // deprecated
RTSPAuthMethods RTSPAuthMethods `json:"rtspAuthMethods"`
// RTMP server
RTMP bool `json:"rtmp"`
RTMPDisable *bool `json:"rtmpDisable,omitempty"` // deprecated
// general
LogLevel LogLevel `json:"logLevel"`
LogDestinations LogDestinations `json:"logDestinations"`
LogFile string `json:"logFile"`
ReadTimeout StringDuration `json:"readTimeout"`
WriteTimeout StringDuration `json:"writeTimeout"`
ReadBufferCount int `json:"readBufferCount"`
UDPMaxPayloadSize int `json:"udpMaxPayloadSize"`
ExternalAuthenticationURL string `json:"externalAuthenticationURL"`
API bool `json:"api"`
APIAddress string `json:"apiAddress"`
Metrics bool `json:"metrics"`
MetricsAddress string `json:"metricsAddress"`
PPROF bool `json:"pprof"`
PPROFAddress string `json:"pprofAddress"`
RunOnConnect string `json:"runOnConnect"`
RunOnConnectRestart bool `json:"runOnConnectRestart"`
// RTSP
RTSPDisable bool `json:"rtspDisable"`
Protocols Protocols `json:"protocols"`
Encryption Encryption `json:"encryption"`
RTSPAddress string `json:"rtspAddress"`
RTSPSAddress string `json:"rtspsAddress"`
RTPAddress string `json:"rtpAddress"`
RTCPAddress string `json:"rtcpAddress"`
MulticastIPRange string `json:"multicastIPRange"`
MulticastRTPPort int `json:"multicastRTPPort"`
MulticastRTCPPort int `json:"multicastRTCPPort"`
ServerKey string `json:"serverKey"`
ServerCert string `json:"serverCert"`
AuthMethods AuthMethods `json:"authMethods"`
// RTMP
RTMPDisable bool `json:"rtmpDisable"`
RTMPAddress string `json:"rtmpAddress"`
RTMPEncryption Encryption `json:"rtmpEncryption"`
RTMPSAddress string `json:"rtmpsAddress"`
RTMPServerKey string `json:"rtmpServerKey"`
RTMPServerCert string `json:"rtmpServerCert"`
// HLS server
HLS bool `json:"hls"`
HLSDisable *bool `json:"hlsDisable,omitempty"` // depreacted
// HLS
HLSDisable bool `json:"hlsDisable"`
HLSAddress string `json:"hlsAddress"`
HLSEncryption bool `json:"hlsEncryption"`
HLSServerKey string `json:"hlsServerKey"`
@ -194,233 +142,52 @@ type Conf struct { @@ -194,233 +142,52 @@ type Conf struct {
HLSPartDuration StringDuration `json:"hlsPartDuration"`
HLSSegmentMaxSize StringSize `json:"hlsSegmentMaxSize"`
HLSAllowOrigin string `json:"hlsAllowOrigin"`
HLSTrustedProxies IPNetworks `json:"hlsTrustedProxies"`
HLSTrustedProxies IPsOrCIDRs `json:"hlsTrustedProxies"`
HLSDirectory string `json:"hlsDirectory"`
// WebRTC server
WebRTC bool `json:"webrtc"`
WebRTCDisable *bool `json:"webrtcDisable,omitempty"` // deprecated
WebRTCAddress string `json:"webrtcAddress"`
WebRTCEncryption bool `json:"webrtcEncryption"`
WebRTCServerKey string `json:"webrtcServerKey"`
WebRTCServerCert string `json:"webrtcServerCert"`
WebRTCAllowOrigin string `json:"webrtcAllowOrigin"`
WebRTCTrustedProxies IPNetworks `json:"webrtcTrustedProxies"`
WebRTCLocalUDPAddress string `json:"webrtcLocalUDPAddress"`
WebRTCLocalTCPAddress string `json:"webrtcLocalTCPAddress"`
WebRTCIPsFromInterfaces bool `json:"webrtcIPsFromInterfaces"`
WebRTCIPsFromInterfacesList []string `json:"webrtcIPsFromInterfacesList"`
WebRTCAdditionalHosts []string `json:"webrtcAdditionalHosts"`
WebRTCICEServers2 []WebRTCICEServer `json:"webrtcICEServers2"`
WebRTCICEUDPMuxAddress *string `json:"webrtcICEUDPMuxAddress,omitempty"` // deprecated
WebRTCICETCPMuxAddress *string `json:"webrtcICETCPMuxAddress,omitempty"` // deprecated
WebRTCICEHostNAT1To1IPs *[]string `json:"webrtcICEHostNAT1To1IPs,omitempty"` // deprecated
WebRTCICEServers *[]string `json:"webrtcICEServers,omitempty"` // deprecated
// SRT server
SRT bool `json:"srt"`
SRTAddress string `json:"srtAddress"`
// Record (deprecated)
Record *bool `json:"record,omitempty"` // deprecated
RecordPath *string `json:"recordPath,omitempty"` // deprecated
RecordFormat *RecordFormat `json:"recordFormat,omitempty"` // deprecated
RecordPartDuration *StringDuration `json:"recordPartDuration,omitempty"` // deprecated
RecordSegmentDuration *StringDuration `json:"recordSegmentDuration,omitempty"` // deprecated
RecordDeleteAfter *StringDuration `json:"recordDeleteAfter,omitempty"` // deprecated
// Path defaults
PathDefaults Path `json:"pathDefaults"`
// Paths
OptionalPaths map[string]*OptionalPath `json:"paths"`
Paths map[string]*Path `json:"-"` // filled by Check()
}
func (conf *Conf) setDefaults() {
// General
conf.LogLevel = LogLevel(logger.Info)
conf.LogDestinations = LogDestinations{logger.DestinationStdout}
conf.LogFile = "mediamtx.log"
conf.ReadTimeout = 10 * StringDuration(time.Second)
conf.WriteTimeout = 10 * StringDuration(time.Second)
conf.WriteQueueSize = 512
conf.UDPMaxPayloadSize = 1472
conf.MetricsAddress = ":9998"
conf.PPROFAddress = ":9999"
// Authentication
conf.AuthInternalUsers = []AuthInternalUser{
{
User: "any",
Pass: "",
Permissions: []AuthInternalUserPermission{
{
Action: AuthActionPublish,
},
{
Action: AuthActionRead,
},
{
Action: AuthActionPlayback,
},
},
},
{
User: "any",
Pass: "",
IPs: IPNetworks{mustParseCIDR("127.0.0.1/32"), mustParseCIDR("::1/128")},
Permissions: []AuthInternalUserPermission{
{
Action: AuthActionAPI,
},
{
Action: AuthActionMetrics,
},
{
Action: AuthActionPprof,
},
},
},
}
conf.AuthHTTPExclude = []AuthInternalUserPermission{
{
Action: AuthActionAPI,
},
{
Action: AuthActionMetrics,
},
{
Action: AuthActionPprof,
},
}
// API
conf.APIAddress = ":9997"
// Playback server
conf.PlaybackAddress = ":9996"
// RTSP server
conf.RTSP = true
conf.Protocols = Protocols{
Protocol(gortsplib.TransportUDP): {},
Protocol(gortsplib.TransportUDPMulticast): {},
Protocol(gortsplib.TransportTCP): {},
}
conf.RTSPAddress = ":8554"
conf.RTSPSAddress = ":8322"
conf.RTPAddress = ":8000"
conf.RTCPAddress = ":8001"
conf.MulticastIPRange = "224.1.0.0/16"
conf.MulticastRTPPort = 8002
conf.MulticastRTCPPort = 8003
conf.ServerKey = "server.key"
conf.ServerCert = "server.crt"
conf.RTSPAuthMethods = RTSPAuthMethods{headers.AuthBasic}
// RTMP server
conf.RTMP = true
conf.RTMPAddress = ":1935"
conf.RTMPSAddress = ":1936"
conf.RTMPServerKey = "server.key"
conf.RTMPServerCert = "server.crt"
// HLS
conf.HLS = true
conf.HLSAddress = ":8888"
conf.HLSServerKey = "server.key"
conf.HLSServerCert = "server.crt"
conf.HLSVariant = HLSVariant(gohlslib.MuxerVariantLowLatency)
conf.HLSSegmentCount = 7
conf.HLSSegmentDuration = 1 * StringDuration(time.Second)
conf.HLSPartDuration = 200 * StringDuration(time.Millisecond)
conf.HLSSegmentMaxSize = 50 * 1024 * 1024
conf.HLSAllowOrigin = "*"
// WebRTC server
conf.WebRTC = true
conf.WebRTCAddress = ":8889"
conf.WebRTCServerKey = "server.key"
conf.WebRTCServerCert = "server.crt"
conf.WebRTCAllowOrigin = "*"
conf.WebRTCLocalUDPAddress = ":8189"
conf.WebRTCIPsFromInterfaces = true
conf.WebRTCIPsFromInterfacesList = []string{}
conf.WebRTCAdditionalHosts = []string{}
conf.WebRTCICEServers2 = []WebRTCICEServer{}
// SRT server
conf.SRT = true
conf.SRTAddress = ":8890"
conf.PathDefaults.setDefaults()
// WebRTC
WebRTCDisable bool `json:"webrtcDisable"`
WebRTCAddress string `json:"webrtcAddress"`
WebRTCEncryption bool `json:"webrtcEncryption"`
WebRTCServerKey string `json:"webrtcServerKey"`
WebRTCServerCert string `json:"webrtcServerCert"`
WebRTCAllowOrigin string `json:"webrtcAllowOrigin"`
WebRTCTrustedProxies IPsOrCIDRs `json:"webrtcTrustedProxies"`
WebRTCICEServers []string `json:"webrtcICEServers"` // deprecated
WebRTCICEServers2 []WebRTCICEServer `json:"webrtcICEServers2"`
WebRTCICEHostNAT1To1IPs []string `json:"webrtcICEHostNAT1To1IPs"`
WebRTCICEUDPMuxAddress string `json:"webrtcICEUDPMuxAddress"`
WebRTCICETCPMuxAddress string `json:"webrtcICETCPMuxAddress"`
// paths
Paths map[string]*PathConf `json:"paths"`
}
// Load loads a Conf.
func Load(fpath string, defaultConfPaths []string) (*Conf, string, error) {
func Load(fpath string) (*Conf, bool, error) {
conf := &Conf{}
fpath, err := conf.loadFromFile(fpath, defaultConfPaths)
found, err := loadFromFile(fpath, conf)
if err != nil {
return nil, "", err
return nil, false, err
}
err = env.Load("RTSP", conf) // legacy prefix
if err != nil {
return nil, "", err
return nil, false, err
}
err = env.Load("MTX", conf)
if err != nil {
return nil, "", err
}
err = conf.Validate()
if err != nil {
return nil, "", err
}
return conf, fpath, nil
}
func (conf *Conf) loadFromFile(fpath string, defaultConfPaths []string) (string, error) {
if fpath == "" {
fpath = firstThatExists(defaultConfPaths)
// when the configuration file is not explicitly set,
// it is optional.
if fpath == "" {
conf.setDefaults()
return "", nil
}
}
byts, err := os.ReadFile(fpath)
if err != nil {
return "", err
return nil, false, err
}
if key, ok := os.LookupEnv("RTSP_CONFKEY"); ok { // legacy format
byts, err = decrypt.Decrypt(key, byts)
if err != nil {
return "", err
}
}
if key, ok := os.LookupEnv("MTX_CONFKEY"); ok {
byts, err = decrypt.Decrypt(key, byts)
if err != nil {
return "", err
}
}
err = yaml.Load(byts, conf)
err = conf.Check()
if err != nil {
return "", err
return nil, false, err
}
return fpath, nil
return conf, found, nil
}
// Clone clones the configuration.
@ -439,90 +206,27 @@ func (conf Conf) Clone() *Conf { @@ -439,90 +206,27 @@ func (conf Conf) Clone() *Conf {
return &dest
}
// Validate checks the configuration for errors.
func (conf *Conf) Validate() error {
// General
if conf.ReadBufferCount != nil {
conf.WriteQueueSize = *conf.ReadBufferCount
}
if (conf.WriteQueueSize & (conf.WriteQueueSize - 1)) != 0 {
return fmt.Errorf("'writeQueueSize' must be a power of two")
// Check checks the configuration for errors.
func (conf *Conf) Check() error {
// general
if (conf.ReadBufferCount & (conf.ReadBufferCount - 1)) != 0 {
return fmt.Errorf("'readBufferCount' must be a power of two")
}
if conf.UDPMaxPayloadSize > 1472 {
return fmt.Errorf("'udpMaxPayloadSize' must be less than 1472")
}
// Authentication
if conf.ExternalAuthenticationURL != nil {
conf.AuthMethod = AuthMethodHTTP
conf.AuthHTTPAddress = *conf.ExternalAuthenticationURL
}
if conf.AuthHTTPAddress != "" &&
!strings.HasPrefix(conf.AuthHTTPAddress, "http://") &&
!strings.HasPrefix(conf.AuthHTTPAddress, "https://") {
return fmt.Errorf("'externalAuthenticationURL' must be a HTTP URL")
}
if conf.AuthJWTJWKS != "" &&
!strings.HasPrefix(conf.AuthJWTJWKS, "http://") &&
!strings.HasPrefix(conf.AuthJWTJWKS, "https://") {
return fmt.Errorf("'authJWTJWKS' must be a HTTP URL")
}
deprecatedCredentialsMode := false
if credentialIsNotEmpty(conf.PathDefaults.PublishUser) ||
credentialIsNotEmpty(conf.PathDefaults.PublishPass) ||
ipNetworkIsNotEmpty(conf.PathDefaults.PublishIPs) ||
credentialIsNotEmpty(conf.PathDefaults.ReadUser) ||
credentialIsNotEmpty(conf.PathDefaults.ReadPass) ||
ipNetworkIsNotEmpty(conf.PathDefaults.ReadIPs) ||
anyPathHasDeprecatedCredentials(conf.OptionalPaths) {
conf.AuthInternalUsers = []AuthInternalUser{
{
User: "any",
Pass: "",
Permissions: []AuthInternalUserPermission{
{
Action: AuthActionPlayback,
},
},
},
{
User: "any",
Pass: "",
IPs: IPNetworks{mustParseCIDR("127.0.0.1/32"), mustParseCIDR("::1/128")},
Permissions: []AuthInternalUserPermission{
{
Action: AuthActionAPI,
},
{
Action: AuthActionMetrics,
},
{
Action: AuthActionPprof,
},
},
},
}
deprecatedCredentialsMode = true
}
switch conf.AuthMethod {
case AuthMethodHTTP:
if conf.AuthHTTPAddress == "" {
return fmt.Errorf("'authHTTPAddress' is empty")
if conf.ExternalAuthenticationURL != "" {
if !strings.HasPrefix(conf.ExternalAuthenticationURL, "http://") &&
!strings.HasPrefix(conf.ExternalAuthenticationURL, "https://") {
return fmt.Errorf("'externalAuthenticationURL' must be a HTTP URL")
}
case AuthMethodJWT:
if conf.AuthJWTJWKS == "" {
return fmt.Errorf("'authJWTJWKS' is empty")
if contains(conf.AuthMethods, headers.AuthDigest) {
return fmt.Errorf("'externalAuthenticationURL' can't be used when 'digest' is in authMethods")
}
}
// RTSP
if conf.RTSPDisable != nil {
conf.RTSP = !*conf.RTSPDisable
}
if conf.Encryption == EncryptionStrict {
if _, ok := conf.Protocols[Protocol(gortsplib.TransportUDP)]; ok {
return fmt.Errorf("strict encryption can't be used with the UDP transport protocol")
@ -531,62 +235,23 @@ func (conf *Conf) Validate() error { @@ -531,62 +235,23 @@ func (conf *Conf) Validate() error {
return fmt.Errorf("strict encryption can't be used with the UDP-multicast transport protocol")
}
}
if conf.AuthMethods != nil {
conf.RTSPAuthMethods = *conf.AuthMethods
}
if contains(conf.RTSPAuthMethods, headers.AuthDigestMD5) {
if conf.AuthMethod != AuthMethodInternal {
return fmt.Errorf("when RTSP digest is enabled, the only supported auth method is 'internal'")
}
for _, user := range conf.AuthInternalUsers {
if user.User.IsHashed() || user.Pass.IsHashed() {
return fmt.Errorf("when RTSP digest is enabled, hashed credentials cannot be used")
}
}
}
// RTMP
if conf.RTMPDisable != nil {
conf.RTMP = !*conf.RTMPDisable
}
// HLS
if conf.HLSDisable != nil {
conf.HLS = !*conf.HLSDisable
}
// WebRTC
if conf.WebRTCDisable != nil {
conf.WebRTC = !*conf.WebRTCDisable
}
if conf.WebRTCICEUDPMuxAddress != nil {
conf.WebRTCLocalUDPAddress = *conf.WebRTCICEUDPMuxAddress
}
if conf.WebRTCICETCPMuxAddress != nil {
conf.WebRTCLocalTCPAddress = *conf.WebRTCICETCPMuxAddress
}
if conf.WebRTCICEHostNAT1To1IPs != nil {
conf.WebRTCAdditionalHosts = *conf.WebRTCICEHostNAT1To1IPs
}
if conf.WebRTCICEServers != nil {
for _, server := range *conf.WebRTCICEServers {
parts := strings.Split(server, ":")
if len(parts) == 5 {
conf.WebRTCICEServers2 = append(conf.WebRTCICEServers2, WebRTCICEServer{
URL: parts[0] + ":" + parts[3] + ":" + parts[4],
Username: parts[1],
Password: parts[2],
})
} else {
conf.WebRTCICEServers2 = append(conf.WebRTCICEServers2, WebRTCICEServer{
URL: server,
})
}
for _, server := range conf.WebRTCICEServers {
parts := strings.Split(server, ":")
if len(parts) == 5 {
conf.WebRTCICEServers2 = append(conf.WebRTCICEServers2, WebRTCICEServer{
URL: parts[0] + ":" + parts[3] + ":" + parts[4],
Username: parts[1],
Password: parts[2],
})
} else {
conf.WebRTCICEServers2 = append(conf.WebRTCICEServers2, WebRTCICEServer{
URL: server,
})
}
}
conf.WebRTCICEServers = nil
for _, server := range conf.WebRTCICEServers2 {
if !strings.HasPrefix(server.URL, "stun:") &&
!strings.HasPrefix(server.URL, "turn:") &&
@ -594,62 +259,22 @@ func (conf *Conf) Validate() error { @@ -594,62 +259,22 @@ func (conf *Conf) Validate() error {
return fmt.Errorf("invalid ICE server: '%s'", server.URL)
}
}
if conf.WebRTCLocalUDPAddress == "" &&
conf.WebRTCLocalTCPAddress == "" &&
len(conf.WebRTCICEServers2) == 0 {
return fmt.Errorf("at least one between 'webrtcLocalUDPAddress'," +
" 'webrtcLocalTCPAddress' or 'webrtcICEServers2' must be filled")
}
if conf.WebRTCLocalUDPAddress != "" || conf.WebRTCLocalTCPAddress != "" {
if !conf.WebRTCIPsFromInterfaces && len(conf.WebRTCAdditionalHosts) == 0 {
return fmt.Errorf("at least one between 'webrtcIPsFromInterfaces' or 'webrtcAdditionalHosts' must be filled")
}
}
// Record (deprecated)
if conf.Record != nil {
conf.PathDefaults.Record = *conf.Record
}
if conf.RecordPath != nil {
conf.PathDefaults.RecordPath = *conf.RecordPath
}
if conf.RecordFormat != nil {
conf.PathDefaults.RecordFormat = *conf.RecordFormat
}
if conf.RecordPartDuration != nil {
conf.PathDefaults.RecordPartDuration = *conf.RecordPartDuration
}
if conf.RecordSegmentDuration != nil {
conf.PathDefaults.RecordSegmentDuration = *conf.RecordSegmentDuration
}
if conf.RecordDeleteAfter != nil {
conf.PathDefaults.RecordDeleteAfter = *conf.RecordDeleteAfter
}
hasAllOthers := false
for name := range conf.OptionalPaths {
if name == "all" || name == "all_others" || name == "~^.*$" {
if hasAllOthers {
return fmt.Errorf("all_others, all and '~^.*$' are aliases")
}
hasAllOthers = true
}
// do not add automatically "all", since user may want to
// initialize all paths through API or hot reloading.
if conf.Paths == nil {
conf.Paths = make(map[string]*PathConf)
}
conf.Paths = make(map[string]*Path)
for _, name := range sortedKeys(conf.OptionalPaths) {
optional := conf.OptionalPaths[name]
if optional == nil {
optional = &OptionalPath{
Values: newOptionalPathValues(),
}
for _, name := range getSortedKeys(conf.Paths) {
pconf := conf.Paths[name]
if pconf == nil {
pconf = &PathConf{}
pconf.UnmarshalJSON(nil) // fill defaults
conf.Paths[name] = pconf
}
pconf := newPath(&conf.PathDefaults, optional)
conf.Paths[name] = pconf
err := pconf.validate(conf, name, deprecatedCredentialsMode)
err := pconf.check(conf, name)
if err != nil {
return err
}
@ -658,77 +283,61 @@ func (conf *Conf) Validate() error { @@ -658,77 +283,61 @@ func (conf *Conf) Validate() error {
return nil
}
// UnmarshalJSON implements json.Unmarshaler.
// UnmarshalJSON implements json.Unmarshaler. It is used to set default values.
func (conf *Conf) UnmarshalJSON(b []byte) error {
conf.setDefaults()
type alias Conf
d := json.NewDecoder(bytes.NewReader(b))
d.DisallowUnknownFields()
return d.Decode((*alias)(conf))
}
// Global returns the global part of Conf.
func (conf *Conf) Global() *Global {
g := &Global{
Values: newGlobalValues(),
}
copyStructFields(g.Values, conf)
return g
}
// PatchGlobal patches the global configuration.
func (conf *Conf) PatchGlobal(optional *OptionalGlobal) {
copyStructFields(conf, optional.Values)
}
// PatchPathDefaults patches path default settings.
func (conf *Conf) PatchPathDefaults(optional *OptionalPath) {
copyStructFields(&conf.PathDefaults, optional.Values)
}
// AddPath adds a path.
func (conf *Conf) AddPath(name string, p *OptionalPath) error {
if _, ok := conf.OptionalPaths[name]; ok {
return fmt.Errorf("path already exists")
}
if conf.OptionalPaths == nil {
conf.OptionalPaths = make(map[string]*OptionalPath)
}
conf.OptionalPaths[name] = p
return nil
}
// general
conf.LogLevel = LogLevel(logger.Info)
conf.LogDestinations = LogDestinations{logger.DestinationStdout}
conf.LogFile = "mediamtx.log"
conf.ReadTimeout = 10 * StringDuration(time.Second)
conf.WriteTimeout = 10 * StringDuration(time.Second)
conf.ReadBufferCount = 512
conf.UDPMaxPayloadSize = 1472
conf.APIAddress = "127.0.0.1:9997"
conf.MetricsAddress = "127.0.0.1:9998"
conf.PPROFAddress = "127.0.0.1:9999"
// PatchPath patches a path.
func (conf *Conf) PatchPath(name string, optional2 *OptionalPath) error {
optional, ok := conf.OptionalPaths[name]
if !ok {
return ErrPathNotFound
// RTSP
conf.Protocols = Protocols{
Protocol(gortsplib.TransportUDP): {},
Protocol(gortsplib.TransportUDPMulticast): {},
Protocol(gortsplib.TransportTCP): {},
}
conf.RTSPAddress = ":8554"
conf.RTSPSAddress = ":8322"
conf.RTPAddress = ":8000"
conf.RTCPAddress = ":8001"
conf.MulticastIPRange = "224.1.0.0/16"
conf.MulticastRTPPort = 8002
conf.MulticastRTCPPort = 8003
conf.ServerKey = "server.key"
conf.ServerCert = "server.crt"
conf.AuthMethods = AuthMethods{headers.AuthBasic}
copyStructFields(optional.Values, optional2.Values)
return nil
}
// ReplacePath replaces a path.
func (conf *Conf) ReplacePath(name string, optional2 *OptionalPath) error {
_, ok := conf.OptionalPaths[name]
if !ok {
return ErrPathNotFound
}
// RTMP
conf.RTMPAddress = ":1935"
conf.RTMPSAddress = ":1936"
conf.OptionalPaths[name] = optional2
return nil
}
// HLS
conf.HLSAddress = ":8888"
conf.HLSServerKey = "server.key"
conf.HLSServerCert = "server.crt"
conf.HLSVariant = HLSVariant(gohlslib.MuxerVariantLowLatency)
conf.HLSSegmentCount = 7
conf.HLSSegmentDuration = 1 * StringDuration(time.Second)
conf.HLSPartDuration = 200 * StringDuration(time.Millisecond)
conf.HLSSegmentMaxSize = 50 * 1024 * 1024
conf.HLSAllowOrigin = "*"
// RemovePath removes a path.
func (conf *Conf) RemovePath(name string) error {
if _, ok := conf.OptionalPaths[name]; !ok {
return ErrPathNotFound
}
// WebRTC
conf.WebRTCAddress = ":8889"
conf.WebRTCServerKey = "server.key"
conf.WebRTCServerCert = "server.crt"
conf.WebRTCAllowOrigin = "*"
conf.WebRTCICEServers2 = []WebRTCICEServer{{URL: "stun:stun.l.google.com:19302"}}
delete(conf.OptionalPaths, name)
return nil
type alias Conf
d := json.NewDecoder(bytes.NewReader(b))
d.DisallowUnknownFields()
return d.Decode((*alias)(conf))
}

207
internal/conf/conf_test.go

@ -8,14 +8,14 @@ import ( @@ -8,14 +8,14 @@ import (
"testing"
"time"
"github.com/bluenviron/gortsplib/v4"
"github.com/bluenviron/gortsplib/v3"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/nacl/secretbox"
"github.com/bluenviron/mediamtx/internal/logger"
)
func createTempFile(byts []byte) (string, error) {
func writeTempFile(byts []byte) (string, error) {
tmpf, err := os.CreateTemp(os.TempDir(), "rtsp-")
if err != nil {
return "", err
@ -32,51 +32,35 @@ func createTempFile(byts []byte) (string, error) { @@ -32,51 +32,35 @@ func createTempFile(byts []byte) (string, error) {
func TestConfFromFile(t *testing.T) {
func() {
tmpf, err := createTempFile([]byte("logLevel: debug\n" +
tmpf, err := writeTempFile([]byte("logLevel: debug\n" +
"paths:\n" +
" cam1:\n" +
" runOnDemandStartTimeout: 5s\n"))
require.NoError(t, err)
defer os.Remove(tmpf)
conf, confPath, err := Load(tmpf, nil)
conf, hasFile, err := Load(tmpf)
require.NoError(t, err)
require.Equal(t, tmpf, confPath)
require.Equal(t, true, hasFile)
require.Equal(t, LogLevel(logger.Debug), conf.LogLevel)
pa, ok := conf.Paths["cam1"]
require.Equal(t, true, ok)
require.Equal(t, &Path{
Name: "cam1",
require.Equal(t, &PathConf{
Source: "publisher",
SourceOnDemandStartTimeout: 10 * StringDuration(time.Second),
SourceOnDemandCloseAfter: 10 * StringDuration(time.Second),
Playback: true,
RecordPath: "./recordings/%path/%Y-%m-%d_%H-%M-%S-%f",
RecordFormat: RecordFormatFMP4,
RecordPartDuration: 100000000,
RecordSegmentDuration: 3600000000000,
RecordDeleteAfter: 86400000000000,
OverridePublisher: true,
RPICameraWidth: 1920,
RPICameraHeight: 1080,
RPICameraContrast: 1,
RPICameraSaturation: 1,
RPICameraSharpness: 1,
RPICameraExposure: "normal",
RPICameraAWB: "auto",
RPICameraAWBGains: []float64{0, 0},
RPICameraDenoise: "off",
RPICameraMetering: "centre",
RPICameraFPS: 30,
RPICameraIDRPeriod: 60,
RPICameraBitrate: 1000000,
RPICameraProfile: "main",
RPICameraLevel: "4.1",
RPICameraAfMode: "continuous",
RPICameraAfRange: "normal",
RPICameraAfSpeed: "normal",
RPICameraTextOverlay: "%Y-%m-%d %H:%M:%S - MediaMTX",
RunOnDemandStartTimeout: 5 * StringDuration(time.Second),
RunOnDemandCloseAfter: 10 * StringDuration(time.Second),
@ -84,78 +68,102 @@ func TestConfFromFile(t *testing.T) { @@ -84,78 +68,102 @@ func TestConfFromFile(t *testing.T) {
}()
func() {
tmpf, err := createTempFile([]byte(``))
tmpf, err := writeTempFile([]byte(``))
require.NoError(t, err)
defer os.Remove(tmpf)
_, _, err = Load(tmpf, nil)
_, _, err = Load(tmpf)
require.NoError(t, err)
}()
func() {
tmpf, err := createTempFile([]byte(`paths:`))
tmpf, err := writeTempFile([]byte(`paths:`))
require.NoError(t, err)
defer os.Remove(tmpf)
_, _, err = Load(tmpf, nil)
_, _, err = Load(tmpf)
require.NoError(t, err)
}()
func() {
tmpf, err := createTempFile([]byte(
tmpf, err := writeTempFile([]byte(
"paths:\n" +
" mypath:\n"))
require.NoError(t, err)
defer os.Remove(tmpf)
_, _, err = Load(tmpf, nil)
_, _, err = Load(tmpf)
require.NoError(t, err)
}()
}
func TestConfFromFileAndEnv(t *testing.T) {
// global parameter
t.Setenv("RTSP_PROTOCOLS", "tcp")
os.Setenv("MTX_PATHS_CAM1_SOURCE", "rtsp://testing")
defer os.Unsetenv("MTX_PATHS_CAM1_SOURCE")
// path parameter
t.Setenv("MTX_PATHS_CAM1_SOURCE", "rtsp://testing")
os.Setenv("RTSP_PROTOCOLS", "tcp")
defer os.Unsetenv("RTSP_PROTOCOLS")
// deprecated global parameter
t.Setenv("MTX_RTMPDISABLE", "yes")
// deprecated path parameter
t.Setenv("MTX_PATHS_CAM2_DISABLEPUBLISHEROVERRIDE", "yes")
tmpf, err := createTempFile([]byte("{}"))
tmpf, err := writeTempFile([]byte("{}"))
require.NoError(t, err)
defer os.Remove(tmpf)
conf, confPath, err := Load(tmpf, nil)
conf, hasFile, err := Load(tmpf)
require.NoError(t, err)
require.Equal(t, tmpf, confPath)
require.Equal(t, true, hasFile)
require.Equal(t, Protocols{Protocol(gortsplib.TransportTCP): {}}, conf.Protocols)
require.Equal(t, false, conf.RTMP)
pa, ok := conf.Paths["cam1"]
require.Equal(t, true, ok)
require.Equal(t, "rtsp://testing", pa.Source)
pa, ok = conf.Paths["cam2"]
require.Equal(t, true, ok)
require.Equal(t, false, pa.OverridePublisher)
require.Equal(t, &PathConf{
Source: "rtsp://testing",
SourceOnDemandStartTimeout: 10 * StringDuration(time.Second),
SourceOnDemandCloseAfter: 10 * StringDuration(time.Second),
RPICameraWidth: 1920,
RPICameraHeight: 1080,
RPICameraContrast: 1,
RPICameraSaturation: 1,
RPICameraSharpness: 1,
RPICameraFPS: 30,
RPICameraIDRPeriod: 60,
RPICameraBitrate: 1000000,
RPICameraProfile: "main",
RPICameraLevel: "4.1",
RPICameraTextOverlay: "%Y-%m-%d %H:%M:%S - MediaMTX",
RunOnDemandStartTimeout: 10 * StringDuration(time.Second),
RunOnDemandCloseAfter: 10 * StringDuration(time.Second),
}, pa)
}
func TestConfFromEnvOnly(t *testing.T) {
t.Setenv("MTX_PATHS_CAM1_SOURCE", "rtsp://testing")
os.Setenv("MTX_PATHS_CAM1_SOURCE", "rtsp://testing")
defer os.Unsetenv("MTX_PATHS_CAM1_SOURCE")
conf, confPath, err := Load("", nil)
conf, hasFile, err := Load("mediamtx.yml")
require.NoError(t, err)
require.Equal(t, "", confPath)
require.Equal(t, false, hasFile)
pa, ok := conf.Paths["cam1"]
require.Equal(t, true, ok)
require.Equal(t, "rtsp://testing", pa.Source)
require.Equal(t, &PathConf{
Source: "rtsp://testing",
SourceOnDemandStartTimeout: 10 * StringDuration(time.Second),
SourceOnDemandCloseAfter: 10 * StringDuration(time.Second),
RPICameraWidth: 1920,
RPICameraHeight: 1080,
RPICameraContrast: 1,
RPICameraSaturation: 1,
RPICameraSharpness: 1,
RPICameraFPS: 30,
RPICameraIDRPeriod: 60,
RPICameraBitrate: 1000000,
RPICameraProfile: "main",
RPICameraLevel: "4.1",
RPICameraTextOverlay: "%Y-%m-%d %H:%M:%S - MediaMTX",
RunOnDemandStartTimeout: 10 * StringDuration(time.Second),
RunOnDemandCloseAfter: 10 * StringDuration(time.Second),
}, pa)
}
func TestConfEncryption(t *testing.T) {
@ -169,22 +177,24 @@ func TestConfEncryption(t *testing.T) { @@ -169,22 +177,24 @@ func TestConfEncryption(t *testing.T) {
copy(secretKey[:], key)
var nonce [24]byte
_, err := io.ReadFull(rand.Reader, nonce[:])
require.NoError(t, err)
if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
panic(err)
}
encrypted := secretbox.Seal(nonce[:], []byte(plaintext), &nonce, &secretKey)
return base64.StdEncoding.EncodeToString(encrypted)
}()
t.Setenv("RTSP_CONFKEY", key)
os.Setenv("RTSP_CONFKEY", key)
defer os.Unsetenv("RTSP_CONFKEY")
tmpf, err := createTempFile([]byte(encryptedConf))
tmpf, err := writeTempFile([]byte(encryptedConf))
require.NoError(t, err)
defer os.Remove(tmpf)
conf, confPath, err := Load(tmpf, nil)
conf, hasFile, err := Load(tmpf)
require.NoError(t, err)
require.Equal(t, tmpf, confPath)
require.Equal(t, true, hasFile)
_, ok := conf.Paths["path1"]
require.Equal(t, true, ok)
@ -205,15 +215,26 @@ func TestConfErrors(t *testing.T) { @@ -205,15 +215,26 @@ func TestConfErrors(t *testing.T) {
"json: unknown field \"invalid\"",
},
{
"invalid writeQueueSize",
"writeQueueSize: 1001\n",
"'writeQueueSize' must be a power of two",
"invalid readBufferCount",
"readBufferCount: 1001\n",
"'readBufferCount' must be a power of two",
},
{
"invalid udpMaxPayloadSize",
"udpMaxPayloadSize: 5000\n",
"'udpMaxPayloadSize' must be less than 1472",
},
{
"invalid externalAuthenticationURL 1",
"externalAuthenticationURL: testing\n",
"'externalAuthenticationURL' must be a HTTP URL",
},
{
"invalid externalAuthenticationURL 2",
"externalAuthenticationURL: http://myurl\n" +
"authMethods: [digest]\n",
"'externalAuthenticationURL' can't be used when 'digest' is in authMethods",
},
{
"invalid strict encryption 1",
"encryption: strict\n" +
@ -252,76 +273,16 @@ func TestConfErrors(t *testing.T) { @@ -252,76 +273,16 @@ func TestConfErrors(t *testing.T) {
" source: rpiCamera\n" +
" cam2:\n" +
" source: rpiCamera\n",
"'rpiCamera' with same camera ID 0 is used as source in two paths, 'cam2' and 'cam1'",
},
{
"invalid srt publish passphrase",
"paths:\n" +
" mypath:\n" +
" srtPublishPassphrase: a\n",
`invalid 'srtPublishPassphrase': must be between 10 and 79 characters`,
},
{
"invalid srt read passphrase",
"paths:\n" +
" mypath:\n" +
" srtReadPassphrase: a\n",
`invalid 'readRTPassphrase': must be between 10 and 79 characters`,
},
{
"all_others aliases",
"paths:\n" +
" all:\n" +
" all_others:\n",
`all_others, all and '~^.*$' are aliases`,
},
{
"all_others aliases",
"paths:\n" +
" all_others:\n" +
" ~^.*$:\n",
`all_others, all and '~^.*$' are aliases`,
"'rpiCamera' with same camera ID 0 is used as source in two paths, 'cam1' and 'cam2'",
},
} {
t.Run(ca.name, func(t *testing.T) {
tmpf, err := createTempFile([]byte(ca.conf))
tmpf, err := writeTempFile([]byte(ca.conf))
require.NoError(t, err)
defer os.Remove(tmpf)
_, _, err = Load(tmpf, nil)
_, _, err = Load(tmpf)
require.EqualError(t, err, ca.err)
})
}
}
func TestSampleConfFile(t *testing.T) {
func() {
conf1, confPath1, err := Load("../../mediamtx.yml", nil)
require.NoError(t, err)
require.Equal(t, "../../mediamtx.yml", confPath1)
conf1.Paths = make(map[string]*Path)
conf1.OptionalPaths = nil
conf2, confPath2, err := Load("", nil)
require.NoError(t, err)
require.Equal(t, "", confPath2)
require.Equal(t, conf1, conf2)
}()
func() {
conf1, confPath1, err := Load("../../mediamtx.yml", nil)
require.NoError(t, err)
require.Equal(t, "../../mediamtx.yml", confPath1)
tmpf, err := createTempFile([]byte("paths:\n all_others:"))
require.NoError(t, err)
defer os.Remove(tmpf)
conf2, confPath2, err := Load(tmpf, nil)
require.NoError(t, err)
require.Equal(t, tmpf, confPath2)
require.Equal(t, conf1.Paths, conf2.Paths)
}()
}

88
internal/conf/credential.go

@ -1,28 +1,15 @@ @@ -1,28 +1,15 @@
package conf
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/matthewhartstonge/argon2"
)
var (
rePlainCredential = regexp.MustCompile(`^[a-zA-Z0-9!\$\(\)\*\+\.;<=>\[\]\^_\-\{\}@#&]+$`)
reBase64 = regexp.MustCompile(`^sha256:[a-zA-Z0-9\+/=]+$`)
)
const plainCredentialSupportedChars = "A-Z,0-9,!,$,(,),*,+,.,;,<,=,>,[,],^,_,-,\",\",@,#,&"
var reCredential = regexp.MustCompile(`^[a-zA-Z0-9!\$\(\)\*\+\.;<=>\[\]\^_\-\{\}@#&]+$`)
func sha256Base64(in string) string {
h := sha256.New()
h.Write([]byte(in))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
const credentialSupportedChars = "A-Z,0-9,!,$,(,),*,+,.,;,<,=,>,[,],^,_,-,\",\",@,#,&"
// Credential is a parameter that is used as username or password.
type Credential string
@ -39,70 +26,17 @@ func (d *Credential) UnmarshalJSON(b []byte) error { @@ -39,70 +26,17 @@ func (d *Credential) UnmarshalJSON(b []byte) error {
return err
}
*d = Credential(in)
return d.validate()
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *Credential) UnmarshalEnv(_ string, v string) error {
return d.UnmarshalJSON([]byte(`"` + v + `"`))
}
// IsSha256 returns true if the credential is a sha256 hash.
func (d Credential) IsSha256() bool {
return strings.HasPrefix(string(d), "sha256:")
}
// IsArgon2 returns true if the credential is an argon2 hash.
func (d Credential) IsArgon2() bool {
return strings.HasPrefix(string(d), "argon2:")
}
// IsHashed returns true if the credential is a sha256 or argon2 hash.
func (d Credential) IsHashed() bool {
return d.IsSha256() || d.IsArgon2()
}
// Check returns true if the given value matches the credential.
func (d Credential) Check(guess string) bool {
if d.IsSha256() {
return string(d)[len("sha256:"):] == sha256Base64(guess)
}
if d.IsArgon2() {
// TODO: remove matthewhartstonge/argon2 when this PR gets merged into mainline Go:
// https://go-review.googlesource.com/c/crypto/+/502515
ok, err := argon2.VerifyEncoded([]byte(guess), []byte(string(d)[len("argon2:"):]))
return ok && err == nil
if in != "" &&
!strings.HasPrefix(in, "sha256:") &&
!reCredential.MatchString(in) {
return fmt.Errorf("credential contains unsupported characters. Supported are: %s", credentialSupportedChars)
}
if d != "" {
return string(d) == guess
}
return true
*d = Credential(in)
return nil
}
func (d Credential) validate() error {
if d != "" {
switch {
case d.IsSha256():
if !reBase64.MatchString(string(d)) {
return fmt.Errorf("credential contains unsupported characters, sha256 hash must be base64 encoded")
}
case d.IsArgon2():
// TODO: remove matthewhartstonge/argon2 when this PR gets merged into mainline Go:
// https://go-review.googlesource.com/c/crypto/+/502515
_, err := argon2.Decode([]byte(string(d)[len("argon2:"):]))
if err != nil {
return fmt.Errorf("invalid argon2 hash: %w", err)
}
default:
if !rePlainCredential.MatchString(string(d)) {
return fmt.Errorf("credential contains unsupported characters. Supported are: %s", plainCredentialSupportedChars)
}
}
}
return nil
// UnmarshalEnv implements envUnmarshaler.
func (d *Credential) UnmarshalEnv(s string) error {
return d.UnmarshalJSON([]byte(`"` + s + `"`))
}

151
internal/conf/credential_test.go

@ -1,151 +0,0 @@ @@ -1,151 +0,0 @@
package conf
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCredential(t *testing.T) {
t.Run("MarshalJSON", func(t *testing.T) {
cred := Credential("password")
expectedJSON := []byte(`"password"`)
actualJSON, err := cred.MarshalJSON()
assert.NoError(t, err)
assert.Equal(t, expectedJSON, actualJSON)
})
t.Run("UnmarshalJSON", func(t *testing.T) {
expectedCred := Credential("password")
jsonData := []byte(`"password"`)
var actualCred Credential
err := actualCred.UnmarshalJSON(jsonData)
assert.NoError(t, err)
assert.Equal(t, expectedCred, actualCred)
})
t.Run("UnmarshalEnv", func(t *testing.T) {
cred := Credential("")
err := cred.UnmarshalEnv("", "password")
assert.NoError(t, err)
assert.Equal(t, Credential("password"), cred)
})
t.Run("IsSha256", func(t *testing.T) {
cred := Credential("")
assert.False(t, cred.IsSha256())
assert.False(t, cred.IsHashed())
cred = "sha256:j1tsRqDEw9xvq/D7/9tMx6Jh/jMhk3UfjwIB2f1zgMo="
assert.True(t, cred.IsSha256())
assert.True(t, cred.IsHashed())
cred = "argon2:$argon2id$v=19$m=65536,t=1," +
"p=4$WXJGqwIB2qd+pRmxMOw9Dg$X4gvR0ZB2DtQoN8vOnJPR2SeFdUhH9TyVzfV98sfWeE"
assert.False(t, cred.IsSha256())
assert.True(t, cred.IsHashed())
})
t.Run("IsArgon2", func(t *testing.T) {
cred := Credential("")
assert.False(t, cred.IsArgon2())
assert.False(t, cred.IsHashed())
cred = "sha256:j1tsRqDEw9xvq/D7/9tMx6Jh/jMhk3UfjwIB2f1zgMo="
assert.False(t, cred.IsArgon2())
assert.True(t, cred.IsHashed())
cred = "argon2:$argon2id$v=19$m=65536,t=1," +
"p=4$WXJGqwIB2qd+pRmxMOw9Dg$X4gvR0ZB2DtQoN8vOnJPR2SeFdUhH9TyVzfV98sfWeE"
assert.True(t, cred.IsArgon2())
assert.True(t, cred.IsHashed())
})
t.Run("Check-plain", func(t *testing.T) {
cred := Credential("password")
assert.True(t, cred.Check("password"))
assert.False(t, cred.Check("wrongpassword"))
})
t.Run("Check-sha256", func(t *testing.T) {
cred := Credential("password")
assert.True(t, cred.Check("password"))
assert.False(t, cred.Check("wrongpassword"))
})
t.Run("Check-sha256", func(t *testing.T) {
cred := Credential("sha256:rl3rgi4NcZkpAEcacZnQ2VuOfJ0FxAqCRaKB/SwdZoQ=")
assert.True(t, cred.Check("testuser"))
assert.False(t, cred.Check("notestuser"))
})
t.Run("Check-argon2", func(t *testing.T) {
cred := Credential("argon2:$argon2id$v=19$m=4096,t=3," +
"p=1$MTIzNDU2Nzg$Ux/LWeTgJQPyfMMJo1myR64+o8rALHoPmlE1i/TR+58")
assert.True(t, cred.Check("testuser"))
assert.False(t, cred.Check("notestuser"))
})
t.Run("validate", func(t *testing.T) {
tests := []struct {
name string
cred Credential
wantErr bool
}{
{
name: "Empty credential",
cred: Credential(""),
wantErr: false,
},
{
name: "Valid plain credential",
cred: Credential("validPlain123"),
wantErr: false,
},
{
name: "Invalid plain credential",
cred: Credential("invalid/Plain"),
wantErr: true,
},
{
name: "Valid sha256 credential",
cred: Credential("sha256:validBase64EncodedHash=="),
wantErr: false,
},
{
name: "Invalid sha256 credential",
cred: Credential("sha256:inval*idBase64"),
wantErr: true,
},
{
name: "Valid Argon2 credential",
cred: Credential("argon2:$argon2id$v=19$m=4096," +
"t=3,p=1$MTIzNDU2Nzg$zarsL19s86GzUWlAkvwt4gJBFuU/A9CVuCjNI4fksow"),
wantErr: false,
},
{
name: "Invalid Argon2 credential",
cred: Credential("argon2:invalid"),
wantErr: true,
},
{
name: "Invalid Argon2 credential",
// testing argon2d errors, because it's not supported
cred: Credential("$argon2d$v=19$m=4096,t=3," +
"p=1$MTIzNDU2Nzg$Xqyd4R7LzXvvAEHaVU12+Nzf5OkHoYcwIEIIYJUDpz0"),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.cred.validate()
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
})
}

13
internal/conf/encryption.go

@ -8,7 +8,7 @@ import ( @@ -8,7 +8,7 @@ import (
// Encryption is the encryption parameter.
type Encryption int
// values.
// supported encryption policies.
const (
EncryptionNo Encryption = iota
EncryptionOptional
@ -26,8 +26,11 @@ func (d Encryption) MarshalJSON() ([]byte, error) { @@ -26,8 +26,11 @@ func (d Encryption) MarshalJSON() ([]byte, error) {
case EncryptionOptional:
out = "optional"
default:
case EncryptionStrict:
out = "strict"
default:
return nil, fmt.Errorf("invalid encryption: %v", d)
}
return json.Marshal(out)
@ -57,7 +60,7 @@ func (d *Encryption) UnmarshalJSON(b []byte) error { @@ -57,7 +60,7 @@ func (d *Encryption) UnmarshalJSON(b []byte) error {
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *Encryption) UnmarshalEnv(_ string, v string) error {
return d.UnmarshalJSON([]byte(`"` + v + `"`))
// UnmarshalEnv implements envUnmarshaler.
func (d *Encryption) UnmarshalEnv(s string) error {
return d.UnmarshalJSON([]byte(`"` + s + `"`))
}

134
internal/conf/env/env.go vendored

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
package env
import (
"encoding/json"
"fmt"
"os"
"reflect"
@ -9,9 +10,8 @@ import ( @@ -9,9 +10,8 @@ import (
"strings"
)
// Unmarshaler can be implemented to override the unmarshaling process.
type Unmarshaler interface {
UnmarshalEnv(prefix string, v string) error
type envUnmarshaler interface {
UnmarshalEnv(string) error
}
func envHasAtLeastAKeyWithPrefix(env map[string]string, prefix string) bool {
@ -23,27 +23,14 @@ func envHasAtLeastAKeyWithPrefix(env map[string]string, prefix string) bool { @@ -23,27 +23,14 @@ func envHasAtLeastAKeyWithPrefix(env map[string]string, prefix string) bool {
return false
}
func loadEnvInternal(env map[string]string, prefix string, prv reflect.Value) error {
if prv.Kind() != reflect.Pointer {
return loadEnvInternal(env, prefix, prv.Addr())
}
rt := prv.Type().Elem()
func loadEnvInternal(env map[string]string, prefix string, rv reflect.Value) error {
rt := rv.Type()
if i, ok := prv.Interface().(Unmarshaler); ok {
if i, ok := rv.Addr().Interface().(envUnmarshaler); ok {
if ev, ok := env[prefix]; ok {
if prv.IsNil() {
prv.Set(reflect.New(rt))
i = prv.Interface().(Unmarshaler)
}
err := i.UnmarshalEnv(prefix, ev)
if err != nil {
return fmt.Errorf("%s: %w", prefix, err)
}
} else if envHasAtLeastAKeyWithPrefix(env, prefix) {
err := i.UnmarshalEnv(prefix, "")
err := i.UnmarshalEnv(ev)
if err != nil {
return fmt.Errorf("%s: %w", prefix, err)
return fmt.Errorf("%s: %s", prefix, err)
}
}
return nil
@ -52,63 +39,48 @@ func loadEnvInternal(env map[string]string, prefix string, prv reflect.Value) er @@ -52,63 +39,48 @@ func loadEnvInternal(env map[string]string, prefix string, prv reflect.Value) er
switch rt {
case reflect.TypeOf(""):
if ev, ok := env[prefix]; ok {
if prv.IsNil() {
prv.Set(reflect.New(rt))
}
prv.Elem().SetString(ev)
rv.SetString(ev)
}
return nil
case reflect.TypeOf(int(0)):
if ev, ok := env[prefix]; ok {
if prv.IsNil() {
prv.Set(reflect.New(rt))
}
iv, err := strconv.ParseInt(ev, 10, 32)
if err != nil {
return fmt.Errorf("%s: %w", prefix, err)
return fmt.Errorf("%s: %s", prefix, err)
}
prv.Elem().SetInt(iv)
rv.SetInt(iv)
}
return nil
case reflect.TypeOf(uint64(0)):
if ev, ok := env[prefix]; ok {
if prv.IsNil() {
prv.Set(reflect.New(rt))
}
iv, err := strconv.ParseUint(ev, 10, 32)
if err != nil {
return fmt.Errorf("%s: %w", prefix, err)
return fmt.Errorf("%s: %s", prefix, err)
}
prv.Elem().SetUint(iv)
rv.SetUint(iv)
}
return nil
case reflect.TypeOf(float64(0)):
if ev, ok := env[prefix]; ok {
if prv.IsNil() {
prv.Set(reflect.New(rt))
}
iv, err := strconv.ParseFloat(ev, 64)
if err != nil {
return fmt.Errorf("%s: %w", prefix, err)
return fmt.Errorf("%s: %s", prefix, err)
}
prv.Elem().SetFloat(iv)
rv.SetFloat(iv)
}
return nil
case reflect.TypeOf(bool(false)):
if ev, ok := env[prefix]; ok {
if prv.IsNil() {
prv.Set(reflect.New(rt))
}
switch strings.ToLower(ev) {
case "yes", "true":
prv.Elem().SetBool(true)
rv.SetBool(true)
case "no", "false":
prv.Elem().SetBool(false)
rv.SetBool(false)
default:
return fmt.Errorf("%s: invalid value '%s'", prefix, ev)
@ -135,16 +107,19 @@ func loadEnvInternal(env map[string]string, prefix string, prv reflect.Value) er @@ -135,16 +107,19 @@ func loadEnvInternal(env map[string]string, prefix string, prv reflect.Value) er
}
// initialize only if there's at least one key
if prv.Elem().IsNil() {
prv.Elem().Set(reflect.MakeMap(rt))
if rv.IsNil() {
rv.Set(reflect.MakeMap(rt))
}
mapKeyLower := strings.ToLower(mapKey)
nv := prv.Elem().MapIndex(reflect.ValueOf(mapKeyLower))
nv := rv.MapIndex(reflect.ValueOf(mapKeyLower))
zero := reflect.Value{}
if nv == zero {
nv = reflect.New(rt.Elem().Elem())
prv.Elem().SetMapIndex(reflect.ValueOf(mapKeyLower), nv)
if unm, ok := nv.Interface().(json.Unmarshaler); ok {
unm.UnmarshalJSON(nil) // load defaults
}
rv.SetMapIndex(reflect.ValueOf(mapKeyLower), nv)
}
err := loadEnvInternal(env, prefix+"_"+mapKey, nv.Elem())
@ -158,15 +133,13 @@ func loadEnvInternal(env map[string]string, prefix string, prv reflect.Value) er @@ -158,15 +133,13 @@ func loadEnvInternal(env map[string]string, prefix string, prv reflect.Value) er
flen := rt.NumField()
for i := 0; i < flen; i++ {
f := rt.Field(i)
jsonTag := f.Tag.Get("json")
// load only public fields
if jsonTag == "-" {
if f.Tag.Get("json") == "-" {
continue
}
err := loadEnvInternal(env, prefix+"_"+
strings.ToUpper(strings.TrimSuffix(jsonTag, ",omitempty")), prv.Elem().Field(i))
err := loadEnvInternal(env, prefix+"_"+strings.ToUpper(f.Name), rv.Field(i))
if err != nil {
return err
}
@ -174,48 +147,20 @@ func loadEnvInternal(env map[string]string, prefix string, prv reflect.Value) er @@ -174,48 +147,20 @@ func loadEnvInternal(env map[string]string, prefix string, prv reflect.Value) er
return nil
case reflect.Slice:
switch {
case rt.Elem() == reflect.TypeOf(""):
if ev, ok := env[prefix]; ok {
if ev == "" {
prv.Elem().Set(reflect.MakeSlice(prv.Elem().Type(), 0, 0))
} else {
if prv.IsNil() {
prv.Set(reflect.New(rt))
}
prv.Elem().Set(reflect.ValueOf(strings.Split(ev, ",")))
}
}
return nil
case rt.Elem() == reflect.TypeOf(float64(0)):
if rt.Elem() == reflect.TypeOf("") {
if ev, ok := env[prefix]; ok {
if ev == "" {
prv.Elem().Set(reflect.MakeSlice(prv.Elem().Type(), 0, 0))
rv.Set(reflect.MakeSlice(rv.Type(), 0, 0))
} else {
if prv.IsNil() {
prv.Set(reflect.New(rt))
}
raw := strings.Split(ev, ",")
vals := make([]float64, len(raw))
for i, v := range raw {
tmp, err := strconv.ParseFloat(v, 64)
if err != nil {
return err
}
vals[i] = tmp
}
prv.Elem().Set(reflect.ValueOf(vals))
rv.Set(reflect.ValueOf(strings.Split(ev, ",")))
}
}
return nil
}
case rt.Elem().Kind() == reflect.Struct:
if rt.Elem().Kind() == reflect.Struct {
if ev, ok := env[prefix]; ok && ev == "" { // special case: empty list
prv.Elem().Set(reflect.MakeSlice(prv.Elem().Type(), 0, 0))
rv.Set(reflect.MakeSlice(rv.Type(), 0, 0))
} else {
for i := 0; ; i++ {
itemPrefix := prefix + "_" + strconv.FormatInt(int64(i), 10)
@ -229,7 +174,7 @@ func loadEnvInternal(env map[string]string, prefix string, prv reflect.Value) er @@ -229,7 +174,7 @@ func loadEnvInternal(env map[string]string, prefix string, prv reflect.Value) er
return err
}
prv.Elem().Set(reflect.Append(prv.Elem(), elem.Elem()))
rv.Set(reflect.Append(rv, elem.Elem()))
}
}
return nil
@ -239,20 +184,13 @@ func loadEnvInternal(env map[string]string, prefix string, prv reflect.Value) er @@ -239,20 +184,13 @@ func loadEnvInternal(env map[string]string, prefix string, prv reflect.Value) er
return fmt.Errorf("unsupported type: %v", rt)
}
func loadWithEnv(env map[string]string, prefix string, v interface{}) error {
return loadEnvInternal(env, prefix, reflect.ValueOf(v).Elem())
}
func envToMap() map[string]string {
// Load loads the configuration from the environment.
func Load(prefix string, v interface{}) error {
env := make(map[string]string)
for _, kv := range os.Environ() {
tmp := strings.SplitN(kv, "=", 2)
env[tmp[0]] = tmp[1]
}
return env
}
// Load loads the configuration from the environment.
func Load(prefix string, v interface{}) error {
return loadWithEnv(envToMap(), prefix, v)
return loadEnvInternal(env, prefix, reflect.ValueOf(v).Elem())
}

250
internal/conf/env/env_test.go vendored

@ -2,34 +2,20 @@ package env @@ -2,34 +2,20 @@ package env
import (
"encoding/json"
"os"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func stringPtr(v string) *string {
return &v
}
func intPtr(v int) *int {
return &v
}
func uint64Ptr(v uint64) *uint64 {
return &v
}
func boolPtr(v bool) *bool {
return &v
}
func float64Ptr(v float64) *float64 {
return &v
type subStruct struct {
MyParam int
}
func durationPtr(v time.Duration) *time.Duration {
return &v
type mapEntry struct {
MyValue string
MyStruct subStruct
}
type myDuration time.Duration
@ -49,157 +35,111 @@ func (d *myDuration) UnmarshalJSON(b []byte) error { @@ -49,157 +35,111 @@ func (d *myDuration) UnmarshalJSON(b []byte) error {
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *myDuration) UnmarshalEnv(_ string, v string) error {
return d.UnmarshalJSON([]byte(`"` + v + `"`))
}
type subStruct struct {
MyParam int `json:"myParam"`
}
type mapEntry struct {
MyValue string `json:"myValue"`
MyStruct subStruct `json:"myStruct"`
// UnmarshalEnv implements envUnmarshaler.
func (d *myDuration) UnmarshalEnv(s string) error {
return d.UnmarshalJSON([]byte(`"` + s + `"`))
}
type mySubStruct struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
MyInt2 int `json:"myInt2"`
URL string
Username string
Password string
}
type testStruct struct {
MyString string `json:"myString"`
MyStringOpt *string `json:"myStringOpt"`
MyInt int `json:"myInt"`
MyIntOpt *int `json:"myIntOpt"`
MyUint uint64 `json:"myUint"`
MyUintOpt *uint64 `json:"myUintOpt"`
MyFloat float64 `json:"myFloat"`
MyFloatOpt *float64 `json:"myFloatOpt"`
MyBool bool `json:"myBool"`
MyBoolOpt *bool `json:"myBoolOpt"`
MyDuration myDuration `json:"myDuration"`
MyDurationOpt *myDuration `json:"myDurationOpt"`
MyDurationOptUnset *myDuration `json:"myDurationOptUnset"`
MyMap map[string]*mapEntry `json:"myMap"`
MySliceFloat []float64 `json:"mySliceFloat"`
MySliceString []string `json:"mySliceString"`
MySliceStringEmpty []string `json:"mySliceStringEmpty"`
MySliceStringOpt *[]string `json:"mySliceStringOpt"`
MySliceStringOptUnset *[]string `json:"mySliceStringOptUnset"`
MySliceSubStruct []mySubStruct `json:"mySliceSubStruct"`
MySliceSubStructEmpty []mySubStruct `json:"mySliceSubStructEmpty"`
MySliceSubStructOpt *[]mySubStruct `json:"mySliceSubStructOpt"`
MySliceSubStructOptUnset *[]mySubStruct `json:"mySliceSubStructOptUnset"`
Unset *bool `json:"unset"`
MyString string
MyInt int
MyFloat float64
MyBool bool
MyDuration myDuration
MyMap map[string]*mapEntry
MySlice []string
MySliceEmpty []string
MySliceSubStruct []mySubStruct
MySliceSubStructEmpty []mySubStruct
}
func TestLoad(t *testing.T) {
env := map[string]string{
"MYPREFIX_MYSTRING": "testcontent",
"MYPREFIX_MYSTRINGOPT": "testcontent2",
"MYPREFIX_MYINT": "123",
"MYPREFIX_MYINTOPT": "456",
"MYPREFIX_MYUINT": "8910",
"MYPREFIX_MYUINTOPT": "112313",
"MYPREFIX_MYFLOAT": "15.2",
"MYPREFIX_MYFLOATOPT": "16.2",
"MYPREFIX_MYBOOL": "yes",
"MYPREFIX_MYBOOLOPT": "false",
"MYPREFIX_MYDURATION": "22s",
"MYPREFIX_MYDURATIONOPT": "30s",
"MYPREFIX_MYMAP_MYKEY": "",
"MYPREFIX_MYMAP_MYKEY2_MYVALUE": "asd",
"MYPREFIX_MYMAP_MYKEY2_MYSTRUCT_MYPARAM": "456",
"MYPREFIX_MYSLICEFLOAT": "0.5,0.5",
"MYPREFIX_MYSLICESTRING": "val1,val2",
"MYPREFIX_MYSLICESTRINGEMPTY": "",
"MYPREFIX_MYSLICESTRINGOPT": "aa",
"MYPREFIX_MYSLICESUBSTRUCT_0_URL": "url1",
"MYPREFIX_MYSLICESUBSTRUCT_0_USERNAME": "user1",
"MYPREFIX_MYSLICESUBSTRUCT_0_PASSWORD": "pass1",
"MYPREFIX_MYSLICESUBSTRUCT_1_URL": "url2",
"MYPREFIX_MYSLICESUBSTRUCT_1_PASSWORD": "pass2",
"MYPREFIX_MYSLICESUBSTRUCTEMPTY": "",
"MYPREFIX_MYSLICESUBSTRUCTOPT_1_PASSWORD": "pwd",
}
os.Setenv("MYPREFIX_MYSTRING", "testcontent")
defer os.Unsetenv("MYPREFIX_MYSTRING")
for key, val := range env {
t.Setenv(key, val)
}
os.Setenv("MYPREFIX_MYINT", "123")
defer os.Unsetenv("MYPREFIX_MYINT")
os.Setenv("MYPREFIX_MYFLOAT", "15.2")
defer os.Unsetenv("MYPREFIX_MYFLOAT")
os.Setenv("MYPREFIX_MYBOOL", "yes")
defer os.Unsetenv("MYPREFIX_MYBOOL")
os.Setenv("MYPREFIX_MYDURATION", "22s")
defer os.Unsetenv("MYPREFIX_MYDURATION")
os.Setenv("MYPREFIX_MYMAP_MYKEY", "")
defer os.Unsetenv("MYPREFIX_MYMAP_MYKEY")
os.Setenv("MYPREFIX_MYMAP_MYKEY2_MYVALUE", "asd")
defer os.Unsetenv("MYPREFIX_MYMAP_MYKEY2_MYVALUE")
os.Setenv("MYPREFIX_MYMAP_MYKEY2_MYSTRUCT_MYPARAM", "456")
defer os.Unsetenv("MYPREFIX_MYMAP_MYKEY2_MYSTRUCT_MYPARAM")
os.Setenv("MYPREFIX_MYSLICE", "val1,val2")
defer os.Unsetenv("MYPREFIX_MYSLICE")
os.Setenv("MYPREFIX_MYSLICEEMPTY", "")
defer os.Unsetenv("MYPREFIX_MYSLICEEMPTY")
os.Setenv("MYPREFIX_MYSLICESUBSTRUCT_0_URL", "url1")
defer os.Unsetenv("MYPREFIX_MYSLICESUBSTRUCT_0_URL")
os.Setenv("MYPREFIX_MYSLICESUBSTRUCT_0_USERNAME", "user1")
defer os.Unsetenv("MYPREFIX_MYSLICESUBSTRUCT_0_USERNAME")
os.Setenv("MYPREFIX_MYSLICESUBSTRUCT_0_PASSWORD", "pass1")
defer os.Unsetenv("MYPREFIX_MYSLICESUBSTRUCT_0_PASSWORD")
os.Setenv("MYPREFIX_MYSLICESUBSTRUCT_1_URL", "url2")
defer os.Unsetenv("MYPREFIX_MYSLICESUBSTRUCT_1_URL")
os.Setenv("MYPREFIX_MYSLICESUBSTRUCT_1_PASSWORD", "pass2")
defer os.Unsetenv("MYPREFIX_MYSLICESUBSTRUCT_1_PASSWORD")
os.Setenv("MYPREFIX_MYSLICESUBSTRUCTEMPTY", "")
defer os.Unsetenv("MYPREFIX_MYSLICESUBSTRUCTEMPTY")
var s testStruct
err := Load("MYPREFIX", &s)
require.NoError(t, err)
require.Equal(t, testStruct{
MyString: "testcontent",
MyStringOpt: stringPtr("testcontent2"),
MyInt: 123,
MyIntOpt: intPtr(456),
MyUint: 8910,
MyUintOpt: uint64Ptr(112313),
MyFloat: 15.2,
MyFloatOpt: float64Ptr(16.2),
MyBool: true,
MyBoolOpt: boolPtr(false),
MyDuration: 22000000000,
MyDurationOpt: (*myDuration)(durationPtr(30000000000)),
MyMap: map[string]*mapEntry{
"mykey": {
MyValue: "",
MyStruct: subStruct{
MyParam: 0,
},
},
"mykey2": {
MyValue: "asd",
MyStruct: subStruct{
MyParam: 456,
},
},
},
MySliceFloat: []float64{0.5, 0.5},
MySliceString: []string{
"val1",
"val2",
require.Equal(t, "testcontent", s.MyString)
require.Equal(t, 123, s.MyInt)
require.Equal(t, 15.2, s.MyFloat)
require.Equal(t, true, s.MyBool)
require.Equal(t, 22*myDuration(time.Second), s.MyDuration)
_, ok := s.MyMap["mykey"]
require.Equal(t, true, ok)
v, ok := s.MyMap["mykey2"]
require.Equal(t, true, ok)
require.Equal(t, "asd", v.MyValue)
require.Equal(t, 456, v.MyStruct.MyParam)
require.Equal(t, []string{"val1", "val2"}, s.MySlice)
require.Equal(t, []string{}, s.MySliceEmpty)
require.Equal(t, []mySubStruct{
{
URL: "url1",
Username: "user1",
Password: "pass1",
},
MySliceStringEmpty: []string{},
MySliceStringOpt: &[]string{"aa"},
MySliceSubStruct: []mySubStruct{
{
URL: "url1",
Username: "user1",
Password: "pass1",
},
{
URL: "url2",
Username: "",
Password: "pass2",
},
{
URL: "url2",
Password: "pass2",
},
MySliceSubStructEmpty: []mySubStruct{},
}, s)
}
}, s.MySliceSubStruct)
func FuzzLoad(f *testing.F) {
f.Add("MYPREFIX_MYINT", "a")
f.Add("MYPREFIX_MYUINT", "a")
f.Add("MYPREFIX_MYFLOAT", "a")
f.Add("MYPREFIX_MYBOOL", "a")
f.Add("MYPREFIX_MYSLICESUBSTRUCT_0_MYINT2", "a")
f.Add("MYPREFIX_MYDURATION", "a")
f.Add("MYPREFIX_MYDURATION_A", "a")
f.Fuzz(func(_ *testing.T, key string, val string) {
env := map[string]string{
key: val,
}
var s testStruct
loadWithEnv(env, "MYPREFIX", &s) //nolint:errcheck
})
require.Equal(t, []mySubStruct{}, s.MySliceSubStructEmpty)
}

41
internal/conf/global.go

@ -1,41 +0,0 @@ @@ -1,41 +0,0 @@
package conf
import (
"encoding/json"
"reflect"
)
var globalValuesType = func() reflect.Type {
var fields []reflect.StructField
rt := reflect.TypeOf(Conf{})
nf := rt.NumField()
for i := 0; i < nf; i++ {
f := rt.Field(i)
j := f.Tag.Get("json")
if j != "-" && j != "pathDefaults" && j != "paths" {
fields = append(fields, reflect.StructField{
Name: f.Name,
Type: f.Type,
Tag: f.Tag,
})
}
}
return reflect.StructOf(fields)
}()
func newGlobalValues() interface{} {
return reflect.New(globalValuesType).Interface()
}
// Global is the global part of Conf.
type Global struct {
Values interface{}
}
// MarshalJSON implements json.Marshaler.
func (p *Global) MarshalJSON() ([]byte, error) {
return json.Marshal(p.Values)
}

11
internal/conf/hls_variant.go

@ -21,8 +21,11 @@ func (d HLSVariant) MarshalJSON() ([]byte, error) { @@ -21,8 +21,11 @@ func (d HLSVariant) MarshalJSON() ([]byte, error) {
case HLSVariant(gohlslib.MuxerVariantFMP4):
out = "fmp4"
default:
case HLSVariant(gohlslib.MuxerVariantLowLatency):
out = "lowLatency"
default:
return nil, fmt.Errorf("invalid HLS variant: %v", d)
}
return json.Marshal(out)
@ -52,7 +55,7 @@ func (d *HLSVariant) UnmarshalJSON(b []byte) error { @@ -52,7 +55,7 @@ func (d *HLSVariant) UnmarshalJSON(b []byte) error {
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *HLSVariant) UnmarshalEnv(_ string, v string) error {
return d.UnmarshalJSON([]byte(`"` + v + `"`))
// UnmarshalEnv implements envUnmarshaler.
func (d *HLSVariant) UnmarshalEnv(s string) error {
return d.UnmarshalJSON([]byte(`"` + s + `"`))
}

84
internal/conf/ip_networks.go

@ -1,84 +0,0 @@ @@ -1,84 +0,0 @@
package conf
import (
"encoding/json"
"fmt"
"net"
"sort"
"strings"
)
// IPNetworks is a parameter that contains a list of IP networks.
type IPNetworks []net.IPNet
// MarshalJSON implements json.Marshaler.
func (d IPNetworks) MarshalJSON() ([]byte, error) {
out := make([]string, len(d))
for i, v := range d {
out[i] = v.String()
}
sort.Strings(out)
return json.Marshal(out)
}
// UnmarshalJSON implements json.Unmarshaler.
func (d *IPNetworks) UnmarshalJSON(b []byte) error {
var in []string
if err := json.Unmarshal(b, &in); err != nil {
return err
}
*d = nil
if len(in) == 0 {
return nil
}
for _, t := range in {
if _, ipnet, err := net.ParseCIDR(t); err == nil {
if ipv4 := ipnet.IP.To4(); ipv4 != nil {
*d = append(*d, net.IPNet{IP: ipv4, Mask: ipnet.Mask[len(ipnet.Mask)-4 : len(ipnet.Mask)]})
} else {
*d = append(*d, *ipnet)
}
} else if ip := net.ParseIP(t); ip != nil {
if ipv4 := ip.To4(); ipv4 != nil {
*d = append(*d, net.IPNet{IP: ipv4, Mask: net.CIDRMask(32, 32)})
} else {
*d = append(*d, net.IPNet{IP: ip, Mask: net.CIDRMask(128, 128)})
}
} else {
return fmt.Errorf("unable to parse IP/CIDR '%s'", t)
}
}
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *IPNetworks) UnmarshalEnv(_ string, v string) error {
byts, _ := json.Marshal(strings.Split(v, ","))
return d.UnmarshalJSON(byts)
}
// ToTrustedProxies converts IPNetworks into a string slice for SetTrustedProxies.
func (d *IPNetworks) ToTrustedProxies() []string {
ret := make([]string, len(*d))
for i, entry := range *d {
ret[i] = entry.String()
}
return ret
}
// Contains checks whether the IP is part of one of the networks.
func (d IPNetworks) Contains(ip net.IP) bool {
for _, network := range d {
if network.Contains(ip) {
return true
}
}
return false
}

57
internal/conf/ips_or_cidrs.go

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
package conf
import (
"encoding/json"
"fmt"
"net"
"sort"
"strings"
)
// IPsOrCIDRs is a parameter that contains a list of IPs or CIDRs.
type IPsOrCIDRs []fmt.Stringer
// MarshalJSON implements json.Marshaler.
func (d IPsOrCIDRs) MarshalJSON() ([]byte, error) {
out := make([]string, len(d))
for i, v := range d {
out[i] = v.String()
}
sort.Strings(out)
return json.Marshal(out)
}
// UnmarshalJSON implements json.Unmarshaler.
func (d *IPsOrCIDRs) UnmarshalJSON(b []byte) error {
var in []string
if err := json.Unmarshal(b, &in); err != nil {
return err
}
*d = nil
if len(in) == 0 {
return nil
}
for _, t := range in {
if _, ipnet, err := net.ParseCIDR(t); err == nil {
*d = append(*d, ipnet)
} else if ip := net.ParseIP(t); ip != nil {
*d = append(*d, ip)
} else {
return fmt.Errorf("unable to parse IP/CIDR '%s'", t)
}
}
return nil
}
// UnmarshalEnv implements envUnmarshaler.
func (d *IPsOrCIDRs) UnmarshalEnv(s string) error {
byts, _ := json.Marshal(strings.Split(s, ","))
return d.UnmarshalJSON(byts)
}

11
internal/conf/log_destination.go

@ -26,8 +26,11 @@ func (d LogDestinations) MarshalJSON() ([]byte, error) { @@ -26,8 +26,11 @@ func (d LogDestinations) MarshalJSON() ([]byte, error) {
case logger.DestinationFile:
v = "file"
default:
case logger.DestinationSyslog:
v = "syslog"
default:
return nil, fmt.Errorf("invalid log destination: %v", p)
}
out[i] = v
@ -81,8 +84,8 @@ func (d *LogDestinations) UnmarshalJSON(b []byte) error { @@ -81,8 +84,8 @@ func (d *LogDestinations) UnmarshalJSON(b []byte) error {
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *LogDestinations) UnmarshalEnv(_ string, v string) error {
byts, _ := json.Marshal(strings.Split(v, ","))
// UnmarshalEnv implements envUnmarshaler.
func (d *LogDestinations) UnmarshalEnv(s string) error {
byts, _ := json.Marshal(strings.Split(s, ","))
return d.UnmarshalJSON(byts)
}

11
internal/conf/log_level.go

@ -24,8 +24,11 @@ func (d LogLevel) MarshalJSON() ([]byte, error) { @@ -24,8 +24,11 @@ func (d LogLevel) MarshalJSON() ([]byte, error) {
case LogLevel(logger.Info):
out = "info"
default:
case LogLevel(logger.Debug):
out = "debug"
default:
return nil, fmt.Errorf("invalid log level: %v", d)
}
return json.Marshal(out)
@ -58,7 +61,7 @@ func (d *LogLevel) UnmarshalJSON(b []byte) error { @@ -58,7 +61,7 @@ func (d *LogLevel) UnmarshalJSON(b []byte) error {
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *LogLevel) UnmarshalEnv(_ string, v string) error {
return d.UnmarshalJSON([]byte(`"` + v + `"`))
// UnmarshalEnv implements envUnmarshaler.
func (d *LogLevel) UnmarshalEnv(s string) error {
return d.UnmarshalJSON([]byte(`"` + s + `"`))
}

60
internal/conf/optional_global.go

@ -1,60 +0,0 @@ @@ -1,60 +0,0 @@
package conf
import (
"bytes"
"encoding/json"
"reflect"
"strings"
)
var optionalGlobalValuesType = func() reflect.Type {
var fields []reflect.StructField
rt := reflect.TypeOf(Conf{})
nf := rt.NumField()
for i := 0; i < nf; i++ {
f := rt.Field(i)
j := f.Tag.Get("json")
if j != "-" && j != "pathDefaults" && j != "paths" {
if !strings.Contains(j, ",omitempty") {
j += ",omitempty"
}
typ := f.Type
if typ.Kind() != reflect.Pointer {
typ = reflect.PtrTo(typ)
}
fields = append(fields, reflect.StructField{
Name: f.Name,
Type: typ,
Tag: reflect.StructTag(`json:"` + j + `"`),
})
}
}
return reflect.StructOf(fields)
}()
func newOptionalGlobalValues() interface{} {
return reflect.New(optionalGlobalValuesType).Interface()
}
// OptionalGlobal is a Conf whose values can all be optional.
type OptionalGlobal struct {
Values interface{}
}
// UnmarshalJSON implements json.Unmarshaler.
func (p *OptionalGlobal) UnmarshalJSON(b []byte) error {
p.Values = newOptionalGlobalValues()
d := json.NewDecoder(bytes.NewReader(b))
d.DisallowUnknownFields()
return d.Decode(p.Values)
}
// MarshalJSON implements json.Marshaler.
func (p *OptionalGlobal) MarshalJSON() ([]byte, error) {
return json.Marshal(p.Values)
}

70
internal/conf/optional_path.go

@ -1,70 +0,0 @@ @@ -1,70 +0,0 @@
package conf
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"github.com/bluenviron/mediamtx/internal/conf/env"
)
var optionalPathValuesType = func() reflect.Type {
var fields []reflect.StructField
rt := reflect.TypeOf(Path{})
nf := rt.NumField()
for i := 0; i < nf; i++ {
f := rt.Field(i)
j := f.Tag.Get("json")
if j != "-" {
if !strings.Contains(j, ",omitempty") {
j += ",omitempty"
}
typ := f.Type
if typ.Kind() != reflect.Pointer {
typ = reflect.PtrTo(typ)
}
fields = append(fields, reflect.StructField{
Name: f.Name,
Type: typ,
Tag: reflect.StructTag(`json:"` + j + `"`),
})
}
}
return reflect.StructOf(fields)
}()
func newOptionalPathValues() interface{} {
return reflect.New(optionalPathValuesType).Interface()
}
// OptionalPath is a Path whose values can all be optional.
type OptionalPath struct {
Values interface{}
}
// UnmarshalJSON implements json.Unmarshaler.
func (p *OptionalPath) UnmarshalJSON(b []byte) error {
p.Values = newOptionalPathValues()
d := json.NewDecoder(bytes.NewReader(b))
d.DisallowUnknownFields()
return d.Decode(p.Values)
}
// UnmarshalEnv implements env.Unmarshaler.
func (p *OptionalPath) UnmarshalEnv(prefix string, _ string) error {
if p.Values == nil {
p.Values = newOptionalPathValues()
}
return env.Load(prefix, p.Values)
}
// MarshalJSON implements json.Marshaler.
func (p *OptionalPath) MarshalJSON() ([]byte, error) {
return json.Marshal(p.Values)
}

617
internal/conf/path.go

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
package conf
import (
"bytes"
"encoding/json"
"fmt"
"net"
@ -10,12 +11,14 @@ import ( @@ -10,12 +11,14 @@ import (
"strings"
"time"
"github.com/bluenviron/gortsplib/v4/pkg/base"
"github.com/bluenviron/gortsplib/v3/pkg/headers"
"github.com/bluenviron/gortsplib/v3/pkg/url"
)
var rePathName = regexp.MustCompile(`^[0-9a-zA-Z_\-/\.~]+$`)
func isValidPathName(name string) error {
// IsValidPathName checks if a path name is valid.
func IsValidPathName(name string) error {
if name == "" {
return fmt.Errorf("cannot be empty")
}
@ -35,267 +38,129 @@ func isValidPathName(name string) error { @@ -35,267 +38,129 @@ func isValidPathName(name string) error {
return nil
}
func srtCheckPassphrase(passphrase string) error {
switch {
case len(passphrase) < 10 || len(passphrase) > 79:
return fmt.Errorf("must be between 10 and 79 characters")
default:
return nil
}
}
// FindPathConf returns the configuration corresponding to the given path name.
func FindPathConf(pathConfs map[string]*Path, name string) (string, *Path, []string, error) {
err := isValidPathName(name)
if err != nil {
return "", nil, nil, fmt.Errorf("invalid path name: %w (%s)", err, name)
}
// PathConf is a path configuration.
type PathConf struct {
Regexp *regexp.Regexp `json:"-"`
// normal path
if pathConf, ok := pathConfs[name]; ok {
return name, pathConf, nil, nil
}
// source
Source string `json:"source"`
// regular expression-based path
for pathConfName, pathConf := range pathConfs {
if pathConf.Regexp != nil && pathConfName != "all" && pathConfName != "all_others" {
m := pathConf.Regexp.FindStringSubmatch(name)
if m != nil {
return pathConfName, pathConf, m, nil
}
}
}
// all_others
for pathConfName, pathConf := range pathConfs {
if pathConfName == "all" || pathConfName == "all_others" {
m := pathConf.Regexp.FindStringSubmatch(name)
if m != nil {
return pathConfName, pathConf, m, nil
}
}
}
return "", nil, nil, fmt.Errorf("path '%s' is not configured", name)
}
// Path is a path configuration.
type Path struct {
Regexp *regexp.Regexp `json:"-"` // filled by Check()
Name string `json:"name"` // filled by Check()
// General
Source string `json:"source"`
// general
SourceFingerprint string `json:"sourceFingerprint"`
SourceOnDemand bool `json:"sourceOnDemand"`
SourceOnDemandStartTimeout StringDuration `json:"sourceOnDemandStartTimeout"`
SourceOnDemandCloseAfter StringDuration `json:"sourceOnDemandCloseAfter"`
MaxReaders int `json:"maxReaders"`
SRTReadPassphrase string `json:"srtReadPassphrase"`
Fallback string `json:"fallback"`
// Record and playback
Record bool `json:"record"`
Playback bool `json:"playback"`
RecordPath string `json:"recordPath"`
RecordFormat RecordFormat `json:"recordFormat"`
RecordPartDuration StringDuration `json:"recordPartDuration"`
RecordSegmentDuration StringDuration `json:"recordSegmentDuration"`
RecordDeleteAfter StringDuration `json:"recordDeleteAfter"`
// Authentication (deprecated)
PublishUser *Credential `json:"publishUser,omitempty"` // deprecated
PublishPass *Credential `json:"publishPass,omitempty"` // deprecated
PublishIPs *IPNetworks `json:"publishIPs,omitempty"` // deprecated
ReadUser *Credential `json:"readUser,omitempty"` // deprecated
ReadPass *Credential `json:"readPass,omitempty"` // deprecated
ReadIPs *IPNetworks `json:"readIPs,omitempty"` // deprecated
// Publisher source
OverridePublisher bool `json:"overridePublisher"`
DisablePublisherOverride *bool `json:"disablePublisherOverride,omitempty"` // deprecated
SRTPublishPassphrase string `json:"srtPublishPassphrase"`
// RTSP source
RTSPTransport RTSPTransport `json:"rtspTransport"`
RTSPAnyPort bool `json:"rtspAnyPort"`
SourceProtocol *RTSPTransport `json:"sourceProtocol,omitempty"` // deprecated
SourceAnyPortEnable *bool `json:"sourceAnyPortEnable,omitempty"` // deprecated
RTSPRangeType RTSPRangeType `json:"rtspRangeType"`
RTSPRangeStart string `json:"rtspRangeStart"`
// Redirect source
SourceRedirect string `json:"sourceRedirect"`
// Raspberry Pi Camera source
RPICameraCamID int `json:"rpiCameraCamID"`
RPICameraWidth int `json:"rpiCameraWidth"`
RPICameraHeight int `json:"rpiCameraHeight"`
RPICameraHFlip bool `json:"rpiCameraHFlip"`
RPICameraVFlip bool `json:"rpiCameraVFlip"`
RPICameraBrightness float64 `json:"rpiCameraBrightness"`
RPICameraContrast float64 `json:"rpiCameraContrast"`
RPICameraSaturation float64 `json:"rpiCameraSaturation"`
RPICameraSharpness float64 `json:"rpiCameraSharpness"`
RPICameraExposure string `json:"rpiCameraExposure"`
RPICameraAWB string `json:"rpiCameraAWB"`
RPICameraAWBGains []float64 `json:"rpiCameraAWBGains"`
RPICameraDenoise string `json:"rpiCameraDenoise"`
RPICameraShutter int `json:"rpiCameraShutter"`
RPICameraMetering string `json:"rpiCameraMetering"`
RPICameraGain float64 `json:"rpiCameraGain"`
RPICameraEV float64 `json:"rpiCameraEV"`
RPICameraROI string `json:"rpiCameraROI"`
RPICameraHDR bool `json:"rpiCameraHDR"`
RPICameraTuningFile string `json:"rpiCameraTuningFile"`
RPICameraMode string `json:"rpiCameraMode"`
RPICameraFPS float64 `json:"rpiCameraFPS"`
RPICameraIDRPeriod int `json:"rpiCameraIDRPeriod"`
RPICameraBitrate int `json:"rpiCameraBitrate"`
RPICameraProfile string `json:"rpiCameraProfile"`
RPICameraLevel string `json:"rpiCameraLevel"`
RPICameraAfMode string `json:"rpiCameraAfMode"`
RPICameraAfRange string `json:"rpiCameraAfRange"`
RPICameraAfSpeed string `json:"rpiCameraAfSpeed"`
RPICameraLensPosition float64 `json:"rpiCameraLensPosition"`
RPICameraAfWindow string `json:"rpiCameraAfWindow"`
RPICameraTextOverlayEnable bool `json:"rpiCameraTextOverlayEnable"`
RPICameraTextOverlay string `json:"rpiCameraTextOverlay"`
// Hooks
RunOnInit string `json:"runOnInit"`
RunOnInitRestart bool `json:"runOnInitRestart"`
RunOnDemand string `json:"runOnDemand"`
RunOnDemandRestart bool `json:"runOnDemandRestart"`
RunOnDemandStartTimeout StringDuration `json:"runOnDemandStartTimeout"`
RunOnDemandCloseAfter StringDuration `json:"runOnDemandCloseAfter"`
RunOnUnDemand string `json:"runOnUnDemand"`
RunOnReady string `json:"runOnReady"`
RunOnReadyRestart bool `json:"runOnReadyRestart"`
RunOnNotReady string `json:"runOnNotReady"`
RunOnRead string `json:"runOnRead"`
RunOnReadRestart bool `json:"runOnReadRestart"`
RunOnUnread string `json:"runOnUnread"`
RunOnRecordSegmentCreate string `json:"runOnRecordSegmentCreate"`
RunOnRecordSegmentComplete string `json:"runOnRecordSegmentComplete"`
}
func (pconf *Path) setDefaults() {
// General
pconf.Source = "publisher"
pconf.SourceOnDemandStartTimeout = 10 * StringDuration(time.Second)
pconf.SourceOnDemandCloseAfter = 10 * StringDuration(time.Second)
// Record and playback
pconf.Playback = true
pconf.RecordPath = "./recordings/%path/%Y-%m-%d_%H-%M-%S-%f"
pconf.RecordFormat = RecordFormatFMP4
pconf.RecordPartDuration = 100 * StringDuration(time.Millisecond)
pconf.RecordSegmentDuration = 3600 * StringDuration(time.Second)
pconf.RecordDeleteAfter = 24 * 3600 * StringDuration(time.Second)
// Publisher source
pconf.OverridePublisher = true
// Raspberry Pi Camera source
pconf.RPICameraWidth = 1920
pconf.RPICameraHeight = 1080
pconf.RPICameraContrast = 1
pconf.RPICameraSaturation = 1
pconf.RPICameraSharpness = 1
pconf.RPICameraExposure = "normal"
pconf.RPICameraAWB = "auto"
pconf.RPICameraAWBGains = []float64{0, 0}
pconf.RPICameraDenoise = "off"
pconf.RPICameraMetering = "centre"
pconf.RPICameraFPS = 30
pconf.RPICameraIDRPeriod = 60
pconf.RPICameraBitrate = 1000000
pconf.RPICameraProfile = "main"
pconf.RPICameraLevel = "4.1"
pconf.RPICameraAfMode = "continuous"
pconf.RPICameraAfRange = "normal"
pconf.RPICameraAfSpeed = "normal"
pconf.RPICameraTextOverlay = "%Y-%m-%d %H:%M:%S - MediaMTX"
// Hooks
pconf.RunOnDemandStartTimeout = 10 * StringDuration(time.Second)
pconf.RunOnDemandCloseAfter = 10 * StringDuration(time.Second)
}
func newPath(defaults *Path, partial *OptionalPath) *Path {
pconf := &Path{}
copyStructFields(pconf, defaults)
copyStructFields(pconf, partial.Values)
return pconf
}
// Clone clones the configuration.
func (pconf Path) Clone() *Path {
enc, err := json.Marshal(pconf)
if err != nil {
panic(err)
}
var dest Path
err = json.Unmarshal(enc, &dest)
if err != nil {
panic(err)
}
dest.Regexp = pconf.Regexp
// authentication
PublishUser Credential `json:"publishUser"`
PublishPass Credential `json:"publishPass"`
PublishIPs IPsOrCIDRs `json:"publishIPs"`
ReadUser Credential `json:"readUser"`
ReadPass Credential `json:"readPass"`
ReadIPs IPsOrCIDRs `json:"readIPs"`
// publisher
DisablePublisherOverride bool `json:"disablePublisherOverride"`
Fallback string `json:"fallback"`
// rtsp
SourceProtocol SourceProtocol `json:"sourceProtocol"`
SourceAnyPortEnable bool `json:"sourceAnyPortEnable"`
RtspRangeType RtspRangeType `json:"rtspRangeType"`
RtspRangeStart string `json:"rtspRangeStart"`
// redirect
SourceRedirect string `json:"sourceRedirect"`
return &dest
// raspberry pi camera
RPICameraCamID int `json:"rpiCameraCamID"`
RPICameraWidth int `json:"rpiCameraWidth"`
RPICameraHeight int `json:"rpiCameraHeight"`
RPICameraHFlip bool `json:"rpiCameraHFlip"`
RPICameraVFlip bool `json:"rpiCameraVFlip"`
RPICameraBrightness float64 `json:"rpiCameraBrightness"`
RPICameraContrast float64 `json:"rpiCameraContrast"`
RPICameraSaturation float64 `json:"rpiCameraSaturation"`
RPICameraSharpness float64 `json:"rpiCameraSharpness"`
RPICameraExposure string `json:"rpiCameraExposure"`
RPICameraAWB string `json:"rpiCameraAWB"`
RPICameraDenoise string `json:"rpiCameraDenoise"`
RPICameraShutter int `json:"rpiCameraShutter"`
RPICameraMetering string `json:"rpiCameraMetering"`
RPICameraGain float64 `json:"rpiCameraGain"`
RPICameraEV float64 `json:"rpiCameraEV"`
RPICameraROI string `json:"rpiCameraROI"`
RPICameraHDR bool `json:"rpiCameraHDR"`
RPICameraTuningFile string `json:"rpiCameraTuningFile"`
RPICameraMode string `json:"rpiCameraMode"`
RPICameraFPS float64 `json:"rpiCameraFPS"`
RPICameraIDRPeriod int `json:"rpiCameraIDRPeriod"`
RPICameraBitrate int `json:"rpiCameraBitrate"`
RPICameraProfile string `json:"rpiCameraProfile"`
RPICameraLevel string `json:"rpiCameraLevel"`
RPICameraAfMode string `json:"rpiCameraAfMode"`
RPICameraAfRange string `json:"rpiCameraAfRange"`
RPICameraAfSpeed string `json:"rpiCameraAfSpeed"`
RPICameraLensPosition float64 `json:"rpiCameraLensPosition"`
RPICameraAfWindow string `json:"rpiCameraAfWindow"`
RPICameraTextOverlayEnable bool `json:"rpiCameraTextOverlayEnable"`
RPICameraTextOverlay string `json:"rpiCameraTextOverlay"`
// external commands
RunOnInit string `json:"runOnInit"`
RunOnInitRestart bool `json:"runOnInitRestart"`
RunOnDemand string `json:"runOnDemand"`
RunOnDemandRestart bool `json:"runOnDemandRestart"`
RunOnDemandStartTimeout StringDuration `json:"runOnDemandStartTimeout"`
RunOnDemandCloseAfter StringDuration `json:"runOnDemandCloseAfter"`
RunOnReady string `json:"runOnReady"`
RunOnReadyRestart bool `json:"runOnReadyRestart"`
RunOnRead string `json:"runOnRead"`
RunOnReadRestart bool `json:"runOnReadRestart"`
}
func (pconf *Path) validate(
conf *Conf,
name string,
deprecatedCredentialsMode bool,
) error {
pconf.Name = name
func (pconf *PathConf) check(conf *Conf, name string) error {
switch {
case name == "all_others", name == "all":
case name == "all":
pconf.Regexp = regexp.MustCompile("^.*$")
case name == "" || name[0] != '~': // normal path
err := isValidPathName(name)
err := IsValidPathName(name)
if err != nil {
return fmt.Errorf("invalid path name '%s': %w", name, err)
return fmt.Errorf("invalid path name '%s': %s", name, err)
}
default: // regular expression-based path
regexp, err := regexp.Compile(name[1:])
pathRegexp, err := regexp.Compile(name[1:])
if err != nil {
return fmt.Errorf("invalid regular expression: %s", name[1:])
}
pconf.Regexp = regexp
pconf.Regexp = pathRegexp
}
// General
if pconf.Source != "publisher" && pconf.Source != "redirect" &&
pconf.Regexp != nil && !pconf.SourceOnDemand {
return fmt.Errorf("a path with a regular expression (or path 'all') and a static source" +
" must have 'sourceOnDemand' set to true")
}
switch {
case pconf.Source == "publisher":
case strings.HasPrefix(pconf.Source, "rtsp://") ||
strings.HasPrefix(pconf.Source, "rtsps://"):
_, err := base.ParseURL(pconf.Source)
if pconf.Regexp != nil {
return fmt.Errorf("a path with a regular expression (or path 'all') cannot have a RTSP source. use another path")
}
_, err := url.Parse(pconf.Source)
if err != nil {
return fmt.Errorf("'%s' is not a valid URL", pconf.Source)
return fmt.Errorf("'%s' is not a valid RTSP URL", pconf.Source)
}
case strings.HasPrefix(pconf.Source, "rtmp://") ||
strings.HasPrefix(pconf.Source, "rtmps://"):
if pconf.Regexp != nil {
return fmt.Errorf("a path with a regular expression (or path 'all') cannot have a RTMP source. use another path")
}
u, err := gourl.Parse(pconf.Source)
if err != nil {
return fmt.Errorf("'%s' is not a valid URL", pconf.Source)
return fmt.Errorf("'%s' is not a valid RTMP URL", pconf.Source)
}
if u.User != nil {
@ -309,12 +174,16 @@ func (pconf *Path) validate( @@ -309,12 +174,16 @@ func (pconf *Path) validate(
case strings.HasPrefix(pconf.Source, "http://") ||
strings.HasPrefix(pconf.Source, "https://"):
if pconf.Regexp != nil {
return fmt.Errorf("a path with a regular expression (or path 'all') cannot have a HLS source. use another path")
}
u, err := gourl.Parse(pconf.Source)
if err != nil {
return fmt.Errorf("'%s' is not a valid URL", pconf.Source)
return fmt.Errorf("'%s' is not a valid HLS URL", pconf.Source)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("'%s' is not a valid URL", pconf.Source)
return fmt.Errorf("'%s' is not a valid HLS URL", pconf.Source)
}
if u.User != nil {
@ -327,233 +196,140 @@ func (pconf *Path) validate( @@ -327,233 +196,140 @@ func (pconf *Path) validate(
}
case strings.HasPrefix(pconf.Source, "udp://"):
_, _, err := net.SplitHostPort(pconf.Source[len("udp://"):])
if pconf.Regexp != nil {
return fmt.Errorf("a path with a regular expression (or path 'all') cannot have a HLS source. use another path")
}
host, _, err := net.SplitHostPort(pconf.Source[len("udp://"):])
if err != nil {
return fmt.Errorf("'%s' is not a valid UDP URL", pconf.Source)
}
case strings.HasPrefix(pconf.Source, "srt://"):
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("'%s' is not a valid IP", host)
}
_, err := gourl.Parse(pconf.Source)
if err != nil {
return fmt.Errorf("'%s' is not a valid URL", pconf.Source)
case pconf.Source == "redirect":
if pconf.SourceRedirect == "" {
return fmt.Errorf("source redirect must be filled")
}
case strings.HasPrefix(pconf.Source, "whep://") ||
strings.HasPrefix(pconf.Source, "wheps://"):
_, err := gourl.Parse(pconf.Source)
_, err := url.Parse(pconf.SourceRedirect)
if err != nil {
return fmt.Errorf("'%s' is not a valid URL", pconf.Source)
return fmt.Errorf("'%s' is not a valid RTSP URL", pconf.SourceRedirect)
}
case pconf.Source == "redirect":
case pconf.Source == "rpiCamera":
if pconf.Regexp != nil {
return fmt.Errorf(
"a path with a regular expression (or path 'all') cannot have 'rpiCamera' as source. use another path")
}
for otherName, otherPath := range conf.Paths {
if otherPath != pconf && otherPath != nil &&
otherPath.Source == "rpiCamera" && otherPath.RPICameraCamID == pconf.RPICameraCamID {
return fmt.Errorf("'rpiCamera' with same camera ID %d is used as source in two paths, '%s' and '%s'",
pconf.RPICameraCamID, name, otherName)
}
}
default:
return fmt.Errorf("invalid source: '%s'", pconf.Source)
}
if pconf.SourceOnDemand {
if pconf.Source == "publisher" {
return fmt.Errorf("'sourceOnDemand' is useless when source is 'publisher'")
}
}
if pconf.SRTReadPassphrase != "" {
err := srtCheckPassphrase(pconf.SRTReadPassphrase)
if err != nil {
return fmt.Errorf("invalid 'readRTPassphrase': %w", err)
}
}
if pconf.Fallback != "" {
if strings.HasPrefix(pconf.Fallback, "/") {
err := isValidPathName(pconf.Fallback[1:])
err := IsValidPathName(pconf.Fallback[1:])
if err != nil {
return fmt.Errorf("'%s': %w", pconf.Fallback, err)
return fmt.Errorf("'%s': %s", pconf.Fallback, err)
}
} else {
_, err := base.ParseURL(pconf.Fallback)
_, err := url.Parse(pconf.Fallback)
if err != nil {
return fmt.Errorf("'%s' is not a valid RTSP URL", pconf.Fallback)
}
}
}
// Authentication (deprecated)
if deprecatedCredentialsMode {
func() {
var user Credential = "any"
if credentialIsNotEmpty(pconf.PublishUser) {
user = *pconf.PublishUser
}
var pass Credential
if credentialIsNotEmpty(pconf.PublishPass) {
pass = *pconf.PublishPass
}
ips := IPNetworks{mustParseCIDR("0.0.0.0/0")}
if ipNetworkIsNotEmpty(pconf.PublishIPs) {
ips = *pconf.PublishIPs
}
pathName := name
if name == "all_others" || name == "all" {
pathName = "~^.*$"
}
conf.AuthInternalUsers = append(conf.AuthInternalUsers, AuthInternalUser{
User: user,
Pass: pass,
IPs: ips,
Permissions: []AuthInternalUserPermission{{
Action: AuthActionPublish,
Path: pathName,
}},
})
}()
func() {
var user Credential = "any"
if credentialIsNotEmpty(pconf.ReadUser) {
user = *pconf.ReadUser
}
var pass Credential
if credentialIsNotEmpty(pconf.ReadPass) {
pass = *pconf.ReadPass
}
ips := IPNetworks{mustParseCIDR("0.0.0.0/0")}
if ipNetworkIsNotEmpty(pconf.ReadIPs) {
ips = *pconf.ReadIPs
}
pathName := name
if name == "all_others" || name == "all" {
pathName = "~^.*$"
}
conf.AuthInternalUsers = append(conf.AuthInternalUsers, AuthInternalUser{
User: user,
Pass: pass,
IPs: ips,
Permissions: []AuthInternalUserPermission{{
Action: AuthActionRead,
Path: pathName,
}},
})
}()
if (pconf.PublishUser != "" && pconf.PublishPass == "") ||
(pconf.PublishUser == "" && pconf.PublishPass != "") {
return fmt.Errorf("read username and password must be both filled")
}
// Publisher source
if pconf.DisablePublisherOverride != nil {
pconf.OverridePublisher = !*pconf.DisablePublisherOverride
if pconf.PublishUser != "" && pconf.Source != "publisher" {
return fmt.Errorf("'publishUser' is useless when source is not 'publisher', since " +
"the stream is not provided by a publisher, but by a fixed source")
}
if pconf.SRTPublishPassphrase != "" {
if pconf.Source != "publisher" {
return fmt.Errorf("'srtPublishPassphase' can only be used when source is 'publisher'")
}
err := srtCheckPassphrase(pconf.SRTPublishPassphrase)
if err != nil {
return fmt.Errorf("invalid 'srtPublishPassphrase': %w", err)
}
if len(pconf.PublishIPs) > 0 && pconf.Source != "publisher" {
return fmt.Errorf("'publishIPs' is useless when source is not 'publisher', since " +
"the stream is not provided by a publisher, but by a fixed source")
}
// RTSP source
if pconf.SourceProtocol != nil {
pconf.RTSPTransport = *pconf.SourceProtocol
if (pconf.ReadUser != "" && pconf.ReadPass == "") ||
(pconf.ReadUser == "" && pconf.ReadPass != "") {
return fmt.Errorf("read username and password must be both filled")
}
if pconf.SourceAnyPortEnable != nil {
pconf.RTSPAnyPort = *pconf.SourceAnyPortEnable
}
// Redirect source
if pconf.Source == "redirect" {
if pconf.SourceRedirect == "" {
return fmt.Errorf("source redirect must be filled")
}
_, err := base.ParseURL(pconf.SourceRedirect)
if err != nil {
return fmt.Errorf("'%s' is not a valid RTSP URL", pconf.SourceRedirect)
if contains(conf.AuthMethods, headers.AuthDigest) {
if strings.HasPrefix(string(pconf.PublishUser), "sha256:") ||
strings.HasPrefix(string(pconf.PublishPass), "sha256:") ||
strings.HasPrefix(string(pconf.ReadUser), "sha256:") ||
strings.HasPrefix(string(pconf.ReadPass), "sha256:") {
return fmt.Errorf("hashed credentials can't be used when the digest auth method is available")
}
}
// Raspberry Pi Camera source
if pconf.Source == "rpiCamera" {
for otherName, otherPath := range conf.Paths {
if otherPath != pconf && otherPath != nil &&
otherPath.Source == "rpiCamera" && otherPath.RPICameraCamID == pconf.RPICameraCamID {
return fmt.Errorf("'rpiCamera' with same camera ID %d is used as source in two paths, '%s' and '%s'",
pconf.RPICameraCamID, name, otherName)
}
if conf.ExternalAuthenticationURL != "" {
if pconf.PublishUser != "" ||
len(pconf.PublishIPs) > 0 ||
pconf.ReadUser != "" ||
len(pconf.ReadIPs) > 0 {
return fmt.Errorf("credentials or IPs can't be used together with 'externalAuthenticationURL'")
}
}
switch pconf.RPICameraExposure {
case "normal", "short", "long", "custom":
default:
return fmt.Errorf("invalid 'rpiCameraExposure' value")
}
switch pconf.RPICameraAWB {
case "auto", "incandescent", "tungsten", "fluorescent", "indoor", "daylight", "cloudy", "custom":
default:
return fmt.Errorf("invalid 'rpiCameraAWB' value")
}
if len(pconf.RPICameraAWBGains) != 2 {
return fmt.Errorf("invalid 'rpiCameraAWBGains' value")
}
switch pconf.RPICameraDenoise {
case "off", "cdn_off", "cdn_fast", "cdn_hq":
default:
return fmt.Errorf("invalid 'rpiCameraDenoise' value")
}
switch pconf.RPICameraMetering {
case "centre", "spot", "matrix", "custom":
default:
return fmt.Errorf("invalid 'rpiCameraMetering' value")
}
switch pconf.RPICameraAfMode {
case "auto", "manual", "continuous":
default:
return fmt.Errorf("invalid 'rpiCameraAfMode' value")
}
switch pconf.RPICameraAfRange {
case "normal", "macro", "full":
default:
return fmt.Errorf("invalid 'rpiCameraAfRange' value")
}
switch pconf.RPICameraAfSpeed {
case "normal", "fast":
default:
return fmt.Errorf("invalid 'rpiCameraAfSpeed' value")
}
// Hooks
if pconf.RunOnInit != "" && pconf.Regexp != nil {
return fmt.Errorf("a path with a regular expression (or path 'all')" +
" does not support option 'runOnInit'; use another path")
return fmt.Errorf("a path with a regular expression does not support option 'runOnInit'; use another path")
}
if (pconf.RunOnDemand != "" || pconf.RunOnUnDemand != "") && pconf.Source != "publisher" {
return fmt.Errorf("'runOnDemand' and 'runOnUnDemand' can be used only when source is 'publisher'")
if pconf.RunOnDemand != "" && pconf.Source != "publisher" {
return fmt.Errorf("'runOnDemand' can be used only when source is 'publisher'")
}
return nil
}
// Equal checks whether two Paths are equal.
func (pconf *Path) Equal(other *Path) bool {
// Equal checks whether two PathConfs are equal.
func (pconf *PathConf) Equal(other *PathConf) bool {
return reflect.DeepEqual(pconf, other)
}
// Clone clones the configuration.
func (pconf PathConf) Clone() *PathConf {
enc, err := json.Marshal(pconf)
if err != nil {
panic(err)
}
var dest PathConf
err = json.Unmarshal(enc, &dest)
if err != nil {
panic(err)
}
return &dest
}
// HasStaticSource checks whether the path has a static source.
func (pconf Path) HasStaticSource() bool {
func (pconf PathConf) HasStaticSource() bool {
return strings.HasPrefix(pconf.Source, "rtsp://") ||
strings.HasPrefix(pconf.Source, "rtsps://") ||
strings.HasPrefix(pconf.Source, "rtmp://") ||
@ -561,18 +337,47 @@ func (pconf Path) HasStaticSource() bool { @@ -561,18 +337,47 @@ func (pconf Path) HasStaticSource() bool {
strings.HasPrefix(pconf.Source, "http://") ||
strings.HasPrefix(pconf.Source, "https://") ||
strings.HasPrefix(pconf.Source, "udp://") ||
strings.HasPrefix(pconf.Source, "srt://") ||
strings.HasPrefix(pconf.Source, "whep://") ||
strings.HasPrefix(pconf.Source, "wheps://") ||
pconf.Source == "rpiCamera"
}
// HasOnDemandStaticSource checks whether the path has a on demand static source.
func (pconf Path) HasOnDemandStaticSource() bool {
func (pconf PathConf) HasOnDemandStaticSource() bool {
return pconf.HasStaticSource() && pconf.SourceOnDemand
}
// HasOnDemandPublisher checks whether the path has a on-demand publisher.
func (pconf Path) HasOnDemandPublisher() bool {
func (pconf PathConf) HasOnDemandPublisher() bool {
return pconf.RunOnDemand != ""
}
// UnmarshalJSON implements json.Unmarshaler. It is used to set default values.
func (pconf *PathConf) UnmarshalJSON(b []byte) error {
// source
pconf.Source = "publisher"
// general
pconf.SourceOnDemandStartTimeout = 10 * StringDuration(time.Second)
pconf.SourceOnDemandCloseAfter = 10 * StringDuration(time.Second)
// raspberry pi camera
pconf.RPICameraWidth = 1920
pconf.RPICameraHeight = 1080
pconf.RPICameraContrast = 1
pconf.RPICameraSaturation = 1
pconf.RPICameraSharpness = 1
pconf.RPICameraFPS = 30
pconf.RPICameraIDRPeriod = 60
pconf.RPICameraBitrate = 1000000
pconf.RPICameraProfile = "main"
pconf.RPICameraLevel = "4.1"
pconf.RPICameraTextOverlay = "%Y-%m-%d %H:%M:%S - MediaMTX"
// external commands
pconf.RunOnDemandStartTimeout = 10 * StringDuration(time.Second)
pconf.RunOnDemandCloseAfter = 10 * StringDuration(time.Second)
type alias PathConf
d := json.NewDecoder(bytes.NewReader(b))
d.DisallowUnknownFields()
return d.Decode((*alias)(pconf))
}

13
internal/conf/protocol.go

@ -6,7 +6,7 @@ import ( @@ -6,7 +6,7 @@ import (
"sort"
"strings"
"github.com/bluenviron/gortsplib/v4"
"github.com/bluenviron/gortsplib/v3"
)
// Protocol is a RTSP transport.
@ -30,8 +30,11 @@ func (d Protocols) MarshalJSON() ([]byte, error) { @@ -30,8 +30,11 @@ func (d Protocols) MarshalJSON() ([]byte, error) {
case Protocol(gortsplib.TransportUDPMulticast):
v = "multicast"
default:
case Protocol(gortsplib.TransportTCP):
v = "tcp"
default:
return nil, fmt.Errorf("invalid protocol: %v", p)
}
out[i] = v
@ -71,8 +74,8 @@ func (d *Protocols) UnmarshalJSON(b []byte) error { @@ -71,8 +74,8 @@ func (d *Protocols) UnmarshalJSON(b []byte) error {
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *Protocols) UnmarshalEnv(_ string, v string) error {
byts, _ := json.Marshal(strings.Split(v, ","))
// UnmarshalEnv implements envUnmarshaler.
func (d *Protocols) UnmarshalEnv(s string) error {
byts, _ := json.Marshal(strings.Split(s, ","))
return d.UnmarshalJSON(byts)
}

56
internal/conf/record_format.go

@ -1,56 +0,0 @@ @@ -1,56 +0,0 @@
package conf
import (
"encoding/json"
"fmt"
)
// RecordFormat is the recordFormat parameter.
type RecordFormat int
// supported values.
const (
RecordFormatFMP4 RecordFormat = iota
RecordFormatMPEGTS
)
// MarshalJSON implements json.Marshaler.
func (d RecordFormat) MarshalJSON() ([]byte, error) {
var out string
switch d {
case RecordFormatMPEGTS:
out = "mpegts"
default:
out = "fmp4"
}
return json.Marshal(out)
}
// UnmarshalJSON implements json.Unmarshaler.
func (d *RecordFormat) UnmarshalJSON(b []byte) error {
var in string
if err := json.Unmarshal(b, &in); err != nil {
return err
}
switch in {
case "mpegts":
*d = RecordFormatMPEGTS
case "fmp4":
*d = RecordFormatFMP4
default:
return fmt.Errorf("invalid record format '%s'", in)
}
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *RecordFormat) UnmarshalEnv(_ string, v string) error {
return d.UnmarshalJSON([]byte(`"` + v + `"`))
}

63
internal/conf/rtsp_auth_methods.go

@ -1,63 +0,0 @@ @@ -1,63 +0,0 @@
package conf
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/bluenviron/gortsplib/v4/pkg/headers"
)
// RTSPAuthMethods is the rtspAuthMethods parameter.
type RTSPAuthMethods []headers.AuthMethod
// MarshalJSON implements json.Marshaler.
func (d RTSPAuthMethods) MarshalJSON() ([]byte, error) {
out := make([]string, len(d))
for i, v := range d {
switch v {
case headers.AuthBasic:
out[i] = "basic"
default:
out[i] = "digest"
}
}
sort.Strings(out)
return json.Marshal(out)
}
// UnmarshalJSON implements json.Unmarshaler.
func (d *RTSPAuthMethods) UnmarshalJSON(b []byte) error {
var in []string
if err := json.Unmarshal(b, &in); err != nil {
return err
}
*d = nil
for _, v := range in {
switch v {
case "basic":
*d = append(*d, headers.AuthBasic)
case "digest":
*d = append(*d, headers.AuthDigestMD5)
default:
return fmt.Errorf("invalid authentication method: '%s'", v)
}
}
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *RTSPAuthMethods) UnmarshalEnv(_ string, v string) error {
byts, _ := json.Marshal(strings.Split(v, ","))
return d.UnmarshalJSON(byts)
}

43
internal/conf/rtsp_range_type.go

@ -5,40 +5,43 @@ import ( @@ -5,40 +5,43 @@ import (
"fmt"
)
// RTSPRangeType is the type used in the Range header.
type RTSPRangeType int
// RtspRangeType is the type used in the Range header.
type RtspRangeType int
// supported values.
// supported rtsp range types.
const (
RTSPRangeTypeUndefined RTSPRangeType = iota
RTSPRangeTypeClock
RTSPRangeTypeNPT
RTSPRangeTypeSMPTE
RtspRangeTypeUndefined RtspRangeType = iota
RtspRangeTypeClock
RtspRangeTypeNPT
RtspRangeTypeSMPTE
)
// MarshalJSON implements json.Marshaler.
func (d RTSPRangeType) MarshalJSON() ([]byte, error) {
func (d RtspRangeType) MarshalJSON() ([]byte, error) {
var out string
switch d {
case RTSPRangeTypeClock:
case RtspRangeTypeClock:
out = "clock"
case RTSPRangeTypeNPT:
case RtspRangeTypeNPT:
out = "npt"
case RTSPRangeTypeSMPTE:
case RtspRangeTypeSMPTE:
out = "smpte"
default:
case RtspRangeTypeUndefined:
out = ""
default:
return nil, fmt.Errorf("invalid rtsp range type: %v", d)
}
return json.Marshal(out)
}
// UnmarshalJSON implements json.Unmarshaler.
func (d *RTSPRangeType) UnmarshalJSON(b []byte) error {
func (d *RtspRangeType) UnmarshalJSON(b []byte) error {
var in string
if err := json.Unmarshal(b, &in); err != nil {
return err
@ -46,16 +49,16 @@ func (d *RTSPRangeType) UnmarshalJSON(b []byte) error { @@ -46,16 +49,16 @@ func (d *RTSPRangeType) UnmarshalJSON(b []byte) error {
switch in {
case "clock":
*d = RTSPRangeTypeClock
*d = RtspRangeTypeClock
case "npt":
*d = RTSPRangeTypeNPT
*d = RtspRangeTypeNPT
case "smpte":
*d = RTSPRangeTypeSMPTE
*d = RtspRangeTypeSMPTE
case "":
*d = RTSPRangeTypeUndefined
*d = RtspRangeTypeUndefined
default:
return fmt.Errorf("invalid rtsp range type: '%s'", in)
@ -64,7 +67,7 @@ func (d *RTSPRangeType) UnmarshalJSON(b []byte) error { @@ -64,7 +67,7 @@ func (d *RTSPRangeType) UnmarshalJSON(b []byte) error {
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *RTSPRangeType) UnmarshalEnv(_ string, v string) error {
return d.UnmarshalJSON([]byte(`"` + v + `"`))
// UnmarshalEnv implements envUnmarshaler.
func (d *RtspRangeType) UnmarshalEnv(s string) error {
return d.UnmarshalJSON([]byte(`"` + s + `"`))
}

21
internal/conf/rtsp_transport.go → internal/conf/source_protocol.go

@ -4,16 +4,16 @@ import ( @@ -4,16 +4,16 @@ import (
"encoding/json"
"fmt"
"github.com/bluenviron/gortsplib/v4"
"github.com/bluenviron/gortsplib/v3"
)
// RTSPTransport is the rtspTransport parameter.
type RTSPTransport struct {
// SourceProtocol is the sourceProtocol parameter.
type SourceProtocol struct {
*gortsplib.Transport
}
// MarshalJSON implements json.Marshaler.
func (d RTSPTransport) MarshalJSON() ([]byte, error) {
func (d SourceProtocol) MarshalJSON() ([]byte, error) {
var out string
if d.Transport == nil {
@ -26,8 +26,11 @@ func (d RTSPTransport) MarshalJSON() ([]byte, error) { @@ -26,8 +26,11 @@ func (d RTSPTransport) MarshalJSON() ([]byte, error) {
case gortsplib.TransportUDPMulticast:
out = "multicast"
default:
case gortsplib.TransportTCP:
out = "tcp"
default:
return nil, fmt.Errorf("invalid protocol: %v", d.Transport)
}
}
@ -35,7 +38,7 @@ func (d RTSPTransport) MarshalJSON() ([]byte, error) { @@ -35,7 +38,7 @@ func (d RTSPTransport) MarshalJSON() ([]byte, error) {
}
// UnmarshalJSON implements json.Unmarshaler.
func (d *RTSPTransport) UnmarshalJSON(b []byte) error {
func (d *SourceProtocol) UnmarshalJSON(b []byte) error {
var in string
if err := json.Unmarshal(b, &in); err != nil {
return err
@ -64,7 +67,7 @@ func (d *RTSPTransport) UnmarshalJSON(b []byte) error { @@ -64,7 +67,7 @@ func (d *RTSPTransport) UnmarshalJSON(b []byte) error {
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *RTSPTransport) UnmarshalEnv(_ string, v string) error {
return d.UnmarshalJSON([]byte(`"` + v + `"`))
// UnmarshalEnv implements envUnmarshaler.
func (d *SourceProtocol) UnmarshalEnv(s string) error {
return d.UnmarshalJSON([]byte(`"` + s + `"`))
}

6
internal/conf/string_duration.go

@ -30,7 +30,7 @@ func (d *StringDuration) UnmarshalJSON(b []byte) error { @@ -30,7 +30,7 @@ func (d *StringDuration) UnmarshalJSON(b []byte) error {
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (d *StringDuration) UnmarshalEnv(_ string, v string) error {
return d.UnmarshalJSON([]byte(`"` + v + `"`))
// UnmarshalEnv implements envUnmarshaler.
func (d *StringDuration) UnmarshalEnv(s string) error {
return d.UnmarshalJSON([]byte(`"` + s + `"`))
}

6
internal/conf/string_size.go

@ -25,12 +25,12 @@ func (s *StringSize) UnmarshalJSON(b []byte) error { @@ -25,12 +25,12 @@ func (s *StringSize) UnmarshalJSON(b []byte) error {
if err != nil {
return err
}
*s = StringSize(v)
*s = StringSize(v)
return nil
}
// UnmarshalEnv implements env.Unmarshaler.
func (s *StringSize) UnmarshalEnv(_ string, v string) error {
// UnmarshalEnv implements envUnmarshaler.
func (s *StringSize) UnmarshalEnv(v string) error {
return s.UnmarshalJSON([]byte(`"` + v + `"`))
}

7
internal/conf/webrtc_ice_server.go

@ -2,8 +2,7 @@ package conf @@ -2,8 +2,7 @@ package conf
// WebRTCICEServer is a WebRTC ICE Server.
type WebRTCICEServer struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
ClientOnly bool `json:"clientOnly"`
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
}

14
internal/confwatcher/confwatcher.go

@ -30,7 +30,14 @@ type ConfWatcher struct { @@ -30,7 +30,14 @@ type ConfWatcher struct {
// New allocates a ConfWatcher.
func New(confPath string) (*ConfWatcher, error) {
if _, err := os.Stat(confPath); err != nil {
return nil, err
if confPath == "mediamtx.yml" {
confPath = "rtsp-simple-server.yml"
if _, err := os.Stat(confPath); err != nil {
return nil, err
}
} else {
return nil, err
}
}
inner, err := fsnotify.NewWatcher()
@ -44,7 +51,7 @@ func New(confPath string) (*ConfWatcher, error) { @@ -44,7 +51,7 @@ func New(confPath string) (*ConfWatcher, error) {
err = inner.Add(parentPath)
if err != nil {
inner.Close() //nolint:errcheck
inner.Close()
return nil, err
}
@ -83,7 +90,6 @@ outer: @@ -83,7 +90,6 @@ outer:
currentWatchedPath, _ := filepath.EvalSymlinks(w.watchedPath)
eventPath, _ := filepath.Abs(event.Name)
eventPath, _ = filepath.EvalSymlinks(eventPath)
if currentWatchedPath == "" {
// watched file was removed; wait for write event to trigger reload
@ -114,7 +120,7 @@ outer: @@ -114,7 +120,7 @@ outer:
}
close(w.signal)
w.inner.Close() //nolint:errcheck
w.inner.Close()
}
// Watch returns a channel that is called after the configuration file has changed.

24
internal/confwatcher/confwatcher_test.go

@ -5,17 +5,31 @@ import ( @@ -5,17 +5,31 @@ import (
"testing"
"time"
"github.com/bluenviron/mediamtx/internal/test"
"github.com/stretchr/testify/require"
)
func writeTempFile(byts []byte) (string, error) {
tmpf, err := os.CreateTemp(os.TempDir(), "confwatcher-")
if err != nil {
return "", err
}
defer tmpf.Close()
_, err = tmpf.Write(byts)
if err != nil {
return "", err
}
return tmpf.Name(), nil
}
func TestNoFile(t *testing.T) {
_, err := New("/nonexistent")
require.Error(t, err)
}
func TestWrite(t *testing.T) {
fpath, err := test.CreateTempFile([]byte("{}"))
fpath, err := writeTempFile([]byte("{}"))
require.NoError(t, err)
w, err := New(fpath)
@ -40,7 +54,7 @@ func TestWrite(t *testing.T) { @@ -40,7 +54,7 @@ func TestWrite(t *testing.T) {
}
func TestWriteMultipleTimes(t *testing.T) {
fpath, err := test.CreateTempFile([]byte("{}"))
fpath, err := writeTempFile([]byte("{}"))
require.NoError(t, err)
w, err := New(fpath)
@ -83,7 +97,7 @@ func TestWriteMultipleTimes(t *testing.T) { @@ -83,7 +97,7 @@ func TestWriteMultipleTimes(t *testing.T) {
}
func TestDeleteCreate(t *testing.T) {
fpath, err := test.CreateTempFile([]byte("{}"))
fpath, err := writeTempFile([]byte("{}"))
require.NoError(t, err)
w, err := New(fpath)
@ -111,7 +125,7 @@ func TestDeleteCreate(t *testing.T) { @@ -111,7 +125,7 @@ func TestDeleteCreate(t *testing.T) {
}
func TestSymlinkDeleteCreate(t *testing.T) {
fpath, err := test.CreateTempFile([]byte("{}"))
fpath, err := writeTempFile([]byte("{}"))
require.NoError(t, err)
err = os.Symlink(fpath, fpath+"-sym")

857
internal/core/api.go

@ -0,0 +1,857 @@ @@ -0,0 +1,857 @@
package core
import (
"encoding/json"
"errors"
"net/http"
"reflect"
"strconv"
"sync"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/logger"
)
var errAPINotFound = errors.New("not found")
func interfaceIsEmpty(i interface{}) bool {
return reflect.ValueOf(i).Kind() != reflect.Ptr || reflect.ValueOf(i).IsNil()
}
func fillStruct(dest interface{}, source interface{}) {
rvsource := reflect.ValueOf(source).Elem()
rvdest := reflect.ValueOf(dest)
nf := rvsource.NumField()
for i := 0; i < nf; i++ {
fnew := rvsource.Field(i)
if !fnew.IsNil() {
f := rvdest.Elem().FieldByName(rvsource.Type().Field(i).Name)
if f.Kind() == reflect.Ptr {
f.Set(fnew)
} else {
f.Set(fnew.Elem())
}
}
}
}
func generateStructWithOptionalFields(model interface{}) interface{} {
var fields []reflect.StructField
rt := reflect.TypeOf(model)
nf := rt.NumField()
for i := 0; i < nf; i++ {
f := rt.Field(i)
j := f.Tag.Get("json")
if j != "-" && j != "paths" {
fields = append(fields, reflect.StructField{
Name: f.Name,
Type: reflect.PtrTo(f.Type),
Tag: f.Tag,
})
}
}
return reflect.New(reflect.StructOf(fields)).Interface()
}
func loadConfData(ctx *gin.Context) (interface{}, error) {
in := generateStructWithOptionalFields(conf.Conf{})
err := json.NewDecoder(ctx.Request.Body).Decode(in)
if err != nil {
return nil, err
}
return in, err
}
func loadConfPathData(ctx *gin.Context) (interface{}, error) {
in := generateStructWithOptionalFields(conf.PathConf{})
err := json.NewDecoder(ctx.Request.Body).Decode(in)
if err != nil {
return nil, err
}
return in, err
}
func paginate2(itemsPtr interface{}, itemsPerPage int, page int) int {
ritems := reflect.ValueOf(itemsPtr).Elem()
itemsLen := ritems.Len()
if itemsLen == 0 {
return 0
}
pageCount := (itemsLen / itemsPerPage)
if (itemsLen % itemsPerPage) != 0 {
pageCount++
}
min := page * itemsPerPage
if min >= itemsLen {
min = itemsLen - 1
}
max := (page + 1) * itemsPerPage
if max >= itemsLen {
max = itemsLen
}
ritems.Set(ritems.Slice(min, max))
return pageCount
}
func paginate(itemsPtr interface{}, itemsPerPageStr string, pageStr string) (int, error) {
itemsPerPage := 100
if itemsPerPageStr != "" {
tmp, err := strconv.ParseUint(itemsPerPageStr, 10, 31)
if err != nil {
return 0, err
}
itemsPerPage = int(tmp)
}
page := 0
if pageStr != "" {
tmp, err := strconv.ParseUint(pageStr, 10, 31)
if err != nil {
return 0, err
}
page = int(tmp)
}
return paginate2(itemsPtr, itemsPerPage, page), nil
}
func abortWithError(ctx *gin.Context, err error) {
if err == errAPINotFound {
ctx.AbortWithStatus(http.StatusNotFound)
} else {
ctx.AbortWithStatus(http.StatusInternalServerError)
}
}
func paramName(ctx *gin.Context) (string, bool) {
name := ctx.Param("name")
if len(name) < 2 || name[0] != '/' {
return "", false
}
return name[1:], true
}
type apiPathManager interface {
apiPathsList() (*apiPathsList, error)
apiPathsGet(string) (*apiPath, error)
}
type apiHLSManager interface {
apiMuxersList() (*apiHLSMuxersList, error)
apiMuxersGet(string) (*apiHLSMuxer, error)
}
type apiRTSPServer interface {
apiConnsList() (*apiRTSPConnsList, error)
apiConnsGet(uuid.UUID) (*apiRTSPConn, error)
apiSessionsList() (*apiRTSPSessionsList, error)
apiSessionsGet(uuid.UUID) (*apiRTSPSession, error)
apiSessionsKick(uuid.UUID) error
}
type apiRTMPServer interface {
apiConnsList() (*apiRTMPConnsList, error)
apiConnsGet(uuid.UUID) (*apiRTMPConn, error)
apiConnsKick(uuid.UUID) error
}
type apiWebRTCManager interface {
apiSessionsList() (*apiWebRTCSessionsList, error)
apiSessionsGet(uuid.UUID) (*apiWebRTCSession, error)
apiSessionsKick(uuid.UUID) error
}
type apiParent interface {
logger.Writer
apiConfigSet(conf *conf.Conf)
}
type api struct {
conf *conf.Conf
pathManager apiPathManager
rtspServer apiRTSPServer
rtspsServer apiRTSPServer
rtmpServer apiRTMPServer
rtmpsServer apiRTMPServer
hlsManager apiHLSManager
webRTCManager apiWebRTCManager
parent apiParent
httpServer *httpServer
mutex sync.Mutex
}
func newAPI(
address string,
readTimeout conf.StringDuration,
conf *conf.Conf,
pathManager apiPathManager,
rtspServer apiRTSPServer,
rtspsServer apiRTSPServer,
rtmpServer apiRTMPServer,
rtmpsServer apiRTMPServer,
hlsManager apiHLSManager,
webRTCManager apiWebRTCManager,
parent apiParent,
) (*api, error) {
a := &api{
conf: conf,
pathManager: pathManager,
rtspServer: rtspServer,
rtspsServer: rtspsServer,
rtmpServer: rtmpServer,
rtmpsServer: rtmpsServer,
hlsManager: hlsManager,
webRTCManager: webRTCManager,
parent: parent,
}
router := gin.New()
router.SetTrustedProxies(nil)
mwLog := httpLoggerMiddleware(a)
router.NoRoute(mwLog, httpServerHeaderMiddleware)
group := router.Group("/", mwLog, httpServerHeaderMiddleware)
group.GET("/v2/config/get", a.onConfigGet)
group.POST("/v2/config/set", a.onConfigSet)
group.POST("/v2/config/paths/add/*name", a.onConfigPathsAdd)
group.POST("/v2/config/paths/edit/*name", a.onConfigPathsEdit)
group.POST("/v2/config/paths/remove/*name", a.onConfigPathsDelete)
if !interfaceIsEmpty(a.hlsManager) {
group.GET("/v2/hlsmuxers/list", a.onHLSMuxersList)
group.GET("/v2/hlsmuxers/get/*name", a.onHLSMuxersGet)
}
group.GET("/v2/paths/list", a.onPathsList)
group.GET("/v2/paths/get/*name", a.onPathsGet)
if !interfaceIsEmpty(a.rtspServer) {
group.GET("/v2/rtspconns/list", a.onRTSPConnsList)
group.GET("/v2/rtspconns/get/:id", a.onRTSPConnsGet)
group.GET("/v2/rtspsessions/list", a.onRTSPSessionsList)
group.GET("/v2/rtspsessions/get/:id", a.onRTSPSessionsGet)
group.POST("/v2/rtspsessions/kick/:id", a.onRTSPSessionsKick)
}
if !interfaceIsEmpty(a.rtspsServer) {
group.GET("/v2/rtspsconns/list", a.onRTSPSConnsList)
group.GET("/v2/rtspsconns/get/:id", a.onRTSPSConnsGet)
group.GET("/v2/rtspssessions/list", a.onRTSPSSessionsList)
group.GET("/v2/rtspssessions/get/:id", a.onRTSPSSessionsGet)
group.POST("/v2/rtspssessions/kick/:id", a.onRTSPSSessionsKick)
}
if !interfaceIsEmpty(a.rtmpServer) {
group.GET("/v2/rtmpconns/list", a.onRTMPConnsList)
group.GET("/v2/rtmpconns/get/:id", a.onRTMPConnsGet)
group.POST("/v2/rtmpconns/kick/:id", a.onRTMPConnsKick)
}
if !interfaceIsEmpty(a.rtmpsServer) {
group.GET("/v2/rtmpsconns/list", a.onRTMPSConnsList)
group.GET("/v2/rtmpsconns/get/:id", a.onRTMPSConnsGet)
group.POST("/v2/rtmpsconns/kick/:id", a.onRTMPSConnsKick)
}
if !interfaceIsEmpty(a.webRTCManager) {
group.GET("/v2/webrtcsessions/list", a.onWebRTCSessionsList)
group.GET("/v2/webrtcsessions/get/:id", a.onWebRTCSessionsGet)
group.POST("/v2/webrtcsessions/kick/:id", a.onWebRTCSessionsKick)
}
var err error
a.httpServer, err = newHTTPServer(
address,
readTimeout,
"",
"",
router,
)
if err != nil {
return nil, err
}
a.Log(logger.Info, "listener opened on "+address)
return a, nil
}
func (a *api) close() {
a.Log(logger.Info, "listener is closing")
a.httpServer.close()
}
func (a *api) Log(level logger.Level, format string, args ...interface{}) {
a.parent.Log(level, "[API] "+format, args...)
}
func (a *api) onConfigGet(ctx *gin.Context) {
a.mutex.Lock()
c := a.conf
a.mutex.Unlock()
ctx.JSON(http.StatusOK, c)
}
func (a *api) onConfigSet(ctx *gin.Context) {
in, err := loadConfData(ctx)
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
a.mutex.Lock()
defer a.mutex.Unlock()
newConf := a.conf.Clone()
fillStruct(newConf, in)
err = newConf.Check()
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
a.conf = newConf
// since reloading the configuration can cause the shutdown of the API,
// call it in a goroutine
go a.parent.apiConfigSet(newConf)
ctx.Status(http.StatusOK)
}
func (a *api) onConfigPathsAdd(ctx *gin.Context) {
name, ok := paramName(ctx)
if !ok {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
in, err := loadConfPathData(ctx)
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
a.mutex.Lock()
defer a.mutex.Unlock()
newConf := a.conf.Clone()
if _, ok := newConf.Paths[name]; ok {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
newConfPath := &conf.PathConf{}
// load default values
newConfPath.UnmarshalJSON([]byte("{}"))
fillStruct(newConfPath, in)
newConf.Paths[name] = newConfPath
err = newConf.Check()
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
a.conf = newConf
// since reloading the configuration can cause the shutdown of the API,
// call it in a goroutine
go a.parent.apiConfigSet(newConf)
ctx.Status(http.StatusOK)
}
func (a *api) onConfigPathsEdit(ctx *gin.Context) {
name, ok := paramName(ctx)
if !ok {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
in, err := loadConfPathData(ctx)
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
a.mutex.Lock()
defer a.mutex.Unlock()
newConf := a.conf.Clone()
newConfPath, ok := newConf.Paths[name]
if !ok {
ctx.AbortWithStatus(http.StatusNotFound)
return
}
fillStruct(newConfPath, in)
err = newConf.Check()
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
a.conf = newConf
// since reloading the configuration can cause the shutdown of the API,
// call it in a goroutine
go a.parent.apiConfigSet(newConf)
ctx.Status(http.StatusOK)
}
func (a *api) onConfigPathsDelete(ctx *gin.Context) {
name, ok := paramName(ctx)
if !ok {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
a.mutex.Lock()
defer a.mutex.Unlock()
if _, ok := a.conf.Paths[name]; !ok {
ctx.AbortWithStatus(http.StatusNotFound)
return
}
newConf := a.conf.Clone()
delete(newConf.Paths, name)
err := newConf.Check()
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
a.conf = newConf
// since reloading the configuration can cause the shutdown of the API,
// call it in a goroutine
go a.parent.apiConfigSet(newConf)
ctx.Status(http.StatusOK)
}
func (a *api) onPathsList(ctx *gin.Context) {
data, err := a.pathManager.apiPathsList()
if err != nil {
ctx.AbortWithStatus(http.StatusInternalServerError)
return
}
data.ItemCount = len(data.Items)
pageCount, err := paginate(&data.Items, ctx.Query("itemsPerPage"), ctx.Query("page"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data.PageCount = pageCount
ctx.JSON(http.StatusOK, data)
}
func (a *api) onPathsGet(ctx *gin.Context) {
name, ok := paramName(ctx)
if !ok {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data, err := a.pathManager.apiPathsGet(name)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.JSON(http.StatusOK, data)
}
func (a *api) onRTSPConnsList(ctx *gin.Context) {
data, err := a.rtspServer.apiConnsList()
if err != nil {
ctx.AbortWithStatus(http.StatusInternalServerError)
return
}
data.ItemCount = len(data.Items)
pageCount, err := paginate(&data.Items, ctx.Query("itemsPerPage"), ctx.Query("page"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data.PageCount = pageCount
ctx.JSON(http.StatusOK, data)
}
func (a *api) onRTSPConnsGet(ctx *gin.Context) {
uuid, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data, err := a.rtspServer.apiConnsGet(uuid)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.JSON(http.StatusOK, data)
}
func (a *api) onRTSPSessionsList(ctx *gin.Context) {
data, err := a.rtspServer.apiSessionsList()
if err != nil {
ctx.AbortWithStatus(http.StatusInternalServerError)
return
}
data.ItemCount = len(data.Items)
pageCount, err := paginate(&data.Items, ctx.Query("itemsPerPage"), ctx.Query("page"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data.PageCount = pageCount
ctx.JSON(http.StatusOK, data)
}
func (a *api) onRTSPSessionsGet(ctx *gin.Context) {
uuid, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data, err := a.rtspServer.apiSessionsGet(uuid)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.JSON(http.StatusOK, data)
}
func (a *api) onRTSPSessionsKick(ctx *gin.Context) {
uuid, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
err = a.rtspServer.apiSessionsKick(uuid)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.Status(http.StatusOK)
}
func (a *api) onRTSPSConnsList(ctx *gin.Context) {
data, err := a.rtspsServer.apiConnsList()
if err != nil {
ctx.AbortWithStatus(http.StatusInternalServerError)
return
}
data.ItemCount = len(data.Items)
pageCount, err := paginate(&data.Items, ctx.Query("itemsPerPage"), ctx.Query("page"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data.PageCount = pageCount
ctx.JSON(http.StatusOK, data)
}
func (a *api) onRTSPSConnsGet(ctx *gin.Context) {
uuid, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data, err := a.rtspsServer.apiConnsGet(uuid)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.JSON(http.StatusOK, data)
}
func (a *api) onRTSPSSessionsList(ctx *gin.Context) {
data, err := a.rtspsServer.apiSessionsList()
if err != nil {
ctx.AbortWithStatus(http.StatusInternalServerError)
return
}
data.ItemCount = len(data.Items)
pageCount, err := paginate(&data.Items, ctx.Query("itemsPerPage"), ctx.Query("page"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data.PageCount = pageCount
ctx.JSON(http.StatusOK, data)
}
func (a *api) onRTSPSSessionsGet(ctx *gin.Context) {
uuid, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data, err := a.rtspsServer.apiSessionsGet(uuid)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.JSON(http.StatusOK, data)
}
func (a *api) onRTSPSSessionsKick(ctx *gin.Context) {
uuid, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
err = a.rtspsServer.apiSessionsKick(uuid)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.Status(http.StatusOK)
}
func (a *api) onRTMPConnsList(ctx *gin.Context) {
data, err := a.rtmpServer.apiConnsList()
if err != nil {
ctx.AbortWithStatus(http.StatusInternalServerError)
return
}
data.ItemCount = len(data.Items)
pageCount, err := paginate(&data.Items, ctx.Query("itemsPerPage"), ctx.Query("page"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data.PageCount = pageCount
ctx.JSON(http.StatusOK, data)
}
func (a *api) onRTMPConnsGet(ctx *gin.Context) {
uuid, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data, err := a.rtmpServer.apiConnsGet(uuid)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.JSON(http.StatusOK, data)
}
func (a *api) onRTMPConnsKick(ctx *gin.Context) {
uuid, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
err = a.rtmpServer.apiConnsKick(uuid)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.Status(http.StatusOK)
}
func (a *api) onRTMPSConnsList(ctx *gin.Context) {
data, err := a.rtmpsServer.apiConnsList()
if err != nil {
ctx.AbortWithStatus(http.StatusInternalServerError)
return
}
data.ItemCount = len(data.Items)
pageCount, err := paginate(&data.Items, ctx.Query("itemsPerPage"), ctx.Query("page"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data.PageCount = pageCount
ctx.JSON(http.StatusOK, data)
}
func (a *api) onRTMPSConnsGet(ctx *gin.Context) {
uuid, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data, err := a.rtmpsServer.apiConnsGet(uuid)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.JSON(http.StatusOK, data)
}
func (a *api) onRTMPSConnsKick(ctx *gin.Context) {
uuid, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
err = a.rtmpsServer.apiConnsKick(uuid)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.Status(http.StatusOK)
}
func (a *api) onHLSMuxersList(ctx *gin.Context) {
data, err := a.hlsManager.apiMuxersList()
if err != nil {
ctx.AbortWithStatus(http.StatusInternalServerError)
return
}
data.ItemCount = len(data.Items)
pageCount, err := paginate(&data.Items, ctx.Query("itemsPerPage"), ctx.Query("page"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data.PageCount = pageCount
ctx.JSON(http.StatusOK, data)
}
func (a *api) onHLSMuxersGet(ctx *gin.Context) {
name, ok := paramName(ctx)
if !ok {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data, err := a.hlsManager.apiMuxersGet(name)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.JSON(http.StatusOK, data)
}
func (a *api) onWebRTCSessionsList(ctx *gin.Context) {
data, err := a.webRTCManager.apiSessionsList()
if err != nil {
ctx.AbortWithStatus(http.StatusInternalServerError)
return
}
data.ItemCount = len(data.Items)
pageCount, err := paginate(&data.Items, ctx.Query("itemsPerPage"), ctx.Query("page"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data.PageCount = pageCount
ctx.JSON(http.StatusOK, data)
}
func (a *api) onWebRTCSessionsGet(ctx *gin.Context) {
uuid, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
data, err := a.webRTCManager.apiSessionsGet(uuid)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.JSON(http.StatusOK, data)
}
func (a *api) onWebRTCSessionsKick(ctx *gin.Context) {
uuid, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
err = a.webRTCManager.apiSessionsKick(uuid)
if err != nil {
abortWithError(ctx, err)
return
}
ctx.Status(http.StatusOK)
}
// confReload is called by core.
func (a *api) confReload(conf *conf.Conf) {
a.mutex.Lock()
defer a.mutex.Unlock()
a.conf = conf
}

106
internal/core/api_defs.go

@ -0,0 +1,106 @@ @@ -0,0 +1,106 @@
package core
import (
"time"
"github.com/google/uuid"
"github.com/bluenviron/mediamtx/internal/conf"
)
type apiPath struct {
Name string `json:"name"`
ConfName string `json:"confName"`
Conf *conf.PathConf `json:"conf"`
Source interface{} `json:"source"`
SourceReady bool `json:"sourceReady"` // Deprecated: renamed to Ready
Ready bool `json:"ready"`
ReadyTime *time.Time `json:"readyTime"`
Tracks []string `json:"tracks"`
BytesReceived uint64 `json:"bytesReceived"`
Readers []interface{} `json:"readers"`
}
type apiPathsList struct {
ItemCount int `json:"itemCount"`
PageCount int `json:"pageCount"`
Items []*apiPath `json:"items"`
}
type apiHLSMuxer struct {
Path string `json:"path"`
Created time.Time `json:"created"`
LastRequest time.Time `json:"lastRequest"`
BytesSent uint64 `json:"bytesSent"`
}
type apiHLSMuxersList struct {
ItemCount int `json:"itemCount"`
PageCount int `json:"pageCount"`
Items []*apiHLSMuxer `json:"items"`
}
type apiRTSPConn struct {
ID uuid.UUID `json:"id"`
Created time.Time `json:"created"`
RemoteAddr string `json:"remoteAddr"`
BytesReceived uint64 `json:"bytesReceived"`
BytesSent uint64 `json:"bytesSent"`
}
type apiRTSPConnsList struct {
ItemCount int `json:"itemCount"`
PageCount int `json:"pageCount"`
Items []*apiRTSPConn `json:"items"`
}
type apiRTMPConn struct {
ID uuid.UUID `json:"id"`
Created time.Time `json:"created"`
RemoteAddr string `json:"remoteAddr"`
State string `json:"state"`
Path string `json:"path"`
BytesReceived uint64 `json:"bytesReceived"`
BytesSent uint64 `json:"bytesSent"`
}
type apiRTMPConnsList struct {
ItemCount int `json:"itemCount"`
PageCount int `json:"pageCount"`
Items []*apiRTMPConn `json:"items"`
}
type apiRTSPSession struct {
ID uuid.UUID `json:"id"`
Created time.Time `json:"created"`
RemoteAddr string `json:"remoteAddr"`
State string `json:"state"`
Path string `json:"path"`
BytesReceived uint64 `json:"bytesReceived"`
BytesSent uint64 `json:"bytesSent"`
}
type apiRTSPSessionsList struct {
ItemCount int `json:"itemCount"`
PageCount int `json:"pageCount"`
Items []*apiRTSPSession `json:"items"`
}
type apiWebRTCSession struct {
ID uuid.UUID `json:"id"`
Created time.Time `json:"created"`
RemoteAddr string `json:"remoteAddr"`
PeerConnectionEstablished bool `json:"peerConnectionEstablished"`
LocalCandidate string `json:"localCandidate"`
RemoteCandidate string `json:"remoteCandidate"`
State string `json:"state"`
Path string `json:"path"`
BytesReceived uint64 `json:"bytesReceived"`
BytesSent uint64 `json:"bytesSent"`
}
type apiWebRTCSessionsList struct {
ItemCount int `json:"itemCount"`
PageCount int `json:"pageCount"`
Items []*apiWebRTCSession `json:"items"`
}

1018
internal/core/api_test.go

File diff suppressed because it is too large Load Diff

184
internal/core/authentication.go

@ -0,0 +1,184 @@ @@ -0,0 +1,184 @@
package core
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"github.com/bluenviron/gortsplib/v3/pkg/auth"
"github.com/bluenviron/gortsplib/v3/pkg/base"
"github.com/bluenviron/gortsplib/v3/pkg/headers"
"github.com/bluenviron/gortsplib/v3/pkg/url"
"github.com/google/uuid"
"github.com/bluenviron/mediamtx/internal/conf"
)
func sha256Base64(in string) string {
h := sha256.New()
h.Write([]byte(in))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
func checkCredential(right string, guess string) bool {
if strings.HasPrefix(right, "sha256:") {
return right[len("sha256:"):] == sha256Base64(guess)
}
return right == guess
}
type authProtocol string
const (
authProtocolRTSP authProtocol = "rtsp"
authProtocolRTMP authProtocol = "rtmp"
authProtocolHLS authProtocol = "hls"
authProtocolWebRTC authProtocol = "webrtc"
)
func externalAuth(
ur string,
ip string,
user string,
password string,
path string,
protocol authProtocol,
id *uuid.UUID,
publish bool,
query string,
) error {
enc, _ := json.Marshal(struct {
IP string `json:"ip"`
User string `json:"user"`
Password string `json:"password"`
Path string `json:"path"`
Protocol string `json:"protocol"`
ID *uuid.UUID `json:"id"`
Action string `json:"action"`
Query string `json:"query"`
}{
IP: ip,
User: user,
Password: password,
Path: path,
Protocol: string(protocol),
ID: id,
Action: func() string {
if publish {
return "publish"
}
return "read"
}(),
Query: query,
})
res, err := http.Post(ur, "application/json", bytes.NewReader(enc))
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode > 299 {
if resBody, err := io.ReadAll(res.Body); err == nil && len(resBody) != 0 {
return fmt.Errorf("external authentication replied with code %d: %s", res.StatusCode, string(resBody))
}
return fmt.Errorf("external authentication replied with code %d", res.StatusCode)
}
return nil
}
type authCredentials struct {
query string
ip net.IP
user string
pass string
proto authProtocol
id *uuid.UUID
rtspRequest *base.Request
rtspBaseURL *url.URL
rtspNonce string
}
func authenticate(
externalAuthenticationURL string,
rtspAuthMethods conf.AuthMethods,
pathName string,
pathConf *conf.PathConf,
publish bool,
credentials authCredentials,
) error {
var rtspAuth headers.Authorization
if credentials.rtspRequest != nil {
err := rtspAuth.Unmarshal(credentials.rtspRequest.Header["Authorization"])
if err == nil && rtspAuth.Method == headers.AuthBasic {
credentials.user = rtspAuth.BasicUser
credentials.pass = rtspAuth.BasicPass
}
}
if externalAuthenticationURL != "" {
err := externalAuth(
externalAuthenticationURL,
credentials.ip.String(),
credentials.user,
credentials.pass,
pathName,
credentials.proto,
credentials.id,
publish,
credentials.query,
)
if err != nil {
return fmt.Errorf("external authentication failed: %s", err)
}
}
var pathIPs conf.IPsOrCIDRs
var pathUser string
var pathPass string
if publish {
pathIPs = pathConf.PublishIPs
pathUser = string(pathConf.PublishUser)
pathPass = string(pathConf.PublishPass)
} else {
pathIPs = pathConf.ReadIPs
pathUser = string(pathConf.ReadUser)
pathPass = string(pathConf.ReadPass)
}
if pathIPs != nil {
if !ipEqualOrInRange(credentials.ip, pathIPs) {
return fmt.Errorf("IP '%s' not allowed", credentials.ip)
}
}
if pathUser != "" {
if credentials.rtspRequest != nil && rtspAuth.Method == headers.AuthDigest {
err := auth.Validate(
credentials.rtspRequest,
pathUser,
pathPass,
credentials.rtspBaseURL,
rtspAuthMethods,
"IPCAM",
credentials.rtspNonce)
if err != nil {
return err
}
} else if !checkCredential(pathUser, credentials.user) ||
!checkCredential(pathPass, credentials.pass) {
return fmt.Errorf("invalid credentials")
}
}
return nil
}

858
internal/core/core.go

File diff suppressed because it is too large Load Diff

407
internal/core/core_test.go

@ -1,23 +1,97 @@ @@ -1,23 +1,97 @@
package core
import (
"bufio"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/bluenviron/gortsplib/v4"
"github.com/bluenviron/gortsplib/v4/pkg/description"
"github.com/bluenviron/mediamtx/internal/test"
"github.com/bluenviron/gortsplib/v3"
"github.com/bluenviron/gortsplib/v3/pkg/base"
"github.com/bluenviron/gortsplib/v3/pkg/headers"
"github.com/bluenviron/gortsplib/v3/pkg/media"
"github.com/bluenviron/gortsplib/v3/pkg/sdp"
"github.com/bluenviron/gortsplib/v3/pkg/url"
"github.com/stretchr/testify/require"
)
var serverCert = []byte(`-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUXw1hEC3LFpTsllv7D3ARJyEq7sIwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMDEyMTMxNzQ0NThaFw0zMDEy
MTExNzQ0NThaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQDG8DyyS51810GsGwgWr5rjJK7OE1kTTLSNEEKax8Bj
zOyiaz8rA2JGl2VUEpi2UjDr9Cm7nd+YIEVs91IIBOb7LGqObBh1kGF3u5aZxLkv
NJE+HrLVvUhaDobK2NU+Wibqc/EI3DfUkt1rSINvv9flwTFu1qHeuLWhoySzDKEp
OzYxpFhwjVSokZIjT4Red3OtFz7gl2E6OAWe2qoh5CwLYVdMWtKR0Xuw3BkDPk9I
qkQKx3fqv97LPEzhyZYjDT5WvGrgZ1WDAN3booxXF3oA1H3GHQc4m/vcLatOtb8e
nI59gMQLEbnp08cl873bAuNuM95EZieXTHNbwUnq5iybAgMBAAGjUzBRMB0GA1Ud
DgQWBBQBKhJh8eWu0a4au9X/2fKhkFX2vjAfBgNVHSMEGDAWgBQBKhJh8eWu0a4a
u9X/2fKhkFX2vjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBj
3aCW0YPKukYgVK9cwN0IbVy/D0C1UPT4nupJcy/E0iC7MXPZ9D/SZxYQoAkdptdO
xfI+RXkpQZLdODNx9uvV+cHyZHZyjtE5ENu/i5Rer2cWI/mSLZm5lUQyx+0KZ2Yu
tEI1bsebDK30msa8QSTn0WidW9XhFnl3gRi4wRdimcQapOWYVs7ih+nAlSvng7NI
XpAyRs8PIEbpDDBMWnldrX4TP6EWYUi49gCp8OUDRREKX3l6Ls1vZ02F34yHIt/7
7IV/XSKG096bhW+icKBWV0IpcEsgTzPK1J1hMxgjhzIMxGboAeUU+kidthOob6Sd
XQxaORfgM//NzX9LhUPk
-----END CERTIFICATE-----
`)
var serverKey = []byte(`-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAxvA8skudfNdBrBsIFq+a4ySuzhNZE0y0jRBCmsfAY8zsoms/
KwNiRpdlVBKYtlIw6/Qpu53fmCBFbPdSCATm+yxqjmwYdZBhd7uWmcS5LzSRPh6y
1b1IWg6GytjVPlom6nPxCNw31JLda0iDb7/X5cExbtah3ri1oaMkswyhKTs2MaRY
cI1UqJGSI0+EXndzrRc+4JdhOjgFntqqIeQsC2FXTFrSkdF7sNwZAz5PSKpECsd3
6r/eyzxM4cmWIw0+Vrxq4GdVgwDd26KMVxd6ANR9xh0HOJv73C2rTrW/HpyOfYDE
CxG56dPHJfO92wLjbjPeRGYnl0xzW8FJ6uYsmwIDAQABAoIBACi0BKcyQ3HElSJC
kaAao+Uvnzh4yvPg8Nwf5JDIp/uDdTMyIEWLtrLczRWrjGVZYbsVROinP5VfnPTT
kYwkfKINj2u+gC6lsNuPnRuvHXikF8eO/mYvCTur1zZvsQnF5kp4GGwIqr+qoPUP
bB0UMndG1PdpoMryHe+JcrvTrLHDmCeH10TqOwMsQMLHYLkowvxwJWsmTY7/Qr5S
Wm3PPpOcW2i0uyPVuyuv4yD1368fqnqJ8QFsQp1K6QtYsNnJ71Hut1/IoxK/e6hj
5Z+byKtHVtmcLnABuoOT7BhleJNFBksX9sh83jid4tMBgci+zXNeGmgqo2EmaWAb
agQslkECgYEA8B1rzjOHVQx/vwSzDa4XOrpoHQRfyElrGNz9JVBvnoC7AorezBXQ
M9WTHQIFTGMjzD8pb+YJGi3gj93VN51r0SmJRxBaBRh1ZZI9kFiFzngYev8POgD3
ygmlS3kTHCNxCK/CJkB+/jMBgtPj5ygDpCWVcTSuWlQFphePkW7jaaECgYEA1Blz
ulqgAyJHZaqgcbcCsI2q6m527hVr9pjzNjIVmkwu38yS9RTCgdlbEVVDnS0hoifl
+jVMEGXjF3xjyMvL50BKbQUH+KAa+V4n1WGlnZOxX9TMny8MBjEuSX2+362vQ3BX
4vOlX00gvoc+sY+lrzvfx/OdPCHQGVYzoKCxhLsCgYA07HcviuIAV/HsO2/vyvhp
xF5gTu+BqNUHNOZDDDid+ge+Jre2yfQLCL8VPLXIQW3Jff53IH/PGl+NtjphuLvj
7UDJvgvpZZuymIojP6+2c3gJ3CASC9aR3JBnUzdoE1O9s2eaoMqc4scpe+SWtZYf
3vzSZ+cqF6zrD/Rf/M35IQKBgHTU4E6ShPm09CcoaeC5sp2WK8OevZw/6IyZi78a
r5Oiy18zzO97U/k6xVMy6F+38ILl/2Rn31JZDVJujniY6eSkIVsUHmPxrWoXV1HO
y++U32uuSFiXDcSLarfIsE992MEJLSAynbF1Rsgsr3gXbGiuToJRyxbIeVy7gwzD
94TpAoGAY4/PejWQj9psZfAhyk5dRGra++gYRQ/gK1IIc1g+Dd2/BxbT/RHr05GK
6vwrfjsoRyMWteC1SsNs/CurjfQ/jqCfHNP5XPvxgd5Ec8sRJIiV7V5RTuWJsPu1
+3K6cnKEyg+0ekYmLertRFIY6SwWmY1fyKgTvxudMcsBY7dC4xs=
-----END RSA PRIVATE KEY-----
`)
func writeTempFile(byts []byte) (string, error) {
tmpf, err := os.CreateTemp(os.TempDir(), "rtsp-")
if err != nil {
return "", err
}
defer tmpf.Close()
_, err = tmpf.Write(byts)
if err != nil {
return "", err
}
return tmpf.Name(), nil
}
func newInstance(conf string) (*Core, bool) {
if conf == "" {
return New([]string{})
}
tmpf, err := test.CreateTempFile([]byte(conf))
tmpf, err := writeTempFile([]byte(conf))
if err != nil {
return nil, false
}
@ -26,74 +100,261 @@ func newInstance(conf string) (*Core, bool) { @@ -26,74 +100,261 @@ func newInstance(conf string) (*Core, bool) {
return New([]string{tmpf})
}
func TestCoreErrors(t *testing.T) {
for _, ca := range []struct {
name string
conf string
}{
{
"logger",
"logDestinations: [file]\n" +
"logFile: /nonexisting/nonexist\n",
},
{
"metrics",
"metrics: yes\n" +
"metricsAddress: invalid\n",
},
{
"pprof",
"pprof: yes\n" +
"pprofAddress: invalid\n",
},
{
"playback",
"playback: yes\n" +
"playbackAddress: invalid\n",
},
{
"rtsp",
"rtspAddress: invalid\n",
},
{
"rtsps",
"encryption: strict\n" +
"rtspAddress: invalid\n",
},
{
"rtmp",
"rtmpAddress: invalid\n",
},
{
"rtmps",
"rtmpEncryption: strict\n" +
"rtmpAddress: invalid\n",
},
{
"hls",
"hlsAddress: invalid\n",
},
{
"webrtc",
"webrtcAddress: invalid\n",
},
{
"srt",
"srtAddress: invalid\n",
},
{
"api",
"api: yes\n" +
"apiAddress: invalid\n",
},
} {
t.Run(ca.name, func(t *testing.T) {
_, ok := newInstance(ca.conf)
require.Equal(t, false, ok)
func TestCorePathAutoDeletion(t *testing.T) {
for _, ca := range []string{"describe", "setup"} {
t.Run(ca, func(t *testing.T) {
p, ok := newInstance("paths:\n" +
" all:\n")
require.Equal(t, true, ok)
defer p.Close()
func() {
conn, err := net.Dial("tcp", "localhost:8554")
require.NoError(t, err)
defer conn.Close()
br := bufio.NewReader(conn)
if ca == "describe" {
u, err := url.Parse("rtsp://localhost:8554/mypath")
require.NoError(t, err)
byts, _ := base.Request{
Method: base.Describe,
URL: u,
Header: base.Header{
"CSeq": base.HeaderValue{"1"},
},
}.Marshal()
_, err = conn.Write(byts)
require.NoError(t, err)
var res base.Response
err = res.Unmarshal(br)
require.NoError(t, err)
require.Equal(t, base.StatusNotFound, res.StatusCode)
} else {
u, err := url.Parse("rtsp://localhost:8554/mypath/trackID=0")
require.NoError(t, err)
byts, _ := base.Request{
Method: base.Setup,
URL: u,
Header: base.Header{
"CSeq": base.HeaderValue{"1"},
"Transport": headers.Transport{
Mode: func() *headers.TransportMode {
v := headers.TransportModePlay
return &v
}(),
Delivery: func() *headers.TransportDelivery {
v := headers.TransportDeliveryUnicast
return &v
}(),
Protocol: headers.TransportProtocolUDP,
ClientPorts: &[2]int{35466, 35467},
}.Marshal(),
},
}.Marshal()
_, err = conn.Write(byts)
require.NoError(t, err)
var res base.Response
err = res.Unmarshal(br)
require.NoError(t, err)
require.Equal(t, base.StatusNotFound, res.StatusCode)
}
}()
data, err := p.pathManager.apiPathsList()
require.NoError(t, err)
require.Equal(t, 0, len(data.Items))
})
}
}
func TestCorePathRunOnDemand(t *testing.T) {
doneFile := filepath.Join(os.TempDir(), "ondemand_done")
srcFile := filepath.Join(os.TempDir(), "ondemand.go")
err := os.WriteFile(srcFile, []byte(`
package main
import (
"os"
"os/signal"
"syscall"
"github.com/bluenviron/gortsplib/v3"
"github.com/bluenviron/gortsplib/v3/pkg/media"
"github.com/bluenviron/gortsplib/v3/pkg/formats"
)
func main() {
if os.Getenv("G1") != "on" {
panic("environment not set")
}
medi := &media.Media{
Type: media.TypeVideo,
Formats: []formats.Format{&formats.H264{
PayloadTyp: 96,
SPS: []byte{0x01, 0x02, 0x03, 0x04},
PPS: []byte{0x01, 0x02, 0x03, 0x04},
PacketizationMode: 1,
}},
}
source := gortsplib.Client{}
err := source.StartRecording(
"rtsp://localhost:" + os.Getenv("RTSP_PORT") + "/" + os.Getenv("MTX_PATH"),
media.Medias{medi})
if err != nil {
panic(err)
}
defer source.Close()
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT)
<-c
err = os.WriteFile("`+doneFile+`", []byte(""), 0644)
if err != nil {
panic(err)
}
}
`), 0o644)
require.NoError(t, err)
execFile := filepath.Join(os.TempDir(), "ondemand_cmd")
cmd := exec.Command("go", "build", "-o", execFile, srcFile)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
require.NoError(t, err)
defer os.Remove(execFile)
os.Remove(srcFile)
for _, ca := range []string{"describe", "setup", "describe and setup"} {
t.Run(ca, func(t *testing.T) {
defer os.Remove(doneFile)
p1, ok := newInstance(fmt.Sprintf("rtmpDisable: yes\n"+
"hlsDisable: yes\n"+
"webrtcDisable: yes\n"+
"paths:\n"+
" '~^(on)demand$':\n"+
" runOnDemand: %s\n"+
" runOnDemandCloseAfter: 1s\n", execFile))
require.Equal(t, true, ok)
defer p1.Close()
var control string
func() {
conn, err := net.Dial("tcp", "localhost:8554")
require.NoError(t, err)
defer conn.Close()
br := bufio.NewReader(conn)
if ca == "describe" || ca == "describe and setup" {
u, err := url.Parse("rtsp://localhost:8554/ondemand")
require.NoError(t, err)
byts, _ := base.Request{
Method: base.Describe,
URL: u,
Header: base.Header{
"CSeq": base.HeaderValue{"1"},
},
}.Marshal()
_, err = conn.Write(byts)
require.NoError(t, err)
var res base.Response
err = res.Unmarshal(br)
require.NoError(t, err)
require.Equal(t, base.StatusOK, res.StatusCode)
var desc sdp.SessionDescription
err = desc.Unmarshal(res.Body)
require.NoError(t, err)
control, _ = desc.MediaDescriptions[0].Attribute("control")
} else {
control = "rtsp://localhost:8554/ondemand/"
}
if ca == "setup" || ca == "describe and setup" {
u, err := url.Parse(control)
require.NoError(t, err)
byts, _ := base.Request{
Method: base.Setup,
URL: u,
Header: base.Header{
"CSeq": base.HeaderValue{"2"},
"Transport": headers.Transport{
Mode: func() *headers.TransportMode {
v := headers.TransportModePlay
return &v
}(),
Protocol: headers.TransportProtocolTCP,
InterleavedIDs: &[2]int{0, 1},
}.Marshal(),
},
}.Marshal()
_, err = conn.Write(byts)
require.NoError(t, err)
var res base.Response
err = res.Unmarshal(br)
require.NoError(t, err)
require.Equal(t, base.StatusOK, res.StatusCode)
}
}()
for {
_, err := os.Stat(doneFile)
if err == nil {
break
}
time.Sleep(100 * time.Millisecond)
}
})
}
}
func TestCorePathRunOnReady(t *testing.T) {
doneFile := filepath.Join(os.TempDir(), "onready_done")
defer os.Remove(doneFile)
p, ok := newInstance(fmt.Sprintf("rtmpDisable: yes\n"+
"hlsDisable: yes\n"+
"webrtcDisable: yes\n"+
"paths:\n"+
" test:\n"+
" runOnReady: touch %s\n",
doneFile))
require.Equal(t, true, ok)
defer p.Close()
medi := testMediaH264
c := gortsplib.Client{}
err := c.StartRecording(
"rtsp://localhost:8554/test",
media.Medias{medi})
require.NoError(t, err)
defer c.Close()
time.Sleep(1 * time.Second)
_, err = os.Stat(doneFile)
require.NoError(t, err)
}
func TestCoreHotReloading(t *testing.T) {
confPath := filepath.Join(os.TempDir(), "rtsp-conf")
@ -110,9 +371,10 @@ func TestCoreHotReloading(t *testing.T) { @@ -110,9 +371,10 @@ func TestCoreHotReloading(t *testing.T) {
defer p.Close()
func() {
medi := testMediaH264
c := gortsplib.Client{}
err = c.StartRecording("rtsp://localhost:8554/test1",
&description.Session{Medias: []*description.Media{test.UniqueMediaH264()}})
err = c.StartRecording("rtsp://localhost:8554/test1", media.Medias{medi})
require.EqualError(t, err, "bad status code: 401 (Unauthorized)")
}()
@ -124,9 +386,10 @@ func TestCoreHotReloading(t *testing.T) { @@ -124,9 +386,10 @@ func TestCoreHotReloading(t *testing.T) {
time.Sleep(1 * time.Second)
func() {
medi := testMediaH264
conn := gortsplib.Client{}
err = conn.StartRecording("rtsp://localhost:8554/test1",
&description.Session{Medias: []*description.Media{test.UniqueMediaH264()}})
err = conn.StartRecording("rtsp://localhost:8554/test1", media.Medias{medi})
require.NoError(t, err)
defer conn.Close()
}()

186
internal/core/hls_http_server.go

@ -0,0 +1,186 @@ @@ -0,0 +1,186 @@
package core
import (
_ "embed"
"fmt"
"net"
"net/http"
gopath "path"
"strings"
"github.com/gin-gonic/gin"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/logger"
)
//go:embed hls_index.html
var hlsIndex []byte
type hlsHTTPServerParent interface {
logger.Writer
handleRequest(req hlsMuxerHandleRequestReq)
}
type hlsHTTPServer struct {
allowOrigin string
pathManager *pathManager
parent hlsHTTPServerParent
inner *httpServer
}
func newHLSHTTPServer( //nolint:dupl
address string,
encryption bool,
serverKey string,
serverCert string,
allowOrigin string,
trustedProxies conf.IPsOrCIDRs,
readTimeout conf.StringDuration,
pathManager *pathManager,
parent hlsHTTPServerParent,
) (*hlsHTTPServer, error) {
if encryption {
if serverCert == "" {
return nil, fmt.Errorf("server cert is missing")
}
} else {
serverKey = ""
serverCert = ""
}
s := &hlsHTTPServer{
allowOrigin: allowOrigin,
pathManager: pathManager,
parent: parent,
}
router := gin.New()
httpSetTrustedProxies(router, trustedProxies)
router.NoRoute(httpLoggerMiddleware(s), httpServerHeaderMiddleware, s.onRequest)
var err error
s.inner, err = newHTTPServer(
address,
readTimeout,
serverCert,
serverKey,
router,
)
if err != nil {
return nil, err
}
return s, nil
}
func (s *hlsHTTPServer) Log(level logger.Level, format string, args ...interface{}) {
s.parent.Log(level, format, args...)
}
func (s *hlsHTTPServer) close() {
s.inner.close()
}
func (s *hlsHTTPServer) onRequest(ctx *gin.Context) {
ctx.Writer.Header().Set("Access-Control-Allow-Origin", s.allowOrigin)
ctx.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
switch ctx.Request.Method {
case http.MethodOptions:
ctx.Writer.Header().Set("Access-Control-Allow-Methods", "OPTIONS, GET")
ctx.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization, Range")
ctx.Writer.WriteHeader(http.StatusOK)
return
case http.MethodGet:
default:
return
}
// remove leading prefix
pa := ctx.Request.URL.Path[1:]
var dir string
var fname string
switch {
case pa == "", pa == "favicon.ico":
return
case strings.HasSuffix(pa, ".m3u8") ||
strings.HasSuffix(pa, ".ts") ||
strings.HasSuffix(pa, ".mp4") ||
strings.HasSuffix(pa, ".mp"):
dir, fname = gopath.Dir(pa), gopath.Base(pa)
if strings.HasSuffix(fname, ".mp") {
fname += "4"
}
default:
dir, fname = pa, ""
if !strings.HasSuffix(dir, "/") {
l := "/" + dir + "/"
if ctx.Request.URL.RawQuery != "" {
l += "?" + ctx.Request.URL.RawQuery
}
ctx.Writer.Header().Set("Location", l)
ctx.Writer.WriteHeader(http.StatusMovedPermanently)
return
}
}
dir = strings.TrimSuffix(dir, "/")
if dir == "" {
return
}
user, pass, hasCredentials := ctx.Request.BasicAuth()
res := s.pathManager.getConfForPath(pathGetConfForPathReq{
name: dir,
publish: false,
credentials: authCredentials{
query: ctx.Request.URL.RawQuery,
ip: net.ParseIP(ctx.ClientIP()),
user: user,
pass: pass,
proto: authProtocolWebRTC,
},
})
if res.err != nil {
if terr, ok := res.err.(pathErrAuth); ok {
if !hasCredentials {
ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`)
ctx.Writer.WriteHeader(http.StatusUnauthorized)
return
}
s.Log(logger.Info, "authentication error: %v", terr.wrapped)
ctx.Writer.WriteHeader(http.StatusUnauthorized)
return
}
ctx.Writer.WriteHeader(http.StatusNotFound)
return
}
switch fname {
case "":
ctx.Writer.Header().Set("Content-Type", "text/html")
ctx.Writer.WriteHeader(http.StatusOK)
ctx.Writer.Write(hlsIndex)
default:
s.parent.handleRequest(hlsMuxerHandleRequestReq{
path: dir,
file: fname,
ctx: ctx,
})
}
}

68
internal/core/hls_index.html

@ -0,0 +1,68 @@ @@ -0,0 +1,68 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
#video {
width: 100%;
height: 100%;
background: black;
}
</style>
</head>
<body>
<video id="video" muted controls autoplay playsinline></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.4.3"></script>
<script>
const create = () => {
const video = document.getElementById('video');
// always prefer hls.js over native HLS.
// this is because some Android versions support native HLS
// but don't support fMP4s.
if (Hls.isSupported()) {
const hls = new Hls({
maxLiveSyncPlaybackRate: 1.5,
});
hls.on(Hls.Events.ERROR, (evt, data) => {
if (data.fatal) {
hls.destroy();
setTimeout(create, 2000);
}
});
hls.loadSource('index.m3u8' + window.location.search);
hls.attachMedia(video);
video.play();
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// since it's not possible to detect timeout errors in iOS,
// wait for the playlist to be available before starting the stream
fetch('index.m3u8')
.then(() => {
video.src = 'index.m3u8';
video.play();
});
}
};
window.addEventListener('DOMContentLoaded', create);
</script>
</body>
</html>

323
internal/core/hls_manager.go

@ -0,0 +1,323 @@ @@ -0,0 +1,323 @@
package core
import (
"context"
"fmt"
"sort"
"sync"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/logger"
)
type hlsManagerAPIMuxersListRes struct {
data *apiHLSMuxersList
err error
}
type hlsManagerAPIMuxersListReq struct {
res chan hlsManagerAPIMuxersListRes
}
type hlsManagerAPIMuxersGetRes struct {
data *apiHLSMuxer
err error
}
type hlsManagerAPIMuxersGetReq struct {
name string
res chan hlsManagerAPIMuxersGetRes
}
type hlsManagerParent interface {
logger.Writer
}
type hlsManager struct {
externalAuthenticationURL string
alwaysRemux bool
variant conf.HLSVariant
segmentCount int
segmentDuration conf.StringDuration
partDuration conf.StringDuration
segmentMaxSize conf.StringSize
directory string
readBufferCount int
pathManager *pathManager
metrics *metrics
parent hlsManagerParent
ctx context.Context
ctxCancel func()
wg sync.WaitGroup
httpServer *hlsHTTPServer
muxers map[string]*hlsMuxer
// in
chPathReady chan *path
chPathNotReady chan *path
chHandleRequest chan hlsMuxerHandleRequestReq
chMuxerClose chan *hlsMuxer
chAPIMuxerList chan hlsManagerAPIMuxersListReq
chAPIMuxerGet chan hlsManagerAPIMuxersGetReq
}
func newHLSManager(
address string,
encryption bool,
serverKey string,
serverCert string,
externalAuthenticationURL string,
alwaysRemux bool,
variant conf.HLSVariant,
segmentCount int,
segmentDuration conf.StringDuration,
partDuration conf.StringDuration,
segmentMaxSize conf.StringSize,
allowOrigin string,
trustedProxies conf.IPsOrCIDRs,
directory string,
readTimeout conf.StringDuration,
readBufferCount int,
pathManager *pathManager,
metrics *metrics,
parent hlsManagerParent,
) (*hlsManager, error) {
ctx, ctxCancel := context.WithCancel(context.Background())
m := &hlsManager{
externalAuthenticationURL: externalAuthenticationURL,
alwaysRemux: alwaysRemux,
variant: variant,
segmentCount: segmentCount,
segmentDuration: segmentDuration,
partDuration: partDuration,
segmentMaxSize: segmentMaxSize,
directory: directory,
readBufferCount: readBufferCount,
pathManager: pathManager,
parent: parent,
metrics: metrics,
ctx: ctx,
ctxCancel: ctxCancel,
muxers: make(map[string]*hlsMuxer),
chPathReady: make(chan *path),
chPathNotReady: make(chan *path),
chHandleRequest: make(chan hlsMuxerHandleRequestReq),
chMuxerClose: make(chan *hlsMuxer),
chAPIMuxerList: make(chan hlsManagerAPIMuxersListReq),
chAPIMuxerGet: make(chan hlsManagerAPIMuxersGetReq),
}
var err error
m.httpServer, err = newHLSHTTPServer(
address,
encryption,
serverKey,
serverCert,
allowOrigin,
trustedProxies,
readTimeout,
m.pathManager,
m,
)
if err != nil {
ctxCancel()
return nil, err
}
m.Log(logger.Info, "listener opened on "+address)
m.pathManager.hlsManagerSet(m)
if m.metrics != nil {
m.metrics.hlsManagerSet(m)
}
m.wg.Add(1)
go m.run()
return m, nil
}
// Log is the main logging function.
func (m *hlsManager) Log(level logger.Level, format string, args ...interface{}) {
m.parent.Log(level, "[HLS] "+format, append([]interface{}{}, args...)...)
}
func (m *hlsManager) close() {
m.Log(logger.Info, "listener is closing")
m.ctxCancel()
m.wg.Wait()
}
func (m *hlsManager) run() {
defer m.wg.Done()
outer:
for {
select {
case pa := <-m.chPathReady:
if m.alwaysRemux && !pa.conf.SourceOnDemand {
if _, ok := m.muxers[pa.name]; !ok {
m.createMuxer(pa.name, "")
}
}
case pa := <-m.chPathNotReady:
c, ok := m.muxers[pa.name]
if ok && c.remoteAddr == "" { // created with "always remux"
c.close()
delete(m.muxers, pa.name)
}
case req := <-m.chHandleRequest:
r, ok := m.muxers[req.path]
switch {
case ok:
r.processRequest(&req)
default:
r := m.createMuxer(req.path, req.ctx.ClientIP())
r.processRequest(&req)
}
case c := <-m.chMuxerClose:
if c2, ok := m.muxers[c.PathName()]; !ok || c2 != c {
continue
}
delete(m.muxers, c.PathName())
case req := <-m.chAPIMuxerList:
data := &apiHLSMuxersList{
Items: []*apiHLSMuxer{},
}
for _, muxer := range m.muxers {
data.Items = append(data.Items, muxer.apiItem())
}
sort.Slice(data.Items, func(i, j int) bool {
return data.Items[i].Created.Before(data.Items[j].Created)
})
req.res <- hlsManagerAPIMuxersListRes{
data: data,
}
case req := <-m.chAPIMuxerGet:
muxer, ok := m.muxers[req.name]
if !ok {
req.res <- hlsManagerAPIMuxersGetRes{err: errAPINotFound}
continue
}
req.res <- hlsManagerAPIMuxersGetRes{data: muxer.apiItem()}
case <-m.ctx.Done():
break outer
}
}
m.ctxCancel()
m.httpServer.close()
m.pathManager.hlsManagerSet(nil)
if m.metrics != nil {
m.metrics.hlsManagerSet(nil)
}
}
func (m *hlsManager) createMuxer(pathName string, remoteAddr string) *hlsMuxer {
r := newHLSMuxer(
m.ctx,
remoteAddr,
m.externalAuthenticationURL,
m.variant,
m.segmentCount,
m.segmentDuration,
m.partDuration,
m.segmentMaxSize,
m.directory,
m.readBufferCount,
&m.wg,
pathName,
m.pathManager,
m)
m.muxers[pathName] = r
return r
}
// muxerClose is called by hlsMuxer.
func (m *hlsManager) muxerClose(c *hlsMuxer) {
select {
case m.chMuxerClose <- c:
case <-m.ctx.Done():
}
}
// pathReady is called by pathManager.
func (m *hlsManager) pathReady(pa *path) {
select {
case m.chPathReady <- pa:
case <-m.ctx.Done():
}
}
// pathNotReady is called by pathManager.
func (m *hlsManager) pathNotReady(pa *path) {
select {
case m.chPathNotReady <- pa:
case <-m.ctx.Done():
}
}
// apiMuxersList is called by api.
func (m *hlsManager) apiMuxersList() (*apiHLSMuxersList, error) {
req := hlsManagerAPIMuxersListReq{
res: make(chan hlsManagerAPIMuxersListRes),
}
select {
case m.chAPIMuxerList <- req:
res := <-req.res
return res.data, res.err
case <-m.ctx.Done():
return nil, fmt.Errorf("terminated")
}
}
// apiMuxersGet is called by api.
func (m *hlsManager) apiMuxersGet(name string) (*apiHLSMuxer, error) {
req := hlsManagerAPIMuxersGetReq{
name: name,
res: make(chan hlsManagerAPIMuxersGetRes),
}
select {
case m.chAPIMuxerGet <- req:
res := <-req.res
return res.data, res.err
case <-m.ctx.Done():
return nil, fmt.Errorf("terminated")
}
}
func (m *hlsManager) handleRequest(req hlsMuxerHandleRequestReq) {
req.res = make(chan *hlsMuxer)
select {
case m.chHandleRequest <- req:
muxer := <-req.res
if muxer != nil {
req.ctx.Request.URL.Path = req.file
muxer.handleRequest(req.ctx)
}
case <-m.ctx.Done():
}
}

225
internal/core/hls_manager_test.go

@ -0,0 +1,225 @@ @@ -0,0 +1,225 @@
package core
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"testing"
"time"
"github.com/bluenviron/gortsplib/v3"
"github.com/bluenviron/gortsplib/v3/pkg/formats"
"github.com/bluenviron/gortsplib/v3/pkg/media"
"github.com/gin-gonic/gin"
"github.com/pion/rtp"
"github.com/stretchr/testify/require"
)
type testHTTPAuthenticator struct {
protocol string
action string
s *http.Server
}
func newTestHTTPAuthenticator(t *testing.T, protocol string, action string) *testHTTPAuthenticator {
ln, err := net.Listen("tcp", "127.0.0.1:9120")
require.NoError(t, err)
ts := &testHTTPAuthenticator{
protocol: protocol,
action: action,
}
router := gin.New()
router.POST("/auth", ts.onAuth)
ts.s = &http.Server{Handler: router}
go ts.s.Serve(ln)
return ts
}
func (ts *testHTTPAuthenticator) close() {
ts.s.Shutdown(context.Background())
}
func (ts *testHTTPAuthenticator) onAuth(ctx *gin.Context) {
var in struct {
IP string `json:"ip"`
User string `json:"user"`
Password string `json:"password"`
Path string `json:"path"`
Protocol string `json:"protocol"`
ID string `json:"id"`
Action string `json:"action"`
Query string `json:"query"`
}
err := json.NewDecoder(ctx.Request.Body).Decode(&in)
if err != nil {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
var user string
if ts.action == "publish" {
user = "testpublisher"
} else {
user = "testreader"
}
if in.IP != "127.0.0.1" ||
in.User != user ||
in.Password != "testpass" ||
in.Path != "teststream" ||
in.Protocol != ts.protocol ||
// in.ID == "" ||
in.Action != ts.action ||
(in.Query != "user=testreader&pass=testpass&param=value" &&
in.Query != "user=testpublisher&pass=testpass&param=value" &&
in.Query != "param=value") {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
}
func httpPullFile(t *testing.T, hc *http.Client, u string) []byte {
res, err := hc.Get(u)
require.NoError(t, err)
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("bad status code: %v", res.StatusCode)
}
byts, err := io.ReadAll(res.Body)
require.NoError(t, err)
return byts
}
func TestHLSReadNotFound(t *testing.T) {
p, ok := newInstance("")
require.Equal(t, true, ok)
defer p.Close()
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8888/stream/", nil)
require.NoError(t, err)
hc := &http.Client{Transport: &http.Transport{}}
res, err := hc.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
}
func TestHLSRead(t *testing.T) {
p, ok := newInstance("hlsAlwaysRemux: yes\n" +
"paths:\n" +
" all:\n")
require.Equal(t, true, ok)
defer p.Close()
medi := &media.Media{
Type: media.TypeVideo,
Formats: []formats.Format{&formats.H264{
PayloadTyp: 96,
PacketizationMode: 1,
SPS: []byte{ // 1920x1080 baseline
0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02,
0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04,
0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9, 0x20,
},
PPS: []byte{0x08, 0x06, 0x07, 0x08},
}},
}
v := gortsplib.TransportTCP
source := gortsplib.Client{
Transport: &v,
}
err := source.StartRecording("rtsp://localhost:8554/stream", media.Medias{medi})
require.NoError(t, err)
defer source.Close()
time.Sleep(500 * time.Millisecond)
for i := 0; i < 2; i++ {
source.WritePacketRTP(medi, &rtp.Packet{
Header: rtp.Header{
Version: 2,
Marker: true,
PayloadType: 96,
SequenceNumber: 123 + uint16(i),
Timestamp: 45343 + uint32(i*90000),
SSRC: 563423,
},
Payload: []byte{
0x05, 0x02, 0x03, 0x04, // IDR
},
})
}
hc := &http.Client{Transport: &http.Transport{}}
cnt := httpPullFile(t, hc, "http://localhost:8888/stream/index.m3u8")
require.Equal(t, "#EXTM3U\n"+
"#EXT-X-VERSION:9\n"+
"#EXT-X-INDEPENDENT-SEGMENTS\n"+
"\n"+
"#EXT-X-STREAM-INF:BANDWIDTH=1256,AVERAGE-BANDWIDTH=1256,"+
"CODECS=\"avc1.42c028\",RESOLUTION=1920x1080,FRAME-RATE=30.000\n"+
"stream.m3u8\n", string(cnt))
cnt = httpPullFile(t, hc, "http://localhost:8888/stream/stream.m3u8")
require.Regexp(t, "#EXTM3U\n"+
"#EXT-X-VERSION:9\n"+
"#EXT-X-TARGETDURATION:1\n"+
"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=2\\.50000,CAN-SKIP-UNTIL=6\\.00000\n"+
"#EXT-X-PART-INF:PART-TARGET=1\\.00000\n"+
"#EXT-X-MEDIA-SEQUENCE:1\n"+
"#EXT-X-MAP:URI=\"init.mp4\"\n"+
"#EXT-X-GAP\n"+
"#EXTINF:1\\.00000,\n"+
"gap.mp4\n"+
"#EXT-X-GAP\n"+
"#EXTINF:1\\.00000,\n"+
"gap.mp4\n"+
"#EXT-X-GAP\n"+
"#EXTINF:1\\.00000,\n"+
"gap.mp4\n"+
"#EXT-X-GAP\n"+
"#EXTINF:1\\.00000,\n"+
"gap.mp4\n"+
"#EXT-X-GAP\n"+
"#EXTINF:1\\.00000,\n"+
"gap.mp4\n"+
"#EXT-X-GAP\n"+
"#EXTINF:1\\.00000,\n"+
"gap.mp4\n"+
"#EXT-X-PROGRAM-DATE-TIME:.+?Z\n"+
"#EXT-X-PART:DURATION=1\\.00000,URI=\"part0.mp4\",INDEPENDENT=YES\n"+
"#EXTINF:1\\.00000,\n"+
"seg7.mp4\n"+
"#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part1.mp4\"\n", string(cnt))
/*trak := <-c.track
pkt, _, err := trak.ReadRTP()
require.NoError(t, err)
require.Equal(t, &rtp.Packet{
Header: rtp.Header{
Version: 2,
Marker: true,
PayloadType: 102,
SequenceNumber: pkt.SequenceNumber,
Timestamp: pkt.Timestamp,
SSRC: pkt.SSRC,
CSRC: []uint32{},
},
Payload: []byte{0x01, 0x02, 0x03, 0x04},
}, pkt)*/
}

603
internal/core/hls_muxer.go

@ -0,0 +1,603 @@ @@ -0,0 +1,603 @@
package core
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/bluenviron/gohlslib"
"github.com/bluenviron/gohlslib/pkg/codecs"
"github.com/bluenviron/gortsplib/v3/pkg/formats"
"github.com/bluenviron/gortsplib/v3/pkg/media"
"github.com/bluenviron/gortsplib/v3/pkg/ringbuffer"
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio"
"github.com/gin-gonic/gin"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/formatprocessor"
"github.com/bluenviron/mediamtx/internal/logger"
)
const (
closeCheckPeriod = 1 * time.Second
closeAfterInactivity = 60 * time.Second
hlsMuxerRecreatePause = 10 * time.Second
)
func int64Ptr(v int64) *int64 {
return &v
}
type responseWriterWithCounter struct {
http.ResponseWriter
bytesSent *uint64
}
func (w *responseWriterWithCounter) Write(p []byte) (int, error) {
n, err := w.ResponseWriter.Write(p)
atomic.AddUint64(w.bytesSent, uint64(n))
return n, err
}
type hlsMuxerHandleRequestReq struct {
path string
file string
ctx *gin.Context
res chan *hlsMuxer
}
type hlsMuxerParent interface {
logger.Writer
muxerClose(*hlsMuxer)
}
type hlsMuxer struct {
remoteAddr string
externalAuthenticationURL string
variant conf.HLSVariant
segmentCount int
segmentDuration conf.StringDuration
partDuration conf.StringDuration
segmentMaxSize conf.StringSize
directory string
readBufferCount int
wg *sync.WaitGroup
pathName string
pathManager *pathManager
parent hlsMuxerParent
ctx context.Context
ctxCancel func()
created time.Time
path *path
ringBuffer *ringbuffer.RingBuffer
lastRequestTime *int64
muxer *gohlslib.Muxer
requests []*hlsMuxerHandleRequestReq
bytesSent *uint64
// in
chRequest chan *hlsMuxerHandleRequestReq
}
func newHLSMuxer(
parentCtx context.Context,
remoteAddr string,
externalAuthenticationURL string,
variant conf.HLSVariant,
segmentCount int,
segmentDuration conf.StringDuration,
partDuration conf.StringDuration,
segmentMaxSize conf.StringSize,
directory string,
readBufferCount int,
wg *sync.WaitGroup,
pathName string,
pathManager *pathManager,
parent hlsMuxerParent,
) *hlsMuxer {
ctx, ctxCancel := context.WithCancel(parentCtx)
m := &hlsMuxer{
remoteAddr: remoteAddr,
externalAuthenticationURL: externalAuthenticationURL,
variant: variant,
segmentCount: segmentCount,
segmentDuration: segmentDuration,
partDuration: partDuration,
segmentMaxSize: segmentMaxSize,
directory: directory,
readBufferCount: readBufferCount,
wg: wg,
pathName: pathName,
pathManager: pathManager,
parent: parent,
ctx: ctx,
ctxCancel: ctxCancel,
created: time.Now(),
lastRequestTime: int64Ptr(time.Now().UnixNano()),
bytesSent: new(uint64),
chRequest: make(chan *hlsMuxerHandleRequestReq),
}
m.Log(logger.Info, "created %s", func() string {
if remoteAddr == "" {
return "automatically"
}
return "(requested by " + remoteAddr + ")"
}())
m.wg.Add(1)
go m.run()
return m
}
func (m *hlsMuxer) close() {
m.ctxCancel()
}
func (m *hlsMuxer) Log(level logger.Level, format string, args ...interface{}) {
m.parent.Log(level, "[muxer %s] "+format, append([]interface{}{m.pathName}, args...)...)
}
// PathName returns the path name.
func (m *hlsMuxer) PathName() string {
return m.pathName
}
func (m *hlsMuxer) run() {
defer m.wg.Done()
err := func() error {
var innerReady chan struct{}
var innerErr chan error
var innerCtx context.Context
var innerCtxCancel func()
createInner := func() {
innerReady = make(chan struct{})
innerErr = make(chan error)
innerCtx, innerCtxCancel = context.WithCancel(context.Background())
go func() {
innerErr <- m.runInner(innerCtx, innerReady)
}()
}
createInner()
isReady := false
isRecreating := false
recreateTimer := newEmptyTimer()
for {
select {
case <-m.ctx.Done():
if !isRecreating {
innerCtxCancel()
<-innerErr
}
return errors.New("terminated")
case req := <-m.chRequest:
switch {
case isRecreating:
req.res <- nil
case isReady:
req.res <- m
default:
m.requests = append(m.requests, req)
}
case <-innerReady:
isReady = true
for _, req := range m.requests {
req.res <- m
}
m.requests = nil
case err := <-innerErr:
innerCtxCancel()
if m.remoteAddr == "" { // created with "always remux"
m.Log(logger.Info, "ERR: %v", err)
m.clearQueuedRequests()
isReady = false
isRecreating = true
recreateTimer = time.NewTimer(hlsMuxerRecreatePause)
} else {
return err
}
case <-recreateTimer.C:
isRecreating = false
createInner()
}
}
}()
m.ctxCancel()
m.clearQueuedRequests()
m.parent.muxerClose(m)
m.Log(logger.Info, "destroyed (%v)", err)
}
func (m *hlsMuxer) clearQueuedRequests() {
for _, req := range m.requests {
req.res <- nil
}
m.requests = nil
}
func (m *hlsMuxer) runInner(innerCtx context.Context, innerReady chan struct{}) error {
res := m.pathManager.readerAdd(pathReaderAddReq{
author: m,
pathName: m.pathName,
skipAuth: true,
})
if res.err != nil {
return res.err
}
m.path = res.path
defer m.path.readerRemove(pathReaderRemoveReq{author: m})
m.ringBuffer, _ = ringbuffer.New(uint64(m.readBufferCount))
var medias media.Medias
videoMedia, videoTrack := m.createVideoTrack(res.stream)
if videoMedia != nil {
medias = append(medias, videoMedia)
}
audioMedia, audioTrack := m.createAudioTrack(res.stream)
if audioMedia != nil {
medias = append(medias, audioMedia)
}
defer res.stream.readerRemove(m)
if medias == nil {
return fmt.Errorf(
"the stream doesn't contain any supported codec, which are currently H265, H264, Opus, MPEG4-Audio")
}
var muxerDirectory string
if m.directory != "" {
muxerDirectory = filepath.Join(m.directory, m.pathName)
os.MkdirAll(muxerDirectory, 0o755)
defer os.Remove(muxerDirectory)
}
m.muxer = &gohlslib.Muxer{
Variant: gohlslib.MuxerVariant(m.variant),
SegmentCount: m.segmentCount,
SegmentDuration: time.Duration(m.segmentDuration),
PartDuration: time.Duration(m.partDuration),
SegmentMaxSize: uint64(m.segmentMaxSize),
VideoTrack: videoTrack,
AudioTrack: audioTrack,
Directory: muxerDirectory,
}
err := m.muxer.Start()
if err != nil {
return fmt.Errorf("muxer error: %v", err)
}
defer m.muxer.Close()
innerReady <- struct{}{}
m.Log(logger.Info, "is converting into HLS, %s",
sourceMediaInfo(medias))
writerDone := make(chan error)
go func() {
writerDone <- m.runWriter()
}()
closeCheckTicker := time.NewTicker(closeCheckPeriod)
defer closeCheckTicker.Stop()
for {
select {
case <-closeCheckTicker.C:
if m.remoteAddr != "" {
t := time.Unix(0, atomic.LoadInt64(m.lastRequestTime))
if time.Since(t) >= closeAfterInactivity {
m.ringBuffer.Close()
<-writerDone
return fmt.Errorf("not used anymore")
}
}
case err := <-writerDone:
return err
case <-innerCtx.Done():
m.ringBuffer.Close()
<-writerDone
return fmt.Errorf("terminated")
}
}
}
func (m *hlsMuxer) createVideoTrack(stream *stream) (*media.Media, *gohlslib.Track) {
var videoFormatH265 *formats.H265
videoMedia := stream.medias().FindFormat(&videoFormatH265)
if videoFormatH265 != nil {
videoStartPTSFilled := false
var videoStartPTS time.Duration
stream.readerAdd(m, videoMedia, videoFormatH265, func(unit formatprocessor.Unit) {
m.ringBuffer.Push(func() error {
tunit := unit.(*formatprocessor.UnitH265)
if tunit.AU == nil {
return nil
}
if !videoStartPTSFilled {
videoStartPTSFilled = true
videoStartPTS = tunit.PTS
}
pts := tunit.PTS - videoStartPTS
err := m.muxer.WriteH26x(tunit.NTP, pts, tunit.AU)
if err != nil {
return fmt.Errorf("muxer error: %v", err)
}
return nil
})
})
vps, sps, pps := videoFormatH265.SafeParams()
return videoMedia, &gohlslib.Track{
Codec: &codecs.H265{
VPS: vps,
SPS: sps,
PPS: pps,
},
}
}
var videoFormatH264 *formats.H264
videoMedia = stream.medias().FindFormat(&videoFormatH264)
if videoFormatH264 != nil {
videoStartPTSFilled := false
var videoStartPTS time.Duration
stream.readerAdd(m, videoMedia, videoFormatH264, func(unit formatprocessor.Unit) {
m.ringBuffer.Push(func() error {
tunit := unit.(*formatprocessor.UnitH264)
if tunit.AU == nil {
return nil
}
if !videoStartPTSFilled {
videoStartPTSFilled = true
videoStartPTS = tunit.PTS
}
pts := tunit.PTS - videoStartPTS
err := m.muxer.WriteH26x(tunit.NTP, pts, tunit.AU)
if err != nil {
return fmt.Errorf("muxer error: %v", err)
}
return nil
})
})
sps, pps := videoFormatH264.SafeParams()
return videoMedia, &gohlslib.Track{
Codec: &codecs.H264{
SPS: sps,
PPS: pps,
},
}
}
return nil, nil
}
func (m *hlsMuxer) createAudioTrack(stream *stream) (*media.Media, *gohlslib.Track) {
var audioFormatMPEG4AudioGeneric *formats.MPEG4AudioGeneric
audioMedia := stream.medias().FindFormat(&audioFormatMPEG4AudioGeneric)
if audioMedia != nil {
audioStartPTSFilled := false
var audioStartPTS time.Duration
stream.readerAdd(m, audioMedia, audioFormatMPEG4AudioGeneric, func(unit formatprocessor.Unit) {
m.ringBuffer.Push(func() error {
tunit := unit.(*formatprocessor.UnitMPEG4AudioGeneric)
if tunit.AUs == nil {
return nil
}
if !audioStartPTSFilled {
audioStartPTSFilled = true
audioStartPTS = tunit.PTS
}
pts := tunit.PTS - audioStartPTS
for i, au := range tunit.AUs {
err := m.muxer.WriteAudio(
tunit.NTP,
pts+time.Duration(i)*mpeg4audio.SamplesPerAccessUnit*
time.Second/time.Duration(audioFormatMPEG4AudioGeneric.ClockRate()),
au)
if err != nil {
return fmt.Errorf("muxer error: %v", err)
}
}
return nil
})
})
return audioMedia, &gohlslib.Track{
Codec: &codecs.MPEG4Audio{
Config: *audioFormatMPEG4AudioGeneric.Config,
},
}
}
var audioFormatMPEG4AudioLATM *formats.MPEG4AudioLATM
audioMedia = stream.medias().FindFormat(&audioFormatMPEG4AudioLATM)
if audioMedia != nil &&
audioFormatMPEG4AudioLATM.Config != nil &&
len(audioFormatMPEG4AudioLATM.Config.Programs) == 1 &&
len(audioFormatMPEG4AudioLATM.Config.Programs[0].Layers) == 1 {
audioStartPTSFilled := false
var audioStartPTS time.Duration
stream.readerAdd(m, audioMedia, audioFormatMPEG4AudioLATM, func(unit formatprocessor.Unit) {
m.ringBuffer.Push(func() error {
tunit := unit.(*formatprocessor.UnitMPEG4AudioLATM)
if tunit.AU == nil {
return nil
}
if !audioStartPTSFilled {
audioStartPTSFilled = true
audioStartPTS = tunit.PTS
}
pts := tunit.PTS - audioStartPTS
err := m.muxer.WriteAudio(
tunit.NTP,
pts,
tunit.AU)
if err != nil {
return fmt.Errorf("muxer error: %v", err)
}
return nil
})
})
return audioMedia, &gohlslib.Track{
Codec: &codecs.MPEG4Audio{
Config: *audioFormatMPEG4AudioLATM.Config.Programs[0].Layers[0].AudioSpecificConfig,
},
}
}
var audioFormatOpus *formats.Opus
audioMedia = stream.medias().FindFormat(&audioFormatOpus)
if audioMedia != nil {
audioStartPTSFilled := false
var audioStartPTS time.Duration
stream.readerAdd(m, audioMedia, audioFormatOpus, func(unit formatprocessor.Unit) {
m.ringBuffer.Push(func() error {
tunit := unit.(*formatprocessor.UnitOpus)
if !audioStartPTSFilled {
audioStartPTSFilled = true
audioStartPTS = tunit.PTS
}
pts := tunit.PTS - audioStartPTS
err := m.muxer.WriteAudio(
tunit.NTP,
pts,
tunit.Frame)
if err != nil {
return fmt.Errorf("muxer error: %v", err)
}
return nil
})
})
return audioMedia, &gohlslib.Track{
Codec: &codecs.Opus{
Channels: func() int {
if audioFormatOpus.IsStereo {
return 2
}
return 1
}(),
},
}
}
return nil, nil
}
func (m *hlsMuxer) runWriter() error {
for {
item, ok := m.ringBuffer.Pull()
if !ok {
return fmt.Errorf("terminated")
}
err := item.(func() error)()
if err != nil {
return err
}
}
}
func (m *hlsMuxer) handleRequest(ctx *gin.Context) {
atomic.StoreInt64(m.lastRequestTime, time.Now().UnixNano())
w := &responseWriterWithCounter{
ResponseWriter: ctx.Writer,
bytesSent: m.bytesSent,
}
m.muxer.Handle(w, ctx.Request)
}
// processRequest is called by hlsserver.Server (forwarded from ServeHTTP).
func (m *hlsMuxer) processRequest(req *hlsMuxerHandleRequestReq) {
select {
case m.chRequest <- req:
case <-m.ctx.Done():
req.res <- nil
}
}
// apiReaderDescribe implements reader.
func (m *hlsMuxer) apiReaderDescribe() pathAPISourceOrReader {
return pathAPISourceOrReader{
Type: "hlsMuxer",
ID: "",
}
}
func (m *hlsMuxer) apiItem() *apiHLSMuxer {
return &apiHLSMuxer{
Path: m.pathName,
Created: m.created,
LastRequest: time.Unix(0, atomic.LoadInt64(m.lastRequestTime)),
BytesSent: atomic.LoadUint64(m.bytesSent),
}
}

189
internal/core/hls_source.go

@ -0,0 +1,189 @@ @@ -0,0 +1,189 @@
package core
import (
"context"
"net/http"
"time"
"github.com/bluenviron/gohlslib"
"github.com/bluenviron/gohlslib/pkg/codecs"
"github.com/bluenviron/gortsplib/v3/pkg/formats"
"github.com/bluenviron/gortsplib/v3/pkg/media"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/formatprocessor"
"github.com/bluenviron/mediamtx/internal/logger"
)
type hlsSourceParent interface {
logger.Writer
sourceStaticImplSetReady(req pathSourceStaticSetReadyReq) pathSourceStaticSetReadyRes
sourceStaticImplSetNotReady(req pathSourceStaticSetNotReadyReq)
}
type hlsSource struct {
parent hlsSourceParent
}
func newHLSSource(
parent hlsSourceParent,
) *hlsSource {
return &hlsSource{
parent: parent,
}
}
func (s *hlsSource) Log(level logger.Level, format string, args ...interface{}) {
s.parent.Log(level, "[hls source] "+format, args...)
}
// run implements sourceStaticImpl.
func (s *hlsSource) run(ctx context.Context, cnf *conf.PathConf, reloadConf chan *conf.PathConf) error {
var stream *stream
defer func() {
if stream != nil {
s.parent.sourceStaticImplSetNotReady(pathSourceStaticSetNotReadyReq{})
}
}()
c := &gohlslib.Client{
URI: cnf.Source,
HTTPClient: &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfigForFingerprint(cnf.SourceFingerprint),
},
},
Log: func(level gohlslib.LogLevel, format string, args ...interface{}) {
s.Log(logger.Level(level), format, args...)
},
}
c.OnTracks(func(tracks []*gohlslib.Track) error {
var medias media.Medias
for _, track := range tracks {
var medi *media.Media
switch tcodec := track.Codec.(type) {
case *codecs.H264:
medi = &media.Media{
Type: media.TypeVideo,
Formats: []formats.Format{&formats.H264{
PayloadTyp: 96,
PacketizationMode: 1,
SPS: tcodec.SPS,
PPS: tcodec.PPS,
}},
}
c.OnData(track, func(pts time.Duration, unit interface{}) {
stream.writeUnit(medi, medi.Formats[0], &formatprocessor.UnitH264{
PTS: pts,
AU: unit.([][]byte),
NTP: time.Now(),
})
})
case *codecs.H265:
medi = &media.Media{
Type: media.TypeVideo,
Formats: []formats.Format{&formats.H265{
PayloadTyp: 96,
VPS: tcodec.VPS,
SPS: tcodec.SPS,
PPS: tcodec.PPS,
}},
}
c.OnData(track, func(pts time.Duration, unit interface{}) {
stream.writeUnit(medi, medi.Formats[0], &formatprocessor.UnitH265{
PTS: pts,
AU: unit.([][]byte),
NTP: time.Now(),
})
})
case *codecs.MPEG4Audio:
medi = &media.Media{
Type: media.TypeAudio,
Formats: []formats.Format{&formats.MPEG4Audio{
PayloadTyp: 96,
SizeLength: 13,
IndexLength: 3,
IndexDeltaLength: 3,
Config: &tcodec.Config,
}},
}
c.OnData(track, func(pts time.Duration, unit interface{}) {
stream.writeUnit(medi, medi.Formats[0], &formatprocessor.UnitMPEG4AudioGeneric{
PTS: pts,
AUs: [][]byte{unit.([]byte)},
NTP: time.Now(),
})
})
case *codecs.Opus:
medi = &media.Media{
Type: media.TypeAudio,
Formats: []formats.Format{&formats.Opus{
PayloadTyp: 96,
IsStereo: (tcodec.Channels == 2),
}},
}
c.OnData(track, func(pts time.Duration, unit interface{}) {
stream.writeUnit(medi, medi.Formats[0], &formatprocessor.UnitOpus{
PTS: pts,
Frame: unit.([]byte),
NTP: time.Now(),
})
})
}
medias = append(medias, medi)
}
res := s.parent.sourceStaticImplSetReady(pathSourceStaticSetReadyReq{
medias: medias,
generateRTPPackets: true,
})
if res.err != nil {
return res.err
}
s.Log(logger.Info, "ready: %s", sourceMediaInfo(medias))
stream = res.stream
return nil
})
err := c.Start()
if err != nil {
return err
}
for {
select {
case err := <-c.Wait():
c.Close()
return err
case <-reloadConf:
case <-ctx.Done():
c.Close()
<-c.Wait()
return nil
}
}
}
// apiSourceDescribe implements sourceStaticImpl.
func (*hlsSource) apiSourceDescribe() pathAPISourceOrReader {
return pathAPISourceOrReader{
Type: "hlsSource",
ID: "",
}
}

294
internal/core/hls_source_test.go

@ -0,0 +1,294 @@ @@ -0,0 +1,294 @@
package core
import (
"bytes"
"context"
"io"
"net"
"net/http"
"testing"
"github.com/asticode/go-astits"
"github.com/bluenviron/gortsplib/v3"
"github.com/bluenviron/gortsplib/v3/pkg/formats"
"github.com/bluenviron/gortsplib/v3/pkg/media"
"github.com/bluenviron/gortsplib/v3/pkg/url"
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio"
"github.com/gin-gonic/gin"
"github.com/pion/rtp"
"github.com/stretchr/testify/require"
)
type testHLSManager struct {
s *http.Server
clientConnected chan struct{}
}
func newTestHLSManager() (*testHLSManager, error) {
ln, err := net.Listen("tcp", "localhost:5780")
if err != nil {
return nil, err
}
ts := &testHLSManager{
clientConnected: make(chan struct{}),
}
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.GET("/stream.m3u8", ts.onPlaylist)
router.GET("/segment1.ts", ts.onSegment1)
router.GET("/segment2.ts", ts.onSegment2)
ts.s = &http.Server{Handler: router}
go ts.s.Serve(ln)
return ts, nil
}
func (ts *testHLSManager) close() {
ts.s.Shutdown(context.Background())
}
func (ts *testHLSManager) onPlaylist(ctx *gin.Context) {
cnt := `#EXTM3U
#EXT-X-VERSION:3
#EXT-X-ALLOW-CACHE:NO
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:2,
segment1.ts
#EXTINF:2,
segment2.ts
#EXT-X-ENDLIST
`
ctx.Writer.Header().Set("Content-Type", `application/vnd.apple.mpegurl`)
io.Copy(ctx.Writer, bytes.NewReader([]byte(cnt)))
}
func (ts *testHLSManager) onSegment1(ctx *gin.Context) {
ctx.Writer.Header().Set("Content-Type", `video/MP2T`)
mux := astits.NewMuxer(context.Background(), ctx.Writer)
mux.AddElementaryStream(astits.PMTElementaryStream{
ElementaryPID: 256,
StreamType: astits.StreamTypeH264Video,
})
mux.AddElementaryStream(astits.PMTElementaryStream{
ElementaryPID: 257,
StreamType: astits.StreamTypeAACAudio,
})
mux.SetPCRPID(256)
mux.WriteTables()
pkts := mpeg4audio.ADTSPackets{
{
Type: 2,
SampleRate: 44100,
ChannelCount: 2,
AU: []byte{0x01, 0x02, 0x03, 0x04},
},
}
enc, _ := pkts.Marshal()
mux.WriteData(&astits.MuxerData{
PID: 257,
PES: &astits.PESData{
Header: &astits.PESHeader{
OptionalHeader: &astits.PESOptionalHeader{
MarkerBits: 2,
PTSDTSIndicator: astits.PTSDTSIndicatorOnlyPTS,
PTS: &astits.ClockReference{Base: int64(1 * 90000)},
},
StreamID: 192,
},
Data: enc,
},
})
}
func (ts *testHLSManager) onSegment2(ctx *gin.Context) {
<-ts.clientConnected
ctx.Writer.Header().Set("Content-Type", `video/MP2T`)
mux := astits.NewMuxer(context.Background(), ctx.Writer)
mux.AddElementaryStream(astits.PMTElementaryStream{
ElementaryPID: 256,
StreamType: astits.StreamTypeH264Video,
})
mux.AddElementaryStream(astits.PMTElementaryStream{
ElementaryPID: 257,
StreamType: astits.StreamTypeAACAudio,
})
mux.SetPCRPID(256)
mux.WriteTables()
enc, _ := h264.AnnexBMarshal([][]byte{
{7, 1, 2, 3}, // SPS
{8}, // PPS
})
mux.WriteData(&astits.MuxerData{
PID: 256,
PES: &astits.PESData{
Header: &astits.PESHeader{
OptionalHeader: &astits.PESOptionalHeader{
MarkerBits: 2,
PTSDTSIndicator: astits.PTSDTSIndicatorOnlyPTS,
PTS: &astits.ClockReference{Base: int64(2 * 90000)},
},
StreamID: 224, // = video
},
Data: enc,
},
})
pkts := mpeg4audio.ADTSPackets{
{
Type: 2,
SampleRate: 44100,
ChannelCount: 2,
AU: []byte{0x01, 0x02, 0x03, 0x04},
},
}
enc, _ = pkts.Marshal()
mux.WriteData(&astits.MuxerData{
PID: 257,
PES: &astits.PESData{
Header: &astits.PESHeader{
OptionalHeader: &astits.PESOptionalHeader{
MarkerBits: 2,
PTSDTSIndicator: astits.PTSDTSIndicatorOnlyPTS,
PTS: &astits.ClockReference{Base: int64(1 * 90000)},
},
StreamID: 192,
},
Data: enc,
},
})
enc, _ = h264.AnnexBMarshal([][]byte{
{5}, // IDR
})
mux.WriteData(&astits.MuxerData{
PID: 256,
PES: &astits.PESData{
Header: &astits.PESHeader{
OptionalHeader: &astits.PESOptionalHeader{
MarkerBits: 2,
PTSDTSIndicator: astits.PTSDTSIndicatorOnlyPTS,
PTS: &astits.ClockReference{Base: int64(2 * 90000)},
},
StreamID: 224, // = video
},
Data: enc,
},
})
}
func TestHLSSource(t *testing.T) {
ts, err := newTestHLSManager()
require.NoError(t, err)
defer ts.close()
p, ok := newInstance("rtmpDisable: yes\n" +
"hlsDisable: yes\n" +
"webrtcDisable: yes\n" +
"paths:\n" +
" proxied:\n" +
" source: http://localhost:5780/stream.m3u8\n" +
" sourceOnDemand: yes\n")
require.Equal(t, true, ok)
defer p.Close()
frameRecv := make(chan struct{})
c := gortsplib.Client{}
u, err := url.Parse("rtsp://localhost:8554/proxied")
require.NoError(t, err)
err = c.Start(u.Scheme, u.Host)
require.NoError(t, err)
defer c.Close()
medias, baseURL, _, err := c.Describe(u)
require.NoError(t, err)
require.Equal(t, media.Medias{
{
Type: media.TypeVideo,
Control: medias[0].Control,
Formats: []formats.Format{
&formats.H264{
PayloadTyp: 96,
PacketizationMode: 1,
},
},
},
{
Type: media.TypeAudio,
Control: medias[1].Control,
Formats: []formats.Format{
&formats.MPEG4Audio{
PayloadTyp: 96,
ProfileLevelID: 1,
Config: &mpeg4audio.Config{
Type: 2,
SampleRate: 44100,
ChannelCount: 2,
},
SizeLength: 13,
IndexLength: 3,
IndexDeltaLength: 3,
},
},
},
}, medias)
err = c.SetupAll(medias, baseURL)
require.NoError(t, err)
c.OnPacketRTP(medias[0], medias[0].Formats[0], func(pkt *rtp.Packet) {
require.Equal(t, &rtp.Packet{
Header: rtp.Header{
Version: 2,
Marker: true,
PayloadType: 96,
SequenceNumber: pkt.SequenceNumber,
Timestamp: pkt.Timestamp,
SSRC: pkt.SSRC,
CSRC: []uint32{},
},
Payload: []byte{
0x18,
0x00, 0x04,
0x07, 0x01, 0x02, 0x03, // SPS
0x00, 0x01,
0x08, // PPS
0x00, 0x01,
0x05, // IDR
},
}, pkt)
close(frameRecv)
})
_, err = c.Play(nil)
require.NoError(t, err)
close(ts.clientConnected)
<-frameRecv
}

58
internal/core/http_logger.go

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
package core
import (
"bytes"
"fmt"
"net/http"
"net/http/httputil"
"github.com/gin-gonic/gin"
"github.com/bluenviron/mediamtx/internal/logger"
)
type httpLoggerWriter struct {
gin.ResponseWriter
buf bytes.Buffer
}
func (w *httpLoggerWriter) Write(b []byte) (int, error) {
w.buf.Write(b)
return w.ResponseWriter.Write(b)
}
func (w *httpLoggerWriter) WriteString(s string) (int, error) {
w.buf.WriteString(s)
return w.ResponseWriter.WriteString(s)
}
func (w *httpLoggerWriter) dump() string {
var buf bytes.Buffer
fmt.Fprintf(&buf, "%s %d %s\n", "HTTP/1.1", w.ResponseWriter.Status(), http.StatusText(w.ResponseWriter.Status()))
w.ResponseWriter.Header().Write(&buf)
buf.Write([]byte("\n"))
if w.buf.Len() > 0 {
fmt.Fprintf(&buf, "(body of %d bytes)", w.buf.Len())
}
return buf.String()
}
type httpLoggerParent interface {
logger.Writer
}
func httpLoggerMiddleware(p httpLoggerParent) func(*gin.Context) {
return func(ctx *gin.Context) {
p.Log(logger.Debug, "[conn %v] %s %s", ctx.Request.RemoteAddr, ctx.Request.Method, ctx.Request.URL.Path)
byts, _ := httputil.DumpRequest(ctx.Request, true)
p.Log(logger.Debug, "[conn %v] [c->s] %s", ctx.Request.RemoteAddr, string(byts))
logw := &httpLoggerWriter{ResponseWriter: ctx.Writer}
ctx.Writer = logw
ctx.Next()
p.Log(logger.Debug, "[conn %v] [s->c] %s", ctx.Request.RemoteAddr, logw.dump())
}
}

94
internal/core/http_server.go

@ -0,0 +1,94 @@ @@ -0,0 +1,94 @@
package core
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"os"
"runtime"
"time"
"github.com/bluenviron/mediamtx/internal/conf"
)
type nilWriter struct{}
func (nilWriter) Write(p []byte) (int, error) {
return len(p), nil
}
// exit when there's a panic inside HTTP handlers.
// https://github.com/golang/go/issues/16542
type exitOnPanicHandler struct {
http.Handler
}
func (h exitOnPanicHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer func() {
err := recover()
if err != nil {
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
fmt.Fprintf(os.Stderr, "panic: %v\n\n%s", err, buf[:n])
os.Exit(1)
}
}()
h.Handler.ServeHTTP(w, r)
}
type httpServer struct {
ln net.Listener
inner *http.Server
}
func newHTTPServer(
address string,
readTimeout conf.StringDuration,
serverCert string,
serverKey string,
handler http.Handler,
) (*httpServer, error) {
ln, err := net.Listen(restrictNetwork("tcp", address))
if err != nil {
return nil, err
}
var tlsConfig *tls.Config
if serverCert != "" {
crt, err := tls.LoadX509KeyPair(serverCert, serverKey)
if err != nil {
ln.Close()
return nil, err
}
tlsConfig = &tls.Config{
Certificates: []tls.Certificate{crt},
}
}
s := &httpServer{
ln: ln,
inner: &http.Server{
Handler: exitOnPanicHandler{handler},
TLSConfig: tlsConfig,
ReadHeaderTimeout: time.Duration(readTimeout),
ErrorLog: log.New(&nilWriter{}, "", 0),
},
}
if tlsConfig != nil {
go s.inner.ServeTLS(s.ln, "", "")
} else {
go s.inner.Serve(s.ln)
}
return s, nil
}
func (s *httpServer) close() {
s.inner.Shutdown(context.Background())
s.ln.Close() // in case Shutdown() is called before Serve()
}

10
internal/core/http_server_header.go

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
package core
import (
"github.com/gin-gonic/gin"
)
func httpServerHeaderMiddleware(ctx *gin.Context) {
ctx.Writer.Header().Set("Server", "mediamtx")
ctx.Next()
}

15
internal/core/http_set_trusted_proxies.go

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
package core
import (
"github.com/gin-gonic/gin"
"github.com/bluenviron/mediamtx/internal/conf"
)
func httpSetTrustedProxies(router *gin.Engine, trustedProxies conf.IPsOrCIDRs) {
tmp := make([]string, len(trustedProxies))
for i, entry := range trustedProxies {
tmp[i] = entry.String()
}
router.SetTrustedProxies(tmp)
}

23
internal/core/ip.go

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
package core
import (
"fmt"
"net"
)
func ipEqualOrInRange(ip net.IP, ips []fmt.Stringer) bool {
for _, item := range ips {
switch titem := item.(type) {
case net.IP:
if titem.Equal(ip) {
return true
}
case *net.IPNet:
if titem.Contains(ip) {
return true
}
}
}
return false
}

257
internal/core/metrics.go

@ -0,0 +1,257 @@ @@ -0,0 +1,257 @@
package core
import (
"io"
"net/http"
"strconv"
"sync"
"github.com/gin-gonic/gin"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/logger"
)
func metric(key string, tags string, value int64) string {
return key + tags + " " + strconv.FormatInt(value, 10) + "\n"
}
type metricsParent interface {
logger.Writer
}
type metrics struct {
parent metricsParent
httpServer *httpServer
mutex sync.Mutex
pathManager apiPathManager
rtspServer apiRTSPServer
rtspsServer apiRTSPServer
rtmpServer apiRTMPServer
hlsManager apiHLSManager
webRTCManager apiWebRTCManager
}
func newMetrics(
address string,
readTimeout conf.StringDuration,
parent metricsParent,
) (*metrics, error) {
m := &metrics{
parent: parent,
}
router := gin.New()
router.SetTrustedProxies(nil)
mwLog := httpLoggerMiddleware(m)
router.NoRoute(mwLog)
router.GET("/metrics", mwLog, m.onMetrics)
var err error
m.httpServer, err = newHTTPServer(
address,
readTimeout,
"",
"",
router,
)
if err != nil {
return nil, err
}
m.Log(logger.Info, "listener opened on "+address)
return m, nil
}
func (m *metrics) close() {
m.Log(logger.Info, "listener is closing")
m.httpServer.close()
}
func (m *metrics) Log(level logger.Level, format string, args ...interface{}) {
m.parent.Log(level, "[metrics] "+format, args...)
}
func (m *metrics) onMetrics(ctx *gin.Context) {
out := ""
data, err := m.pathManager.apiPathsList()
if err == nil && len(data.Items) != 0 {
for _, i := range data.Items {
var state string
if i.SourceReady {
state = "ready"
} else {
state = "notReady"
}
tags := "{name=\"" + i.Name + "\",state=\"" + state + "\"}"
out += metric("paths", tags, 1)
out += metric("paths_bytes_received", tags, int64(i.BytesReceived))
}
} else {
out += metric("paths", "", 0)
}
if !interfaceIsEmpty(m.hlsManager) {
data, err := m.hlsManager.apiMuxersList()
if err == nil && len(data.Items) != 0 {
for _, i := range data.Items {
tags := "{name=\"" + i.Path + "\"}"
out += metric("hls_muxers", tags, 1)
out += metric("hls_muxers_bytes_sent", tags, int64(i.BytesSent))
}
} else {
out += metric("hls_muxers", "", 0)
out += metric("hls_muxers_bytes_sent", "", 0)
}
}
if !interfaceIsEmpty(m.rtspServer) { //nolint:dupl
func() {
data, err := m.rtspServer.apiConnsList()
if err == nil && len(data.Items) != 0 {
for _, i := range data.Items {
tags := "{id=\"" + i.ID.String() + "\"}"
out += metric("rtsp_conns", tags, 1)
out += metric("rtsp_conns_bytes_received", tags, int64(i.BytesReceived))
out += metric("rtsp_conns_bytes_sent", tags, int64(i.BytesSent))
}
} else {
out += metric("rtsp_conns", "", 0)
out += metric("rtsp_conns_bytes_received", "", 0)
out += metric("rtsp_conns_bytes_sent", "", 0)
}
}()
func() {
data, err := m.rtspServer.apiSessionsList()
if err == nil && len(data.Items) != 0 {
for _, i := range data.Items {
tags := "{id=\"" + i.ID.String() + "\",state=\"" + i.State + "\"}"
out += metric("rtsp_sessions", tags, 1)
out += metric("rtsp_sessions_bytes_received", tags, int64(i.BytesReceived))
out += metric("rtsp_sessions_bytes_sent", tags, int64(i.BytesSent))
}
} else {
out += metric("rtsp_sessions", "", 0)
out += metric("rtsp_sessions_bytes_received", "", 0)
out += metric("rtsp_sessions_bytes_sent", "", 0)
}
}()
}
if !interfaceIsEmpty(m.rtspsServer) { //nolint:dupl
func() {
data, err := m.rtspsServer.apiConnsList()
if err == nil && len(data.Items) != 0 {
for _, i := range data.Items {
tags := "{id=\"" + i.ID.String() + "\"}"
out += metric("rtsps_conns", tags, 1)
out += metric("rtsps_conns_bytes_received", tags, int64(i.BytesReceived))
out += metric("rtsps_conns_bytes_sent", tags, int64(i.BytesSent))
}
} else {
out += metric("rtsps_conns", "", 0)
out += metric("rtsps_conns_bytes_received", "", 0)
out += metric("rtsps_conns_bytes_sent", "", 0)
}
}()
func() {
data, err := m.rtspsServer.apiSessionsList()
if err == nil && len(data.Items) != 0 {
for _, i := range data.Items {
tags := "{id=\"" + i.ID.String() + "\",state=\"" + i.State + "\"}"
out += metric("rtsps_sessions", tags, 1)
out += metric("rtsps_sessions_bytes_received", tags, int64(i.BytesReceived))
out += metric("rtsps_sessions_bytes_sent", tags, int64(i.BytesSent))
}
} else {
out += metric("rtsps_sessions", "", 0)
out += metric("rtsps_sessions_bytes_received", "", 0)
out += metric("rtsps_sessions_bytes_sent", "", 0)
}
}()
}
if !interfaceIsEmpty(m.rtmpServer) {
data, err := m.rtmpServer.apiConnsList()
if err == nil && len(data.Items) != 0 {
for _, i := range data.Items {
tags := "{id=\"" + i.ID.String() + "\",state=\"" + i.State + "\"}"
out += metric("rtmp_conns", tags, 1)
out += metric("rtmp_conns_bytes_received", tags, int64(i.BytesReceived))
out += metric("rtmp_conns_bytes_sent", tags, int64(i.BytesSent))
}
} else {
out += metric("rtmp_conns", "", 0)
out += metric("rtmp_conns_bytes_received", "", 0)
out += metric("rtmp_conns_bytes_sent", "", 0)
}
}
if !interfaceIsEmpty(m.webRTCManager) {
data, err := m.webRTCManager.apiSessionsList()
if err == nil && len(data.Items) != 0 {
for _, i := range data.Items {
tags := "{id=\"" + i.ID.String() + "\"}"
out += metric("webrtc_sessions", tags, 1)
out += metric("webrtc_sessions_bytes_received", tags, int64(i.BytesReceived))
out += metric("webrtc_sessions_bytes_sent", tags, int64(i.BytesSent))
}
} else {
out += metric("webrtc_sessions", "", 0)
out += metric("webrtc_sessions_bytes_received", "", 0)
out += metric("webrtc_sessions_bytes_sent", "", 0)
}
}
ctx.Writer.WriteHeader(http.StatusOK)
io.WriteString(ctx.Writer, out)
}
// pathManagerSet is called by pathManager.
func (m *metrics) pathManagerSet(s apiPathManager) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.pathManager = s
}
// hlsManagerSet is called by hlsManager.
func (m *metrics) hlsManagerSet(s apiHLSManager) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.hlsManager = s
}
// rtspServerSet is called by rtspServer (plain).
func (m *metrics) rtspServerSet(s apiRTSPServer) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.rtspServer = s
}
// rtspsServerSet is called by rtspServer (tls).
func (m *metrics) rtspsServerSet(s apiRTSPServer) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.rtspsServer = s
}
// rtmpServerSet is called by rtmpServer.
func (m *metrics) rtmpServerSet(s apiRTMPServer) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.rtmpServer = s
}
// webRTCManagerSet is called by webRTCManager.
func (m *metrics) webRTCManagerSet(s apiWebRTCManager) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.webRTCManager = s
}

377
internal/core/metrics_test.go

@ -1,78 +1,48 @@ @@ -1,78 +1,48 @@
package core
import (
"bufio"
"context"
"crypto/tls"
"io"
"net"
"net/http"
"net/url"
"os"
"sync"
"testing"
"time"
"github.com/bluenviron/gortsplib/v4"
"github.com/bluenviron/gortsplib/v4/pkg/description"
"github.com/bluenviron/mediacommon/pkg/formats/mpegts"
srt "github.com/datarhei/gosrt"
"github.com/pion/rtp"
"github.com/bluenviron/gortsplib/v3"
"github.com/bluenviron/gortsplib/v3/pkg/formats"
"github.com/bluenviron/gortsplib/v3/pkg/media"
"github.com/stretchr/testify/require"
"github.com/bluenviron/mediamtx/internal/protocols/rtmp"
"github.com/bluenviron/mediamtx/internal/protocols/webrtc"
"github.com/bluenviron/mediamtx/internal/test"
"github.com/bluenviron/mediamtx/internal/rtmp"
)
func httpPullFile(t *testing.T, hc *http.Client, u string) []byte {
res, err := hc.Get(u)
require.NoError(t, err)
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("bad status code: %v", res.StatusCode)
}
byts, err := io.ReadAll(res.Body)
require.NoError(t, err)
return byts
}
func TestMetrics(t *testing.T) {
serverCertFpath, err := test.CreateTempFile(test.TLSCertPub)
serverCertFpath, err := writeTempFile(serverCert)
require.NoError(t, err)
defer os.Remove(serverCertFpath)
serverKeyFpath, err := test.CreateTempFile(test.TLSCertKey)
serverKeyFpath, err := writeTempFile(serverKey)
require.NoError(t, err)
defer os.Remove(serverKeyFpath)
p, ok := newInstance("api: yes\n" +
"hlsAlwaysRemux: yes\n" +
p, ok := newInstance("hlsAlwaysRemux: yes\n" +
"metrics: yes\n" +
"webrtcServerCert: " + serverCertFpath + "\n" +
"webrtcServerKey: " + serverKeyFpath + "\n" +
"encryption: optional\n" +
"serverCert: " + serverCertFpath + "\n" +
"serverKey: " + serverKeyFpath + "\n" +
"rtmpEncryption: optional\n" +
"rtmpServerCert: " + serverCertFpath + "\n" +
"rtmpServerKey: " + serverKeyFpath + "\n" +
"paths:\n" +
" all_others:\n")
" all:\n")
require.Equal(t, true, ok)
defer p.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
hc := &http.Client{Transport: &http.Transport{}}
t.Run("initial", func(t *testing.T) {
bo := httpPullFile(t, hc, "http://localhost:9998/metrics")
bo := httpPullFile(t, hc, "http://localhost:9998/metrics")
require.Equal(t, `paths 0
require.Equal(t, `paths 0
hls_muxers 0
hls_muxers_bytes_sent 0
rtsp_conns 0
@ -90,274 +60,85 @@ rtsps_sessions_bytes_sent 0 @@ -90,274 +60,85 @@ rtsps_sessions_bytes_sent 0
rtmp_conns 0
rtmp_conns_bytes_received 0
rtmp_conns_bytes_sent 0
rtmps_conns 0
rtmps_conns_bytes_received 0
rtmps_conns_bytes_sent 0
srt_conns 0
srt_conns_bytes_received 0
srt_conns_bytes_sent 0
webrtc_sessions 0
webrtc_sessions_bytes_received 0
webrtc_sessions_bytes_sent 0
`, string(bo))
})
t.Run("with data", func(t *testing.T) {
terminate := make(chan struct{})
var wg sync.WaitGroup
wg.Add(6)
go func() {
defer wg.Done()
source := gortsplib.Client{}
err := source.StartRecording("rtsp://localhost:8554/rtsp_path",
&description.Session{Medias: []*description.Media{test.UniqueMediaH264()}})
require.NoError(t, err)
defer source.Close()
<-terminate
}()
go func() {
defer wg.Done()
source2 := gortsplib.Client{TLSConfig: &tls.Config{InsecureSkipVerify: true}}
err := source2.StartRecording("rtsps://localhost:8322/rtsps_path",
&description.Session{Medias: []*description.Media{test.UniqueMediaH264()}})
require.NoError(t, err)
defer source2.Close()
<-terminate
}()
go func() {
defer wg.Done()
u, err := url.Parse("rtmp://localhost:1935/rtmp_path")
require.NoError(t, err)
nconn, err := net.Dial("tcp", u.Host)
require.NoError(t, err)
defer nconn.Close()
conn, err := rtmp.NewClientConn(nconn, u, true)
require.NoError(t, err)
_, err = rtmp.NewWriter(conn, test.FormatH264, nil)
require.NoError(t, err)
<-terminate
}()
go func() {
defer wg.Done()
u, err := url.Parse("rtmp://localhost:1936/rtmps_path")
require.NoError(t, err)
nconn, err := tls.Dial("tcp", u.Host, &tls.Config{InsecureSkipVerify: true})
require.NoError(t, err)
defer nconn.Close() //nolint:errcheck
conn, err := rtmp.NewClientConn(nconn, u, true)
require.NoError(t, err)
_, err = rtmp.NewWriter(conn, test.FormatH264, nil)
require.NoError(t, err)
<-terminate
}()
go func() {
defer wg.Done()
su, err := url.Parse("http://localhost:8889/webrtc_path/whip")
require.NoError(t, err)
medi := testMediaH264
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
s := &webrtc.WHIPClient{
HTTPClient: hc,
URL: su,
Log: test.NilLogger{},
}
tracks, err := s.Publish(context.Background(), test.MediaH264.Formats[0], nil)
require.NoError(t, err)
defer checkClose(t, s.Close)
err = tracks[0].WriteRTP(&rtp.Packet{
Header: rtp.Header{
Version: 2,
Marker: true,
PayloadType: 96,
SequenceNumber: 123,
Timestamp: 45343,
SSRC: 563423,
},
Payload: []byte{1},
})
require.NoError(t, err)
<-terminate
}()
go func() {
defer wg.Done()
srtConf := srt.DefaultConfig()
address, err := srtConf.UnmarshalURL("srt://localhost:8890?streamid=publish:srt_path")
require.NoError(t, err)
err = srtConf.Validate()
require.NoError(t, err)
publisher, err := srt.Dial("srt", address, srtConf)
require.NoError(t, err)
defer publisher.Close()
track := &mpegts.Track{
Codec: &mpegts.CodecH264{},
}
bw := bufio.NewWriter(publisher)
w := mpegts.NewWriter(bw, []*mpegts.Track{track})
require.NoError(t, err)
err = w.WriteH26x(track, 0, 0, true, [][]byte{
test.FormatH264.SPS,
test.FormatH264.PPS,
{0x05, 1}, // IDR
})
require.NoError(t, err)
err = bw.Flush()
require.NoError(t, err)
<-terminate
}()
time.Sleep(500 * time.Millisecond)
source := gortsplib.Client{}
err = source.StartRecording("rtsp://localhost:8554/rtsp_path",
media.Medias{medi})
require.NoError(t, err)
defer source.Close()
bo := httpPullFile(t, hc, "http://localhost:9998/metrics")
source2 := gortsplib.Client{TLSConfig: &tls.Config{InsecureSkipVerify: true}}
err = source2.StartRecording("rtsps://localhost:8322/rtsps_path",
media.Medias{medi})
require.NoError(t, err)
defer source2.Close()
require.Regexp(t,
`^paths\{name=".*?",state="ready"\} 1`+"\n"+
`paths_bytes_received\{name=".*?",state="ready"\} [0-9]+`+"\n"+
`paths_bytes_sent\{name=".*?",state="ready"\} [0-9]+`+"\n"+
`paths\{name=".*?",state="ready"\} 1`+"\n"+
`paths_bytes_received\{name=".*?",state="ready"\} [0-9]+`+"\n"+
`paths_bytes_sent\{name=".*?",state="ready"\} [0-9]+`+"\n"+
`paths\{name=".*?",state="ready"\} 1`+"\n"+
`paths_bytes_received\{name=".*?",state="ready"\} [0-9]+`+"\n"+
`paths_bytes_sent\{name=".*?",state="ready"\} [0-9]+`+"\n"+
`paths\{name=".*?",state="ready"\} 1`+"\n"+
`paths_bytes_received\{name=".*?",state="ready"\} [0-9]+`+"\n"+
`paths_bytes_sent\{name=".*?",state="ready"\} [0-9]+`+"\n"+
`paths\{name=".*?",state="ready"\} 1`+"\n"+
`paths_bytes_received\{name=".*?",state="ready"\} [0-9]+`+"\n"+
`paths_bytes_sent\{name=".*?",state="ready"\} [0-9]+`+"\n"+
`paths\{name=".*?",state="ready"\} 1`+"\n"+
`paths_bytes_received\{name=".*?",state="ready"\} [0-9]+`+"\n"+
`paths_bytes_sent\{name=".*?",state="ready"\} [0-9]+`+"\n"+
`hls_muxers\{name=".*?"\} 1`+"\n"+
`hls_muxers_bytes_sent\{name=".*?"\} 0`+"\n"+
`hls_muxers\{name=".*?"\} 1`+"\n"+
`hls_muxers_bytes_sent\{name=".*?"\} 0`+"\n"+
`hls_muxers\{name=".*?"\} 1`+"\n"+
`hls_muxers_bytes_sent\{name=".*?"\} 0`+"\n"+
`hls_muxers\{name=".*?"\} 1`+"\n"+
`hls_muxers_bytes_sent\{name=".*?"\} 0`+"\n"+
`hls_muxers\{name=".*?"\} 1`+"\n"+
`hls_muxers_bytes_sent\{name=".*?"\} 0`+"\n"+
`hls_muxers\{name=".*?"\} 1`+"\n"+
`hls_muxers_bytes_sent\{name=".*?"\} 0`+"\n"+
`rtsp_conns\{id=".*?"\} 1`+"\n"+
`rtsp_conns_bytes_received\{id=".*?"\} [0-9]+`+"\n"+
`rtsp_conns_bytes_sent\{id=".*?"\} [0-9]+`+"\n"+
`rtsp_sessions\{id=".*?",state="publish"\} 1`+"\n"+
`rtsp_sessions_bytes_received\{id=".*?",state="publish"\} 0`+"\n"+
`rtsp_sessions_bytes_sent\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`rtsps_conns\{id=".*?"\} 1`+"\n"+
`rtsps_conns_bytes_received\{id=".*?"\} [0-9]+`+"\n"+
`rtsps_conns_bytes_sent\{id=".*?"\} [0-9]+`+"\n"+
`rtsps_sessions\{id=".*?",state="publish"\} 1`+"\n"+
`rtsps_sessions_bytes_received\{id=".*?",state="publish"\} 0`+"\n"+
`rtsps_sessions_bytes_sent\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`rtmp_conns\{id=".*?",state="publish"\} 1`+"\n"+
`rtmp_conns_bytes_received\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`rtmp_conns_bytes_sent\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`rtmps_conns\{id=".*?",state="publish"\} 1`+"\n"+
`rtmps_conns_bytes_received\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`rtmps_conns_bytes_sent\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns\{id=".*?",state="publish"\} 1`+"\n"+
`srt_conns_packets_sent\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_received\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_sent_unique\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_received_unique\{id=".*?",state="publish"\} 1`+"\n"+
`srt_conns_packets_send_loss\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_received_loss\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_retrans\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_received_retrans\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_sent_ack\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_received_ack\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_sent_nak\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_received_nak\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_sent_km\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_received_km\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_us_snd_duration\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_send_drop\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_received_drop\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_received_undecrypt\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_bytes_sent\{id=".*?",state="publish"\} 0`+"\n"+
`srt_conns_bytes_received\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_bytes_sent_unique\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_bytes_received_unique\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_bytes_received_loss\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_bytes_retrans\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_bytes_received_retrans\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_bytes_send_drop\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_bytes_received_drop\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_bytes_received_undecrypt\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_us_packets_send_period\{id=".*?",state="publish"\} \d+\.\d+`+"\n"+
`srt_conns_packets_flow_window\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_flight_size\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_ms_rtt\{id=".*?",state="publish"\} \d+\.\d+`+"\n"+
`srt_conns_mbps_send_rate\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_mbps_receive_rate\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_mbps_link_capacity\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_bytes_avail_send_buf\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_bytes_avail_receive_buf\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_mbps_max_bw\{id=".*?",state="publish"\} -1`+"\n"+
`srt_conns_bytes_mss\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_send_buf\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_bytes_send_buf\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_ms_send_buf\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_ms_send_tsb_pd_delay\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_receive_buf\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_bytes_receive_buf\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_ms_receive_buf\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_ms_receive_tsb_pd_delay\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_reorder_tolerance\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_received_avg_belated_time\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_send_loss_rate\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`srt_conns_packets_received_loss_rate\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`webrtc_sessions\{id=".*?",state="publish"\} 1`+"\n"+
`webrtc_sessions_bytes_received\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`webrtc_sessions_bytes_sent\{id=".*?",state="publish"\} [0-9]+`+"\n"+
"$",
string(bo))
u, err := url.Parse("rtmp://localhost:1935/rtmp_path")
require.NoError(t, err)
close(terminate)
wg.Wait()
})
nconn, err := net.Dial("tcp", u.Host)
require.NoError(t, err)
defer nconn.Close()
conn := rtmp.NewConn(nconn)
t.Run("servers deleted", func(t *testing.T) {
httpRequest(t, hc, http.MethodPatch, "http://localhost:9997/v3/config/global/patch", map[string]interface{}{
"rtsp": false,
"rtmp": false,
"srt": false,
"hls": false,
"webrtc": false,
}, nil)
err = conn.InitializeClient(u, true)
require.NoError(t, err)
time.Sleep(500 * time.Millisecond)
videoTrack := &formats.H264{
PayloadTyp: 96,
SPS: []byte{ // 1920x1080 baseline
0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02,
0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04,
0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9, 0x20,
},
PPS: []byte{0x08, 0x06, 0x07, 0x08},
PacketizationMode: 1,
}
bo := httpPullFile(t, hc, "http://localhost:9998/metrics")
err = conn.WriteTracks(videoTrack, nil)
require.NoError(t, err)
require.Equal(t, "paths 0\n", string(bo))
})
time.Sleep(500 * time.Millisecond)
bo = httpPullFile(t, hc, "http://localhost:9998/metrics")
require.Regexp(t,
`^paths\{name=".*?",state="ready"\} 1`+"\n"+
`paths_bytes_received\{name=".*?",state="ready"\} 0`+"\n"+
`paths\{name=".*?",state="ready"\} 1`+"\n"+
`paths_bytes_received\{name=".*?",state="ready"\} 0`+"\n"+
`paths\{name=".*?",state="ready"\} 1`+"\n"+
`paths_bytes_received\{name=".*?",state="ready"\} 0`+"\n"+
`hls_muxers\{name=".*?"\} 1`+"\n"+
`hls_muxers_bytes_sent\{name=".*?"\} [0-9]+`+"\n"+
`hls_muxers\{name=".*?"\} 1`+"\n"+
`hls_muxers_bytes_sent\{name=".*?"\} [0-9]+`+"\n"+
`hls_muxers\{name=".*?"\} 1`+"\n"+
`hls_muxers_bytes_sent\{name=".*?"\} [0-9]+`+"\n"+
`rtsp_conns\{id=".*?"\} 1`+"\n"+
`rtsp_conns_bytes_received\{id=".*?"\} [0-9]+`+"\n"+
`rtsp_conns_bytes_sent\{id=".*?"\} [0-9]+`+"\n"+
`rtsp_sessions\{id=".*?",state="publish"\} 1`+"\n"+
`rtsp_sessions_bytes_received\{id=".*?",state="publish"\} 0`+"\n"+
`rtsp_sessions_bytes_sent\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`rtsps_conns\{id=".*?"\} 1`+"\n"+
`rtsps_conns_bytes_received\{id=".*?"\} [0-9]+`+"\n"+
`rtsps_conns_bytes_sent\{id=".*?"\} [0-9]+`+"\n"+
`rtsps_sessions\{id=".*?",state="publish"\} 1`+"\n"+
`rtsps_sessions_bytes_received\{id=".*?",state="publish"\} 0`+"\n"+
`rtsps_sessions_bytes_sent\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`rtmp_conns\{id=".*?",state="publish"\} 1`+"\n"+
`rtmp_conns_bytes_received\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`rtmp_conns_bytes_sent\{id=".*?",state="publish"\} [0-9]+`+"\n"+
`webrtc_sessions 0`+"\n"+
`webrtc_sessions_bytes_received 0`+"\n"+
`webrtc_sessions_bytes_sent 0`+"\n"+
"$",
string(bo))
}

1264
internal/core/path.go

File diff suppressed because it is too large Load Diff

581
internal/core/path_manager.go

@ -6,41 +6,57 @@ import ( @@ -6,41 +6,57 @@ import (
"sort"
"sync"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
"github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/stream"
)
func pathConfCanBeUpdated(oldPathConf *conf.Path, newPathConf *conf.Path) bool {
func pathConfCanBeUpdated(oldPathConf *conf.PathConf, newPathConf *conf.PathConf) bool {
clone := oldPathConf.Clone()
clone.Record = newPathConf.Record
clone.RPICameraBrightness = newPathConf.RPICameraBrightness
clone.RPICameraContrast = newPathConf.RPICameraContrast
clone.RPICameraSaturation = newPathConf.RPICameraSaturation
clone.RPICameraSharpness = newPathConf.RPICameraSharpness
clone.RPICameraExposure = newPathConf.RPICameraExposure
clone.RPICameraAWB = newPathConf.RPICameraAWB
clone.RPICameraAWBGains = newPathConf.RPICameraAWBGains
clone.RPICameraDenoise = newPathConf.RPICameraDenoise
clone.RPICameraShutter = newPathConf.RPICameraShutter
clone.RPICameraMetering = newPathConf.RPICameraMetering
clone.RPICameraGain = newPathConf.RPICameraGain
clone.RPICameraEV = newPathConf.RPICameraEV
clone.RPICameraFPS = newPathConf.RPICameraFPS
clone.RPICameraIDRPeriod = newPathConf.RPICameraIDRPeriod
clone.RPICameraBitrate = newPathConf.RPICameraBitrate
return newPathConf.Equal(clone)
}
type pathManagerHLSServer interface {
PathReady(defs.Path)
PathNotReady(defs.Path)
func getConfForPath(pathConfs map[string]*conf.PathConf, name string) (string, *conf.PathConf, []string, error) {
err := conf.IsValidPathName(name)
if err != nil {
return "", nil, nil, fmt.Errorf("invalid path name: %s (%s)", err, name)
}
// normal path
if pathConf, ok := pathConfs[name]; ok {
return name, pathConf, nil, nil
}
// regular expression-based path
for pathConfName, pathConf := range pathConfs {
if pathConf.Regexp != nil {
m := pathConf.Regexp.FindStringSubmatch(name)
if m != nil {
return pathConfName, pathConf, m, nil
}
}
}
return "", nil, nil, fmt.Errorf("path '%s' is not configured", name)
}
type pathManagerHLSManager interface {
pathReady(*path)
pathNotReady(*path)
}
type pathManagerParent interface {
@ -48,56 +64,82 @@ type pathManagerParent interface { @@ -48,56 +64,82 @@ type pathManagerParent interface {
}
type pathManager struct {
logLevel conf.LogLevel
authManager *auth.Manager
rtspAddress string
readTimeout conf.StringDuration
writeTimeout conf.StringDuration
writeQueueSize int
udpMaxPayloadSize int
pathConfs map[string]*conf.Path
externalCmdPool *externalcmd.Pool
parent pathManagerParent
externalAuthenticationURL string
rtspAddress string
authMethods conf.AuthMethods
readTimeout conf.StringDuration
writeTimeout conf.StringDuration
readBufferCount int
udpMaxPayloadSize int
pathConfs map[string]*conf.PathConf
externalCmdPool *externalcmd.Pool
metrics *metrics
parent pathManagerParent
ctx context.Context
ctxCancel func()
wg sync.WaitGroup
hlsManager pathManagerHLSServer
hlsManager pathManagerHLSManager
paths map[string]*path
pathsByConf map[string]map[*path]struct{}
// in
chReloadConf chan map[string]*conf.Path
chSetHLSServer chan pathManagerHLSServer
chClosePath chan *path
chPathReady chan *path
chPathNotReady chan *path
chFindPathConf chan defs.PathFindPathConfReq
chDescribe chan defs.PathDescribeReq
chAddReader chan defs.PathAddReaderReq
chAddPublisher chan defs.PathAddPublisherReq
chAPIPathsList chan pathAPIPathsListReq
chAPIPathsGet chan pathAPIPathsGetReq
}
func (pm *pathManager) initialize() {
chConfReload chan map[string]*conf.PathConf
chPathClose chan *path
chPathReady chan *path
chPathNotReady chan *path
chGetConfForPath chan pathGetConfForPathReq
chDescribe chan pathDescribeReq
chReaderAdd chan pathReaderAddReq
chPublisherAdd chan pathPublisherAddReq
chHLSManagerSet chan pathManagerHLSManager
chAPIPathsList chan pathAPIPathsListReq
chAPIPathsGet chan pathAPIPathsGetReq
}
func newPathManager(
externalAuthenticationURL string,
rtspAddress string,
authMethods conf.AuthMethods,
readTimeout conf.StringDuration,
writeTimeout conf.StringDuration,
readBufferCount int,
udpMaxPayloadSize int,
pathConfs map[string]*conf.PathConf,
externalCmdPool *externalcmd.Pool,
metrics *metrics,
parent pathManagerParent,
) *pathManager {
ctx, ctxCancel := context.WithCancel(context.Background())
pm.ctx = ctx
pm.ctxCancel = ctxCancel
pm.paths = make(map[string]*path)
pm.pathsByConf = make(map[string]map[*path]struct{})
pm.chReloadConf = make(chan map[string]*conf.Path)
pm.chSetHLSServer = make(chan pathManagerHLSServer)
pm.chClosePath = make(chan *path)
pm.chPathReady = make(chan *path)
pm.chPathNotReady = make(chan *path)
pm.chFindPathConf = make(chan defs.PathFindPathConfReq)
pm.chDescribe = make(chan defs.PathDescribeReq)
pm.chAddReader = make(chan defs.PathAddReaderReq)
pm.chAddPublisher = make(chan defs.PathAddPublisherReq)
pm.chAPIPathsList = make(chan pathAPIPathsListReq)
pm.chAPIPathsGet = make(chan pathAPIPathsGetReq)
pm := &pathManager{
externalAuthenticationURL: externalAuthenticationURL,
rtspAddress: rtspAddress,
authMethods: authMethods,
readTimeout: readTimeout,
writeTimeout: writeTimeout,
readBufferCount: readBufferCount,
udpMaxPayloadSize: udpMaxPayloadSize,
pathConfs: pathConfs,
externalCmdPool: externalCmdPool,
metrics: metrics,
parent: parent,
ctx: ctx,
ctxCancel: ctxCancel,
paths: make(map[string]*path),
pathsByConf: make(map[string]map[*path]struct{}),
chConfReload: make(chan map[string]*conf.PathConf),
chPathClose: make(chan *path),
chPathReady: make(chan *path),
chPathNotReady: make(chan *path),
chGetConfForPath: make(chan pathGetConfForPathReq),
chDescribe: make(chan pathDescribeReq),
chReaderAdd: make(chan pathReaderAddReq),
chPublisherAdd: make(chan pathPublisherAddReq),
chHLSManagerSet: make(chan pathManagerHLSManager),
chAPIPathsList: make(chan pathAPIPathsListReq),
chAPIPathsGet: make(chan pathAPIPathsGetReq),
}
for pathConfName, pathConf := range pm.pathConfs {
if pathConf.Regexp == nil {
@ -105,10 +147,16 @@ func (pm *pathManager) initialize() { @@ -105,10 +147,16 @@ func (pm *pathManager) initialize() {
}
}
if pm.metrics != nil {
pm.metrics.pathManagerSet(pm)
}
pm.Log(logger.Debug, "path manager created")
pm.wg.Add(1)
go pm.run()
return pm
}
func (pm *pathManager) close() {
@ -117,7 +165,7 @@ func (pm *pathManager) close() { @@ -117,7 +165,7 @@ func (pm *pathManager) close() {
pm.wg.Wait()
}
// Log implements logger.Writer.
// Log is the main logging function.
func (pm *pathManager) Log(level logger.Level, format string, args ...interface{}) {
pm.parent.Log(level, format, args...)
}
@ -128,57 +176,25 @@ func (pm *pathManager) run() { @@ -128,57 +176,25 @@ func (pm *pathManager) run() {
outer:
for {
select {
case newPaths := <-pm.chReloadConf:
pm.doReloadConf(newPaths)
case m := <-pm.chSetHLSServer:
pm.doSetHLSServer(m)
case pa := <-pm.chClosePath:
pm.doClosePath(pa)
case pa := <-pm.chPathReady:
pm.doPathReady(pa)
case pa := <-pm.chPathNotReady:
pm.doPathNotReady(pa)
case req := <-pm.chFindPathConf:
pm.doFindPathConf(req)
case req := <-pm.chDescribe:
pm.doDescribe(req)
case req := <-pm.chAddReader:
pm.doAddReader(req)
case req := <-pm.chAddPublisher:
pm.doAddPublisher(req)
case req := <-pm.chAPIPathsList:
pm.doAPIPathsList(req)
case req := <-pm.chAPIPathsGet:
pm.doAPIPathsGet(req)
case <-pm.ctx.Done():
break outer
}
}
pm.ctxCancel()
}
func (pm *pathManager) doReloadConf(newPaths map[string]*conf.Path) {
for confName, pathConf := range pm.pathConfs {
if newPath, ok := newPaths[confName]; ok {
// configuration has changed
if !newPath.Equal(pathConf) {
if pathConfCanBeUpdated(pathConf, newPath) { // paths associated with the configuration can be updated
for pa := range pm.pathsByConf[confName] {
go pa.reloadConf(newPath)
case newPathConfs := <-pm.chConfReload:
for confName, pathConf := range pm.pathConfs {
if newPathConf, ok := newPathConfs[confName]; ok {
// configuration has changed
if !newPathConf.Equal(pathConf) {
if pathConfCanBeUpdated(pathConf, newPathConf) { // paths associated with the configuration can be updated
for pa := range pm.pathsByConf[confName] {
go pa.reloadConf(newPathConf)
}
} else { // paths associated with the configuration must be recreated
for pa := range pm.pathsByConf[confName] {
pm.removePath(pa)
pa.close()
pa.wait() // avoid conflicts between sources
}
}
}
} else { // paths associated with the configuration must be recreated
} else {
// configuration has been deleted, remove associated paths
for pa := range pm.pathsByConf[confName] {
pm.removePath(pa)
pa.close()
@ -186,175 +202,165 @@ func (pm *pathManager) doReloadConf(newPaths map[string]*conf.Path) { @@ -186,175 +202,165 @@ func (pm *pathManager) doReloadConf(newPaths map[string]*conf.Path) {
}
}
}
} else {
// configuration has been deleted, remove associated paths
for pa := range pm.pathsByConf[confName] {
pm.removePath(pa)
pa.close()
pa.wait() // avoid conflicts between sources
pm.pathConfs = newPathConfs
// add new paths
for pathConfName, pathConf := range pm.pathConfs {
if _, ok := pm.paths[pathConfName]; !ok && pathConf.Regexp == nil {
pm.createPath(pathConfName, pathConf, pathConfName, nil)
}
}
}
}
pm.pathConfs = newPaths
case pa := <-pm.chPathClose:
if pmpa, ok := pm.paths[pa.name]; !ok || pmpa != pa {
continue
}
pm.removePath(pa)
// add new paths
for pathConfName, pathConf := range pm.pathConfs {
if _, ok := pm.paths[pathConfName]; !ok && pathConf.Regexp == nil {
pm.createPath(pathConfName, pathConf, pathConfName, nil)
}
}
}
case pa := <-pm.chPathReady:
if pm.hlsManager != nil {
pm.hlsManager.pathReady(pa)
}
func (pm *pathManager) doSetHLSServer(m pathManagerHLSServer) {
pm.hlsManager = m
}
case pa := <-pm.chPathNotReady:
if pm.hlsManager != nil {
pm.hlsManager.pathNotReady(pa)
}
func (pm *pathManager) doClosePath(pa *path) {
if pmpa, ok := pm.paths[pa.name]; !ok || pmpa != pa {
return
}
pm.removePath(pa)
}
case req := <-pm.chGetConfForPath:
_, pathConf, _, err := getConfForPath(pm.pathConfs, req.name)
if err != nil {
req.res <- pathGetConfForPathRes{err: err}
continue
}
func (pm *pathManager) doPathReady(pa *path) {
if pm.hlsManager != nil {
pm.hlsManager.PathReady(pa)
}
}
err = authenticate(pm.externalAuthenticationURL, pm.authMethods,
req.name, pathConf, req.publish, req.credentials)
if err != nil {
req.res <- pathGetConfForPathRes{err: pathErrAuth{wrapped: err}}
continue
}
func (pm *pathManager) doPathNotReady(pa *path) {
if pm.hlsManager != nil {
pm.hlsManager.PathNotReady(pa)
}
}
req.res <- pathGetConfForPathRes{conf: pathConf}
func (pm *pathManager) doFindPathConf(req defs.PathFindPathConfReq) {
_, pathConf, _, err := conf.FindPathConf(pm.pathConfs, req.AccessRequest.Name)
if err != nil {
req.Res <- defs.PathFindPathConfRes{Err: err}
return
}
case req := <-pm.chDescribe:
pathConfName, pathConf, pathMatches, err := getConfForPath(pm.pathConfs, req.pathName)
if err != nil {
req.res <- pathDescribeRes{err: err}
continue
}
err = pm.authManager.Authenticate(req.AccessRequest.ToAuthRequest())
if err != nil {
req.Res <- defs.PathFindPathConfRes{Err: err}
return
}
err = authenticate(pm.externalAuthenticationURL, pm.authMethods, req.pathName, pathConf, false, req.credentials)
if err != nil {
req.res <- pathDescribeRes{err: pathErrAuth{wrapped: err}}
continue
}
req.Res <- defs.PathFindPathConfRes{Conf: pathConf}
}
// create path if it doesn't exist
if _, ok := pm.paths[req.pathName]; !ok {
pm.createPath(pathConfName, pathConf, req.pathName, pathMatches)
}
func (pm *pathManager) doDescribe(req defs.PathDescribeReq) {
pathConfName, pathConf, pathMatches, err := conf.FindPathConf(pm.pathConfs, req.AccessRequest.Name)
if err != nil {
req.Res <- defs.PathDescribeRes{Err: err}
return
}
req.res <- pathDescribeRes{path: pm.paths[req.pathName]}
err = pm.authManager.Authenticate(req.AccessRequest.ToAuthRequest())
if err != nil {
req.Res <- defs.PathDescribeRes{Err: err}
return
}
case req := <-pm.chReaderAdd:
pathConfName, pathConf, pathMatches, err := getConfForPath(pm.pathConfs, req.pathName)
if err != nil {
req.res <- pathReaderSetupPlayRes{err: err}
continue
}
// create path if it doesn't exist
if _, ok := pm.paths[req.AccessRequest.Name]; !ok {
pm.createPath(pathConfName, pathConf, req.AccessRequest.Name, pathMatches)
}
if !req.skipAuth {
err = authenticate(pm.externalAuthenticationURL, pm.authMethods, req.pathName, pathConf, false, req.credentials)
if err != nil {
req.res <- pathReaderSetupPlayRes{err: pathErrAuth{wrapped: err}}
continue
}
}
req.Res <- defs.PathDescribeRes{Path: pm.paths[req.AccessRequest.Name]}
}
// create path if it doesn't exist
if _, ok := pm.paths[req.pathName]; !ok {
pm.createPath(pathConfName, pathConf, req.pathName, pathMatches)
}
func (pm *pathManager) doAddReader(req defs.PathAddReaderReq) {
pathConfName, pathConf, pathMatches, err := conf.FindPathConf(pm.pathConfs, req.AccessRequest.Name)
if err != nil {
req.Res <- defs.PathAddReaderRes{Err: err}
return
}
req.res <- pathReaderSetupPlayRes{path: pm.paths[req.pathName]}
if !req.AccessRequest.SkipAuth {
err = pm.authManager.Authenticate(req.AccessRequest.ToAuthRequest())
if err != nil {
req.Res <- defs.PathAddReaderRes{Err: err}
return
}
}
case req := <-pm.chPublisherAdd:
pathConfName, pathConf, pathMatches, err := getConfForPath(pm.pathConfs, req.pathName)
if err != nil {
req.res <- pathPublisherAnnounceRes{err: err}
continue
}
// create path if it doesn't exist
if _, ok := pm.paths[req.AccessRequest.Name]; !ok {
pm.createPath(pathConfName, pathConf, req.AccessRequest.Name, pathMatches)
}
if !req.skipAuth {
err = authenticate(pm.externalAuthenticationURL, pm.authMethods, req.pathName, pathConf, true, req.credentials)
if err != nil {
req.res <- pathPublisherAnnounceRes{err: pathErrAuth{wrapped: err}}
continue
}
}
req.Res <- defs.PathAddReaderRes{Path: pm.paths[req.AccessRequest.Name]}
}
// create path if it doesn't exist
if _, ok := pm.paths[req.pathName]; !ok {
pm.createPath(pathConfName, pathConf, req.pathName, pathMatches)
}
func (pm *pathManager) doAddPublisher(req defs.PathAddPublisherReq) {
pathConfName, pathConf, pathMatches, err := conf.FindPathConf(pm.pathConfs, req.AccessRequest.Name)
if err != nil {
req.Res <- defs.PathAddPublisherRes{Err: err}
return
}
req.res <- pathPublisherAnnounceRes{path: pm.paths[req.pathName]}
if !req.AccessRequest.SkipAuth {
err = pm.authManager.Authenticate(req.AccessRequest.ToAuthRequest())
if err != nil {
req.Res <- defs.PathAddPublisherRes{Err: err}
return
}
}
case s := <-pm.chHLSManagerSet:
pm.hlsManager = s
// create path if it doesn't exist
if _, ok := pm.paths[req.AccessRequest.Name]; !ok {
pm.createPath(pathConfName, pathConf, req.AccessRequest.Name, pathMatches)
}
case req := <-pm.chAPIPathsList:
paths := make(map[string]*path)
req.Res <- defs.PathAddPublisherRes{Path: pm.paths[req.AccessRequest.Name]}
}
for name, pa := range pm.paths {
paths[name] = pa
}
func (pm *pathManager) doAPIPathsList(req pathAPIPathsListReq) {
paths := make(map[string]*path)
req.res <- pathAPIPathsListRes{paths: paths}
for name, pa := range pm.paths {
paths[name] = pa
}
case req := <-pm.chAPIPathsGet:
path, ok := pm.paths[req.name]
if !ok {
req.res <- pathAPIPathsGetRes{err: errAPINotFound}
continue
}
req.res <- pathAPIPathsListRes{paths: paths}
}
req.res <- pathAPIPathsGetRes{path: path}
func (pm *pathManager) doAPIPathsGet(req pathAPIPathsGetReq) {
path, ok := pm.paths[req.name]
if !ok {
req.res <- pathAPIPathsGetRes{err: conf.ErrPathNotFound}
return
case <-pm.ctx.Done():
break outer
}
}
req.res <- pathAPIPathsGetRes{path: path}
pm.ctxCancel()
if pm.metrics != nil {
pm.metrics.pathManagerSet(nil)
}
}
func (pm *pathManager) createPath(
pathConfName string,
pathConf *conf.Path,
pathConf *conf.PathConf,
name string,
matches []string,
) {
pa := &path{
parentCtx: pm.ctx,
logLevel: pm.logLevel,
rtspAddress: pm.rtspAddress,
readTimeout: pm.readTimeout,
writeTimeout: pm.writeTimeout,
writeQueueSize: pm.writeQueueSize,
udpMaxPayloadSize: pm.udpMaxPayloadSize,
confName: pathConfName,
conf: pathConf,
name: name,
matches: matches,
wg: &pm.wg,
externalCmdPool: pm.externalCmdPool,
parent: pm,
}
pa.initialize()
pa := newPath(
pm.ctx,
pm.rtspAddress,
pm.readTimeout,
pm.writeTimeout,
pm.readBufferCount,
pm.udpMaxPayloadSize,
pathConfName,
pathConf,
name,
matches,
&pm.wg,
pm.externalCmdPool,
pm)
pm.paths[name] = pa
@ -372,10 +378,10 @@ func (pm *pathManager) removePath(pa *path) { @@ -372,10 +378,10 @@ func (pm *pathManager) removePath(pa *path) {
delete(pm.paths, pa.name)
}
// ReloadPathConfs is called by core.
func (pm *pathManager) ReloadPathConfs(pathConfs map[string]*conf.Path) {
// confReload is called by core.
func (pm *pathManager) confReload(pathConfs map[string]*conf.PathConf) {
select {
case pm.chReloadConf <- pathConfs:
case pm.chConfReload <- pathConfs:
case <-pm.ctx.Done():
}
}
@ -398,95 +404,94 @@ func (pm *pathManager) pathNotReady(pa *path) { @@ -398,95 +404,94 @@ func (pm *pathManager) pathNotReady(pa *path) {
}
}
// closePath is called by path.
func (pm *pathManager) closePath(pa *path) {
// onPathClose is called by path.
func (pm *pathManager) onPathClose(pa *path) {
select {
case pm.chClosePath <- pa:
case pm.chPathClose <- pa:
case <-pm.ctx.Done():
case <-pa.ctx.Done(): // in case pathManager is blocked by path.wait()
}
}
// GetConfForPath is called by a reader or publisher.
func (pm *pathManager) FindPathConf(req defs.PathFindPathConfReq) (*conf.Path, error) {
req.Res = make(chan defs.PathFindPathConfRes)
// getConfForPath is called by a reader or publisher.
func (pm *pathManager) getConfForPath(req pathGetConfForPathReq) pathGetConfForPathRes {
req.res = make(chan pathGetConfForPathRes)
select {
case pm.chFindPathConf <- req:
res := <-req.Res
return res.Conf, res.Err
case pm.chGetConfForPath <- req:
return <-req.res
case <-pm.ctx.Done():
return nil, fmt.Errorf("terminated")
return pathGetConfForPathRes{err: fmt.Errorf("terminated")}
}
}
// Describe is called by a reader or publisher.
func (pm *pathManager) Describe(req defs.PathDescribeReq) defs.PathDescribeRes {
req.Res = make(chan defs.PathDescribeRes)
// describe is called by a reader or publisher.
func (pm *pathManager) describe(req pathDescribeReq) pathDescribeRes {
req.res = make(chan pathDescribeRes)
select {
case pm.chDescribe <- req:
res1 := <-req.Res
if res1.Err != nil {
res1 := <-req.res
if res1.err != nil {
return res1
}
res2 := res1.Path.(*path).describe(req)
if res2.Err != nil {
res2 := res1.path.describe(req)
if res2.err != nil {
return res2
}
res2.Path = res1.Path
res2.path = res1.path
return res2
case <-pm.ctx.Done():
return defs.PathDescribeRes{Err: fmt.Errorf("terminated")}
return pathDescribeRes{err: fmt.Errorf("terminated")}
}
}
// AddPublisher is called by a publisher.
func (pm *pathManager) AddPublisher(req defs.PathAddPublisherReq) (defs.Path, error) {
req.Res = make(chan defs.PathAddPublisherRes)
// publisherAnnounce is called by a publisher.
func (pm *pathManager) publisherAdd(req pathPublisherAddReq) pathPublisherAnnounceRes {
req.res = make(chan pathPublisherAnnounceRes)
select {
case pm.chAddPublisher <- req:
res := <-req.Res
if res.Err != nil {
return nil, res.Err
case pm.chPublisherAdd <- req:
res := <-req.res
if res.err != nil {
return res
}
return res.Path.(*path).addPublisher(req)
return res.path.publisherAdd(req)
case <-pm.ctx.Done():
return nil, fmt.Errorf("terminated")
return pathPublisherAnnounceRes{err: fmt.Errorf("terminated")}
}
}
// AddReader is called by a reader.
func (pm *pathManager) AddReader(req defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) {
req.Res = make(chan defs.PathAddReaderRes)
// readerSetupPlay is called by a reader.
func (pm *pathManager) readerAdd(req pathReaderAddReq) pathReaderSetupPlayRes {
req.res = make(chan pathReaderSetupPlayRes)
select {
case pm.chAddReader <- req:
res := <-req.Res
if res.Err != nil {
return nil, nil, res.Err
case pm.chReaderAdd <- req:
res := <-req.res
if res.err != nil {
return res
}
return res.Path.(*path).addReader(req)
return res.path.readerAdd(req)
case <-pm.ctx.Done():
return nil, nil, fmt.Errorf("terminated")
return pathReaderSetupPlayRes{err: fmt.Errorf("terminated")}
}
}
// setHLSServer is called by hlsManager.
func (pm *pathManager) setHLSServer(s pathManagerHLSServer) {
// hlsManagerSet is called by hlsManager.
func (pm *pathManager) hlsManagerSet(s pathManagerHLSManager) {
select {
case pm.chSetHLSServer <- s:
case pm.chHLSManagerSet <- s:
case <-pm.ctx.Done():
}
}
// APIPathsList is called by api.
func (pm *pathManager) APIPathsList() (*defs.APIPathList, error) {
// apiPathsList is called by api.
func (pm *pathManager) apiPathsList() (*apiPathsList, error) {
req := pathAPIPathsListReq{
res: make(chan pathAPIPathsListRes),
}
@ -495,12 +500,12 @@ func (pm *pathManager) APIPathsList() (*defs.APIPathList, error) { @@ -495,12 +500,12 @@ func (pm *pathManager) APIPathsList() (*defs.APIPathList, error) {
case pm.chAPIPathsList <- req:
res := <-req.res
res.data = &defs.APIPathList{
Items: []*defs.APIPath{},
res.data = &apiPathsList{
Items: []*apiPath{},
}
for _, pa := range res.paths {
item, err := pa.APIPathsGet(pathAPIPathsGetReq{})
item, err := pa.apiPathsGet(pathAPIPathsGetReq{})
if err == nil {
res.data.Items = append(res.data.Items, item)
}
@ -517,8 +522,8 @@ func (pm *pathManager) APIPathsList() (*defs.APIPathList, error) { @@ -517,8 +522,8 @@ func (pm *pathManager) APIPathsList() (*defs.APIPathList, error) {
}
}
// APIPathsGet is called by api.
func (pm *pathManager) APIPathsGet(name string) (*defs.APIPath, error) {
// apiPathsGet is called by api.
func (pm *pathManager) apiPathsGet(name string) (*apiPath, error) {
req := pathAPIPathsGetReq{
name: name,
res: make(chan pathAPIPathsGetRes),
@ -531,7 +536,7 @@ func (pm *pathManager) APIPathsGet(name string) (*defs.APIPath, error) { @@ -531,7 +536,7 @@ func (pm *pathManager) APIPathsGet(name string) (*defs.APIPath, error) {
return nil, res.err
}
data, err := res.path.APIPathsGet(req)
data, err := res.path.apiPathsGet(req)
return data, err
case <-pm.ctx.Done():

84
internal/core/path_manager_test.go

@ -1,84 +0,0 @@ @@ -1,84 +0,0 @@
package core
import (
"bufio"
"net"
"testing"
"github.com/bluenviron/gortsplib/v4/pkg/base"
"github.com/bluenviron/gortsplib/v4/pkg/headers"
"github.com/stretchr/testify/require"
)
func TestPathAutoDeletion(t *testing.T) {
for _, ca := range []string{"describe", "setup"} {
t.Run(ca, func(t *testing.T) {
p, ok := newInstance("paths:\n" +
" all_others:\n")
require.Equal(t, true, ok)
defer p.Close()
func() {
conn, err := net.Dial("tcp", "localhost:8554")
require.NoError(t, err)
defer conn.Close()
br := bufio.NewReader(conn)
if ca == "describe" {
u, err := base.ParseURL("rtsp://localhost:8554/mypath")
require.NoError(t, err)
byts, _ := base.Request{
Method: base.Describe,
URL: u,
Header: base.Header{
"CSeq": base.HeaderValue{"1"},
},
}.Marshal()
_, err = conn.Write(byts)
require.NoError(t, err)
var res base.Response
err = res.Unmarshal(br)
require.NoError(t, err)
require.Equal(t, base.StatusNotFound, res.StatusCode)
} else {
u, err := base.ParseURL("rtsp://localhost:8554/mypath/trackID=0")
require.NoError(t, err)
byts, _ := base.Request{
Method: base.Setup,
URL: u,
Header: base.Header{
"CSeq": base.HeaderValue{"1"},
"Transport": headers.Transport{
Mode: func() *headers.TransportMode {
v := headers.TransportModePlay
return &v
}(),
Delivery: func() *headers.TransportDelivery {
v := headers.TransportDeliveryUnicast
return &v
}(),
Protocol: headers.TransportProtocolUDP,
ClientPorts: &[2]int{35466, 35467},
}.Marshal(),
},
}.Marshal()
_, err = conn.Write(byts)
require.NoError(t, err)
var res base.Response
err = res.Unmarshal(br)
require.NoError(t, err)
require.Equal(t, base.StatusNotFound, res.StatusCode)
}
}()
data, err := p.pathManager.APIPathsList()
require.NoError(t, err)
require.Equal(t, 0, len(data.Items))
})
}
}

797
internal/core/path_test.go

@ -1,797 +0,0 @@ @@ -1,797 +0,0 @@
package core
import (
"bufio"
"context"
"fmt"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/bluenviron/gortsplib/v4"
"github.com/bluenviron/gortsplib/v4/pkg/base"
"github.com/bluenviron/gortsplib/v4/pkg/description"
"github.com/bluenviron/gortsplib/v4/pkg/headers"
"github.com/bluenviron/gortsplib/v4/pkg/sdp"
srt "github.com/datarhei/gosrt"
"github.com/pion/rtp"
"github.com/stretchr/testify/require"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/protocols/rtmp"
"github.com/bluenviron/mediamtx/internal/protocols/webrtc"
"github.com/bluenviron/mediamtx/internal/test"
)
var runOnDemandSampleScript = `
package main
import (
"os"
"os/signal"
"syscall"
"github.com/bluenviron/gortsplib/v4"
"github.com/bluenviron/gortsplib/v4/pkg/description"
"github.com/bluenviron/gortsplib/v4/pkg/format"
)
func main() {
if os.Getenv("MTX_PATH") != "ondemand" ||
os.Getenv("MTX_QUERY") != "param=value" ||
os.Getenv("G1") != "on" {
panic("environment not set")
}
medi := &description.Media{
Type: description.MediaTypeVideo,
Formats: []format.Format{&format.H264{
PayloadTyp: 96,
SPS: []byte{
0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02,
0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04,
0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9, 0x20,
},
PPS: []byte{0x01, 0x02, 0x03, 0x04},
PacketizationMode: 1,
}},
}
source := gortsplib.Client{}
err := source.StartRecording(
"rtsp://localhost:" + os.Getenv("RTSP_PORT") + "/" + os.Getenv("MTX_PATH"),
&description.Session{Medias: []*description.Media{medi}})
if err != nil {
panic(err)
}
defer source.Close()
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT)
<-c
err = os.WriteFile("ON_DEMAND_FILE", []byte(""), 0644)
if err != nil {
panic(err)
}
}
`
type testServer struct {
onDescribe func(*gortsplib.ServerHandlerOnDescribeCtx) (*base.Response, *gortsplib.ServerStream, error)
onSetup func(*gortsplib.ServerHandlerOnSetupCtx) (*base.Response, *gortsplib.ServerStream, error)
onPlay func(*gortsplib.ServerHandlerOnPlayCtx) (*base.Response, error)
}
func (sh *testServer) OnDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx,
) (*base.Response, *gortsplib.ServerStream, error) {
return sh.onDescribe(ctx)
}
func (sh *testServer) OnSetup(ctx *gortsplib.ServerHandlerOnSetupCtx) (*base.Response, *gortsplib.ServerStream, error) {
return sh.onSetup(ctx)
}
func (sh *testServer) OnPlay(ctx *gortsplib.ServerHandlerOnPlayCtx) (*base.Response, error) {
return sh.onPlay(ctx)
}
var _ defs.Path = &path{}
func TestPathRunOnDemand(t *testing.T) {
onDemandFile := filepath.Join(os.TempDir(), "ondemand")
onUnDemandFile := filepath.Join(os.TempDir(), "onundemand")
srcFile := filepath.Join(os.TempDir(), "ondemand.go")
err := os.WriteFile(srcFile,
[]byte(strings.ReplaceAll(runOnDemandSampleScript, "ON_DEMAND_FILE", onDemandFile)), 0o644)
require.NoError(t, err)
execFile := filepath.Join(os.TempDir(), "ondemand_cmd")
cmd := exec.Command("go", "build", "-o", execFile, srcFile)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
require.NoError(t, err)
defer os.Remove(execFile)
os.Remove(srcFile)
for _, ca := range []string{"describe", "setup", "describe and setup"} {
t.Run(ca, func(t *testing.T) {
defer os.Remove(onDemandFile)
defer os.Remove(onUnDemandFile)
p1, ok := newInstance(fmt.Sprintf("rtmp: no\n"+
"hls: no\n"+
"webrtc: no\n"+
"paths:\n"+
" '~^(on)demand$':\n"+
" runOnDemand: %s\n"+
" runOnDemandCloseAfter: 1s\n"+
" runOnUnDemand: touch %s\n", execFile, onUnDemandFile))
require.Equal(t, true, ok)
defer p1.Close()
var control string
func() {
conn, err := net.Dial("tcp", "localhost:8554")
require.NoError(t, err)
defer conn.Close()
br := bufio.NewReader(conn)
if ca == "describe" || ca == "describe and setup" {
u, err := base.ParseURL("rtsp://localhost:8554/ondemand?param=value")
require.NoError(t, err)
byts, _ := base.Request{
Method: base.Describe,
URL: u,
Header: base.Header{
"CSeq": base.HeaderValue{"1"},
},
}.Marshal()
_, err = conn.Write(byts)
require.NoError(t, err)
var res base.Response
err = res.Unmarshal(br)
require.NoError(t, err)
require.Equal(t, base.StatusOK, res.StatusCode)
var desc sdp.SessionDescription
err = desc.Unmarshal(res.Body)
require.NoError(t, err)
control, _ = desc.MediaDescriptions[0].Attribute("control")
} else {
control = "rtsp://localhost:8554/ondemand?param=value/"
}
if ca == "setup" || ca == "describe and setup" {
u, err := base.ParseURL(control)
require.NoError(t, err)
byts, _ := base.Request{
Method: base.Setup,
URL: u,
Header: base.Header{
"CSeq": base.HeaderValue{"2"},
"Transport": headers.Transport{
Mode: func() *headers.TransportMode {
v := headers.TransportModePlay
return &v
}(),
Protocol: headers.TransportProtocolTCP,
InterleavedIDs: &[2]int{0, 1},
}.Marshal(),
},
}.Marshal()
_, err = conn.Write(byts)
require.NoError(t, err)
var res base.Response
err = res.Unmarshal(br)
require.NoError(t, err)
require.Equal(t, base.StatusOK, res.StatusCode)
}
}()
for {
_, err := os.Stat(onUnDemandFile)
if err == nil {
break
}
time.Sleep(100 * time.Millisecond)
}
_, err := os.Stat(onDemandFile)
require.NoError(t, err)
})
}
}
func TestPathRunOnConnect(t *testing.T) {
for _, ca := range []string{"rtsp", "rtmp", "srt"} {
t.Run(ca, func(t *testing.T) {
onConnectFile := filepath.Join(os.TempDir(), "onconnect")
defer os.Remove(onConnectFile)
onDisconnectFile := filepath.Join(os.TempDir(), "ondisconnect")
defer os.Remove(onDisconnectFile)
func() {
p, ok := newInstance(fmt.Sprintf(
"paths:\n"+
" test:\n"+
"runOnConnect: touch %s\n"+
"runOnDisconnect: touch %s\n",
onConnectFile, onDisconnectFile))
require.Equal(t, true, ok)
defer p.Close()
switch ca {
case "rtsp":
c := gortsplib.Client{}
err := c.StartRecording(
"rtsp://localhost:8554/test",
&description.Session{Medias: []*description.Media{test.UniqueMediaH264()}})
require.NoError(t, err)
defer c.Close()
case "rtmp":
u, err := url.Parse("rtmp://127.0.0.1:1935/test")
require.NoError(t, err)
nconn, err := net.Dial("tcp", u.Host)
require.NoError(t, err)
defer nconn.Close()
_, err = rtmp.NewClientConn(nconn, u, true)
require.NoError(t, err)
case "srt":
conf := srt.DefaultConfig()
address, err := conf.UnmarshalURL("srt://localhost:8890?streamid=publish:test")
require.NoError(t, err)
err = conf.Validate()
require.NoError(t, err)
c, err := srt.Dial("srt", address, conf)
require.NoError(t, err)
defer c.Close()
}
time.Sleep(500 * time.Millisecond)
}()
_, err := os.Stat(onConnectFile)
require.NoError(t, err)
_, err = os.Stat(onDisconnectFile)
require.NoError(t, err)
})
}
}
func TestPathRunOnReady(t *testing.T) {
onReadyFile := filepath.Join(os.TempDir(), "onready")
defer os.Remove(onReadyFile)
onNotReadyFile := filepath.Join(os.TempDir(), "onunready")
defer os.Remove(onNotReadyFile)
func() {
p, ok := newInstance(fmt.Sprintf("rtmp: no\n"+
"hls: no\n"+
"webrtc: no\n"+
"paths:\n"+
" test:\n"+
" runOnReady: sh -c 'echo \"$MTX_PATH $MTX_QUERY\" > %s'\n"+
" runOnNotReady: sh -c 'echo \"$MTX_PATH $MTX_QUERY\" > %s'\n",
onReadyFile, onNotReadyFile))
require.Equal(t, true, ok)
defer p.Close()
c := gortsplib.Client{}
err := c.StartRecording(
"rtsp://localhost:8554/test?query=value",
&description.Session{Medias: []*description.Media{test.UniqueMediaH264()}})
require.NoError(t, err)
defer c.Close()
time.Sleep(500 * time.Millisecond)
}()
byts, err := os.ReadFile(onReadyFile)
require.NoError(t, err)
require.Equal(t, "test query=value\n", string(byts))
byts, err = os.ReadFile(onNotReadyFile)
require.NoError(t, err)
require.Equal(t, "test query=value\n", string(byts))
}
func TestPathRunOnRead(t *testing.T) {
for _, ca := range []string{"rtsp", "rtmp", "srt", "webrtc"} {
t.Run(ca, func(t *testing.T) {
onReadFile := filepath.Join(os.TempDir(), "onread")
defer os.Remove(onReadFile)
onUnreadFile := filepath.Join(os.TempDir(), "onunread")
defer os.Remove(onUnreadFile)
func() {
p, ok := newInstance(fmt.Sprintf(
"paths:\n"+
" test:\n"+
" runOnRead: sh -c 'echo \"$MTX_PATH $MTX_QUERY\" > %s'\n"+
" runOnUnread: sh -c 'echo \"$MTX_PATH $MTX_QUERY\" > %s'\n",
onReadFile, onUnreadFile))
require.Equal(t, true, ok)
defer p.Close()
media0 := test.UniqueMediaH264()
source := gortsplib.Client{}
err := source.StartRecording(
"rtsp://localhost:8554/test",
&description.Session{Medias: []*description.Media{media0}})
require.NoError(t, err)
defer source.Close()
switch ca {
case "rtsp":
reader := gortsplib.Client{}
u, err := base.ParseURL("rtsp://127.0.0.1:8554/test?query=value")
require.NoError(t, err)
err = reader.Start(u.Scheme, u.Host)
require.NoError(t, err)
defer reader.Close()
desc, _, err := reader.Describe(u)
require.NoError(t, err)
err = reader.SetupAll(desc.BaseURL, desc.Medias)
require.NoError(t, err)
_, err = reader.Play(nil)
require.NoError(t, err)
case "rtmp":
u, err := url.Parse("rtmp://127.0.0.1:1935/test?query=value")
require.NoError(t, err)
nconn, err := net.Dial("tcp", u.Host)
require.NoError(t, err)
defer nconn.Close()
conn, err := rtmp.NewClientConn(nconn, u, false)
require.NoError(t, err)
_, err = rtmp.NewReader(conn)
require.NoError(t, err)
case "srt":
conf := srt.DefaultConfig()
address, err := conf.UnmarshalURL("srt://localhost:8890?streamid=read:test:query=value")
require.NoError(t, err)
err = conf.Validate()
require.NoError(t, err)
reader, err := srt.Dial("srt", address, conf)
require.NoError(t, err)
defer reader.Close()
case "webrtc":
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
u, err := url.Parse("http://localhost:8889/test/whep?query=value")
require.NoError(t, err)
c := &webrtc.WHIPClient{
HTTPClient: hc,
URL: u,
Log: test.NilLogger{},
}
writerDone := make(chan struct{})
defer func() { <-writerDone }()
writerTerminate := make(chan struct{})
defer close(writerTerminate)
go func() {
defer close(writerDone)
i := uint16(0)
for {
select {
case <-time.After(100 * time.Millisecond):
case <-writerTerminate:
return
}
err := source.WritePacketRTP(media0, &rtp.Packet{
Header: rtp.Header{
Version: 2,
Marker: true,
PayloadType: 96,
SequenceNumber: 123 + i,
Timestamp: 45343,
SSRC: 563423,
},
Payload: []byte{5},
})
require.NoError(t, err)
i++
}
}()
_, err = c.Read(context.Background())
require.NoError(t, err)
defer checkClose(t, c.Close)
}
time.Sleep(500 * time.Millisecond)
}()
byts, err := os.ReadFile(onReadFile)
require.NoError(t, err)
require.Equal(t, "test query=value\n", string(byts))
byts, err = os.ReadFile(onUnreadFile)
require.NoError(t, err)
require.Equal(t, "test query=value\n", string(byts))
})
}
}
func TestPathMaxReaders(t *testing.T) {
p, ok := newInstance("paths:\n" +
" all_others:\n" +
" maxReaders: 1\n")
require.Equal(t, true, ok)
defer p.Close()
source := gortsplib.Client{}
err := source.StartRecording(
"rtsp://localhost:8554/mystream",
&description.Session{Medias: []*description.Media{
test.UniqueMediaH264(),
test.UniqueMediaMPEG4Audio(),
}})
require.NoError(t, err)
defer source.Close()
for i := 0; i < 2; i++ {
reader := gortsplib.Client{}
u, err := base.ParseURL("rtsp://127.0.0.1:8554/mystream")
require.NoError(t, err)
err = reader.Start(u.Scheme, u.Host)
require.NoError(t, err)
defer reader.Close()
desc, _, err := reader.Describe(u)
require.NoError(t, err)
err = reader.SetupAll(desc.BaseURL, desc.Medias)
if i != 1 {
require.NoError(t, err)
} else {
require.Error(t, err)
}
}
}
func TestPathRecord(t *testing.T) {
dir, err := os.MkdirTemp("", "rtsp-path-record")
require.NoError(t, err)
defer os.RemoveAll(dir)
p, ok := newInstance("api: yes\n" +
"record: yes\n" +
"recordPath: " + filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f") + "\n" +
"paths:\n" +
" all_others:\n" +
" record: yes\n")
require.Equal(t, true, ok)
defer p.Close()
media0 := test.UniqueMediaH264()
source := gortsplib.Client{}
err = source.StartRecording(
"rtsp://localhost:8554/mystream",
&description.Session{Medias: []*description.Media{media0}})
require.NoError(t, err)
defer source.Close()
for i := 0; i < 4; i++ {
err := source.WritePacketRTP(media0, &rtp.Packet{
Header: rtp.Header{
Version: 2,
Marker: true,
PayloadType: 96,
SequenceNumber: 1123 + uint16(i),
Timestamp: 45343 + 90000*uint32(i),
SSRC: 563423,
},
Payload: []byte{5},
})
require.NoError(t, err)
}
time.Sleep(500 * time.Millisecond)
files, err := os.ReadDir(filepath.Join(dir, "mystream"))
require.NoError(t, err)
require.Equal(t, 1, len(files))
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
httpRequest(t, hc, http.MethodPatch, "http://localhost:9997/v3/config/paths/patch/all_others", map[string]interface{}{
"record": false,
}, nil)
time.Sleep(500 * time.Millisecond)
httpRequest(t, hc, http.MethodPatch, "http://localhost:9997/v3/config/paths/patch/all_others", map[string]interface{}{
"record": true,
}, nil)
time.Sleep(500 * time.Millisecond)
for i := 4; i < 8; i++ {
err := source.WritePacketRTP(media0, &rtp.Packet{
Header: rtp.Header{
Version: 2,
Marker: true,
PayloadType: 96,
SequenceNumber: 1123 + uint16(i),
Timestamp: 45343 + 90000*uint32(i),
SSRC: 563423,
},
Payload: []byte{5},
})
require.NoError(t, err)
}
time.Sleep(500 * time.Millisecond)
files, err = os.ReadDir(filepath.Join(dir, "mystream"))
require.NoError(t, err)
require.Equal(t, 2, len(files))
}
func TestPathFallback(t *testing.T) {
for _, ca := range []string{
"absolute",
"relative",
"source",
} {
t.Run(ca, func(t *testing.T) {
var conf string
switch ca {
case "absolute":
conf = "paths:\n" +
" path1:\n" +
" fallback: rtsp://localhost:8554/path2\n" +
" path2:\n"
case "relative":
conf = "paths:\n" +
" path1:\n" +
" fallback: /path2\n" +
" path2:\n"
case "source":
conf = "paths:\n" +
" path1:\n" +
" fallback: /path2\n" +
" source: rtsp://localhost:3333/nonexistent\n" +
" path2:\n"
}
p1, ok := newInstance(conf)
require.Equal(t, true, ok)
defer p1.Close()
source := gortsplib.Client{}
err := source.StartRecording("rtsp://localhost:8554/path2",
&description.Session{Medias: []*description.Media{test.UniqueMediaH264()}})
require.NoError(t, err)
defer source.Close()
u, err := base.ParseURL("rtsp://localhost:8554/path1")
require.NoError(t, err)
dest := gortsplib.Client{}
err = dest.Start(u.Scheme, u.Host)
require.NoError(t, err)
defer dest.Close()
desc, _, err := dest.Describe(u)
require.NoError(t, err)
require.Equal(t, 1, len(desc.Medias))
})
}
}
func TestPathSourceRegexp(t *testing.T) {
var stream *gortsplib.ServerStream
s := gortsplib.Server{
Handler: &testServer{
onDescribe: func(ctx *gortsplib.ServerHandlerOnDescribeCtx,
) (*base.Response, *gortsplib.ServerStream, error) {
require.Equal(t, "/a", ctx.Path)
return &base.Response{
StatusCode: base.StatusOK,
}, stream, nil
},
onSetup: func(_ *gortsplib.ServerHandlerOnSetupCtx) (*base.Response, *gortsplib.ServerStream, error) {
return &base.Response{
StatusCode: base.StatusOK,
}, stream, nil
},
onPlay: func(_ *gortsplib.ServerHandlerOnPlayCtx) (*base.Response, error) {
return &base.Response{
StatusCode: base.StatusOK,
}, nil
},
},
RTSPAddress: "127.0.0.1:8555",
}
err := s.Start()
require.NoError(t, err)
defer s.Close()
stream = gortsplib.NewServerStream(&s, &description.Session{Medias: []*description.Media{test.MediaH264}})
defer stream.Close()
p, ok := newInstance(
"paths:\n" +
" '~^test_(.+)$':\n" +
" source: rtsp://127.0.0.1:8555/$G1\n" +
" sourceOnDemand: yes\n" +
" 'all':\n")
require.Equal(t, true, ok)
defer p.Close()
reader := gortsplib.Client{}
u, err := base.ParseURL("rtsp://127.0.0.1:8554/test_a")
require.NoError(t, err)
err = reader.Start(u.Scheme, u.Host)
require.NoError(t, err)
defer reader.Close()
_, _, err = reader.Describe(u)
require.NoError(t, err)
}
func TestPathOverridePublisher(t *testing.T) {
for _, ca := range []string{
"enabled",
"disabled",
} {
t.Run(ca, func(t *testing.T) {
conf := "rtmp: no\n" +
"paths:\n" +
" all_others:\n"
if ca == "disabled" {
conf += " overridePublisher: no\n"
}
p, ok := newInstance(conf)
require.Equal(t, true, ok)
defer p.Close()
medi := test.UniqueMediaH264()
s1 := gortsplib.Client{}
err := s1.StartRecording("rtsp://localhost:8554/teststream",
&description.Session{Medias: []*description.Media{medi}})
require.NoError(t, err)
defer s1.Close()
s2 := gortsplib.Client{}
err = s2.StartRecording("rtsp://localhost:8554/teststream",
&description.Session{Medias: []*description.Media{medi}})
if ca == "enabled" {
require.NoError(t, err)
defer s2.Close()
} else {
require.Error(t, err)
}
frameRecv := make(chan struct{})
c := gortsplib.Client{}
u, err := base.ParseURL("rtsp://localhost:8554/teststream")
require.NoError(t, err)
err = c.Start(u.Scheme, u.Host)
require.NoError(t, err)
defer c.Close()
desc, _, err := c.Describe(u)
require.NoError(t, err)
err = c.SetupAll(desc.BaseURL, desc.Medias)
require.NoError(t, err)
c.OnPacketRTP(desc.Medias[0], desc.Medias[0].Formats[0], func(pkt *rtp.Packet) {
if ca == "enabled" {
require.Equal(t, []byte{5, 15, 16, 17, 18}, pkt.Payload)
} else {
require.Equal(t, []byte{5, 11, 12, 13, 14}, pkt.Payload)
}
close(frameRecv)
})
_, err = c.Play(nil)
require.NoError(t, err)
if ca == "enabled" {
err := s1.Wait()
require.EqualError(t, err, "EOF")
err = s2.WritePacketRTP(medi, &rtp.Packet{
Header: rtp.Header{
Version: 0x02,
PayloadType: 96,
SequenceNumber: 57899,
Timestamp: 345234345,
SSRC: 978651231,
Marker: true,
},
Payload: []byte{5, 15, 16, 17, 18},
})
require.NoError(t, err)
} else {
err = s1.WritePacketRTP(medi, &rtp.Packet{
Header: rtp.Header{
Version: 0x02,
PayloadType: 96,
SequenceNumber: 57899,
Timestamp: 345234345,
SSRC: 978651231,
Marker: true,
},
Payload: []byte{5, 11, 12, 13, 14},
})
require.NoError(t, err)
}
<-frameRecv
})
}
}

56
internal/core/pprof.go

@ -0,0 +1,56 @@ @@ -0,0 +1,56 @@
package core
import (
"net/http"
// start pprof
_ "net/http/pprof"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/logger"
)
type pprofParent interface {
logger.Writer
}
type pprof struct {
parent pprofParent
httpServer *httpServer
}
func newPPROF(
address string,
readTimeout conf.StringDuration,
parent pprofParent,
) (*pprof, error) {
pp := &pprof{
parent: parent,
}
var err error
pp.httpServer, err = newHTTPServer(
address,
readTimeout,
"",
"",
http.DefaultServeMux,
)
if err != nil {
return nil, err
}
pp.Log(logger.Info, "listener opened on "+address)
return pp, nil
}
func (pp *pprof) close() {
pp.Log(logger.Info, "listener is closing")
pp.httpServer.close()
}
func (pp *pprof) Log(level logger.Level, format string, args ...interface{}) {
pp.parent.Log(level, "[pprof] "+format, args...)
}

7
internal/core/publisher.go

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
package core
// publisher is an entity that can publish a stream.
type publisher interface {
source
close()
}

7
internal/core/reader.go

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
package core
// reader is an entity that can read a stream.
type reader interface {
close()
apiReaderDescribe() pathAPISourceOrReader
}

17
internal/core/restrict_network.go

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
package core
import (
"net"
)
// do not listen on IPv6 when address is 0.0.0.0.
func restrictNetwork(network string, address string) (string, string) {
host, _, err := net.SplitHostPort(address)
if err == nil {
if host == "0.0.0.0" {
return network + "4", address
}
}
return network, address
}

137
internal/core/rpicamera_source.go

@ -0,0 +1,137 @@ @@ -0,0 +1,137 @@
package core
import (
"context"
"time"
"github.com/bluenviron/gortsplib/v3/pkg/formats"
"github.com/bluenviron/gortsplib/v3/pkg/media"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/formatprocessor"
"github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/rpicamera"
)
func paramsFromConf(cnf *conf.PathConf) rpicamera.Params {
return rpicamera.Params{
CameraID: cnf.RPICameraCamID,
Width: cnf.RPICameraWidth,
Height: cnf.RPICameraHeight,
HFlip: cnf.RPICameraHFlip,
VFlip: cnf.RPICameraVFlip,
Brightness: cnf.RPICameraBrightness,
Contrast: cnf.RPICameraContrast,
Saturation: cnf.RPICameraSaturation,
Sharpness: cnf.RPICameraSharpness,
Exposure: cnf.RPICameraExposure,
AWB: cnf.RPICameraAWB,
Denoise: cnf.RPICameraDenoise,
Shutter: cnf.RPICameraShutter,
Metering: cnf.RPICameraMetering,
Gain: cnf.RPICameraGain,
EV: cnf.RPICameraEV,
ROI: cnf.RPICameraROI,
HDR: cnf.RPICameraHDR,
TuningFile: cnf.RPICameraTuningFile,
Mode: cnf.RPICameraMode,
FPS: cnf.RPICameraFPS,
IDRPeriod: cnf.RPICameraIDRPeriod,
Bitrate: cnf.RPICameraBitrate,
Profile: cnf.RPICameraProfile,
Level: cnf.RPICameraLevel,
AfMode: cnf.RPICameraAfMode,
AfRange: cnf.RPICameraAfRange,
AfSpeed: cnf.RPICameraAfSpeed,
LensPosition: cnf.RPICameraLensPosition,
AfWindow: cnf.RPICameraAfWindow,
TextOverlayEnable: cnf.RPICameraTextOverlayEnable,
TextOverlay: cnf.RPICameraTextOverlay,
}
}
type rpiCameraSourceParent interface {
logger.Writer
sourceStaticImplSetReady(req pathSourceStaticSetReadyReq) pathSourceStaticSetReadyRes
sourceStaticImplSetNotReady(req pathSourceStaticSetNotReadyReq)
}
type rpiCameraSource struct {
parent rpiCameraSourceParent
}
func newRPICameraSource(
parent rpiCameraSourceParent,
) *rpiCameraSource {
return &rpiCameraSource{
parent: parent,
}
}
func (s *rpiCameraSource) Log(level logger.Level, format string, args ...interface{}) {
s.parent.Log(level, "[rpicamera source] "+format, args...)
}
// run implements sourceStaticImpl.
func (s *rpiCameraSource) run(ctx context.Context, cnf *conf.PathConf, reloadConf chan *conf.PathConf) error {
medi := &media.Media{
Type: media.TypeVideo,
Formats: []formats.Format{&formats.H264{
PayloadTyp: 96,
PacketizationMode: 1,
}},
}
medias := media.Medias{medi}
var stream *stream
onData := func(dts time.Duration, au [][]byte) {
if stream == nil {
res := s.parent.sourceStaticImplSetReady(pathSourceStaticSetReadyReq{
medias: medias,
generateRTPPackets: true,
})
if res.err != nil {
return
}
s.Log(logger.Info, "ready: %s", sourceMediaInfo(medias))
stream = res.stream
}
stream.writeUnit(medi, medi.Formats[0], &formatprocessor.UnitH264{
PTS: dts,
AU: au,
NTP: time.Now(),
})
}
cam, err := rpicamera.New(paramsFromConf(cnf), onData)
if err != nil {
return err
}
defer cam.Close()
defer func() {
if stream != nil {
s.parent.sourceStaticImplSetNotReady(pathSourceStaticSetNotReadyReq{})
}
}()
for {
select {
case cnf := <-reloadConf:
cam.ReloadParams(paramsFromConf(cnf))
case <-ctx.Done():
return nil
}
}
}
// apiSourceDescribe implements sourceStaticImpl.
func (*rpiCameraSource) apiSourceDescribe() pathAPISourceOrReader {
return pathAPISourceOrReader{
Type: "rpiCameraSource",
ID: "",
}
}

913
internal/core/rtmp_conn.go

@ -0,0 +1,913 @@ @@ -0,0 +1,913 @@
package core
import (
"context"
"errors"
"fmt"
"net"
"net/url"
"strings"
"sync"
"time"
"github.com/bluenviron/gortsplib/v3/pkg/formats"
"github.com/bluenviron/gortsplib/v3/pkg/media"
"github.com/bluenviron/gortsplib/v3/pkg/ringbuffer"
"github.com/bluenviron/mediacommon/pkg/codecs/av1"
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg2audio"
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio"
"github.com/google/uuid"
"github.com/notedit/rtmp/format/flv/flvio"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/externalcmd"
"github.com/bluenviron/mediamtx/internal/formatprocessor"
"github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/rtmp"
"github.com/bluenviron/mediamtx/internal/rtmp/h264conf"
"github.com/bluenviron/mediamtx/internal/rtmp/message"
)
const (
rtmpConnPauseAfterAuthError = 2 * time.Second
)
func pathNameAndQuery(inURL *url.URL) (string, url.Values, string) {
// remove leading and trailing slashes inserted by OBS and some other clients
tmp := strings.TrimRight(inURL.String(), "/")
ur, _ := url.Parse(tmp)
pathName := strings.TrimLeft(ur.Path, "/")
return pathName, ur.Query(), ur.RawQuery
}
type rtmpWriteFunc func(msg interface{}) error
func getRTMPWriteFunc(medi *media.Media, format formats.Format, stream *stream) rtmpWriteFunc {
switch format.(type) {
case *formats.H264:
return func(msg interface{}) error {
tmsg := msg.(*message.Video)
switch tmsg.Type {
case message.VideoTypeConfig:
var conf h264conf.Conf
err := conf.Unmarshal(tmsg.Payload)
if err != nil {
return fmt.Errorf("unable to parse H264 config: %v", err)
}
au := [][]byte{
conf.SPS,
conf.PPS,
}
stream.writeUnit(medi, format, &formatprocessor.UnitH264{
PTS: tmsg.DTS + tmsg.PTSDelta,
AU: au,
NTP: time.Now(),
})
case message.VideoTypeAU:
au, err := h264.AVCCUnmarshal(tmsg.Payload)
if err != nil {
return fmt.Errorf("unable to decode AVCC: %v", err)
}
stream.writeUnit(medi, format, &formatprocessor.UnitH264{
PTS: tmsg.DTS + tmsg.PTSDelta,
AU: au,
NTP: time.Now(),
})
}
return nil
}
case *formats.H265:
return func(msg interface{}) error {
switch tmsg := msg.(type) {
case *message.Video:
au, err := h264.AVCCUnmarshal(tmsg.Payload)
if err != nil {
return fmt.Errorf("unable to decode AVCC: %v", err)
}
stream.writeUnit(medi, format, &formatprocessor.UnitH265{
PTS: tmsg.DTS + tmsg.PTSDelta,
AU: au,
NTP: time.Now(),
})
case *message.ExtendedFramesX:
au, err := h264.AVCCUnmarshal(tmsg.Payload)
if err != nil {
return fmt.Errorf("unable to decode AVCC: %v", err)
}
stream.writeUnit(medi, format, &formatprocessor.UnitH265{
PTS: tmsg.DTS,
AU: au,
NTP: time.Now(),
})
case *message.ExtendedCodedFrames:
au, err := h264.AVCCUnmarshal(tmsg.Payload)
if err != nil {
return fmt.Errorf("unable to decode AVCC: %v", err)
}
stream.writeUnit(medi, format, &formatprocessor.UnitH265{
PTS: tmsg.DTS + tmsg.PTSDelta,
AU: au,
NTP: time.Now(),
})
}
return nil
}
case *formats.AV1:
return func(msg interface{}) error {
if tmsg, ok := msg.(*message.ExtendedCodedFrames); ok {
obus, err := av1.BitstreamUnmarshal(tmsg.Payload, true)
if err != nil {
return fmt.Errorf("unable to decode bitstream: %v", err)
}
stream.writeUnit(medi, format, &formatprocessor.UnitAV1{
PTS: tmsg.DTS,
OBUs: obus,
NTP: time.Now(),
})
}
return nil
}
case *formats.MPEG2Audio:
return func(msg interface{}) error {
tmsg := msg.(*message.Audio)
stream.writeUnit(medi, format, &formatprocessor.UnitMPEG2Audio{
PTS: tmsg.DTS,
Frames: [][]byte{tmsg.Payload},
NTP: time.Now(),
})
return nil
}
case *formats.MPEG4Audio:
return func(msg interface{}) error {
tmsg := msg.(*message.Audio)
if tmsg.AACType == message.AudioAACTypeAU {
stream.writeUnit(medi, format, &formatprocessor.UnitMPEG4AudioGeneric{
PTS: tmsg.DTS,
AUs: [][]byte{tmsg.Payload},
NTP: time.Now(),
})
}
return nil
}
}
return nil
}
type rtmpConnState int
const (
rtmpConnStateIdle rtmpConnState = iota //nolint:deadcode,varcheck
rtmpConnStateRead
rtmpConnStatePublish
)
type rtmpConnPathManager interface {
readerAdd(req pathReaderAddReq) pathReaderSetupPlayRes
publisherAdd(req pathPublisherAddReq) pathPublisherAnnounceRes
}
type rtmpConnParent interface {
logger.Writer
connClose(*rtmpConn)
}
type rtmpConn struct {
isTLS bool
rtspAddress string
readTimeout conf.StringDuration
writeTimeout conf.StringDuration
readBufferCount int
runOnConnect string
runOnConnectRestart bool
wg *sync.WaitGroup
conn *rtmp.Conn
nconn net.Conn
externalCmdPool *externalcmd.Pool
pathManager rtmpConnPathManager
parent rtmpConnParent
ctx context.Context
ctxCancel func()
uuid uuid.UUID
created time.Time
mutex sync.Mutex
state rtmpConnState
pathName string
}
func newRTMPConn(
parentCtx context.Context,
isTLS bool,
rtspAddress string,
readTimeout conf.StringDuration,
writeTimeout conf.StringDuration,
readBufferCount int,
runOnConnect string,
runOnConnectRestart bool,
wg *sync.WaitGroup,
nconn net.Conn,
externalCmdPool *externalcmd.Pool,
pathManager rtmpConnPathManager,
parent rtmpConnParent,
) *rtmpConn {
ctx, ctxCancel := context.WithCancel(parentCtx)
c := &rtmpConn{
isTLS: isTLS,
rtspAddress: rtspAddress,
readTimeout: readTimeout,
writeTimeout: writeTimeout,
readBufferCount: readBufferCount,
runOnConnect: runOnConnect,
runOnConnectRestart: runOnConnectRestart,
wg: wg,
conn: rtmp.NewConn(nconn),
nconn: nconn,
externalCmdPool: externalCmdPool,
pathManager: pathManager,
parent: parent,
ctx: ctx,
ctxCancel: ctxCancel,
uuid: uuid.New(),
created: time.Now(),
}
c.Log(logger.Info, "opened")
c.wg.Add(1)
go c.run()
return c
}
func (c *rtmpConn) close() {
c.ctxCancel()
}
func (c *rtmpConn) remoteAddr() net.Addr {
return c.nconn.RemoteAddr()
}
func (c *rtmpConn) Log(level logger.Level, format string, args ...interface{}) {
c.parent.Log(level, "[conn %v] "+format, append([]interface{}{c.nconn.RemoteAddr()}, args...)...)
}
func (c *rtmpConn) ip() net.IP {
return c.nconn.RemoteAddr().(*net.TCPAddr).IP
}
func (c *rtmpConn) run() {
defer c.wg.Done()
if c.runOnConnect != "" {
c.Log(logger.Info, "runOnConnect command started")
_, port, _ := net.SplitHostPort(c.rtspAddress)
onConnectCmd := externalcmd.NewCmd(
c.externalCmdPool,
c.runOnConnect,
c.runOnConnectRestart,
externalcmd.Environment{
"MTX_PATH": "",
"RTSP_PATH": "", // deprecated
"RTSP_PORT": port,
},
func(err error) {
c.Log(logger.Info, "runOnConnect command exited: %v", err)
})
defer func() {
onConnectCmd.Close()
c.Log(logger.Info, "runOnConnect command stopped")
}()
}
ctx, cancel := context.WithCancel(c.ctx)
runErr := make(chan error)
go func() {
runErr <- c.runInner(ctx)
}()
var err error
select {
case err = <-runErr:
cancel()
case <-c.ctx.Done():
cancel()
<-runErr
err = errors.New("terminated")
}
c.ctxCancel()
c.parent.connClose(c)
c.Log(logger.Info, "closed (%v)", err)
}
func (c *rtmpConn) runInner(ctx context.Context) error {
go func() {
<-ctx.Done()
c.nconn.Close()
}()
c.nconn.SetReadDeadline(time.Now().Add(time.Duration(c.readTimeout)))
c.nconn.SetWriteDeadline(time.Now().Add(time.Duration(c.writeTimeout)))
u, publish, err := c.conn.InitializeServer()
if err != nil {
return err
}
if !publish {
return c.runRead(ctx, u)
}
return c.runPublish(u)
}
func (c *rtmpConn) runRead(ctx context.Context, u *url.URL) error {
pathName, query, rawQuery := pathNameAndQuery(u)
res := c.pathManager.readerAdd(pathReaderAddReq{
author: c,
pathName: pathName,
credentials: authCredentials{
query: rawQuery,
ip: c.ip(),
user: query.Get("user"),
pass: query.Get("pass"),
proto: authProtocolRTMP,
id: &c.uuid,
},
})
if res.err != nil {
if terr, ok := res.err.(pathErrAuth); ok {
// wait some seconds to stop brute force attacks
<-time.After(rtmpConnPauseAfterAuthError)
return terr.wrapped
}
return res.err
}
defer res.path.readerRemove(pathReaderRemoveReq{author: c})
c.mutex.Lock()
c.state = rtmpConnStateRead
c.pathName = pathName
c.mutex.Unlock()
ringBuffer, _ := ringbuffer.New(uint64(c.readBufferCount))
go func() {
<-ctx.Done()
ringBuffer.Close()
}()
var medias media.Medias
videoFirstIDRFound := false
var videoStartDTS time.Duration
videoMedia, videoFormat := c.findVideoFormat(res.stream, ringBuffer,
&videoFirstIDRFound, &videoStartDTS)
if videoMedia != nil {
medias = append(medias, videoMedia)
}
audioMedia, audioFormat := c.findAudioFormat(res.stream, ringBuffer,
videoFormat, &videoFirstIDRFound, &videoStartDTS)
if audioFormat != nil {
medias = append(medias, audioMedia)
}
if videoFormat == nil && audioFormat == nil {
return fmt.Errorf(
"the stream doesn't contain any supported codec, which are currently H264, MPEG-4 Audio, MPEG-1/2 Audio")
}
defer res.stream.readerRemove(c)
c.Log(logger.Info, "is reading from path '%s', %s",
res.path.name, sourceMediaInfo(medias))
pathConf := res.path.safeConf()
if pathConf.RunOnRead != "" {
c.Log(logger.Info, "runOnRead command started")
onReadCmd := externalcmd.NewCmd(
c.externalCmdPool,
pathConf.RunOnRead,
pathConf.RunOnReadRestart,
res.path.externalCmdEnv(),
func(err error) {
c.Log(logger.Info, "runOnRead command exited: %v", err)
})
defer func() {
onReadCmd.Close()
c.Log(logger.Info, "runOnRead command stopped")
}()
}
err := c.conn.WriteTracks(videoFormat, audioFormat)
if err != nil {
return err
}
// disable read deadline
c.nconn.SetReadDeadline(time.Time{})
for {
item, ok := ringBuffer.Pull()
if !ok {
return fmt.Errorf("terminated")
}
err := item.(func() error)()
if err != nil {
return err
}
}
}
func (c *rtmpConn) findVideoFormat(stream *stream, ringBuffer *ringbuffer.RingBuffer,
videoFirstIDRFound *bool, videoStartDTS *time.Duration,
) (*media.Media, formats.Format) {
var videoFormatH264 *formats.H264
videoMedia := stream.medias().FindFormat(&videoFormatH264)
if videoFormatH264 != nil {
videoStartPTSFilled := false
var videoStartPTS time.Duration
var videoDTSExtractor *h264.DTSExtractor
stream.readerAdd(c, videoMedia, videoFormatH264, func(unit formatprocessor.Unit) {
ringBuffer.Push(func() error {
tunit := unit.(*formatprocessor.UnitH264)
if tunit.AU == nil {
return nil
}
if !videoStartPTSFilled {
videoStartPTSFilled = true
videoStartPTS = tunit.PTS
}
pts := tunit.PTS - videoStartPTS
idrPresent := false
nonIDRPresent := false
for _, nalu := range tunit.AU {
typ := h264.NALUType(nalu[0] & 0x1F)
switch typ {
case h264.NALUTypeIDR:
idrPresent = true
case h264.NALUTypeNonIDR:
nonIDRPresent = true
}
}
var dts time.Duration
// wait until we receive an IDR
if !*videoFirstIDRFound {
if !idrPresent {
return nil
}
*videoFirstIDRFound = true
videoDTSExtractor = h264.NewDTSExtractor()
var err error
dts, err = videoDTSExtractor.Extract(tunit.AU, pts)
if err != nil {
return err
}
*videoStartDTS = dts
dts = 0
pts -= *videoStartDTS
} else {
if !idrPresent && !nonIDRPresent {
return nil
}
var err error
dts, err = videoDTSExtractor.Extract(tunit.AU, pts)
if err != nil {
return err
}
dts -= *videoStartDTS
pts -= *videoStartDTS
}
avcc, err := h264.AVCCMarshal(tunit.AU)
if err != nil {
return err
}
c.nconn.SetWriteDeadline(time.Now().Add(time.Duration(c.writeTimeout)))
err = c.conn.WriteMessage(&message.Video{
ChunkStreamID: message.VideoChunkStreamID,
MessageStreamID: 0x1000000,
Codec: message.CodecH264,
IsKeyFrame: idrPresent,
Type: message.VideoTypeAU,
Payload: avcc,
DTS: dts,
PTSDelta: pts - dts,
})
if err != nil {
return err
}
return nil
})
})
return videoMedia, videoFormatH264
}
return nil, nil
}
func (c *rtmpConn) findAudioFormat(
stream *stream,
ringBuffer *ringbuffer.RingBuffer,
videoFormat formats.Format,
videoFirstIDRFound *bool,
videoStartDTS *time.Duration,
) (*media.Media, formats.Format) {
var audioFormatMPEG4Generic *formats.MPEG4AudioGeneric
audioMedia := stream.medias().FindFormat(&audioFormatMPEG4Generic)
if audioMedia != nil {
audioStartPTSFilled := false
var audioStartPTS time.Duration
stream.readerAdd(c, audioMedia, audioFormatMPEG4Generic, func(unit formatprocessor.Unit) {
ringBuffer.Push(func() error {
tunit := unit.(*formatprocessor.UnitMPEG4AudioGeneric)
if tunit.AUs == nil {
return nil
}
if !audioStartPTSFilled {
audioStartPTSFilled = true
audioStartPTS = tunit.PTS
}
pts := tunit.PTS - audioStartPTS
if videoFormat != nil {
if !*videoFirstIDRFound {
return nil
}
pts -= *videoStartDTS
if pts < 0 {
return nil
}
}
for i, au := range tunit.AUs {
c.nconn.SetWriteDeadline(time.Now().Add(time.Duration(c.writeTimeout)))
err := c.conn.WriteMessage(&message.Audio{
ChunkStreamID: message.AudioChunkStreamID,
MessageStreamID: 0x1000000,
Codec: message.CodecMPEG4Audio,
Rate: flvio.SOUND_44Khz,
Depth: flvio.SOUND_16BIT,
Channels: flvio.SOUND_STEREO,
AACType: message.AudioAACTypeAU,
Payload: au,
DTS: pts + time.Duration(i)*mpeg4audio.SamplesPerAccessUnit*
time.Second/time.Duration(audioFormatMPEG4Generic.ClockRate()),
})
if err != nil {
return err
}
}
return nil
})
})
return audioMedia, audioFormatMPEG4Generic
}
var audioFormatMPEG4AudioLATM *formats.MPEG4AudioLATM
audioMedia = stream.medias().FindFormat(&audioFormatMPEG4AudioLATM)
if audioMedia != nil &&
audioFormatMPEG4AudioLATM.Config != nil &&
len(audioFormatMPEG4AudioLATM.Config.Programs) == 1 &&
len(audioFormatMPEG4AudioLATM.Config.Programs[0].Layers) == 1 {
audioStartPTSFilled := false
var audioStartPTS time.Duration
stream.readerAdd(c, audioMedia, audioFormatMPEG4AudioLATM, func(unit formatprocessor.Unit) {
ringBuffer.Push(func() error {
tunit := unit.(*formatprocessor.UnitMPEG4AudioLATM)
if tunit.AU == nil {
return nil
}
if !audioStartPTSFilled {
audioStartPTSFilled = true
audioStartPTS = tunit.PTS
}
pts := tunit.PTS - audioStartPTS
if videoFormat != nil {
if !*videoFirstIDRFound {
return nil
}
pts -= *videoStartDTS
if pts < 0 {
return nil
}
}
c.nconn.SetWriteDeadline(time.Now().Add(time.Duration(c.writeTimeout)))
err := c.conn.WriteMessage(&message.Audio{
ChunkStreamID: message.AudioChunkStreamID,
MessageStreamID: 0x1000000,
Codec: message.CodecMPEG4Audio,
Rate: flvio.SOUND_44Khz,
Depth: flvio.SOUND_16BIT,
Channels: flvio.SOUND_STEREO,
AACType: message.AudioAACTypeAU,
Payload: tunit.AU,
DTS: pts,
})
if err != nil {
return err
}
return nil
})
})
return audioMedia, audioFormatMPEG4AudioLATM
}
var audioFormatMPEG2 *formats.MPEG2Audio
audioMedia = stream.medias().FindFormat(&audioFormatMPEG2)
if audioMedia != nil {
audioStartPTSFilled := false
var audioStartPTS time.Duration
stream.readerAdd(c, audioMedia, audioFormatMPEG2, func(unit formatprocessor.Unit) {
ringBuffer.Push(func() error {
tunit := unit.(*formatprocessor.UnitMPEG2Audio)
if !audioStartPTSFilled {
audioStartPTSFilled = true
audioStartPTS = tunit.PTS
}
pts := tunit.PTS - audioStartPTS
if videoFormat != nil {
if !*videoFirstIDRFound {
return nil
}
pts -= *videoStartDTS
if pts < 0 {
return nil
}
}
for _, frame := range tunit.Frames {
var h mpeg2audio.FrameHeader
err := h.Unmarshal(frame)
if err != nil {
return err
}
if !(!h.MPEG2 && h.Layer == 3) {
return fmt.Errorf("RTMP only supports MPEG-1 layer 3 audio")
}
channels := uint8(flvio.SOUND_STEREO)
if h.ChannelMode == mpeg2audio.ChannelModeMono {
channels = flvio.SOUND_MONO
}
rate := uint8(flvio.SOUND_44Khz)
switch h.SampleRate {
case 5500:
rate = flvio.SOUND_5_5Khz
case 11025:
rate = flvio.SOUND_11Khz
case 22050:
rate = flvio.SOUND_22Khz
}
msg := &message.Audio{
ChunkStreamID: message.AudioChunkStreamID,
MessageStreamID: 0x1000000,
Codec: message.CodecMPEG2Audio,
Rate: rate,
Depth: flvio.SOUND_16BIT,
Channels: channels,
Payload: frame,
DTS: pts,
}
c.nconn.SetWriteDeadline(time.Now().Add(time.Duration(c.writeTimeout)))
err = c.conn.WriteMessage(msg)
if err != nil {
return err
}
pts += time.Duration(h.SampleCount()) *
time.Second / time.Duration(h.SampleRate)
}
return nil
})
})
return audioMedia, audioFormatMPEG2
}
return nil, nil
}
func (c *rtmpConn) runPublish(u *url.URL) error {
pathName, query, rawQuery := pathNameAndQuery(u)
res := c.pathManager.publisherAdd(pathPublisherAddReq{
author: c,
pathName: pathName,
credentials: authCredentials{
query: rawQuery,
ip: c.ip(),
user: query.Get("user"),
pass: query.Get("pass"),
proto: authProtocolRTMP,
id: &c.uuid,
},
})
if res.err != nil {
if terr, ok := res.err.(pathErrAuth); ok {
// wait some seconds to stop brute force attacks
<-time.After(rtmpConnPauseAfterAuthError)
return terr.wrapped
}
return res.err
}
defer res.path.publisherRemove(pathPublisherRemoveReq{author: c})
c.mutex.Lock()
c.state = rtmpConnStatePublish
c.pathName = pathName
c.mutex.Unlock()
videoFormat, audioFormat, err := c.conn.ReadTracks()
if err != nil {
return err
}
var medias media.Medias
var videoMedia *media.Media
var audioMedia *media.Media
if videoFormat != nil {
videoMedia = &media.Media{
Type: media.TypeVideo,
Formats: []formats.Format{videoFormat},
}
medias = append(medias, videoMedia)
}
if audioFormat != nil {
audioMedia = &media.Media{
Type: media.TypeAudio,
Formats: []formats.Format{audioFormat},
}
medias = append(medias, audioMedia)
}
rres := res.path.publisherStart(pathPublisherStartReq{
author: c,
medias: medias,
generateRTPPackets: true,
})
if rres.err != nil {
return rres.err
}
c.Log(logger.Info, "is publishing to path '%s', %s",
res.path.name,
sourceMediaInfo(medias))
// disable write deadline to allow outgoing acknowledges
c.nconn.SetWriteDeadline(time.Time{})
videoWriteFunc := getRTMPWriteFunc(videoMedia, videoFormat, rres.stream)
audioWriteFunc := getRTMPWriteFunc(audioMedia, audioFormat, rres.stream)
for {
c.nconn.SetReadDeadline(time.Now().Add(time.Duration(c.readTimeout)))
msg, err := c.conn.ReadMessage()
if err != nil {
return err
}
switch msg.(type) {
case *message.Video, *message.ExtendedFramesX, *message.ExtendedCodedFrames:
if videoFormat == nil {
return fmt.Errorf("received a video packet, but track is not set up")
}
err := videoWriteFunc(msg)
if err != nil {
c.Log(logger.Warn, "%v", err)
}
case *message.Audio:
if audioFormat == nil {
return fmt.Errorf("received an audio packet, but track is not set up")
}
err := audioWriteFunc(msg)
if err != nil {
c.Log(logger.Warn, "%v", err)
}
}
}
}
// apiReaderDescribe implements reader.
func (c *rtmpConn) apiReaderDescribe() pathAPISourceOrReader {
return pathAPISourceOrReader{
Type: func() string {
if c.isTLS {
return "rtmpsConn"
}
return "rtmpConn"
}(),
ID: c.uuid.String(),
}
}
// apiSourceDescribe implements source.
func (c *rtmpConn) apiSourceDescribe() pathAPISourceOrReader {
return c.apiReaderDescribe()
}
func (c *rtmpConn) apiItem() *apiRTMPConn {
c.mutex.Lock()
defer c.mutex.Unlock()
return &apiRTMPConn{
ID: c.uuid,
Created: c.created,
RemoteAddr: c.remoteAddr().String(),
State: func() string {
switch c.state {
case rtmpConnStateRead:
return "read"
case rtmpConnStatePublish:
return "publish"
}
return "idle"
}(),
Path: c.pathName,
BytesReceived: c.conn.BytesReceived(),
BytesSent: c.conn.BytesSent(),
}
}

331
internal/core/rtmp_server.go

@ -0,0 +1,331 @@ @@ -0,0 +1,331 @@
package core
import (
"context"
"crypto/tls"
"fmt"
"net"
"sort"
"sync"
"github.com/google/uuid"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/externalcmd"
"github.com/bluenviron/mediamtx/internal/logger"
)
type rtmpServerAPIConnsListRes struct {
data *apiRTMPConnsList
err error
}
type rtmpServerAPIConnsListReq struct {
res chan rtmpServerAPIConnsListRes
}
type rtmpServerAPIConnsGetRes struct {
data *apiRTMPConn
err error
}
type rtmpServerAPIConnsGetReq struct {
uuid uuid.UUID
res chan rtmpServerAPIConnsGetRes
}
type rtmpServerAPIConnsKickRes struct {
err error
}
type rtmpServerAPIConnsKickReq struct {
uuid uuid.UUID
res chan rtmpServerAPIConnsKickRes
}
type rtmpServerParent interface {
logger.Writer
}
type rtmpServer struct {
readTimeout conf.StringDuration
writeTimeout conf.StringDuration
readBufferCount int
isTLS bool
rtspAddress string
runOnConnect string
runOnConnectRestart bool
externalCmdPool *externalcmd.Pool
metrics *metrics
pathManager *pathManager
parent rtmpServerParent
ctx context.Context
ctxCancel func()
wg sync.WaitGroup
ln net.Listener
conns map[*rtmpConn]struct{}
// in
chConnClose chan *rtmpConn
chAPIConnsList chan rtmpServerAPIConnsListReq
chAPIConnsGet chan rtmpServerAPIConnsGetReq
chAPIConnsKick chan rtmpServerAPIConnsKickReq
}
func newRTMPServer(
address string,
readTimeout conf.StringDuration,
writeTimeout conf.StringDuration,
readBufferCount int,
isTLS bool,
serverCert string,
serverKey string,
rtspAddress string,
runOnConnect string,
runOnConnectRestart bool,
externalCmdPool *externalcmd.Pool,
metrics *metrics,
pathManager *pathManager,
parent rtmpServerParent,
) (*rtmpServer, error) {
ln, err := func() (net.Listener, error) {
if !isTLS {
return net.Listen(restrictNetwork("tcp", address))
}
cert, err := tls.LoadX509KeyPair(serverCert, serverKey)
if err != nil {
return nil, err
}
network, address := restrictNetwork("tcp", address)
return tls.Listen(network, address, &tls.Config{Certificates: []tls.Certificate{cert}})
}()
if err != nil {
return nil, err
}
ctx, ctxCancel := context.WithCancel(context.Background())
s := &rtmpServer{
readTimeout: readTimeout,
writeTimeout: writeTimeout,
readBufferCount: readBufferCount,
rtspAddress: rtspAddress,
runOnConnect: runOnConnect,
runOnConnectRestart: runOnConnectRestart,
isTLS: isTLS,
externalCmdPool: externalCmdPool,
metrics: metrics,
pathManager: pathManager,
parent: parent,
ctx: ctx,
ctxCancel: ctxCancel,
ln: ln,
conns: make(map[*rtmpConn]struct{}),
chConnClose: make(chan *rtmpConn),
chAPIConnsList: make(chan rtmpServerAPIConnsListReq),
chAPIConnsGet: make(chan rtmpServerAPIConnsGetReq),
chAPIConnsKick: make(chan rtmpServerAPIConnsKickReq),
}
s.Log(logger.Info, "listener opened on %s", address)
if s.metrics != nil {
s.metrics.rtmpServerSet(s)
}
s.wg.Add(1)
go s.run()
return s, nil
}
func (s *rtmpServer) Log(level logger.Level, format string, args ...interface{}) {
label := func() string {
if s.isTLS {
return "RTMPS"
}
return "RTMP"
}()
s.parent.Log(level, "[%s] "+format, append([]interface{}{label}, args...)...)
}
func (s *rtmpServer) close() {
s.Log(logger.Info, "listener is closing")
s.ctxCancel()
s.wg.Wait()
}
func (s *rtmpServer) run() {
defer s.wg.Done()
s.wg.Add(1)
connNew := make(chan net.Conn)
acceptErr := make(chan error)
go func() {
defer s.wg.Done()
err := func() error {
for {
conn, err := s.ln.Accept()
if err != nil {
return err
}
select {
case connNew <- conn:
case <-s.ctx.Done():
conn.Close()
}
}
}()
select {
case acceptErr <- err:
case <-s.ctx.Done():
}
}()
outer:
for {
select {
case err := <-acceptErr:
s.Log(logger.Error, "%s", err)
break outer
case nconn := <-connNew:
c := newRTMPConn(
s.ctx,
s.isTLS,
s.rtspAddress,
s.readTimeout,
s.writeTimeout,
s.readBufferCount,
s.runOnConnect,
s.runOnConnectRestart,
&s.wg,
nconn,
s.externalCmdPool,
s.pathManager,
s)
s.conns[c] = struct{}{}
case c := <-s.chConnClose:
delete(s.conns, c)
case req := <-s.chAPIConnsList:
data := &apiRTMPConnsList{
Items: []*apiRTMPConn{},
}
for c := range s.conns {
data.Items = append(data.Items, c.apiItem())
}
sort.Slice(data.Items, func(i, j int) bool {
return data.Items[i].Created.Before(data.Items[j].Created)
})
req.res <- rtmpServerAPIConnsListRes{data: data}
case req := <-s.chAPIConnsGet:
c := s.findConnByUUID(req.uuid)
if c == nil {
req.res <- rtmpServerAPIConnsGetRes{err: errAPINotFound}
continue
}
req.res <- rtmpServerAPIConnsGetRes{data: c.apiItem()}
case req := <-s.chAPIConnsKick:
c := s.findConnByUUID(req.uuid)
if c == nil {
req.res <- rtmpServerAPIConnsKickRes{err: errAPINotFound}
continue
}
delete(s.conns, c)
c.close()
req.res <- rtmpServerAPIConnsKickRes{}
case <-s.ctx.Done():
break outer
}
}
s.ctxCancel()
s.ln.Close()
if s.metrics != nil {
s.metrics.rtmpServerSet(s)
}
}
func (s *rtmpServer) findConnByUUID(uuid uuid.UUID) *rtmpConn {
for c := range s.conns {
if c.uuid == uuid {
return c
}
}
return nil
}
// connClose is called by rtmpConn.
func (s *rtmpServer) connClose(c *rtmpConn) {
select {
case s.chConnClose <- c:
case <-s.ctx.Done():
}
}
// apiConnsList is called by api.
func (s *rtmpServer) apiConnsList() (*apiRTMPConnsList, error) {
req := rtmpServerAPIConnsListReq{
res: make(chan rtmpServerAPIConnsListRes),
}
select {
case s.chAPIConnsList <- req:
res := <-req.res
return res.data, res.err
case <-s.ctx.Done():
return nil, fmt.Errorf("terminated")
}
}
// apiConnsGet is called by api.
func (s *rtmpServer) apiConnsGet(uuid uuid.UUID) (*apiRTMPConn, error) {
req := rtmpServerAPIConnsGetReq{
uuid: uuid,
res: make(chan rtmpServerAPIConnsGetRes),
}
select {
case s.chAPIConnsGet <- req:
res := <-req.res
return res.data, res.err
case <-s.ctx.Done():
return nil, fmt.Errorf("terminated")
}
}
// apiConnsKick is called by api.
func (s *rtmpServer) apiConnsKick(uuid uuid.UUID) error {
req := rtmpServerAPIConnsKickReq{
uuid: uuid,
res: make(chan rtmpServerAPIConnsKickRes),
}
select {
case s.chAPIConnsKick <- req:
res := <-req.res
return res.err
case <-s.ctx.Done():
return fmt.Errorf("terminated")
}
}

390
internal/core/rtmp_server_test.go

@ -0,0 +1,390 @@ @@ -0,0 +1,390 @@
package core //nolint:dupl
import (
"crypto/tls"
"net"
"net/url"
"os"
"testing"
"time"
"github.com/bluenviron/gortsplib/v3/pkg/formats"
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio"
"github.com/stretchr/testify/require"
"github.com/bluenviron/mediamtx/internal/rtmp"
"github.com/bluenviron/mediamtx/internal/rtmp/message"
)
func TestRTMPServerRunOnConnect(t *testing.T) {
f, err := os.CreateTemp(os.TempDir(), "rtspss-runonconnect-")
require.NoError(t, err)
f.Close()
defer os.Remove(f.Name())
p, ok := newInstance(
"runOnConnect: sh -c 'echo aa > " + f.Name() + "'\n" +
"paths:\n" +
" all:\n")
require.Equal(t, true, ok)
defer p.Close()
u, err := url.Parse("rtmp://127.0.0.1:1935/teststream")
require.NoError(t, err)
nconn, err := net.Dial("tcp", u.Host)
require.NoError(t, err)
defer nconn.Close()
conn := rtmp.NewConn(nconn)
err = conn.InitializeClient(u, true)
require.NoError(t, err)
time.Sleep(500 * time.Millisecond)
byts, err := os.ReadFile(f.Name())
require.NoError(t, err)
require.Equal(t, "aa\n", string(byts))
}
func TestRTMPServer(t *testing.T) {
for _, encrypt := range []string{
"plain",
"tls",
} {
for _, auth := range []string{
"none",
"internal",
"external",
} {
t.Run("encrypt_"+encrypt+"_auth_"+auth, func(t *testing.T) {
var port string
var conf string
if encrypt == "plain" {
port = "1935"
conf = "rtspDisable: yes\n" +
"hlsDisable: yes\n"
} else {
port = "1936"
serverCertFpath, err := writeTempFile(serverCert)
require.NoError(t, err)
defer os.Remove(serverCertFpath)
serverKeyFpath, err := writeTempFile(serverKey)
require.NoError(t, err)
defer os.Remove(serverKeyFpath)
conf = "rtspDisable: yes\n" +
"hlsDisable: yes\n" +
"webrtcDisable: yes\n" +
"rtmpEncryption: \"yes\"\n" +
"rtmpServerCert: " + serverCertFpath + "\n" +
"rtmpServerKey: " + serverKeyFpath + "\n"
}
switch auth {
case "none":
conf += "paths:\n" +
" all:\n"
case "internal":
conf += "paths:\n" +
" all:\n" +
" publishUser: testpublisher\n" +
" publishPass: testpass\n" +
" publishIPs: [127.0.0.0/16]\n" +
" readUser: testreader\n" +
" readPass: testpass\n" +
" readIPs: [127.0.0.0/16]\n"
case "external":
conf += "externalAuthenticationURL: http://localhost:9120/auth\n" +
"paths:\n" +
" all:\n"
}
p, ok := newInstance(conf)
require.Equal(t, true, ok)
defer p.Close()
var a *testHTTPAuthenticator
if auth == "external" {
a = newTestHTTPAuthenticator(t, "rtmp", "publish")
}
u1, err := url.Parse("rtmp://127.0.0.1:" + port + "/teststream?user=testpublisher&pass=testpass&param=value")
require.NoError(t, err)
nconn1, err := func() (net.Conn, error) {
if encrypt == "plain" {
return net.Dial("tcp", u1.Host)
}
return tls.Dial("tcp", u1.Host, &tls.Config{InsecureSkipVerify: true})
}()
require.NoError(t, err)
defer nconn1.Close()
conn1 := rtmp.NewConn(nconn1)
err = conn1.InitializeClient(u1, true)
require.NoError(t, err)
videoTrack := &formats.H264{
PayloadTyp: 96,
SPS: []byte{ // 1920x1080 baseline
0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02,
0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04,
0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9, 0x20,
},
PPS: []byte{0x08, 0x06, 0x07, 0x08},
PacketizationMode: 1,
}
audioTrack := &formats.MPEG4Audio{
PayloadTyp: 96,
Config: &mpeg4audio.Config{
Type: 2,
SampleRate: 44100,
ChannelCount: 2,
},
SizeLength: 13,
IndexLength: 3,
IndexDeltaLength: 3,
}
err = conn1.WriteTracks(videoTrack, audioTrack)
require.NoError(t, err)
time.Sleep(500 * time.Millisecond)
if auth == "external" {
a.close()
a = newTestHTTPAuthenticator(t, "rtmp", "read")
defer a.close()
}
u2, err := url.Parse("rtmp://127.0.0.1:" + port + "/teststream?user=testreader&pass=testpass&param=value")
require.NoError(t, err)
nconn2, err := func() (net.Conn, error) {
if encrypt == "plain" {
return net.Dial("tcp", u2.Host)
}
return tls.Dial("tcp", u2.Host, &tls.Config{InsecureSkipVerify: true})
}()
require.NoError(t, err)
defer nconn2.Close()
conn2 := rtmp.NewConn(nconn2)
err = conn2.InitializeClient(u2, false)
require.NoError(t, err)
videoTrack1, audioTrack2, err := conn2.ReadTracks()
require.NoError(t, err)
require.Equal(t, videoTrack, videoTrack1)
require.Equal(t, audioTrack, audioTrack2)
err = conn1.WriteMessage(&message.Video{
ChunkStreamID: message.VideoChunkStreamID,
MessageStreamID: 0x1000000,
Codec: message.CodecH264,
IsKeyFrame: true,
Type: message.VideoTypeAU,
Payload: []byte{
0x00, 0x00, 0x00, 0x04, 0x05, 0x02, 0x03, 0x04, // IDR 1
0x00, 0x00, 0x00, 0x04, 0x05, 0x02, 0x03, 0x04, // IDR 2
},
})
require.NoError(t, err)
msg1, err := conn2.ReadMessage()
require.NoError(t, err)
require.Equal(t, &message.Video{
ChunkStreamID: message.VideoChunkStreamID,
MessageStreamID: 0x1000000,
Codec: message.CodecH264,
IsKeyFrame: true,
Type: message.VideoTypeAU,
Payload: []byte{
0x00, 0x00, 0x00, 0x19, // SPS
0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02,
0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04,
0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9,
0x20,
0x00, 0x00, 0x00, 0x04, 0x08, 0x06, 0x07, 0x08, // PPS
0x00, 0x00, 0x00, 0x04, 0x05, 0x02, 0x03, 0x04, // IDR 1
0x00, 0x00, 0x00, 0x04, 0x05, 0x02, 0x03, 0x04, // IDR 2
},
}, msg1)
})
}
}
}
func TestRTMPServerAuthFail(t *testing.T) {
t.Run("publish", func(t *testing.T) { //nolint:dupl
p, ok := newInstance("rtspDisable: yes\n" +
"hlsDisable: yes\n" +
"webrtcDisable: yes\n" +
"paths:\n" +
" all:\n" +
" publishUser: testuser2\n" +
" publishPass: testpass\n")
require.Equal(t, true, ok)
defer p.Close()
u1, err := url.Parse("rtmp://127.0.0.1:1935/teststream?user=testuser&pass=testpass")
require.NoError(t, err)
nconn1, err := net.Dial("tcp", u1.Host)
require.NoError(t, err)
defer nconn1.Close()
conn1 := rtmp.NewConn(nconn1)
err = conn1.InitializeClient(u1, true)
require.NoError(t, err)
videoTrack := &formats.H264{
PayloadTyp: 96,
SPS: []byte{
0x67, 0x64, 0x00, 0x0c, 0xac, 0x3b, 0x50, 0xb0,
0x4b, 0x42, 0x00, 0x00, 0x03, 0x00, 0x02, 0x00,
0x00, 0x03, 0x00, 0x3d, 0x08,
},
PPS: []byte{
0x68, 0xee, 0x3c, 0x80,
},
PacketizationMode: 1,
}
err = conn1.WriteTracks(videoTrack, nil)
require.NoError(t, err)
time.Sleep(500 * time.Millisecond)
u2, err := url.Parse("rtmp://127.0.0.1:1935/teststream")
require.NoError(t, err)
nconn2, err := net.Dial("tcp", u2.Host)
require.NoError(t, err)
defer nconn2.Close()
conn2 := rtmp.NewConn(nconn2)
err = conn2.InitializeClient(u2, false)
require.NoError(t, err)
_, _, err = conn2.ReadTracks()
require.EqualError(t, err, "EOF")
})
t.Run("publish_external", func(t *testing.T) {
p, ok := newInstance("externalAuthenticationURL: http://localhost:9120/auth\n" +
"paths:\n" +
" all:\n")
require.Equal(t, true, ok)
defer p.Close()
a := newTestHTTPAuthenticator(t, "rtmp", "publish")
defer a.close()
u1, err := url.Parse("rtmp://127.0.0.1:1935/teststream?user=testuser1&pass=testpass")
require.NoError(t, err)
nconn1, err := net.Dial("tcp", u1.Host)
require.NoError(t, err)
defer nconn1.Close()
conn1 := rtmp.NewConn(nconn1)
err = conn1.InitializeClient(u1, true)
require.NoError(t, err)
videoTrack := &formats.H264{
PayloadTyp: 96,
SPS: []byte{
0x67, 0x64, 0x00, 0x0c, 0xac, 0x3b, 0x50, 0xb0,
0x4b, 0x42, 0x00, 0x00, 0x03, 0x00, 0x02, 0x00,
0x00, 0x03, 0x00, 0x3d, 0x08,
},
PPS: []byte{
0x68, 0xee, 0x3c, 0x80,
},
PacketizationMode: 1,
}
err = conn1.WriteTracks(videoTrack, nil)
require.NoError(t, err)
time.Sleep(500 * time.Millisecond)
u2, err := url.Parse("rtmp://127.0.0.1:1935/teststream")
require.NoError(t, err)
nconn2, err := net.Dial("tcp", u2.Host)
require.NoError(t, err)
defer nconn2.Close()
conn2 := rtmp.NewConn(nconn2)
err = conn2.InitializeClient(u2, false)
require.NoError(t, err)
_, _, err = conn2.ReadTracks()
require.EqualError(t, err, "EOF")
})
t.Run("read", func(t *testing.T) { //nolint:dupl
p, ok := newInstance("rtspDisable: yes\n" +
"hlsDisable: yes\n" +
"webrtcDisable: yes\n" +
"paths:\n" +
" all:\n" +
" readUser: testuser2\n" +
" readPass: testpass\n")
require.Equal(t, true, ok)
defer p.Close()
u1, err := url.Parse("rtmp://127.0.0.1:1935/teststream")
require.NoError(t, err)
nconn1, err := net.Dial("tcp", u1.Host)
require.NoError(t, err)
defer nconn1.Close()
conn1 := rtmp.NewConn(nconn1)
err = conn1.InitializeClient(u1, true)
require.NoError(t, err)
videoTrack := &formats.H264{
PayloadTyp: 96,
SPS: []byte{
0x67, 0x64, 0x00, 0x0c, 0xac, 0x3b, 0x50, 0xb0,
0x4b, 0x42, 0x00, 0x00, 0x03, 0x00, 0x02, 0x00,
0x00, 0x03, 0x00, 0x3d, 0x08,
},
PPS: []byte{
0x68, 0xee, 0x3c, 0x80,
},
PacketizationMode: 1,
}
err = conn1.WriteTracks(videoTrack, nil)
require.NoError(t, err)
time.Sleep(500 * time.Millisecond)
u2, err := url.Parse("rtmp://127.0.0.1:1935/teststream?user=testuser1&pass=testpass")
require.NoError(t, err)
nconn2, err := net.Dial("tcp", u2.Host)
require.NoError(t, err)
defer nconn2.Close()
conn2 := rtmp.NewConn(nconn2)
err = conn2.InitializeClient(u2, false)
require.NoError(t, err)
_, _, err = conn2.ReadTracks()
require.EqualError(t, err, "EOF")
})
}

195
internal/core/rtmp_source.go

@ -0,0 +1,195 @@ @@ -0,0 +1,195 @@
package core
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/url"
"time"
"github.com/bluenviron/gortsplib/v3/pkg/formats"
"github.com/bluenviron/gortsplib/v3/pkg/media"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/rtmp"
"github.com/bluenviron/mediamtx/internal/rtmp/message"
)
type rtmpSourceParent interface {
logger.Writer
sourceStaticImplSetReady(req pathSourceStaticSetReadyReq) pathSourceStaticSetReadyRes
sourceStaticImplSetNotReady(req pathSourceStaticSetNotReadyReq)
}
type rtmpSource struct {
readTimeout conf.StringDuration
writeTimeout conf.StringDuration
parent rtmpSourceParent
}
func newRTMPSource(
readTimeout conf.StringDuration,
writeTimeout conf.StringDuration,
parent rtmpSourceParent,
) *rtmpSource {
return &rtmpSource{
readTimeout: readTimeout,
writeTimeout: writeTimeout,
parent: parent,
}
}
func (s *rtmpSource) Log(level logger.Level, format string, args ...interface{}) {
s.parent.Log(level, "[rtmp source] "+format, args...)
}
// run implements sourceStaticImpl.
func (s *rtmpSource) run(ctx context.Context, cnf *conf.PathConf, reloadConf chan *conf.PathConf) error {
s.Log(logger.Debug, "connecting")
u, err := url.Parse(cnf.Source)
if err != nil {
return err
}
// add default port
_, _, err = net.SplitHostPort(u.Host)
if err != nil {
u.Host = net.JoinHostPort(u.Host, "1935")
}
ctx2, cancel2 := context.WithTimeout(ctx, time.Duration(s.readTimeout))
defer cancel2()
nconn, err := func() (net.Conn, error) {
if u.Scheme == "rtmp" {
return (&net.Dialer{}).DialContext(ctx2, "tcp", u.Host)
}
return (&tls.Dialer{
Config: tlsConfigForFingerprint(cnf.SourceFingerprint),
}).DialContext(ctx2, "tcp", u.Host)
}()
if err != nil {
return err
}
conn := rtmp.NewConn(nconn)
readDone := make(chan error)
go func() {
readDone <- func() error {
nconn.SetReadDeadline(time.Now().Add(time.Duration(s.readTimeout)))
nconn.SetWriteDeadline(time.Now().Add(time.Duration(s.writeTimeout)))
err = conn.InitializeClient(u, false)
if err != nil {
return err
}
nconn.SetWriteDeadline(time.Time{})
nconn.SetReadDeadline(time.Now().Add(time.Duration(s.readTimeout)))
videoFormat, audioFormat, err := conn.ReadTracks()
if err != nil {
return err
}
switch videoFormat.(type) {
case *formats.H265, *formats.AV1:
return fmt.Errorf("proxying H265 or AV1 tracks with RTMP is not supported")
}
var medias media.Medias
var videoMedia *media.Media
var audioMedia *media.Media
if videoFormat != nil {
videoMedia = &media.Media{
Type: media.TypeVideo,
Formats: []formats.Format{videoFormat},
}
medias = append(medias, videoMedia)
}
if audioFormat != nil {
audioMedia = &media.Media{
Type: media.TypeAudio,
Formats: []formats.Format{audioFormat},
}
medias = append(medias, audioMedia)
}
res := s.parent.sourceStaticImplSetReady(pathSourceStaticSetReadyReq{
medias: medias,
generateRTPPackets: true,
})
if res.err != nil {
return res.err
}
s.Log(logger.Info, "ready: %s", sourceMediaInfo(medias))
defer s.parent.sourceStaticImplSetNotReady(pathSourceStaticSetNotReadyReq{})
videoWriteFunc := getRTMPWriteFunc(videoMedia, videoFormat, res.stream)
audioWriteFunc := getRTMPWriteFunc(audioMedia, audioFormat, res.stream)
// disable write deadline to allow outgoing acknowledges
nconn.SetWriteDeadline(time.Time{})
for {
nconn.SetReadDeadline(time.Now().Add(time.Duration(s.readTimeout)))
msg, err := conn.ReadMessage()
if err != nil {
return err
}
switch tmsg := msg.(type) {
case *message.Video:
if videoFormat == nil {
return fmt.Errorf("received an H264 packet, but track is not set up")
}
err := videoWriteFunc(tmsg)
if err != nil {
s.Log(logger.Warn, "%v", err)
}
case *message.Audio:
if audioFormat == nil {
return fmt.Errorf("received an AAC packet, but track is not set up")
}
err := audioWriteFunc(tmsg)
if err != nil {
s.Log(logger.Warn, "%v", err)
}
}
}
}()
}()
for {
select {
case err := <-readDone:
nconn.Close()
return err
case <-reloadConf:
case <-ctx.Done():
nconn.Close()
<-readDone
return nil
}
}
}
// apiSourceDescribe implements sourceStaticImpl.
func (*rtmpSource) apiSourceDescribe() pathAPISourceOrReader {
return pathAPISourceOrReader{
Type: "rtmpSource",
ID: "",
}
}

153
internal/core/rtmp_source_test.go

@ -0,0 +1,153 @@ @@ -0,0 +1,153 @@
package core
import (
"crypto/tls"
"net"
"os"
"testing"
"github.com/bluenviron/gortsplib/v3"
"github.com/bluenviron/gortsplib/v3/pkg/formats"
"github.com/bluenviron/gortsplib/v3/pkg/url"
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio"
"github.com/pion/rtp"
"github.com/stretchr/testify/require"
"github.com/bluenviron/mediamtx/internal/rtmp"
"github.com/bluenviron/mediamtx/internal/rtmp/message"
)
func TestRTMPSource(t *testing.T) {
for _, ca := range []string{
"plain",
"tls",
} {
t.Run(ca, func(t *testing.T) {
ln, err := func() (net.Listener, error) {
if ca == "plain" {
return net.Listen("tcp", "127.0.0.1:1937")
}
serverCertFpath, err := writeTempFile(serverCert)
require.NoError(t, err)
defer os.Remove(serverCertFpath)
serverKeyFpath, err := writeTempFile(serverKey)
require.NoError(t, err)
defer os.Remove(serverKeyFpath)
var cert tls.Certificate
cert, err = tls.LoadX509KeyPair(serverCertFpath, serverKeyFpath)
require.NoError(t, err)
return tls.Listen("tcp", "127.0.0.1:1937", &tls.Config{Certificates: []tls.Certificate{cert}})
}()
require.NoError(t, err)
defer ln.Close()
connected := make(chan struct{})
received := make(chan struct{})
done := make(chan struct{})
go func() {
nconn, err := ln.Accept()
require.NoError(t, err)
defer nconn.Close()
conn := rtmp.NewConn(nconn)
_, _, err = conn.InitializeServer()
require.NoError(t, err)
videoTrack := &formats.H264{
PayloadTyp: 96,
SPS: []byte{ // 1920x1080 baseline
0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02,
0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04,
0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9, 0x20,
},
PPS: []byte{0x08, 0x06, 0x07, 0x08},
PacketizationMode: 1,
}
audioTrack := &formats.MPEG4Audio{
PayloadTyp: 96,
Config: &mpeg4audio.Config{
Type: 2,
SampleRate: 44100,
ChannelCount: 2,
},
SizeLength: 13,
IndexLength: 3,
IndexDeltaLength: 3,
}
err = conn.WriteTracks(videoTrack, audioTrack)
require.NoError(t, err)
<-connected
err = conn.WriteMessage(&message.Video{
ChunkStreamID: message.VideoChunkStreamID,
MessageStreamID: 0x1000000,
Codec: message.CodecH264,
IsKeyFrame: true,
Type: message.VideoTypeAU,
Payload: []byte{0x00, 0x00, 0x00, 0x04, 0x05, 0x02, 0x03, 0x04},
})
require.NoError(t, err)
<-done
}()
if ca == "plain" {
p, ok := newInstance("paths:\n" +
" proxied:\n" +
" source: rtmp://localhost:1937/teststream\n" +
" sourceOnDemand: yes\n")
require.Equal(t, true, ok)
defer p.Close()
} else {
p, ok := newInstance("paths:\n" +
" proxied:\n" +
" source: rtmps://localhost:1937/teststream\n" +
" sourceFingerprint: 33949E05FFFB5FF3E8AA16F8213A6251B4D9363804BA53233C4DA9A46D6F2739\n" +
" sourceOnDemand: yes\n")
require.Equal(t, true, ok)
defer p.Close()
}
c := gortsplib.Client{}
u, err := url.Parse("rtsp://127.0.0.1:8554/proxied")
require.NoError(t, err)
err = c.Start(u.Scheme, u.Host)
require.NoError(t, err)
defer c.Close()
medias, baseURL, _, err := c.Describe(u)
require.NoError(t, err)
err = c.SetupAll(medias, baseURL)
require.NoError(t, err)
c.OnPacketRTP(medias[0], medias[0].Formats[0], func(pkt *rtp.Packet) {
require.Equal(t, []byte{
0x18, 0x0, 0x19, 0x67, 0x42, 0xc0, 0x28, 0xd9,
0x0, 0x78, 0x2, 0x27, 0xe5, 0x84, 0x0, 0x0,
0x3, 0x0, 0x4, 0x0, 0x0, 0x3, 0x0, 0xf0,
0x3c, 0x60, 0xc9, 0x20, 0x0, 0x4, 0x8, 0x6,
0x7, 0x8, 0x0, 0x4, 0x5, 0x2, 0x3, 0x4,
}, pkt.Payload)
close(received)
})
_, err = c.Play(nil)
require.NoError(t, err)
close(connected)
<-received
close(done)
})
}
}

222
internal/core/rtsp_conn.go

@ -0,0 +1,222 @@ @@ -0,0 +1,222 @@
package core
import (
"fmt"
"net"
"time"
"github.com/bluenviron/gortsplib/v3"
"github.com/bluenviron/gortsplib/v3/pkg/auth"
"github.com/bluenviron/gortsplib/v3/pkg/base"
"github.com/bluenviron/gortsplib/v3/pkg/headers"
"github.com/google/uuid"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/externalcmd"
"github.com/bluenviron/mediamtx/internal/logger"
)
const (
rtspConnPauseAfterAuthError = 2 * time.Second
)
type rtspConnParent interface {
logger.Writer
}
type rtspConn struct {
rtspAddress string
authMethods []headers.AuthMethod
readTimeout conf.StringDuration
runOnConnect string
runOnConnectRestart bool
externalCmdPool *externalcmd.Pool
pathManager *pathManager
conn *gortsplib.ServerConn
parent rtspConnParent
uuid uuid.UUID
created time.Time
onConnectCmd *externalcmd.Cmd
authNonce string
authFailures int
}
func newRTSPConn(
rtspAddress string,
authMethods []headers.AuthMethod,
readTimeout conf.StringDuration,
runOnConnect string,
runOnConnectRestart bool,
externalCmdPool *externalcmd.Pool,
pathManager *pathManager,
conn *gortsplib.ServerConn,
parent rtspConnParent,
) *rtspConn {
c := &rtspConn{
rtspAddress: rtspAddress,
authMethods: authMethods,
readTimeout: readTimeout,
runOnConnect: runOnConnect,
runOnConnectRestart: runOnConnectRestart,
externalCmdPool: externalCmdPool,
pathManager: pathManager,
conn: conn,
parent: parent,
uuid: uuid.New(),
created: time.Now(),
}
c.Log(logger.Info, "opened")
if c.runOnConnect != "" {
c.Log(logger.Info, "runOnConnect command started")
_, port, _ := net.SplitHostPort(c.rtspAddress)
c.onConnectCmd = externalcmd.NewCmd(
c.externalCmdPool,
c.runOnConnect,
c.runOnConnectRestart,
externalcmd.Environment{
"MTX_PATH": "",
"RTSP_PATH": "", // deprecated
"RTSP_PORT": port,
},
func(err error) {
c.Log(logger.Info, "runOnInit command exited: %v", err)
})
}
return c
}
func (c *rtspConn) Log(level logger.Level, format string, args ...interface{}) {
c.parent.Log(level, "[conn %v] "+format, append([]interface{}{c.conn.NetConn().RemoteAddr()}, args...)...)
}
// Conn returns the RTSP connection.
func (c *rtspConn) Conn() *gortsplib.ServerConn {
return c.conn
}
func (c *rtspConn) remoteAddr() net.Addr {
return c.conn.NetConn().RemoteAddr()
}
func (c *rtspConn) ip() net.IP {
return c.conn.NetConn().RemoteAddr().(*net.TCPAddr).IP
}
// onClose is called by rtspServer.
func (c *rtspConn) onClose(err error) {
c.Log(logger.Info, "closed (%v)", err)
if c.onConnectCmd != nil {
c.onConnectCmd.Close()
c.Log(logger.Info, "runOnConnect command stopped")
}
}
// onRequest is called by rtspServer.
func (c *rtspConn) onRequest(req *base.Request) {
c.Log(logger.Debug, "[c->s] %v", req)
}
// OnResponse is called by rtspServer.
func (c *rtspConn) OnResponse(res *base.Response) {
c.Log(logger.Debug, "[s->c] %v", res)
}
// onDescribe is called by rtspServer.
func (c *rtspConn) onDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx,
) (*base.Response, *gortsplib.ServerStream, error) {
if len(ctx.Path) == 0 || ctx.Path[0] != '/' {
return &base.Response{
StatusCode: base.StatusBadRequest,
}, nil, fmt.Errorf("invalid path")
}
ctx.Path = ctx.Path[1:]
if c.authNonce == "" {
c.authNonce = auth.GenerateNonce()
}
res := c.pathManager.describe(pathDescribeReq{
pathName: ctx.Path,
url: ctx.Request.URL,
credentials: authCredentials{
query: ctx.Query,
ip: c.ip(),
proto: authProtocolRTSP,
id: &c.uuid,
rtspRequest: ctx.Request,
rtspNonce: c.authNonce,
},
})
if res.err != nil {
switch terr := res.err.(type) {
case pathErrAuth:
res, err := c.handleAuthError(terr.wrapped)
return res, nil, err
case pathErrNoOnePublishing:
return &base.Response{
StatusCode: base.StatusNotFound,
}, nil, res.err
default:
return &base.Response{
StatusCode: base.StatusBadRequest,
}, nil, res.err
}
}
if res.redirect != "" {
return &base.Response{
StatusCode: base.StatusMovedPermanently,
Header: base.Header{
"Location": base.HeaderValue{res.redirect},
},
}, nil, nil
}
return &base.Response{
StatusCode: base.StatusOK,
}, res.stream.rtspStream, nil
}
func (c *rtspConn) handleAuthError(authErr error) (*base.Response, error) {
c.authFailures++
// VLC with login prompt sends 4 requests:
// 1) without credentials
// 2) with password but without username
// 3) without credentials
// 4) with password and username
// therefore we must allow up to 3 failures
if c.authFailures <= 3 {
return &base.Response{
StatusCode: base.StatusUnauthorized,
Header: base.Header{
"WWW-Authenticate": auth.GenerateWWWAuthenticate(c.authMethods, "IPCAM", c.authNonce),
},
}, nil
}
// wait some seconds to stop brute force attacks
<-time.After(rtspConnPauseAfterAuthError)
return &base.Response{
StatusCode: base.StatusUnauthorized,
}, authErr
}
func (c *rtspConn) apiItem() *apiRTSPConn {
return &apiRTSPConn{
ID: c.uuid,
Created: c.created,
RemoteAddr: c.remoteAddr().String(),
BytesReceived: c.conn.BytesReceived(),
BytesSent: c.conn.BytesSent(),
}
}

454
internal/core/rtsp_server.go

@ -0,0 +1,454 @@ @@ -0,0 +1,454 @@
package core
import (
"context"
"crypto/tls"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/bluenviron/gortsplib/v3"
"github.com/bluenviron/gortsplib/v3/pkg/base"
"github.com/bluenviron/gortsplib/v3/pkg/headers"
"github.com/bluenviron/gortsplib/v3/pkg/liberrors"
"github.com/google/uuid"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/externalcmd"
"github.com/bluenviron/mediamtx/internal/logger"
)
type rtspServerParent interface {
logger.Writer
}
func printAddresses(srv *gortsplib.Server) string {
var ret []string
ret = append(ret, fmt.Sprintf("%s (TCP)", srv.RTSPAddress))
if srv.UDPRTPAddress != "" {
ret = append(ret, fmt.Sprintf("%s (UDP/RTP)", srv.UDPRTPAddress))
}
if srv.UDPRTCPAddress != "" {
ret = append(ret, fmt.Sprintf("%s (UDP/RTCP)", srv.UDPRTCPAddress))
}
return strings.Join(ret, ", ")
}
type rtspServer struct {
authMethods []headers.AuthMethod
readTimeout conf.StringDuration
isTLS bool
rtspAddress string
protocols map[conf.Protocol]struct{}
runOnConnect string
runOnConnectRestart bool
externalCmdPool *externalcmd.Pool
metrics *metrics
pathManager *pathManager
parent rtspServerParent
ctx context.Context
ctxCancel func()
wg sync.WaitGroup
srv *gortsplib.Server
mutex sync.RWMutex
conns map[*gortsplib.ServerConn]*rtspConn
sessions map[*gortsplib.ServerSession]*rtspSession
}
func newRTSPServer(
address string,
authMethods []headers.AuthMethod,
readTimeout conf.StringDuration,
writeTimeout conf.StringDuration,
readBufferCount int,
useUDP bool,
useMulticast bool,
rtpAddress string,
rtcpAddress string,
multicastIPRange string,
multicastRTPPort int,
multicastRTCPPort int,
isTLS bool,
serverCert string,
serverKey string,
rtspAddress string,
protocols map[conf.Protocol]struct{},
runOnConnect string,
runOnConnectRestart bool,
externalCmdPool *externalcmd.Pool,
metrics *metrics,
pathManager *pathManager,
parent rtspServerParent,
) (*rtspServer, error) {
ctx, ctxCancel := context.WithCancel(context.Background())
s := &rtspServer{
authMethods: authMethods,
readTimeout: readTimeout,
isTLS: isTLS,
rtspAddress: rtspAddress,
protocols: protocols,
runOnConnect: runOnConnect,
runOnConnectRestart: runOnConnectRestart,
externalCmdPool: externalCmdPool,
metrics: metrics,
pathManager: pathManager,
parent: parent,
ctx: ctx,
ctxCancel: ctxCancel,
conns: make(map[*gortsplib.ServerConn]*rtspConn),
sessions: make(map[*gortsplib.ServerSession]*rtspSession),
}
s.srv = &gortsplib.Server{
Handler: s,
ReadTimeout: time.Duration(readTimeout),
WriteTimeout: time.Duration(writeTimeout),
ReadBufferCount: readBufferCount,
WriteBufferCount: readBufferCount,
RTSPAddress: address,
}
if useUDP {
s.srv.UDPRTPAddress = rtpAddress
s.srv.UDPRTCPAddress = rtcpAddress
}
if useMulticast {
s.srv.MulticastIPRange = multicastIPRange
s.srv.MulticastRTPPort = multicastRTPPort
s.srv.MulticastRTCPPort = multicastRTCPPort
}
if isTLS {
cert, err := tls.LoadX509KeyPair(serverCert, serverKey)
if err != nil {
return nil, err
}
s.srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
}
err := s.srv.Start()
if err != nil {
return nil, err
}
s.Log(logger.Info, "listener opened on %s", printAddresses(s.srv))
if metrics != nil {
if !isTLS {
metrics.rtspServerSet(s)
} else {
metrics.rtspsServerSet(s)
}
}
s.wg.Add(1)
go s.run()
return s, nil
}
func (s *rtspServer) Log(level logger.Level, format string, args ...interface{}) {
label := func() string {
if s.isTLS {
return "RTSPS"
}
return "RTSP"
}()
s.parent.Log(level, "[%s] "+format, append([]interface{}{label}, args...)...)
}
func (s *rtspServer) close() {
s.Log(logger.Info, "listener is closing")
s.ctxCancel()
s.wg.Wait()
}
func (s *rtspServer) run() {
defer s.wg.Done()
serverErr := make(chan error)
go func() {
serverErr <- s.srv.Wait()
}()
outer:
select {
case err := <-serverErr:
s.Log(logger.Error, "%s", err)
break outer
case <-s.ctx.Done():
s.srv.Close()
<-serverErr
break outer
}
s.ctxCancel()
if s.metrics != nil {
if !s.isTLS {
s.metrics.rtspServerSet(nil)
} else {
s.metrics.rtspsServerSet(nil)
}
}
}
// OnConnOpen implements gortsplib.ServerHandlerOnConnOpen.
func (s *rtspServer) OnConnOpen(ctx *gortsplib.ServerHandlerOnConnOpenCtx) {
c := newRTSPConn(
s.rtspAddress,
s.authMethods,
s.readTimeout,
s.runOnConnect,
s.runOnConnectRestart,
s.externalCmdPool,
s.pathManager,
ctx.Conn,
s)
s.mutex.Lock()
s.conns[ctx.Conn] = c
s.mutex.Unlock()
ctx.Conn.SetUserData(c)
}
// OnConnClose implements gortsplib.ServerHandlerOnConnClose.
func (s *rtspServer) OnConnClose(ctx *gortsplib.ServerHandlerOnConnCloseCtx) {
s.mutex.Lock()
c := s.conns[ctx.Conn]
delete(s.conns, ctx.Conn)
s.mutex.Unlock()
c.onClose(ctx.Error)
}
// OnRequest implements gortsplib.ServerHandlerOnRequest.
func (s *rtspServer) OnRequest(sc *gortsplib.ServerConn, req *base.Request) {
c := sc.UserData().(*rtspConn)
c.onRequest(req)
}
// OnResponse implements gortsplib.ServerHandlerOnResponse.
func (s *rtspServer) OnResponse(sc *gortsplib.ServerConn, res *base.Response) {
c := sc.UserData().(*rtspConn)
c.OnResponse(res)
}
// OnSessionOpen implements gortsplib.ServerHandlerOnSessionOpen.
func (s *rtspServer) OnSessionOpen(ctx *gortsplib.ServerHandlerOnSessionOpenCtx) {
se := newRTSPSession(
s.isTLS,
s.protocols,
ctx.Session,
ctx.Conn,
s.externalCmdPool,
s.pathManager,
s)
s.mutex.Lock()
s.sessions[ctx.Session] = se
s.mutex.Unlock()
ctx.Session.SetUserData(se)
}
// OnSessionClose implements gortsplib.ServerHandlerOnSessionClose.
func (s *rtspServer) OnSessionClose(ctx *gortsplib.ServerHandlerOnSessionCloseCtx) {
s.mutex.Lock()
se := s.sessions[ctx.Session]
delete(s.sessions, ctx.Session)
s.mutex.Unlock()
if se != nil {
se.onClose(ctx.Error)
}
}
// OnDescribe implements gortsplib.ServerHandlerOnDescribe.
func (s *rtspServer) OnDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx,
) (*base.Response, *gortsplib.ServerStream, error) {
c := ctx.Conn.UserData().(*rtspConn)
return c.onDescribe(ctx)
}
// OnAnnounce implements gortsplib.ServerHandlerOnAnnounce.
func (s *rtspServer) OnAnnounce(ctx *gortsplib.ServerHandlerOnAnnounceCtx) (*base.Response, error) {
c := ctx.Conn.UserData().(*rtspConn)
se := ctx.Session.UserData().(*rtspSession)
return se.onAnnounce(c, ctx)
}
// OnSetup implements gortsplib.ServerHandlerOnSetup.
func (s *rtspServer) OnSetup(ctx *gortsplib.ServerHandlerOnSetupCtx) (*base.Response, *gortsplib.ServerStream, error) {
c := ctx.Conn.UserData().(*rtspConn)
se := ctx.Session.UserData().(*rtspSession)
return se.onSetup(c, ctx)
}
// OnPlay implements gortsplib.ServerHandlerOnPlay.
func (s *rtspServer) OnPlay(ctx *gortsplib.ServerHandlerOnPlayCtx) (*base.Response, error) {
se := ctx.Session.UserData().(*rtspSession)
return se.onPlay(ctx)
}
// OnRecord implements gortsplib.ServerHandlerOnRecord.
func (s *rtspServer) OnRecord(ctx *gortsplib.ServerHandlerOnRecordCtx) (*base.Response, error) {
se := ctx.Session.UserData().(*rtspSession)
return se.onRecord(ctx)
}
// OnPause implements gortsplib.ServerHandlerOnPause.
func (s *rtspServer) OnPause(ctx *gortsplib.ServerHandlerOnPauseCtx) (*base.Response, error) {
se := ctx.Session.UserData().(*rtspSession)
return se.onPause(ctx)
}
// OnPacketLost implements gortsplib.ServerHandlerOnDecodeError.
func (s *rtspServer) OnPacketLost(ctx *gortsplib.ServerHandlerOnPacketLostCtx) {
se := ctx.Session.UserData().(*rtspSession)
se.onPacketLost(ctx)
}
// OnDecodeError implements gortsplib.ServerHandlerOnDecodeError.
func (s *rtspServer) OnDecodeError(ctx *gortsplib.ServerHandlerOnDecodeErrorCtx) {
se := ctx.Session.UserData().(*rtspSession)
se.onDecodeError(ctx)
}
func (s *rtspServer) findConnByUUID(uuid uuid.UUID) *rtspConn {
for _, c := range s.conns {
if c.uuid == uuid {
return c
}
}
return nil
}
func (s *rtspServer) findSessionByUUID(uuid uuid.UUID) (*gortsplib.ServerSession, *rtspSession) {
for key, sx := range s.sessions {
if sx.uuid == uuid {
return key, sx
}
}
return nil, nil
}
// apiConnsList is called by api and metrics.
func (s *rtspServer) apiConnsList() (*apiRTSPConnsList, error) {
select {
case <-s.ctx.Done():
return nil, fmt.Errorf("terminated")
default:
}
s.mutex.RLock()
defer s.mutex.RUnlock()
data := &apiRTSPConnsList{
Items: []*apiRTSPConn{},
}
for _, c := range s.conns {
data.Items = append(data.Items, c.apiItem())
}
sort.Slice(data.Items, func(i, j int) bool {
return data.Items[i].Created.Before(data.Items[j].Created)
})
return data, nil
}
// apiConnsGet is called by api.
func (s *rtspServer) apiConnsGet(uuid uuid.UUID) (*apiRTSPConn, error) {
select {
case <-s.ctx.Done():
return nil, fmt.Errorf("terminated")
default:
}
s.mutex.RLock()
defer s.mutex.RUnlock()
conn := s.findConnByUUID(uuid)
if conn == nil {
return nil, errAPINotFound
}
return conn.apiItem(), nil
}
// apiSessionsList is called by api and metrics.
func (s *rtspServer) apiSessionsList() (*apiRTSPSessionsList, error) {
select {
case <-s.ctx.Done():
return nil, fmt.Errorf("terminated")
default:
}
s.mutex.RLock()
defer s.mutex.RUnlock()
data := &apiRTSPSessionsList{
Items: []*apiRTSPSession{},
}
for _, s := range s.sessions {
data.Items = append(data.Items, s.apiItem())
}
sort.Slice(data.Items, func(i, j int) bool {
return data.Items[i].Created.Before(data.Items[j].Created)
})
return data, nil
}
// apiSessionsGet is called by api.
func (s *rtspServer) apiSessionsGet(uuid uuid.UUID) (*apiRTSPSession, error) {
select {
case <-s.ctx.Done():
return nil, fmt.Errorf("terminated")
default:
}
s.mutex.RLock()
defer s.mutex.RUnlock()
_, sx := s.findSessionByUUID(uuid)
if sx == nil {
return nil, errAPINotFound
}
return sx.apiItem(), nil
}
// apiSessionsKick is called by api.
func (s *rtspServer) apiSessionsKick(uuid uuid.UUID) error {
select {
case <-s.ctx.Done():
return fmt.Errorf("terminated")
default:
}
s.mutex.RLock()
defer s.mutex.RUnlock()
key, sx := s.findSessionByUUID(uuid)
if sx == nil {
return errAPINotFound
}
sx.close()
delete(s.sessions, key)
sx.onClose(liberrors.ErrServerTerminated{})
return nil
}

422
internal/core/rtsp_server_test.go

@ -0,0 +1,422 @@ @@ -0,0 +1,422 @@
package core
import (
"os"
"testing"
"time"
"github.com/bluenviron/gortsplib/v3"
"github.com/bluenviron/gortsplib/v3/pkg/media"
"github.com/bluenviron/gortsplib/v3/pkg/url"
"github.com/pion/rtp"
"github.com/stretchr/testify/require"
)
func TestRTSPServerRunOnConnect(t *testing.T) {
f, err := os.CreateTemp(os.TempDir(), "rtspss-runonconnect-")
require.NoError(t, err)
f.Close()
defer os.Remove(f.Name())
p, ok := newInstance(
"runOnConnect: sh -c 'echo aa > " + f.Name() + "'\n" +
"paths:\n" +
" all:\n")
require.Equal(t, true, ok)
defer p.Close()
source := gortsplib.Client{}
err = source.StartRecording(
"rtsp://127.0.0.1:8554/mypath",
media.Medias{testMediaH264})
require.NoError(t, err)
defer source.Close()
time.Sleep(500 * time.Millisecond)
byts, err := os.ReadFile(f.Name())
require.NoError(t, err)
require.Equal(t, "aa\n", string(byts))
}
func TestRTSPServer(t *testing.T) {
for _, auth := range []string{
"none",
"internal",
"external",
} {
t.Run("auth_"+auth, func(t *testing.T) {
var conf string
switch auth {
case "none":
conf = "paths:\n" +
" all:\n"
case "internal":
conf = "rtmpDisable: yes\n" +
"hlsDisable: yes\n" +
"webrtcDisable: yes\n" +
"paths:\n" +
" all:\n" +
" publishUser: testpublisher\n" +
" publishPass: testpass\n" +
" publishIPs: [127.0.0.0/16]\n" +
" readUser: testreader\n" +
" readPass: testpass\n" +
" readIPs: [127.0.0.0/16]\n"
case "external":
conf = "externalAuthenticationURL: http://localhost:9120/auth\n" +
"paths:\n" +
" all:\n"
}
p, ok := newInstance(conf)
require.Equal(t, true, ok)
defer p.Close()
var a *testHTTPAuthenticator
if auth == "external" {
a = newTestHTTPAuthenticator(t, "rtsp", "publish")
}
medi := testMediaH264
source := gortsplib.Client{}
err := source.StartRecording(
"rtsp://testpublisher:testpass@127.0.0.1:8554/teststream?param=value",
media.Medias{medi})
require.NoError(t, err)
defer source.Close()
if auth == "external" {
a.close()
a = newTestHTTPAuthenticator(t, "rtsp", "read")
defer a.close()
}
reader := gortsplib.Client{}
u, err := url.Parse("rtsp://testreader:testpass@127.0.0.1:8554/teststream?param=value")
require.NoError(t, err)
err = reader.Start(u.Scheme, u.Host)
require.NoError(t, err)
defer reader.Close()
medias, baseURL, _, err := reader.Describe(u)
require.NoError(t, err)
err = reader.SetupAll(medias, baseURL)
require.NoError(t, err)
_, err = reader.Play(nil)
require.NoError(t, err)
})
}
}
func TestRTSPServerAuthHashed(t *testing.T) {
p, ok := newInstance(
"rtmpDisable: yes\n" +
"hlsDisable: yes\n" +
"webrtcDisable: yes\n" +
"paths:\n" +
" all:\n" +
" publishUser: sha256:rl3rgi4NcZkpAEcacZnQ2VuOfJ0FxAqCRaKB/SwdZoQ=\n" +
" publishPass: sha256:E9JJ8stBJ7QM+nV4ZoUCeHk/gU3tPFh/5YieiJp6n2w=\n")
require.Equal(t, true, ok)
defer p.Close()
medi := testMediaH264
source := gortsplib.Client{}
err := source.StartRecording(
"rtsp://testuser:testpass@127.0.0.1:8554/test/stream",
media.Medias{medi})
require.NoError(t, err)
defer source.Close()
}
func TestRTSPServerAuthFail(t *testing.T) {
for _, ca := range []struct {
name string
user string
pass string
}{
{
"wronguser",
"test1user",
"testpass",
},
{
"wrongpass",
"testuser",
"test1pass",
},
{
"wrongboth",
"test1user",
"test1pass",
},
} {
t.Run("publish_"+ca.name, func(t *testing.T) {
p, ok := newInstance("rtmpDisable: yes\n" +
"hlsDisable: yes\n" +
"webrtcDisable: yes\n" +
"paths:\n" +
" all:\n" +
" publishUser: testuser\n" +
" publishPass: testpass\n")
require.Equal(t, true, ok)
defer p.Close()
medi := testMediaH264
c := gortsplib.Client{}
err := c.StartRecording(
"rtsp://"+ca.user+":"+ca.pass+"@localhost:8554/test/stream",
media.Medias{medi},
)
require.EqualError(t, err, "bad status code: 401 (Unauthorized)")
})
}
for _, ca := range []struct {
name string
user string
pass string
}{
{
"wronguser",
"test1user",
"testpass",
},
{
"wrongpass",
"testuser",
"test1pass",
},
{
"wrongboth",
"test1user",
"test1pass",
},
} {
t.Run("read_"+ca.name, func(t *testing.T) {
p, ok := newInstance("rtmpDisable: yes\n" +
"hlsDisable: yes\n" +
"webrtcDisable: yes\n" +
"paths:\n" +
" all:\n" +
" readUser: testuser\n" +
" readPass: testpass\n")
require.Equal(t, true, ok)
defer p.Close()
c := gortsplib.Client{}
u, err := url.Parse("rtsp://" + ca.user + ":" + ca.pass + "@localhost:8554/test/stream")
require.NoError(t, err)
err = c.Start(u.Scheme, u.Host)
require.NoError(t, err)
defer c.Close()
_, _, _, err = c.Describe(u)
require.EqualError(t, err, "bad status code: 401 (Unauthorized)")
})
}
t.Run("ip", func(t *testing.T) {
p, ok := newInstance("rtmpDisable: yes\n" +
"hlsDisable: yes\n" +
"webrtcDisable: yes\n" +
"paths:\n" +
" all:\n" +
" publishIPs: [128.0.0.1/32]\n")
require.Equal(t, true, ok)
defer p.Close()
medi := testMediaH264
c := gortsplib.Client{}
err := c.StartRecording(
"rtsp://localhost:8554/test/stream",
media.Medias{medi},
)
require.EqualError(t, err, "bad status code: 401 (Unauthorized)")
})
t.Run("external", func(t *testing.T) {
p, ok := newInstance("externalAuthenticationURL: http://localhost:9120/auth\n" +
"paths:\n" +
" all:\n")
require.Equal(t, true, ok)
defer p.Close()
a := newTestHTTPAuthenticator(t, "rtsp", "publish")
defer a.close()
medi := testMediaH264
c := gortsplib.Client{}
err := c.StartRecording(
"rtsp://testpublisher2:testpass@localhost:8554/teststream?param=value",
media.Medias{medi},
)
require.EqualError(t, err, "bad status code: 401 (Unauthorized)")
})
}
func TestRTSPServerPublisherOverride(t *testing.T) {
for _, ca := range []string{
"enabled",
"disabled",
} {
t.Run(ca, func(t *testing.T) {
conf := "rtmpDisable: yes\n" +
"paths:\n" +
" all:\n"
if ca == "disabled" {
conf += " disablePublisherOverride: yes\n"
}
p, ok := newInstance(conf)
require.Equal(t, true, ok)
defer p.Close()
medi := testMediaH264
s1 := gortsplib.Client{}
err := s1.StartRecording("rtsp://localhost:8554/teststream", media.Medias{medi})
require.NoError(t, err)
defer s1.Close()
s2 := gortsplib.Client{}
err = s2.StartRecording("rtsp://localhost:8554/teststream", media.Medias{medi})
if ca == "enabled" {
require.NoError(t, err)
defer s2.Close()
} else {
require.Error(t, err)
}
frameRecv := make(chan struct{})
c := gortsplib.Client{}
u, err := url.Parse("rtsp://localhost:8554/teststream")
require.NoError(t, err)
err = c.Start(u.Scheme, u.Host)
require.NoError(t, err)
defer c.Close()
medias, baseURL, _, err := c.Describe(u)
require.NoError(t, err)
err = c.SetupAll(medias, baseURL)
require.NoError(t, err)
c.OnPacketRTP(medias[0], medias[0].Formats[0], func(pkt *rtp.Packet) {
if ca == "enabled" {
require.Equal(t, []byte{0x05, 0x06, 0x07, 0x08}, pkt.Payload)
} else {
require.Equal(t, []byte{0x01, 0x02, 0x03, 0x04}, pkt.Payload)
}
close(frameRecv)
})
_, err = c.Play(nil)
require.NoError(t, err)
if ca == "enabled" {
err := s1.Wait()
require.EqualError(t, err, "EOF")
err = s2.WritePacketRTP(medi, &rtp.Packet{
Header: rtp.Header{
Version: 0x02,
PayloadType: 96,
SequenceNumber: 57899,
Timestamp: 345234345,
SSRC: 978651231,
Marker: true,
},
Payload: []byte{0x05, 0x06, 0x07, 0x08},
})
require.NoError(t, err)
} else {
err = s1.WritePacketRTP(medi, &rtp.Packet{
Header: rtp.Header{
Version: 0x02,
PayloadType: 96,
SequenceNumber: 57899,
Timestamp: 345234345,
SSRC: 978651231,
Marker: true,
},
Payload: []byte{0x01, 0x02, 0x03, 0x04},
})
require.NoError(t, err)
}
<-frameRecv
})
}
}
func TestRTSPServerFallback(t *testing.T) {
for _, ca := range []string{
"absolute",
"relative",
} {
t.Run(ca, func(t *testing.T) {
val := func() string {
if ca == "absolute" {
return "rtsp://localhost:8554/path2"
}
return "/path2"
}()
p1, ok := newInstance("rtmpDisable: yes\n" +
"hlsDisable: yes\n" +
"webrtcDisable: yes\n" +
"paths:\n" +
" path1:\n" +
" fallback: " + val + "\n" +
" path2:\n")
require.Equal(t, true, ok)
defer p1.Close()
source := gortsplib.Client{}
err := source.StartRecording("rtsp://localhost:8554/path2",
media.Medias{testMediaH264})
require.NoError(t, err)
defer source.Close()
u, err := url.Parse("rtsp://localhost:8554/path1")
require.NoError(t, err)
dest := gortsplib.Client{}
err = dest.Start(u.Scheme, u.Host)
require.NoError(t, err)
defer dest.Close()
medias, _, _, err := dest.Describe(u)
require.NoError(t, err)
require.Equal(t, 1, len(medias))
})
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save