Compare commits
1 Commits
main
...
archived/f
| Author | SHA1 | Date |
|---|---|---|
|
|
1368d442f5 | 3 years ago |
463 changed files with 20892 additions and 38827 deletions
@ -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` |
||||
@ -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}`, |
||||
}); |
||||
} |
||||
@ -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: |
||||
@ -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 |
||||
@ -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 |
||||
@ -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. |
||||
@ -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) |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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") |
||||
} |
||||
} |
||||
@ -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") |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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¶m=value" && |
||||
in.Query != "user=testpublisher&pass=testpass¶m=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) |
||||
} |
||||
@ -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 + `"`)) |
||||
} |
||||
@ -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"` |
||||
} |
||||
@ -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) |
||||
} |
||||
}) |
||||
} |
||||
}) |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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 + `"`)) |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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"` |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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, |
||||
}) |
||||
} |
||||
} |
||||
@ -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> |
||||
@ -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(): |
||||
} |
||||
} |
||||
@ -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¶m=value" && |
||||
in.Query != "user=testpublisher&pass=testpass¶m=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)*/ |
||||
} |
||||
@ -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), |
||||
} |
||||
} |
||||
@ -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: "", |
||||
} |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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()) |
||||
} |
||||
} |
||||
@ -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()
|
||||
} |
||||
@ -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() |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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)) |
||||
}) |
||||
} |
||||
} |
||||
@ -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 |
||||
}) |
||||
} |
||||
} |
||||
@ -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...) |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
package core |
||||
|
||||
// publisher is an entity that can publish a stream.
|
||||
type publisher interface { |
||||
source |
||||
close() |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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: "", |
||||
} |
||||
} |
||||
@ -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(), |
||||
} |
||||
} |
||||
@ -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") |
||||
} |
||||
} |
||||
@ -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¶m=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¶m=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") |
||||
}) |
||||
} |
||||
@ -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: "", |
||||
} |
||||
} |
||||
@ -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) |
||||
}) |
||||
} |
||||
} |
||||
@ -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(), |
||||
} |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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…
Reference in new issue