New authentication system (#1341) (#1992) (#2205) (#3081)

This is a new authentication system that covers all the features exposed by the server, including playback, API, metrics and PPROF, improves internal authentication by adding permissions, improves HTTP-based authentication by adding the ability to exclude certain actions from being authenticated, adds an additional method (JWT-based authentication).
This commit is contained in:
Alessandro Ros 2024-03-04 14:20:34 +01:00 committed by GitHub
parent 765d29fc8e
commit 9c6ba7e2c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 1974 additions and 945 deletions

View File

@ -15,11 +15,11 @@ jobs:
- uses: actions/setup-go@v3
with:
go-version: "1.19"
go-version: "1.21"
- run: touch internal/servers/hls/hls.min.js
- uses: golangci/golangci-lint-action@v3
- uses: golangci/golangci-lint-action@v4
with:
version: v1.55.0

181
README.md
View File

@ -57,7 +57,7 @@ And can be recorded and played back with:
* Serve multiple streams at once in separate paths
* Record streams to disk
* Playback recorded streams
* Authenticate users; use internal or external authentication
* Authenticate users
* Redirect readers to other RTSP servers (load balancing)
* Control the server through the Control API
* Reload the configuration without disconnecting existing clients (hot reloading)
@ -113,6 +113,9 @@ _rtsp-simple-server_ has been rebranded as _MediaMTX_. The reason is pretty obvi
* [Other features](#other-features)
* [Configuration](#configuration)
* [Authentication](#authentication)
* [Internal](#internal)
* [HTTP-based](#http-based)
* [JWT-based](#jwt-based)
* [Encrypt the configuration](#encrypt-the-configuration)
* [Remuxing, re-encoding, compression](#remuxing-re-encoding-compression)
* [Record streams to disk](#record-streams-to-disk)
@ -1028,31 +1031,44 @@ There are 3 ways to change the configuration:
### Authentication
Edit `mediamtx.yml` and set `publishUser` and `publishPass`:
#### Internal
The server provides three way to authenticate users:
* Internal: users are stored in the configuration file
* HTTP-based: an external HTTP URL is contacted to perform authentication
* JWT: an external identity server provides authentication through JWTs
The internal authentication method is the default one. Users are stored inside the configuration file, in this format:
```yml
pathDefaults:
publishUser: myuser
publishPass: mypass
authInternalUsers:
# Username. 'any' means any user, including anonymous ones.
- user: any
# Password. Not used in case of 'any' user.
pass:
# IPs or networks allowed to use this user. An empty list means any IP.
ips: []
# List of permissions.
permissions:
# Available actions are: publish, read, playback, api, metrics, pprof.
- action: publish
# Paths can be set to further restrict access to a specific path.
# An empty path means any path.
# Regular expressions can be used by using a tilde as prefix.
path:
- action: read
path:
- action: playback
path:
```
Only publishers that provide both username and password will be able to proceed:
Only clients that provide username and passwords will be able to perform a given action:
```
ffmpeg -re -stream_loop -1 -i file.ts -c copy -f rtsp rtsp://myuser:mypass@localhost:8554/mystream
```
It's possible to setup authentication for readers too:
```yml
pathDefaults:
readUser: myuser
readPass: mypass
```
If storing plain credentials in the configuration file is a security problem, username and passwords can be stored as hashed strings. The Argon2 and SHA256 hashing algorithms are supported.
To use Argon2, the string must be hashed using Argon2id (recommended) or Argon2i:
If storing plain credentials in the configuration file is a security problem, username and passwords can be stored as hashed strings. The Argon2 and SHA256 hashing algorithms are supported. To use Argon2, the string must be hashed using Argon2id (recommended) or Argon2i:
```
echo -n "mypass" | argon2 saltItWithSalt -id -l 32 -e
@ -1061,9 +1077,11 @@ echo -n "mypass" | argon2 saltItWithSalt -id -l 32 -e
Then stored with the `argon2:` prefix:
```yml
pathDefaults:
readUser: argon2:$argon2id$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$OGGO0eCMN0ievb4YGSzvS/H+Vajx1pcbUmtLp2tRqRU
readPass: argon2:$argon2i$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$oct3kOiFywTdDdt19kT07hdvmsPTvt9zxAUho2DLqZw
authInternalUsers:
- user: argon2:$argon2id$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$OGGO0eCMN0ievb4YGSzvS/H+Vajx1pcbUmtLp2tRqRU
pass: argon2:$argon2i$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$oct3kOiFywTdDdt19kT07hdvmsPTvt9zxAUho2DLqZw
permissions:
- action: publish
```
To use SHA256, the string must be hashed with SHA256 and encoded with base64:
@ -1075,16 +1093,21 @@ echo -n "mypass" | openssl dgst -binary -sha256 | openssl base64
Then stored with the `sha256:` prefix:
```yml
pathDefaults:
readUser: sha256:j1tsRqDEw9xvq/D7/9tMx6Jh/jMhk3UfjwIB2f1zgMo=
readPass: sha256:BdSWkrdV+ZxFBLUQQY7+7uv9RmiSVA8nrPmjGjJtZQQ=
authInternalUsers:
- user: sha256:j1tsRqDEw9xvq/D7/9tMx6Jh/jMhk3UfjwIB2f1zgMo=
pass: sha256:BdSWkrdV+ZxFBLUQQY7+7uv9RmiSVA8nrPmjGjJtZQQ=
permissions:
- action: publish
```
**WARNING**: enable encryption or use a VPN to ensure that no one is intercepting the credentials in transit.
#### HTTP-based
Authentication can be delegated to an external HTTP server:
```yml
authMethod: http
externalAuthenticationURL: http://myauthserver/auth
```
@ -1092,20 +1115,18 @@ Each time a user needs to be authenticated, the specified URL will be requested
```json
{
"ip": "ip",
"user": "user",
"password": "password",
"ip": "ip",
"action": "publish|read|playback|api|metrics|pprof",
"path": "path",
"protocol": "rtsp|rtmp|hls|webrtc",
"protocol": "rtsp|rtmp|hls|webrtc|srt",
"id": "id",
"action": "read|publish",
"query": "query"
}
```
If the URL returns a status code that begins with `20` (i.e. `200`), authentication is successful, otherwise it fails.
Please be aware that it's perfectly normal for the authentication server to receive requests with empty users and passwords, i.e.:
If the URL returns a status code that begins with `20` (i.e. `200`), authentication is successful, otherwise it fails. Be aware that it's perfectly normal for the authentication server to receive requests with empty users and passwords, i.e.:
```json
{
@ -1114,7 +1135,107 @@ Please be aware that it's perfectly normal for the authentication server to rece
}
```
This happens because a RTSP client doesn't provide credentials until it is asked to. In order to receive the credentials, the authentication server must reply with status code `401`, then the client will send credentials.
This happens because RTSP clients don't provide credentials until they are asked to. In order to receive the credentials, the authentication server must reply with status code `401`, then the client will send credentials.
Some actions can be excluded from the process:
```yml
# Actions to exclude from HTTP-based authentication.
# Format is the same as the one of user permissions.
authHTTPExclude:
- action: api
- action: metrics
- action: pprof
```
#### JWT-based
Authentication can be delegated to an external identity server, that is capable of generating JWTs and provides a JWKS endpoint. With respect to the HTTP-based method, this has the advantage that the external server is contacted just once, and not for every request, greatly improving performance. In order to use the JWT-based authentication method, set `authMethod` and `authJWTJWKS`:
```yml
authMethod: jwt
authJWTJWKS: http://my_identity_server/jwks_endpoint
```
The JWT is expected to contain the `mediamtx_permissions` scope, with a list of permissions in the same format as the one of user permissions:
```json
{
"mediamtx_permissions": [
{
"action": "publish",
"path": ""
}
]
}
```
Clients are expected to pass the JWT in query parameters, for instance:
```
ffmpeg -re -stream_loop -1 -i file.ts -c copy -f rtsp rtsp://localhost:8554/mystream?jwt=MY_JWT
```
Here's a tutorial on how to setup the [Keycloak identity server](https://www.keycloak.org/) in order to provide such JWTs:
1. Start Keycloak:
```
docker run --name=keycloak -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:23.0.7 start-dev
```
2. Open the Keycloak administration console on http://localhost:8080, click on _master_ in the top left corner, _create realm_, set realm name to `mediamtx`, Save
3. Open page _Client scopes_, _create client scope_, set name to `mediamtx`, Save
4. Open tab _Mappers_, _Configure a new Mapper_, _User Attribute_
* Name: `mediamtx_permissions`
* User Attribute: `mediamtx_permissions`
* Token Claim Name: `mediamtx_permissions`
* Claim JSON Type: `JSON`
* Multivalued: `On`
Save
5. Open page _Clients_, _Create client_, set Client ID to `mediamtx`, Next, Client authentication `On`, Next, Save
6. Open tab _Credentials_, copy client secret somewhere
7. Open tab _Client scopes_, _Add client scope_, Select `mediamtx`, Add, Default
8. Open page _Users_, _Create user_, Username `testuser`, Tab credentials, _Set password_, pick a password, Save
9. Open tab _Attributes_, _Add an attribute_
* Key: `mediamtx_permissions`
* Value: `{"action":"publish", "paths": "all"}`
You can add as many attributes with key `mediamtx_permissions` as you want, each with a single permission in it
10. In MediaMTX, use the following URL:
```yml
authJWTJWKS: http://localhost:8080/realms/mediamtx/protocol/openid-connect/certs
```
11. Perform authentication on Keycloak:
```
curl \
-d "client_id=mediamtx" \
-d "client_secret=$CLIENT_SECRET" \
-d "username=$USER" \
-d "password=$PASS" \
-d "grant_type=password" \
http://localhost:8080/realms/mediamtx/protocol/openid-connect/token
```
The JWT is inside the `access_token` key of the response:
```json
{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIyNzVjX3ptOVlOdHQ0TkhwWVk4Und6ZndUclVGSzRBRmQwY3lsM2wtY3pzIn0.eyJleHAiOjE3MDk1NTUwOTIsImlhdCI6MTcwOTU1NDc5MiwianRpIjoiMzE3ZTQ1NGUtNzczMi00OTM1LWExNzAtOTNhYzQ2ODhhYWIxIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tZWRpYW10eCIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiI2NTBhZDA5Zi03MDgxLTQyNGItODI4Ni0xM2I3YTA3ZDI0MWEiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJtZWRpYW10eCIsInNlc3Npb25fc3RhdGUiOiJjYzJkNDhjYy1kMmU5LTQ0YjAtODkzZS0wYTdhNjJiZDI1YmQiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbIi8qIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xlcy1tZWRpYW10eCJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoibWVkaWFtdHggcHJvZmlsZSBlbWFpbCIsInNpZCI6ImNjMmQ0OGNjLWQyZTktNDRiMC04OTNlLTBhN2E2MmJkMjViZCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibWVkaWFtdHhfcGVybWlzc2lvbnMiOlt7ImFjdGlvbiI6InB1Ymxpc2giLCJwYXRocyI6ImFsbCJ9XSwicHJlZmVycmVkX3VzZXJuYW1lIjoidGVzdHVzZXIifQ.Gevz7rf1qHqFg7cqtSfSP31v_NS0VH7MYfwAdra1t6Yt5rTr9vJzqUeGfjYLQWR3fr4XC58DrPOhNnILCpo7jWRdimCnbPmuuCJ0AYM-Aoi3PAsWZNxgmtopq24_JokbFArY9Y1wSGFvF8puU64lt1jyOOyxf2M4cBHCs_EarCKOwuQmEZxSf8Z-QV9nlfkoTUszDCQTiKyeIkLRHL2Iy7Fw7_T3UI7sxJjVIt0c6HCNJhBBazGsYzmcSQ_GrmhbUteMTg00o6FicqkMBe99uZFnx9wIBm_QbO9hbAkkzF923I-DTAQrFLxT08ESMepDwmzFrmnwWYBLE3u8zuUlCA","expires_in":300,"refresh_expires_in":1800,"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI3OTI3Zjg4Zi05YWM4LTRlNmEtYWE1OC1kZmY0MDQzZDRhNGUifQ.eyJleHAiOjE3MDk1NTY1OTIsImlhdCI6MTcwOTU1NDc5MiwianRpIjoiMGVhZWFhMWItYzNhMC00M2YxLWJkZjAtZjI2NTRiODlkOTE3IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tZWRpYW10eCIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9yZWFsbXMvbWVkaWFtdHgiLCJzdWIiOiI2NTBhZDA5Zi03MDgxLTQyNGItODI4Ni0xM2I3YTA3ZDI0MWEiLCJ0eXAiOiJSZWZyZXNoIiwiYXpwIjoibWVkaWFtdHgiLCJzZXNzaW9uX3N0YXRlIjoiY2MyZDQ4Y2MtZDJlOS00NGIwLTg5M2UtMGE3YTYyYmQyNWJkIiwic2NvcGUiOiJtZWRpYW10eCBwcm9maWxlIGVtYWlsIiwic2lkIjoiY2MyZDQ4Y2MtZDJlOS00NGIwLTg5M2UtMGE3YTYyYmQyNWJkIn0.yuXV8_JU0TQLuosNdp5xlYMjn7eO5Xq-PusdHzE7bsQ","token_type":"Bearer","not-before-policy":0,"session_state":"cc2d48cc-d2e9-44b0-893e-0a7a62bd25bd","scope":"mediamtx profile email"}
```
### Encrypt the configuration

View File

@ -97,7 +97,7 @@ components:
type: string
serverCert:
type: string
authMethods:
rtspAuthMethods:
type: array
items:
type: string

4
go.mod
View File

@ -4,6 +4,7 @@ go 1.21
require (
code.cloudfoundry.org/bytefmt v0.0.0
github.com/MicahParks/keyfunc/v3 v3.2.5
github.com/abema/go-mp4 v1.2.0
github.com/alecthomas/kong v0.8.1
github.com/bluenviron/gohlslib v1.2.2
@ -12,6 +13,7 @@ require (
github.com/datarhei/gosrt v0.5.7
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/gookit/color v1.5.4
github.com/gorilla/websocket v1.5.1
@ -32,6 +34,7 @@ require (
)
require (
github.com/MicahParks/jwkset v0.5.12 // indirect
github.com/asticode/go-astikit v0.30.0 // indirect
github.com/asticode/go-astits v1.13.0 // indirect
github.com/benburkert/openpgp v0.0.0-20160410205803-c2471f86866c // indirect
@ -67,6 +70,7 @@ require (
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

8
go.sum
View File

@ -1,3 +1,7 @@
github.com/MicahParks/jwkset v0.5.12 h1:wEwKZXB77yHFIHBtYoawNKIUwqC1X24S8tIhWutJHMA=
github.com/MicahParks/jwkset v0.5.12/go.mod h1:q8ptTGn/Z9c4MwbcfeCDssADeVQb3Pk7PnVxrvi+2QY=
github.com/MicahParks/keyfunc/v3 v3.2.5 h1:eg4s2zd2nfadnAzAsv9xvJCdCfLNy4s/aSiAxRn+aAk=
github.com/MicahParks/keyfunc/v3 v3.2.5/go.mod h1:8hmM7h/hNerfF8uC8cFVnT+afxBgh6nKRTR/0vAm5So=
github.com/abema/go-mp4 v1.2.0 h1:gi4X8xg/m179N/J15Fn5ugywN9vtI6PLk6iLldHGLAk=
github.com/abema/go-mp4 v1.2.0/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0=
@ -57,6 +61,8 @@ github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@ -285,6 +291,8 @@ golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"reflect"
@ -17,6 +18,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/logger"
@ -159,6 +161,7 @@ type API struct {
Address string
ReadTimeout conf.StringDuration
Conf *conf.Conf
AuthManager *auth.Manager
PathManager PathManager
RTSPServer RTSPServer
RTSPSServer RTSPServer
@ -178,7 +181,7 @@ func (a *API) Initialize() error {
router := gin.New()
router.SetTrustedProxies(nil) //nolint:errcheck
group := router.Group("/")
group := router.Group("/", a.mwAuth)
group.GET("/v3/config/global/get", a.onConfigGlobalGet)
group.PATCH("/v3/config/global/patch", a.onConfigGlobalPatch)
@ -287,6 +290,30 @@ func (a *API) writeError(ctx *gin.Context, status int, err error) {
})
}
func (a *API) mwAuth(ctx *gin.Context) {
user, pass, hasCredentials := ctx.Request.BasicAuth()
err := a.AuthManager.Authenticate(&auth.Request{
User: user,
Pass: pass,
IP: net.ParseIP(ctx.ClientIP()),
Action: conf.AuthActionAPI,
})
if err != nil {
if !hasCredentials {
ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`)
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
// wait some seconds to mitigate brute force attacks
<-time.After(auth.PauseAfterError)
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
}
func (a *API) onConfigGlobalGet(ctx *gin.Context) {
a.mutex.RLock()
c := a.Conf

View File

@ -11,6 +11,7 @@ import (
"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"
@ -105,6 +106,22 @@ func TestPaginate(t *testing.T) {
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")
@ -112,6 +129,7 @@ func TestConfigGlobalGet(t *testing.T) {
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
@ -121,7 +139,7 @@ func TestConfigGlobalGet(t *testing.T) {
hc := &http.Client{Transport: &http.Transport{}}
var out map[string]interface{}
httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/config/global/get", nil, &out)
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/global/get", nil, &out)
require.Equal(t, true, out["api"])
}
@ -132,6 +150,7 @@ func TestConfigGlobalPatch(t *testing.T) {
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
@ -140,17 +159,18 @@ func TestConfigGlobalPatch(t *testing.T) {
hc := &http.Client{Transport: &http.Transport{}}
httpRequest(t, hc, http.MethodPatch, "http://localhost:9997/v3/config/global/patch", map[string]interface{}{
"rtmp": false,
"readTimeout": "7s",
"protocols": []string{"tcp"},
"readBufferCount": 4096, // test setting a deprecated parameter
}, nil)
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://localhost:9997/v3/config/global/get", nil, &out)
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"])
@ -164,6 +184,7 @@ func TestAPIConfigGlobalPatchUnknownField(t *testing.T) { //nolint:dupl
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
@ -179,7 +200,8 @@ func TestAPIConfigGlobalPatchUnknownField(t *testing.T) { //nolint:dupl
hc := &http.Client{Transport: &http.Transport{}}
req, err := http.NewRequest(http.MethodPatch, "http://localhost:9997/v3/config/global/patch", bytes.NewReader(byts))
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)
@ -197,6 +219,7 @@ func TestAPIConfigPathDefaultsGet(t *testing.T) {
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
@ -206,7 +229,7 @@ func TestAPIConfigPathDefaultsGet(t *testing.T) {
hc := &http.Client{Transport: &http.Transport{}}
var out map[string]interface{}
httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/config/pathdefaults/get", nil, &out)
httpRequest(t, hc, http.MethodGet, "http://myuser:mypass@localhost:9997/v3/config/pathdefaults/get", nil, &out)
require.Equal(t, "publisher", out["source"])
}
@ -217,6 +240,7 @@ func TestAPIConfigPathDefaultsPatch(t *testing.T) {
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
@ -225,15 +249,16 @@ func TestAPIConfigPathDefaultsPatch(t *testing.T) {
hc := &http.Client{Transport: &http.Transport{}}
httpRequest(t, hc, http.MethodPatch, "http://localhost:9997/v3/config/pathdefaults/patch", map[string]interface{}{
"readUser": "myuser",
"readPass": "mypass",
}, nil)
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://localhost:9997/v3/config/pathdefaults/get", nil, &out)
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"])
}
@ -252,6 +277,7 @@ func TestAPIConfigPathsList(t *testing.T) {
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
@ -269,7 +295,7 @@ func TestAPIConfigPathsList(t *testing.T) {
hc := &http.Client{Transport: &http.Transport{}}
var out listRes
httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/config/paths/list", nil, &out)
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"])
@ -291,6 +317,7 @@ func TestAPIConfigPathsGet(t *testing.T) {
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
@ -300,7 +327,7 @@ func TestAPIConfigPathsGet(t *testing.T) {
hc := &http.Client{Transport: &http.Transport{}}
var out map[string]interface{}
httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/config/paths/get/my/path", nil, &out)
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"])
}
@ -312,6 +339,7 @@ func TestAPIConfigPathsAdd(t *testing.T) {
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
@ -320,15 +348,16 @@ func TestAPIConfigPathsAdd(t *testing.T) {
hc := &http.Client{Transport: &http.Transport{}}
httpRequest(t, hc, http.MethodPost, "http://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/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://localhost:9997/v3/config/paths/get/my/path", nil, &out)
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"])
@ -342,6 +371,7 @@ func TestAPIConfigPathsAddUnknownField(t *testing.T) { //nolint:dupl
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
@ -358,7 +388,7 @@ func TestAPIConfigPathsAddUnknownField(t *testing.T) { //nolint:dupl
hc := &http.Client{Transport: &http.Transport{}}
req, err := http.NewRequest(http.MethodPost,
"http://localhost:9997/v3/config/paths/add/my/path", bytes.NewReader(byts))
"http://myuser:mypass@localhost:9997/v3/config/paths/add/my/path", bytes.NewReader(byts))
require.NoError(t, err)
res, err := hc.Do(req)
@ -376,6 +406,7 @@ func TestAPIConfigPathsPatch(t *testing.T) { //nolint:dupl
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
@ -384,20 +415,22 @@ func TestAPIConfigPathsPatch(t *testing.T) { //nolint:dupl
hc := &http.Client{Transport: &http.Transport{}}
httpRequest(t, hc, http.MethodPost, "http://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/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://localhost:9997/v3/config/paths/patch/my/path", map[string]interface{}{
"source": "rtsp://127.0.0.1:9998/mypath",
"sourceOnDemand": 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://localhost:9997/v3/config/paths/get/my/path", nil, &out)
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"])
@ -411,6 +444,7 @@ func TestAPIConfigPathsReplace(t *testing.T) { //nolint:dupl
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
@ -419,20 +453,22 @@ func TestAPIConfigPathsReplace(t *testing.T) { //nolint:dupl
hc := &http.Client{Transport: &http.Transport{}}
httpRequest(t, hc, http.MethodPost, "http://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/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://localhost:9997/v3/config/paths/replace/my/path", map[string]interface{}{
"source": "rtsp://127.0.0.1:9998/mypath",
"sourceOnDemand": 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://localhost:9997/v3/config/paths/get/my/path", nil, &out)
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"])
@ -446,6 +482,7 @@ func TestAPIConfigPathsDelete(t *testing.T) {
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err := api.Initialize()
@ -454,14 +491,15 @@ func TestAPIConfigPathsDelete(t *testing.T) {
hc := &http.Client{Transport: &http.Transport{}}
httpRequest(t, hc, http.MethodPost, "http://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.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://localhost:9997/v3/config/paths/delete/my/path", nil, 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://localhost:9997/v3/config/paths/get/my/path", 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)
@ -486,6 +524,7 @@ func TestRecordingsList(t *testing.T) {
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err = api.Initialize()
@ -510,7 +549,7 @@ func TestRecordingsList(t *testing.T) {
hc := &http.Client{Transport: &http.Transport{}}
var out interface{}
httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/recordings/list", nil, &out)
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),
@ -552,6 +591,7 @@ func TestRecordingsGet(t *testing.T) {
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err = api.Initialize()
@ -570,7 +610,7 @@ func TestRecordingsGet(t *testing.T) {
hc := &http.Client{Transport: &http.Transport{}}
var out interface{}
httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/recordings/get/mypath1", nil, &out)
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{}{
@ -598,6 +638,7 @@ func TestRecordingsDeleteSegment(t *testing.T) {
Address: "localhost:9997",
ReadTimeout: conf.StringDuration(10 * time.Second),
Conf: cnf,
AuthManager: authManager,
Parent: &testParent{},
}
err = api.Initialize()
@ -612,16 +653,13 @@ func TestRecordingsDeleteSegment(t *testing.T) {
hc := &http.Client{Transport: &http.Transport{}}
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 := &url.URL{
Scheme: "http",
Host: "localhost:9997",
Path: "/v3/recordings/deletesegment",
RawQuery: v.Encode(),
}
u.RawQuery = v.Encode()
req, err := http.NewRequest(http.MethodDelete, u.String(), nil)
require.NoError(t, err)

327
internal/auth/manager.go Normal file
View File

@ -0,0 +1,327 @@
// 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
}

View File

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

View File

@ -0,0 +1,52 @@
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 + `"`))
}

View File

@ -0,0 +1,15 @@
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"`
}

View File

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

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"os"
"reflect"
"sort"
@ -82,25 +83,58 @@ func copyStructFields(dest interface{}, source interface{}) {
}
}
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
}
func anyPathHasDeprecatedCredentials(paths map[string]*OptionalPath) bool {
for _, pa := range paths {
if pa != nil {
rva := reflect.ValueOf(pa.Values).Elem()
if !rva.FieldByName("PublishUser").IsNil() || !rva.FieldByName("PublishPass").IsNil() ||
!rva.FieldByName("PublishIPs").IsNil() ||
!rva.FieldByName("ReadUser").IsNil() || !rva.FieldByName("ReadPass").IsNil() ||
!rva.FieldByName("ReadIPs").IsNil() {
return true
}
}
}
return false
}
// Conf is a configuration.
type Conf struct {
// General
LogLevel LogLevel `json:"logLevel"`
LogDestinations LogDestinations `json:"logDestinations"`
LogFile string `json:"logFile"`
ReadTimeout StringDuration `json:"readTimeout"`
WriteTimeout StringDuration `json:"writeTimeout"`
ReadBufferCount *int `json:"readBufferCount,omitempty"` // deprecated
WriteQueueSize int `json:"writeQueueSize"`
UDPMaxPayloadSize int `json:"udpMaxPayloadSize"`
ExternalAuthenticationURL string `json:"externalAuthenticationURL"`
Metrics bool `json:"metrics"`
MetricsAddress string `json:"metricsAddress"`
PPROF bool `json:"pprof"`
PPROFAddress string `json:"pprofAddress"`
RunOnConnect string `json:"runOnConnect"`
RunOnConnectRestart bool `json:"runOnConnectRestart"`
RunOnDisconnect string `json:"runOnDisconnect"`
LogLevel LogLevel `json:"logLevel"`
LogDestinations LogDestinations `json:"logDestinations"`
LogFile string `json:"logFile"`
ReadTimeout StringDuration `json:"readTimeout"`
WriteTimeout StringDuration `json:"writeTimeout"`
ReadBufferCount *int `json:"readBufferCount,omitempty"` // deprecated
WriteQueueSize int `json:"writeQueueSize"`
UDPMaxPayloadSize int `json:"udpMaxPayloadSize"`
Metrics bool `json:"metrics"`
MetricsAddress string `json:"metricsAddress"`
PPROF bool `json:"pprof"`
PPROFAddress string `json:"pprofAddress"`
RunOnConnect string `json:"runOnConnect"`
RunOnConnectRestart bool `json:"runOnConnectRestart"`
RunOnDisconnect string `json:"runOnDisconnect"`
// Authentication
AuthMethod AuthMethod `json:"authMethod"`
AuthInternalUsers []AuthInternalUser `json:"authInternalUsers"`
AuthHTTPAddress string `json:"authHTTPAddress"`
ExternalAuthenticationURL *string `json:"externalAuthenticationURL,omitempty"` // deprecated
AuthHTTPExclude []AuthInternalUserPermission `json:"authHTTPExclude"`
AuthJWTJWKS string `json:"authJWTJWKS"`
// API
API bool `json:"api"`
@ -111,20 +145,21 @@ type Conf struct {
PlaybackAddress string `json:"playbackAddress"`
// RTSP server
RTSP bool `json:"rtsp"`
RTSPDisable *bool `json:"rtspDisable,omitempty"` // deprecated
Protocols Protocols `json:"protocols"`
Encryption Encryption `json:"encryption"`
RTSPAddress string `json:"rtspAddress"`
RTSPSAddress string `json:"rtspsAddress"`
RTPAddress string `json:"rtpAddress"`
RTCPAddress string `json:"rtcpAddress"`
MulticastIPRange string `json:"multicastIPRange"`
MulticastRTPPort int `json:"multicastRTPPort"`
MulticastRTCPPort int `json:"multicastRTCPPort"`
ServerKey string `json:"serverKey"`
ServerCert string `json:"serverCert"`
AuthMethods AuthMethods `json:"authMethods"`
RTSP bool `json:"rtsp"`
RTSPDisable *bool `json:"rtspDisable,omitempty"` // deprecated
Protocols Protocols `json:"protocols"`
Encryption Encryption `json:"encryption"`
RTSPAddress string `json:"rtspAddress"`
RTSPSAddress string `json:"rtspsAddress"`
RTPAddress string `json:"rtpAddress"`
RTCPAddress string `json:"rtcpAddress"`
MulticastIPRange string `json:"multicastIPRange"`
MulticastRTPPort int `json:"multicastRTPPort"`
MulticastRTCPPort int `json:"multicastRTCPPort"`
ServerKey string `json:"serverKey"`
ServerCert string `json:"serverCert"`
AuthMethods *RTSPAuthMethods `json:"authMethods,omitempty"` // deprecated
RTSPAuthMethods RTSPAuthMethods `json:"rtspAuthMethods"`
// RTMP server
RTMP bool `json:"rtmp"`
@ -149,7 +184,7 @@ type Conf struct {
HLSPartDuration StringDuration `json:"hlsPartDuration"`
HLSSegmentMaxSize StringSize `json:"hlsSegmentMaxSize"`
HLSAllowOrigin string `json:"hlsAllowOrigin"`
HLSTrustedProxies IPsOrCIDRs `json:"hlsTrustedProxies"`
HLSTrustedProxies IPNetworks `json:"hlsTrustedProxies"`
HLSDirectory string `json:"hlsDirectory"`
// WebRTC server
@ -160,7 +195,7 @@ type Conf struct {
WebRTCServerKey string `json:"webrtcServerKey"`
WebRTCServerCert string `json:"webrtcServerCert"`
WebRTCAllowOrigin string `json:"webrtcAllowOrigin"`
WebRTCTrustedProxies IPsOrCIDRs `json:"webrtcTrustedProxies"`
WebRTCTrustedProxies IPNetworks `json:"webrtcTrustedProxies"`
WebRTCLocalUDPAddress string `json:"webrtcLocalUDPAddress"`
WebRTCLocalTCPAddress string `json:"webrtcLocalTCPAddress"`
WebRTCIPsFromInterfaces bool `json:"webrtcIPsFromInterfaces"`
@ -201,11 +236,57 @@ func (conf *Conf) setDefaults() {
conf.WriteTimeout = 10 * StringDuration(time.Second)
conf.WriteQueueSize = 512
conf.UDPMaxPayloadSize = 1472
conf.MetricsAddress = "127.0.0.1:9998"
conf.PPROFAddress = "127.0.0.1:9999"
conf.MetricsAddress = ":9998"
conf.PPROFAddress = ":9999"
// Authentication
conf.AuthInternalUsers = []AuthInternalUser{
{
User: "any",
Pass: "",
Permissions: []AuthInternalUserPermission{
{
Action: AuthActionPublish,
},
{
Action: AuthActionRead,
},
{
Action: AuthActionPlayback,
},
},
},
{
User: "any",
Pass: "",
IPs: IPNetworks{mustParseCIDR("127.0.0.1/32"), mustParseCIDR("::1/128")},
Permissions: []AuthInternalUserPermission{
{
Action: AuthActionAPI,
},
{
Action: AuthActionMetrics,
},
{
Action: AuthActionPprof,
},
},
},
}
conf.AuthHTTPExclude = []AuthInternalUserPermission{
{
Action: AuthActionAPI,
},
{
Action: AuthActionMetrics,
},
{
Action: AuthActionPprof,
},
}
// API
conf.APIAddress = "127.0.0.1:9997"
conf.APIAddress = ":9997"
// Playback server
conf.PlaybackAddress = ":9996"
@ -226,7 +307,7 @@ func (conf *Conf) setDefaults() {
conf.MulticastRTCPPort = 8003
conf.ServerKey = "server.key"
conf.ServerCert = "server.crt"
conf.AuthMethods = AuthMethods{headers.AuthBasic}
conf.RTSPAuthMethods = RTSPAuthMethods{headers.AuthBasic}
// RTMP server
conf.RTMP = true
@ -361,14 +442,67 @@ func (conf *Conf) Validate() error {
if conf.UDPMaxPayloadSize > 1472 {
return fmt.Errorf("'udpMaxPayloadSize' must be less than 1472")
}
if conf.ExternalAuthenticationURL != "" {
if !strings.HasPrefix(conf.ExternalAuthenticationURL, "http://") &&
!strings.HasPrefix(conf.ExternalAuthenticationURL, "https://") {
return fmt.Errorf("'externalAuthenticationURL' must be a HTTP URL")
// Authentication
if conf.ExternalAuthenticationURL != nil {
conf.AuthMethod = AuthMethodHTTP
conf.AuthHTTPAddress = *conf.ExternalAuthenticationURL
}
if conf.AuthHTTPAddress != "" &&
!strings.HasPrefix(conf.AuthHTTPAddress, "http://") &&
!strings.HasPrefix(conf.AuthHTTPAddress, "https://") {
return fmt.Errorf("'externalAuthenticationURL' must be a HTTP URL")
}
if conf.AuthJWTJWKS != "" &&
!strings.HasPrefix(conf.AuthJWTJWKS, "http://") &&
!strings.HasPrefix(conf.AuthJWTJWKS, "https://") {
return fmt.Errorf("'authJWTJWKS' must be a HTTP URL")
}
deprecatedCredentialsMode := false
if conf.PathDefaults.PublishUser != nil || conf.PathDefaults.PublishPass != nil ||
conf.PathDefaults.PublishIPs != nil ||
conf.PathDefaults.ReadUser != nil || conf.PathDefaults.ReadPass != nil ||
conf.PathDefaults.ReadIPs != nil ||
anyPathHasDeprecatedCredentials(conf.OptionalPaths) {
conf.AuthInternalUsers = []AuthInternalUser{
{
User: "any",
Pass: "",
Permissions: []AuthInternalUserPermission{
{
Action: AuthActionPlayback,
},
},
},
{
User: "any",
Pass: "",
IPs: IPNetworks{mustParseCIDR("127.0.0.1/32"), mustParseCIDR("::1/128")},
Permissions: []AuthInternalUserPermission{
{
Action: AuthActionAPI,
},
{
Action: AuthActionMetrics,
},
{
Action: AuthActionPprof,
},
},
},
}
deprecatedCredentialsMode = true
}
switch conf.AuthMethod {
case AuthMethodHTTP:
if conf.AuthHTTPAddress == "" {
return fmt.Errorf("'authHTTPAddress' is empty")
}
if contains(conf.AuthMethods, headers.AuthDigestMD5) {
return fmt.Errorf("'externalAuthenticationURL' can't be used when 'digest' is in authMethods")
case AuthMethodJWT:
if conf.AuthJWTJWKS == "" {
return fmt.Errorf("'authJWTJWKS' is empty")
}
}
@ -385,6 +519,19 @@ func (conf *Conf) Validate() error {
return fmt.Errorf("strict encryption can't be used with the UDP-multicast transport protocol")
}
}
if conf.AuthMethods != nil {
conf.RTSPAuthMethods = *conf.AuthMethods
}
if contains(conf.RTSPAuthMethods, headers.AuthDigestMD5) {
if conf.AuthMethod != AuthMethodInternal {
return fmt.Errorf("when RTSP digest is enabled, the only supported auth method is 'internal'")
}
for _, user := range conf.AuthInternalUsers {
if user.User.IsHashed() || user.Pass.IsHashed() {
return fmt.Errorf("when RTSP digest is enabled, hashed credentials cannot be used")
}
}
}
// RTMP
@ -490,7 +637,7 @@ func (conf *Conf) Validate() error {
pconf := newPath(&conf.PathDefaults, optional)
conf.Paths[name] = pconf
err := pconf.validate(conf, name)
err := pconf.validate(conf, name, deprecatedCredentialsMode)
if err != nil {
return err
}

View File

@ -214,17 +214,6 @@ func TestConfErrors(t *testing.T) {
"udpMaxPayloadSize: 5000\n",
"'udpMaxPayloadSize' must be less than 1472",
},
{
"invalid externalAuthenticationURL 1",
"externalAuthenticationURL: testing\n",
"'externalAuthenticationURL' must be a HTTP URL",
},
{
"invalid externalAuthenticationURL 2",
"externalAuthenticationURL: http://myurl\n" +
"authMethods: [digest]\n",
"'externalAuthenticationURL' can't be used when 'digest' is in authMethods",
},
{
"invalid strict encryption 1",
"encryption: strict\n" +

View File

@ -26,11 +26,8 @@ func (d Encryption) MarshalJSON() ([]byte, error) {
case EncryptionOptional:
out = "optional"
case EncryptionStrict:
out = "strict"
default:
return nil, fmt.Errorf("invalid encryption: %v", d)
out = "strict"
}
return json.Marshal(out)

View File

@ -21,11 +21,8 @@ func (d HLSVariant) MarshalJSON() ([]byte, error) {
case HLSVariant(gohlslib.MuxerVariantFMP4):
out = "fmp4"
case HLSVariant(gohlslib.MuxerVariantLowLatency):
out = "lowLatency"
default:
return nil, fmt.Errorf("invalid HLS variant: %v", d)
out = "lowLatency"
}
return json.Marshal(out)

View File

@ -0,0 +1,84 @@
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
}

View File

@ -1,66 +0,0 @@
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 env.Unmarshaler.
func (d *IPsOrCIDRs) UnmarshalEnv(_ string, v string) error {
byts, _ := json.Marshal(strings.Split(v, ","))
return d.UnmarshalJSON(byts)
}
// ToTrustedProxies converts IPsOrCIDRs into a string slice for SetTrustedProxies.
func (d *IPsOrCIDRs) ToTrustedProxies() []string {
ret := make([]string, len(*d))
for i, entry := range *d {
ret[i] = entry.String()
}
return ret
}

View File

@ -26,11 +26,8 @@ func (d LogDestinations) MarshalJSON() ([]byte, error) {
case logger.DestinationFile:
v = "file"
case logger.DestinationSyslog:
v = "syslog"
default:
return nil, fmt.Errorf("invalid log destination: %v", p)
v = "syslog"
}
out[i] = v

View File

@ -24,11 +24,8 @@ func (d LogLevel) MarshalJSON() ([]byte, error) {
case LogLevel(logger.Info):
out = "info"
case LogLevel(logger.Debug):
out = "debug"
default:
return nil, fmt.Errorf("invalid log level: %v", d)
out = "debug"
}
return json.Marshal(out)

View File

@ -11,7 +11,6 @@ import (
"time"
"github.com/bluenviron/gortsplib/v4/pkg/base"
"github.com/bluenviron/gortsplib/v4/pkg/headers"
)
var rePathName = regexp.MustCompile(`^[0-9a-zA-Z_\-/\.~]+$`)
@ -105,13 +104,13 @@ type Path struct {
RecordSegmentDuration StringDuration `json:"recordSegmentDuration"`
RecordDeleteAfter StringDuration `json:"recordDeleteAfter"`
// Authentication
PublishUser Credential `json:"publishUser"`
PublishPass Credential `json:"publishPass"`
PublishIPs IPsOrCIDRs `json:"publishIPs"`
ReadUser Credential `json:"readUser"`
ReadPass Credential `json:"readPass"`
ReadIPs IPsOrCIDRs `json:"readIPs"`
// Authentication (deprecated)
PublishUser *Credential `json:"publishUser,omitempty"` // deprecated
PublishPass *Credential `json:"publishPass,omitempty"` // deprecated
PublishIPs *IPNetworks `json:"publishIPs,omitempty"` // deprecated
ReadUser *Credential `json:"readUser,omitempty"` // deprecated
ReadPass *Credential `json:"readPass,omitempty"` // deprecated
ReadIPs *IPNetworks `json:"readIPs,omitempty"` // deprecated
// Publisher source
OverridePublisher bool `json:"overridePublisher"`
@ -250,7 +249,11 @@ func (pconf Path) Clone() *Path {
return &dest
}
func (pconf *Path) validate(conf *Conf, name string) error {
func (pconf *Path) validate(
conf *Conf,
name string,
deprecatedCredentialsMode bool,
) error {
pconf.Name = name
switch {
@ -375,39 +378,72 @@ func (pconf *Path) validate(conf *Conf, name string) error {
}
}
// Authentication
// Authentication (deprecated)
if (pconf.PublishUser != "" && pconf.PublishPass == "") ||
(pconf.PublishUser == "" && pconf.PublishPass != "") {
return fmt.Errorf("read username and password must be both filled")
}
if pconf.PublishUser != "" && pconf.Source != "publisher" {
return fmt.Errorf("'publishUser' is useless when source is not 'publisher', since " +
"the stream is not provided by a publisher, but by a fixed source")
}
if len(pconf.PublishIPs) > 0 && pconf.Source != "publisher" {
return fmt.Errorf("'publishIPs' is useless when source is not 'publisher', since " +
"the stream is not provided by a publisher, but by a fixed source")
}
if (pconf.ReadUser != "" && pconf.ReadPass == "") ||
(pconf.ReadUser == "" && pconf.ReadPass != "") {
return fmt.Errorf("read username and password must be both filled")
}
if contains(conf.AuthMethods, headers.AuthDigestMD5) {
if pconf.PublishUser.IsHashed() ||
pconf.PublishPass.IsHashed() ||
pconf.ReadUser.IsHashed() ||
pconf.ReadPass.IsHashed() {
return fmt.Errorf("hashed credentials can't be used when the digest auth method is available")
}
}
if conf.ExternalAuthenticationURL != "" {
if pconf.PublishUser != "" ||
len(pconf.PublishIPs) > 0 ||
pconf.ReadUser != "" ||
len(pconf.ReadIPs) > 0 {
return fmt.Errorf("credentials or IPs can't be used together with 'externalAuthenticationURL'")
}
if deprecatedCredentialsMode {
func() {
var user Credential = "any"
if pconf.PublishUser != nil {
user = *pconf.PublishUser
}
var pass Credential
if pconf.PublishPass != nil {
pass = *pconf.PublishPass
}
ips := IPNetworks{mustParseCIDR("0.0.0.0/0")}
if pconf.PublishIPs != nil {
ips = *pconf.PublishIPs
}
pathName := name
if name == "all_others" || name == "all" {
pathName = "~^.*$"
}
conf.AuthInternalUsers = append(conf.AuthInternalUsers, AuthInternalUser{
User: user,
Pass: pass,
IPs: ips,
Permissions: []AuthInternalUserPermission{{
Action: AuthActionPublish,
Path: pathName,
}},
})
}()
func() {
var user Credential = "any"
if pconf.ReadUser != nil {
user = *pconf.ReadUser
}
var pass Credential
if pconf.ReadPass != nil {
pass = *pconf.ReadPass
}
ips := IPNetworks{mustParseCIDR("0.0.0.0/0")}
if pconf.ReadIPs != nil {
ips = *pconf.ReadIPs
}
pathName := name
if name == "all_others" || name == "all" {
pathName = "~^.*$"
}
conf.AuthInternalUsers = append(conf.AuthInternalUsers, AuthInternalUser{
User: user,
Pass: pass,
IPs: ips,
Permissions: []AuthInternalUserPermission{{
Action: AuthActionRead,
Path: pathName,
}},
})
}()
}
// Publisher source

View File

@ -30,11 +30,8 @@ func (d Protocols) MarshalJSON() ([]byte, error) {
case Protocol(gortsplib.TransportUDPMulticast):
v = "multicast"
case Protocol(gortsplib.TransportTCP):
v = "tcp"
default:
return nil, fmt.Errorf("invalid protocol: %v", p)
v = "tcp"
}
out[i] = v

View File

@ -0,0 +1,63 @@
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)
}

View File

@ -30,11 +30,8 @@ func (d RTSPRangeType) MarshalJSON() ([]byte, error) {
case RTSPRangeTypeSMPTE:
out = "smpte"
case RTSPRangeTypeUndefined:
out = ""
default:
return nil, fmt.Errorf("invalid rtsp range type: %v", d)
out = ""
}
return json.Marshal(out)

View File

@ -26,11 +26,8 @@ func (d RTSPTransport) MarshalJSON() ([]byte, error) {
case gortsplib.TransportUDPMulticast:
out = "multicast"
case gortsplib.TransportTCP:
out = "tcp"
default:
return nil, fmt.Errorf("invalid protocol: %v", d.Transport)
out = "tcp"
}
}

View File

@ -25,8 +25,8 @@ func (s *StringSize) UnmarshalJSON(b []byte) error {
if err != nil {
return err
}
*s = StringSize(v)
return nil
}

View File

@ -1,126 +0,0 @@
package core
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/bluenviron/gortsplib/v4/pkg/auth"
"github.com/bluenviron/gortsplib/v4/pkg/headers"
"github.com/google/uuid"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
)
func doExternalAuthentication(
ur string,
accessRequest defs.PathAccessRequest,
) 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: accessRequest.IP.String(),
User: accessRequest.User,
Password: accessRequest.Pass,
Path: accessRequest.Name,
Protocol: string(accessRequest.Proto),
ID: accessRequest.ID,
Action: func() string {
if accessRequest.Publish {
return "publish"
}
return "read"
}(),
Query: accessRequest.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("server replied with code %d: %s", res.StatusCode, string(resBody))
}
return fmt.Errorf("server replied with code %d", res.StatusCode)
}
return nil
}
func doAuthentication(
externalAuthenticationURL string,
rtspAuthMethods conf.AuthMethods,
pathConf *conf.Path,
accessRequest defs.PathAccessRequest,
) error {
var rtspAuth headers.Authorization
if accessRequest.RTSPRequest != nil {
err := rtspAuth.Unmarshal(accessRequest.RTSPRequest.Header["Authorization"])
if err == nil && rtspAuth.Method == headers.AuthBasic {
accessRequest.User = rtspAuth.BasicUser
accessRequest.Pass = rtspAuth.BasicPass
}
}
if externalAuthenticationURL != "" {
err := doExternalAuthentication(
externalAuthenticationURL,
accessRequest,
)
if err != nil {
return defs.AuthenticationError{Message: fmt.Sprintf("external authentication failed: %s", err)}
}
}
var pathIPs conf.IPsOrCIDRs
var pathUser conf.Credential
var pathPass conf.Credential
if accessRequest.Publish {
pathIPs = pathConf.PublishIPs
pathUser = pathConf.PublishUser
pathPass = pathConf.PublishPass
} else {
pathIPs = pathConf.ReadIPs
pathUser = pathConf.ReadUser
pathPass = pathConf.ReadPass
}
if pathIPs != nil {
if !ipEqualOrInRange(accessRequest.IP, pathIPs) {
return defs.AuthenticationError{Message: fmt.Sprintf("IP %s not allowed", accessRequest.IP)}
}
}
if pathUser != "" {
if accessRequest.RTSPRequest != nil && rtspAuth.Method == headers.AuthDigestMD5 {
err := auth.Validate(
accessRequest.RTSPRequest,
string(pathUser),
string(pathPass),
accessRequest.RTSPBaseURL,
rtspAuthMethods,
"IPCAM",
accessRequest.RTSPNonce)
if err != nil {
return defs.AuthenticationError{Message: err.Error()}
}
} else if !pathUser.Check(accessRequest.User) || !pathPass.Check(accessRequest.Pass) {
return defs.AuthenticationError{Message: "invalid credentials"}
}
}
return nil
}

View File

@ -1,155 +0,0 @@
package core
import (
"context"
"encoding/json"
"net"
"net/http"
"testing"
"github.com/bluenviron/gortsplib/v4/pkg/headers"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/stretchr/testify/require"
)
type testHTTPAuthenticator struct {
*http.Server
}
func (ts *testHTTPAuthenticator) initialize(t *testing.T, protocol string, action string) {
firstReceived := false
ts.Server = &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/auth", r.URL.Path)
var in struct {
IP string `json:"ip"`
User string `json:"user"`
Password string `json:"password"`
Path string `json:"path"`
Protocol string `json:"protocol"`
ID string `json:"id"`
Action string `json:"action"`
Query string `json:"query"`
}
err := json.NewDecoder(r.Body).Decode(&in)
require.NoError(t, err)
var user string
if action == "publish" {
user = "testpublisher"
} else {
user = "testreader"
}
if in.IP != "127.0.0.1" ||
in.User != user ||
in.Password != "testpass" ||
in.Path != "teststream" ||
in.Protocol != protocol ||
(firstReceived && in.ID == "") ||
in.Action != action ||
(in.Query != "user=testreader&pass=testpass&param=value" &&
in.Query != "user=testpublisher&pass=testpass&param=value" &&
in.Query != "param=value") {
w.WriteHeader(http.StatusBadRequest)
return
}
firstReceived = true
}),
}
ln, err := net.Listen("tcp", "127.0.0.1:9120")
require.NoError(t, err)
go ts.Server.Serve(ln)
}
func (ts *testHTTPAuthenticator) close() {
ts.Server.Shutdown(context.Background())
}
func TestAuthSha256(t *testing.T) {
err := doAuthentication(
"",
conf.AuthMethods{headers.AuthBasic},
&conf.Path{
PublishUser: conf.Credential("sha256:rl3rgi4NcZkpAEcacZnQ2VuOfJ0FxAqCRaKB/SwdZoQ="),
PublishPass: conf.Credential("sha256:E9JJ8stBJ7QM+nV4ZoUCeHk/gU3tPFh/5YieiJp6n2w="),
},
defs.PathAccessRequest{
Name: "mypath",
Query: "",
Publish: true,
SkipAuth: false,
IP: net.ParseIP("127.0.0.1"),
User: "testuser",
Pass: "testpass",
Proto: defs.AuthProtocolRTSP,
ID: nil,
RTSPRequest: nil,
RTSPBaseURL: nil,
RTSPNonce: "",
},
)
require.NoError(t, err)
}
func TestAuthArgon2(t *testing.T) {
err := doAuthentication(
"",
conf.AuthMethods{headers.AuthBasic},
&conf.Path{
PublishUser: conf.Credential(
"argon2:$argon2id$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$Ux/LWeTgJQPyfMMJo1myR64+o8rALHoPmlE1i/TR+58"),
PublishPass: conf.Credential(
"argon2:$argon2i$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$/mrZ42TiTv1mcPnpMUera5oi0SFYbbyueAbdx5sUvWo"),
},
defs.PathAccessRequest{
Name: "mypath",
Query: "",
Publish: true,
SkipAuth: false,
IP: net.ParseIP("127.0.0.1"),
User: "testuser",
Pass: "testpass",
Proto: defs.AuthProtocolRTSP,
ID: nil,
RTSPRequest: nil,
RTSPBaseURL: nil,
RTSPNonce: "",
},
)
require.NoError(t, err)
}
func TestAuthExternal(t *testing.T) {
au := &testHTTPAuthenticator{}
au.initialize(t, "rtsp", "publish")
defer au.close()
err := doAuthentication(
"http://127.0.0.1:9120/auth",
conf.AuthMethods{headers.AuthBasic},
&conf.Path{},
defs.PathAccessRequest{
Name: "teststream",
Query: "param=value",
Publish: true,
SkipAuth: false,
IP: net.ParseIP("127.0.0.1"),
User: "testpublisher",
Pass: "testpass",
Proto: defs.AuthProtocolRTSP,
ID: nil,
RTSPRequest: nil,
RTSPBaseURL: nil,
RTSPNonce: "",
},
)
require.NoError(t, err)
}

View File

@ -17,6 +17,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/bluenviron/mediamtx/internal/api"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/confwatcher"
"github.com/bluenviron/mediamtx/internal/externalcmd"
@ -88,6 +89,7 @@ type Core struct {
conf *conf.Conf
logger *logger.Logger
externalCmdPool *externalcmd.Pool
authManager *auth.Manager
metrics *metrics.Metrics
pprof *pprof.PPROF
recordCleaner *record.Cleaner
@ -278,11 +280,24 @@ func (p *Core) createResources(initial bool) error {
p.externalCmdPool = externalcmd.NewPool()
}
if p.authManager == nil {
p.authManager = &auth.Manager{
Method: p.conf.AuthMethod,
InternalUsers: p.conf.AuthInternalUsers,
HTTPAddress: p.conf.AuthHTTPAddress,
HTTPExclude: p.conf.AuthHTTPExclude,
JWTJWKS: p.conf.AuthJWTJWKS,
ReadTimeout: time.Duration(p.conf.ReadTimeout),
RTSPAuthMethods: p.conf.RTSPAuthMethods,
}
}
if p.conf.Metrics &&
p.metrics == nil {
i := &metrics.Metrics{
Address: p.conf.MetricsAddress,
ReadTimeout: p.conf.ReadTimeout,
AuthManager: p.authManager,
Parent: p,
}
err := i.Initialize()
@ -297,6 +312,7 @@ func (p *Core) createResources(initial bool) error {
i := &pprof.PPROF{
Address: p.conf.PPROFAddress,
ReadTimeout: p.conf.ReadTimeout,
AuthManager: p.authManager,
Parent: p,
}
err := i.Initialize()
@ -322,6 +338,7 @@ func (p *Core) createResources(initial bool) error {
Address: p.conf.PlaybackAddress,
ReadTimeout: p.conf.ReadTimeout,
PathConfs: p.conf.Paths,
AuthManager: p.authManager,
Parent: p,
}
err := i.Initialize()
@ -333,17 +350,16 @@ func (p *Core) createResources(initial bool) error {
if p.pathManager == nil {
p.pathManager = &pathManager{
logLevel: p.conf.LogLevel,
externalAuthenticationURL: p.conf.ExternalAuthenticationURL,
rtspAddress: p.conf.RTSPAddress,
authMethods: p.conf.AuthMethods,
readTimeout: p.conf.ReadTimeout,
writeTimeout: p.conf.WriteTimeout,
writeQueueSize: p.conf.WriteQueueSize,
udpMaxPayloadSize: p.conf.UDPMaxPayloadSize,
pathConfs: p.conf.Paths,
externalCmdPool: p.externalCmdPool,
parent: p,
logLevel: p.conf.LogLevel,
authManager: p.authManager,
rtspAddress: p.conf.RTSPAddress,
readTimeout: p.conf.ReadTimeout,
writeTimeout: p.conf.WriteTimeout,
writeQueueSize: p.conf.WriteQueueSize,
udpMaxPayloadSize: p.conf.UDPMaxPayloadSize,
pathConfs: p.conf.Paths,
externalCmdPool: p.externalCmdPool,
parent: p,
}
p.pathManager.initialize()
@ -361,7 +377,7 @@ func (p *Core) createResources(initial bool) error {
i := &rtsp.Server{
Address: p.conf.RTSPAddress,
AuthMethods: p.conf.AuthMethods,
AuthMethods: p.conf.RTSPAuthMethods,
ReadTimeout: p.conf.ReadTimeout,
WriteTimeout: p.conf.WriteTimeout,
WriteQueueSize: p.conf.WriteQueueSize,
@ -401,7 +417,7 @@ func (p *Core) createResources(initial bool) error {
p.rtspsServer == nil {
i := &rtsp.Server{
Address: p.conf.RTSPSAddress,
AuthMethods: p.conf.AuthMethods,
AuthMethods: p.conf.RTSPAuthMethods,
ReadTimeout: p.conf.ReadTimeout,
WriteTimeout: p.conf.WriteTimeout,
WriteQueueSize: p.conf.WriteQueueSize,
@ -500,24 +516,23 @@ func (p *Core) createResources(initial bool) error {
if p.conf.HLS &&
p.hlsServer == nil {
i := &hls.Server{
Address: p.conf.HLSAddress,
Encryption: p.conf.HLSEncryption,
ServerKey: p.conf.HLSServerKey,
ServerCert: p.conf.HLSServerCert,
ExternalAuthenticationURL: p.conf.ExternalAuthenticationURL,
AlwaysRemux: p.conf.HLSAlwaysRemux,
Variant: p.conf.HLSVariant,
SegmentCount: p.conf.HLSSegmentCount,
SegmentDuration: p.conf.HLSSegmentDuration,
PartDuration: p.conf.HLSPartDuration,
SegmentMaxSize: p.conf.HLSSegmentMaxSize,
AllowOrigin: p.conf.HLSAllowOrigin,
TrustedProxies: p.conf.HLSTrustedProxies,
Directory: p.conf.HLSDirectory,
ReadTimeout: p.conf.ReadTimeout,
WriteQueueSize: p.conf.WriteQueueSize,
PathManager: p.pathManager,
Parent: p,
Address: p.conf.HLSAddress,
Encryption: p.conf.HLSEncryption,
ServerKey: p.conf.HLSServerKey,
ServerCert: p.conf.HLSServerCert,
AlwaysRemux: p.conf.HLSAlwaysRemux,
Variant: p.conf.HLSVariant,
SegmentCount: p.conf.HLSSegmentCount,
SegmentDuration: p.conf.HLSSegmentDuration,
PartDuration: p.conf.HLSPartDuration,
SegmentMaxSize: p.conf.HLSSegmentMaxSize,
AllowOrigin: p.conf.HLSAllowOrigin,
TrustedProxies: p.conf.HLSTrustedProxies,
Directory: p.conf.HLSDirectory,
ReadTimeout: p.conf.ReadTimeout,
WriteQueueSize: p.conf.WriteQueueSize,
PathManager: p.pathManager,
Parent: p,
}
err := i.Initialize()
if err != nil {
@ -597,6 +612,7 @@ func (p *Core) createResources(initial bool) error {
Address: p.conf.APIAddress,
ReadTimeout: p.conf.ReadTimeout,
Conf: p.conf,
AuthManager: p.authManager,
PathManager: p.pathManager,
RTSPServer: p.rtspServer,
RTSPSServer: p.rtspsServer,
@ -630,16 +646,29 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) {
!reflect.DeepEqual(newConf.LogDestinations, p.conf.LogDestinations) ||
newConf.LogFile != p.conf.LogFile
closeAuthManager := newConf == nil ||
newConf.AuthMethod != p.conf.AuthMethod ||
newConf.AuthHTTPAddress != p.conf.AuthHTTPAddress ||
!reflect.DeepEqual(newConf.AuthHTTPExclude, p.conf.AuthHTTPExclude) ||
newConf.AuthJWTJWKS != p.conf.AuthJWTJWKS ||
newConf.ReadTimeout != p.conf.ReadTimeout ||
!reflect.DeepEqual(newConf.RTSPAuthMethods, p.conf.RTSPAuthMethods)
if !closeAuthManager && !reflect.DeepEqual(newConf.AuthInternalUsers, p.conf.AuthInternalUsers) {
p.authManager.ReloadInternalUsers(newConf.AuthInternalUsers)
}
closeMetrics := newConf == nil ||
newConf.Metrics != p.conf.Metrics ||
newConf.MetricsAddress != p.conf.MetricsAddress ||
newConf.ReadTimeout != p.conf.ReadTimeout ||
closeAuthManager ||
closeLogger
closePPROF := newConf == nil ||
newConf.PPROF != p.conf.PPROF ||
newConf.PPROFAddress != p.conf.PPROFAddress ||
newConf.ReadTimeout != p.conf.ReadTimeout ||
closeAuthManager ||
closeLogger
closeRecorderCleaner := newConf == nil ||
@ -650,6 +679,7 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) {
newConf.Playback != p.conf.Playback ||
newConf.PlaybackAddress != p.conf.PlaybackAddress ||
newConf.ReadTimeout != p.conf.ReadTimeout ||
closeAuthManager ||
closeLogger
if !closePlaybackServer && p.playbackServer != nil && !reflect.DeepEqual(newConf.Paths, p.conf.Paths) {
p.playbackServer.ReloadPathConfs(newConf.Paths)
@ -657,14 +687,14 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) {
closePathManager := newConf == nil ||
newConf.LogLevel != p.conf.LogLevel ||
newConf.ExternalAuthenticationURL != p.conf.ExternalAuthenticationURL ||
newConf.RTSPAddress != p.conf.RTSPAddress ||
!reflect.DeepEqual(newConf.AuthMethods, p.conf.AuthMethods) ||
!reflect.DeepEqual(newConf.RTSPAuthMethods, p.conf.RTSPAuthMethods) ||
newConf.ReadTimeout != p.conf.ReadTimeout ||
newConf.WriteTimeout != p.conf.WriteTimeout ||
newConf.WriteQueueSize != p.conf.WriteQueueSize ||
newConf.UDPMaxPayloadSize != p.conf.UDPMaxPayloadSize ||
closeMetrics ||
closeAuthManager ||
closeLogger
if !closePathManager && !reflect.DeepEqual(newConf.Paths, p.conf.Paths) {
p.pathManager.ReloadPathConfs(newConf.Paths)
@ -674,7 +704,7 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) {
newConf.RTSP != p.conf.RTSP ||
newConf.Encryption != p.conf.Encryption ||
newConf.RTSPAddress != p.conf.RTSPAddress ||
!reflect.DeepEqual(newConf.AuthMethods, p.conf.AuthMethods) ||
!reflect.DeepEqual(newConf.RTSPAuthMethods, p.conf.RTSPAuthMethods) ||
newConf.ReadTimeout != p.conf.ReadTimeout ||
newConf.WriteTimeout != p.conf.WriteTimeout ||
newConf.WriteQueueSize != p.conf.WriteQueueSize ||
@ -697,7 +727,7 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) {
newConf.RTSP != p.conf.RTSP ||
newConf.Encryption != p.conf.Encryption ||
newConf.RTSPSAddress != p.conf.RTSPSAddress ||
!reflect.DeepEqual(newConf.AuthMethods, p.conf.AuthMethods) ||
!reflect.DeepEqual(newConf.RTSPAuthMethods, p.conf.RTSPAuthMethods) ||
newConf.ReadTimeout != p.conf.ReadTimeout ||
newConf.WriteTimeout != p.conf.WriteTimeout ||
newConf.WriteQueueSize != p.conf.WriteQueueSize ||
@ -750,7 +780,6 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) {
newConf.HLSEncryption != p.conf.HLSEncryption ||
newConf.HLSServerKey != p.conf.HLSServerKey ||
newConf.HLSServerCert != p.conf.HLSServerCert ||
newConf.ExternalAuthenticationURL != p.conf.ExternalAuthenticationURL ||
newConf.HLSAlwaysRemux != p.conf.HLSAlwaysRemux ||
newConf.HLSVariant != p.conf.HLSVariant ||
newConf.HLSSegmentCount != p.conf.HLSSegmentCount ||
@ -804,6 +833,7 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) {
newConf.API != p.conf.API ||
newConf.APIAddress != p.conf.APIAddress ||
newConf.ReadTimeout != p.conf.ReadTimeout ||
closeAuthManager ||
closePathManager ||
closeRTSPServer ||
closeRTSPSServer ||
@ -921,6 +951,10 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) {
p.metrics = nil
}
if closeAuthManager && p.authManager != nil {
p.authManager = nil
}
if newConf == nil && p.externalCmdPool != nil {
p.Log(logger.Info, "waiting for running hooks")
p.externalCmdPool.Close()

View File

@ -1,23 +0,0 @@
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
}

View File

@ -6,6 +6,7 @@ import (
"sort"
"sync"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
@ -47,17 +48,16 @@ type pathManagerParent interface {
}
type pathManager struct {
logLevel conf.LogLevel
externalAuthenticationURL string
rtspAddress string
authMethods conf.AuthMethods
readTimeout conf.StringDuration
writeTimeout conf.StringDuration
writeQueueSize int
udpMaxPayloadSize int
pathConfs map[string]*conf.Path
externalCmdPool *externalcmd.Pool
parent pathManagerParent
logLevel conf.LogLevel
authManager *auth.Manager
rtspAddress string
readTimeout conf.StringDuration
writeTimeout conf.StringDuration
writeQueueSize int
udpMaxPayloadSize int
pathConfs map[string]*conf.Path
externalCmdPool *externalcmd.Pool
parent pathManagerParent
ctx context.Context
ctxCancel func()
@ -236,8 +236,7 @@ func (pm *pathManager) doFindPathConf(req defs.PathFindPathConfReq) {
return
}
err = doAuthentication(pm.externalAuthenticationURL, pm.authMethods,
pathConf, req.AccessRequest)
err = pm.authManager.Authenticate(req.AccessRequest.ToAuthRequest())
if err != nil {
req.Res <- defs.PathFindPathConfRes{Err: err}
return
@ -253,8 +252,7 @@ func (pm *pathManager) doDescribe(req defs.PathDescribeReq) {
return
}
err = doAuthentication(pm.externalAuthenticationURL, pm.authMethods,
pathConf, req.AccessRequest)
err = pm.authManager.Authenticate(req.AccessRequest.ToAuthRequest())
if err != nil {
req.Res <- defs.PathDescribeRes{Err: err}
return
@ -276,8 +274,7 @@ func (pm *pathManager) doAddReader(req defs.PathAddReaderReq) {
}
if !req.AccessRequest.SkipAuth {
err = doAuthentication(pm.externalAuthenticationURL, pm.authMethods,
pathConf, req.AccessRequest)
err = pm.authManager.Authenticate(req.AccessRequest.ToAuthRequest())
if err != nil {
req.Res <- defs.PathAddReaderRes{Err: err}
return
@ -300,8 +297,7 @@ func (pm *pathManager) doAddPublisher(req defs.PathAddPublisherReq) {
}
if !req.AccessRequest.SkipAuth {
err = doAuthentication(pm.externalAuthenticationURL, pm.authMethods,
pathConf, req.AccessRequest)
err = pm.authManager.Authenticate(req.AccessRequest.ToAuthRequest())
if err != nil {
req.Res <- defs.PathAddPublisherRes{Err: err}
return

View File

@ -1,23 +0,0 @@
package defs
// AuthProtocol is a authentication protocol.
type AuthProtocol string
// authentication protocols.
const (
AuthProtocolRTSP AuthProtocol = "rtsp"
AuthProtocolRTMP AuthProtocol = "rtmp"
AuthProtocolHLS AuthProtocol = "hls"
AuthProtocolWebRTC AuthProtocol = "webrtc"
AuthProtocolSRT AuthProtocol = "srt"
)
// AuthenticationError is a authentication error.
type AuthenticationError struct {
Message string
}
// Error implements the error interface.
func (e AuthenticationError) Error() string {
return "authentication failed: " + e.Message
}

View File

@ -8,6 +8,7 @@ import (
"github.com/bluenviron/gortsplib/v4/pkg/description"
"github.com/google/uuid"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/externalcmd"
"github.com/bluenviron/mediamtx/internal/stream"
@ -45,13 +46,35 @@ type PathAccessRequest struct {
IP net.IP
User string
Pass string
Proto AuthProtocol
Proto auth.Protocol
ID *uuid.UUID
RTSPRequest *base.Request
RTSPBaseURL *base.URL
RTSPNonce string
}
// ToAuthRequest converts a path access request into an authentication request.
func (r *PathAccessRequest) ToAuthRequest() *auth.Request {
return &auth.Request{
User: r.User,
Pass: r.Pass,
IP: r.IP,
Action: func() conf.AuthAction {
if r.Publish {
return conf.AuthActionPublish
}
return conf.AuthActionRead
}(),
Path: r.Name,
Protocol: r.Proto,
ID: r.ID,
Query: r.Query,
RTSPRequest: r.RTSPRequest,
RTSPBaseURL: r.RTSPBaseURL,
RTSPNonce: r.RTSPNonce,
}
}
// PathFindPathConfRes contains the response of FindPathConf().
type PathFindPathConfRes struct {
Conf *conf.Path

View File

@ -3,6 +3,7 @@ package metrics
import (
"io"
"net"
"net/http"
"reflect"
"strconv"
@ -12,6 +13,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/bluenviron/mediamtx/internal/api"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/protocols/httpp"
@ -38,6 +40,7 @@ type metricsParent interface {
type Metrics struct {
Address string
ReadTimeout conf.StringDuration
AuthManager *auth.Manager
Parent metricsParent
httpServer *httpp.WrappedServer
@ -57,7 +60,7 @@ func (m *Metrics) Initialize() error {
router := gin.New()
router.SetTrustedProxies(nil) //nolint:errcheck
router.GET("/metrics", m.onMetrics)
router.GET("/metrics", m.mwAuth, m.onMetrics)
network, address := restrictnetwork.Restrict("tcp", m.Address)
@ -91,6 +94,30 @@ func (m *Metrics) Log(level logger.Level, format string, args ...interface{}) {
m.Parent.Log(level, "[metrics] "+format, args...)
}
func (m *Metrics) mwAuth(ctx *gin.Context) {
user, pass, hasCredentials := ctx.Request.BasicAuth()
err := m.AuthManager.Authenticate(&auth.Request{
User: user,
Pass: pass,
IP: net.ParseIP(ctx.ClientIP()),
Action: conf.AuthActionMetrics,
})
if err != nil {
if !hasCredentials {
ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`)
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
// wait some seconds to mitigate brute force attacks
<-time.After(auth.PauseAfterError)
ctx.Writer.WriteHeader(http.StatusUnauthorized)
return
}
}
func (m *Metrics) onMetrics(ctx *gin.Context) {
out := ""

View File

@ -10,6 +10,7 @@ import (
"sync"
"time"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/protocols/httpp"
@ -57,6 +58,7 @@ type Server struct {
Address string
ReadTimeout conf.StringDuration
PathConfs map[string]*conf.Path
AuthManager *auth.Manager
Parent logger.Writer
httpServer *httpp.WrappedServer
@ -128,9 +130,45 @@ func (p *Server) safeFindPathConf(name string) (*conf.Path, error) {
return pathConf, err
}
func (p *Server) doAuth(ctx *gin.Context, pathName string) bool {
user, pass, hasCredentials := ctx.Request.BasicAuth()
err := p.AuthManager.Authenticate(&auth.Request{
User: user,
Pass: pass,
IP: net.ParseIP(ctx.ClientIP()),
Action: conf.AuthActionPlayback,
Path: pathName,
})
if err != nil {
if !hasCredentials {
ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`)
ctx.Writer.WriteHeader(http.StatusUnauthorized)
return false
}
var terr auth.Error
errors.As(err, &terr)
p.Log(logger.Info, "connection %v failed to authenticate: %v", httpp.RemoteAddr(ctx), terr.Message)
// wait some seconds to mitigate brute force attacks
<-time.After(auth.PauseAfterError)
ctx.Writer.WriteHeader(http.StatusUnauthorized)
return false
}
return true
}
func (p *Server) onList(ctx *gin.Context) {
pathName := ctx.Query("path")
if !p.doAuth(ctx, pathName) {
return
}
pathConf, err := p.safeFindPathConf(pathName)
if err != nil {
p.writeError(ctx, http.StatusBadRequest, err)
@ -182,6 +220,10 @@ func (p *Server) onList(ctx *gin.Context) {
func (p *Server) onGet(ctx *gin.Context) {
pathName := ctx.Query("path")
if !p.doAuth(ctx, pathName) {
return
}
start, err := time.Parse(time.RFC3339, ctx.Query("start"))
if err != nil {
p.writeError(ctx, http.StatusBadRequest, fmt.Errorf("invalid start: %w", err))

View File

@ -12,6 +12,7 @@ import (
"github.com/bluenviron/mediacommon/pkg/formats/fmp4"
"github.com/bluenviron/mediacommon/pkg/formats/fmp4/seekablebuffer"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/test"
"github.com/stretchr/testify/require"
@ -120,6 +121,23 @@ func writeSegment2(t *testing.T, fpath string) {
require.NoError(t, err)
}
var authManager = &auth.Manager{
Method: conf.AuthMethodInternal,
InternalUsers: []conf.AuthInternalUser{
{
User: "myuser",
Pass: "mypass",
Permissions: []conf.AuthInternalUserPermission{
{
Action: conf.AuthActionPlayback,
Path: "mypath",
},
},
},
},
RTSPAuthMethods: nil,
}
func TestServerGet(t *testing.T) {
dir, err := os.MkdirTemp("", "mediamtx-playback")
require.NoError(t, err)
@ -140,24 +158,22 @@ func TestServerGet(t *testing.T) {
RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"),
},
},
Parent: &test.NilLogger{},
AuthManager: authManager,
Parent: &test.NilLogger{},
}
err = s.Initialize()
require.NoError(t, err)
defer s.Close()
u, err := url.Parse("http://myuser:mypass@localhost:9996/get")
require.NoError(t, err)
v := url.Values{}
v.Set("path", "mypath")
v.Set("start", time.Date(2008, 11, 0o7, 11, 23, 1, 500000000, time.Local).Format(time.RFC3339Nano))
v.Set("duration", "2")
v.Set("format", "fmp4")
u := &url.URL{
Scheme: "http",
Host: "localhost:9996",
Path: "/get",
RawQuery: v.Encode(),
}
u.RawQuery = v.Encode()
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
require.NoError(t, err)
@ -234,21 +250,19 @@ func TestServerList(t *testing.T) {
RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"),
},
},
Parent: &test.NilLogger{},
AuthManager: authManager,
Parent: &test.NilLogger{},
}
err = s.Initialize()
require.NoError(t, err)
defer s.Close()
u, err := url.Parse("http://myuser:mypass@localhost:9996/list")
require.NoError(t, err)
v := url.Values{}
v.Set("path", "mypath")
u := &url.URL{
Scheme: "http",
Host: "localhost:9996",
Path: "/list",
RawQuery: v.Encode(),
}
u.RawQuery = v.Encode()
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
require.NoError(t, err)

View File

@ -2,12 +2,15 @@
package pprof
import (
"net"
"net/http"
"strings"
"time"
// start pprof
_ "net/http/pprof"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/protocols/httpp"
@ -22,6 +25,7 @@ type pprofParent interface {
type PPROF struct {
Address string
ReadTimeout conf.StringDuration
AuthManager *auth.Manager
Parent pprofParent
httpServer *httpp.WrappedServer
@ -38,7 +42,7 @@ func (pp *PPROF) Initialize() error {
time.Duration(pp.ReadTimeout),
"",
"",
http.DefaultServeMux,
pp,
pp,
)
if err != nil {
@ -60,3 +64,31 @@ func (pp *PPROF) Close() {
func (pp *PPROF) Log(level logger.Level, format string, args ...interface{}) {
pp.Parent.Log(level, "[pprof] "+format, args...)
}
func (pp *PPROF) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user, pass, hasCredentials := r.BasicAuth()
ip, _, _ := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
err := pp.AuthManager.Authenticate(&auth.Request{
User: user,
Pass: pass,
IP: net.ParseIP(ip),
Action: conf.AuthActionMetrics,
})
if err != nil {
if !hasCredentials {
w.Header().Set("WWW-Authenticate", `Basic realm="mediamtx"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
// wait some seconds to mitigate brute force attacks
<-time.After(auth.PauseAfterError)
w.WriteHeader(http.StatusUnauthorized)
return
}
http.DefaultServeMux.ServeHTTP(w, r)
}

View File

@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/logger"
@ -19,10 +20,6 @@ import (
"github.com/bluenviron/mediamtx/internal/restrictnetwork"
)
const (
pauseAfterAuthError = 2 * time.Second
)
//go:generate go run ./hlsjsdownloader
//go:embed index.html
@ -38,7 +35,7 @@ type httpServer struct {
serverKey string
serverCert string
allowOrigin string
trustedProxies conf.IPsOrCIDRs
trustedProxies conf.IPNetworks
readTimeout conf.StringDuration
pathManager serverPathManager
parent *Server
@ -158,11 +155,11 @@ func (s *httpServer) onRequest(ctx *gin.Context) {
IP: net.ParseIP(ctx.ClientIP()),
User: user,
Pass: pass,
Proto: defs.AuthProtocolHLS,
Proto: auth.ProtocolHLS,
},
})
if err != nil {
var terr defs.AuthenticationError
var terr auth.Error
if errors.As(err, &terr) {
if !hasCredentials {
ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`)
@ -173,7 +170,7 @@ func (s *httpServer) onRequest(ctx *gin.Context) {
s.Log(logger.Info, "connection %v failed to authenticate: %v", httpp.RemoteAddr(ctx), terr.Message)
// wait some seconds to mitigate brute force attacks
<-time.After(pauseAfterAuthError)
<-time.After(auth.PauseAfterError)
ctx.Writer.WriteHeader(http.StatusUnauthorized)
return

View File

@ -46,20 +46,19 @@ type muxerGetInstanceReq struct {
}
type muxer struct {
parentCtx context.Context
remoteAddr string
externalAuthenticationURL string
variant conf.HLSVariant
segmentCount int
segmentDuration conf.StringDuration
partDuration conf.StringDuration
segmentMaxSize conf.StringSize
directory string
writeQueueSize int
wg *sync.WaitGroup
pathName string
pathManager serverPathManager
parent *Server
parentCtx context.Context
remoteAddr string
variant conf.HLSVariant
segmentCount int
segmentDuration conf.StringDuration
partDuration conf.StringDuration
segmentMaxSize conf.StringSize
directory string
writeQueueSize int
wg *sync.WaitGroup
pathName string
pathManager serverPathManager
parent *Server
ctx context.Context
ctxCancel func()

View File

@ -59,24 +59,23 @@ type serverParent interface {
// Server is a HLS server.
type Server struct {
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
WriteQueueSize int
PathManager serverPathManager
Parent serverParent
Address string
Encryption bool
ServerKey string
ServerCert string
AlwaysRemux bool
Variant conf.HLSVariant
SegmentCount int
SegmentDuration conf.StringDuration
PartDuration conf.StringDuration
SegmentMaxSize conf.StringSize
AllowOrigin string
TrustedProxies conf.IPNetworks
Directory string
ReadTimeout conf.StringDuration
WriteQueueSize int
PathManager serverPathManager
Parent serverParent
ctx context.Context
ctxCancel func()
@ -218,20 +217,19 @@ outer:
func (s *Server) createMuxer(pathName string, remoteAddr string) *muxer {
r := &muxer{
parentCtx: s.ctx,
remoteAddr: remoteAddr,
externalAuthenticationURL: s.ExternalAuthenticationURL,
variant: s.Variant,
segmentCount: s.SegmentCount,
segmentDuration: s.SegmentDuration,
partDuration: s.PartDuration,
segmentMaxSize: s.SegmentMaxSize,
directory: s.Directory,
writeQueueSize: s.WriteQueueSize,
wg: &s.wg,
pathName: pathName,
pathManager: s.PathManager,
parent: s,
parentCtx: s.ctx,
remoteAddr: remoteAddr,
variant: s.Variant,
segmentCount: s.SegmentCount,
segmentDuration: s.SegmentDuration,
partDuration: s.PartDuration,
segmentMaxSize: s.SegmentMaxSize,
directory: s.Directory,
writeQueueSize: s.WriteQueueSize,
wg: &s.wg,
pathName: pathName,
pathManager: s.PathManager,
parent: s,
}
r.initialize()
s.muxers[pathName] = r

View File

@ -10,6 +10,7 @@ import (
"github.com/bluenviron/gohlslib/pkg/codecs"
"github.com/bluenviron/gortsplib/v4/pkg/description"
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
@ -50,7 +51,10 @@ type dummyPathManager struct {
stream *stream.Stream
}
func (pm *dummyPathManager) FindPathConf(_ defs.PathFindPathConfReq) (*conf.Path, error) {
func (pm *dummyPathManager) FindPathConf(req defs.PathFindPathConfReq) (*conf.Path, error) {
if req.AccessRequest.User != "myuser" || req.AccessRequest.Pass != "mypass" {
return nil, auth.Error{}
}
return &conf.Path{}, nil
}
@ -68,24 +72,23 @@ func TestServerNotFound(t *testing.T) {
} {
t.Run(ca, func(t *testing.T) {
s := &Server{
Address: "127.0.0.1:8888",
Encryption: false,
ServerKey: "",
ServerCert: "",
ExternalAuthenticationURL: "",
AlwaysRemux: ca == "always remux on",
Variant: conf.HLSVariant(gohlslib.MuxerVariantMPEGTS),
SegmentCount: 7,
SegmentDuration: conf.StringDuration(1 * time.Second),
PartDuration: conf.StringDuration(200 * time.Millisecond),
SegmentMaxSize: 50 * 1024 * 1024,
AllowOrigin: "",
TrustedProxies: conf.IPsOrCIDRs{},
Directory: "",
ReadTimeout: conf.StringDuration(10 * time.Second),
WriteQueueSize: 512,
PathManager: &dummyPathManager{},
Parent: &test.NilLogger{},
Address: "127.0.0.1:8888",
Encryption: false,
ServerKey: "",
ServerCert: "",
AlwaysRemux: ca == "always remux on",
Variant: conf.HLSVariant(gohlslib.MuxerVariantMPEGTS),
SegmentCount: 7,
SegmentDuration: conf.StringDuration(1 * time.Second),
PartDuration: conf.StringDuration(200 * time.Millisecond),
SegmentMaxSize: 50 * 1024 * 1024,
AllowOrigin: "",
TrustedProxies: conf.IPNetworks{},
Directory: "",
ReadTimeout: conf.StringDuration(10 * time.Second),
WriteQueueSize: 512,
PathManager: &dummyPathManager{},
Parent: &test.NilLogger{},
}
err := s.Initialize()
require.NoError(t, err)
@ -94,7 +97,7 @@ func TestServerNotFound(t *testing.T) {
hc := &http.Client{Transport: &http.Transport{}}
func() {
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8888/nonexisting/", nil)
req, err := http.NewRequest(http.MethodGet, "http://myuser:mypass@127.0.0.1:8888/nonexisting/", nil)
require.NoError(t, err)
res, err := hc.Do(req)
@ -104,7 +107,7 @@ func TestServerNotFound(t *testing.T) {
}()
func() {
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8888/nonexisting/index.m3u8", nil)
req, err := http.NewRequest(http.MethodGet, "http://myuser:mypass@127.0.0.1:8888/nonexisting/index.m3u8", nil)
require.NoError(t, err)
res, err := hc.Do(req)
@ -131,31 +134,30 @@ func TestServerRead(t *testing.T) {
pathManager := &dummyPathManager{stream: stream}
s := &Server{
Address: "127.0.0.1:8888",
Encryption: false,
ServerKey: "",
ServerCert: "",
ExternalAuthenticationURL: "",
AlwaysRemux: false,
Variant: conf.HLSVariant(gohlslib.MuxerVariantMPEGTS),
SegmentCount: 7,
SegmentDuration: conf.StringDuration(1 * time.Second),
PartDuration: conf.StringDuration(200 * time.Millisecond),
SegmentMaxSize: 50 * 1024 * 1024,
AllowOrigin: "",
TrustedProxies: conf.IPsOrCIDRs{},
Directory: "",
ReadTimeout: conf.StringDuration(10 * time.Second),
WriteQueueSize: 512,
PathManager: pathManager,
Parent: &test.NilLogger{},
Address: "127.0.0.1:8888",
Encryption: false,
ServerKey: "",
ServerCert: "",
AlwaysRemux: false,
Variant: conf.HLSVariant(gohlslib.MuxerVariantMPEGTS),
SegmentCount: 7,
SegmentDuration: conf.StringDuration(1 * time.Second),
PartDuration: conf.StringDuration(200 * time.Millisecond),
SegmentMaxSize: 50 * 1024 * 1024,
AllowOrigin: "",
TrustedProxies: conf.IPNetworks{},
Directory: "",
ReadTimeout: conf.StringDuration(10 * time.Second),
WriteQueueSize: 512,
PathManager: pathManager,
Parent: &test.NilLogger{},
}
err = s.Initialize()
require.NoError(t, err)
defer s.Close()
c := &gohlslib.Client{
URI: "http://127.0.0.1:8888/mystream/index.m3u8",
URI: "http://myuser:mypass@127.0.0.1:8888/mystream/index.m3u8",
}
recv := make(chan struct{})
@ -217,24 +219,23 @@ func TestServerRead(t *testing.T) {
pathManager := &dummyPathManager{stream: stream}
s := &Server{
Address: "127.0.0.1:8888",
Encryption: false,
ServerKey: "",
ServerCert: "",
ExternalAuthenticationURL: "",
AlwaysRemux: true,
Variant: conf.HLSVariant(gohlslib.MuxerVariantMPEGTS),
SegmentCount: 7,
SegmentDuration: conf.StringDuration(1 * time.Second),
PartDuration: conf.StringDuration(200 * time.Millisecond),
SegmentMaxSize: 50 * 1024 * 1024,
AllowOrigin: "",
TrustedProxies: conf.IPsOrCIDRs{},
Directory: "",
ReadTimeout: conf.StringDuration(10 * time.Second),
WriteQueueSize: 512,
PathManager: pathManager,
Parent: &test.NilLogger{},
Address: "127.0.0.1:8888",
Encryption: false,
ServerKey: "",
ServerCert: "",
AlwaysRemux: true,
Variant: conf.HLSVariant(gohlslib.MuxerVariantMPEGTS),
SegmentCount: 7,
SegmentDuration: conf.StringDuration(1 * time.Second),
PartDuration: conf.StringDuration(200 * time.Millisecond),
SegmentMaxSize: 50 * 1024 * 1024,
AllowOrigin: "",
TrustedProxies: conf.IPNetworks{},
Directory: "",
ReadTimeout: conf.StringDuration(10 * time.Second),
WriteQueueSize: 512,
PathManager: pathManager,
Parent: &test.NilLogger{},
}
err = s.Initialize()
require.NoError(t, err)
@ -257,7 +258,7 @@ func TestServerRead(t *testing.T) {
}
c := &gohlslib.Client{
URI: "http://127.0.0.1:8888/mystream/index.m3u8",
URI: "http://myuser:mypass@127.0.0.1:8888/mystream/index.m3u8",
}
recv := make(chan struct{})

View File

@ -18,6 +18,7 @@ import (
"github.com/google/uuid"
"github.com/bluenviron/mediamtx/internal/asyncwriter"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
@ -28,10 +29,6 @@ import (
"github.com/bluenviron/mediamtx/internal/unit"
)
const (
pauseAfterAuthError = 2 * time.Second
)
var errNoSupportedCodecs = errors.New(
"the stream doesn't contain any supported codec, which are currently H264, MPEG-4 Audio, MPEG-1/2 Audio")
@ -176,15 +173,15 @@ func (c *conn) runRead(conn *rtmp.Conn, u *url.URL) error {
IP: c.ip(),
User: query.Get("user"),
Pass: query.Get("pass"),
Proto: defs.AuthProtocolRTMP,
Proto: auth.ProtocolRTMP,
ID: &c.uuid,
},
})
if err != nil {
var terr defs.AuthenticationError
var terr auth.Error
if errors.As(err, &terr) {
// wait some seconds to mitigate brute force attacks
<-time.After(pauseAfterAuthError)
<-time.After(auth.PauseAfterError)
return terr
}
return err
@ -405,15 +402,15 @@ func (c *conn) runPublish(conn *rtmp.Conn, u *url.URL) error {
IP: c.ip(),
User: query.Get("user"),
Pass: query.Get("pass"),
Proto: defs.AuthProtocolRTMP,
Proto: auth.ProtocolRTMP,
ID: &c.uuid,
},
})
if err != nil {
var terr defs.AuthenticationError
var terr auth.Error
if errors.As(err, &terr) {
// wait some seconds to mitigate brute force attacks
<-time.After(pauseAfterAuthError)
<-time.After(auth.PauseAfterError)
return terr
}
return err

View File

@ -10,6 +10,7 @@ import (
"github.com/bluenviron/gortsplib/v4/pkg/description"
"github.com/bluenviron/mediamtx/internal/asyncwriter"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
@ -65,11 +66,17 @@ type dummyPathManager struct {
path *dummyPath
}
func (pm *dummyPathManager) AddPublisher(_ defs.PathAddPublisherReq) (defs.Path, error) {
func (pm *dummyPathManager) AddPublisher(req defs.PathAddPublisherReq) (defs.Path, error) {
if req.AccessRequest.User != "myuser" || req.AccessRequest.Pass != "mypass" {
return nil, auth.Error{}
}
return pm.path, nil
}
func (pm *dummyPathManager) AddReader(_ defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) {
func (pm *dummyPathManager) AddReader(req defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) {
if req.AccessRequest.User != "myuser" || req.AccessRequest.Pass != "mypass" {
return nil, nil, auth.Error{}
}
return pm.path, pm.path.stream, nil
}
@ -119,7 +126,7 @@ func TestServerPublish(t *testing.T) {
require.NoError(t, err)
defer s.Close()
u, err := url.Parse("rtmp://127.0.0.1:1935/teststream?user=testpublisher&pass=testpass&param=value")
u, err := url.Parse("rtmp://127.0.0.1:1935/teststream?user=myuser&pass=mypass&param=value")
require.NoError(t, err)
nconn, err := func() (net.Conn, error) {
@ -221,7 +228,7 @@ func TestServerRead(t *testing.T) {
require.NoError(t, err)
defer s.Close()
u, err := url.Parse("rtmp://127.0.0.1:1935/teststream?user=testreader&pass=testpass&param=value")
u, err := url.Parse("rtmp://127.0.0.1:1935/teststream?user=myuser&pass=mypass&param=value")
require.NoError(t, err)
nconn, err := func() (net.Conn, error) {

View File

@ -7,11 +7,12 @@ import (
"time"
"github.com/bluenviron/gortsplib/v4"
"github.com/bluenviron/gortsplib/v4/pkg/auth"
rtspauth "github.com/bluenviron/gortsplib/v4/pkg/auth"
"github.com/bluenviron/gortsplib/v4/pkg/base"
"github.com/bluenviron/gortsplib/v4/pkg/headers"
"github.com/google/uuid"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
@ -20,7 +21,7 @@ import (
)
const (
pauseAfterAuthError = 2 * time.Second
rtspAuthRealm = "IPCAM"
)
type conn struct {
@ -118,7 +119,7 @@ func (c *conn) onDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx,
if c.authNonce == "" {
var err error
c.authNonce, err = auth.GenerateNonce()
c.authNonce, err = rtspauth.GenerateNonce()
if err != nil {
return &base.Response{
StatusCode: base.StatusInternalServerError,
@ -131,7 +132,7 @@ func (c *conn) onDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx,
Name: ctx.Path,
Query: ctx.Query,
IP: c.ip(),
Proto: defs.AuthProtocolRTSP,
Proto: auth.ProtocolRTSP,
ID: &c.uuid,
RTSPRequest: ctx.Request,
RTSPNonce: c.authNonce,
@ -139,7 +140,7 @@ func (c *conn) onDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx,
})
if res.Err != nil {
var terr defs.AuthenticationError
var terr auth.Error
if errors.As(res.Err, &terr) {
res, err := c.handleAuthError(terr)
return res, nil, err
@ -191,13 +192,13 @@ func (c *conn) handleAuthError(authErr error) (*base.Response, error) {
return &base.Response{
StatusCode: base.StatusUnauthorized,
Header: base.Header{
"WWW-Authenticate": auth.GenerateWWWAuthenticate(c.authMethods, "IPCAM", c.authNonce),
"WWW-Authenticate": rtspauth.GenerateWWWAuthenticate(c.authMethods, rtspAuthRealm, c.authNonce),
},
}, nil
}
// wait some seconds to mitigate brute force attacks
<-time.After(pauseAfterAuthError)
<-time.After(auth.PauseAfterError)
return &base.Response{
StatusCode: base.StatusUnauthorized,

View File

@ -48,7 +48,9 @@ func (p *dummyPath) StartPublisher(req defs.PathStartPublisherReq) (*stream.Stre
if err != nil {
return nil, err
}
close(p.streamCreated)
return p.stream, nil
}
@ -123,7 +125,7 @@ func TestServerPublish(t *testing.T) {
media0 := test.UniqueMediaH264()
err = source.StartRecording(
"rtsp://testpublisher:testpass@127.0.0.1:8557/teststream?param=value",
"rtsp://myuser:mypass@127.0.0.1:8557/teststream?param=value",
&description.Session{Medias: []*description.Media{media0}})
require.NoError(t, err)
defer source.Close()
@ -211,7 +213,7 @@ func TestServerRead(t *testing.T) {
reader := gortsplib.Client{}
u, err := base.ParseURL("rtsp://testreader:testpass@127.0.0.1:8557/teststream?param=value")
u, err := base.ParseURL("rtsp://myuser:mypass@127.0.0.1:8557/teststream?param=value")
require.NoError(t, err)
err = reader.Start(u.Scheme, u.Host)

View File

@ -9,11 +9,12 @@ import (
"time"
"github.com/bluenviron/gortsplib/v4"
"github.com/bluenviron/gortsplib/v4/pkg/auth"
rtspauth "github.com/bluenviron/gortsplib/v4/pkg/auth"
"github.com/bluenviron/gortsplib/v4/pkg/base"
"github.com/google/uuid"
"github.com/pion/rtp"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
@ -102,7 +103,7 @@ func (s *session) onAnnounce(c *conn, ctx *gortsplib.ServerHandlerOnAnnounceCtx)
if c.authNonce == "" {
var err error
c.authNonce, err = auth.GenerateNonce()
c.authNonce, err = rtspauth.GenerateNonce()
if err != nil {
return &base.Response{
StatusCode: base.StatusInternalServerError,
@ -117,7 +118,7 @@ func (s *session) onAnnounce(c *conn, ctx *gortsplib.ServerHandlerOnAnnounceCtx)
Query: ctx.Query,
Publish: true,
IP: c.ip(),
Proto: defs.AuthProtocolRTSP,
Proto: auth.ProtocolRTSP,
ID: &c.uuid,
RTSPRequest: ctx.Request,
RTSPBaseURL: nil,
@ -125,7 +126,7 @@ func (s *session) onAnnounce(c *conn, ctx *gortsplib.ServerHandlerOnAnnounceCtx)
},
})
if err != nil {
var terr defs.AuthenticationError
var terr auth.Error
if errors.As(err, &terr) {
return c.handleAuthError(terr)
}
@ -187,7 +188,7 @@ func (s *session) onSetup(c *conn, ctx *gortsplib.ServerHandlerOnSetupCtx,
if c.authNonce == "" {
var err error
c.authNonce, err = auth.GenerateNonce()
c.authNonce, err = rtspauth.GenerateNonce()
if err != nil {
return &base.Response{
StatusCode: base.StatusInternalServerError,
@ -201,7 +202,7 @@ func (s *session) onSetup(c *conn, ctx *gortsplib.ServerHandlerOnSetupCtx,
Name: ctx.Path,
Query: ctx.Query,
IP: c.ip(),
Proto: defs.AuthProtocolRTSP,
Proto: auth.ProtocolRTSP,
ID: &c.uuid,
RTSPRequest: ctx.Request,
RTSPBaseURL: baseURL,
@ -209,7 +210,7 @@ func (s *session) onSetup(c *conn, ctx *gortsplib.ServerHandlerOnSetupCtx,
},
})
if err != nil {
var terr defs.AuthenticationError
var terr auth.Error
if errors.As(err, &terr) {
res, err := c.handleAuthError(terr)
return res, nil, err

View File

@ -15,6 +15,7 @@ import (
"github.com/google/uuid"
"github.com/bluenviron/mediamtx/internal/asyncwriter"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
@ -24,10 +25,6 @@ import (
"github.com/bluenviron/mediamtx/internal/stream"
)
const (
pauseAfterAuthError = 2 * time.Second
)
func srtCheckPassphrase(connReq srt.ConnRequest, passphrase string) error {
if passphrase == "" {
return nil
@ -171,16 +168,16 @@ func (c *conn) runPublish(req srtNewConnReq, streamID *streamID) (bool, error) {
Publish: true,
User: streamID.user,
Pass: streamID.pass,
Proto: defs.AuthProtocolSRT,
Proto: auth.ProtocolSRT,
ID: &c.uuid,
Query: streamID.query,
},
})
if err != nil {
var terr defs.AuthenticationError
var terr auth.Error
if errors.As(err, &terr) {
// wait some seconds to mitigate brute force attacks
<-time.After(pauseAfterAuthError)
<-time.After(auth.PauseAfterError)
return false, terr
}
return false, err
@ -267,16 +264,16 @@ func (c *conn) runRead(req srtNewConnReq, streamID *streamID) (bool, error) {
IP: c.ip(),
User: streamID.user,
Pass: streamID.pass,
Proto: defs.AuthProtocolSRT,
Proto: auth.ProtocolSRT,
ID: &c.uuid,
Query: streamID.query,
},
})
if err != nil {
var terr defs.AuthenticationError
var terr auth.Error
if errors.As(err, &terr) {
// wait some seconds to mitigate brute force attacks
<-time.After(pauseAfterAuthError)
<-time.After(auth.PauseAfterError)
return false, err
}
return false, err

View File

@ -8,6 +8,7 @@ import (
"github.com/bluenviron/gortsplib/v4/pkg/description"
"github.com/bluenviron/mediacommon/pkg/formats/mpegts"
"github.com/bluenviron/mediamtx/internal/asyncwriter"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
@ -63,11 +64,17 @@ type dummyPathManager struct {
path *dummyPath
}
func (pm *dummyPathManager) AddPublisher(_ defs.PathAddPublisherReq) (defs.Path, error) {
func (pm *dummyPathManager) AddPublisher(req defs.PathAddPublisherReq) (defs.Path, error) {
if req.AccessRequest.User != "myuser" || req.AccessRequest.Pass != "mypass" {
return nil, auth.Error{}
}
return pm.path, nil
}
func (pm *dummyPathManager) AddReader(_ defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) {
func (pm *dummyPathManager) AddReader(req defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) {
if req.AccessRequest.User != "myuser" || req.AccessRequest.Pass != "mypass" {
return nil, nil, auth.Error{}
}
return pm.path, pm.path.stream, nil
}
@ -99,7 +106,7 @@ func TestServerPublish(t *testing.T) {
require.NoError(t, err)
defer s.Close()
u := "srt://localhost:8890?streamid=publish:mypath"
u := "srt://localhost:8890?streamid=publish:mypath:myuser:mypass"
srtConf := srt.DefaultConfig()
address, err := srtConf.UnmarshalURL(u)
@ -198,7 +205,7 @@ func TestServerRead(t *testing.T) {
require.NoError(t, err)
defer s.Close()
u := "srt://localhost:8890?streamid=read:mypath"
u := "srt://localhost:8890?streamid=read:mypath:myuser:mypass"
srtConf := srt.DefaultConfig()
address, err := srtConf.UnmarshalURL(u)

View File

@ -14,6 +14,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/logger"
@ -56,7 +57,7 @@ type httpServer struct {
serverKey string
serverCert string
allowOrigin string
trustedProxies conf.IPsOrCIDRs
trustedProxies conf.IPNetworks
readTimeout conf.StringDuration
pathManager serverPathManager
parent *Server
@ -117,11 +118,11 @@ func (s *httpServer) checkAuthOutsideSession(ctx *gin.Context, path string, publ
IP: net.ParseIP(ctx.ClientIP()),
User: user,
Pass: pass,
Proto: defs.AuthProtocolWebRTC,
Proto: auth.ProtocolWebRTC,
},
})
if err != nil {
var terr defs.AuthenticationError
var terr auth.Error
if errors.As(err, &terr) {
if !hasCredentials {
ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`)
@ -132,7 +133,7 @@ func (s *httpServer) checkAuthOutsideSession(ctx *gin.Context, path string, publ
s.Log(logger.Info, "connection %v failed to authenticate: %v", httpp.RemoteAddr(ctx), terr.Message)
// wait some seconds to mitigate brute force attacks
<-time.After(pauseAfterAuthError)
<-time.After(auth.PauseAfterError)
writeError(ctx, http.StatusUnauthorized, terr)
return false

View File

@ -30,7 +30,6 @@ import (
)
const (
pauseAfterAuthError = 2 * time.Second
webrtcTurnSecretExpiration = 24 * 3600 * time.Second
webrtcPayloadMaxSize = 1188 // 1200 - 12 (RTP header)
)
@ -182,7 +181,7 @@ type Server struct {
ServerKey string
ServerCert string
AllowOrigin string
TrustedProxies conf.IPsOrCIDRs
TrustedProxies conf.IPNetworks
ReadTimeout conf.StringDuration
WriteQueueSize int
LocalUDPAddress string

View File

@ -10,6 +10,7 @@ import (
"github.com/bluenviron/gortsplib/v4/pkg/description"
"github.com/bluenviron/mediamtx/internal/asyncwriter"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
@ -71,7 +72,10 @@ type dummyPathManager struct {
path *dummyPath
}
func (pm *dummyPathManager) FindPathConf(_ defs.PathFindPathConfReq) (*conf.Path, error) {
func (pm *dummyPathManager) FindPathConf(req defs.PathFindPathConfReq) (*conf.Path, error) {
if req.AccessRequest.User != "myuser" || req.AccessRequest.Pass != "mypass" {
return nil, auth.Error{}
}
return &conf.Path{}, nil
}
@ -93,7 +97,7 @@ func TestServerStaticPages(t *testing.T) {
ServerKey: "",
ServerCert: "",
AllowOrigin: "",
TrustedProxies: conf.IPsOrCIDRs{},
TrustedProxies: conf.IPNetworks{},
ReadTimeout: conf.StringDuration(10 * time.Second),
WriteQueueSize: 512,
LocalUDPAddress: "127.0.0.1:8887",
@ -114,7 +118,7 @@ func TestServerStaticPages(t *testing.T) {
for _, path := range []string{"/stream", "/stream/publish", "/publish"} {
func() {
req, err := http.NewRequest(http.MethodGet, "http://localhost:8886"+path, nil)
req, err := http.NewRequest(http.MethodGet, "http://myuser:mypass@localhost:8886"+path, nil)
require.NoError(t, err)
res, err := hc.Do(req)
@ -139,7 +143,7 @@ func TestServerPublish(t *testing.T) {
ServerKey: "",
ServerCert: "",
AllowOrigin: "",
TrustedProxies: conf.IPsOrCIDRs{},
TrustedProxies: conf.IPNetworks{},
ReadTimeout: conf.StringDuration(10 * time.Second),
WriteQueueSize: 512,
LocalUDPAddress: "127.0.0.1:8887",
@ -175,8 +179,7 @@ func TestServerPublish(t *testing.T) {
require.Equal(t, false, ok)
}()
ur := "http://"
ur += "localhost:8886/teststream/whip?param=value"
ur := "http://myuser:mypass@localhost:8886/teststream/whip?param=value"
su, err := url.Parse(ur)
require.NoError(t, err)
@ -260,7 +263,7 @@ func TestServerRead(t *testing.T) {
ServerKey: "",
ServerCert: "",
AllowOrigin: "",
TrustedProxies: conf.IPsOrCIDRs{},
TrustedProxies: conf.IPNetworks{},
ReadTimeout: conf.StringDuration(10 * time.Second),
WriteQueueSize: 512,
LocalUDPAddress: "127.0.0.1:8887",
@ -277,8 +280,7 @@ func TestServerRead(t *testing.T) {
require.NoError(t, err)
defer s.Close()
ur := "http://"
ur += "localhost:8886/teststream/whep?param=value"
ur := "http://myuser:mypass@localhost:8886/teststream/whep?param=value"
u, err := url.Parse(ur)
require.NoError(t, err)
@ -351,7 +353,7 @@ func TestServerReadNotFound(t *testing.T) {
ServerKey: "",
ServerCert: "",
AllowOrigin: "",
TrustedProxies: conf.IPsOrCIDRs{},
TrustedProxies: conf.IPNetworks{},
ReadTimeout: conf.StringDuration(10 * time.Second),
WriteQueueSize: 512,
LocalUDPAddress: "127.0.0.1:8887",
@ -370,7 +372,8 @@ func TestServerReadNotFound(t *testing.T) {
hc := &http.Client{Transport: &http.Transport{}}
iceServers, err := webrtc.WHIPOptionsICEServers(context.Background(), hc, "http://localhost:8886/nonexisting/whep")
iceServers, err := webrtc.WHIPOptionsICEServers(context.Background(), hc,
"http://myuser:mypass@localhost:8886/nonexisting/whep")
require.NoError(t, err)
pc, err := pwebrtc.NewPeerConnection(pwebrtc.Configuration{
@ -386,7 +389,7 @@ func TestServerReadNotFound(t *testing.T) {
require.NoError(t, err)
req, err := http.NewRequest(http.MethodPost,
"http://localhost:8886/nonexisting/whep", bytes.NewReader([]byte(offer.SDP)))
"http://myuser:mypass@localhost:8886/nonexisting/whep", bytes.NewReader([]byte(offer.SDP)))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/sdp")

View File

@ -22,6 +22,7 @@ import (
pwebrtc "github.com/pion/webrtc/v3"
"github.com/bluenviron/mediamtx/internal/asyncwriter"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
"github.com/bluenviron/mediamtx/internal/hooks"
@ -374,15 +375,15 @@ func (s *session) runPublish() (int, error) {
IP: net.ParseIP(ip),
User: s.req.user,
Pass: s.req.pass,
Proto: defs.AuthProtocolWebRTC,
Proto: auth.ProtocolWebRTC,
ID: &s.uuid,
},
})
if err != nil {
var terr defs.AuthenticationError
var terr auth.Error
if errors.As(err, &terr) {
// wait some seconds to mitigate brute force attacks
<-time.After(pauseAfterAuthError)
<-time.After(auth.PauseAfterError)
return http.StatusUnauthorized, err
}
@ -505,15 +506,15 @@ func (s *session) runRead() (int, error) {
IP: net.ParseIP(ip),
User: s.req.user,
Pass: s.req.pass,
Proto: defs.AuthProtocolWebRTC,
Proto: auth.ProtocolWebRTC,
ID: &s.uuid,
},
})
if err != nil {
var terr1 defs.AuthenticationError
var terr1 auth.Error
if errors.As(err, &terr1) {
// wait some seconds to mitigate brute force attacks
<-time.After(pauseAfterAuthError)
<-time.After(auth.PauseAfterError)
return http.StatusUnauthorized, err
}

View File

@ -24,32 +24,15 @@ writeQueueSize: 512
# This can be decreased to avoid fragmentation on networks with a low UDP MTU.
udpMaxPayloadSize: 1472
# HTTP URL to perform external authentication.
# Every time a user wants to authenticate, the server calls this URL
# with the POST method and a body containing:
# {
# "ip": "ip",
# "user": "user",
# "password": "password",
# "path": "path",
# "protocol": "rtsp|rtmp|hls|webrtc",
# "id": "id",
# "action": "read|publish",
# "query": "query"
# }
# If the response code is 20x, authentication is accepted, otherwise
# it is discarded.
externalAuthenticationURL:
# Enable Prometheus-compatible metrics.
metrics: no
# Address of the metrics listener.
metricsAddress: 127.0.0.1:9998
metricsAddress: :9998
# Enable pprof-compatible endpoint to monitor performances.
pprof: no
# Address of the pprof listener.
pprofAddress: 127.0.0.1:9999
pprofAddress: :9999
# Command to run when a client connects to the server.
# This is terminated with SIGINT when a client disconnects from the server.
@ -64,13 +47,98 @@ runOnConnectRestart: no
# Environment variables are the same of runOnConnect.
runOnDisconnect:
###############################################
# Global settings -> Authentication
# Authentication method. Available values are:
# * internal: users are stored in the configuration file
# * http: an external HTTP URL is contacted to perform authentication
# * jwt: an external identity server provides authentication through JWTs
authMethod: internal
# Internal authentication.
# list of users.
authInternalUsers:
# Default unprivileged user.
# Username. 'any' means any user, including anonymous ones.
- user: any
# Password. Not used in case of 'any' user.
pass:
# IPs or networks allowed to use this user. An empty list means any IP.
ips: []
# List of permissions.
permissions:
# Available actions are: publish, read, playback, api, metrics, pprof.
- action: publish
# Paths can be set to further restrict access to a specific path.
# An empty path means any path.
# Regular expressions can be used by using a tilde as prefix.
path:
- action: read
path:
- action: playback
path:
# Default administrator.
# This allows to use API, metrics and PPROF without authentication,
# if the IP is localhost.
- user: any
pass:
ips: ['127.0.0.1', '::1']
permissions:
- action: api
- action: metrics
- action: pprof
# HTTP-based authentication.
# URL called to perform authentication. Every time a user wants
# to authenticate, the server calls this URL with the POST method
# and a body containing:
# {
# "user": "user",
# "password": "password",
# "ip": "ip",
# "action": "publish|read|playback|api|metrics|pprof",
# "path": "path",
# "protocol": "rtsp|rtmp|hls|webrtc|srt",
# "id": "id",
# "query": "query"
# }
# If the response code is 20x, authentication is accepted, otherwise
# it is discarded.
authHTTPAddress:
# Actions to exclude from HTTP-based authentication.
# Format is the same as the one of user permissions.
authHTTPExclude:
- action: api
- action: metrics
- action: pprof
# JWT-based authentication.
# Users have to login through an external identity server and obtain a JWT.
# This JWT must contain the claim "mediamtx_permissions" with permissions,
# for instance:
# {
# ...
# "mediamtx_permissions": [
# {
# "action": "publish",
# "path": "somepath"
# }
# ]
# }
# Users are then expected to pass the JWT as a query parameter, i.e. ?jwt=...
# This is the JWKS URL that will be used to pull (once) the public key that allows
# to validate JWTs.
authJWTJWKS:
###############################################
# Global settings -> API
# Enable controlling the server through the API.
api: no
# Address of the API listener.
apiAddress: 127.0.0.1:9997
apiAddress: :9997
###############################################
# Global settings -> Playback server
@ -117,8 +185,8 @@ serverKey: server.key
# Path to the server certificate. This is needed only when encryption is "strict" or "optional".
serverCert: server.crt
# Authentication methods. Available are "basic" and "digest".
# "digest" doesn't provide any additional security and is available for compatibility reasons only.
authMethods: [basic]
# "digest" doesn't provide any additional security and is available for compatibility only.
rtspAuthMethods: [basic]
###############################################
# Global settings -> RTMP server
@ -327,27 +395,6 @@ pathDefaults:
# Set to 0s to disable automatic deletion.
recordDeleteAfter: 24h
###############################################
# Default path settings -> Authentication
# Username required to publish.
# Hashed values can be inserted with the "argon2:" or "sha256:" prefix.
publishUser:
# Password required to publish.
# Hashed values can be inserted with the "argon2:" or "sha256:" prefix.
publishPass:
# IPs or networks (x.x.x.x/24) allowed to publish.
publishIPs: []
# Username required to read.
# Hashed values can be inserted with the "argon2:" or "sha256:" prefix.
readUser:
# password required to read.
# Hashed values can be inserted with the "argon2:" or "sha256:" prefix.
readPass:
# IPs or networks (x.x.x.x/24) allowed to read.
readIPs: []
###############################################
# Default path settings -> Publisher source (when source is "publisher")