430 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			430 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Go
		
	
	
	
| package api
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"encoding/json"
 | |
| 	"fmt"
 | |
| 	"io"
 | |
| 	"net/http"
 | |
| 	"net/textproto"
 | |
| 	"net/url"
 | |
| 	"strconv"
 | |
| 	"strings"
 | |
| 
 | |
| 	"github.com/prometheus/client_golang/prometheus"
 | |
| 	"github.com/prometheus/client_golang/prometheus/promauto"
 | |
| 
 | |
| 	"gitlab.com/gitlab-org/gitaly/v14/proto/go/gitalypb"
 | |
| 
 | |
| 	"gitlab.com/gitlab-org/gitlab/workhorse/internal/config"
 | |
| 	"gitlab.com/gitlab-org/gitlab/workhorse/internal/gitaly"
 | |
| 	"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper"
 | |
| 	"gitlab.com/gitlab-org/gitlab/workhorse/internal/log"
 | |
| 
 | |
| 	"gitlab.com/gitlab-org/gitlab/workhorse/internal/secret"
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	// Custom content type for API responses, to catch routing / programming mistakes
 | |
| 	ResponseContentType = "application/vnd.gitlab-workhorse+json"
 | |
| 
 | |
| 	failureResponseLimit = 32768
 | |
| 
 | |
| 	geoProxyEndpointPath = "/api/v4/geo/proxy"
 | |
| )
 | |
| 
 | |
| type API struct {
 | |
| 	Client  *http.Client
 | |
| 	URL     *url.URL
 | |
| 	Version string
 | |
| }
 | |
| 
 | |
| var (
 | |
| 	requestsCounter = promauto.NewCounterVec(
 | |
| 		prometheus.CounterOpts{
 | |
| 			Name: "gitlab_workhorse_internal_api_requests",
 | |
| 			Help: "How many internal API requests have been completed by gitlab-workhorse, partitioned by status code and HTTP method.",
 | |
| 		},
 | |
| 		[]string{"code", "method"},
 | |
| 	)
 | |
| 	bytesTotal = promauto.NewCounter(
 | |
| 		prometheus.CounterOpts{
 | |
| 			Name: "gitlab_workhorse_internal_api_failure_response_bytes",
 | |
| 			Help: "How many bytes have been returned by upstream GitLab in API failure/rejection response bodies.",
 | |
| 		},
 | |
| 	)
 | |
| )
 | |
| 
 | |
| func NewAPI(myURL *url.URL, version string, roundTripper http.RoundTripper) *API {
 | |
| 	return &API{
 | |
| 		Client:  &http.Client{Transport: roundTripper},
 | |
| 		URL:     myURL,
 | |
| 		Version: version,
 | |
| 	}
 | |
| }
 | |
| 
 | |
| type GeoProxyEndpointResponse struct {
 | |
| 	GeoProxyURL string `json:"geo_proxy_url"`
 | |
| }
 | |
| 
 | |
| type HandleFunc func(http.ResponseWriter, *http.Request, *Response)
 | |
| 
 | |
| type MultipartUploadParams struct {
 | |
| 	// PartSize is the exact size of each uploaded part. Only the last one can be smaller
 | |
| 	PartSize int64
 | |
| 	// PartURLs contains the presigned URLs for each part
 | |
| 	PartURLs []string
 | |
| 	// CompleteURL is a presigned URL for CompleteMulipartUpload
 | |
| 	CompleteURL string
 | |
| 	// AbortURL is a presigned URL for AbortMultipartUpload
 | |
| 	AbortURL string
 | |
| }
 | |
| 
 | |
| type ObjectStorageParams struct {
 | |
| 	Provider      string
 | |
| 	S3Config      config.S3Config
 | |
| 	GoCloudConfig config.GoCloudConfig
 | |
| }
 | |
| 
 | |
| type RemoteObject struct {
 | |
| 	// GetURL is an S3 GetObject URL
 | |
| 	GetURL string
 | |
| 	// DeleteURL is a presigned S3 RemoveObject URL
 | |
| 	DeleteURL string
 | |
| 	// StoreURL is the temporary presigned S3 PutObject URL to which upload the first found file
 | |
| 	StoreURL string
 | |
| 	// Boolean to indicate whether to use headers included in PutHeaders
 | |
| 	CustomPutHeaders bool
 | |
| 	// PutHeaders are HTTP headers (e.g. Content-Type) to be sent with StoreURL
 | |
| 	PutHeaders map[string]string
 | |
| 	// Whether to ignore Rails pre-signed URLs and have Workhorse directly access object storage provider
 | |
| 	UseWorkhorseClient bool
 | |
| 	// Remote, temporary object name where Rails will move to the final destination
 | |
| 	RemoteTempObjectID string
 | |
| 	// ID is a unique identifier of object storage upload
 | |
| 	ID string
 | |
| 	// Timeout is a number that represents timeout in seconds for sending data to StoreURL
 | |
| 	Timeout int
 | |
| 	// MultipartUpload contains presigned URLs for S3 MultipartUpload
 | |
| 	MultipartUpload *MultipartUploadParams
 | |
| 	// Object storage config for Workhorse client
 | |
| 	ObjectStorage *ObjectStorageParams
 | |
| }
 | |
| 
 | |
| type Response struct {
 | |
| 	// GL_ID is an environment variable used by gitlab-shell hooks during 'git
 | |
| 	// push' and 'git pull'
 | |
| 	GL_ID string
 | |
| 
 | |
| 	// GL_USERNAME holds gitlab username of the user who is taking the action causing hooks to be invoked
 | |
| 	GL_USERNAME string
 | |
| 
 | |
| 	// GL_REPOSITORY is an environment variable used by gitlab-shell hooks during
 | |
| 	// 'git push' and 'git pull'
 | |
| 	GL_REPOSITORY string
 | |
| 	// GitConfigOptions holds the custom options that we want to pass to the git command
 | |
| 	GitConfigOptions []string
 | |
| 	// StoreLFSPath is provided by the GitLab Rails application to mark where the tmp file should be placed.
 | |
| 	// This field is deprecated. GitLab will use TempPath instead
 | |
| 	StoreLFSPath string
 | |
| 	// LFS object id
 | |
| 	LfsOid string
 | |
| 	// LFS object size
 | |
| 	LfsSize int64
 | |
| 	// TmpPath is the path where we should store temporary files
 | |
| 	// This is set by authorization middleware
 | |
| 	TempPath string
 | |
| 	// RemoteObject is provided by the GitLab Rails application
 | |
| 	// and defines a way to store object on remote storage
 | |
| 	RemoteObject RemoteObject
 | |
| 	// Archive is the path where the artifacts archive is stored
 | |
| 	Archive string `json:"archive"`
 | |
| 	// Entry is a filename inside the archive point to file that needs to be extracted
 | |
| 	Entry string `json:"entry"`
 | |
| 	// Used to communicate channel session details
 | |
| 	Channel *ChannelSettings
 | |
| 	// GitalyServer specifies an address and authentication token for a gitaly server we should connect to.
 | |
| 	GitalyServer gitaly.Server
 | |
| 	// Repository object for making gRPC requests to Gitaly.
 | |
| 	Repository gitalypb.Repository
 | |
| 	// For git-http, does the requestor have the right to view all refs?
 | |
| 	ShowAllRefs bool
 | |
| 	// Detects whether an artifact is used for code intelligence
 | |
| 	ProcessLsif bool
 | |
| 	// Detects whether LSIF artifact will be parsed with references
 | |
| 	ProcessLsifReferences bool
 | |
| 	// The maximum accepted size in bytes of the upload
 | |
| 	MaximumSize int64
 | |
| 	// DEPRECATED: Feature flag used to determine whether to strip the multipart filename of any directories
 | |
| 	FeatureFlagExtractBase bool
 | |
| }
 | |
| 
 | |
| // singleJoiningSlash is taken from reverseproxy.go:singleJoiningSlash
 | |
| func singleJoiningSlash(a, b string) string {
 | |
| 	aslash := strings.HasSuffix(a, "/")
 | |
| 	bslash := strings.HasPrefix(b, "/")
 | |
| 	switch {
 | |
| 	case aslash && bslash:
 | |
| 		return a + b[1:]
 | |
| 	case !aslash && !bslash:
 | |
| 		return a + "/" + b
 | |
| 	}
 | |
| 	return a + b
 | |
| }
 | |
| 
 | |
| // joinURLPath is taken from reverseproxy.go:joinURLPath
 | |
| func joinURLPath(a *url.URL, b string) (path string, rawpath string) {
 | |
| 	// Avoid adding a trailing slash if the suffix is empty
 | |
| 	if b == "" {
 | |
| 		return a.Path, a.RawPath
 | |
| 	} else if a.RawPath == "" {
 | |
| 		return singleJoiningSlash(a.Path, b), ""
 | |
| 	}
 | |
| 
 | |
| 	// Same as singleJoiningSlash, but uses EscapedPath to determine
 | |
| 	// whether a slash should be added
 | |
| 	apath := a.EscapedPath()
 | |
| 	bpath := b
 | |
| 
 | |
| 	aslash := strings.HasSuffix(apath, "/")
 | |
| 	bslash := strings.HasPrefix(bpath, "/")
 | |
| 
 | |
| 	switch {
 | |
| 	case aslash && bslash:
 | |
| 		return a.Path + bpath[1:], apath + bpath[1:]
 | |
| 	case !aslash && !bslash:
 | |
| 		return a.Path + "/" + bpath, apath + "/" + bpath
 | |
| 	}
 | |
| 	return a.Path + bpath, apath + bpath
 | |
| }
 | |
| 
 | |
| // rebaseUrl is taken from reverseproxy.go:NewSingleHostReverseProxy
 | |
| func rebaseUrl(url *url.URL, onto *url.URL, suffix string) *url.URL {
 | |
| 	newUrl := *url
 | |
| 	newUrl.Scheme = onto.Scheme
 | |
| 	newUrl.Host = onto.Host
 | |
| 	newUrl.Path, newUrl.RawPath = joinURLPath(url, suffix)
 | |
| 
 | |
| 	if onto.RawQuery == "" || newUrl.RawQuery == "" {
 | |
| 		newUrl.RawQuery = onto.RawQuery + newUrl.RawQuery
 | |
| 	} else {
 | |
| 		newUrl.RawQuery = onto.RawQuery + "&" + newUrl.RawQuery
 | |
| 	}
 | |
| 	return &newUrl
 | |
| }
 | |
| 
 | |
| func (api *API) newRequest(r *http.Request, suffix string) (*http.Request, error) {
 | |
| 	authReq := &http.Request{
 | |
| 		Method: r.Method,
 | |
| 		URL:    rebaseUrl(r.URL, api.URL, suffix),
 | |
| 		Header: helper.HeaderClone(r.Header),
 | |
| 	}
 | |
| 
 | |
| 	authReq = authReq.WithContext(r.Context())
 | |
| 
 | |
| 	removeConnectionHeaders(authReq.Header)
 | |
| 
 | |
| 	// Clean some headers when issuing a new request without body
 | |
| 	authReq.Header.Del("Content-Type")
 | |
| 	authReq.Header.Del("Content-Encoding")
 | |
| 	authReq.Header.Del("Content-Length")
 | |
| 	authReq.Header.Del("Content-Disposition")
 | |
| 	authReq.Header.Del("Accept-Encoding")
 | |
| 
 | |
| 	// Hop-by-hop headers. These are removed when sent to the backend.
 | |
| 	// http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html
 | |
| 	authReq.Header.Del("Transfer-Encoding")
 | |
| 	authReq.Header.Del("Connection")
 | |
| 	authReq.Header.Del("Keep-Alive")
 | |
| 	authReq.Header.Del("Proxy-Authenticate")
 | |
| 	authReq.Header.Del("Proxy-Authorization")
 | |
| 	authReq.Header.Del("Te")
 | |
| 	// "Trailer", not "Trailers" as per rfc2616; See errata https://www.rfc-editor.org/errata_search.php?eid=4522
 | |
| 	// See https://httpwg.org/http-core/draft-ietf-httpbis-semantics-latest.html#field.connection
 | |
| 	authReq.Header.Del("Trailer")
 | |
| 	authReq.Header.Del("Upgrade")
 | |
| 
 | |
| 	// Also forward the Host header, which is excluded from the Header map by the http library.
 | |
| 	// This allows the Host header received by the backend to be consistent with other
 | |
| 	// requests not going through gitlab-workhorse.
 | |
| 	authReq.Host = r.Host
 | |
| 
 | |
| 	return authReq, nil
 | |
| }
 | |
| 
 | |
| // PreAuthorize performs a pre-authorization check against the API for the given HTTP request
 | |
| //
 | |
| // If `outErr` is set, the other fields will be nil and it should be treated as
 | |
| // a 500 error.
 | |
| //
 | |
| // If httpResponse is present, the caller is responsible for closing its body
 | |
| //
 | |
| // authResponse will only be present if the authorization check was successful
 | |
| func (api *API) PreAuthorize(suffix string, r *http.Request) (httpResponse *http.Response, authResponse *Response, outErr error) {
 | |
| 	authReq, err := api.newRequest(r, suffix)
 | |
| 	if err != nil {
 | |
| 		return nil, nil, fmt.Errorf("preAuthorizeHandler newUpstreamRequest: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	httpResponse, err = api.doRequestWithoutRedirects(authReq)
 | |
| 	if err != nil {
 | |
| 		return nil, nil, fmt.Errorf("preAuthorizeHandler: do request: %v", err)
 | |
| 	}
 | |
| 	defer func() {
 | |
| 		if outErr != nil {
 | |
| 			httpResponse.Body.Close()
 | |
| 			httpResponse = nil
 | |
| 		}
 | |
| 	}()
 | |
| 	requestsCounter.WithLabelValues(strconv.Itoa(httpResponse.StatusCode), authReq.Method).Inc()
 | |
| 
 | |
| 	// This may be a false positive, e.g. for .../info/refs, rather than a
 | |
| 	// failure, so pass the response back
 | |
| 	if httpResponse.StatusCode != http.StatusOK || !validResponseContentType(httpResponse) {
 | |
| 		return httpResponse, nil, nil
 | |
| 	}
 | |
| 
 | |
| 	authResponse = &Response{}
 | |
| 	// The auth backend validated the client request and told us additional
 | |
| 	// request metadata. We must extract this information from the auth
 | |
| 	// response body.
 | |
| 	if err := json.NewDecoder(httpResponse.Body).Decode(authResponse); err != nil {
 | |
| 		return httpResponse, nil, fmt.Errorf("preAuthorizeHandler: decode authorization response: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	return httpResponse, authResponse, nil
 | |
| }
 | |
| 
 | |
| func (api *API) PreAuthorizeHandler(next HandleFunc, suffix string) http.Handler {
 | |
| 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | |
| 		httpResponse, authResponse, err := api.PreAuthorize(suffix, r)
 | |
| 		if httpResponse != nil {
 | |
| 			defer httpResponse.Body.Close()
 | |
| 		}
 | |
| 
 | |
| 		if err != nil {
 | |
| 			helper.Fail500(w, r, err)
 | |
| 			return
 | |
| 		}
 | |
| 
 | |
| 		// The response couldn't be interpreted as a valid auth response, so
 | |
| 		// pass it back (mostly) unmodified
 | |
| 		if httpResponse != nil && authResponse == nil {
 | |
| 			passResponseBack(httpResponse, w, r)
 | |
| 			return
 | |
| 		}
 | |
| 
 | |
| 		httpResponse.Body.Close() // Free up the Puma thread
 | |
| 
 | |
| 		copyAuthHeader(httpResponse, w)
 | |
| 
 | |
| 		next(w, r, authResponse)
 | |
| 	})
 | |
| }
 | |
| 
 | |
| func (api *API) doRequestWithoutRedirects(authReq *http.Request) (*http.Response, error) {
 | |
| 	signingTripper := secret.NewRoundTripper(api.Client.Transport, api.Version)
 | |
| 
 | |
| 	return signingTripper.RoundTrip(authReq)
 | |
| }
 | |
| 
 | |
| // removeConnectionHeaders removes hop-by-hop headers listed in the "Connection" header of h.
 | |
| // See https://tools.ietf.org/html/rfc7230#section-6.1
 | |
| func removeConnectionHeaders(h http.Header) {
 | |
| 	for _, f := range h["Connection"] {
 | |
| 		for _, sf := range strings.Split(f, ",") {
 | |
| 			if sf = textproto.TrimString(sf); sf != "" {
 | |
| 				h.Del(sf)
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func copyAuthHeader(httpResponse *http.Response, w http.ResponseWriter) {
 | |
| 	// Negotiate authentication (Kerberos) may need to return a WWW-Authenticate
 | |
| 	// header to the client even in case of success as per RFC4559.
 | |
| 	for k, v := range httpResponse.Header {
 | |
| 		// Case-insensitive comparison as per RFC7230
 | |
| 		if strings.EqualFold(k, "WWW-Authenticate") {
 | |
| 			w.Header()[k] = v
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func passResponseBack(httpResponse *http.Response, w http.ResponseWriter, r *http.Request) {
 | |
| 	// NGINX response buffering is disabled on this path (with
 | |
| 	// X-Accel-Buffering: no) but we still want to free up the Puma thread
 | |
| 	// that generated httpResponse as fast as possible. To do this we buffer
 | |
| 	// the entire response body in memory before sending it on.
 | |
| 	responseBody, err := bufferResponse(httpResponse.Body)
 | |
| 	if err != nil {
 | |
| 		helper.Fail500(w, r, err)
 | |
| 		return
 | |
| 	}
 | |
| 	httpResponse.Body.Close() // Free up the Puma thread
 | |
| 	bytesTotal.Add(float64(responseBody.Len()))
 | |
| 
 | |
| 	for k, v := range httpResponse.Header {
 | |
| 		// Accommodate broken clients that do case-sensitive header lookup
 | |
| 		if k == "Www-Authenticate" {
 | |
| 			w.Header()["WWW-Authenticate"] = v
 | |
| 		} else {
 | |
| 			w.Header()[k] = v
 | |
| 		}
 | |
| 	}
 | |
| 	w.WriteHeader(httpResponse.StatusCode)
 | |
| 	if _, err := io.Copy(w, responseBody); err != nil {
 | |
| 		log.WithRequest(r).WithError(err).Error()
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func bufferResponse(r io.Reader) (*bytes.Buffer, error) {
 | |
| 	responseBody := &bytes.Buffer{}
 | |
| 	n, err := io.Copy(responseBody, io.LimitReader(r, failureResponseLimit))
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	if n == failureResponseLimit {
 | |
| 		return nil, fmt.Errorf("response body exceeded maximum buffer size (%d bytes)", failureResponseLimit)
 | |
| 	}
 | |
| 
 | |
| 	return responseBody, nil
 | |
| }
 | |
| 
 | |
| func validResponseContentType(resp *http.Response) bool {
 | |
| 	return helper.IsContentType(ResponseContentType, resp.Header.Get("Content-Type"))
 | |
| }
 | |
| 
 | |
| func (api *API) GetGeoProxyURL() (*url.URL, error) {
 | |
| 	geoProxyApiUrl := *api.URL
 | |
| 	geoProxyApiUrl.Path, geoProxyApiUrl.RawPath = joinURLPath(api.URL, geoProxyEndpointPath)
 | |
| 	geoProxyApiReq := &http.Request{
 | |
| 		Method: "GET",
 | |
| 		URL:    &geoProxyApiUrl,
 | |
| 		Header: make(http.Header),
 | |
| 	}
 | |
| 
 | |
| 	httpResponse, err := api.doRequestWithoutRedirects(geoProxyApiReq)
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("GetGeoProxyURL: do request: %v", err)
 | |
| 	}
 | |
| 	defer httpResponse.Body.Close()
 | |
| 
 | |
| 	if httpResponse.StatusCode != http.StatusOK {
 | |
| 		return nil, fmt.Errorf("GetGeoProxyURL: Received HTTP status code: %v", httpResponse.StatusCode)
 | |
| 	}
 | |
| 
 | |
| 	response := &GeoProxyEndpointResponse{}
 | |
| 	if err := json.NewDecoder(httpResponse.Body).Decode(response); err != nil {
 | |
| 		return nil, fmt.Errorf("GetGeoProxyURL: decode response: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	geoProxyURL, err := url.Parse(response.GeoProxyURL)
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("GetGeoProxyURL: Could not parse Geo proxy URL: %v, err: %v", response.GeoProxyURL, err)
 | |
| 	}
 | |
| 
 | |
| 	return geoProxyURL, nil
 | |
| }
 |