api/client.go (367 lines of code) (raw):

package api import ( "crypto/tls" "crypto/x509" "fmt" "io" "net" "net/http" "net/url" "os" "strconv" "strings" "time" gitlab "gitlab.com/gitlab-org/api/client-go" "gitlab.com/gitlab-org/cli/internal/config" "gitlab.com/gitlab-org/cli/pkg/glinstance" "gitlab.com/gitlab-org/cli/pkg/oauth2" ) // AuthType represents an authentication type within GitLab. type authType int const ( NoToken authType = iota OAuthToken PrivateToken ) type glabInstall struct { version, platform, architecture string } var currentGlabInstall glabInstall // Global api client to be used throughout glab var apiClient *Client // Client represents an argument to NewClient type Client struct { // LabClient represents GitLab API client. // Note: this is exported for tests. Do not access it directly. Use Lab() method LabClient *gitlab.Client // internal http client httpClient *http.Client // internal http client overrider httpClientOverride *http.Client // Token type used to make authenticated API calls. AuthType authType // custom certificate caFile string // Protocol: host url protocol to make requests. Default is https Protocol string host string token string isGraphQL bool isOauth2 bool isJobToken bool allowInsecure bool refreshLabInstance bool } func (i glabInstall) UserAgent() string { return fmt.Sprintf("glab/%s (%s, %s)", i.version, i.platform, i.architecture) } func SetUserAgent(version string, platform string, architecture string) { currentGlabInstall = glabInstall{ version: version, platform: platform, architecture: architecture, } } func init() { // initialise the global api client to be used throughout glab RefreshClient() } // RefreshClient re-initializes the api client func RefreshClient() { apiClient = &Client{ Protocol: "https", AuthType: NoToken, httpClient: &http.Client{}, refreshLabInstance: true, } } // GetClient returns the global Client instance. func GetClient() *Client { return apiClient } // HTTPClient returns the httpClient instance used to initialise the gitlab api client func HTTPClient() *http.Client { return apiClient.HTTPClient() } func (c *Client) HTTPClient() *http.Client { if c.httpClientOverride != nil { return c.httpClientOverride } if c.httpClient != nil { return c.httpClient } return &http.Client{} } // OverrideHTTPClient overrides the default http client func OverrideHTTPClient(client *http.Client) { apiClient.OverrideHTTPClient(client) } func (c *Client) OverrideHTTPClient(client *http.Client) { c.httpClientOverride = client } // Token returns the authentication token func Token() string { return apiClient.Token() } func (c *Client) Token() string { return c.token } func SetProtocol(protocol string) { apiClient.SetProtocol(protocol) } func (c *Client) SetProtocol(protocol string) { c.Protocol = protocol } var secureCipherSuites = []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, } func tlsConfig(host string) *tls.Config { config := &tls.Config{ MinVersion: tls.VersionTLS12, InsecureSkipVerify: apiClient.allowInsecure, } if host == "gitlab.com" { config.CipherSuites = secureCipherSuites } return config } // NewClient initializes a api client for use throughout glab. func NewClient(host, token string, allowInsecure bool, isGraphQL bool, isOAuth2 bool, isJobToken bool) (*Client, error) { apiClient.host = host apiClient.token = token apiClient.allowInsecure = allowInsecure apiClient.isGraphQL = isGraphQL apiClient.isOauth2 = isOAuth2 apiClient.isJobToken = isJobToken if apiClient.httpClientOverride == nil { apiClient.httpClient = &http.Client{ Transport: &http.Transport{ DisableKeepAlives: DisableHTTPKeepAlives, Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{ Timeout: 5 * time.Second, KeepAlive: 5 * time.Second, }).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 30 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, TLSClientConfig: tlsConfig(host), }, } } apiClient.refreshLabInstance = true err := apiClient.NewLab() return apiClient, err } // NewClientWithCustomCA initializes the global api client with a self-signed certificate func NewClientWithCustomCA(host, token, caFile string, isGraphQL bool, isOAuth2 bool) (*Client, error) { apiClient.host = host apiClient.token = token apiClient.caFile = caFile apiClient.isGraphQL = isGraphQL apiClient.isOauth2 = isOAuth2 if apiClient.httpClientOverride == nil { caCert, err := os.ReadFile(apiClient.caFile) if err != nil { return nil, fmt.Errorf("error reading cert file: %w", err) } // use system cert pool as a baseline caCertPool, err := x509.SystemCertPool() if err != nil { return nil, err } caCertPool.AppendCertsFromPEM(caCert) apiClient.httpClient = &http.Client{ Transport: &http.Transport{ DisableKeepAlives: DisableHTTPKeepAlives, Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, TLSClientConfig: &tls.Config{ RootCAs: caCertPool, }, }, } } apiClient.refreshLabInstance = true err := apiClient.NewLab() return apiClient, err } // NewClientWithCustomCAClientCert initializes the global api client with a self-signed certificate and client certificates func NewClientWithCustomCAClientCert(host, token, caFile string, certFile string, keyFile string, isGraphQL bool, isOAuth2 bool) (*Client, error) { apiClient.host = host apiClient.token = token apiClient.caFile = caFile apiClient.isGraphQL = isGraphQL apiClient.isOauth2 = isOAuth2 if apiClient.httpClientOverride == nil { caCert, err := os.ReadFile(apiClient.caFile) if err != nil { return nil, fmt.Errorf("error reading cert file: %w", err) } // use system cert pool as a baseline caCertPool, err := x509.SystemCertPool() if err != nil { return nil, err } caCertPool.AppendCertsFromPEM(caCert) clientCert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { return nil, err } clientCerts := []tls.Certificate{clientCert} apiClient.httpClient = &http.Client{ Transport: &http.Transport{ DisableKeepAlives: DisableHTTPKeepAlives, Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, TLSClientConfig: &tls.Config{ RootCAs: caCertPool, Certificates: clientCerts, }, }, } } apiClient.refreshLabInstance = true err := apiClient.NewLab() return apiClient, err } // NewClientWithCfg initializes the global api with the config data func NewClientWithCfg(repoHost string, cfg config.Config, isGraphQL bool) (client *Client, err error) { if repoHost == "" { repoHost = glinstance.OverridableDefault() } apiHost, _ := cfg.Get(repoHost, "api_host") if apiHost == "" { apiHost = repoHost } apiProtocol, _ := cfg.Get(repoHost, "api_protocol") if apiProtocol != "" { SetProtocol(apiProtocol) } isOAuth2Cfg, _ := cfg.Get(repoHost, "is_oauth2") isOAuth2 := false if isOAuth2Cfg == "true" { isOAuth2 = true err = oauth2.RefreshToken(repoHost, cfg, "https") if err != nil { return nil, err } } token, _ := cfg.Get(repoHost, "token") jobToken, _ := cfg.Get(repoHost, "job_token") tlsVerify, _ := cfg.Get(repoHost, "skip_tls_verify") skipTlsVerify := tlsVerify == "true" || tlsVerify == "1" caCert, _ := cfg.Get(repoHost, "ca_cert") clientCert, _ := cfg.Get(repoHost, "client_cert") keyFile, _ := cfg.Get(repoHost, "client_key") if caCert != "" && clientCert != "" && keyFile != "" { client, err = NewClientWithCustomCAClientCert(apiHost, token, caCert, clientCert, keyFile, isGraphQL, isOAuth2) } else if caCert != "" { client, err = NewClientWithCustomCA(apiHost, token, caCert, isGraphQL, isOAuth2) } else if jobToken != "" { client, err = NewClient(apiHost, jobToken, skipTlsVerify, isGraphQL, isOAuth2, true) } else { client, err = NewClient(apiHost, token, skipTlsVerify, isGraphQL, isOAuth2, false) } return } // NewLab initializes the GitLab Client func (c *Client) NewLab() error { var err error var baseURL string httpClient := c.httpClient if c.httpClientOverride != nil { httpClient = c.httpClientOverride } if apiClient.refreshLabInstance { if c.host == "" { c.host = glinstance.OverridableDefault() } if c.isGraphQL { baseURL = glinstance.GraphQLEndpoint(c.host, c.Protocol) } else { baseURL = glinstance.APIEndpoint(c.host, c.Protocol, "") } if c.isOauth2 { c.LabClient, err = gitlab.NewOAuthClient(c.token, gitlab.WithHTTPClient(httpClient), gitlab.WithBaseURL(baseURL)) } else if c.isJobToken { c.LabClient, err = gitlab.NewJobClient(c.token, gitlab.WithHTTPClient(httpClient), gitlab.WithBaseURL(baseURL)) } else { c.LabClient, err = gitlab.NewClient(c.token, gitlab.WithHTTPClient(httpClient), gitlab.WithBaseURL(baseURL)) } if err != nil { return fmt.Errorf("failed to initialize GitLab client: %v", err) } c.LabClient.UserAgent = currentGlabInstall.UserAgent() if c.token != "" { if c.isOauth2 { c.AuthType = OAuthToken } else { c.AuthType = PrivateToken } } } return nil } // Lab returns the initialized GitLab client. // Initializes a new GitLab Client if not initialized but error is ignored func (c *Client) Lab() *gitlab.Client { if c.LabClient != nil { return c.LabClient } err := c.NewLab() if err != nil { c.LabClient = &gitlab.Client{} } return c.LabClient } // BaseURL returns a copy of the BaseURL func (c *Client) BaseURL() *url.URL { return c.Lab().BaseURL() } func NewHTTPRequest(c *Client, method string, baseURL *url.URL, body io.Reader, headers []string, bodyIsJSON bool) (*http.Request, error) { req, err := http.NewRequest(method, baseURL.String(), body) if err != nil { return nil, err } for _, h := range headers { idx := strings.IndexRune(h, ':') if idx == -1 { return nil, fmt.Errorf("header %q requires a value separated by ':'", h) } name, value := h[0:idx], strings.TrimSpace(h[idx+1:]) if strings.EqualFold(name, "Content-Length") { length, err := strconv.ParseInt(value, 10, 0) if err != nil { return nil, err } req.ContentLength = length } else { req.Header.Add(name, value) } } if bodyIsJSON && req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json; charset=utf-8") } if c.Lab().UserAgent != "" { req.Header.Set("User-Agent", c.Lab().UserAgent) } // TODO: support GITLAB_CI_TOKEN switch c.AuthType { case OAuthToken: req.Header.Set("Authorization", "Bearer "+c.Token()) case PrivateToken: req.Header.Set("PRIVATE-TOKEN", c.Token()) } return req, nil } func TestClient(httpClient *http.Client, token, host string, isGraphQL bool) (*Client, error) { testClient, err := NewClient(host, token, true, isGraphQL, false, false) if err != nil { return nil, err } testClient.SetProtocol("https") testClient.OverrideHTTPClient(httpClient) testClient.refreshLabInstance = true if token != "" { testClient.AuthType = PrivateToken } return testClient, nil } // Is404 checks if the error represents a 404 response func Is404(err error) bool { // If the error is a typed response if errResponse, ok := err.(*gitlab.ErrorResponse); ok && errResponse.Response != nil && errResponse.Response.StatusCode == http.StatusNotFound { return true } // This can also come back as a string 404 from gitlab client-go if err != nil && err.Error() == "404 Not Found" { return true } return false }