github.com/cs3org/reva/v2@v2.27.7/pkg/eosclient/eosgrpc/eoshttp.go (about)

     1  // Copyright 2018-2021 CERN
     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  // In applying this license, CERN does not waive the privileges and immunities
    16  // granted to it by virtue of its status as an Intergovernmental Organization
    17  // or submit itself to any jurisdiction.
    18  
    19  package eosgrpc
    20  
    21  import (
    22  	"bytes"
    23  	"context"
    24  	"crypto/tls"
    25  	"fmt"
    26  	"io"
    27  	"net/http"
    28  	"net/url"
    29  	"os"
    30  	"strconv"
    31  	"time"
    32  
    33  	"github.com/cs3org/reva/v2/pkg/appctx"
    34  	"github.com/cs3org/reva/v2/pkg/eosclient"
    35  	"github.com/cs3org/reva/v2/pkg/errtypes"
    36  	"github.com/cs3org/reva/v2/pkg/logger"
    37  )
    38  
    39  // HTTPOptions to configure the Client.
    40  type HTTPOptions struct {
    41  
    42  	// HTTP URL of the EOS MGM.
    43  	// Default is https://eos-example.org
    44  	BaseURL string
    45  
    46  	// Timeout in seconds for connecting to the service
    47  	ConnectTimeout int
    48  
    49  	// Timeout in seconds for sending a request to the service and getting a response
    50  	// Does not include redirections
    51  	RWTimeout int
    52  
    53  	// Timeout in seconds for performing an operation. Includes every redirection, retry, etc
    54  	OpTimeout int
    55  
    56  	// Max idle conns per Transport
    57  	MaxIdleConns int
    58  
    59  	// Max conns per transport per destination host
    60  	MaxConnsPerHost int
    61  
    62  	// Max idle conns per transport per destination host
    63  	MaxIdleConnsPerHost int
    64  
    65  	// TTL for an idle conn per transport
    66  	IdleConnTimeout int
    67  
    68  	// If the URL is https, then we need to configure this client
    69  	// with the usual TLS stuff
    70  	// Defaults are /etc/grid-security/hostcert.pem and /etc/grid-security/hostkey.pem
    71  	ClientCertFile string
    72  	ClientKeyFile  string
    73  
    74  	// These will override the defaults, which are common system paths hardcoded
    75  	// in the go x509 implementation (why did they do that?!?!?)
    76  	// of course /etc/grid-security/certificates is NOT in those defaults!
    77  	ClientCADirs  string
    78  	ClientCAFiles string
    79  }
    80  
    81  // Init fills the basic fields
    82  func (opt *HTTPOptions) init() {
    83  
    84  	if opt.BaseURL == "" {
    85  		opt.BaseURL = "https://eos-example.org"
    86  	}
    87  
    88  	if opt.ConnectTimeout == 0 {
    89  		opt.ConnectTimeout = 30
    90  	}
    91  	if opt.RWTimeout == 0 {
    92  		opt.RWTimeout = 180
    93  	}
    94  	if opt.OpTimeout == 0 {
    95  		opt.OpTimeout = 360
    96  	}
    97  	if opt.MaxIdleConns == 0 {
    98  		opt.MaxIdleConns = 100
    99  	}
   100  	if opt.MaxConnsPerHost == 0 {
   101  		opt.MaxConnsPerHost = 64
   102  	}
   103  	if opt.MaxIdleConnsPerHost == 0 {
   104  		opt.MaxIdleConnsPerHost = 8
   105  	}
   106  	if opt.IdleConnTimeout == 0 {
   107  		opt.IdleConnTimeout = 30
   108  	}
   109  
   110  	if opt.ClientCertFile == "" {
   111  		opt.ClientCertFile = "/etc/grid-security/hostcert.pem"
   112  	}
   113  	if opt.ClientKeyFile == "" {
   114  		opt.ClientKeyFile = "/etc/grid-security/hostkey.pem"
   115  	}
   116  
   117  	if opt.ClientCAFiles != "" {
   118  		os.Setenv("SSL_CERT_FILE", opt.ClientCAFiles)
   119  	}
   120  	if opt.ClientCADirs != "" {
   121  		os.Setenv("SSL_CERT_DIR", opt.ClientCADirs)
   122  	} else {
   123  		os.Setenv("SSL_CERT_DIR", "/etc/grid-security/certificates")
   124  	}
   125  }
   126  
   127  // EOSHTTPClient performs HTTP-based tasks (e.g. upload, download)
   128  // against a EOS management node (MGM)
   129  // using the EOS XrdHTTP interface.
   130  // In this module we wrap eos-related behaviour, e.g. headers or r/w retries
   131  type EOSHTTPClient struct {
   132  	opt *HTTPOptions
   133  	cl  *http.Client
   134  }
   135  
   136  // NewEOSHTTPClient creates a new client with the given options.
   137  func NewEOSHTTPClient(opt *HTTPOptions) (*EOSHTTPClient, error) {
   138  	log := logger.New().With().Int("pid", os.Getpid()).Logger()
   139  	log.Debug().Str("func", "New").Str("Creating new eoshttp client. opt: ", "'"+fmt.Sprintf("%#v", opt)+"' ").Msg("")
   140  
   141  	if opt == nil {
   142  		log.Debug().Str("opt is nil, error creating http client ", "").Msg("")
   143  		return nil, errtypes.InternalError("HTTPOptions is nil")
   144  	}
   145  
   146  	opt.init()
   147  	cert, err := tls.LoadX509KeyPair(opt.ClientCertFile, opt.ClientKeyFile)
   148  	if err != nil {
   149  		return nil, err
   150  	}
   151  
   152  	// TODO: the error reporting of http.transport is insufficient
   153  	// we may want to check manually at least the existence of the certfiles
   154  	// The point is that also the error reporting of the context that calls this function
   155  	// is weak
   156  	t := &http.Transport{
   157  		TLSClientConfig: &tls.Config{
   158  			Certificates: []tls.Certificate{cert},
   159  		},
   160  		MaxIdleConns:        opt.MaxIdleConns,
   161  		MaxConnsPerHost:     opt.MaxConnsPerHost,
   162  		MaxIdleConnsPerHost: opt.MaxIdleConnsPerHost,
   163  		IdleConnTimeout:     time.Duration(opt.IdleConnTimeout) * time.Second,
   164  		DisableCompression:  true,
   165  	}
   166  
   167  	cl := &http.Client{
   168  		Transport: t,
   169  		CheckRedirect: func(req *http.Request, via []*http.Request) error {
   170  			return http.ErrUseLastResponse
   171  		},
   172  	}
   173  
   174  	return &EOSHTTPClient{
   175  		opt: opt,
   176  		cl:  cl,
   177  	}, nil
   178  }
   179  
   180  // Format a human readable line that describes a response
   181  func rspdesc(rsp *http.Response) string {
   182  	desc := "'" + fmt.Sprintf("%d", rsp.StatusCode) + "'" + ": '" + rsp.Status + "'"
   183  
   184  	buf := new(bytes.Buffer)
   185  	r := "<none>"
   186  	n, e := buf.ReadFrom(rsp.Body)
   187  
   188  	if e != nil {
   189  		r = "Error reading body: '" + e.Error() + "'"
   190  	} else if n > 0 {
   191  		r = buf.String()
   192  	}
   193  
   194  	desc += " - '" + r + "'"
   195  
   196  	return desc
   197  }
   198  
   199  // If the error is not nil, take that
   200  // If there is an error coming from EOS, erturn a descriptive error
   201  func (c *EOSHTTPClient) getRespError(rsp *http.Response, err error) error {
   202  	if err != nil {
   203  		return err
   204  	}
   205  
   206  	if rsp.StatusCode == 0 {
   207  		return nil
   208  	}
   209  
   210  	switch rsp.StatusCode {
   211  	case 0, 200, 201:
   212  		return nil
   213  	case 403:
   214  		return errtypes.PermissionDenied(rspdesc(rsp))
   215  	case 404:
   216  		return errtypes.NotFound(rspdesc(rsp))
   217  	}
   218  
   219  	err2 := errtypes.InternalError("Err from EOS: " + rspdesc(rsp))
   220  	return err2
   221  }
   222  
   223  // From the basepath and the file path... build an url
   224  func (c *EOSHTTPClient) buildFullURL(urlpath string, auth eosclient.Authorization) (string, error) {
   225  
   226  	u, err := url.Parse(c.opt.BaseURL)
   227  	if err != nil {
   228  		return "", err
   229  	}
   230  
   231  	u, err = u.Parse(url.PathEscape(urlpath))
   232  	if err != nil {
   233  		return "", err
   234  	}
   235  
   236  	// Prohibit malicious users from injecting a false uid/gid into the url
   237  	v := u.Query()
   238  	if v.Get("eos.ruid") != "" || v.Get("eos.rgid") != "" {
   239  		return "", errtypes.PermissionDenied("Illegal malicious url " + urlpath)
   240  	}
   241  
   242  	if len(auth.Role.UID) > 0 {
   243  		v.Set("eos.ruid", auth.Role.UID)
   244  	}
   245  	if len(auth.Role.GID) > 0 {
   246  		v.Set("eos.rgid", auth.Role.GID)
   247  	}
   248  
   249  	u.RawQuery = v.Encode()
   250  	return u.String(), nil
   251  }
   252  
   253  // GETFile does an entire GET to download a full file. Returns a stream to read the content from
   254  func (c *EOSHTTPClient) GETFile(ctx context.Context, remoteuser string, auth eosclient.Authorization, urlpath string, stream io.WriteCloser) (io.ReadCloser, error) {
   255  
   256  	log := appctx.GetLogger(ctx)
   257  	log.Info().Str("func", "GETFile").Str("remoteuser", remoteuser).Str("uid,gid", auth.Role.UID+","+auth.Role.GID).Str("path", urlpath).Msg("")
   258  
   259  	// Now send the req and see what happens
   260  	finalurl, err := c.buildFullURL(urlpath, auth)
   261  	if err != nil {
   262  		log.Error().Str("func", "GETFile").Str("url", finalurl).Str("err", err.Error()).Msg("can't create request")
   263  		return nil, err
   264  	}
   265  	req, err := http.NewRequestWithContext(ctx, "GET", finalurl, nil)
   266  	if err != nil {
   267  		log.Error().Str("func", "GETFile").Str("url", finalurl).Str("err", err.Error()).Msg("can't create request")
   268  		return nil, err
   269  	}
   270  
   271  	ntries := 0
   272  	nredirs := 0
   273  	timebegin := time.Now().Unix()
   274  
   275  	for {
   276  		// Check for a max count of redirections or retries
   277  
   278  		// Check for a global timeout in any case
   279  		tdiff := time.Now().Unix() - timebegin
   280  		if tdiff > int64(c.opt.OpTimeout) {
   281  			log.Error().Str("func", "GETFile").Str("url", finalurl).Int64("timeout", tdiff).Int("ntries", ntries).Msg("")
   282  			return nil, errtypes.InternalError("Timeout with url" + finalurl)
   283  		}
   284  
   285  		// Execute the request. I don't like that there is no explicit timeout or buffer control on the input stream
   286  		log.Debug().Str("func", "GETFile").Msg("sending req")
   287  		resp, err := c.cl.Do(req)
   288  
   289  		// Let's support redirections... and if we retry we have to retry at the same FST, avoid going back to the MGM
   290  		if resp != nil && (resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusTemporaryRedirect) {
   291  
   292  			// io.Copy(io.Discard, resp.Body)
   293  			// resp.Body.Close()
   294  
   295  			loc, err := resp.Location()
   296  			if err != nil {
   297  				log.Error().Str("func", "GETFile").Str("url", finalurl).Str("err", err.Error()).Msg("can't get a new location for a redirection")
   298  				return nil, err
   299  			}
   300  
   301  			req, err = http.NewRequestWithContext(ctx, "GET", loc.String(), nil)
   302  			if err != nil {
   303  				log.Error().Str("func", "GETFile").Str("url", loc.String()).Str("err", err.Error()).Msg("can't create redirected request")
   304  				return nil, err
   305  			}
   306  
   307  			req.Close = true
   308  
   309  			log.Debug().Str("func", "GETFile").Str("location", loc.String()).Msg("redirection")
   310  			nredirs++
   311  			resp = nil
   312  			err = nil
   313  			continue
   314  		}
   315  
   316  		// And get an error code (if error) that is worth propagating
   317  		e := c.getRespError(resp, err)
   318  		if e != nil {
   319  			if os.IsTimeout(e) {
   320  				ntries++
   321  				log.Warn().Str("func", "GETFile").Str("url", finalurl).Str("err", e.Error()).Int("try", ntries).Msg("recoverable network timeout")
   322  				continue
   323  			}
   324  			log.Error().Str("func", "GETFile").Str("url", finalurl).Str("err", e.Error()).Msg("")
   325  			return nil, e
   326  		}
   327  
   328  		log.Debug().Str("func", "GETFile").Str("url", finalurl).Str("resp:", fmt.Sprintf("%#v", resp)).Msg("")
   329  		if resp == nil {
   330  			return nil, errtypes.NotFound(fmt.Sprintf("url: %s", finalurl))
   331  		}
   332  
   333  		if stream != nil {
   334  			// Streaming versus localfile. If we have bene given a dest stream then copy the body into it
   335  			_, err = io.Copy(stream, resp.Body)
   336  			return nil, err
   337  		}
   338  
   339  		// If we have not been given a stream to write into then return our stream to read from
   340  		return resp.Body, nil
   341  	}
   342  
   343  }
   344  
   345  // PUTFile does an entire PUT to upload a full file, taking the data from a stream
   346  func (c *EOSHTTPClient) PUTFile(ctx context.Context, remoteuser string, auth eosclient.Authorization, urlpath string, stream io.ReadCloser, length int64) error {
   347  
   348  	log := appctx.GetLogger(ctx)
   349  	log.Info().Str("func", "PUTFile").Str("remoteuser", remoteuser).Str("uid,gid", auth.Role.UID+","+auth.Role.GID).Str("path", urlpath).Int64("length", length).Msg("")
   350  
   351  	// Now send the req and see what happens
   352  	finalurl, err := c.buildFullURL(urlpath, auth)
   353  	if err != nil {
   354  		log.Error().Str("func", "PUTFile").Str("url", finalurl).Str("err", err.Error()).Msg("can't create request")
   355  		return err
   356  	}
   357  	req, err := http.NewRequestWithContext(ctx, "PUT", finalurl, nil)
   358  	if err != nil {
   359  		log.Error().Str("func", "PUTFile").Str("url", finalurl).Str("err", err.Error()).Msg("can't create request")
   360  		return err
   361  	}
   362  
   363  	req.Close = true
   364  
   365  	ntries := 0
   366  	nredirs := 0
   367  	timebegin := time.Now().Unix()
   368  
   369  	for {
   370  		// Check for a max count of redirections or retries
   371  
   372  		// Check for a global timeout in any case
   373  		tdiff := time.Now().Unix() - timebegin
   374  		if tdiff > int64(c.opt.OpTimeout) {
   375  			log.Error().Str("func", "PUTFile").Str("url", finalurl).Int64("timeout", tdiff).Int("ntries", ntries).Msg("")
   376  			return errtypes.InternalError("Timeout with url" + finalurl)
   377  		}
   378  
   379  		// Execute the request. I don't like that there is no explicit timeout or buffer control on the input stream
   380  		log.Debug().Str("func", "PUTFile").Msg("sending req")
   381  		resp, err := c.cl.Do(req)
   382  		if resp != nil {
   383  			resp.Body.Close()
   384  		}
   385  
   386  		// Let's support redirections... and if we retry we retry at the same FST
   387  		if resp != nil && resp.StatusCode == 307 {
   388  
   389  			// io.Copy(io.Discard, resp.Body)
   390  			// resp.Body.Close()
   391  
   392  			loc, err := resp.Location()
   393  			if err != nil {
   394  				log.Error().Str("func", "PUTFile").Str("url", finalurl).Str("err", err.Error()).Msg("can't get a new location for a redirection")
   395  				return err
   396  			}
   397  
   398  			req, err = http.NewRequestWithContext(ctx, "PUT", loc.String(), stream)
   399  			if err != nil {
   400  				log.Error().Str("func", "PUTFile").Str("url", loc.String()).Str("err", err.Error()).Msg("can't create redirected request")
   401  				return err
   402  			}
   403  			if length >= 0 {
   404  				log.Debug().Str("func", "PUTFile").Int64("Content-Length", length).Msg("setting header")
   405  				req.Header.Set("Content-Length", strconv.FormatInt(length, 10))
   406  
   407  			}
   408  			if err != nil {
   409  				log.Error().Str("func", "PUTFile").Str("url", loc.String()).Str("err", err.Error()).Msg("can't create redirected request")
   410  				return err
   411  			}
   412  			if length >= 0 {
   413  				log.Debug().Str("func", "PUTFile").Int64("Content-Length", length).Msg("setting header")
   414  				req.Header.Set("Content-Length", strconv.FormatInt(length, 10))
   415  
   416  			}
   417  
   418  			log.Debug().Str("func", "PUTFile").Str("location", loc.String()).Msg("redirection")
   419  			nredirs++
   420  			resp = nil
   421  			err = nil
   422  			continue
   423  		}
   424  
   425  		// And get an error code (if error) that is worth propagating
   426  		e := c.getRespError(resp, err)
   427  		if e != nil {
   428  			if os.IsTimeout(e) {
   429  				ntries++
   430  				log.Warn().Str("func", "PUTFile").Str("url", finalurl).Str("err", e.Error()).Int("try", ntries).Msg("recoverable network timeout")
   431  				continue
   432  			}
   433  			log.Error().Str("func", "PUTFile").Str("url", finalurl).Str("err", e.Error()).Msg("")
   434  			return e
   435  		}
   436  
   437  		log.Debug().Str("func", "PUTFile").Str("url", finalurl).Str("resp:", fmt.Sprintf("%#v", resp)).Msg("")
   438  		if resp == nil {
   439  			return errtypes.NotFound(fmt.Sprintf("url: %s", finalurl))
   440  		}
   441  
   442  		return nil
   443  	}
   444  
   445  }
   446  
   447  // Head performs a HEAD req. Useful to check the server
   448  func (c *EOSHTTPClient) Head(ctx context.Context, remoteuser string, auth eosclient.Authorization, urlpath string) error {
   449  
   450  	log := appctx.GetLogger(ctx)
   451  	log.Info().Str("func", "Head").Str("remoteuser", remoteuser).Str("uid,gid", auth.Role.UID+","+auth.Role.GID).Str("path", urlpath).Msg("")
   452  
   453  	// Now send the req and see what happens
   454  	finalurl, err := c.buildFullURL(urlpath, auth)
   455  	if err != nil {
   456  		log.Error().Str("func", "Head").Str("url", finalurl).Str("err", err.Error()).Msg("can't create request")
   457  		return err
   458  	}
   459  
   460  	req, err := http.NewRequestWithContext(ctx, "HEAD", finalurl, nil)
   461  	if err != nil {
   462  		log.Error().Str("func", "Head").Str("remoteuser", remoteuser).Str("uid,gid", auth.Role.UID+","+auth.Role.GID).Str("url", finalurl).Str("err", err.Error()).Msg("can't create request")
   463  		return err
   464  	}
   465  
   466  	ntries := 0
   467  
   468  	timebegin := time.Now().Unix()
   469  	for {
   470  		tdiff := time.Now().Unix() - timebegin
   471  		if tdiff > int64(c.opt.OpTimeout) {
   472  			log.Error().Str("func", "Head").Str("url", finalurl).Int64("timeout", tdiff).Int("ntries", ntries).Msg("")
   473  			return errtypes.InternalError("Timeout with url" + finalurl)
   474  		}
   475  		// Execute the request. I don't like that there is no explicit timeout or buffer control on the input stream
   476  		resp, err := c.cl.Do(req)
   477  		if resp != nil {
   478  			resp.Body.Close()
   479  		}
   480  
   481  		// And get an error code (if error) that is worth propagating
   482  		e := c.getRespError(resp, err)
   483  		if e != nil {
   484  			if os.IsTimeout(e) {
   485  				ntries++
   486  				log.Warn().Str("func", "Head").Str("url", finalurl).Str("err", e.Error()).Int("try", ntries).Msg("recoverable network timeout")
   487  				continue
   488  			}
   489  			log.Error().Str("func", "Head").Str("url", finalurl).Str("err", e.Error()).Msg("")
   490  			return e
   491  		}
   492  
   493  		log.Debug().Str("func", "Head").Str("url", finalurl).Str("resp:", fmt.Sprintf("%#v", resp)).Msg("")
   494  		if resp == nil {
   495  			return errtypes.NotFound(fmt.Sprintf("url: %s", finalurl))
   496  		}
   497  	}
   498  	// return nil
   499  
   500  }