diff --git a/.github/workflows/code_lint.yml b/.github/workflows/code_lint.yml index e8ba45da..92be6bfe 100644 --- a/.github/workflows/code_lint.yml +++ b/.github/workflows/code_lint.yml @@ -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 diff --git a/README.md b/README.md index 4f93afe7..3e80d490 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apidocs/openapi.yaml b/apidocs/openapi.yaml index 68dedbf2..54c243d0 100644 --- a/apidocs/openapi.yaml +++ b/apidocs/openapi.yaml @@ -97,7 +97,7 @@ components: type: string serverCert: type: string - authMethods: + rtspAuthMethods: type: array items: type: string diff --git a/go.mod b/go.mod index 5127b905..1478f2d4 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 52e1dfad..5a8deb88 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/api/api.go b/internal/api/api.go index 4a83f38a..04ca7208 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 59a83225..ad64b7f7 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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) diff --git a/internal/auth/manager.go b/internal/auth/manager.go new file mode 100644 index 00000000..c58dcebb --- /dev/null +++ b/internal/auth/manager.go @@ -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 +} diff --git a/internal/auth/manager_test.go b/internal/auth/manager_test.go new file mode 100644 index 00000000..c97a1a58 --- /dev/null +++ b/internal/auth/manager_test.go @@ -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¶m=value" && + in.Query != "user=testpublisher&pass=testpass¶m=value" && + in.Query != "param=value") { + w.WriteHeader(http.StatusBadRequest) + return + } + + firstReceived = true + }), + } + + ln, err := net.Listen("tcp", "127.0.0.1:9120") + require.NoError(t, err) + + go ts.Server.Serve(ln) +} + +func (ts *testHTTPAuthenticator) close() { + ts.Server.Shutdown(context.Background()) +} + +func TestAuthInternal(t *testing.T) { + for _, outcome := range []string{ + "ok", + "wrong user", + "wrong pass", + "wrong ip", + "wrong action", + "wrong path", + } { + for _, encryption := range []string{ + "plain", + "sha256", + "argon2", + } { + t.Run(outcome+" "+encryption, func(t *testing.T) { + m := Manager{ + Method: conf.AuthMethodInternal, + InternalUsers: []conf.AuthInternalUser{ + { + IPs: conf.IPNetworks{mustParseCIDR("127.1.1.1/32")}, + Permissions: []conf.AuthInternalUserPermission{ + { + Action: conf.AuthActionPublish, + Path: "mypath", + }, + }, + }, + }, + HTTPAddress: "", + RTSPAuthMethods: nil, + } + + switch encryption { + case "plain": + m.InternalUsers[0].User = conf.Credential("testuser") + m.InternalUsers[0].Pass = conf.Credential("testpass") + + case "sha256": + m.InternalUsers[0].User = conf.Credential("sha256:rl3rgi4NcZkpAEcacZnQ2VuOfJ0FxAqCRaKB/SwdZoQ=") + m.InternalUsers[0].Pass = conf.Credential("sha256:E9JJ8stBJ7QM+nV4ZoUCeHk/gU3tPFh/5YieiJp6n2w=") + + case "argon2": + m.InternalUsers[0].User = conf.Credential( + "argon2:$argon2id$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$Ux/LWeTgJQPyfMMJo1myR64+o8rALHoPmlE1i/TR+58") + m.InternalUsers[0].Pass = conf.Credential( + "argon2:$argon2i$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$/mrZ42TiTv1mcPnpMUera5oi0SFYbbyueAbdx5sUvWo") + } + + switch outcome { + case "ok": + err := m.Authenticate(&Request{ + User: "testuser", + Pass: "testpass", + IP: net.ParseIP("127.1.1.1"), + Action: conf.AuthActionPublish, + Path: "mypath", + }) + require.NoError(t, err) + + case "wrong user": + err := m.Authenticate(&Request{ + User: "wrong", + Pass: "testpass", + IP: net.ParseIP("127.1.1.1"), + Action: conf.AuthActionPublish, + Path: "mypath", + }) + require.Error(t, err) + + case "wrong pass": + err := m.Authenticate(&Request{ + User: "testuser", + Pass: "wrong", + IP: net.ParseIP("127.1.1.1"), + Action: conf.AuthActionPublish, + Path: "mypath", + }) + require.Error(t, err) + + case "wrong ip": + err := m.Authenticate(&Request{ + User: "testuser", + Pass: "testpass", + IP: net.ParseIP("127.1.1.2"), + Action: conf.AuthActionPublish, + Path: "mypath", + }) + require.Error(t, err) + + case "wrong action": + err := m.Authenticate(&Request{ + User: "testuser", + Pass: "testpass", + IP: net.ParseIP("127.1.1.1"), + Action: conf.AuthActionRead, + Path: "mypath", + }) + require.Error(t, err) + + case "wrong path": + err := m.Authenticate(&Request{ + User: "testuser", + Pass: "testpass", + IP: net.ParseIP("127.1.1.1"), + Action: conf.AuthActionPublish, + Path: "wrong", + }) + require.Error(t, err) + } + }) + } + } +} + +func TestAuthInternalRTSPDigest(t *testing.T) { + m := Manager{ + Method: conf.AuthMethodInternal, + InternalUsers: []conf.AuthInternalUser{ + { + User: "myuser", + Pass: "mypass", + IPs: conf.IPNetworks{mustParseCIDR("127.1.1.1/32")}, + Permissions: []conf.AuthInternalUserPermission{ + { + Action: conf.AuthActionPublish, + Path: "mypath", + }, + }, + }, + }, + HTTPAddress: "", + RTSPAuthMethods: []headers.AuthMethod{headers.AuthDigestMD5}, + } + + u, err := base.ParseURL("rtsp://127.0.0.1:8554/mypath") + require.NoError(t, err) + + s, err := auth.NewSender( + auth.GenerateWWWAuthenticate([]headers.AuthMethod{headers.AuthDigestMD5}, "IPCAM", "mynonce"), + "myuser", + "mypass", + ) + require.NoError(t, err) + + req := &base.Request{ + Method: "ANNOUNCE", + URL: u, + } + + s.AddAuthorization(req) + + err = m.Authenticate(&Request{ + IP: net.ParseIP("127.1.1.1"), + Action: conf.AuthActionPublish, + Path: "mypath", + RTSPRequest: req, + RTSPNonce: "mynonce", + }) + require.NoError(t, err) +} + +func TestAuthHTTP(t *testing.T) { + for _, outcome := range []string{"ok", "fail"} { + t.Run(outcome, func(t *testing.T) { + m := Manager{ + Method: conf.AuthMethodHTTP, + HTTPAddress: "http://127.0.0.1:9120/auth", + RTSPAuthMethods: nil, + } + + au := &testHTTPAuthenticator{} + au.initialize(t, "rtsp", "publish") + defer au.close() + + if outcome == "ok" { + err := m.Authenticate(&Request{ + User: "testpublisher", + Pass: "testpass", + IP: net.ParseIP("127.0.0.1"), + Action: conf.AuthActionPublish, + Path: "teststream", + Protocol: ProtocolRTSP, + Query: "param=value", + }) + require.NoError(t, err) + } else { + err := m.Authenticate(&Request{ + User: "invalid", + Pass: "testpass", + IP: net.ParseIP("127.0.0.1"), + Action: conf.AuthActionPublish, + Path: "teststream", + Protocol: ProtocolRTSP, + Query: "param=value", + }) + require.Error(t, err) + } + }) + } +} + +func TestAuthHTTPExclude(t *testing.T) { + m := Manager{ + Method: conf.AuthMethodHTTP, + HTTPAddress: "http://not-to-be-used:9120/auth", + HTTPExclude: []conf.AuthInternalUserPermission{{ + Action: conf.AuthActionPublish, + }}, + RTSPAuthMethods: nil, + } + + err := m.Authenticate(&Request{ + User: "", + Pass: "", + IP: net.ParseIP("127.0.0.1"), + Action: conf.AuthActionPublish, + Path: "teststream", + Protocol: ProtocolRTSP, + Query: "param=value", + }) + require.NoError(t, err) +} diff --git a/internal/conf/auth_action.go b/internal/conf/auth_action.go new file mode 100644 index 00000000..2d6b3615 --- /dev/null +++ b/internal/conf/auth_action.go @@ -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 + `"`)) +} diff --git a/internal/conf/auth_internal_users.go b/internal/conf/auth_internal_users.go new file mode 100644 index 00000000..76cdc959 --- /dev/null +++ b/internal/conf/auth_internal_users.go @@ -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"` +} diff --git a/internal/conf/auth_method.go b/internal/conf/auth_method.go index 9c35c5eb..eb707a3c 100644 --- a/internal/conf/auth_method.go +++ b/internal/conf/auth_method.go @@ -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 + `"`)) } diff --git a/internal/conf/conf.go b/internal/conf/conf.go index 54b4d715..b10f4e16 100644 --- a/internal/conf/conf.go +++ b/internal/conf/conf.go @@ -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 } diff --git a/internal/conf/conf_test.go b/internal/conf/conf_test.go index 40f48f3f..1018b292 100644 --- a/internal/conf/conf_test.go +++ b/internal/conf/conf_test.go @@ -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" + diff --git a/internal/conf/encryption.go b/internal/conf/encryption.go index d7ff4177..039fb009 100644 --- a/internal/conf/encryption.go +++ b/internal/conf/encryption.go @@ -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) diff --git a/internal/conf/hls_variant.go b/internal/conf/hls_variant.go index 9d0dfc05..d9f970a4 100644 --- a/internal/conf/hls_variant.go +++ b/internal/conf/hls_variant.go @@ -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) diff --git a/internal/conf/ip_networks.go b/internal/conf/ip_networks.go new file mode 100644 index 00000000..d9f50d9e --- /dev/null +++ b/internal/conf/ip_networks.go @@ -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 +} diff --git a/internal/conf/ips_or_cidrs.go b/internal/conf/ips_or_cidrs.go deleted file mode 100644 index 59d56d32..00000000 --- a/internal/conf/ips_or_cidrs.go +++ /dev/null @@ -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 -} diff --git a/internal/conf/log_destination.go b/internal/conf/log_destination.go index cb369f00..29c4c72d 100644 --- a/internal/conf/log_destination.go +++ b/internal/conf/log_destination.go @@ -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 diff --git a/internal/conf/log_level.go b/internal/conf/log_level.go index 2231067c..36ff866b 100644 --- a/internal/conf/log_level.go +++ b/internal/conf/log_level.go @@ -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) diff --git a/internal/conf/path.go b/internal/conf/path.go index a37b99ff..6bf93ada 100644 --- a/internal/conf/path.go +++ b/internal/conf/path.go @@ -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 diff --git a/internal/conf/protocol.go b/internal/conf/protocol.go index e2e46c94..74713061 100644 --- a/internal/conf/protocol.go +++ b/internal/conf/protocol.go @@ -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 diff --git a/internal/conf/rtsp_auth_methods.go b/internal/conf/rtsp_auth_methods.go new file mode 100644 index 00000000..c77f9fc3 --- /dev/null +++ b/internal/conf/rtsp_auth_methods.go @@ -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) +} diff --git a/internal/conf/rtsp_range_type.go b/internal/conf/rtsp_range_type.go index 2a6decd2..1760e388 100644 --- a/internal/conf/rtsp_range_type.go +++ b/internal/conf/rtsp_range_type.go @@ -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) diff --git a/internal/conf/rtsp_transport.go b/internal/conf/rtsp_transport.go index cb49a11c..6cc6b223 100644 --- a/internal/conf/rtsp_transport.go +++ b/internal/conf/rtsp_transport.go @@ -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" } } diff --git a/internal/conf/string_size.go b/internal/conf/string_size.go index e3a1dcc4..cd9f8df6 100644 --- a/internal/conf/string_size.go +++ b/internal/conf/string_size.go @@ -25,8 +25,8 @@ func (s *StringSize) UnmarshalJSON(b []byte) error { if err != nil { return err } - *s = StringSize(v) + return nil } diff --git a/internal/core/auth.go b/internal/core/auth.go deleted file mode 100644 index 16c99b1b..00000000 --- a/internal/core/auth.go +++ /dev/null @@ -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 -} diff --git a/internal/core/auth_test.go b/internal/core/auth_test.go deleted file mode 100644 index 9a373164..00000000 --- a/internal/core/auth_test.go +++ /dev/null @@ -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¶m=value" && - in.Query != "user=testpublisher&pass=testpass¶m=value" && - in.Query != "param=value") { - w.WriteHeader(http.StatusBadRequest) - return - } - - firstReceived = true - }), - } - - ln, err := net.Listen("tcp", "127.0.0.1:9120") - require.NoError(t, err) - - go ts.Server.Serve(ln) -} - -func (ts *testHTTPAuthenticator) close() { - ts.Server.Shutdown(context.Background()) -} - -func 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) -} diff --git a/internal/core/core.go b/internal/core/core.go index 19426dc9..06c14bc5 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -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() diff --git a/internal/core/ip.go b/internal/core/ip.go deleted file mode 100644 index a88ce462..00000000 --- a/internal/core/ip.go +++ /dev/null @@ -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 -} diff --git a/internal/core/path_manager.go b/internal/core/path_manager.go index 011275a5..ae78ba1b 100644 --- a/internal/core/path_manager.go +++ b/internal/core/path_manager.go @@ -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 diff --git a/internal/defs/auth.go b/internal/defs/auth.go deleted file mode 100644 index a0d708f9..00000000 --- a/internal/defs/auth.go +++ /dev/null @@ -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 -} diff --git a/internal/defs/path.go b/internal/defs/path.go index d9d98bb9..d5ee377b 100644 --- a/internal/defs/path.go +++ b/internal/defs/path.go @@ -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 diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 4a2520fb..b6fe11e6 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -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 := "" diff --git a/internal/playback/server.go b/internal/playback/server.go index b3e4fc2f..cea04391 100644 --- a/internal/playback/server.go +++ b/internal/playback/server.go @@ -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)) diff --git a/internal/playback/server_test.go b/internal/playback/server_test.go index 89f9adb4..f56d6ec0 100644 --- a/internal/playback/server_test.go +++ b/internal/playback/server_test.go @@ -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) diff --git a/internal/pprof/pprof.go b/internal/pprof/pprof.go index a15bc028..55ada0a9 100644 --- a/internal/pprof/pprof.go +++ b/internal/pprof/pprof.go @@ -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) +} diff --git a/internal/servers/hls/http_server.go b/internal/servers/hls/http_server.go index aa70a3c9..a297373b 100644 --- a/internal/servers/hls/http_server.go +++ b/internal/servers/hls/http_server.go @@ -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 diff --git a/internal/servers/hls/muxer.go b/internal/servers/hls/muxer.go index 92506407..6a319a5f 100644 --- a/internal/servers/hls/muxer.go +++ b/internal/servers/hls/muxer.go @@ -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() diff --git a/internal/servers/hls/server.go b/internal/servers/hls/server.go index 34b8fc9f..3b5f80a0 100644 --- a/internal/servers/hls/server.go +++ b/internal/servers/hls/server.go @@ -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 diff --git a/internal/servers/hls/server_test.go b/internal/servers/hls/server_test.go index 28ba042f..3e352053 100644 --- a/internal/servers/hls/server_test.go +++ b/internal/servers/hls/server_test.go @@ -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{}) diff --git a/internal/servers/rtmp/conn.go b/internal/servers/rtmp/conn.go index f4b4774b..2b7b8c02 100644 --- a/internal/servers/rtmp/conn.go +++ b/internal/servers/rtmp/conn.go @@ -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 diff --git a/internal/servers/rtmp/server_test.go b/internal/servers/rtmp/server_test.go index aa92e06e..24a1b8ef 100644 --- a/internal/servers/rtmp/server_test.go +++ b/internal/servers/rtmp/server_test.go @@ -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¶m=value") + u, err := url.Parse("rtmp://127.0.0.1:1935/teststream?user=myuser&pass=mypass¶m=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¶m=value") + u, err := url.Parse("rtmp://127.0.0.1:1935/teststream?user=myuser&pass=mypass¶m=value") require.NoError(t, err) nconn, err := func() (net.Conn, error) { diff --git a/internal/servers/rtsp/conn.go b/internal/servers/rtsp/conn.go index edd725e6..daed394e 100644 --- a/internal/servers/rtsp/conn.go +++ b/internal/servers/rtsp/conn.go @@ -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, diff --git a/internal/servers/rtsp/server_test.go b/internal/servers/rtsp/server_test.go index 618d1ba8..943910b6 100644 --- a/internal/servers/rtsp/server_test.go +++ b/internal/servers/rtsp/server_test.go @@ -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) diff --git a/internal/servers/rtsp/session.go b/internal/servers/rtsp/session.go index 2581b43b..bc705ba8 100644 --- a/internal/servers/rtsp/session.go +++ b/internal/servers/rtsp/session.go @@ -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 diff --git a/internal/servers/srt/conn.go b/internal/servers/srt/conn.go index 9ffd360a..b227fe4f 100644 --- a/internal/servers/srt/conn.go +++ b/internal/servers/srt/conn.go @@ -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 diff --git a/internal/servers/srt/server_test.go b/internal/servers/srt/server_test.go index 7c637e1f..b3b238a6 100644 --- a/internal/servers/srt/server_test.go +++ b/internal/servers/srt/server_test.go @@ -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) diff --git a/internal/servers/webrtc/http_server.go b/internal/servers/webrtc/http_server.go index de82cfe5..95bddf2b 100644 --- a/internal/servers/webrtc/http_server.go +++ b/internal/servers/webrtc/http_server.go @@ -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 diff --git a/internal/servers/webrtc/server.go b/internal/servers/webrtc/server.go index cb99ec8a..4ac0d7da 100644 --- a/internal/servers/webrtc/server.go +++ b/internal/servers/webrtc/server.go @@ -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 diff --git a/internal/servers/webrtc/server_test.go b/internal/servers/webrtc/server_test.go index f6e9af06..05d1a79f 100644 --- a/internal/servers/webrtc/server_test.go +++ b/internal/servers/webrtc/server_test.go @@ -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") diff --git a/internal/servers/webrtc/session.go b/internal/servers/webrtc/session.go index 525235dc..67e3a2a8 100644 --- a/internal/servers/webrtc/session.go +++ b/internal/servers/webrtc/session.go @@ -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 } diff --git a/mediamtx.yml b/mediamtx.yml index 670a65c0..b4a8a7dc 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -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")