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, | |||
| 79 | 79 | 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 | 89 | // RegistryAuthV2 middleware provides enhanced Docker Registry authentication | |
| 83 | 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 | 92 | return func(next http.Handler) http.Handler { | |
| 86 | 93 | 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) { | |
| 89 | 96 | log.Debug(). | |
| 90 | 97 | Str(zerowrap.FieldLayer, "adapter"). | |
| 91 | 98 | Str(zerowrap.FieldAdapter, "http"). | |
| 92 | 99 | Str(zerowrap.FieldMethod, r.Method). | |
| 93 | 100 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 94 | 101 | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |
| 95 | - | Msg("localhost request - skipping auth") | |
| 102 | + | Msg("localhost request with internal auth - skipping auth") | |
| 96 | 103 | next.ServeHTTP(w, r) | |
| 97 | 104 | return | |
| 98 | 105 | } | |
| @@ -236,7 +243,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 236 | 243 | } | |
| 237 | 244 | ||
| 238 | 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 | 247 | func isLocalhostRequest(r *http.Request) bool { | |
| 241 | 248 | host := r.RemoteAddr | |
| 242 | 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 | 256 | 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 | 274 | // sendUnauthorized sends an HTTP 401 response with appropriate headers. | |
| 253 | 275 | func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) { | |
| 254 | 276 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |
| @@ -79,20 +79,27 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, | |||||
| 79 | return authenticated | 79 | 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 authentication | 89 | // 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 | return | 104 | 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.RemoteAddr | 248 | 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 false | 256 | 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 @@ | |||
| 1 | 1 | package middleware | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "encoding/base64" | |
| 6 | + | "errors" | |
| 5 | 7 | "net/http" | |
| 6 | 8 | "net/http/httptest" | |
| 7 | 9 | "testing" | |
| 10 | + | "time" | |
| 8 | 11 | ||
| 9 | 12 | "github.com/bnema/zerowrap" | |
| 10 | 13 | "github.com/stretchr/testify/assert" | |
| 14 | + | ||
| 15 | + | "gordon/internal/domain" | |
| 11 | 16 | ) | |
| 12 | 17 | ||
| 13 | 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 | + | } | |
| @@ -1,13 +1,18 @@ | |||||
| 1 | package middleware | 1 | 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 | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "crypto/rand" | |
| 7 | + | "encoding/hex" | |
| 6 | 8 | "fmt" | |
| 7 | 9 | "net/http" | |
| 8 | 10 | "os" | |
| @@ -104,6 +106,8 @@ type services struct { | |||
| 104 | 106 | proxySvc *proxy.Service | |
| 105 | 107 | authSvc *auth.Service | |
| 106 | 108 | tokenHandler *registry.TokenHandler | |
| 109 | + | internalRegUser string | |
| 110 | + | internalRegPass string | |
| 107 | 111 | } | |
| 108 | 112 | ||
| 109 | 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 | 236 | 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 | 248 | // Create use case services | |
| 236 | 249 | svc.configSvc = config.NewService(v, svc.eventBus) | |
| 237 | 250 | if err := svc.configSvc.Load(ctx); err != nil { | |
| @@ -358,6 +371,24 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err | |||
| 358 | 371 | 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 | 392 | // createAuthService creates the authentication service and token store. | |
| 362 | 393 | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | |
| 363 | 394 | if !cfg.RegistryAuth.Enabled { | |
| @@ -477,19 +508,21 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi | |||
| 477 | 508 | // createContainerService creates the container service with configuration. | |
| 478 | 509 | func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service { | |
| 479 | 510 | 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(), | |
| 493 | 526 | } | |
| 494 | 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 | 596 | if cfg.RegistryAuth.Enabled && svc.authSvc != nil { | |
| 597 | + | internalAuth := middleware.InternalRegistryAuth{ | |
| 598 | + | Username: svc.internalRegUser, | |
| 599 | + | Password: svc.internalRegPass, | |
| 600 | + | } | |
| 564 | 601 | registryMiddlewares = append(registryMiddlewares, | |
| 565 | - | middleware.RegistryAuthV2(svc.authSvc, log)) | |
| 602 | + | middleware.RegistryAuthV2(svc.authSvc, internalAuth, log)) | |
| 566 | 603 | } | |
| 567 | 604 | ||
| 568 | 605 | 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.Service | 106 | proxySvc *proxy.Service | ||
| 105 | authSvc *auth.Service | 107 | authSvc *auth.Service | ||
| 106 | tokenHandler *registry.TokenHandler | 108 | 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, err | 236 | 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 services | 248 | // 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, nil | 371 | 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 | |||
| 60 | 60 | ||
| 61 | 61 | const ( | |
| 62 | 62 | // 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. | |
| 65 | 65 | ContextKeyInternalDeploy contextKey = "internal_deploy" | |
| 66 | 66 | ) | |
| 67 | 67 | ||
| @@ -60,8 +60,8 @@ type contextKey string | |||||
| 60 | 60 | ||||
| 61 | const ( | 61 | const ( | ||
| 62 | // ContextKeyInternalDeploy indicates the deployment is triggered internally | 62 | // ContextKeyInternalDeploy indicates the deployment is triggered internally | ||
| 63 | - | // (e.g., from our own registry's image.pushed event) and should bypass | 63 | + | // (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 { | |||
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | 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 | 61 | internalCtx := domain.WithInternalDeploy(ctx) | |
| 62 | 62 | ||
| 63 | 63 | 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 ( | |||
| 18 | 18 | ||
| 19 | 19 | // Config holds configuration needed by the container service. | |
| 20 | 20 | 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 | |
| 35 | 37 | } | |
| 36 | 38 | ||
| 37 | 39 | // Service implements the ContainerService interface. | |
| @@ -476,12 +478,12 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err | |||
| 476 | 478 | // Determine if this is an internal deploy and what reference to use | |
| 477 | 479 | pullRef := imageRef | |
| 478 | 480 | 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) | |
| 481 | 483 | log.Info(). | |
| 482 | 484 | 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") | |
| 485 | 487 | } | |
| 486 | 488 | ||
| 487 | 489 | // 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 | 503 | // Pull image | |
| 502 | 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 | 519 | if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil { | |
| 506 | 520 | return "", log.WrapErr(err, "failed to pull image with auth") | |
| 507 | 521 | } | |
| 508 | - | } else { | |
| 522 | + | default: | |
| 509 | 523 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | |
| 510 | 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. | |
| 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 { | |
| 971 | 985 | if registryDomain == "" { | |
| 972 | 986 | return imageRef | |
| 973 | 987 | } | |
| 974 | 988 | ||
| 975 | - | // Strip the registry domain prefix if present | |
| 976 | 989 | prefix := registryDomain + "/" | |
| 977 | - | imagePath := imageRef | |
| 978 | 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 | } | |
| @@ -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 bool | 21 | + | RegistryAuthEnabled bool |
| 22 | - | RegistryDomain string | 22 | + | RegistryDomain string |
| 23 | - | RegistryPort int | 23 | + | RegistryPort int |
| 24 | - | RegistryUsername string | 24 | + | RegistryUsername string |
| 25 | - | RegistryPassword string | 25 | + | RegistryPassword string |
| 26 | - | VolumeAutoCreate bool | 26 | + | InternalRegistryUsername string |
| 27 | - | VolumePrefix string | 27 | + | InternalRegistryPassword string |
| 28 | - | VolumePreserve bool | 28 | + | VolumeAutoCreate bool |
| 29 | - | NetworkIsolation bool | 29 | + | VolumePrefix string |
| 30 | - | NetworkPrefix string | 30 | + | VolumePreserve bool |
| 31 | - | DNSSuffix string | 31 | + | NetworkIsolation bool |
| 32 | - | NetworkGroups map[string][]string | 32 | + | NetworkPrefix string |
| 33 | - | Attachments map[string][]string | 33 | + | DNSSuffix string |
| 34 | - | ReadinessDelay time.Duration // Delay after container starts before considering it ready | 34 | + | 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 use | 478 | // Determine if this is an internal deploy and what reference to use | ||
| 477 | pullRef := imageRef | 479 | 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 use | 489 | // 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 image | 503 | // 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 imageRef | 986 | 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) { | |||
| 610 | 610 | assert.Contains(t, result, "BAZ=user") | |
| 611 | 611 | } | |
| 612 | 612 | ||
| 613 | - | func TestRewriteToLocalhost(t *testing.T) { | |
| 613 | + | func TestRewriteToRegistryDomain(t *testing.T) { | |
| 614 | 614 | tests := []struct { | |
| 615 | 615 | name string | |
| 616 | 616 | imageRef string | |
| 617 | 617 | registryDomain string | |
| 618 | - | registryPort int | |
| 619 | 618 | wantRef string | |
| 620 | 619 | }{ | |
| 621 | 620 | { | |
| 622 | - | name: "rewrites registry domain to localhost", | |
| 621 | + | name: "keeps registry domain prefix", | |
| 623 | 622 | imageRef: "registry.example.com/myapp:latest", | |
| 624 | 623 | registryDomain: "registry.example.com", | |
| 625 | - | registryPort: 5000, | |
| 626 | - | wantRef: "localhost:5000/myapp:latest", | |
| 624 | + | wantRef: "registry.example.com/myapp:latest", | |
| 627 | 625 | }, | |
| 628 | 626 | { | |
| 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", | |
| 631 | 629 | registryDomain: "registry.example.com", | |
| 632 | - | registryPort: 5000, | |
| 633 | - | wantRef: "localhost:5000/org/myapp:v1.0", | |
| 630 | + | wantRef: "registry.example.com/myapp:v1.0", | |
| 634 | 631 | }, | |
| 635 | 632 | { | |
| 636 | - | name: "rewrites external image to localhost with port", | |
| 633 | + | name: "prefixes external image", | |
| 637 | 634 | imageRef: "docker.io/library/nginx:latest", | |
| 638 | 635 | 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", | |
| 641 | 637 | }, | |
| 642 | 638 | { | |
| 643 | 639 | name: "empty registry domain returns original", | |
| 644 | 640 | imageRef: "registry.example.com/myapp:latest", | |
| 645 | 641 | registryDomain: "", | |
| 646 | - | registryPort: 5000, | |
| 647 | 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 | 646 | for _, tt := range tests { | |
| 659 | 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 | 649 | assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference") | |
| 662 | 650 | }) | |
| 663 | 651 | } | |
| @@ -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 string | 615 | name string | ||
| 616 | imageRef string | 616 | imageRef string | ||
| 617 | registryDomain string | 617 | registryDomain string | ||
| 618 | - | registryPort int | |||
| 619 | wantRef string | 618 | 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 | } | ||