github.com/minio/minio@v0.0.0-20240328213742-3f72439b8a27/internal/event/target/webhook.go (about) 1 // Copyright (c) 2015-2023 MinIO, Inc. 2 // 3 // This file is part of MinIO Object Storage stack 4 // 5 // This program is free software: you can redistribute it and/or modify 6 // it under the terms of the GNU Affero General Public License as published by 7 // the Free Software Foundation, either version 3 of the License, or 8 // (at your option) any later version. 9 // 10 // This program is distributed in the hope that it will be useful 11 // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 // GNU Affero General Public License for more details. 14 // 15 // You should have received a copy of the GNU Affero General Public License 16 // along with this program. If not, see <http://www.gnu.org/licenses/>. 17 18 package target 19 20 import ( 21 "bytes" 22 "context" 23 "crypto/tls" 24 "encoding/json" 25 "errors" 26 "fmt" 27 "net" 28 "net/http" 29 "net/url" 30 "os" 31 "path/filepath" 32 "strings" 33 "syscall" 34 "time" 35 36 "github.com/minio/minio/internal/event" 37 xhttp "github.com/minio/minio/internal/http" 38 "github.com/minio/minio/internal/logger" 39 "github.com/minio/minio/internal/once" 40 "github.com/minio/minio/internal/store" 41 "github.com/minio/pkg/v2/certs" 42 xnet "github.com/minio/pkg/v2/net" 43 ) 44 45 // Webhook constants 46 const ( 47 WebhookEndpoint = "endpoint" 48 WebhookAuthToken = "auth_token" 49 WebhookQueueDir = "queue_dir" 50 WebhookQueueLimit = "queue_limit" 51 WebhookClientCert = "client_cert" 52 WebhookClientKey = "client_key" 53 54 EnvWebhookEnable = "MINIO_NOTIFY_WEBHOOK_ENABLE" 55 EnvWebhookEndpoint = "MINIO_NOTIFY_WEBHOOK_ENDPOINT" 56 EnvWebhookAuthToken = "MINIO_NOTIFY_WEBHOOK_AUTH_TOKEN" 57 EnvWebhookQueueDir = "MINIO_NOTIFY_WEBHOOK_QUEUE_DIR" 58 EnvWebhookQueueLimit = "MINIO_NOTIFY_WEBHOOK_QUEUE_LIMIT" 59 EnvWebhookClientCert = "MINIO_NOTIFY_WEBHOOK_CLIENT_CERT" 60 EnvWebhookClientKey = "MINIO_NOTIFY_WEBHOOK_CLIENT_KEY" 61 ) 62 63 // WebhookArgs - Webhook target arguments. 64 type WebhookArgs struct { 65 Enable bool `json:"enable"` 66 Endpoint xnet.URL `json:"endpoint"` 67 AuthToken string `json:"authToken"` 68 Transport *http.Transport `json:"-"` 69 QueueDir string `json:"queueDir"` 70 QueueLimit uint64 `json:"queueLimit"` 71 ClientCert string `json:"clientCert"` 72 ClientKey string `json:"clientKey"` 73 } 74 75 // Validate WebhookArgs fields 76 func (w WebhookArgs) Validate() error { 77 if !w.Enable { 78 return nil 79 } 80 if w.Endpoint.IsEmpty() { 81 return errors.New("endpoint empty") 82 } 83 if w.QueueDir != "" { 84 if !filepath.IsAbs(w.QueueDir) { 85 return errors.New("queueDir path should be absolute") 86 } 87 } 88 if w.ClientCert != "" && w.ClientKey == "" || w.ClientCert == "" && w.ClientKey != "" { 89 return errors.New("cert and key must be specified as a pair") 90 } 91 return nil 92 } 93 94 // WebhookTarget - Webhook target. 95 type WebhookTarget struct { 96 initOnce once.Init 97 98 id event.TargetID 99 args WebhookArgs 100 transport *http.Transport 101 httpClient *http.Client 102 store store.Store[event.Event] 103 loggerOnce logger.LogOnce 104 cancel context.CancelFunc 105 cancelCh <-chan struct{} 106 107 addr string // full address ip/dns with a port number, e.g. x.x.x.x:8080 108 } 109 110 // ID - returns target ID. 111 func (target *WebhookTarget) ID() event.TargetID { 112 return target.id 113 } 114 115 // Name - returns the Name of the target. 116 func (target *WebhookTarget) Name() string { 117 return target.ID().String() 118 } 119 120 // IsActive - Return true if target is up and active 121 func (target *WebhookTarget) IsActive() (bool, error) { 122 if err := target.init(); err != nil { 123 return false, err 124 } 125 return target.isActive() 126 } 127 128 // Store returns any underlying store if set. 129 func (target *WebhookTarget) Store() event.TargetStore { 130 return target.store 131 } 132 133 func (target *WebhookTarget) isActive() (bool, error) { 134 conn, err := net.DialTimeout("tcp", target.addr, 5*time.Second) 135 if err != nil { 136 if xnet.IsNetworkOrHostDown(err, false) { 137 return false, store.ErrNotConnected 138 } 139 return false, err 140 } 141 defer conn.Close() 142 return true, nil 143 } 144 145 // Save - saves the events to the store if queuestore is configured, 146 // which will be replayed when the webhook connection is active. 147 func (target *WebhookTarget) Save(eventData event.Event) error { 148 if target.store != nil { 149 return target.store.Put(eventData) 150 } 151 if err := target.init(); err != nil { 152 return err 153 } 154 err := target.send(eventData) 155 if err != nil { 156 if xnet.IsNetworkOrHostDown(err, false) { 157 return store.ErrNotConnected 158 } 159 } 160 return err 161 } 162 163 // send - sends an event to the webhook. 164 func (target *WebhookTarget) send(eventData event.Event) error { 165 objectName, err := url.QueryUnescape(eventData.S3.Object.Key) 166 if err != nil { 167 return err 168 } 169 key := eventData.S3.Bucket.Name + "/" + objectName 170 171 data, err := json.Marshal(event.Log{EventName: eventData.EventName, Key: key, Records: []event.Event{eventData}}) 172 if err != nil { 173 return err 174 } 175 176 req, err := http.NewRequest(http.MethodPost, target.args.Endpoint.String(), bytes.NewReader(data)) 177 if err != nil { 178 return err 179 } 180 181 // Verify if the authToken already contains 182 // <Key> <Token> like format, if this is 183 // already present we can blindly use the 184 // authToken as is instead of adding 'Bearer' 185 tokens := strings.Fields(target.args.AuthToken) 186 switch len(tokens) { 187 case 2: 188 req.Header.Set("Authorization", target.args.AuthToken) 189 case 1: 190 req.Header.Set("Authorization", "Bearer "+target.args.AuthToken) 191 } 192 193 req.Header.Set("Content-Type", "application/json") 194 195 resp, err := target.httpClient.Do(req) 196 if err != nil { 197 return err 198 } 199 defer xhttp.DrainBody(resp.Body) 200 201 if resp.StatusCode < 200 || resp.StatusCode > 299 { 202 return fmt.Errorf("sending event failed with %v", resp.Status) 203 } 204 205 return nil 206 } 207 208 // SendFromStore - reads an event from store and sends it to webhook. 209 func (target *WebhookTarget) SendFromStore(key store.Key) error { 210 if err := target.init(); err != nil { 211 return err 212 } 213 214 eventData, eErr := target.store.Get(key.Name) 215 if eErr != nil { 216 // The last event key in a successful batch will be sent in the channel atmost once by the replayEvents() 217 // Such events will not exist and would've been already been sent successfully. 218 if os.IsNotExist(eErr) { 219 return nil 220 } 221 return eErr 222 } 223 224 if err := target.send(eventData); err != nil { 225 if xnet.IsNetworkOrHostDown(err, false) { 226 return store.ErrNotConnected 227 } 228 return err 229 } 230 231 // Delete the event from store. 232 return target.store.Del(key.Name) 233 } 234 235 // Close - does nothing and available for interface compatibility. 236 func (target *WebhookTarget) Close() error { 237 target.cancel() 238 return nil 239 } 240 241 func (target *WebhookTarget) init() error { 242 return target.initOnce.Do(target.initWebhook) 243 } 244 245 // Only called from init() 246 func (target *WebhookTarget) initWebhook() error { 247 args := target.args 248 transport := target.transport 249 250 if args.ClientCert != "" && args.ClientKey != "" { 251 manager, err := certs.NewManager(context.Background(), args.ClientCert, args.ClientKey, tls.LoadX509KeyPair) 252 if err != nil { 253 return err 254 } 255 manager.ReloadOnSignal(syscall.SIGHUP) // allow reloads upon SIGHUP 256 transport.TLSClientConfig.GetClientCertificate = manager.GetClientCertificate 257 } 258 target.httpClient = &http.Client{Transport: transport} 259 260 yes, err := target.isActive() 261 if err != nil { 262 return err 263 } 264 if !yes { 265 return store.ErrNotConnected 266 } 267 268 return nil 269 } 270 271 // NewWebhookTarget - creates new Webhook target. 272 func NewWebhookTarget(ctx context.Context, id string, args WebhookArgs, loggerOnce logger.LogOnce, transport *http.Transport) (*WebhookTarget, error) { 273 ctx, cancel := context.WithCancel(ctx) 274 275 var queueStore store.Store[event.Event] 276 if args.QueueDir != "" { 277 queueDir := filepath.Join(args.QueueDir, storePrefix+"-webhook-"+id) 278 queueStore = store.NewQueueStore[event.Event](queueDir, args.QueueLimit, event.StoreExtension) 279 if err := queueStore.Open(); err != nil { 280 cancel() 281 return nil, fmt.Errorf("unable to initialize the queue store of Webhook `%s`: %w", id, err) 282 } 283 } 284 285 target := &WebhookTarget{ 286 id: event.TargetID{ID: id, Name: "webhook"}, 287 args: args, 288 loggerOnce: loggerOnce, 289 transport: transport, 290 store: queueStore, 291 cancel: cancel, 292 cancelCh: ctx.Done(), 293 } 294 295 // Calculate the webhook addr with the port number format 296 target.addr = args.Endpoint.Host 297 if _, _, err := net.SplitHostPort(args.Endpoint.Host); err != nil && strings.Contains(err.Error(), "missing port in address") { 298 switch strings.ToLower(args.Endpoint.Scheme) { 299 case "http": 300 target.addr += ":80" 301 case "https": 302 target.addr += ":443" 303 default: 304 return nil, errors.New("unsupported scheme") 305 } 306 } 307 308 if target.store != nil { 309 store.StreamItems(target.store, target, target.cancelCh, target.loggerOnce) 310 } 311 312 return target, nil 313 }