internal/source/gitlab/client/client.go (165 lines of code) (raw):

package client import ( "context" "errors" "fmt" "io" "net/http" "net/url" "path" "time" "github.com/golang-jwt/jwt/v5" "gitlab.com/gitlab-org/labkit/correlation" "gitlab.com/gitlab-org/labkit/log" "gitlab.com/gitlab-org/gitlab-pages/internal/config" "gitlab.com/gitlab-org/gitlab-pages/internal/domain" "gitlab.com/gitlab-org/gitlab-pages/internal/httptransport" "gitlab.com/gitlab-org/gitlab-pages/internal/source/gitlab/api" "gitlab.com/gitlab-org/gitlab-pages/metrics" ) // ConnectionErrorMsg to be returned with `gc.Status` if Pages // fails to connect to the internal GitLab API, times out // or a 401 given that the credentials used are wrong const ConnectionErrorMsg = "failed to connect to internal Pages API" const transportClientName = "gitlab_internal_api" // ErrUnauthorizedAPI is returned when resolving a domain with the GitLab API // returns a http.StatusUnauthorized. This happens if the common secret file // is not synced between gitlab-pages and gitlab-rails servers. // See https://gitlab.com/gitlab-org/gitlab-pages/-/issues/535 for more details. var ErrUnauthorizedAPI = errors.New("pages endpoint unauthorized") // Client is a HTTP client to access Pages internal API type Client struct { secretKey []byte baseURL *url.URL httpClient *http.Client jwtTokenExpiry time.Duration } // NewClient initializes and returns new Client baseUrl is // appConfig.InternalGitLabServer secretKey is appConfig.GitLabAPISecretKey func NewClient(baseURL string, secretKey []byte, connectionTimeout, jwtTokenExpiry time.Duration, clientCfg config.HTTPClientCfg) (*Client, error) { if len(baseURL) == 0 || len(secretKey) == 0 { return nil, errors.New("GitLab API URL or API secret has not been provided") } parsedURL, err := url.Parse(baseURL) if err != nil { return nil, err } if connectionTimeout == 0 { return nil, errors.New("GitLab HTTP client connection timeout has not been provided") } if jwtTokenExpiry == 0 { return nil, errors.New("GitLab JWT token expiry has not been provided") } httpTransport := httptransport.NewTransportWithClientCert(clientCfg) return &Client{ secretKey: secretKey, baseURL: parsedURL, httpClient: &http.Client{ Timeout: connectionTimeout, Transport: httptransport.NewMeteredRoundTripper( correlation.NewInstrumentedRoundTripper( httpTransport, correlation.WithClientName(transportClientName), ), transportClientName, metrics.DomainsSourceAPITraceDuration, metrics.DomainsSourceAPICallDuration, metrics.DomainsSourceAPIReqTotal, httptransport.DefaultTTFBTimeout, ), }, jwtTokenExpiry: jwtTokenExpiry, }, nil } // NewFromConfig creates a new client from Config struct func NewFromConfig(cfg *config.GitLab) (*Client, error) { return NewClient(cfg.InternalServer, cfg.APISecretKey, cfg.ClientHTTPTimeout, cfg.JWTTokenExpiration, cfg.ClientCfg) } // Resolve returns a VirtualDomain configuration wrapped into a Lookup for a // given host. It implements api.Resolve type. func (gc *Client) Resolve(ctx context.Context, host string) *api.Lookup { lookup := gc.GetLookup(ctx, host) return &lookup } // GetLookup returns a VirtualDomain configuration wrapped into a Lookup for a // given host func (gc *Client) GetLookup(ctx context.Context, host string) api.Lookup { params := url.Values{} params.Set("host", host) if err := ValidateHostName(host); err != nil { log.WithError(err).WithFields( log.Fields{ "correlation_id": correlation.ExtractFromContext(ctx), "lookup_name": host, }).Error() return api.Lookup{Name: host, Error: domain.ErrDomainDoesNotExist} } resp, err := gc.get(ctx, "/api/v4/internal/pages", params) if err != nil { metrics.DomainsSourceFailures.Inc() return api.Lookup{Name: host, Error: err} } if resp == nil { log.WithError(domain.ErrDomainDoesNotExist).WithFields( log.Fields{ "correlation_id": correlation.ExtractFromContext(ctx), "lookup_name": host, }).Error("unexpected nil response from gitlab") return api.Lookup{Name: host, Error: domain.ErrDomainDoesNotExist} } // ensure that entire response body has been read and close it, to make it // possible to reuse HTTP connection. In case of a JSON being invalid and // larger than 512 bytes, the response body will not be closed properly, thus // we need to close it manually in every case. defer func() { io.Copy(io.Discard, resp.Body) resp.Body.Close() }() lookup := api.Lookup{Name: host} lookup.ParseDomain(resp.Body) return lookup } func (gc *Client) get(ctx context.Context, path string, params url.Values) (*http.Response, error) { endpoint, err := gc.endpoint(path, params) if err != nil { return nil, err } req, err := gc.request(ctx, "GET", endpoint) if err != nil { return nil, err } resp, err := gc.httpClient.Do(req) if err != nil { return nil, err } if resp == nil { return nil, errors.New("unknown response") } // StatusOK means we should return the API response if resp.StatusCode == http.StatusOK { return resp, nil } // best effort to discard and close the response body io.Copy(io.Discard, resp.Body) resp.Body.Close() // StatusNoContent means that a domain does not exist, it is not an error if resp.StatusCode == http.StatusNoContent { return nil, nil } else if resp.StatusCode == http.StatusUnauthorized { return nil, ErrUnauthorizedAPI } return nil, fmt.Errorf("HTTP status: %d", resp.StatusCode) } func (gc *Client) endpoint(urlPath string, params url.Values) (*url.URL, error) { parsedPath, err := url.Parse(urlPath) if err != nil { return nil, err } // fix for https://gitlab.com/gitlab-org/gitlab-pages/-/issues/587 // ensure gc.baseURL.Path is still present and append new urlPath // it cleans double `/` in either path endpoint, err := gc.baseURL.Parse(path.Join(gc.baseURL.Path, parsedPath.Path)) if err != nil { return nil, err } endpoint.RawQuery = params.Encode() return endpoint, nil } func (gc *Client) request(ctx context.Context, method string, endpoint *url.URL) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), nil) if err != nil { return nil, err } token, err := gc.token() if err != nil { return nil, err } req.Header.Set("Gitlab-Pages-Api-Request", token) return req, nil } func (gc *Client) token() (string, error) { claims := jwt.RegisteredClaims{ Issuer: "gitlab-pages", ExpiresAt: jwt.NewNumericDate(time.Now().UTC().Add(gc.jwtTokenExpiry)), } token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(gc.secretKey) if err != nil { return "", err } return token, nil }