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  }