Skip to content

Commit 0483260

Browse files
e_forbesclaude
andcommitted
Split client.go into focused files per reviewer suggestion
- client.go — Config, Client, New, Get, Post, do, setHeaders, normalizePath - auth.go — jwtToken and JWT constants - transport.go — buildTransport, socket/HTTPS helpers, forwardedIPTransport, CA helpers - response.go — ParseJSON No behaviour changes; pure file reorganisation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1737e74 commit 0483260

4 files changed

Lines changed: 253 additions & 220 deletions

File tree

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: 4 additions & 220 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,15 @@ package gitlab
77
import (
88
"bytes"
99
"context"
10-
"crypto/tls"
11-
"crypto/x509"
1210
"encoding/json"
1311
"errors"
1412
"fmt"
1513
"io"
16-
"net"
1714
"net/http"
18-
"os"
1915
"path"
20-
"path/filepath"
2116
"strings"
2217
"time"
2318

24-
"github.com/golang-jwt/jwt/v5"
2519
"gitlab.com/gitlab-org/labkit/correlation"
2620
"gitlab.com/gitlab-org/labkit/v2/httpclient"
2721

@@ -30,17 +24,8 @@ import (
3024
)
3125

3226
const (
33-
internalAPIPath = "/api/v4/internal"
34-
apiSecretHeaderName = "Gitlab-Shell-Api-Request" // #nosec G101
35-
defaultUserAgent = "GitLab-Shell"
36-
jwtTTL = time.Minute
37-
jwtIssuer = "gitlab-shell"
38-
defaultReadTimeout = 300 * time.Second
39-
40-
socketBaseURL = "http://unix"
41-
unixSocketProtocol = "http+unix://"
42-
httpProtocol = "http://"
43-
httpsProtocol = "https://"
27+
internalAPIPath = "/api/v4/internal"
28+
defaultReadTimeout = 300 * time.Second
4429
)
4530

4631
// Config holds the configuration for the GitLab internal API client.
@@ -63,30 +48,6 @@ type Config struct {
6348
ReadTimeoutSeconds uint64
6449
}
6550

66-
// ParseJSON decodes a successful (< 400) HTTP response body into dst, or
67-
// returns a *client.APIError describing the failure. The error semantics
68-
// deliberately match those of the old client package so that sub-clients
69-
// migrated to this package do not need to change their error handling:
70-
//
71-
// - 4xx/5xx with a JSON {"message":"…"} body → *client.APIError{Msg: message}
72-
// - 4xx/5xx with no decodable message → *client.APIError{Msg: "Internal API error (N)"}
73-
// - 2xx with non-JSON body → errors.New("parsing failed")
74-
func ParseJSON(resp *http.Response, dst any) error {
75-
if resp.StatusCode >= 400 {
76-
var errResp struct {
77-
Message string `json:"message"`
78-
}
79-
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil || errResp.Message == "" {
80-
return &client.APIError{Msg: fmt.Sprintf("Internal API error (%d)", resp.StatusCode)}
81-
}
82-
return &client.APIError{Msg: errResp.Message}
83-
}
84-
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
85-
return errors.New("parsing failed")
86-
}
87-
return nil
88-
}
89-
9051
// Client is an HTTP client for the GitLab internal API.
9152
type Client struct {
9253
inner *httpclient.Client
@@ -158,8 +119,8 @@ func (c *Client) Post(ctx context.Context, path string, body any) (*http.Respons
158119
// is applied consistently regardless of the HTTP method. During the migration
159120
// from client.GitlabNetClient, additional methods (PUT, DELETE, …) can be
160121
// added here without duplicating the auth logic.
161-
func (c *Client) do(ctx context.Context, method, path string, data any) (*http.Response, error) {
162-
normalized, err := normalizePath(path)
122+
func (c *Client) do(ctx context.Context, method, apiPath string, data any) (*http.Response, error) {
123+
normalized, err := normalizePath(apiPath)
163124
if err != nil {
164125
return nil, err
165126
}
@@ -235,21 +196,6 @@ func (c *Client) setHeaders(req *http.Request) error {
235196
return nil
236197
}
237198

238-
// jwtToken mints a short-lived HS256 token signed with the shared secret.
239-
// A fresh token is generated per request because the TTL is only one minute;
240-
// reusing a cached token across requests risks sending an expired credential
241-
// if the caller batches requests or retries after a delay. This matches the
242-
// behavior of client.GitlabNetClient.DoRequest.
243-
func (c *Client) jwtToken() (string, error) {
244-
now := time.Now()
245-
claims := jwt.RegisteredClaims{
246-
Issuer: jwtIssuer,
247-
IssuedAt: jwt.NewNumericDate(now),
248-
ExpiresAt: jwt.NewNumericDate(now.Add(jwtTTL)),
249-
}
250-
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(strings.TrimSpace(c.secret)))
251-
}
252-
253199
// normalizePath ensures every path is rooted under /api/v4/internal. This
254200
// mirrors the logic in client.GitlabNetClient so that callers migrated to
255201
// this package can pass the same short paths (e.g. "/check", "lfs/objects")
@@ -272,165 +218,3 @@ func normalizePath(p string) (string, error) {
272218
}
273219
return cleaned, nil
274220
}
275-
276-
// buildTransport selects the appropriate base http.RoundTripper for the
277-
// configured URL scheme and returns it alongside the resolved host string
278-
// that all request URLs will be prefixed with. The three schemes below
279-
// replicate the behavior of client.NewHTTPClientWithOpts, which is the
280-
// function this package is intended to replace:
281-
//
282-
// - http+unix:// — GitLab Workhorse / Rails communicate over a Unix domain
283-
// socket in most single-node deployments. The standard net/http stack does
284-
// not understand this scheme, so the DialContext is overridden to open a
285-
// unix socket, and the URL is rewritten to http://unix/… so that the
286-
// net/http request machinery produces valid Host headers.
287-
// - http:// — plain TCP; no additional transport configuration required.
288-
// - https:// — TLS with optional custom CA bundle; see buildHTTPSTransport.
289-
//
290-
// The returned transport is passed to httpclient.NewWithConfig as
291-
// Config.Transport, which layers LabKit's OTel tracing and structured logging
292-
// on top of it.
293-
func buildTransport(cfg *Config) (http.RoundTripper, string, error) {
294-
switch {
295-
case strings.HasPrefix(cfg.GitlabURL, unixSocketProtocol):
296-
t, host := buildSocketTransport(cfg.GitlabURL, cfg.RelativeURLRoot)
297-
return t, host, nil
298-
case strings.HasPrefix(cfg.GitlabURL, httpProtocol):
299-
return &http.Transport{}, cfg.GitlabURL, nil
300-
case strings.HasPrefix(cfg.GitlabURL, httpsProtocol):
301-
t, err := buildHTTPSTransport(cfg)
302-
if err != nil {
303-
return nil, "", err
304-
}
305-
return t, cfg.GitlabURL, nil
306-
default:
307-
return nil, "", errors.New("unknown GitLab URL prefix")
308-
}
309-
}
310-
311-
// buildSocketTransport creates a transport that dials over a Unix domain
312-
// socket. Because net/http requires an HTTP host in request URLs, the host
313-
// is set to the synthetic value "http://unix" (with an optional relative URL
314-
// root appended). The DialContext ignores the network/address arguments
315-
// supplied by net/http and always opens the socket path extracted from the
316-
// original http+unix:// URL, which is the same approach used by the existing
317-
// client package.
318-
func buildSocketTransport(gitlabURL, relativeURLRoot string) (http.RoundTripper, string) {
319-
socketPath := strings.TrimPrefix(gitlabURL, unixSocketProtocol)
320-
transport := &http.Transport{
321-
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
322-
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
323-
},
324-
}
325-
host := socketBaseURL
326-
if r := strings.Trim(relativeURLRoot, "/"); r != "" {
327-
host = host + "/" + r
328-
}
329-
return transport, host
330-
}
331-
332-
// buildHTTPSTransport constructs a TLS-enabled transport. GitLab installations
333-
// that use a privately-signed certificate (common in self-managed deployments)
334-
// pass either a single CA file or a directory of CA files via the config YAML.
335-
// Both are loaded into the cert pool so that TLS handshakes succeed without
336-
// disabling certificate verification. The minimum TLS version is pinned to
337-
// 1.2 to match the existing client package and GitLab's own TLS policy.
338-
//
339-
// An error is returned if a specified CA file cannot be read or if it contains
340-
// no valid PEM certificates, making misconfigured TLS explicit at startup
341-
// rather than failing silently at connection time.
342-
func buildHTTPSTransport(cfg *Config) (http.RoundTripper, error) {
343-
certPool, err := x509.SystemCertPool()
344-
if err != nil {
345-
certPool = x509.NewCertPool()
346-
}
347-
348-
if err := appendCaFile(certPool, cfg.CaFile); err != nil {
349-
return nil, err
350-
}
351-
if err := appendCaDir(certPool, cfg.CaPath); err != nil {
352-
return nil, err
353-
}
354-
355-
return &http.Transport{
356-
TLSClientConfig: &tls.Config{
357-
RootCAs: certPool,
358-
MinVersion: tls.VersionTLS12,
359-
},
360-
}, nil
361-
}
362-
363-
// appendCaFile loads a single PEM CA certificate file into pool.
364-
func appendCaFile(pool *x509.CertPool, caFile string) error {
365-
if caFile == "" {
366-
return nil
367-
}
368-
cert, err := os.ReadFile(filepath.Clean(caFile))
369-
if err != nil {
370-
return fmt.Errorf("reading CA file %q: %w", caFile, err)
371-
}
372-
if !pool.AppendCertsFromPEM(cert) {
373-
return fmt.Errorf("CA file %q contains no valid PEM certificates", caFile)
374-
}
375-
return nil
376-
}
377-
378-
// appendCaDir loads every certificate file found in caPath into pool.
379-
// Non-certificate files (e.g. README, .keep) are skipped before reading to
380-
// avoid unnecessary I/O. Only .pem and .crt files are considered; files with
381-
// those extensions that contain no valid PEM certificates are treated as errors.
382-
func appendCaDir(pool *x509.CertPool, caPath string) error {
383-
if caPath == "" {
384-
return nil
385-
}
386-
entries, err := os.ReadDir(caPath)
387-
if err != nil {
388-
return fmt.Errorf("reading CA directory %q: %w", caPath, err)
389-
}
390-
for _, entry := range entries {
391-
if entry.IsDir() {
392-
continue
393-
}
394-
if !isCertificateFile(entry.Name()) {
395-
continue
396-
}
397-
if err := appendCaDirEntry(pool, caPath, entry.Name()); err != nil {
398-
return err
399-
}
400-
}
401-
return nil
402-
}
403-
404-
func isCertificateFile(name string) bool {
405-
return strings.HasSuffix(name, ".pem") || strings.HasSuffix(name, ".crt")
406-
}
407-
408-
func appendCaDirEntry(pool *x509.CertPool, dir, name string) error {
409-
p := filepath.Join(dir, name)
410-
cert, err := os.ReadFile(p) // #nosec G304
411-
if err != nil {
412-
return fmt.Errorf("reading CA file %q: %w", p, err)
413-
}
414-
if !pool.AppendCertsFromPEM(cert) {
415-
return fmt.Errorf("CA file %q contains no valid PEM certificates", p)
416-
}
417-
return nil
418-
}
419-
420-
// forwardedIPTransport propagates the original client IP address as the
421-
// X-Forwarded-For header on every outbound request. The IP is read from the
422-
// request context using client.OriginalRemoteIPContextKey, which is set by the
423-
// SSH server when it accepts a connection. This matches the behavior of the old
424-
// client.transport.RoundTrip so that GitLab access logs continue to show the
425-
// real client IP rather than the gitlab-shell host address.
426-
type forwardedIPTransport struct {
427-
next http.RoundTripper
428-
}
429-
430-
func (t *forwardedIPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
431-
if ip, ok := req.Context().Value(client.OriginalRemoteIPContextKey{}).(string); ok && ip != "" {
432-
req = req.Clone(req.Context())
433-
req.Header.Add("X-Forwarded-For", ip)
434-
}
435-
return t.next.RoundTrip(req)
436-
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package gitlab
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
9+
"gitlab.com/gitlab-org/gitlab-shell/v14/client"
10+
)
11+
12+
// ParseJSON decodes a successful (< 400) HTTP response body into dst, or
13+
// returns a *client.APIError describing the failure. The error semantics
14+
// deliberately match those of the old client package so that sub-clients
15+
// migrated to this package do not need to change their error handling:
16+
//
17+
// - 4xx/5xx with a JSON {"message":"…"} body → *client.APIError{Msg: message}
18+
// - 4xx/5xx with no decodable message → *client.APIError{Msg: "Internal API error (N)"}
19+
// - 2xx with non-JSON body → errors.New("parsing failed")
20+
func ParseJSON(resp *http.Response, dst any) error {
21+
if resp.StatusCode >= 400 {
22+
var errResp struct {
23+
Message string `json:"message"`
24+
}
25+
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil || errResp.Message == "" {
26+
return &client.APIError{Msg: fmt.Sprintf("Internal API error (%d)", resp.StatusCode)}
27+
}
28+
return &client.APIError{Msg: errResp.Message}
29+
}
30+
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
31+
return errors.New("parsing failed")
32+
}
33+
return nil
34+
}

0 commit comments

Comments
 (0)