diff --git a/plugin.json b/plugin.json index 8c2a7927d..328378eb0 100644 --- a/plugin.json +++ b/plugin.json @@ -66,6 +66,27 @@ "default": "[{\"urls\":[\"stun:stun.global.calls.mattermost.com:3478\"]}]", "placeholder": "[{\n \"urls\": \"[\"turn:turnserver.example.org:3478\"]\",\n \"username\": \"webrtc\",\n \"credential\": \"turnpassword\"\n}]" }, + { + "key": "TURNStaticAuthSecret", + "display_name": "TURN Static Auth Secret", + "type": "text", + "default": "", + "help_text": "(Optional) The secret key used to generate TURN short-lived authentication credentials." + }, + { + "key": "TURNCredentialsExpirationMinutes", + "display_name": "TURN Credentials Expiration (minutes)", + "type": "number", + "default": 1440, + "help_text": "(Optional) The number of minutes that the generated TURN credentials will be valid for." + }, + { + "key": "ServerSideTURN", + "display_name": "Server Side TURN", + "type": "bool", + "default": false, + "help_text": "(Optional) When set to true it will pass and use configured TURN candidates to server initiated connections." + }, { "key": "RTCDServiceURL", "display_name": "RTCD service URL", diff --git a/server/activate.go b/server/activate.go index 41fd8fb95..9209a1f01 100644 --- a/server/activate.go +++ b/server/activate.go @@ -103,11 +103,18 @@ func (p *Plugin) OnActivate() error { }() } - rtcServer, err := rtc.NewServer(rtc.ServerConfig{ + rtcServerConfig := rtc.ServerConfig{ ICEPortUDP: *cfg.UDPServerPort, ICEHostOverride: cfg.ICEHostOverride, - ICEServers: rtc.ICEServers(cfg.getICEServers()), - }, newLogger(p), p.metrics.RTCMetrics()) + ICEServers: rtc.ICEServers(cfg.getICEServers(false)), + TURNConfig: rtc.TURNConfig{ + CredentialsExpirationMinutes: *cfg.TURNCredentialsExpirationMinutes, + }, + } + if *cfg.ServerSideTURN { + rtcServerConfig.TURNConfig.StaticAuthSecret = cfg.TURNStaticAuthSecret + } + rtcServer, err := rtc.NewServer(rtcServerConfig, newLogger(p), p.metrics.RTCMetrics()) if err != nil { p.LogError(err.Error()) return err diff --git a/server/api.go b/server/api.go index 6e72b68a6..7bd89eda3 100644 --- a/server/api.go +++ b/server/api.go @@ -13,6 +13,10 @@ import ( "strings" "time" + "golang.org/x/time/rate" + + "github.com/mattermost/rtcd/service/rtc" + "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" ) @@ -377,6 +381,44 @@ func (p *Plugin) handleDebug(w http.ResponseWriter, r *http.Request) { res.Code = http.StatusNotFound } +func (p *Plugin) handleGetTURNCredentials(w http.ResponseWriter, r *http.Request) { + var res httpResponse + defer p.httpAudit("handleGetTURNCredentials", &res, w, r) + + cfg := p.getConfiguration() + if cfg.TURNStaticAuthSecret == "" { + res.Err = "TURNStaticAuthSecret should be set" + res.Code = http.StatusForbidden + return + } + + turnServers := cfg.ICEServersConfigs.getTURNConfigsForCredentials() + if len(turnServers) == 0 { + res.Err = "No TURN server was configured" + res.Code = http.StatusForbidden + return + } + + user, appErr := p.API.GetUser(r.Header.Get("Mattermost-User-Id")) + if appErr != nil { + res.Err = appErr.Error() + res.Code = http.StatusInternalServerError + return + } + + configs, err := rtc.GenTURNConfigs(turnServers, user.Username, cfg.TURNStaticAuthSecret, *cfg.TURNCredentialsExpirationMinutes) + if err != nil { + res.Err = err.Error() + res.Code = http.StatusInternalServerError + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(configs); err != nil { + p.LogError(err.Error()) + } +} + // handleConfig returns the client configuration, and cloud license information // that isn't exposed to clients yet on the webapp func (p *Plugin) handleConfig(w http.ResponseWriter) error { @@ -403,6 +445,24 @@ func (p *Plugin) handleConfig(w http.ResponseWriter) error { return nil } +func (p *Plugin) checkAPIRateLimits(userID string) error { + p.apiLimitersMut.RLock() + limiter := p.apiLimiters[userID] + p.apiLimitersMut.RUnlock() + if limiter == nil { + limiter = rate.NewLimiter(1, 10) + p.apiLimitersMut.Lock() + p.apiLimiters[userID] = limiter + p.apiLimitersMut.Unlock() + } + + if !limiter.Allow() { + return fmt.Errorf(`{"message": "too many requests", "status_code": %d}`, http.StatusTooManyRequests) + } + + return nil +} + func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/version") { p.handleGetVersion(w, r) @@ -414,11 +474,17 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req return } - if r.Header.Get("Mattermost-User-Id") == "" { + userID := r.Header.Get("Mattermost-User-Id") + if userID == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } + if err := p.checkAPIRateLimits(userID); err != nil { + http.Error(w, err.Error(), http.StatusTooManyRequests) + return + } + if strings.HasPrefix(r.URL.Path, "/debug") { p.handleDebug(w, r) return @@ -437,6 +503,11 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req return } + if r.URL.Path == "/turn-credentials" { + p.handleGetTURNCredentials(w, r) + return + } + if matches := chRE.FindStringSubmatch(r.URL.Path); len(matches) == 2 { p.handleGetChannel(w, r, matches[1]) return diff --git a/server/configuration.go b/server/configuration.go index 78a37d160..53c68207d 100644 --- a/server/configuration.go +++ b/server/configuration.go @@ -36,12 +36,19 @@ type configuration struct { // The URL to a running RTCD service instance that should host the calls. // When set (non empty) all calls will be handled by the external service. RTCDServiceURL string + // The secret key used to generate TURN short-lived authentication credentials + TURNStaticAuthSecret string + // The number of minutes that the generated TURN credentials will be valid for. + TURNCredentialsExpirationMinutes *int + // When set to true it will pass and use configured TURN candidates to server + // initiated connections. + ServerSideTURN *bool clientConfig } type clientConfig struct { - // **DEPRECATED use ICEServersConfigs** A comma separated list of ICE servers URLs (STUN/TURN) to use. + // **DEPRECATED: use ICEServersConfigs** A comma separated list of ICE servers URLs (STUN/TURN) to use. ICEServers ICEServers // A list of ICE server configurations to use. @@ -54,6 +61,8 @@ type clientConfig struct { // The maximum number of participants that can join a call. The zero value // means unlimited. MaxCallParticipants *int + // Used to signal the client whether or not to generate TURN credentials. This is a client only option, generated server side. + NeedsTURNCredentials *bool } type ICEServers []string @@ -137,11 +146,12 @@ func (pr PortsRange) IsValid() error { func (c *configuration) getClientConfig() clientConfig { return clientConfig{ - AllowEnableCalls: c.AllowEnableCalls, - DefaultEnabled: c.DefaultEnabled, - ICEServers: c.ICEServers, - ICEServersConfigs: c.getICEServers(), - MaxCallParticipants: c.MaxCallParticipants, + AllowEnableCalls: c.AllowEnableCalls, + DefaultEnabled: c.DefaultEnabled, + ICEServers: c.ICEServers, + ICEServersConfigs: c.getICEServers(true), + MaxCallParticipants: c.MaxCallParticipants, + NeedsTURNCredentials: model.NewBool(c.TURNStaticAuthSecret != "" && len(c.ICEServersConfigs.getTURNConfigsForCredentials()) > 0), } } @@ -159,6 +169,12 @@ func (c *configuration) SetDefaults() { if c.MaxCallParticipants == nil { c.MaxCallParticipants = new(int) } + if c.TURNCredentialsExpirationMinutes == nil { + c.TURNCredentialsExpirationMinutes = model.NewInt(1440) + } + if c.ServerSideTURN == nil { + c.ServerSideTURN = new(bool) + } } func (c *configuration) IsValid() error { @@ -174,6 +190,10 @@ func (c *configuration) IsValid() error { return fmt.Errorf("MaxCallParticipants is not valid") } + if c.TURNCredentialsExpirationMinutes != nil && *c.TURNCredentialsExpirationMinutes < 0 { + return fmt.Errorf("TURNCredentialsExpirationMinutes is not valid") + } + return nil } @@ -183,6 +203,7 @@ func (c *configuration) Clone() *configuration { cfg.ICEHostOverride = c.ICEHostOverride cfg.RTCDServiceURL = c.RTCDServiceURL + cfg.TURNStaticAuthSecret = c.TURNStaticAuthSecret if c.UDPServerPort != nil { cfg.UDPServerPort = new(int) @@ -211,6 +232,14 @@ func (c *configuration) Clone() *configuration { cfg.MaxCallParticipants = model.NewInt(*c.MaxCallParticipants) } + if c.TURNCredentialsExpirationMinutes != nil { + cfg.TURNCredentialsExpirationMinutes = model.NewInt(*c.TURNCredentialsExpirationMinutes) + } + + if c.ServerSideTURN != nil { + cfg.ServerSideTURN = model.NewBool(*c.ServerSideTURN) + } + return &cfg } @@ -322,8 +351,16 @@ func (p *Plugin) isHAEnabled() bool { return cfg != nil && cfg.ClusterSettings.Enable != nil && *cfg.ClusterSettings.Enable } -func (c *configuration) getICEServers() ICEServersConfigs { - iceServers := c.ICEServersConfigs +func (c *configuration) getICEServers(forClient bool) ICEServersConfigs { + var iceServers ICEServersConfigs + + for _, cfg := range c.ICEServersConfigs { + if forClient && cfg.IsTURN() && cfg.Username == "" && cfg.Credential == "" { + continue + } + iceServers = append(iceServers, cfg) + } + if len(c.ICEServers) > 0 { iceServers = append(iceServers, rtc.ICEServerConfig{ URLs: c.ICEServers, @@ -332,3 +369,13 @@ func (c *configuration) getICEServers() ICEServersConfigs { return iceServers } + +func (cfgs ICEServersConfigs) getTURNConfigsForCredentials() []rtc.ICEServerConfig { + var configs []rtc.ICEServerConfig + for _, cfg := range cfgs { + if cfg.IsTURN() && cfg.Username == "" && cfg.Credential == "" { + configs = append(configs, cfg) + } + } + return configs +} diff --git a/server/main.go b/server/main.go index 8ad38adca..3ee772314 100644 --- a/server/main.go +++ b/server/main.go @@ -4,6 +4,8 @@ package main import ( + "golang.org/x/time/rate" + "github.com/mattermost/mattermost-plugin-calls/server/performance" "github.com/mattermost/mattermost-server/v6/model" @@ -26,5 +28,6 @@ func main() { clusterEvCh: make(chan model.PluginClusterEvent, clusterEventQueueSize), sessions: map[string]*session{}, metrics: performance.NewMetrics(), + apiLimiters: map[string]*rate.Limiter{}, }) } diff --git a/server/plugin.go b/server/plugin.go index 404ffc840..105acce49 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -8,6 +8,8 @@ import ( "sync" "time" + "golang.org/x/time/rate" + "github.com/mattermost/mattermost-plugin-calls/server/enterprise" "github.com/mattermost/mattermost-plugin-calls/server/performance" "github.com/mattermost/mattermost-plugin-calls/server/telemetry" @@ -42,6 +44,11 @@ type Plugin struct { rtcServer *rtc.Server rtcdManager *rtcdClientManager + + // A map of userID -> limiter to implement basic, user based API rate-limiting. + // TODO: consider moving this to a dedicated API object. + apiLimiters map[string]*rate.Limiter + apiLimitersMut sync.RWMutex } func (p *Plugin) startSession(us *session, senderID string) { diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index c5051c332..7e481036b 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -27,6 +27,7 @@ import { voiceChannelRootPost, allowEnableCalls, iceServers, + needsTURNCredentials, } from './selectors'; import {pluginId} from './manifest'; @@ -466,9 +467,20 @@ export default class Plugin { return; } + const iceConfigs = [...iceServers(store.getState())]; + if (needsTURNCredentials(store.getState())) { + logDebug('turn credentials needed'); + try { + const resp = await axios.get(`${getPluginPath()}/turn-credentials`); + iceConfigs.push(...resp.data); + } catch (err) { + logErr(err); + } + } + window.callsClient = new CallsClient({ wsURL: getWSConnectionURL(getConfig(store.getState())), - iceServers: iceServers(store.getState()), + iceServers: iceConfigs, }); const globalComponentID = registry.registerGlobalComponent(CallWidget); const rootComponentID = registry.registerRootComponent(ExpandedView); diff --git a/webapp/src/selectors.ts b/webapp/src/selectors.ts index 62a0afa51..278f5f018 100644 --- a/webapp/src/selectors.ts +++ b/webapp/src/selectors.ts @@ -95,7 +95,7 @@ const callsConfig = (state: GlobalState): CallsConfig => { export const iceServers: (state: GlobalState) => RTCIceServer[] = createSelector( 'iceServers', callsConfig, - (config) => config.ICEServersConfigs, + (config) => config.ICEServersConfigs || [], ); export const allowEnableCalls: (state: GlobalState) => boolean = createSelector( @@ -110,6 +110,12 @@ export const maxParticipants: (state: GlobalState) => number = createSelector( (config) => config.MaxCallParticipants, ); +export const needsTURNCredentials: (state: GlobalState) => boolean = createSelector( + 'maxParticipants', + callsConfig, + (config) => config.NeedsTURNCredentials, +); + export const isLimitRestricted: (state: GlobalState) => boolean = createSelector( 'isLimitRestricted', numCurrentVoiceConnectedUsers, diff --git a/webapp/src/types/types.ts b/webapp/src/types/types.ts index cd5b151b9..1c632f8fa 100644 --- a/webapp/src/types/types.ts +++ b/webapp/src/types/types.ts @@ -60,6 +60,7 @@ export type CallsConfig = { AllowEnableCalls: boolean, DefaultEnabled: boolean, MaxCallParticipants: number, + NeedsTURNCredentials: boolean, sku_short_name: string, } @@ -69,6 +70,7 @@ export const CallsConfigDefault = { AllowEnableCalls: false, DefaultEnabled: false, MaxCallParticipants: 0, + NeedsTURNCredentials: false, sku_short_name: '', } as CallsConfig;