detached 67b31fc

fix(registry): secure internal loopback auth

commit
67b31fc12c17aaf7c81c9d0140d6b1f9211bc69c
author
bnema · · 2026-01-13 20:24 UTC
committer
bnema · · 2026-01-13 20:24 UTC
parent
1eea3ef
signature
unsigned
7 files · +274 -70
M internal/adapters/in/http/middleware/auth.go +27 -5
@@ -79,20 +79,27 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string,
7979 return authenticated
8080 }
8181
82+// InternalRegistryAuth holds the credentials used for loopback-only registry access.
83+// These are generated per Gordon instance and are never exposed in config.
84+type InternalRegistryAuth struct {
85+ Username string
86+ Password string
87+}
88+
8289 // RegistryAuthV2 middleware provides enhanced Docker Registry authentication
8390 // supporting both password and token-based authentication.
84-func RegistryAuthV2(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler {
91+func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, log zerowrap.Logger) func(http.Handler) http.Handler {
8592 return func(next http.Handler) http.Handler {
8693 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87- // Allow localhost requests without auth (internal pulls from Gordon itself)
88- if isLocalhostRequest(r) {
94+ // Allow localhost requests only with internal instance credentials.
95+ if isLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
8996 log.Debug().
9097 Str(zerowrap.FieldLayer, "adapter").
9198 Str(zerowrap.FieldAdapter, "http").
9299 Str(zerowrap.FieldMethod, r.Method).
93100 Str(zerowrap.FieldPath, r.URL.Path).
94101 Str(zerowrap.FieldClientIP, r.RemoteAddr).
95- Msg("localhost request - skipping auth")
102+ Msg("localhost request with internal auth - skipping auth")
96103 next.ServeHTTP(w, r)
97104 return
98105 }
@@ -236,7 +243,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
236243 }
237244
238245 // isLocalhostRequest checks if the request originates from localhost.
239-// This is used to allow Gordon to pull from its own registry without auth.
246+// This is used to allow Gordon to pull from its own registry with internal auth.
240247 func isLocalhostRequest(r *http.Request) bool {
241248 host := r.RemoteAddr
242249 // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345"
@@ -249,6 +256,21 @@ func isLocalhostRequest(r *http.Request) bool {
249256 return false
250257 }
251258
259+func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
260+ if internalAuth.Username == "" || internalAuth.Password == "" {
261+ return false
262+ }
263+
264+ username, password, ok := r.BasicAuth()
265+ if !ok {
266+ return false
267+ }
268+
269+ usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(internalAuth.Username)) == 1
270+ passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(internalAuth.Password)) == 1
271+ return usernameMatch && passwordMatch
272+}
273+
252274 // sendUnauthorized sends an HTTP 401 response with appropriate headers.
253275 func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
254276 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
@@ -79,20 +79,27 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string,
79 return authenticated79 return authenticated
80 }80 }
81 81
82+// InternalRegistryAuth holds the credentials used for loopback-only registry access.
83+// These are generated per Gordon instance and are never exposed in config.
84+type InternalRegistryAuth struct {
85+ Username string
86+ Password string
87+}
88+
82 // RegistryAuthV2 middleware provides enhanced Docker Registry authentication89 // RegistryAuthV2 middleware provides enhanced Docker Registry authentication
83 // supporting both password and token-based authentication.90 // supporting both password and token-based authentication.
84-func RegistryAuthV2(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler {91+func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, log zerowrap.Logger) func(http.Handler) http.Handler {
85 return func(next http.Handler) http.Handler {92 return func(next http.Handler) http.Handler {
86 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {93 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87- // Allow localhost requests without auth (internal pulls from Gordon itself)94+ // Allow localhost requests only with internal instance credentials.
88- if isLocalhostRequest(r) {95+ if isLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
89 log.Debug().96 log.Debug().
90 Str(zerowrap.FieldLayer, "adapter").97 Str(zerowrap.FieldLayer, "adapter").
91 Str(zerowrap.FieldAdapter, "http").98 Str(zerowrap.FieldAdapter, "http").
92 Str(zerowrap.FieldMethod, r.Method).99 Str(zerowrap.FieldMethod, r.Method).
93 Str(zerowrap.FieldPath, r.URL.Path).100 Str(zerowrap.FieldPath, r.URL.Path).
94 Str(zerowrap.FieldClientIP, r.RemoteAddr).101 Str(zerowrap.FieldClientIP, r.RemoteAddr).
95- Msg("localhost request - skipping auth")102+ Msg("localhost request with internal auth - skipping auth")
96 next.ServeHTTP(w, r)103 next.ServeHTTP(w, r)
97 return104 return
98 }105 }
@@ -236,7 +243,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
236 }243 }
237 244
238 // isLocalhostRequest checks if the request originates from localhost.245 // isLocalhostRequest checks if the request originates from localhost.
239-// This is used to allow Gordon to pull from its own registry without auth.246+// This is used to allow Gordon to pull from its own registry with internal auth.
240 func isLocalhostRequest(r *http.Request) bool {247 func isLocalhostRequest(r *http.Request) bool {
241 host := r.RemoteAddr248 host := r.RemoteAddr
242 // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345"249 // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345"
@@ -249,6 +256,21 @@ func isLocalhostRequest(r *http.Request) bool {
249 return false256 return false
250 }257 }
251 258
259+func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
260+ if internalAuth.Username == "" || internalAuth.Password == "" {
261+ return false
262+ }
263+
264+ username, password, ok := r.BasicAuth()
265+ if !ok {
266+ return false
267+ }
268+
269+ usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(internalAuth.Username)) == 1
270+ passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(internalAuth.Password)) == 1
271+ return usernameMatch && passwordMatch
272+}
273+
252 // sendUnauthorized sends an HTTP 401 response with appropriate headers.274 // sendUnauthorized sends an HTTP 401 response with appropriate headers.
253 func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {275 func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
254 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")276 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
M internal/adapters/in/http/middleware/auth_test.go +145 -0
@@ -1,13 +1,18 @@
11 package middleware
22
33 import (
4+ "context"
45 "encoding/base64"
6+ "errors"
57 "net/http"
68 "net/http/httptest"
79 "testing"
10+ "time"
811
912 "github.com/bnema/zerowrap"
1013 "github.com/stretchr/testify/assert"
14+
15+ "gordon/internal/domain"
1116 )
1217
1318 func testLogger() zerowrap.Logger {
@@ -347,3 +352,143 @@ func TestRegistryAuth_Base64EdgeCases(t *testing.T) {
347352 })
348353 }
349354 }
355+
356+func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
357+ log := testLogger()
358+ called := false
359+ authSvc := stubAuthService{
360+ enabled: true,
361+ authType: domain.AuthTypePassword,
362+ validatePassword: func(_ context.Context, _ string, _ string) bool {
363+ called = true
364+ return false
365+ },
366+ }
367+ internalAuth := InternalRegistryAuth{
368+ Username: "internal",
369+ Password: "secret",
370+ }
371+
372+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
373+ w.WriteHeader(http.StatusOK)
374+ }))
375+
376+ req := httptest.NewRequest("GET", "/v2/", nil)
377+ req.RemoteAddr = "127.0.0.1:12345"
378+ req.SetBasicAuth("internal", "secret")
379+ rec := httptest.NewRecorder()
380+
381+ handler.ServeHTTP(rec, req)
382+
383+ assert.Equal(t, http.StatusOK, rec.Code)
384+ assert.False(t, called, "auth service should not be used for internal loopback auth")
385+}
386+
387+func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
388+ log := testLogger()
389+ called := false
390+ authSvc := stubAuthService{
391+ enabled: true,
392+ authType: domain.AuthTypePassword,
393+ validatePassword: func(_ context.Context, _ string, _ string) bool {
394+ called = true
395+ return false
396+ },
397+ }
398+ internalAuth := InternalRegistryAuth{
399+ Username: "internal",
400+ Password: "secret",
401+ }
402+
403+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
404+ w.WriteHeader(http.StatusOK)
405+ }))
406+
407+ req := httptest.NewRequest("GET", "/v2/", nil)
408+ req.RemoteAddr = "127.0.0.1:12345"
409+ req.SetBasicAuth("internal", "wrong")
410+ rec := httptest.NewRecorder()
411+
412+ handler.ServeHTTP(rec, req)
413+
414+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
415+ assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
416+ assert.True(t, called, "auth service should be used when internal auth fails")
417+}
418+
419+func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
420+ log := testLogger()
421+ called := false
422+ authSvc := stubAuthService{
423+ enabled: true,
424+ authType: domain.AuthTypePassword,
425+ validatePassword: func(_ context.Context, _ string, _ string) bool {
426+ called = true
427+ return false
428+ },
429+ }
430+ internalAuth := InternalRegistryAuth{
431+ Username: "internal",
432+ Password: "secret",
433+ }
434+
435+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
436+ w.WriteHeader(http.StatusOK)
437+ }))
438+
439+ req := httptest.NewRequest("GET", "/v2/", nil)
440+ req.RemoteAddr = "192.168.1.10:12345"
441+ req.SetBasicAuth("internal", "secret")
442+ rec := httptest.NewRecorder()
443+
444+ handler.ServeHTTP(rec, req)
445+
446+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
447+ assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
448+ assert.True(t, called, "auth service should be used for non-localhost requests")
449+}
450+
451+type stubAuthService struct {
452+ enabled bool
453+ authType domain.AuthType
454+ validatePassword func(ctx context.Context, username, password string) bool
455+ validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
456+}
457+
458+func (s stubAuthService) GetAuthType() domain.AuthType {
459+ return s.authType
460+}
461+
462+func (s stubAuthService) IsEnabled() bool {
463+ return s.enabled
464+}
465+
466+func (s stubAuthService) ValidatePassword(ctx context.Context, username, password string) bool {
467+ if s.validatePassword != nil {
468+ return s.validatePassword(ctx, username, password)
469+ }
470+ return false
471+}
472+
473+func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
474+ if s.validateToken != nil {
475+ return s.validateToken(ctx, tokenString)
476+ }
477+ return nil, errors.New("not implemented")
478+}
479+
480+func (s stubAuthService) GenerateToken(context.Context, string, []string, time.Duration) (string, error) {
481+ return "", errors.New("not implemented")
482+}
483+
484+func (s stubAuthService) RevokeToken(context.Context, string) error {
485+ return errors.New("not implemented")
486+}
487+
488+func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
489+ return nil, errors.New("not implemented")
490+}
491+
492+func (s stubAuthService) GeneratePasswordHash(string) (string, error) {
493+ return "", errors.New("not implemented")
494+}
@@ -1,13 +1,18 @@
1 package middleware1 package middleware
2 2
3 import (3 import (
4+ "context"
4 "encoding/base64"5 "encoding/base64"
6+ "errors"
5 "net/http"7 "net/http"
6 "net/http/httptest"8 "net/http/httptest"
7 "testing"9 "testing"
10+ "time"
8 11
9 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
10 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
14+
15+ "gordon/internal/domain"
11 )16 )
12 17
13 func testLogger() zerowrap.Logger {18 func testLogger() zerowrap.Logger {
@@ -347,3 +352,143 @@ func TestRegistryAuth_Base64EdgeCases(t *testing.T) {
347 })352 })
348 }353 }
349 }354 }
355+
356+func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
357+ log := testLogger()
358+ called := false
359+ authSvc := stubAuthService{
360+ enabled: true,
361+ authType: domain.AuthTypePassword,
362+ validatePassword: func(_ context.Context, _ string, _ string) bool {
363+ called = true
364+ return false
365+ },
366+ }
367+ internalAuth := InternalRegistryAuth{
368+ Username: "internal",
369+ Password: "secret",
370+ }
371+
372+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
373+ w.WriteHeader(http.StatusOK)
374+ }))
375+
376+ req := httptest.NewRequest("GET", "/v2/", nil)
377+ req.RemoteAddr = "127.0.0.1:12345"
378+ req.SetBasicAuth("internal", "secret")
379+ rec := httptest.NewRecorder()
380+
381+ handler.ServeHTTP(rec, req)
382+
383+ assert.Equal(t, http.StatusOK, rec.Code)
384+ assert.False(t, called, "auth service should not be used for internal loopback auth")
385+}
386+
387+func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
388+ log := testLogger()
389+ called := false
390+ authSvc := stubAuthService{
391+ enabled: true,
392+ authType: domain.AuthTypePassword,
393+ validatePassword: func(_ context.Context, _ string, _ string) bool {
394+ called = true
395+ return false
396+ },
397+ }
398+ internalAuth := InternalRegistryAuth{
399+ Username: "internal",
400+ Password: "secret",
401+ }
402+
403+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
404+ w.WriteHeader(http.StatusOK)
405+ }))
406+
407+ req := httptest.NewRequest("GET", "/v2/", nil)
408+ req.RemoteAddr = "127.0.0.1:12345"
409+ req.SetBasicAuth("internal", "wrong")
410+ rec := httptest.NewRecorder()
411+
412+ handler.ServeHTTP(rec, req)
413+
414+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
415+ assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
416+ assert.True(t, called, "auth service should be used when internal auth fails")
417+}
418+
419+func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
420+ log := testLogger()
421+ called := false
422+ authSvc := stubAuthService{
423+ enabled: true,
424+ authType: domain.AuthTypePassword,
425+ validatePassword: func(_ context.Context, _ string, _ string) bool {
426+ called = true
427+ return false
428+ },
429+ }
430+ internalAuth := InternalRegistryAuth{
431+ Username: "internal",
432+ Password: "secret",
433+ }
434+
435+ handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
436+ w.WriteHeader(http.StatusOK)
437+ }))
438+
439+ req := httptest.NewRequest("GET", "/v2/", nil)
440+ req.RemoteAddr = "192.168.1.10:12345"
441+ req.SetBasicAuth("internal", "secret")
442+ rec := httptest.NewRecorder()
443+
444+ handler.ServeHTTP(rec, req)
445+
446+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
447+ assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
448+ assert.True(t, called, "auth service should be used for non-localhost requests")
449+}
450+
451+type stubAuthService struct {
452+ enabled bool
453+ authType domain.AuthType
454+ validatePassword func(ctx context.Context, username, password string) bool
455+ validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
456+}
457+
458+func (s stubAuthService) GetAuthType() domain.AuthType {
459+ return s.authType
460+}
461+
462+func (s stubAuthService) IsEnabled() bool {
463+ return s.enabled
464+}
465+
466+func (s stubAuthService) ValidatePassword(ctx context.Context, username, password string) bool {
467+ if s.validatePassword != nil {
468+ return s.validatePassword(ctx, username, password)
469+ }
470+ return false
471+}
472+
473+func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
474+ if s.validateToken != nil {
475+ return s.validateToken(ctx, tokenString)
476+ }
477+ return nil, errors.New("not implemented")
478+}
479+
480+func (s stubAuthService) GenerateToken(context.Context, string, []string, time.Duration) (string, error) {
481+ return "", errors.New("not implemented")
482+}
483+
484+func (s stubAuthService) RevokeToken(context.Context, string) error {
485+ return errors.New("not implemented")
486+}
487+
488+func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
489+ return nil, errors.New("not implemented")
490+}
491+
492+func (s stubAuthService) GeneratePasswordHash(string) (string, error) {
493+ return "", errors.New("not implemented")
494+}
M internal/app/run.go +51 -14
@@ -3,6 +3,8 @@ package app
33
44 import (
55 "context"
6+ "crypto/rand"
7+ "encoding/hex"
68 "fmt"
79 "net/http"
810 "os"
@@ -104,6 +106,8 @@ type services struct {
104106 proxySvc *proxy.Service
105107 authSvc *auth.Service
106108 tokenHandler *registry.TokenHandler
109+ internalRegUser string
110+ internalRegPass string
107111 }
108112
109113 // Run initializes and starts the Gordon application.
@@ -232,6 +236,15 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
232236 return nil, err
233237 }
234238
239+ // Generate internal registry credentials for loopback-only pulls
240+ if cfg.RegistryAuth.Enabled {
241+ svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
242+ if err != nil {
243+ return nil, log.WrapErr(err, "failed to generate internal registry credentials")
244+ }
245+ log.Debug().Msg("internal registry auth generated for loopback pulls")
246+ }
247+
235248 // Create use case services
236249 svc.configSvc = config.NewService(v, svc.eventBus)
237250 if err := svc.configSvc.Load(ctx); err != nil {
@@ -358,6 +371,24 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err
358371 return writer, nil
359372 }
360373
374+const internalRegistryUsername = "gordon-internal"
375+
376+func generateInternalRegistryAuth() (string, string, error) {
377+ password, err := randomTokenHex(32)
378+ if err != nil {
379+ return "", "", err
380+ }
381+ return internalRegistryUsername, password, nil
382+}
383+
384+func randomTokenHex(size int) (string, error) {
385+ buf := make([]byte, size)
386+ if _, err := rand.Read(buf); err != nil {
387+ return "", err
388+ }
389+ return hex.EncodeToString(buf), nil
390+}
391+
361392 // createAuthService creates the authentication service and token store.
362393 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
363394 if !cfg.RegistryAuth.Enabled {
@@ -477,19 +508,21 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
477508 // createContainerService creates the container service with configuration.
478509 func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {
479510 containerConfig := container.Config{
480- RegistryAuthEnabled: cfg.RegistryAuth.Enabled,
481- RegistryDomain: cfg.Server.RegistryDomain,
482- RegistryPort: cfg.Server.RegistryPort,
483- RegistryUsername: cfg.RegistryAuth.Username,
484- RegistryPassword: cfg.RegistryAuth.Password,
485- VolumeAutoCreate: v.GetBool("volumes.auto_create"),
486- VolumePrefix: v.GetString("volumes.prefix"),
487- VolumePreserve: v.GetBool("volumes.preserve"),
488- NetworkIsolation: v.GetBool("network_isolation.enabled"),
489- NetworkPrefix: v.GetString("network_isolation.network_prefix"),
490- DNSSuffix: v.GetString("network_isolation.dns_suffix"),
491- NetworkGroups: svc.configSvc.GetNetworkGroups(),
492- Attachments: svc.configSvc.GetAttachments(),
511+ RegistryAuthEnabled: cfg.RegistryAuth.Enabled,
512+ RegistryDomain: cfg.Server.RegistryDomain,
513+ RegistryPort: cfg.Server.RegistryPort,
514+ RegistryUsername: cfg.RegistryAuth.Username,
515+ RegistryPassword: cfg.RegistryAuth.Password,
516+ InternalRegistryUsername: svc.internalRegUser,
517+ InternalRegistryPassword: svc.internalRegPass,
518+ VolumeAutoCreate: v.GetBool("volumes.auto_create"),
519+ VolumePrefix: v.GetString("volumes.prefix"),
520+ VolumePreserve: v.GetBool("volumes.preserve"),
521+ NetworkIsolation: v.GetBool("network_isolation.enabled"),
522+ NetworkPrefix: v.GetString("network_isolation.network_prefix"),
523+ DNSSuffix: v.GetString("network_isolation.dns_suffix"),
524+ NetworkGroups: svc.configSvc.GetNetworkGroups(),
525+ Attachments: svc.configSvc.GetAttachments(),
493526 }
494527 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig)
495528 }
@@ -561,8 +594,12 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
561594 }
562595
563596 if cfg.RegistryAuth.Enabled && svc.authSvc != nil {
597+ internalAuth := middleware.InternalRegistryAuth{
598+ Username: svc.internalRegUser,
599+ Password: svc.internalRegPass,
600+ }
564601 registryMiddlewares = append(registryMiddlewares,
565- middleware.RegistryAuthV2(svc.authSvc, log))
602+ middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
566603 }
567604
568605 registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
@@ -3,6 +3,8 @@ package app
3 3
4 import (4 import (
5 "context"5 "context"
6+ "crypto/rand"
7+ "encoding/hex"
6 "fmt"8 "fmt"
7 "net/http"9 "net/http"
8 "os"10 "os"
@@ -104,6 +106,8 @@ type services struct {
104 proxySvc *proxy.Service106 proxySvc *proxy.Service
105 authSvc *auth.Service107 authSvc *auth.Service
106 tokenHandler *registry.TokenHandler108 tokenHandler *registry.TokenHandler
109+ internalRegUser string
110+ internalRegPass string
107 }111 }
108 112
109 // Run initializes and starts the Gordon application.113 // Run initializes and starts the Gordon application.
@@ -232,6 +236,15 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
232 return nil, err236 return nil, err
233 }237 }
234 238
239+ // Generate internal registry credentials for loopback-only pulls
240+ if cfg.RegistryAuth.Enabled {
241+ svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
242+ if err != nil {
243+ return nil, log.WrapErr(err, "failed to generate internal registry credentials")
244+ }
245+ log.Debug().Msg("internal registry auth generated for loopback pulls")
246+ }
247+
235 // Create use case services248 // Create use case services
236 svc.configSvc = config.NewService(v, svc.eventBus)249 svc.configSvc = config.NewService(v, svc.eventBus)
237 if err := svc.configSvc.Load(ctx); err != nil {250 if err := svc.configSvc.Load(ctx); err != nil {
@@ -358,6 +371,24 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err
358 return writer, nil371 return writer, nil
359 }372 }
360 373
374+const internalRegistryUsername = "gordon-internal"
375+
376+func generateInternalRegistryAuth() (string, string, error) {
377+ password, err := randomTokenHex(32)
378+ if err != nil {
379+ return "", "", err
380+ }
381+ return internalRegistryUsername, password, nil
382+}
383+
384+func randomTokenHex(size int) (string, error) {
385+ buf := make([]byte, size)
386+ if _, err := rand.Read(buf); err != nil {
387+ return "", err
388+ }
389+ return hex.EncodeToString(buf), nil
390+}
391+
361 // createAuthService creates the authentication service and token store.392 // createAuthService creates the authentication service and token store.
362 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {393 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
363 if !cfg.RegistryAuth.Enabled {394 if !cfg.RegistryAuth.Enabled {
@@ -477,19 +508,21 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
477 // createContainerService creates the container service with configuration.508 // createContainerService creates the container service with configuration.
478 func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {509 func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {
479 containerConfig := container.Config{510 containerConfig := container.Config{
480- RegistryAuthEnabled: cfg.RegistryAuth.Enabled,511+ RegistryAuthEnabled: cfg.RegistryAuth.Enabled,
481- RegistryDomain: cfg.Server.RegistryDomain,512+ RegistryDomain: cfg.Server.RegistryDomain,
482- RegistryPort: cfg.Server.RegistryPort,513+ RegistryPort: cfg.Server.RegistryPort,
483- RegistryUsername: cfg.RegistryAuth.Username,514+ RegistryUsername: cfg.RegistryAuth.Username,
484- RegistryPassword: cfg.RegistryAuth.Password,515+ RegistryPassword: cfg.RegistryAuth.Password,
485- VolumeAutoCreate: v.GetBool("volumes.auto_create"),516+ InternalRegistryUsername: svc.internalRegUser,
486- VolumePrefix: v.GetString("volumes.prefix"),517+ InternalRegistryPassword: svc.internalRegPass,
487- VolumePreserve: v.GetBool("volumes.preserve"),518+ VolumeAutoCreate: v.GetBool("volumes.auto_create"),
488- NetworkIsolation: v.GetBool("network_isolation.enabled"),519+ VolumePrefix: v.GetString("volumes.prefix"),
489- NetworkPrefix: v.GetString("network_isolation.network_prefix"),520+ VolumePreserve: v.GetBool("volumes.preserve"),
490- DNSSuffix: v.GetString("network_isolation.dns_suffix"),521+ NetworkIsolation: v.GetBool("network_isolation.enabled"),
491- NetworkGroups: svc.configSvc.GetNetworkGroups(),522+ NetworkPrefix: v.GetString("network_isolation.network_prefix"),
492- Attachments: svc.configSvc.GetAttachments(),523+ DNSSuffix: v.GetString("network_isolation.dns_suffix"),
524+ NetworkGroups: svc.configSvc.GetNetworkGroups(),
525+ Attachments: svc.configSvc.GetAttachments(),
493 }526 }
494 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig)527 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig)
495 }528 }
@@ -561,8 +594,12 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
561 }594 }
562 595
563 if cfg.RegistryAuth.Enabled && svc.authSvc != nil {596 if cfg.RegistryAuth.Enabled && svc.authSvc != nil {
597+ internalAuth := middleware.InternalRegistryAuth{
598+ Username: svc.internalRegUser,
599+ Password: svc.internalRegPass,
600+ }
564 registryMiddlewares = append(registryMiddlewares,601 registryMiddlewares = append(registryMiddlewares,
565- middleware.RegistryAuthV2(svc.authSvc, log))602+ middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
566 }603 }
567 604
568 registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)605 registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
M internal/domain/event.go +2 -2
@@ -60,8 +60,8 @@ type contextKey string
6060
6161 const (
6262 // ContextKeyInternalDeploy indicates the deployment is triggered internally
63- // (e.g., from our own registry's image.pushed event) and should bypass
64- // external authentication for image pulls.
63+ // (e.g., from our own registry's image.pushed event) and should use
64+ // internal registry authentication for image pulls.
6565 ContextKeyInternalDeploy contextKey = "internal_deploy"
6666 )
6767
@@ -60,8 +60,8 @@ type contextKey string
60 60
61 const (61 const (
62 // ContextKeyInternalDeploy indicates the deployment is triggered internally62 // ContextKeyInternalDeploy indicates the deployment is triggered internally
63- // (e.g., from our own registry's image.pushed event) and should bypass63+ // (e.g., from our own registry's image.pushed event) and should use
64- // external authentication for image pulls.64+ // internal registry authentication for image pulls.
65 ContextKeyInternalDeploy contextKey = "internal_deploy"65 ContextKeyInternalDeploy contextKey = "internal_deploy"
66 )66 )
67 67
M internal/usecase/container/events.go +1 -1
@@ -57,7 +57,7 @@ func (h *ImagePushedHandler) Handle(event domain.Event) error {
5757 }
5858
5959 // Mark context as internal deploy - the event originated from our own registry,
60- // so we can bypass external auth when pulling images (use localhost instead).
60+ // so we can use internal registry auth when pulling images.
6161 internalCtx := domain.WithInternalDeploy(ctx)
6262
6363 for _, route := range routes {
@@ -57,7 +57,7 @@ func (h *ImagePushedHandler) Handle(event domain.Event) error {
57 }57 }
58 58
59 // Mark context as internal deploy - the event originated from our own registry,59 // Mark context as internal deploy - the event originated from our own registry,
60- // so we can bypass external auth when pulling images (use localhost instead).60+ // so we can use internal registry auth when pulling images.
61 internalCtx := domain.WithInternalDeploy(ctx)61 internalCtx := domain.WithInternalDeploy(ctx)
62 62
63 for _, route := range routes {63 for _, route := range routes {
M internal/usecase/container/service.go +39 -27
@@ -18,20 +18,22 @@ import (
1818
1919 // Config holds configuration needed by the container service.
2020 type Config struct {
21- RegistryAuthEnabled bool
22- RegistryDomain string
23- RegistryPort int
24- RegistryUsername string
25- RegistryPassword string
26- VolumeAutoCreate bool
27- VolumePrefix string
28- VolumePreserve bool
29- NetworkIsolation bool
30- NetworkPrefix string
31- DNSSuffix string
32- NetworkGroups map[string][]string
33- Attachments map[string][]string
34- ReadinessDelay time.Duration // Delay after container starts before considering it ready
21+ RegistryAuthEnabled bool
22+ RegistryDomain string
23+ RegistryPort int
24+ RegistryUsername string
25+ RegistryPassword string
26+ InternalRegistryUsername string
27+ InternalRegistryPassword string
28+ VolumeAutoCreate bool
29+ VolumePrefix string
30+ VolumePreserve bool
31+ NetworkIsolation bool
32+ NetworkPrefix string
33+ DNSSuffix string
34+ NetworkGroups map[string][]string
35+ Attachments map[string][]string
36+ ReadinessDelay time.Duration // Delay after container starts before considering it ready
3537 }
3638
3739 // Service implements the ContainerService interface.
@@ -476,12 +478,12 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
476478 // Determine if this is an internal deploy and what reference to use
477479 pullRef := imageRef
478480 isInternal := domain.IsInternalDeploy(ctx)
479- if isInternal && s.config.RegistryPort > 0 {
480- pullRef = rewriteToLocalhost(imageRef, s.config.RegistryDomain, s.config.RegistryPort)
481+ if isInternal && s.config.RegistryDomain != "" {
482+ pullRef = rewriteToRegistryDomain(imageRef, s.config.RegistryDomain)
481483 log.Info().
482484 Str("original_ref", imageRef).
483- Str("local_ref", pullRef).
484- Msg("internal deploy: using localhost for pull")
485+ Str("registry_ref", pullRef).
486+ Msg("internal deploy: using registry domain for pull")
485487 }
486488
487489 // Check local images using the reference we'll actually use
@@ -501,11 +503,23 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
501503 // Pull image
502504 log.Info().Msg("pulling image from registry")
503505
504- if s.config.RegistryAuthEnabled && !isInternal {
506+ switch {
507+ case isInternal && s.config.RegistryAuthEnabled:
508+ if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" {
509+ return "", log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
510+ }
511+ if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil {
512+ return "", log.WrapErr(err, "failed to pull image with internal auth")
513+ }
514+ case isInternal:
515+ if err := s.runtime.PullImage(ctx, pullRef); err != nil {
516+ return "", log.WrapErr(err, "failed to pull image")
517+ }
518+ case s.config.RegistryAuthEnabled:
505519 if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {
506520 return "", log.WrapErr(err, "failed to pull image with auth")
507521 }
508- } else {
522+ default:
509523 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
510524 return "", log.WrapErr(err, "failed to pull image")
511525 }
@@ -965,19 +979,17 @@ func (s *Service) publishContainerDeployed(ctx context.Context, domainName, cont
965979 }
966980 }
967981
968-// rewriteToLocalhost rewrites an image reference to use localhost for internal pulls.
969-// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
970-func rewriteToLocalhost(imageRef, registryDomain string, registryPort int) string {
982+// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
983+// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
984+func rewriteToRegistryDomain(imageRef, registryDomain string) string {
971985 if registryDomain == "" {
972986 return imageRef
973987 }
974988
975- // Strip the registry domain prefix if present
976989 prefix := registryDomain + "/"
977- imagePath := imageRef
978990 if strings.HasPrefix(imageRef, prefix) {
979- imagePath = strings.TrimPrefix(imageRef, prefix)
991+ return imageRef
980992 }
981993
982- return fmt.Sprintf("localhost:%d/%s", registryPort, imagePath)
994+ return prefix + imageRef
983995 }
@@ -18,20 +18,22 @@ import (
18 18
19 // Config holds configuration needed by the container service.19 // Config holds configuration needed by the container service.
20 type Config struct {20 type Config struct {
21- RegistryAuthEnabled bool21+ RegistryAuthEnabled bool
22- RegistryDomain string22+ RegistryDomain string
23- RegistryPort int23+ RegistryPort int
24- RegistryUsername string24+ RegistryUsername string
25- RegistryPassword string25+ RegistryPassword string
26- VolumeAutoCreate bool26+ InternalRegistryUsername string
27- VolumePrefix string27+ InternalRegistryPassword string
28- VolumePreserve bool28+ VolumeAutoCreate bool
29- NetworkIsolation bool29+ VolumePrefix string
30- NetworkPrefix string30+ VolumePreserve bool
31- DNSSuffix string31+ NetworkIsolation bool
32- NetworkGroups map[string][]string32+ NetworkPrefix string
33- Attachments map[string][]string33+ DNSSuffix string
34- ReadinessDelay time.Duration // Delay after container starts before considering it ready34+ NetworkGroups map[string][]string
35+ Attachments map[string][]string
36+ ReadinessDelay time.Duration // Delay after container starts before considering it ready
35 }37 }
36 38
37 // Service implements the ContainerService interface.39 // Service implements the ContainerService interface.
@@ -476,12 +478,12 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
476 // Determine if this is an internal deploy and what reference to use478 // Determine if this is an internal deploy and what reference to use
477 pullRef := imageRef479 pullRef := imageRef
478 isInternal := domain.IsInternalDeploy(ctx)480 isInternal := domain.IsInternalDeploy(ctx)
479- if isInternal && s.config.RegistryPort > 0 {481+ if isInternal && s.config.RegistryDomain != "" {
480- pullRef = rewriteToLocalhost(imageRef, s.config.RegistryDomain, s.config.RegistryPort)482+ pullRef = rewriteToRegistryDomain(imageRef, s.config.RegistryDomain)
481 log.Info().483 log.Info().
482 Str("original_ref", imageRef).484 Str("original_ref", imageRef).
483- Str("local_ref", pullRef).485+ Str("registry_ref", pullRef).
484- Msg("internal deploy: using localhost for pull")486+ Msg("internal deploy: using registry domain for pull")
485 }487 }
486 488
487 // Check local images using the reference we'll actually use489 // Check local images using the reference we'll actually use
@@ -501,11 +503,23 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
501 // Pull image503 // Pull image
502 log.Info().Msg("pulling image from registry")504 log.Info().Msg("pulling image from registry")
503 505
504- if s.config.RegistryAuthEnabled && !isInternal {506+ switch {
507+ case isInternal && s.config.RegistryAuthEnabled:
508+ if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" {
509+ return "", log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
510+ }
511+ if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil {
512+ return "", log.WrapErr(err, "failed to pull image with internal auth")
513+ }
514+ case isInternal:
515+ if err := s.runtime.PullImage(ctx, pullRef); err != nil {
516+ return "", log.WrapErr(err, "failed to pull image")
517+ }
518+ case s.config.RegistryAuthEnabled:
505 if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {519 if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {
506 return "", log.WrapErr(err, "failed to pull image with auth")520 return "", log.WrapErr(err, "failed to pull image with auth")
507 }521 }
508- } else {522+ default:
509 if err := s.runtime.PullImage(ctx, pullRef); err != nil {523 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
510 return "", log.WrapErr(err, "failed to pull image")524 return "", log.WrapErr(err, "failed to pull image")
511 }525 }
@@ -965,19 +979,17 @@ func (s *Service) publishContainerDeployed(ctx context.Context, domainName, cont
965 }979 }
966 }980 }
967 981
968-// rewriteToLocalhost rewrites an image reference to use localhost for internal pulls.982+// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
969-// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"983+// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
970-func rewriteToLocalhost(imageRef, registryDomain string, registryPort int) string {984+func rewriteToRegistryDomain(imageRef, registryDomain string) string {
971 if registryDomain == "" {985 if registryDomain == "" {
972 return imageRef986 return imageRef
973 }987 }
974 988
975- // Strip the registry domain prefix if present
976 prefix := registryDomain + "/"989 prefix := registryDomain + "/"
977- imagePath := imageRef
978 if strings.HasPrefix(imageRef, prefix) {990 if strings.HasPrefix(imageRef, prefix) {
979- imagePath = strings.TrimPrefix(imageRef, prefix)991+ return imageRef
980 }992 }
981 993
982- return fmt.Sprintf("localhost:%d/%s", registryPort, imagePath)994+ return prefix + imageRef
983 }995 }
M internal/usecase/container/service_test.go +9 -21
@@ -610,54 +610,42 @@ func TestMergeEnvironmentVariables(t *testing.T) {
610610 assert.Contains(t, result, "BAZ=user")
611611 }
612612
613-func TestRewriteToLocalhost(t *testing.T) {
613+func TestRewriteToRegistryDomain(t *testing.T) {
614614 tests := []struct {
615615 name string
616616 imageRef string
617617 registryDomain string
618- registryPort int
619618 wantRef string
620619 }{
621620 {
622- name: "rewrites registry domain to localhost",
621+ name: "keeps registry domain prefix",
623622 imageRef: "registry.example.com/myapp:latest",
624623 registryDomain: "registry.example.com",
625- registryPort: 5000,
626- wantRef: "localhost:5000/myapp:latest",
624+ wantRef: "registry.example.com/myapp:latest",
627625 },
628626 {
629- name: "rewrites with nested path",
630- imageRef: "registry.example.com/org/myapp:v1.0",
627+ name: "prefixes registry domain",
628+ imageRef: "myapp:v1.0",
631629 registryDomain: "registry.example.com",
632- registryPort: 5000,
633- wantRef: "localhost:5000/org/myapp:v1.0",
630+ wantRef: "registry.example.com/myapp:v1.0",
634631 },
635632 {
636- name: "rewrites external image to localhost with port",
633+ name: "prefixes external image",
637634 imageRef: "docker.io/library/nginx:latest",
638635 registryDomain: "registry.example.com",
639- registryPort: 5000,
640- wantRef: "localhost:5000/docker.io/library/nginx:latest",
636+ wantRef: "registry.example.com/docker.io/library/nginx:latest",
641637 },
642638 {
643639 name: "empty registry domain returns original",
644640 imageRef: "registry.example.com/myapp:latest",
645641 registryDomain: "",
646- registryPort: 5000,
647642 wantRef: "registry.example.com/myapp:latest",
648643 },
649- {
650- name: "custom port in config",
651- imageRef: "registry.example.com/myapp:latest",
652- registryDomain: "registry.example.com",
653- registryPort: 5001,
654- wantRef: "localhost:5001/myapp:latest",
655- },
656644 }
657645
658646 for _, tt := range tests {
659647 t.Run(tt.name, func(t *testing.T) {
660- gotRef := rewriteToLocalhost(tt.imageRef, tt.registryDomain, tt.registryPort)
648+ gotRef := rewriteToRegistryDomain(tt.imageRef, tt.registryDomain)
661649 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
662650 })
663651 }
@@ -610,54 +610,42 @@ func TestMergeEnvironmentVariables(t *testing.T) {
610 assert.Contains(t, result, "BAZ=user")610 assert.Contains(t, result, "BAZ=user")
611 }611 }
612 612
613-func TestRewriteToLocalhost(t *testing.T) {613+func TestRewriteToRegistryDomain(t *testing.T) {
614 tests := []struct {614 tests := []struct {
615 name string615 name string
616 imageRef string616 imageRef string
617 registryDomain string617 registryDomain string
618- registryPort int
619 wantRef string618 wantRef string
620 }{619 }{
621 {620 {
622- name: "rewrites registry domain to localhost",621+ name: "keeps registry domain prefix",
623 imageRef: "registry.example.com/myapp:latest",622 imageRef: "registry.example.com/myapp:latest",
624 registryDomain: "registry.example.com",623 registryDomain: "registry.example.com",
625- registryPort: 5000,624+ wantRef: "registry.example.com/myapp:latest",
626- wantRef: "localhost:5000/myapp:latest",
627 },625 },
628 {626 {
629- name: "rewrites with nested path",627+ name: "prefixes registry domain",
630- imageRef: "registry.example.com/org/myapp:v1.0",628+ imageRef: "myapp:v1.0",
631 registryDomain: "registry.example.com",629 registryDomain: "registry.example.com",
632- registryPort: 5000,630+ wantRef: "registry.example.com/myapp:v1.0",
633- wantRef: "localhost:5000/org/myapp:v1.0",
634 },631 },
635 {632 {
636- name: "rewrites external image to localhost with port",633+ name: "prefixes external image",
637 imageRef: "docker.io/library/nginx:latest",634 imageRef: "docker.io/library/nginx:latest",
638 registryDomain: "registry.example.com",635 registryDomain: "registry.example.com",
639- registryPort: 5000,636+ wantRef: "registry.example.com/docker.io/library/nginx:latest",
640- wantRef: "localhost:5000/docker.io/library/nginx:latest",
641 },637 },
642 {638 {
643 name: "empty registry domain returns original",639 name: "empty registry domain returns original",
644 imageRef: "registry.example.com/myapp:latest",640 imageRef: "registry.example.com/myapp:latest",
645 registryDomain: "",641 registryDomain: "",
646- registryPort: 5000,
647 wantRef: "registry.example.com/myapp:latest",642 wantRef: "registry.example.com/myapp:latest",
648 },643 },
649- {
650- name: "custom port in config",
651- imageRef: "registry.example.com/myapp:latest",
652- registryDomain: "registry.example.com",
653- registryPort: 5001,
654- wantRef: "localhost:5001/myapp:latest",
655- },
656 }644 }
657 645
658 for _, tt := range tests {646 for _, tt := range tests {
659 t.Run(tt.name, func(t *testing.T) {647 t.Run(tt.name, func(t *testing.T) {
660- gotRef := rewriteToLocalhost(tt.imageRef, tt.registryDomain, tt.registryPort)648+ gotRef := rewriteToRegistryDomain(tt.imageRef, tt.registryDomain)
661 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")649 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
662 })650 })
663 }651 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close