Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions docs/2-features/19-external-path-configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# External path configuration

_MediaMTX_ normally requires every path to be declared in `mediamtx.yml` (either explicitly or via `all`/`all_others`). The **external path configuration** feature lets you delegate path resolution to an external HTTP service: whenever a client requests a path that matches only the catch-all `all` or `all_others` entry, the server queries your endpoint to obtain a custom configuration for that specific path.

This is useful when paths are created dynamically — for example in multi-tenant systems where each stream has its own configuration (source, recording policy, authentication requirements, etc.) stored in a database outside of MediaMTX.

## How it works

1. A client connects and requests a path (e.g. `rtsp://localhost:8554/live/camera42`).
2. MediaMTX runs its normal lookup:
- If a **static** path config matches → used directly, external endpoint is **not** called.
- If a **specific regexp** path config matches → used directly, external endpoint is **not** called.
- If only `all` / `all_others` matches → external endpoint **is** called.
3. The endpoint receives a `GET` request at `{pathExternalConfURL}/{pathName}`.
4. Possible responses:
- **HTTP 200** — body is a JSON object with path configuration fields (same fields as `pathDefaults`). Fields omitted in the response inherit from `pathDefaults`.
- **HTTP 404** — path is unknown to the external service; MediaMTX falls back to `all`/`all_others`.
- **Any other status** — treated as a transient error; a warning is logged and MediaMTX falls back to `all`/`all_others`.

## Configuration

```yml
# Enable dynamic path configuration fetching.
# Default: false
pathExternalConfEnabled: true

# URL prefix of the external path configuration endpoint.
# MediaMTX appends /{pathName} to this URL.
# Required when pathExternalConfEnabled is yes.
pathExternalConfURL: http://my-backend/api/stream-configs
```

## Endpoint specification

### Request

```
GET {pathExternalConfURL}?path={pathName}&{originalQueryString}
```

| Query parameter | Description |
|-----------------|-------------|
| `path` | Name of the requested path (e.g. `live/camera42`) |
| `*` | All other query parameters from the client's original request are forwarded as-is. If the client sends a `path` parameter it is ignored to avoid collision. |

**Authentication headers** — MediaMTX forwards the connecting client's credentials to the endpoint according to the configured `authMethod`:

| `authMethod` | Header sent to endpoint |
|---|---|
| `internal` or `http` | `Authorization: Basic <base64(user:pass)>` |
| `jwt` | `Authorization: Bearer <token>` |

### Response

Return `Content-Type: application/json` with a JSON object. All fields are optional; omitted fields inherit from `pathDefaults`. Example:

```json
{
"source": "rtsp://10.0.0.5:554/stream",
"sourceOnDemand": true,
"record": true,
"recordPath": "./recordings/%path/%Y-%m-%d_%H-%M-%S-%f",
"runOnReady": "/usr/local/bin/notify.sh"
}
```

Return `404` if the path is not known. Any other non-200 status is treated as a transient error.

## Example

Suppose you have a database of cameras. Each camera is assigned a unique stream key. You want MediaMTX to accept any valid key and pull the corresponding RTSP source.

`mediamtx.yml`:

```yml
pathExternalConfEnabled: true
pathExternalConfURL: http://localhost:8080/api/stream-configs

paths:
all_others:
```

Backend (pseudo-code):

```
GET /api/stream-configs?path=camera42
→ 200 { "source": "rtsp://10.0.1.42:554/live", "sourceOnDemand": true }

GET /api/stream-configs?path=unknown-key
→ 404
```

When a client opens `rtsp://mediamtx:8554/camera42`:

1. `camera42` has no static config → matches `all_others`.
2. MediaMTX queries `http://localhost:8080/api/stream-configs/camera42`.
3. The backend returns the source URL.
4. MediaMTX uses that config to pull the RTSP feed and serve the client.

When a client opens `rtsp://mediamtx:8554/unknown-key`:

1. `unknown-key` matches `all_others`.
2. MediaMTX queries the backend → `404`.
3. MediaMTX falls back to the `all_others` config (default publisher mode).

## Notes

- The external endpoint is called **on every new connection** for paths that fall through to `all`/`all_others`. Responses are **not cached**; add caching in your backend if needed.
- The returned configuration is **not validated** by MediaMTX the same way static configs are. Make sure the JSON fields are correct — invalid values may cause unexpected behavior.
- Hot-reloading MediaMTX configuration does **not** re-read external path configs for already-established paths.
- The `pathExternalConfURL` setting does **not** support hot-reload; restart MediaMTX to change it.
19 changes: 18 additions & 1 deletion internal/conf/conf.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net"
"net/url"
"os"
"reflect"
"slices"
Expand Down Expand Up @@ -402,6 +403,10 @@ type Conf struct {
RecordSegmentDuration *Duration `json:"recordSegmentDuration,omitempty" deprecated:"true"`
RecordDeleteAfter *Duration `json:"recordDeleteAfter,omitempty" deprecated:"true"`

// External path configuration source
PathExternalConfEnabled bool `json:"pathExternalConfEnabled"`
PathExternalConfURL string `json:"pathExternalConfURL"`

// Path defaults
PathDefaults Path `json:"pathDefaults"`

Expand Down Expand Up @@ -1061,6 +1066,18 @@ func (conf *Conf) Validate(l logger.Writer) error {
conf.PathDefaults.RecordDeleteAfter = *conf.RecordDeleteAfter
}

// external path configuration source

if conf.PathExternalConfEnabled {
if conf.PathExternalConfURL == "" {
return fmt.Errorf("'pathExternalConfURL' must be set when 'pathExternalConfEnabled' is true")
}
_, err := url.ParseRequestURI(conf.PathExternalConfURL)
if err != nil {
return fmt.Errorf("invalid 'pathExternalConfURL': %w", err)
}
}

// paths

hasAllOthers := false
Expand All @@ -1084,7 +1101,7 @@ func (conf *Conf) Validate(l logger.Writer) error {
conf.OptionalPaths[name] = optional
}

pconf := newPath(&conf.PathDefaults, optional)
pconf := NewPath(&conf.PathDefaults, optional)
conf.Paths[name] = pconf
}

Expand Down
8 changes: 5 additions & 3 deletions internal/conf/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,9 @@ func FindPathConf(pathConfs map[string]*Path, name string) (*Path, []string, err

// Path is a path configuration.
type Path struct {
Regexp *regexp.Regexp `json:"-"` // filled by Validate()
Name string `json:"name"` // filled by Validate()
Regexp *regexp.Regexp `json:"-"` // filled by Validate()
Name string `json:"name"` // filled by Validate()
CreatedAt string `json:"createdAt,omitempty"`

// General
Source string `json:"source"`
Expand Down Expand Up @@ -369,7 +370,8 @@ func (pconf *Path) setDefaults() {
pconf.RunOnDemandCloseAfter = 10 * Duration(time.Second)
}

func newPath(defaults *Path, partial *OptionalPath) *Path {
// NewPath creates a Path by merging defaults with optional partial overrides.
func NewPath(defaults *Path, partial *OptionalPath) *Path {
pconf := &Path{}
copyStructFields(pconf, defaults)
copyStructFields(pconf, partial.Values)
Expand Down
8 changes: 6 additions & 2 deletions internal/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,8 +438,12 @@ func (p *Core) createResources(initial bool) error {
writeQueueSize: p.conf.WriteQueueSize,
udpReadBufferSize: p.conf.UDPReadBufferSize,
rtpMaxPayloadSize: rtpMaxPayloadSize,
pathConfs: p.conf.Paths,
authManager: p.authManager,
pathConfs: p.conf.Paths,
pathDefaults: p.conf.PathDefaults,
pathExternalConfEnabled: p.conf.PathExternalConfEnabled,
pathExternalConfURL: p.conf.PathExternalConfURL,
authMethod: p.conf.AuthMethod,
authManager: p.authManager,
externalCmdPool: p.externalCmdPool,
metrics: p.metrics,
parent: p,
Expand Down
11 changes: 9 additions & 2 deletions internal/core/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ type path struct {
source defs.Source
stream *stream.Stream
recorder *recorder.Recorder
createdAt string
availableTime time.Time
onlineTime time.Time
onUnDemandHook func(string)
Expand Down Expand Up @@ -128,6 +129,11 @@ func (pa *path) initialize() {
ctx, ctxCancel := context.WithCancel(pa.parentCtx)

pa.confName = pa.conf.Name
if pa.conf.CreatedAt != "" {
pa.createdAt = pa.conf.CreatedAt
} else {
pa.createdAt = time.Now().Format(time.RFC3339)
}
pa.ctx = ctx
pa.ctxCancel = ctxCancel
pa.readers = make(map[defs.Reader]struct{})
Expand Down Expand Up @@ -623,8 +629,9 @@ func (pa *path) doRemoveReader(req defs.PathRemoveReaderReq) {
func (pa *path) doAPIPathsGet(req pathAPIPathsGetReq) {
req.res <- pathAPIPathsGetRes{
data: &defs.APIPath{
Name: pa.name,
ConfName: pa.conf.Name,
Name: pa.name,
ConfName: pa.conf.Name,
CreatedAt: pa.createdAt,
Ready: pa.isAvailable(),
ReadyTime: func() *time.Time {
if !pa.isAvailable() {
Expand Down
115 changes: 109 additions & 6 deletions internal/core/path_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@ package core

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"sort"
"strings"
"sync"

"github.com/bluenviron/mediamtx/internal/auth"
Expand Down Expand Up @@ -76,8 +82,12 @@ type pathManager struct {
writeQueueSize int
udpReadBufferSize uint
rtpMaxPayloadSize int
pathConfs map[string]*conf.Path
authManager pathManagerAuthManager
pathConfs map[string]*conf.Path
pathDefaults conf.Path
pathExternalConfEnabled bool
pathExternalConfURL string
authMethod conf.AuthMethod
authManager pathManagerAuthManager
externalCmdPool *externalcmd.Pool
metrics *metrics.Metrics
parent pathManagerParent
Expand All @@ -103,6 +113,99 @@ type pathManager struct {
chAPIPathsGet chan pathAPIPathsGetReq
}

const (
externalPathConfTimeout = 10 // seconds
externalPathConfMaxBodySize = 1 << 20 // 1 MB
)

var externalPathConfClient = &http.Client{
Timeout: externalPathConfTimeout * 1e9,
}

func (pm *pathManager) fetchExternalPathConf(name, query string, creds *auth.Credentials) (*conf.Path, error) {
params := url.Values{"path": {name}}
if query != "" {
if parsed, err := url.ParseQuery(query); err == nil {
for k, v := range parsed {
if k != "path" {
params[k] = v
}
}
}
}
rawURL := strings.TrimRight(pm.pathExternalConfURL, "/") + "?" + params.Encode()

req, err := http.NewRequestWithContext(pm.ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, fmt.Errorf("external path conf: %w", err)
}

if creds != nil {
switch pm.authMethod {
case conf.AuthMethodInternal, conf.AuthMethodHTTP:
if creds.User != "" || creds.Pass != "" {
req.SetBasicAuth(creds.User, creds.Pass)
}
case conf.AuthMethodJWT:
if creds.Token != "" {
req.Header.Set("Authorization", "Bearer "+creds.Token)
}
}
}

resp, err := externalPathConfClient.Do(req)
if err != nil {
return nil, fmt.Errorf("external path conf: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound {
return nil, errExternalPathNotFound
}

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("external path conf: status %d", resp.StatusCode)
}

body, err := io.ReadAll(io.LimitReader(resp.Body, externalPathConfMaxBodySize))
if err != nil {
return nil, fmt.Errorf("external path conf: %w", err)
}

var op conf.OptionalPath
if err := json.Unmarshal(body, &op); err != nil {
return nil, fmt.Errorf("external path conf: decode: %w", err)
}

pathConf := conf.NewPath(&pm.pathDefaults, &op)
pathConf.Name = name
return pathConf, nil
}

var errExternalPathNotFound = errors.New("external path not found")

// findPathConf resolves a path config. If FindPathConf returns a catch-all
// (all/all_others) and external conf is enabled, the external source is tried
// first; on any failure the catch-all is returned unchanged.
func (pm *pathManager) findPathConf(name, query string, creds *auth.Credentials) (*conf.Path, []string, error) {
pathConf, matches, err := conf.FindPathConf(pm.pathConfs, name)
if err != nil {
return nil, nil, err
}

if pm.pathExternalConfEnabled && (pathConf.Name == "all" || pathConf.Name == "all_others") {
external, fetchErr := pm.fetchExternalPathConf(name, query, creds)
if fetchErr == nil {
return external, nil, nil
}
if !errors.Is(fetchErr, errExternalPathNotFound) {
pm.Log(logger.Warn, "external path conf fetch failed: %v", fetchErr)
}
}

return pathConf, matches, nil
}

func (pm *pathManager) initialize() {
ctx, ctxCancel := context.WithCancel(context.Background())

Expand Down Expand Up @@ -318,7 +421,7 @@ func (pm *pathManager) doSetPathNotReady(pa *path) {
}

func (pm *pathManager) doFindPathConf(req defs.PathFindPathConfReq) {
pathConf, _, err := conf.FindPathConf(pm.pathConfs, req.AccessRequest.Name)
pathConf, _, err := pm.findPathConf(req.AccessRequest.Name, req.AccessRequest.Query, req.AccessRequest.Credentials)
if err != nil {
req.Res <- defs.PathFindPathConfRes{Err: err}
return
Expand All @@ -337,7 +440,7 @@ func (pm *pathManager) doFindPathConf(req defs.PathFindPathConfReq) {
}

func (pm *pathManager) doDescribe(req defs.PathDescribeReq) {
pathConf, pathMatches, err := conf.FindPathConf(pm.pathConfs, req.AccessRequest.Name)
pathConf, pathMatches, err := pm.findPathConf(req.AccessRequest.Name, req.AccessRequest.Query, req.AccessRequest.Credentials)
if err != nil {
req.Res <- defs.PathDescribeRes{Err: err}
return
Expand All @@ -362,7 +465,7 @@ func (pm *pathManager) doDescribe(req defs.PathDescribeReq) {
}

func (pm *pathManager) doAddReader(req defs.PathAddReaderReq) {
pathConf, pathMatches, err := conf.FindPathConf(pm.pathConfs, req.AccessRequest.Name)
pathConf, pathMatches, err := pm.findPathConf(req.AccessRequest.Name, req.AccessRequest.Query, req.AccessRequest.Credentials)
if err != nil {
req.Res <- defs.PathAddReaderRes{Err: err}
return
Expand Down Expand Up @@ -395,7 +498,7 @@ func (pm *pathManager) doAddReader(req defs.PathAddReaderReq) {
}

func (pm *pathManager) doAddPublisher(req defs.PathAddPublisherReq) {
pathConf, pathMatches, err := conf.FindPathConf(pm.pathConfs, req.AccessRequest.Name)
pathConf, pathMatches, err := pm.findPathConf(req.AccessRequest.Name, req.AccessRequest.Query, req.AccessRequest.Credentials)
if err != nil {
req.Res <- defs.PathAddPublisherRes{Err: err}
return
Expand Down
Loading