Skip to content

Commit 8eac692

Browse files
Igor Drozdove_forbes
authored andcommitted
Merge branch 'ef-bootstrap-internal-gitlab-client' into 'main'
Bootstrap internal gitlab client using labkit v2 httpclient Closes #834 See merge request https://gitlab.com/gitlab-org/gitlab-shell/-/merge_requests/1395 Merged-by: Igor Drozdov <idrozdov@gitlab.com> Approved-by: Vasilii Iakliushin <viakliushin@gitlab.com> Approved-by: Igor Drozdov <idrozdov@gitlab.com> Reviewed-by: Vasilii Iakliushin <viakliushin@gitlab.com> Reviewed-by: GitLab Duo <gitlab-duo@gitlab.com> Co-authored-by: e_forbes <eforbes@gitlab.com>
2 parents b66c79c + ecc1f0c commit 8eac692

7 files changed

Lines changed: 882 additions & 3 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ require (
1818
gitlab.com/gitlab-org/cells/topology-service v0.0.0-20260213143839-af4593cd7194
1919
gitlab.com/gitlab-org/gitaly/v18 v18.9.0-rc4
2020
gitlab.com/gitlab-org/labkit v1.40.1
21-
gitlab.com/gitlab-org/labkit/v2 v2.0.0-20260303104025-2b90740e814f
21+
gitlab.com/gitlab-org/labkit/v2 v2.0.0-20260310142729-826b25a7b550
2222
golang.org/x/crypto v0.48.0
2323
golang.org/x/sync v0.19.0
2424
google.golang.org/grpc v1.79.1

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -551,8 +551,8 @@ gitlab.com/gitlab-org/go/reopen v1.0.0 h1:6BujZ0lkkjGIejTUJdNO1w56mN1SI10qcVQyQl
551551
gitlab.com/gitlab-org/go/reopen v1.0.0/go.mod h1:D6OID8YJDzEVZNYW02R/Pkj0v8gYFSIhXFTArAsBQw8=
552552
gitlab.com/gitlab-org/labkit v1.40.1 h1:4Rx+uI1Ht1i9jDw8KxOv8VrR37+cR4VXBtM3cW5tlXY=
553553
gitlab.com/gitlab-org/labkit v1.40.1/go.mod h1:9P1ZHh9uXr1dY2w1m2heXPZ+BZsSlWimbn7QmxTfpbM=
554-
gitlab.com/gitlab-org/labkit/v2 v2.0.0-20260303104025-2b90740e814f h1:PfMcv8Fi9/iYmHB5t4eF7n4OKc8MRtXTyLIHn/gZzTM=
555-
gitlab.com/gitlab-org/labkit/v2 v2.0.0-20260303104025-2b90740e814f/go.mod h1:Ib+MfYHWfDFujVkC/m00UWkCb7bBP/FCNxUiBjmNXNU=
554+
gitlab.com/gitlab-org/labkit/v2 v2.0.0-20260310142729-826b25a7b550 h1:0ln145LrtqJyyg0t/MgtACAGRvmtel3XQ95HWH64MrY=
555+
gitlab.com/gitlab-org/labkit/v2 v2.0.0-20260310142729-826b25a7b550/go.mod h1:DdL2IZrztOm0tVkJfZjY03sdcdwaDgDXUxqeuFniTBg=
556556
go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ=
557557
go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo=
558558
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=

internal/clients/gitlab/auth.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package gitlab
2+
3+
import (
4+
"strings"
5+
"time"
6+
7+
"github.com/golang-jwt/jwt/v5"
8+
)
9+
10+
const (
11+
apiSecretHeaderName = "Gitlab-Shell-Api-Request" // #nosec G101
12+
defaultUserAgent = "GitLab-Shell"
13+
jwtTTL = time.Minute
14+
jwtIssuer = "gitlab-shell"
15+
)
16+
17+
// jwtToken mints a short-lived HS256 token signed with the shared secret.
18+
// A fresh token is generated per request because the TTL is only one minute;
19+
// reusing a cached token across requests risks sending an expired credential
20+
// if the caller batches requests or retries after a delay. This matches the
21+
// behavior of client.GitlabNetClient.DoRequest.
22+
func (c *Client) jwtToken() (string, error) {
23+
now := time.Now()
24+
claims := jwt.RegisteredClaims{
25+
Issuer: jwtIssuer,
26+
IssuedAt: jwt.NewNumericDate(now),
27+
ExpiresAt: jwt.NewNumericDate(now.Add(jwtTTL)),
28+
}
29+
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(strings.TrimSpace(c.secret)))
30+
}

internal/clients/gitlab/client.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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

Comments
 (0)