github.com/livekit/protocol@v1.39.3/webhook/resource_url_notifier.go (about)

     1  // Copyright 2023 LiveKit, Inc.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package webhook
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"crypto/sha256"
    21  	"encoding/base64"
    22  	"errors"
    23  	"fmt"
    24  	"sync"
    25  	"time"
    26  
    27  	"github.com/frostbyte73/core"
    28  	"github.com/hashicorp/go-retryablehttp"
    29  	"google.golang.org/protobuf/encoding/protojson"
    30  
    31  	"github.com/livekit/protocol/auth"
    32  	"github.com/livekit/protocol/livekit"
    33  	"github.com/livekit/protocol/logger"
    34  	"github.com/livekit/protocol/utils"
    35  )
    36  
    37  const (
    38  	defaultTimeout = 5 * time.Minute
    39  )
    40  
    41  var (
    42  	errClosed = errors.New("notifier is closed")
    43  	errNoKey  = errors.New("no singing key or secret was provided")
    44  )
    45  
    46  type ResourceURLNotifierConfig struct {
    47  	MaxAge   time.Duration `yaml:"max_age,omitempty"`
    48  	MaxDepth int           `yaml:"max_depth,omitempty"`
    49  }
    50  
    51  var DefaultResourceURLNotifierConfig = ResourceURLNotifierConfig{
    52  	MaxAge:   30 * time.Second,
    53  	MaxDepth: 200,
    54  }
    55  
    56  type poster interface {
    57  	Process(
    58  		ctx context.Context,
    59  		queuedAt time.Time,
    60  		event *livekit.WebhookEvent,
    61  		params *ResourceURLNotifierParams,
    62  		qLen int,
    63  	)
    64  }
    65  
    66  type resourceQueueInfo struct {
    67  	*resourceQueue
    68  	key string
    69  	tqi *utils.TimeoutQueueItem[*resourceQueueInfo]
    70  }
    71  
    72  type ResourceURLNotifierParams struct {
    73  	HTTPClientParams
    74  	Logger     logger.Logger
    75  	Timeout    time.Duration
    76  	Config     ResourceURLNotifierConfig
    77  	URL        string
    78  	APIKey     string
    79  	APISecret  string
    80  	FieldsHook func(whi *livekit.WebhookInfo)
    81  	FilterParams
    82  }
    83  
    84  // ResourceURLNotifier is a QueuedNotifier that sends a POST request to a Webhook URL.
    85  // It queues up events per resource (could be egress, ingress, room, participant, track, etc.)
    86  // to avoid blocking events of one resource blocking another resource's event(s).
    87  // It will retry on failure, and will drop events if notification fall too far behind,
    88  // either in age or queue depth.
    89  type ResourceURLNotifier struct {
    90  	mu            sync.RWMutex
    91  	params        ResourceURLNotifierParams
    92  	client        *retryablehttp.Client
    93  	processedHook func(ctx context.Context, whi *livekit.WebhookInfo)
    94  
    95  	resourceQueues            map[string]*resourceQueueInfo
    96  	resourceQueueTimeoutQueue utils.TimeoutQueue[*resourceQueueInfo]
    97  
    98  	filter *filter
    99  
   100  	closed core.Fuse
   101  }
   102  
   103  func NewResourceURLNotifier(params ResourceURLNotifierParams) *ResourceURLNotifier {
   104  	if params.Logger == nil {
   105  		params.Logger = logger.GetLogger()
   106  	}
   107  
   108  	if params.Timeout == 0 {
   109  		params.Timeout = defaultTimeout
   110  	}
   111  	if params.Config.MaxAge == 0 {
   112  		params.Config.MaxAge = DefaultResourceURLNotifierConfig.MaxAge
   113  	}
   114  	if params.Config.MaxDepth == 0 {
   115  		params.Config.MaxDepth = DefaultResourceURLNotifierConfig.MaxDepth
   116  	}
   117  
   118  	rhc := retryablehttp.NewClient()
   119  	if params.RetryWaitMin > 0 {
   120  		rhc.RetryWaitMin = params.RetryWaitMin
   121  	}
   122  	if params.RetryWaitMax > 0 {
   123  		rhc.RetryWaitMax = params.RetryWaitMax
   124  	}
   125  	if params.MaxRetries > 0 {
   126  		rhc.RetryMax = params.MaxRetries
   127  	}
   128  	if params.ClientTimeout > 0 {
   129  		rhc.HTTPClient.Timeout = params.ClientTimeout
   130  	}
   131  	rhc.Logger = &logAdapter{}
   132  	r := &ResourceURLNotifier{
   133  		params:         params,
   134  		client:         rhc,
   135  		resourceQueues: make(map[string]*resourceQueueInfo),
   136  		filter:         newFilter(params.FilterParams),
   137  	}
   138  
   139  	go r.sweeper()
   140  	return r
   141  }
   142  
   143  func (r *ResourceURLNotifier) SetKeys(apiKey, apiSecret string) {
   144  	r.mu.Lock()
   145  	defer r.mu.Unlock()
   146  
   147  	r.params.APIKey = apiKey
   148  	r.params.APISecret = apiSecret
   149  }
   150  
   151  func (r *ResourceURLNotifier) SetFilter(params FilterParams) {
   152  	r.mu.Lock()
   153  	defer r.mu.Unlock()
   154  
   155  	r.filter.SetFilter(params)
   156  }
   157  
   158  func (r *ResourceURLNotifier) RegisterProcessedHook(hook func(ctx context.Context, whi *livekit.WebhookInfo)) {
   159  	r.mu.Lock()
   160  	defer r.mu.Unlock()
   161  	r.processedHook = hook
   162  }
   163  
   164  func (r *ResourceURLNotifier) getProcessedHook() func(ctx context.Context, whi *livekit.WebhookInfo) {
   165  	r.mu.RLock()
   166  	defer r.mu.RUnlock()
   167  	return r.processedHook
   168  }
   169  
   170  func (r *ResourceURLNotifier) QueueNotify(ctx context.Context, event *livekit.WebhookEvent, opts ...NotifyOption) error {
   171  	if !r.filter.IsAllowed(event.Event) {
   172  		return nil
   173  	}
   174  
   175  	if r.closed.IsBroken() {
   176  		return errClosed
   177  	}
   178  
   179  	key := eventKey(event)
   180  
   181  	p := &NotifyParams{}
   182  	for _, o := range opts {
   183  		o(p)
   184  	}
   185  
   186  	r.mu.Lock()
   187  	// copy the parameters
   188  	params := r.params
   189  	if len(p.ExtraWebhooks) > 1 {
   190  		return fmt.Errorf("more than 1 extra webhook url unexpected")
   191  	}
   192  	if len(p.ExtraWebhooks) == 1 {
   193  		params.URL = p.ExtraWebhooks[0].Url
   194  		if p.ExtraWebhooks[0].SigningKey != "" {
   195  			params.APIKey = p.ExtraWebhooks[0].SigningKey
   196  		}
   197  	}
   198  
   199  	if p.Secret != "" {
   200  		params.APISecret = p.Secret
   201  	}
   202  
   203  	if params.APIKey == "" || params.APISecret == "" {
   204  		return errNoKey
   205  	}
   206  
   207  	rqi := r.resourceQueues[key]
   208  	if rqi == nil || !r.resourceQueueTimeoutQueue.Reset(rqi.tqi) {
   209  		rq := newResourceQueue(resourceQueueParams{
   210  			MaxDepth: params.Config.MaxDepth,
   211  			Poster:   r,
   212  		})
   213  		rqi = &resourceQueueInfo{resourceQueue: rq, key: key}
   214  		rqi.tqi = &utils.TimeoutQueueItem[*resourceQueueInfo]{Value: rqi}
   215  		r.resourceQueueTimeoutQueue.Reset(rqi.tqi)
   216  		r.resourceQueues[key] = rqi
   217  	}
   218  	r.mu.Unlock()
   219  
   220  	qLen, err := rqi.resourceQueue.Enqueue(ctx, event, &params)
   221  	if err != nil {
   222  		fields := logFields(event, params.URL)
   223  		fields = append(fields, "reason", err)
   224  		params.Logger.Infow("dropped webhook", fields...)
   225  		IncDispatchDrop(err.Error())
   226  
   227  		if ph := r.getProcessedHook(); ph != nil {
   228  			whi := webhookInfo(
   229  				event,
   230  				time.Time{},
   231  				0,
   232  				time.Time{},
   233  				0,
   234  				params.URL,
   235  				true,
   236  				nil,
   237  			)
   238  			if params.FieldsHook != nil {
   239  				params.FieldsHook(whi)
   240  			}
   241  			ph(ctx, whi)
   242  		}
   243  	} else {
   244  		RecordQueueLength(qLen)
   245  	}
   246  	return err
   247  }
   248  
   249  func (r *ResourceURLNotifier) Stop(force bool) {
   250  	r.closed.Break()
   251  
   252  	r.mu.Lock()
   253  	resourceQueues := r.resourceQueues
   254  	r.resourceQueues = make(map[string]*resourceQueueInfo)
   255  	r.mu.Unlock()
   256  
   257  	for _, rq := range resourceQueues {
   258  		rq.Stop(force)
   259  	}
   260  }
   261  
   262  // poster interface
   263  func (r *ResourceURLNotifier) Process(
   264  	ctx context.Context,
   265  	queuedAt time.Time,
   266  	event *livekit.WebhookEvent,
   267  	params *ResourceURLNotifierParams,
   268  	qLen int,
   269  ) {
   270  	fields := logFields(event, params.URL)
   271  
   272  	queueDuration := time.Since(queuedAt)
   273  	fields = append(fields, "queueDuration", queueDuration, "qLen", qLen)
   274  
   275  	if queueDuration > params.Config.MaxAge {
   276  		fields = append(fields, "reason", "age")
   277  		params.Logger.Infow("dropped webhook", fields...)
   278  		IncDispatchDrop("age")
   279  
   280  		if ph := r.getProcessedHook(); ph != nil {
   281  			whi := webhookInfo(
   282  				event,
   283  				queuedAt,
   284  				queueDuration,
   285  				time.Time{},
   286  				0,
   287  				params.URL,
   288  				true,
   289  				nil,
   290  			)
   291  			if params.FieldsHook != nil {
   292  				params.FieldsHook(whi)
   293  			}
   294  			ph(ctx, whi)
   295  		}
   296  		return
   297  	}
   298  
   299  	sendStart := time.Now()
   300  	err := r.send(event, params)
   301  	sendDuration := time.Since(sendStart)
   302  	fields = append(fields, "sendDuration", sendDuration)
   303  	if err != nil {
   304  		params.Logger.Warnw("failed to send webhook", err, fields...)
   305  		IncDispatchFailure()
   306  	} else {
   307  		params.Logger.Infow("sent webhook", fields...)
   308  		IncDispatchSuccess()
   309  	}
   310  	if ph := r.getProcessedHook(); ph != nil {
   311  		whi := webhookInfo(
   312  			event,
   313  			queuedAt,
   314  			queueDuration,
   315  			sendStart,
   316  			sendDuration,
   317  			params.URL,
   318  			false,
   319  			err,
   320  		)
   321  		if params.FieldsHook != nil {
   322  			params.FieldsHook(whi)
   323  		}
   324  		ph(ctx, whi)
   325  	}
   326  }
   327  
   328  func (r *ResourceURLNotifier) send(event *livekit.WebhookEvent, params *ResourceURLNotifierParams) error {
   329  	encoded, err := protojson.Marshal(event)
   330  	if err != nil {
   331  		return err
   332  	}
   333  	// sign payload
   334  	sum := sha256.Sum256(encoded)
   335  	b64 := base64.StdEncoding.EncodeToString(sum[:])
   336  
   337  	apiKey := params.APIKey
   338  	apiSecret := params.APISecret
   339  
   340  	at := auth.NewAccessToken(apiKey, apiSecret).
   341  		SetValidFor(5 * time.Minute).
   342  		SetSha256(b64)
   343  	token, err := at.ToJWT()
   344  	if err != nil {
   345  		return err
   346  	}
   347  	req, err := retryablehttp.NewRequest("POST", params.URL, bytes.NewReader(encoded))
   348  	if err != nil {
   349  		// ignore and continue
   350  		return err
   351  	}
   352  	req.Header.Set(authHeader, token)
   353  	// use a custom mime type to ensure signature is checked prior to parsing
   354  	req.Header.Set("content-type", "application/webhook+json")
   355  	res, err := r.client.Do(req)
   356  	if err != nil {
   357  		return err
   358  	}
   359  	_ = res.Body.Close()
   360  	return nil
   361  }
   362  
   363  func (r *ResourceURLNotifier) sweeper() {
   364  	ticker := time.NewTicker(r.params.Timeout / 2)
   365  	defer ticker.Stop()
   366  
   367  	for {
   368  		select {
   369  		case <-r.closed.Watch():
   370  			return
   371  
   372  		case <-ticker.C:
   373  			for it := r.resourceQueueTimeoutQueue.IterateRemoveAfter(r.params.Timeout); it.Next(); {
   374  				rqi := it.Item().Value
   375  
   376  				r.mu.Lock()
   377  				if r.resourceQueues[rqi.key] == rqi {
   378  					delete(r.resourceQueues, rqi.key)
   379  				}
   380  				r.mu.Unlock()
   381  
   382  				rqi.Stop(false)
   383  			}
   384  		}
   385  	}
   386  }