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, ¶ms) 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 }