Skip to content

Commit

Permalink
[MM-45001] Add support for generating short-lived TURN credentials (#116
Browse files Browse the repository at this point in the history
)

* Add support for generating short-lived TURN credentials

* Update to latest specs
  • Loading branch information
streamer45 authored Jul 5, 2022
1 parent 64897bb commit e7e6825
Show file tree
Hide file tree
Showing 9 changed files with 190 additions and 14 deletions.
21 changes: 21 additions & 0 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 10 additions & 3 deletions server/activate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 72 additions & 1 deletion server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
63 changes: 55 additions & 8 deletions server/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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),
}
}

Expand All @@ -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 {
Expand All @@ -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
}

Expand All @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand All @@ -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
}
3 changes: 3 additions & 0 deletions server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -26,5 +28,6 @@ func main() {
clusterEvCh: make(chan model.PluginClusterEvent, clusterEventQueueSize),
sessions: map[string]*session{},
metrics: performance.NewMetrics(),
apiLimiters: map[string]*rate.Limiter{},
})
}
7 changes: 7 additions & 0 deletions server/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 13 additions & 1 deletion webapp/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
voiceChannelRootPost,
allowEnableCalls,
iceServers,
needsTURNCredentials,
} from './selectors';

import {pluginId} from './manifest';
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion webapp/src/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions webapp/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type CallsConfig = {
AllowEnableCalls: boolean,
DefaultEnabled: boolean,
MaxCallParticipants: number,
NeedsTURNCredentials: boolean,
sku_short_name: string,
}

Expand All @@ -69,6 +70,7 @@ export const CallsConfigDefault = {
AllowEnableCalls: false,
DefaultEnabled: false,
MaxCallParticipants: 0,
NeedsTURNCredentials: false,
sku_short_name: '',
} as CallsConfig;

Expand Down

0 comments on commit e7e6825

Please sign in to comment.