github.com/livekit/protocol@v1.39.3/webhook/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  	"fmt"
    23  	"sync"
    24  	"time"
    25  
    26  	"github.com/frostbyte73/core"
    27  	"github.com/hashicorp/go-retryablehttp"
    28  	"go.uber.org/atomic"
    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  )
    35  
    36  const (
    37  	numWorkers       = 10
    38  	defaultQueueSize = 100
    39  )
    40  
    41  type URLNotifierConfig struct {
    42  	NumWorkers int `yaml:"num_workers,omitempty"`
    43  	QueueSize  int `yaml:"queue_size,omitempty"`
    44  }
    45  
    46  var DefaultURLNotifierConfig = URLNotifierConfig{
    47  	NumWorkers: 10,
    48  	QueueSize:  100,
    49  }
    50  
    51  type URLNotifierParams struct {
    52  	HTTPClientParams
    53  	Logger     logger.Logger
    54  	Config     URLNotifierConfig
    55  	URL        string
    56  	APIKey     string
    57  	APISecret  string
    58  	FieldsHook func(whi *livekit.WebhookInfo)
    59  	FilterParams
    60  }
    61  
    62  // URLNotifier is a QueuedNotifier that sends a POST request to a Webhook URL.
    63  // It will retry on failure, and will drop events if notification fall too far behind
    64  type URLNotifier struct {
    65  	mu            sync.RWMutex
    66  	params        URLNotifierParams
    67  	client        *retryablehttp.Client
    68  	dropped       atomic.Int32
    69  	pool          core.QueuePool
    70  	processedHook func(ctx context.Context, whi *livekit.WebhookInfo)
    71  	filter        *filter
    72  }
    73  
    74  func NewURLNotifier(params URLNotifierParams) *URLNotifier {
    75  	if params.Config.NumWorkers == 0 {
    76  		params.Config.NumWorkers = DefaultURLNotifierConfig.NumWorkers
    77  	}
    78  	if params.Config.QueueSize == 0 {
    79  		params.Config.QueueSize = DefaultURLNotifierConfig.QueueSize
    80  	}
    81  	if params.Logger == nil {
    82  		params.Logger = logger.GetLogger()
    83  	}
    84  
    85  	rhc := retryablehttp.NewClient()
    86  	if params.RetryWaitMin > 0 {
    87  		rhc.RetryWaitMin = params.RetryWaitMin
    88  	}
    89  	if params.RetryWaitMax > 0 {
    90  		rhc.RetryWaitMax = params.RetryWaitMax
    91  	}
    92  	if params.MaxRetries > 0 {
    93  		rhc.RetryMax = params.MaxRetries
    94  	}
    95  	if params.ClientTimeout > 0 {
    96  		rhc.HTTPClient.Timeout = params.ClientTimeout
    97  	}
    98  	n := &URLNotifier{
    99  		params: params,
   100  		client: rhc,
   101  		filter: newFilter(params.FilterParams),
   102  	}
   103  	n.client.Logger = &logAdapter{}
   104  
   105  	n.pool = core.NewQueuePool(params.Config.NumWorkers, core.QueueWorkerParams{
   106  		QueueSize:    params.Config.QueueSize,
   107  		DropWhenFull: true,
   108  	})
   109  	return n
   110  }
   111  
   112  func (n *URLNotifier) SetKeys(apiKey, apiSecret string) {
   113  	n.mu.Lock()
   114  	defer n.mu.Unlock()
   115  	n.params.APIKey = apiKey
   116  	n.params.APISecret = apiSecret
   117  }
   118  
   119  func (n *URLNotifier) SetFilter(params FilterParams) {
   120  	n.mu.Lock()
   121  	defer n.mu.Unlock()
   122  	n.filter.SetFilter(params)
   123  }
   124  
   125  func (n *URLNotifier) RegisterProcessedHook(hook func(ctx context.Context, whi *livekit.WebhookInfo)) {
   126  	n.mu.Lock()
   127  	defer n.mu.Unlock()
   128  	n.processedHook = hook
   129  }
   130  
   131  func (n *URLNotifier) getProcessedHook() func(ctx context.Context, whi *livekit.WebhookInfo) {
   132  	n.mu.RLock()
   133  	defer n.mu.RUnlock()
   134  	return n.processedHook
   135  }
   136  
   137  func (n *URLNotifier) QueueNotify(ctx context.Context, event *livekit.WebhookEvent, opts ...NotifyOption) error {
   138  	if !n.filter.IsAllowed(event.Event) {
   139  		return nil
   140  	}
   141  
   142  	enqueuedAt := time.Now()
   143  
   144  	key := eventKey(event)
   145  
   146  	p := &NotifyParams{}
   147  	for _, o := range opts {
   148  		o(p)
   149  	}
   150  
   151  	n.mu.RLock()
   152  	params := n.params
   153  	n.mu.RUnlock()
   154  
   155  	if len(p.ExtraWebhooks) > 1 {
   156  		return fmt.Errorf("more than 1 extra webhook url unexpected")
   157  	}
   158  	if len(p.ExtraWebhooks) == 1 {
   159  		params.URL = p.ExtraWebhooks[0].Url
   160  		if p.ExtraWebhooks[0].SigningKey != "" {
   161  			params.APIKey = p.ExtraWebhooks[0].SigningKey
   162  		}
   163  	}
   164  
   165  	if p.Secret != "" {
   166  		params.APISecret = p.Secret
   167  	}
   168  
   169  	if params.APIKey == "" || params.APISecret == "" {
   170  		return errNoKey
   171  	}
   172  
   173  	if !n.pool.Submit(key, func() {
   174  		fields := logFields(event, params.URL)
   175  
   176  		queueDuration := time.Since(enqueuedAt)
   177  		fields = append(fields, "queueDuration", queueDuration)
   178  
   179  		sendStart := time.Now()
   180  		err := n.send(event, &params)
   181  		sendDuration := time.Since(sendStart)
   182  		fields = append(fields, "sendDuration", sendDuration)
   183  		if err != nil {
   184  			params.Logger.Warnw("failed to send webhook", err, fields...)
   185  			n.dropped.Add(event.NumDropped + 1)
   186  			IncDispatchFailure()
   187  		} else {
   188  			params.Logger.Infow("sent webhook", fields...)
   189  			IncDispatchSuccess()
   190  		}
   191  		if ph := n.getProcessedHook(); ph != nil {
   192  			whi := webhookInfo(
   193  				event,
   194  				enqueuedAt,
   195  				queueDuration,
   196  				sendStart,
   197  				sendDuration,
   198  				params.URL,
   199  				false,
   200  				err,
   201  			)
   202  			if params.FieldsHook != nil {
   203  				params.FieldsHook(whi)
   204  			}
   205  			ph(ctx, whi)
   206  		}
   207  	}) {
   208  		n.dropped.Inc()
   209  
   210  		fields := logFields(event, params.URL)
   211  		params.Logger.Infow("dropped webhook", fields...)
   212  		IncDispatchDrop("overflow")
   213  
   214  		if ph := n.getProcessedHook(); ph != nil {
   215  			whi := webhookInfo(
   216  				event,
   217  				time.Time{},
   218  				0,
   219  				time.Time{},
   220  				0,
   221  				params.URL,
   222  				true,
   223  				nil,
   224  			)
   225  			if params.FieldsHook != nil {
   226  				params.FieldsHook(whi)
   227  			}
   228  			ph(ctx, whi)
   229  		}
   230  	}
   231  	return nil
   232  }
   233  
   234  func (n *URLNotifier) Stop(force bool) {
   235  	if force {
   236  		n.pool.Kill()
   237  	} else {
   238  		n.pool.Drain()
   239  	}
   240  }
   241  
   242  func (n *URLNotifier) send(event *livekit.WebhookEvent, params *URLNotifierParams) error {
   243  	// set dropped count
   244  	event.NumDropped = n.dropped.Swap(0)
   245  	encoded, err := protojson.Marshal(event)
   246  	if err != nil {
   247  		return err
   248  	}
   249  	// sign payload
   250  	sum := sha256.Sum256(encoded)
   251  	b64 := base64.StdEncoding.EncodeToString(sum[:])
   252  
   253  	at := auth.NewAccessToken(params.APIKey, params.APISecret).
   254  		SetValidFor(5 * time.Minute).
   255  		SetSha256(b64)
   256  	token, err := at.ToJWT()
   257  	if err != nil {
   258  		return err
   259  	}
   260  	r, err := retryablehttp.NewRequest("POST", params.URL, bytes.NewReader(encoded))
   261  	if err != nil {
   262  		// ignore and continue
   263  		return err
   264  	}
   265  	r.Header.Set(authHeader, token)
   266  	// use a custom mime type to ensure signature is checked prior to parsing
   267  	r.Header.Set("content-type", "application/webhook+json")
   268  	res, err := n.client.Do(r)
   269  	if err != nil {
   270  		return err
   271  	}
   272  	_ = res.Body.Close()
   273  	return nil
   274  }