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