|
| 1 | +// Package gitlab provides an HTTP client for the GitLab internal API. |
| 2 | +// It is the replacement for the client and gitlabnet packages and is being |
| 3 | +// introduced incrementally as part of the consolidation described in |
| 4 | +// https://gitlab.com/gitlab-org/gitlab-shell/-/issues/834. |
| 5 | +package gitlab |
| 6 | + |
| 7 | +import ( |
| 8 | + "bytes" |
| 9 | + "context" |
| 10 | + "encoding/json" |
| 11 | + "errors" |
| 12 | + "fmt" |
| 13 | + "io" |
| 14 | + "log/slog" |
| 15 | + "net/http" |
| 16 | + "path" |
| 17 | + "strings" |
| 18 | + "time" |
| 19 | + |
| 20 | + "gitlab.com/gitlab-org/labkit/correlation" |
| 21 | + "gitlab.com/gitlab-org/labkit/v2/httpclient" |
| 22 | + lablog "gitlab.com/gitlab-org/labkit/v2/log" |
| 23 | + |
| 24 | + "gitlab.com/gitlab-org/gitlab-shell/v14/client" |
| 25 | + "gitlab.com/gitlab-org/gitlab-shell/v14/internal/metrics" |
| 26 | +) |
| 27 | + |
| 28 | +const ( |
| 29 | + internalAPIPath = "/api/v4/internal" |
| 30 | + defaultReadTimeout = 300 * time.Second |
| 31 | +) |
| 32 | + |
| 33 | +// Config holds the configuration for the GitLab internal API client. |
| 34 | +type Config struct { |
| 35 | + // GitlabURL is the base URL of the GitLab instance. |
| 36 | + GitlabURL string |
| 37 | + // RelativeURLRoot is an optional relative URL root prefix (for Unix socket URLs). |
| 38 | + RelativeURLRoot string |
| 39 | + // User is the HTTP basic auth username. |
| 40 | + User string |
| 41 | + // Password is the HTTP basic auth password. |
| 42 | + Password string |
| 43 | + // Secret is the HS256 JWT signing secret. Must not be empty. |
| 44 | + Secret string |
| 45 | + // CaFile is the path to a custom CA certificate file. |
| 46 | + CaFile string |
| 47 | + // CaPath is the path to a directory of custom CA certificate files. |
| 48 | + CaPath string |
| 49 | + // ReadTimeoutSeconds is the HTTP read timeout. Defaults to 300s when zero. |
| 50 | + ReadTimeoutSeconds uint64 |
| 51 | +} |
| 52 | + |
| 53 | +// Client is an HTTP client for the GitLab internal API. |
| 54 | +type Client struct { |
| 55 | + inner *httpclient.Client |
| 56 | + host string |
| 57 | + user string |
| 58 | + password string |
| 59 | + secret string |
| 60 | +} |
| 61 | + |
| 62 | +// New creates a new Client from the given Config. |
| 63 | +func New(cfg *Config) (*Client, error) { |
| 64 | + if cfg == nil { |
| 65 | + return nil, errors.New("config must not be nil") |
| 66 | + } |
| 67 | + |
| 68 | + if strings.TrimSpace(cfg.Secret) == "" { |
| 69 | + return nil, errors.New("secret must not be empty") |
| 70 | + } |
| 71 | + |
| 72 | + transport, host, err := buildTransport(cfg) |
| 73 | + if err != nil { |
| 74 | + return nil, err |
| 75 | + } |
| 76 | + |
| 77 | + // Layer cross-cutting concerns on top of the base transport, innermost first: |
| 78 | + // 1. forwardedIPTransport — propagates X-Forwarded-For from context so that |
| 79 | + // GitLab can log the original client IP for SSH-over-HTTP connections. |
| 80 | + // 2. metrics.NewRoundTripper — instruments every request with Prometheus |
| 81 | + // counters/histograms, matching the old config.HTTPClient() behavior. |
| 82 | + // 3. correlation.NewInstrumentedRoundTripper — injects the correlation ID |
| 83 | + // from context as the X-Request-Id header, matching the old transport chain. |
| 84 | + // |
| 85 | + // Retries are handled at the call site via httpclient.Client.DoWithRetry. |
| 86 | + transport = &forwardedIPTransport{next: transport} |
| 87 | + transport = metrics.NewRoundTripper(transport) |
| 88 | + transport = correlation.NewInstrumentedRoundTripper(transport) |
| 89 | + |
| 90 | + timeout := time.Duration(cfg.ReadTimeoutSeconds) * time.Second // #nosec G115 |
| 91 | + if cfg.ReadTimeoutSeconds == 0 { |
| 92 | + timeout = defaultReadTimeout |
| 93 | + } |
| 94 | + |
| 95 | + inner := httpclient.NewWithConfig(&httpclient.Config{ |
| 96 | + Transport: transport, |
| 97 | + Timeout: timeout, |
| 98 | + }) |
| 99 | + |
| 100 | + return &Client{ |
| 101 | + inner: inner, |
| 102 | + host: host, |
| 103 | + user: cfg.User, |
| 104 | + password: cfg.Password, |
| 105 | + secret: cfg.Secret, |
| 106 | + }, nil |
| 107 | +} |
| 108 | + |
| 109 | +// Get makes a GET request to the given internal API path. |
| 110 | +func (c *Client) Get(ctx context.Context, path string) (*http.Response, error) { |
| 111 | + return c.do(ctx, http.MethodGet, path, nil) |
| 112 | +} |
| 113 | + |
| 114 | +// Post makes a POST request to the given internal API path with a JSON body. |
| 115 | +func (c *Client) Post(ctx context.Context, path string, body any) (*http.Response, error) { |
| 116 | + return c.do(ctx, http.MethodPost, path, body) |
| 117 | +} |
| 118 | + |
| 119 | +// do is the single request path for all outbound calls. It exists to keep |
| 120 | +// Get/Post thin and ensure that header injection (JWT, basic auth, User-Agent) |
| 121 | +// is applied consistently regardless of the HTTP method. During the migration |
| 122 | +// from client.GitlabNetClient, additional methods (PUT, DELETE, …) can be |
| 123 | +// added here without duplicating the auth logic. |
| 124 | +func (c *Client) do(ctx context.Context, method, apiPath string, data any) (*http.Response, error) { |
| 125 | + normalized, err := normalizePath(apiPath) |
| 126 | + if err != nil { |
| 127 | + return nil, err |
| 128 | + } |
| 129 | + url := strings.TrimSuffix(c.host, "/") + normalized |
| 130 | + |
| 131 | + var ( |
| 132 | + bodyReader io.Reader |
| 133 | + encoded []byte |
| 134 | + ) |
| 135 | + if data != nil { |
| 136 | + var marshalErr error |
| 137 | + encoded, marshalErr = json.Marshal(data) |
| 138 | + if marshalErr != nil { |
| 139 | + return nil, fmt.Errorf("marshaling request body: %w", marshalErr) |
| 140 | + } |
| 141 | + bodyReader = bytes.NewReader(encoded) |
| 142 | + } |
| 143 | + |
| 144 | + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) |
| 145 | + if err != nil { |
| 146 | + return nil, err |
| 147 | + } |
| 148 | + |
| 149 | + // Provide GetBody so DoWithRetry can restore the request body between |
| 150 | + // retry attempts. bytes.NewReader supports Reset but http.Request.Body is |
| 151 | + // an io.ReadCloser consumed after the first attempt; GetBody is the |
| 152 | + // standard mechanism for re-creating it. |
| 153 | + if len(encoded) > 0 { |
| 154 | + snapshot := encoded |
| 155 | + req.GetBody = func() (io.ReadCloser, error) { |
| 156 | + return io.NopCloser(bytes.NewReader(snapshot)), nil |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + if err = c.setHeaders(req); err != nil { |
| 161 | + return nil, err |
| 162 | + } |
| 163 | + |
| 164 | + resp, err := c.inner.DoWithRetry(req, nil) |
| 165 | + if err != nil { |
| 166 | + slog.ErrorContext(ctx, "Internal API unreachable", lablog.ErrorMessage(err.Error())) |
| 167 | + return nil, &client.APIError{Msg: "Internal API unreachable"} |
| 168 | + } |
| 169 | + return resp, nil |
| 170 | +} |
| 171 | + |
| 172 | +// setHeaders stamps every outbound request with the three auth/identity |
| 173 | +// signals that the GitLab internal API requires: |
| 174 | +// |
| 175 | +// - Basic auth — used by some GitLab deployments that sit behind an |
| 176 | +// nginx auth_basic gate; mirrored from HTTPSettingsConfig.User/Password |
| 177 | +// in the existing config package. Both username and password must be |
| 178 | +// non-empty, matching the behavior of the old client.GitlabNetClient. |
| 179 | +// - JWT bearer token — the primary machine-to-machine secret; the Rails |
| 180 | +// side validates the HS256 signature and rejects requests whose token has |
| 181 | +// expired or was signed with the wrong key. |
| 182 | +// - User-Agent — helps GitLab ops distinguish gitlab-shell traffic in |
| 183 | +// access logs; kept identical to the value used by client.GitlabNetClient |
| 184 | +// so log-based dashboards do not need updating during the migration. |
| 185 | +func (c *Client) setHeaders(req *http.Request) error { |
| 186 | + if c.user != "" && c.password != "" { |
| 187 | + req.SetBasicAuth(c.user, c.password) |
| 188 | + } |
| 189 | + |
| 190 | + token, err := c.jwtToken() |
| 191 | + if err != nil { |
| 192 | + return err |
| 193 | + } |
| 194 | + |
| 195 | + req.Header.Set(apiSecretHeaderName, token) |
| 196 | + req.Header.Set("Content-Type", "application/json") |
| 197 | + req.Header.Set("User-Agent", defaultUserAgent) |
| 198 | + |
| 199 | + return nil |
| 200 | +} |
| 201 | + |
| 202 | +// normalizePath ensures every path is rooted under /api/v4/internal. This |
| 203 | +// mirrors the logic in client.GitlabNetClient so that callers migrated to |
| 204 | +// this package can pass the same short paths (e.g. "/check", "lfs/objects") |
| 205 | +// without any changes at the call site. |
| 206 | +// |
| 207 | +// path.Clean is applied after prefixing to collapse any traversal segments |
| 208 | +// (e.g. "/../") and repeated slashes. An error is returned if the cleaned |
| 209 | +// result no longer starts with /api/v4/internal, which would indicate a |
| 210 | +// traversal attempt escaping the internal API prefix. |
| 211 | +func normalizePath(p string) (string, error) { |
| 212 | + if !strings.HasPrefix(p, "/") { |
| 213 | + p = "/" + p |
| 214 | + } |
| 215 | + if !strings.HasPrefix(p, internalAPIPath) { |
| 216 | + p = internalAPIPath + p |
| 217 | + } |
| 218 | + cleaned := path.Clean(p) |
| 219 | + if !strings.HasPrefix(cleaned, internalAPIPath) { |
| 220 | + return "", fmt.Errorf("path %q escapes the internal API prefix", p) |
| 221 | + } |
| 222 | + return cleaned, nil |
| 223 | +} |
0 commit comments