diff --git a/config/config.sample.toml b/config/config.sample.toml index d4a9b26..238b9cd 100644 --- a/config/config.sample.toml +++ b/config/config.sample.toml @@ -11,6 +11,8 @@ security.admin_secret_key = "" ice_port_udp = 8443 ice_host_override = "" ice_servers = [] +turn.static_auth_secret = "" +turn.credentials_expiration_minutes = 1440 [store] data_source = "/tmp/rtcd_db" diff --git a/docs/env_config.md b/docs/env_config.md index 0faac62..1c7d922 100644 --- a/docs/env_config.md +++ b/docs/env_config.md @@ -1,24 +1,26 @@ ### Config Environment Overrides ``` -KEY TYPE -RTCD_API_HTTP_LISTENADDRESS String -RTCD_API_HTTP_TLS_ENABLE True or False -RTCD_API_HTTP_TLS_CERTFILE String -RTCD_API_HTTP_TLS_CERTKEY String -RTCD_API_SECURITY_ENABLEADMIN True or False -RTCD_API_SECURITY_ADMINSECRETKEY String -RTCD_API_SECURITY_ALLOWSELFREGISTRATION True or False -RTCD_RTC_ICEPORTUDP Integer -RTCD_RTC_ICEHOSTOVERRIDE String -RTCD_RTC_ICESERVERS Comma-separated list of -RTCD_STORE_DATASOURCE String -RTCD_LOGGER_ENABLECONSOLE True or False -RTCD_LOGGER_CONSOLEJSON True or False -RTCD_LOGGER_CONSOLELEVEL String -RTCD_LOGGER_ENABLEFILE True or False -RTCD_LOGGER_FILEJSON True or False -RTCD_LOGGER_FILELEVEL String -RTCD_LOGGER_FILELOCATION String -RTCD_LOGGER_ENABLECOLOR True or False +KEY TYPE +RTCD_API_HTTP_LISTENADDRESS String +RTCD_API_HTTP_TLS_ENABLE True or False +RTCD_API_HTTP_TLS_CERTFILE String +RTCD_API_HTTP_TLS_CERTKEY String +RTCD_API_SECURITY_ENABLEADMIN True or False +RTCD_API_SECURITY_ADMINSECRETKEY String +RTCD_API_SECURITY_ALLOWSELFREGISTRATION True or False +RTCD_RTC_ICEPORTUDP Integer +RTCD_RTC_ICEHOSTOVERRIDE String +RTCD_RTC_ICESERVERS Comma-separated list of +RTCD_RTC_TURNCONFIG_STATICAUTHSECRET String +RTCD_RTC_TURNCONFIG_CREDENTIALSEXPIRATIONMINUTES Integer +RTCD_STORE_DATASOURCE String +RTCD_LOGGER_ENABLECONSOLE True or False +RTCD_LOGGER_CONSOLEJSON True or False +RTCD_LOGGER_CONSOLELEVEL String +RTCD_LOGGER_ENABLEFILE True or False +RTCD_LOGGER_FILEJSON True or False +RTCD_LOGGER_FILELEVEL String +RTCD_LOGGER_FILELOCATION String +RTCD_LOGGER_ENABLECOLOR True or False ``` diff --git a/service/config.go b/service/config.go index fc666f9..c3a1898 100644 --- a/service/config.go +++ b/service/config.go @@ -63,6 +63,10 @@ func (c Config) IsValid() error { return err } + if err := c.RTC.IsValid(); err != nil { + return err + } + if err := c.Store.IsValid(); err != nil { return err } @@ -77,6 +81,7 @@ func (c Config) IsValid() error { func (c *Config) SetDefaults() { c.API.HTTP.ListenAddress = ":8045" c.RTC.ICEPortUDP = 8443 + c.RTC.TURNConfig.CredentialsExpirationMinutes = 1440 c.Store.DataSource = "/tmp/rtcd_db" c.Logger.EnableConsole = true c.Logger.ConsoleJSON = false diff --git a/service/rtc/config.go b/service/rtc/config.go index ceefbc8..c750ef4 100644 --- a/service/rtc/config.go +++ b/service/rtc/config.go @@ -17,6 +17,7 @@ type ServerConfig struct { ICEHostOverride string `toml:"ice_host_override"` // A list of ICE server (STUN/TURN) configurations to use. ICEServers ICEServers `toml:"ice_servers"` + TURNConfig TURNConfig `toml:"turn"` } func (c ServerConfig) IsValid() error { @@ -28,6 +29,10 @@ func (c ServerConfig) IsValid() error { return fmt.Errorf("invalid ICEServers value: %w", err) } + if err := c.TURNConfig.IsValid(); err != nil { + return fmt.Errorf("invalid TURNConfig: %w", err) + } + return nil } @@ -79,13 +84,31 @@ func (c ICEServerConfig) IsValid() error { return fmt.Errorf("invalid empty URL") } - if !strings.HasPrefix(u, "stun:") && !strings.HasPrefix(u, "turn:") { + if !c.IsSTUN() && !c.IsTURN() { return fmt.Errorf("URL is not a valid STUN/TURN server") } } return nil } +func (c ICEServerConfig) IsTURN() bool { + for _, u := range c.URLs { + if !strings.HasPrefix(u, "turn:") { + return false + } + } + return len(c.URLs) > 0 +} + +func (c ICEServerConfig) IsSTUN() bool { + for _, u := range c.URLs { + if !strings.HasPrefix(u, "stun:") { + return false + } + } + return len(c.URLs) > 0 +} + func (s ICEServers) IsValid() error { for _, cfg := range s { if err := cfg.IsValid(); err != nil { @@ -97,10 +120,8 @@ func (s ICEServers) IsValid() error { func (s ICEServers) getSTUN() string { for _, cfg := range s { - for _, u := range cfg.URLs { - if strings.HasPrefix(u, "stun:") { - return u - } + if cfg.IsSTUN() { + return cfg.URLs[0] } } return "" diff --git a/service/rtc/config_test.go b/service/rtc/config_test.go index fcf6eae..bc1ba3e 100644 --- a/service/rtc/config_test.go +++ b/service/rtc/config_test.go @@ -28,9 +28,24 @@ func TestServerConfigIsValid(t *testing.T) { require.Equal(t, "invalid ICEPortUDP value: 65000 is not in allowed range [80, 49151]", err.Error()) }) + t.Run("invalid TURNCredentialsExpirationMinutes", func(t *testing.T) { + var cfg ServerConfig + cfg.ICEPortUDP = 8443 + cfg.TURNConfig.StaticAuthSecret = "secret" + err := cfg.IsValid() + require.Error(t, err) + require.Equal(t, "invalid TURNConfig: invalid CredentialsExpirationMinutes value: should be a positive number", err.Error()) + + cfg.TURNConfig.CredentialsExpirationMinutes = 20000 + err = cfg.IsValid() + require.Error(t, err) + require.Equal(t, "invalid TURNConfig: invalid CredentialsExpirationMinutes value: should be less than 1 week", err.Error()) + }) + t.Run("valid", func(t *testing.T) { var cfg ServerConfig cfg.ICEPortUDP = 8443 + cfg.TURNConfig.CredentialsExpirationMinutes = 1440 err := cfg.IsValid() require.NoError(t, err) }) @@ -172,6 +187,18 @@ func TestICEServerConfigIsValid(t *testing.T) { require.Equal(t, "URL is not a valid STUN/TURN server", err.Error()) }) + t.Run("partially valid", func(t *testing.T) { + cfg := ICEServerConfig{ + URLs: []string{ + "turn:turn1.localhost:3478", + "turn2.localhost:3478", + }, + } + err := cfg.IsValid() + require.Error(t, err) + require.Equal(t, "URL is not a valid STUN/TURN server", err.Error()) + }) + t.Run("valid", func(t *testing.T) { cfg := ICEServerConfig{ URLs: []string{ diff --git a/service/rtc/sfu.go b/service/rtc/sfu.go index 3dcd19a..9aa31c8 100644 --- a/service/rtc/sfu.go +++ b/service/rtc/sfu.go @@ -90,14 +90,29 @@ func initInterceptors(m *webrtc.MediaEngine) (*interceptor.Registry, error) { func (s *Server) InitSession(cfg SessionConfig, closeCb func() error) error { s.metrics.IncRTCSessions(cfg.GroupID, cfg.CallID) - iceServers := make([]webrtc.ICEServer, len(s.cfg.ICEServers)) - for _, cfg := range s.cfg.ICEServers { + iceServers := make([]webrtc.ICEServer, 0, len(s.cfg.ICEServers)) + for _, iceCfg := range s.cfg.ICEServers { + // generating short-lived TURN credentials if needed. + if iceCfg.IsTURN() && s.cfg.TURNConfig.StaticAuthSecret == "" { + continue + } + if iceCfg.IsTURN() && iceCfg.Username == "" && iceCfg.Credential == "" { + ts := time.Now().Add(time.Duration(s.cfg.TURNConfig.CredentialsExpirationMinutes) * time.Minute).Unix() + username, password, err := genTURNCredentials(cfg.SessionID, s.cfg.TURNConfig.StaticAuthSecret, ts) + if err != nil { + s.log.Error("failed to generate TURN credentials", mlog.Err(err)) + continue + } + iceCfg.Username = username + iceCfg.Credential = password + } iceServers = append(iceServers, webrtc.ICEServer{ - URLs: cfg.URLs, - Username: cfg.Username, - Credential: cfg.Credential, + URLs: iceCfg.URLs, + Username: iceCfg.Username, + Credential: iceCfg.Credential, }) } + peerConnConfig := webrtc.Configuration{ ICEServers: iceServers, SDPSemantics: webrtc.SDPSemanticsUnifiedPlanWithFallback, diff --git a/service/rtc/turn.go b/service/rtc/turn.go new file mode 100644 index 0000000..0bae8e8 --- /dev/null +++ b/service/rtc/turn.go @@ -0,0 +1,84 @@ +// Copyright (c) 2022-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package rtc + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "fmt" + "time" +) + +const MaxTURNCredentialsExpiration = 7 * 24 * 60 // 1 week in minutes + +type TURNConfig struct { + // The secret key used to generate TURN short-lived authentication + // credentials. + StaticAuthSecret string `toml:"static_auth_secret"` + // The number of minutes that the generated TURN credentials will be valid for. + CredentialsExpirationMinutes int `toml:"credentials_expiration_minutes"` +} + +func (c TURNConfig) IsValid() error { + if c.StaticAuthSecret != "" { + if c.CredentialsExpirationMinutes <= 0 { + return fmt.Errorf("invalid CredentialsExpirationMinutes value: should be a positive number") + } + if c.CredentialsExpirationMinutes >= MaxTURNCredentialsExpiration { + return fmt.Errorf("invalid CredentialsExpirationMinutes value: should be less than 1 week") + } + } + + return nil +} + +func genTURNCredentials(username, secret string, expirationTS int64) (string, string, error) { + if username == "" { + return "", "", fmt.Errorf("username should not be empty") + } + + if secret == "" { + return "", "", fmt.Errorf("secret should not be empty") + } + + if expirationTS <= 0 { + return "", "", fmt.Errorf("expirationTS should be a positive number") + } + + if expirationTS > time.Now().Add(MaxTURNCredentialsExpiration*time.Minute).Unix() { + return "", "", fmt.Errorf("expirationTS cannot be more than a week into the future") + } + + h := hmac.New(sha1.New, []byte(secret)) + username = fmt.Sprintf("%d:%s", expirationTS, username) + _, err := h.Write([]byte(username)) + if err != nil { + return "", "", fmt.Errorf("failed to write hmac: %w", err) + } + password := base64.StdEncoding.EncodeToString(h.Sum(nil)) + return username, password, nil +} + +func GenTURNConfigs(turnServers ICEServers, username, secret string, expiryMinutes int) (ICEServers, error) { + var configs ICEServers + ts := time.Now().Add(time.Duration(expiryMinutes) * time.Minute).Unix() + + for _, cfg := range turnServers { + if cfg.Username != "" || cfg.Credential != "" { + continue + } + username, password, err := genTURNCredentials(username, secret, ts) + if err != nil { + return nil, err + } + configs = append(configs, ICEServerConfig{ + URLs: cfg.URLs, + Username: username, + Credential: password, + }) + } + + return configs, nil +} diff --git a/service/rtc/turn_test.go b/service/rtc/turn_test.go new file mode 100644 index 0000000..d78eafd --- /dev/null +++ b/service/rtc/turn_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2022-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package rtc + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGenTURNCredentials(t *testing.T) { + t.Run("empty username", func(t *testing.T) { + ts := time.Now().Add(30 * time.Minute).Unix() + username, password, err := genTURNCredentials("", "secret", ts) + require.EqualError(t, err, "username should not be empty") + require.Empty(t, username) + require.Empty(t, password) + }) + + t.Run("empty secret", func(t *testing.T) { + ts := time.Now().Add(30 * time.Minute).Unix() + username, password, err := genTURNCredentials("username", "", ts) + require.EqualError(t, err, "secret should not be empty") + require.Empty(t, username) + require.Empty(t, password) + }) + + t.Run("invalid timestamp", func(t *testing.T) { + username, password, err := genTURNCredentials("username", "secret", 0) + require.EqualError(t, err, "expirationTS should be a positive number") + require.Empty(t, username) + require.Empty(t, password) + }) + + t.Run("expiration > 1 week", func(t *testing.T) { + ts := time.Now().Add(20000 * time.Minute).Unix() + username, password, err := genTURNCredentials("username", "secret", ts) + require.EqualError(t, err, "expirationTS cannot be more than a week into the future") + require.Empty(t, username) + require.Empty(t, password) + }) + + t.Run("valid", func(t *testing.T) { + ts := time.Now().Add(30 * time.Minute).Unix() + username, password, err := genTURNCredentials("username", "secret", ts) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("%d:username", ts), username) + require.NotEmpty(t, password) + }) +} + +func TestGenTURNConfigs(t *testing.T) { + t.Run("no servers", func(t *testing.T) { + configs, err := GenTURNConfigs(nil, "", "", 0) + require.NoError(t, err) + require.Empty(t, configs) + + configs, err = GenTURNConfigs(ICEServers{}, "", "", 0) + require.NoError(t, err) + require.Empty(t, configs) + }) + + t.Run("static credentials", func(t *testing.T) { + servers := ICEServers{ + ICEServerConfig{ + URLs: []string{"turn:turn1.example.com:3478"}, + Username: "username", + Credential: "password", + }, + } + configs, err := GenTURNConfigs(servers, "", "", 0) + require.NoError(t, err) + require.Empty(t, configs) + }) + + t.Run("turn servers", func(t *testing.T) { + servers := ICEServers{ + ICEServerConfig{ + URLs: []string{"turn:turn1.example.com:3478"}, + }, + ICEServerConfig{ + URLs: []string{"turn:turn2.example.com:3478"}, + }, + } + configs, err := GenTURNConfigs(servers, "username", "secret", 1440) + require.NoError(t, err) + require.Len(t, configs, 2) + require.Len(t, configs[0].URLs, 1) + require.Len(t, configs[1].URLs, 1) + require.Equal(t, "turn:turn1.example.com:3478", configs[0].URLs[0]) + require.NotEmpty(t, configs[0].Username) + require.NotEmpty(t, configs[0].Credential) + require.Equal(t, "turn:turn2.example.com:3478", configs[1].URLs[0]) + require.NotEmpty(t, configs[1].Username) + require.NotEmpty(t, configs[1].Credential) + }) +}