@@ -7,21 +7,15 @@ package gitlab
77import (
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
3226const (
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.
9152type 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- }
0 commit comments