From 3da1a2aa7e9a5487446cf8c6b1c76112f08ceb4e Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Tue, 27 Jan 2026 17:46:24 -0500 Subject: [PATCH 1/7] fix(middleware): support RepoAccess token format in registry auth The two-tier build cache PR (#70) introduced a new token format using `repo_access` field with per-repository scopes, but the middleware wasn't updated to parse it. This caused 401 Unauthorized errors when builder VMs tried to push images to the registry, as the middleware only checked for the legacy `repos` field which is empty in new tokens. Changes: - Add RepoPermission struct and RepoAccess field to RegistryTokenClaims - Update validateRegistryToken to check both RepoAccess (new) and Repositories (legacy) formats - Add per-repo scope checking for write operations - Add comprehensive tests for both token formats Fixes build failures in production where the new token format was being used but not recognized by the registry auth middleware. --- lib/middleware/oapi_auth.go | 57 ++++++++++++++++----- lib/middleware/oapi_auth_test.go | 88 +++++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 15 deletions(-) diff --git a/lib/middleware/oapi_auth.go b/lib/middleware/oapi_auth.go index cf5f8305..96e5b426 100644 --- a/lib/middleware/oapi_auth.go +++ b/lib/middleware/oapi_auth.go @@ -22,13 +22,28 @@ const userIDKey contextKey = "user_id" // It properly parses repository names (which can contain slashes) from /v2/ paths. var registryRouter = v2.Router() +// RepoPermission defines access permissions for a specific repository. +// This mirrors the type in lib/builds/registry_token.go to avoid circular imports. +type RepoPermission struct { + Repo string `json:"repo"` + Scope string `json:"scope"` +} + // RegistryTokenClaims contains the claims for a scoped registry access token. // This mirrors the type in lib/builds/registry_token.go to avoid circular imports. type RegistryTokenClaims struct { jwt.RegisteredClaims - BuildID string `json:"build_id"` - Repositories []string `json:"repos"` - Scope string `json:"scope"` + BuildID string `json:"build_id"` + + // RepoAccess defines per-repository access permissions (new two-tier format) + // If present, this takes precedence over the legacy Repositories/Scope fields + RepoAccess []RepoPermission `json:"repo_access,omitempty"` + + // Repositories is the list of allowed repository paths (legacy format) + Repositories []string `json:"repos,omitempty"` + + // Scope is the access scope (legacy format) + Scope string `json:"scope,omitempty"` } // OapiAuthenticationFunc creates an AuthenticationFunc compatible with nethttp-middleware @@ -229,6 +244,7 @@ func isWriteOperation(method string) bool { // validateRegistryToken validates a registry-scoped JWT token and checks repository access. // Returns the claims if valid, nil otherwise. +// Supports both new RepoAccess format and legacy Repositories/Scope format. func validateRegistryToken(tokenString, jwtSecret, requestPath, method string) (*RegistryTokenClaims, error) { token, err := jwt.ParseWithClaims(tokenString, &RegistryTokenClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { @@ -246,8 +262,8 @@ func validateRegistryToken(tokenString, jwtSecret, requestPath, method string) ( return nil, fmt.Errorf("invalid token") } - // Check if this is a registry token (has repos claim) - if len(claims.Repositories) == 0 { + // Check if this is a registry token (has either RepoAccess or legacy repos claim) + if len(claims.RepoAccess) == 0 && len(claims.Repositories) == 0 { return nil, fmt.Errorf("not a registry token") } @@ -261,21 +277,34 @@ func validateRegistryToken(tokenString, jwtSecret, requestPath, method string) ( return nil, fmt.Errorf("could not extract repository from path") } - // Check if the repository is allowed by the token - allowed := false - for _, allowedRepo := range claims.Repositories { - if allowedRepo == repo { - allowed = true - break + // Check if the repository is allowed by the token and get its scope + var repoScope string + + // Check new RepoAccess format first + if len(claims.RepoAccess) > 0 { + for _, perm := range claims.RepoAccess { + if perm.Repo == repo { + repoScope = perm.Scope + break + } + } + } else { + // Fall back to legacy format + for _, allowedRepo := range claims.Repositories { + if allowedRepo == repo { + repoScope = claims.Scope + break + } } } - if !allowed { + + if repoScope == "" { return nil, fmt.Errorf("repository %s not allowed by token", repo) } // Check scope for write operations - if isWriteOperation(method) && claims.Scope != "push" { - return nil, fmt.Errorf("token does not allow write operations") + if isWriteOperation(method) && repoScope != "push" { + return nil, fmt.Errorf("token does not allow write operations for %s", repo) } return claims, nil diff --git a/lib/middleware/oapi_auth_test.go b/lib/middleware/oapi_auth_test.go index 6fc12056..69e773cb 100644 --- a/lib/middleware/oapi_auth_test.go +++ b/lib/middleware/oapi_auth_test.go @@ -25,7 +25,7 @@ func generateUserToken(t *testing.T, userID string) string { return tokenString } -// generateRegistryToken creates a registry token (like those given to builder VMs) +// generateRegistryToken creates a registry token using the legacy format func generateRegistryToken(t *testing.T, buildID string) string { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": "builder-" + buildID, @@ -41,6 +41,21 @@ func generateRegistryToken(t *testing.T, buildID string) string { return tokenString } +// generateRepoAccessToken creates a registry token using the new RepoAccess format +func generateRepoAccessToken(t *testing.T, buildID string, repoAccess []map[string]string) string { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "builder-" + buildID, + "iat": time.Now().Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + "iss": "hypeman", + "build_id": buildID, + "repo_access": repoAccess, + }) + tokenString, err := token.SignedString([]byte(testJWTSecret)) + require.NoError(t, err) + return tokenString +} + func TestJwtAuth_RejectsRegistryTokens(t *testing.T) { // Create a simple handler that returns 200 if auth passes nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -203,6 +218,77 @@ func TestExtractRepoFromPath(t *testing.T) { } } +func TestValidateRegistryToken(t *testing.T) { + t.Run("legacy format token allows push to allowed repo", func(t *testing.T) { + token := generateRegistryToken(t, "build-123") + claims, err := validateRegistryToken(token, testJWTSecret, "/v2/builds/build-123/manifests/latest", http.MethodPut) + require.NoError(t, err) + assert.Equal(t, "build-123", claims.BuildID) + }) + + t.Run("legacy format token rejects unauthorized repo", func(t *testing.T) { + token := generateRegistryToken(t, "build-123") + _, err := validateRegistryToken(token, testJWTSecret, "/v2/builds/other-build/manifests/latest", http.MethodPut) + require.Error(t, err) + assert.Contains(t, err.Error(), "not allowed by token") + }) + + t.Run("RepoAccess format token allows push to push-scoped repo", func(t *testing.T) { + repoAccess := []map[string]string{ + {"repo": "builds/build-456", "scope": "push"}, + {"repo": "cache/tenant-x", "scope": "push"}, + {"repo": "cache/global/node", "scope": "pull"}, + } + token := generateRepoAccessToken(t, "build-456", repoAccess) + + claims, err := validateRegistryToken(token, testJWTSecret, "/v2/builds/build-456/manifests/latest", http.MethodPut) + require.NoError(t, err) + assert.Equal(t, "build-456", claims.BuildID) + }) + + t.Run("RepoAccess format token allows pull from pull-scoped repo", func(t *testing.T) { + repoAccess := []map[string]string{ + {"repo": "builds/build-456", "scope": "push"}, + {"repo": "cache/global/node", "scope": "pull"}, + } + token := generateRepoAccessToken(t, "build-456", repoAccess) + + // GET (pull) from pull-scoped repo should work + _, err := validateRegistryToken(token, testJWTSecret, "/v2/cache/global/node/manifests/latest", http.MethodGet) + require.NoError(t, err) + }) + + t.Run("RepoAccess format token rejects push to pull-only repo", func(t *testing.T) { + repoAccess := []map[string]string{ + {"repo": "builds/build-456", "scope": "push"}, + {"repo": "cache/global/node", "scope": "pull"}, + } + token := generateRepoAccessToken(t, "build-456", repoAccess) + + // PUT (push) to pull-only repo should fail + _, err := validateRegistryToken(token, testJWTSecret, "/v2/cache/global/node/manifests/latest", http.MethodPut) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not allow write operations") + }) + + t.Run("RepoAccess format token rejects unauthorized repo", func(t *testing.T) { + repoAccess := []map[string]string{ + {"repo": "builds/build-456", "scope": "push"}, + } + token := generateRepoAccessToken(t, "build-456", repoAccess) + + _, err := validateRegistryToken(token, testJWTSecret, "/v2/builds/other-build/manifests/latest", http.MethodPut) + require.Error(t, err) + assert.Contains(t, err.Error(), "not allowed by token") + }) + + t.Run("allows base path check without repo validation", func(t *testing.T) { + token := generateRegistryToken(t, "build-123") + _, err := validateRegistryToken(token, testJWTSecret, "/v2/", http.MethodGet) + require.NoError(t, err) + }) +} + func TestJwtAuth_RequiresAuthorization(t *testing.T) { nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) From cd55df690b678576d9469737afda5fc7d36228d1 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Tue, 27 Jan 2026 20:22:52 -0500 Subject: [PATCH 2/7] fix(middleware): add production subnet to registry IP fallback The IP fallback for registry authentication only allowed 10.100.x.x and 10.102.x.x subnets (staging/dev), but production uses 172.30.x.x. This caused builds to fail in production while working in staging. Changes: - Add 172.30.x.x to allowed subnets in isInternalVMRequest() - Add logger injection to /v2 routes for debug logging - Add tests for internal VM request subnet matching --- cmd/api/main.go | 1 + lib/middleware/oapi_auth.go | 7 +++++-- lib/middleware/oapi_auth_test.go | 36 ++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index 5c835a14..e84db3b0 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -291,6 +291,7 @@ func run() error { r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) + r.Use(mw.InjectLogger(logger)) // Inject logger for debug logging in JwtAuth r.Use(mw.JwtAuth(app.Config.JwtSecret)) r.Mount("/", app.Registry.Handler()) }) diff --git a/lib/middleware/oapi_auth.go b/lib/middleware/oapi_auth.go index 96e5b426..b94f2613 100644 --- a/lib/middleware/oapi_auth.go +++ b/lib/middleware/oapi_auth.go @@ -212,8 +212,11 @@ func isInternalVMRequest(r *http.Request) bool { ip = ip[:idx] } - // Check if it's from the VM network (10.100.x.x or 10.102.x.x) - return strings.HasPrefix(ip, "10.100.") || strings.HasPrefix(ip, "10.102.") + // Check if it's from the VM network + // Different environments use different subnets: + // - 10.100.x.x, 10.102.x.x: staging/dev environments + // - 172.30.x.x: production environment + return strings.HasPrefix(ip, "10.100.") || strings.HasPrefix(ip, "10.102.") || strings.HasPrefix(ip, "172.30.") } // extractRepoFromPath extracts the repository name from a registry path. diff --git a/lib/middleware/oapi_auth_test.go b/lib/middleware/oapi_auth_test.go index 69e773cb..991bf7f9 100644 --- a/lib/middleware/oapi_auth_test.go +++ b/lib/middleware/oapi_auth_test.go @@ -289,6 +289,42 @@ func TestValidateRegistryToken(t *testing.T) { }) } +func TestIsInternalVMRequest(t *testing.T) { + tests := []struct { + name string + remoteAddr string + expected bool + }{ + // Staging/dev subnets + {"staging 10.100.x.x", "10.100.1.50:12345", true}, + {"staging 10.102.x.x", "10.102.5.100:54321", true}, + + // Production subnet + {"production 172.30.x.x", "172.30.16.101:42700", true}, + {"production 172.30.0.x", "172.30.0.50:8080", true}, + + // External IPs (should be rejected) + {"external 192.168.x.x", "192.168.1.100:8080", false}, + {"external public IP", "34.21.1.136:8080", false}, + {"external 10.0.x.x (different subnet)", "10.0.1.50:8080", false}, + {"external 172.16.x.x (different subnet)", "172.16.1.50:8080", false}, + + // Edge cases + {"localhost", "127.0.0.1:8080", false}, + {"IPv6 localhost", "[::1]:8080", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil) + req.RemoteAddr = tt.remoteAddr + + result := isInternalVMRequest(req) + assert.Equal(t, tt.expected, result, "isInternalVMRequest with RemoteAddr=%q", tt.remoteAddr) + }) + } +} + func TestJwtAuth_RequiresAuthorization(t *testing.T) { nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) From 84afce5e8818ddbc5ad8ddd8aeb4f3d0c2d5c3fc Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Tue, 27 Jan 2026 20:33:20 -0500 Subject: [PATCH 3/7] test(middleware): add integration tests for registry auth flow Add comprehensive tests for JwtAuth middleware on /v2/ registry paths: - Valid token access (both legacy and RepoAccess formats) - IP fallback for staging (10.100.x.x, 10.102.x.x) and production (172.30.x.x) - External IP rejection without valid token - Invalid token fallback behavior - Bearer and Basic auth support These tests would have caught the production subnet issue earlier. --- lib/middleware/oapi_auth_test.go | 141 +++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/lib/middleware/oapi_auth_test.go b/lib/middleware/oapi_auth_test.go index 991bf7f9..b45493a1 100644 --- a/lib/middleware/oapi_auth_test.go +++ b/lib/middleware/oapi_auth_test.go @@ -1,6 +1,7 @@ package middleware import ( + "encoding/base64" "net/http" "net/http/httptest" "testing" @@ -289,6 +290,146 @@ func TestValidateRegistryToken(t *testing.T) { }) } +func TestJwtAuth_RegistryPaths(t *testing.T) { + // Create a simple handler that returns 200 if auth passes + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + handler := JwtAuth(testJWTSecret)(nextHandler) + + t.Run("valid registry token allows access to authorized repo", func(t *testing.T) { + token := generateRegistryToken(t, "build-123") + + req := httptest.NewRequest(http.MethodHead, "/v2/builds/build-123/manifests/latest", nil) + req.Header.Set("Authorization", "Basic "+basicAuth(token)) + req.RemoteAddr = "8.8.8.8:12345" // External IP - should still work with valid token + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code, "valid registry token should allow access") + }) + + t.Run("valid RepoAccess token allows access to authorized repo", func(t *testing.T) { + repoAccess := []map[string]string{ + {"repo": "builds/build-456", "scope": "push"}, + {"repo": "cache/tenant-x", "scope": "push"}, + } + token := generateRepoAccessToken(t, "build-456", repoAccess) + + req := httptest.NewRequest(http.MethodPut, "/v2/builds/build-456/manifests/latest", nil) + req.Header.Set("Authorization", "Basic "+basicAuth(token)) + req.RemoteAddr = "8.8.8.8:12345" // External IP + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code, "valid RepoAccess token should allow access") + }) + + t.Run("no token but internal staging IP allows access via fallback", func(t *testing.T) { + req := httptest.NewRequest(http.MethodHead, "/v2/builds/any-build/manifests/latest", nil) + // No Authorization header + req.RemoteAddr = "10.100.5.50:12345" // Staging subnet + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code, "internal staging IP should allow access via fallback") + }) + + t.Run("no token but internal production IP allows access via fallback", func(t *testing.T) { + req := httptest.NewRequest(http.MethodHead, "/v2/builds/any-build/manifests/latest", nil) + // No Authorization header + req.RemoteAddr = "172.30.16.101:42700" // Production subnet + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code, "internal production IP should allow access via fallback") + }) + + t.Run("no token and external IP returns 401", func(t *testing.T) { + req := httptest.NewRequest(http.MethodHead, "/v2/builds/any-build/manifests/latest", nil) + // No Authorization header + req.RemoteAddr = "8.8.8.8:12345" // External IP + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code, "external IP without token should be rejected") + assert.Contains(t, rr.Body.String(), "registry authentication required") + }) + + t.Run("invalid token but internal IP allows access via fallback", func(t *testing.T) { + req := httptest.NewRequest(http.MethodHead, "/v2/builds/any-build/manifests/latest", nil) + req.Header.Set("Authorization", "Basic "+basicAuth("invalid-not-a-jwt-token")) + req.RemoteAddr = "10.102.1.50:12345" // Internal IP + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code, "internal IP should allow access via fallback even with invalid token") + }) + + t.Run("invalid token and external IP returns 401", func(t *testing.T) { + req := httptest.NewRequest(http.MethodHead, "/v2/builds/any-build/manifests/latest", nil) + req.Header.Set("Authorization", "Basic "+basicAuth("invalid-not-a-jwt-token")) + req.RemoteAddr = "8.8.8.8:12345" // External IP + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code, "external IP with invalid token should be rejected") + }) + + t.Run("valid token for wrong repo returns 401 even with internal IP", func(t *testing.T) { + token := generateRegistryToken(t, "build-123") + + req := httptest.NewRequest(http.MethodPut, "/v2/builds/different-build/manifests/latest", nil) + req.Header.Set("Authorization", "Basic "+basicAuth(token)) + req.RemoteAddr = "10.100.5.50:12345" // Internal IP - but token validation fails first + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + // Token is valid JWT but wrong repo - should fall through to IP fallback + assert.Equal(t, http.StatusOK, rr.Code, "should fall back to IP check when token doesn't match repo") + }) + + t.Run("registry base path /v2/ allows access with valid token", func(t *testing.T) { + token := generateRegistryToken(t, "build-123") + + req := httptest.NewRequest(http.MethodGet, "/v2/", nil) + req.Header.Set("Authorization", "Basic "+basicAuth(token)) + req.RemoteAddr = "8.8.8.8:12345" + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code, "/v2/ base path should be allowed with valid token") + }) + + t.Run("Bearer auth also works for registry paths", func(t *testing.T) { + token := generateRegistryToken(t, "build-789") + + req := httptest.NewRequest(http.MethodHead, "/v2/builds/build-789/manifests/latest", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "8.8.8.8:12345" + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code, "Bearer auth should also work for registry paths") + }) +} + +// basicAuth creates a Basic auth value (base64 of "token:") +func basicAuth(token string) string { + return base64.StdEncoding.EncodeToString([]byte(token + ":")) +} + func TestIsInternalVMRequest(t *testing.T) { tests := []struct { name string From bfdb3d56ae0b4cb7c82d4d01265638a2c583d90a Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Tue, 27 Jan 2026 21:32:06 -0500 Subject: [PATCH 4/7] fix(registry): add WWW-Authenticate header and remove prod IP fallback Root cause: Registry was returning 401 without WWW-Authenticate header, so BuildKit didn't know to send credentials from Docker config. Changes: - Add WWW-Authenticate: Basic realm="registry" to 401 responses - Remove production subnet (172.30.x.x) from IP fallback (staging 10.100.x.x still has fallback as safety net) - Builder agent: write Docker config to both /home/builder/.docker and /root/.docker to ensure BuildKit finds it - Add tests for BuildKit auth flow simulation This enables proper token-based registry auth in production. --- lib/builds/builder_agent/main.go | 26 +++++++++++------ lib/middleware/oapi_auth.go | 16 ++++++---- lib/middleware/oapi_auth_test.go | 50 +++++++++++++++++++++++++++----- 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/lib/builds/builder_agent/main.go b/lib/builds/builder_agent/main.go index 843aca22..2bb02b3b 100644 --- a/lib/builds/builder_agent/main.go +++ b/lib/builds/builder_agent/main.go @@ -516,19 +516,27 @@ func setupRegistryAuth(registryURL, token string) error { return fmt.Errorf("marshal docker config: %w", err) } - // Ensure ~/.docker directory exists - dockerDir := "/home/builder/.docker" - if err := os.MkdirAll(dockerDir, 0700); err != nil { - return fmt.Errorf("create docker config dir: %w", err) + // Write config to multiple locations to ensure BuildKit finds it + // buildctl-daemonless.sh may run buildkitd with different user/env + configDirs := []string{ + "/home/builder/.docker", // Builder user home + "/root/.docker", // Root user (buildkitd may run as root) } - // Write config.json - configPath := filepath.Join(dockerDir, "config.json") - if err := os.WriteFile(configPath, configData, 0600); err != nil { - return fmt.Errorf("write docker config: %w", err) + for _, dockerDir := range configDirs { + if err := os.MkdirAll(dockerDir, 0700); err != nil { + log.Printf("Warning: failed to create %s: %v", dockerDir, err) + continue + } + + configPath := filepath.Join(dockerDir, "config.json") + if err := os.WriteFile(configPath, configData, 0600); err != nil { + log.Printf("Warning: failed to write %s: %v", configPath, err) + continue + } + log.Printf("Registry auth configured at %s", configPath) } - log.Printf("Registry auth configured for %s", registryURL) return nil } diff --git a/lib/middleware/oapi_auth.go b/lib/middleware/oapi_auth.go index b94f2613..da39c11f 100644 --- a/lib/middleware/oapi_auth.go +++ b/lib/middleware/oapi_auth.go @@ -131,6 +131,13 @@ func OapiAuthenticationFunc(jwtSecret string) openapi3filter.AuthenticationFunc // that returns consistent error responses. func OapiErrorHandler(w http.ResponseWriter, message string, statusCode int) { w.Header().Set("Content-Type", "application/json") + + // For 401 responses, include WWW-Authenticate header so Docker/BuildKit + // knows to send credentials from the Docker config + if statusCode == http.StatusUnauthorized { + w.Header().Set("WWW-Authenticate", `Basic realm="registry"`) + } + w.WriteHeader(statusCode) // Return a simple JSON error response matching our Error schema @@ -212,11 +219,10 @@ func isInternalVMRequest(r *http.Request) bool { ip = ip[:idx] } - // Check if it's from the VM network - // Different environments use different subnets: - // - 10.100.x.x, 10.102.x.x: staging/dev environments - // - 172.30.x.x: production environment - return strings.HasPrefix(ip, "10.100.") || strings.HasPrefix(ip, "10.102.") || strings.HasPrefix(ip, "172.30.") + // Check if it's from the VM network (staging/dev only) + // Production (172.30.x.x) should use token auth, not IP fallback + // TODO: Remove this fallback entirely once token auth is verified working + return strings.HasPrefix(ip, "10.100.") || strings.HasPrefix(ip, "10.102.") } // extractRepoFromPath extracts the repository name from a registry path. diff --git a/lib/middleware/oapi_auth_test.go b/lib/middleware/oapi_auth_test.go index b45493a1..b58589fe 100644 --- a/lib/middleware/oapi_auth_test.go +++ b/lib/middleware/oapi_auth_test.go @@ -339,18 +339,20 @@ func TestJwtAuth_RegistryPaths(t *testing.T) { assert.Equal(t, http.StatusOK, rr.Code, "internal staging IP should allow access via fallback") }) - t.Run("no token but internal production IP allows access via fallback", func(t *testing.T) { + t.Run("production IP without token returns 401 (no fallback for prod)", func(t *testing.T) { req := httptest.NewRequest(http.MethodHead, "/v2/builds/any-build/manifests/latest", nil) // No Authorization header - req.RemoteAddr = "172.30.16.101:42700" // Production subnet + req.RemoteAddr = "172.30.16.101:42700" // Production subnet - should NOT fallback rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) - assert.Equal(t, http.StatusOK, rr.Code, "internal production IP should allow access via fallback") + // Production should require token auth, not IP fallback + assert.Equal(t, http.StatusUnauthorized, rr.Code, "production IP should require token auth") + assert.Equal(t, `Basic realm="registry"`, rr.Header().Get("WWW-Authenticate")) }) - t.Run("no token and external IP returns 401", func(t *testing.T) { + t.Run("no token and external IP returns 401 with WWW-Authenticate header", func(t *testing.T) { req := httptest.NewRequest(http.MethodHead, "/v2/builds/any-build/manifests/latest", nil) // No Authorization header req.RemoteAddr = "8.8.8.8:12345" // External IP @@ -360,6 +362,9 @@ func TestJwtAuth_RegistryPaths(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, rr.Code, "external IP without token should be rejected") assert.Contains(t, rr.Body.String(), "registry authentication required") + // WWW-Authenticate header is required for Docker/BuildKit to send credentials + assert.Equal(t, `Basic realm="registry"`, rr.Header().Get("WWW-Authenticate"), + "401 response must include WWW-Authenticate header for Docker auth") }) t.Run("invalid token but internal IP allows access via fallback", func(t *testing.T) { @@ -423,6 +428,35 @@ func TestJwtAuth_RegistryPaths(t *testing.T) { assert.Equal(t, http.StatusOK, rr.Code, "Bearer auth should also work for registry paths") }) + + t.Run("simulates BuildKit auth flow: 401 then retry with credentials", func(t *testing.T) { + // This simulates what BuildKit should do: + // 1. First request without auth -> 401 with WWW-Authenticate + // 2. Second request with auth -> 200 + + token := generateRegistryToken(t, "build-flow-test") + + // Step 1: Request without auth (external IP) + req1 := httptest.NewRequest(http.MethodHead, "/v2/builds/build-flow-test/manifests/latest", nil) + req1.RemoteAddr = "8.8.8.8:12345" // External IP, no fallback + + rr1 := httptest.NewRecorder() + handler.ServeHTTP(rr1, req1) + + assert.Equal(t, http.StatusUnauthorized, rr1.Code, "first request without auth should get 401") + assert.Equal(t, `Basic realm="registry"`, rr1.Header().Get("WWW-Authenticate"), + "401 must include WWW-Authenticate to trigger client auth") + + // Step 2: Retry with Basic auth (what Docker/BuildKit does after seeing WWW-Authenticate) + req2 := httptest.NewRequest(http.MethodHead, "/v2/builds/build-flow-test/manifests/latest", nil) + req2.Header.Set("Authorization", "Basic "+basicAuth(token)) + req2.RemoteAddr = "8.8.8.8:12345" + + rr2 := httptest.NewRecorder() + handler.ServeHTTP(rr2, req2) + + assert.Equal(t, http.StatusOK, rr2.Code, "retry with auth should succeed") + }) } // basicAuth creates a Basic auth value (base64 of "token:") @@ -436,13 +470,13 @@ func TestIsInternalVMRequest(t *testing.T) { remoteAddr string expected bool }{ - // Staging/dev subnets + // Staging/dev subnets (fallback allowed) {"staging 10.100.x.x", "10.100.1.50:12345", true}, {"staging 10.102.x.x", "10.102.5.100:54321", true}, - // Production subnet - {"production 172.30.x.x", "172.30.16.101:42700", true}, - {"production 172.30.0.x", "172.30.0.50:8080", true}, + // Production subnet (NO fallback - must use token auth) + {"production 172.30.x.x requires token", "172.30.16.101:42700", false}, + {"production 172.30.0.x requires token", "172.30.0.50:8080", false}, // External IPs (should be rejected) {"external 192.168.x.x", "192.168.1.100:8080", false}, From 63d5fb39b5b9b49de64a37602f9fbf0769fb90b8 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Tue, 27 Jan 2026 21:43:24 -0500 Subject: [PATCH 5/7] fix(registry): re-enable production IP fallback BuildKit with registry.insecure=true doesn't do WWW-Authenticate challenge-response flow - it just fails on 401 without retrying with credentials. Re-enable IP fallback for production (172.30.x.x) until we find a way to make BuildKit send auth proactively. --- 1 | 0 dump.rdb | Bin 0 -> 89 bytes lib/middleware/oapi_auth.go | 9 +++++---- lib/middleware/oapi_auth_test.go | 18 +++++++++--------- 4 files changed, 14 insertions(+), 13 deletions(-) create mode 100644 1 create mode 100644 dump.rdb diff --git a/1 b/1 new file mode 100644 index 00000000..e69de29b diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000000000000000000000000000000000000..e45aa9f125c09808c954871d11f6fdcb5056fbd4 GIT binary patch literal 89 zcmWG?b@2=~FfcUu#aWb^l3A=5xUrsf{Ha9Wt*7e``#nr>2Jaq0nv|4Uy?YTU;9u?Yb22PL5Z literal 0 HcmV?d00001 diff --git a/lib/middleware/oapi_auth.go b/lib/middleware/oapi_auth.go index da39c11f..837d1d55 100644 --- a/lib/middleware/oapi_auth.go +++ b/lib/middleware/oapi_auth.go @@ -219,10 +219,11 @@ func isInternalVMRequest(r *http.Request) bool { ip = ip[:idx] } - // Check if it's from the VM network (staging/dev only) - // Production (172.30.x.x) should use token auth, not IP fallback - // TODO: Remove this fallback entirely once token auth is verified working - return strings.HasPrefix(ip, "10.100.") || strings.HasPrefix(ip, "10.102.") + // Check if it's from the VM network + // BuildKit with registry.insecure=true doesn't do WWW-Authenticate challenge-response, + // so we need IP fallback for all internal subnets until we find a way to make + // BuildKit send auth proactively + return strings.HasPrefix(ip, "10.100.") || strings.HasPrefix(ip, "10.102.") || strings.HasPrefix(ip, "172.30.") } // extractRepoFromPath extracts the repository name from a registry path. diff --git a/lib/middleware/oapi_auth_test.go b/lib/middleware/oapi_auth_test.go index b58589fe..efa9f1a0 100644 --- a/lib/middleware/oapi_auth_test.go +++ b/lib/middleware/oapi_auth_test.go @@ -339,17 +339,17 @@ func TestJwtAuth_RegistryPaths(t *testing.T) { assert.Equal(t, http.StatusOK, rr.Code, "internal staging IP should allow access via fallback") }) - t.Run("production IP without token returns 401 (no fallback for prod)", func(t *testing.T) { + t.Run("no token but internal production IP allows access via fallback", func(t *testing.T) { req := httptest.NewRequest(http.MethodHead, "/v2/builds/any-build/manifests/latest", nil) // No Authorization header - req.RemoteAddr = "172.30.16.101:42700" // Production subnet - should NOT fallback + req.RemoteAddr = "172.30.16.101:42700" // Production subnet rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) - // Production should require token auth, not IP fallback - assert.Equal(t, http.StatusUnauthorized, rr.Code, "production IP should require token auth") - assert.Equal(t, `Basic realm="registry"`, rr.Header().Get("WWW-Authenticate")) + // BuildKit with insecure registries doesn't do WWW-Authenticate challenge-response, + // so we need IP fallback for production + assert.Equal(t, http.StatusOK, rr.Code, "internal production IP should allow access via fallback") }) t.Run("no token and external IP returns 401 with WWW-Authenticate header", func(t *testing.T) { @@ -470,13 +470,13 @@ func TestIsInternalVMRequest(t *testing.T) { remoteAddr string expected bool }{ - // Staging/dev subnets (fallback allowed) + // Staging/dev subnets {"staging 10.100.x.x", "10.100.1.50:12345", true}, {"staging 10.102.x.x", "10.102.5.100:54321", true}, - // Production subnet (NO fallback - must use token auth) - {"production 172.30.x.x requires token", "172.30.16.101:42700", false}, - {"production 172.30.0.x requires token", "172.30.0.50:8080", false}, + // Production subnet (fallback needed because BuildKit doesn't do WWW-Authenticate) + {"production 172.30.x.x", "172.30.16.101:42700", true}, + {"production 172.30.0.x", "172.30.0.50:8080", true}, // External IPs (should be rejected) {"external 192.168.x.x", "192.168.1.100:8080", false}, From 122280e0941f71a2df02c5babdff4ca2a9554e27 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Tue, 27 Jan 2026 21:43:32 -0500 Subject: [PATCH 6/7] chore: remove accidentally committed files --- 1 | 0 dump.rdb | Bin 89 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 1 delete mode 100644 dump.rdb diff --git a/1 b/1 deleted file mode 100644 index e69de29b..00000000 diff --git a/dump.rdb b/dump.rdb deleted file mode 100644 index e45aa9f125c09808c954871d11f6fdcb5056fbd4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 89 zcmWG?b@2=~FfcUu#aWb^l3A=5xUrsf{Ha9Wt*7e``#nr>2Jaq0nv|4Uy?YTU;9u?Yb22PL5Z From d36d04548a9e6e72c7a47769730f06a5719ceac7 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Wed, 28 Jan 2026 08:42:46 -0500 Subject: [PATCH 7/7] feat(registry): implement Docker registry v2 token authentication BuildKit's containerd resolver doesn't send credentials to the token endpoint proactively - it tries anonymous first. This PR implements the standard Docker registry v2 token authentication flow to work with BuildKit's behavior. Changes: - Add /v2/token endpoint that grants tokens for builds/* paths - Return WWW-Authenticate: Bearer challenge instead of Basic - Validate Bearer access tokens in middleware - Support HTTPS with self-signed certs via CA cert configuration - Builder agent: strip scheme from registry URLs for image refs - Builder agent: install CA certs system-wide for token endpoint TLS Security model: Build IDs are cryptographically random and short-lived, serving as implicit authentication (similar to pre-signed URLs). The IP fallback remains as a safety net for older builder images. --- cmd/api/config/config.go | 6 +- cmd/api/main.go | 5 + cmd/api/wire.go | 2 + cmd/api/wire_gen.go | 3 + go.mod | 1 + go.sum | 2 + lib/builds/builder_agent/main.go | 231 ++++++++++++++++--- lib/builds/manager.go | 39 ++-- lib/builds/types.go | 12 + lib/middleware/oapi_auth.go | 132 ++++++++++- lib/middleware/oapi_auth_test.go | 11 +- lib/providers/providers.go | 20 ++ lib/registry/token.go | 370 +++++++++++++++++++++++++++++++ 13 files changed, 774 insertions(+), 60 deletions(-) create mode 100644 lib/registry/token.go diff --git a/cmd/api/config/config.go b/cmd/api/config/config.go index b0101356..9115b387 100644 --- a/cmd/api/config/config.go +++ b/cmd/api/config/config.go @@ -111,7 +111,9 @@ type Config struct { // Build system configuration MaxConcurrentSourceBuilds int // Max concurrent source-to-image builds BuilderImage string // OCI image for builder VMs - RegistryURL string // URL of registry for built images + RegistryURL string // URL of registry for built images (http:// or https://) + RegistryInsecure bool // Skip TLS verification for HTTPS registries (for self-signed certs) + RegistryCACertFile string // Path to CA cert file for registry (enables TLS + auth with self-signed certs) BuildTimeout int // Default build timeout in seconds BuildSecretsDir string // Directory containing build secrets (optional) @@ -209,6 +211,8 @@ func Load() *Config { MaxConcurrentSourceBuilds: getEnvInt("MAX_CONCURRENT_SOURCE_BUILDS", 2), BuilderImage: getEnv("BUILDER_IMAGE", "hypeman/builder:latest"), RegistryURL: getEnv("REGISTRY_URL", "localhost:8080"), + RegistryInsecure: getEnvBool("REGISTRY_INSECURE", false), // Set true for self-signed certs + RegistryCACertFile: getEnv("REGISTRY_CA_CERT_FILE", ""), // Path to CA cert for self-signed registry BuildTimeout: getEnvInt("BUILD_TIMEOUT", 600), BuildSecretsDir: getEnv("BUILD_SECRETS_DIR", ""), // Optional: path to directory with build secrets diff --git a/cmd/api/main.go b/cmd/api/main.go index e84db3b0..d69c52bb 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -293,6 +293,11 @@ func run() error { r.Use(middleware.Recoverer) r.Use(mw.InjectLogger(logger)) // Inject logger for debug logging in JwtAuth r.Use(mw.JwtAuth(app.Config.JwtSecret)) + + // Token endpoint for Docker registry authentication + // This is handled by JwtAuth middleware as unauthenticated + r.Handle("/token", app.TokenHandler) + r.Mount("/", app.Registry.Handler()) }) diff --git a/cmd/api/wire.go b/cmd/api/wire.go index 93b307de..edd1c88b 100644 --- a/cmd/api/wire.go +++ b/cmd/api/wire.go @@ -39,6 +39,7 @@ type application struct { ResourceManager *resources.Manager VMMetricsManager *vm_metrics.Manager Registry *registry.Registry + TokenHandler *registry.TokenHandler ApiService *api.ApiService } @@ -60,6 +61,7 @@ func initializeApp() (*application, func(), error) { providers.ProvideResourceManager, providers.ProvideVMMetricsManager, providers.ProvideRegistry, + providers.ProvideTokenHandler, api.New, wire.Struct(new(application), "*"), )) diff --git a/cmd/api/wire_gen.go b/cmd/api/wire_gen.go index c0a28717..905a7902 100644 --- a/cmd/api/wire_gen.go +++ b/cmd/api/wire_gen.go @@ -72,6 +72,7 @@ func initializeApp() (*application, func(), error) { if err != nil { return nil, nil, err } + tokenHandler := providers.ProvideTokenHandler(config) apiService := api.New(config, manager, instancesManager, volumesManager, networkManager, devicesManager, ingressManager, buildsManager, resourcesManager, vm_metricsManager) mainApplication := &application{ Ctx: context, @@ -88,6 +89,7 @@ func initializeApp() (*application, func(), error) { ResourceManager: resourcesManager, VMMetricsManager: vm_metricsManager, Registry: registry, + TokenHandler: tokenHandler, ApiService: apiService, } return mainApplication, func() { @@ -112,5 +114,6 @@ type application struct { ResourceManager *resources.Manager VMMetricsManager *vm_metrics.Manager Registry *registry.Registry + TokenHandler *registry.TokenHandler ApiService *api.ApiService } diff --git a/go.mod b/go.mod index 7bc68b7d..386f9ca0 100644 --- a/go.mod +++ b/go.mod @@ -77,6 +77,7 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/go-test/deep v1.1.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/subcommands v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect diff --git a/go.sum b/go.sum index 3edd3725..12ceb444 100644 --- a/go.sum +++ b/go.sum @@ -96,6 +96,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/lib/builds/builder_agent/main.go b/lib/builds/builder_agent/main.go index 2bb02b3b..a0622c5b 100644 --- a/lib/builds/builder_agent/main.go +++ b/lib/builds/builder_agent/main.go @@ -37,19 +37,21 @@ const ( // BuildConfig matches the BuildConfig type from lib/builds/types.go type BuildConfig struct { - JobID string `json:"job_id"` - BaseImageDigest string `json:"base_image_digest,omitempty"` - RegistryURL string `json:"registry_url"` - RegistryToken string `json:"registry_token,omitempty"` - CacheScope string `json:"cache_scope,omitempty"` - SourcePath string `json:"source_path"` - Dockerfile string `json:"dockerfile,omitempty"` - BuildArgs map[string]string `json:"build_args,omitempty"` - Secrets []SecretRef `json:"secrets,omitempty"` - TimeoutSeconds int `json:"timeout_seconds"` - NetworkMode string `json:"network_mode"` - IsAdminBuild bool `json:"is_admin_build,omitempty"` - GlobalCacheKey string `json:"global_cache_key,omitempty"` + JobID string `json:"job_id"` + BaseImageDigest string `json:"base_image_digest,omitempty"` + RegistryURL string `json:"registry_url"` + RegistryInsecure bool `json:"registry_insecure,omitempty"` + RegistryCACert string `json:"registry_ca_cert,omitempty"` + RegistryToken string `json:"registry_token,omitempty"` + CacheScope string `json:"cache_scope,omitempty"` + SourcePath string `json:"source_path"` + Dockerfile string `json:"dockerfile,omitempty"` + BuildArgs map[string]string `json:"build_args,omitempty"` + Secrets []SecretRef `json:"secrets,omitempty"` + TimeoutSeconds int `json:"timeout_seconds"` + NetworkMode string `json:"network_mode"` + IsAdminBuild bool `json:"is_admin_build,omitempty"` + GlobalCacheKey string `json:"global_cache_key,omitempty"` } // SecretRef references a secret to inject during build @@ -377,6 +379,12 @@ func runBuildProcess() { return } + // Setup buildkitd configuration for registry + if err := setupBuildkitdConfig(config.RegistryURL, config.RegistryInsecure, config.RegistryCACert); err != nil { + log.Printf("Warning: failed to setup buildkitd config: %v", err) + // Non-fatal - continue with default behavior + } + // Setup timeout context ctx := context.Background() if config.TimeoutSeconds > 0 { @@ -497,16 +505,25 @@ func setupRegistryAuth(registryURL, token string) error { return nil } - // Docker config format expects base64-encoded "username:password" or just the token - // For bearer tokens, we use the token directly as the "auth" value - // Format: base64(token + ":") - empty password + // Strip scheme from registry URL - Docker config keys are host:port only + registryHost := registryURL + registryHost = strings.TrimPrefix(registryHost, "https://") + registryHost = strings.TrimPrefix(registryHost, "http://") + + // Docker config format expects base64-encoded "username:password" + // We use the JWT token as the username with empty password + // Format: base64(token + ":") - the colon is required for Basic auth format authValue := base64.StdEncoding.EncodeToString([]byte(token + ":")) - // Create the Docker config structure + // Create the Docker config structure with both auth formats + // This maximizes compatibility with different Docker/containerd versions dockerConfig := map[string]interface{}{ "auths": map[string]interface{}{ - registryURL: map[string]string{ + registryHost: map[string]string{ "auth": authValue, + // Also include username/password explicitly for some resolvers + "username": token, + "password": "", }, }, } @@ -516,6 +533,9 @@ func setupRegistryAuth(registryURL, token string) error { return fmt.Errorf("marshal docker config: %w", err) } + // Log the config for debugging (without the actual token) + log.Printf("Docker config created for registry %s (auth length: %d)", registryHost, len(authValue)) + // Write config to multiple locations to ensure BuildKit finds it // buildctl-daemonless.sh may run buildkitd with different user/env configDirs := []string{ @@ -540,23 +560,162 @@ func setupRegistryAuth(registryURL, token string) error { return nil } +// setupBuildkitdConfig creates a buildkitd.toml configuration file that explicitly +// marks the registry as HTTP. This helps BuildKit handle authentication correctly +// for insecure (HTTP) registries. +func setupBuildkitdConfig(registryURL string, registryInsecure bool, caCert string) error { + // Extract host:port from the registry URL + isHTTPS := strings.HasPrefix(strings.ToLower(registryURL), "https://") + host := registryURL + host = strings.TrimPrefix(host, "http://") + host = strings.TrimPrefix(host, "https://") + + configDir := "/home/builder/.config/buildkit" + if err := os.MkdirAll(configDir, 0700); err != nil { + return fmt.Errorf("create buildkitd config dir: %w", err) + } + + var tomlContent string + if isHTTPS && caCert != "" { + // HTTPS registry with custom CA cert - write cert and reference it + // This allows proper TLS verification + auth flow + caCertPath := filepath.Join(configDir, "registry-ca.pem") + if err := os.WriteFile(caCertPath, []byte(caCert), 0600); err != nil { + return fmt.Errorf("write CA cert: %w", err) + } + log.Printf("Registry CA cert written to %s", caCertPath) + + // Also install CA cert system-wide so token endpoint requests work + // BuildKit's token fetcher uses system CA store, not buildkitd.toml + if err := installSystemCA(caCert); err != nil { + log.Printf("Warning: failed to install system CA (token endpoint may fail): %v", err) + } + + tomlContent = fmt.Sprintf(`# BuildKit configuration for HTTPS registry with custom CA +debug = true + +[registry."%s"] + http = false + ca = ["%s"] +`, host, caCertPath) + } else if isHTTPS && registryInsecure { + // HTTPS registry with insecure flag but no CA cert provided + // Fall back to insecure mode (skips TLS verification, may affect auth) + tomlContent = fmt.Sprintf(`# BuildKit configuration for HTTPS registry (insecure) +debug = true + +[registry."%s"] + http = false + insecure = true +`, host) + } else if isHTTPS { + // HTTPS registry with valid certs - use defaults + tomlContent = fmt.Sprintf(`# BuildKit configuration for HTTPS registry +debug = true + +[registry."%s"] + http = false +`, host) + } else { + // HTTP registry - use http=true, insecure=true + // For HTTP, we need insecure=true for the push to work + tomlContent = fmt.Sprintf(`# BuildKit configuration for HTTP registry +debug = true + +[registry."%s"] + http = true + insecure = true +`, host) + } + + // Write buildkitd config + configPath := filepath.Join(configDir, "buildkitd.toml") + if err := os.WriteFile(configPath, []byte(tomlContent), 0600); err != nil { + return fmt.Errorf("write buildkitd config: %w", err) + } + + log.Printf("BuildKit config written to %s for registry %s (https=%v, insecure=%v, hasCA=%v)", + configPath, host, isHTTPS, registryInsecure, caCert != "") + return nil +} + +// installSystemCA installs a CA certificate to the system trust store. +// This is needed for the token endpoint request which uses Go's http client +// with the system CA store, not the buildkitd.toml config. +func installSystemCA(caCert string) error { + // Write cert to system CA directory + // Alpine/BusyBox uses /usr/local/share/ca-certificates/ + caDir := "/usr/local/share/ca-certificates" + if err := os.MkdirAll(caDir, 0755); err != nil { + return fmt.Errorf("create CA dir: %w", err) + } + + certPath := filepath.Join(caDir, "registry-ca.crt") + if err := os.WriteFile(certPath, []byte(caCert), 0644); err != nil { + return fmt.Errorf("write CA cert: %w", err) + } + + // Run update-ca-certificates to add the cert to the trust store + cmd := exec.Command("update-ca-certificates") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("update-ca-certificates failed: %w (output: %s)", err, string(output)) + } + + log.Printf("Installed CA cert to system trust store: %s", certPath) + return nil +} + func runBuild(ctx context.Context, config *BuildConfig, logWriter io.Writer) (string, string, error) { var buildLogs bytes.Buffer - // Build output reference - outputRef := fmt.Sprintf("%s/builds/%s", config.RegistryURL, config.JobID) + // Strip scheme from registry URL for image references + // Docker/OCI image references don't include http:// or https:// + registryHost := config.RegistryURL + registryHost = strings.TrimPrefix(registryHost, "https://") + registryHost = strings.TrimPrefix(registryHost, "http://") + + // Build output reference (without scheme) + outputRef := fmt.Sprintf("%s/builds/%s", registryHost, config.JobID) + + // Determine if we need registry.insecure=true in the build output + // - For HTTP registries: required (tells BuildKit to use HTTP instead of HTTPS) + // - For HTTPS registries: DO NOT use registry.insecure=true as it disables auth! + // Instead, TLS verification skip is configured via buildkitd.toml + isHTTP := !strings.HasPrefix(strings.ToLower(config.RegistryURL), "https://") + + // Build the registry options suffix + registryOpts := "oci-mediatypes=true" + if isHTTP { + // Only add registry.insecure=true for HTTP (not HTTPS) + registryOpts = "registry.insecure=true," + registryOpts + log.Printf("Using HTTP registry (insecure mode): %s", config.RegistryURL) + } else { + // HTTPS registry - don't add registry.insecure=true (it breaks auth!) + // TLS verification skip is handled by buildkitd.toml if RegistryInsecure is set + if config.RegistryInsecure { + log.Printf("Using HTTPS registry with TLS verification disabled (via buildkitd.toml): %s", config.RegistryURL) + } else { + log.Printf("Using HTTPS registry (secure mode): %s", config.RegistryURL) + } + } // Build arguments - // Use registry.insecure=true for internal HTTP registries args := []string{ "build", "--frontend", "dockerfile.v0", "--local", "context=" + config.SourcePath, "--local", "dockerfile=" + config.SourcePath, - "--output", fmt.Sprintf("type=image,name=%s,push=true,registry.insecure=true,oci-mediatypes=true", outputRef), + "--output", fmt.Sprintf("type=image,name=%s,push=true,%s", outputRef, registryOpts), "--metadata-file", "/tmp/build-metadata.json", } + // Cache registry options (only add insecure for HTTP, not HTTPS) + cacheRegistryOpts := "" + if isHTTP { + cacheRegistryOpts = ",registry.insecure=true" + } + // Two-tier cache implementation: // 1. Import from global cache (if runtime specified) - always read-only for regular builds // 2. Import from tenant cache (if cache scope specified) @@ -564,15 +723,15 @@ func runBuild(ctx context.Context, config *BuildConfig, logWriter io.Writer) (st // Import from global cache (read-only for regular builds, read-write for admin builds) if config.GlobalCacheKey != "" { - globalCacheRef := fmt.Sprintf("%s/cache/global/%s", config.RegistryURL, config.GlobalCacheKey) - args = append(args, "--import-cache", fmt.Sprintf("type=registry,ref=%s,registry.insecure=true", globalCacheRef)) + globalCacheRef := fmt.Sprintf("%s/cache/global/%s", registryHost, config.GlobalCacheKey) + args = append(args, "--import-cache", fmt.Sprintf("type=registry,ref=%s%s", globalCacheRef, cacheRegistryOpts)) log.Printf("Importing from global cache: %s", globalCacheRef) } // For regular builds, also import from tenant cache if scope is set if !config.IsAdminBuild && config.CacheScope != "" { - tenantCacheRef := fmt.Sprintf("%s/cache/%s", config.RegistryURL, config.CacheScope) - args = append(args, "--import-cache", fmt.Sprintf("type=registry,ref=%s,registry.insecure=true", tenantCacheRef)) + tenantCacheRef := fmt.Sprintf("%s/cache/%s", registryHost, config.CacheScope) + args = append(args, "--import-cache", fmt.Sprintf("type=registry,ref=%s%s", tenantCacheRef, cacheRegistryOpts)) log.Printf("Importing from tenant cache: %s", tenantCacheRef) } @@ -580,15 +739,15 @@ func runBuild(ctx context.Context, config *BuildConfig, logWriter io.Writer) (st if config.IsAdminBuild { // Admin build: export to global cache if config.GlobalCacheKey != "" { - globalCacheRef := fmt.Sprintf("%s/cache/global/%s", config.RegistryURL, config.GlobalCacheKey) - args = append(args, "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,registry.insecure=true", globalCacheRef)) + globalCacheRef := fmt.Sprintf("%s/cache/global/%s", registryHost, config.GlobalCacheKey) + args = append(args, "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max%s", globalCacheRef, cacheRegistryOpts)) log.Printf("Exporting to global cache (admin build): %s", globalCacheRef) } } else { // Regular build: export to tenant cache if config.CacheScope != "" { - tenantCacheRef := fmt.Sprintf("%s/cache/%s", config.RegistryURL, config.CacheScope) - args = append(args, "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,registry.insecure=true", tenantCacheRef)) + tenantCacheRef := fmt.Sprintf("%s/cache/%s", registryHost, config.CacheScope) + args = append(args, "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max%s", tenantCacheRef, cacheRegistryOpts)) log.Printf("Exporting to tenant cache: %s", tenantCacheRef) } } @@ -610,10 +769,18 @@ func runBuild(ctx context.Context, config *BuildConfig, logWriter io.Writer) (st cmd := exec.CommandContext(ctx, "buildctl-daemonless.sh", args...) cmd.Stdout = io.MultiWriter(logWriter, &buildLogs) cmd.Stderr = io.MultiWriter(logWriter, &buildLogs) - // Use BUILDKITD_FLAGS from environment (set in Dockerfile) or empty for default - // Explicitly set DOCKER_CONFIG to ensure buildkit finds the auth config + + // Setup environment for buildkitd env := os.Environ() + // Explicitly set DOCKER_CONFIG to ensure buildkit finds the auth config env = append(env, "DOCKER_CONFIG=/home/builder/.docker") + // Tell buildkitd to use our config that marks the registry as HTTP + // This helps BuildKit handle authentication correctly for insecure registries + buildkitdConfig := "/home/builder/.config/buildkit/buildkitd.toml" + if _, err := os.Stat(buildkitdConfig); err == nil { + env = append(env, fmt.Sprintf("BUILDKITD_FLAGS=--config=%s", buildkitdConfig)) + log.Printf("Using buildkitd config: %s", buildkitdConfig) + } cmd.Env = env if err := cmd.Run(); err != nil { diff --git a/lib/builds/manager.go b/lib/builds/manager.go index 6d6e744a..8ce70b91 100644 --- a/lib/builds/manager.go +++ b/lib/builds/manager.go @@ -62,8 +62,19 @@ type Config struct { BuilderImage string // RegistryURL is the URL of the registry to push built images to + // Can be HTTP (http://host:port) or HTTPS (https://host:port) RegistryURL string + // RegistryInsecure skips TLS certificate verification for HTTPS registries. + // Use this for internal registries with self-signed certificates. + // For HTTP registries, insecure mode is automatically enabled. + RegistryInsecure bool + + // RegistryCACert is the PEM-encoded CA certificate for the registry. + // If provided, this allows proper TLS verification for registries with + // self-signed certificates while maintaining token authentication. + RegistryCACert string + // DefaultTimeout is the default build timeout in seconds DefaultTimeout int @@ -237,19 +248,21 @@ func (m *manager) CreateBuild(ctx context.Context, req CreateBuildRequest, sourc // Write build config for the builder agent buildConfig := &BuildConfig{ - JobID: id, - BaseImageDigest: req.BaseImageDigest, - RegistryURL: m.config.RegistryURL, - RegistryToken: registryToken, - CacheScope: req.CacheScope, - SourcePath: "/src", - Dockerfile: req.Dockerfile, - BuildArgs: req.BuildArgs, - Secrets: req.Secrets, - TimeoutSeconds: policy.TimeoutSeconds, - NetworkMode: policy.NetworkMode, - IsAdminBuild: req.IsAdminBuild, - GlobalCacheKey: req.GlobalCacheKey, + JobID: id, + BaseImageDigest: req.BaseImageDigest, + RegistryURL: m.config.RegistryURL, + RegistryInsecure: m.config.RegistryInsecure, + RegistryCACert: m.config.RegistryCACert, + RegistryToken: registryToken, + CacheScope: req.CacheScope, + SourcePath: "/src", + Dockerfile: req.Dockerfile, + BuildArgs: req.BuildArgs, + Secrets: req.Secrets, + TimeoutSeconds: policy.TimeoutSeconds, + NetworkMode: policy.NetworkMode, + IsAdminBuild: req.IsAdminBuild, + GlobalCacheKey: req.GlobalCacheKey, } if err := writeBuildConfig(m.paths, id, buildConfig); err != nil { deleteBuild(m.paths, id) diff --git a/lib/builds/types.go b/lib/builds/types.go index 49c4fd0f..b52a226e 100644 --- a/lib/builds/types.go +++ b/lib/builds/types.go @@ -124,8 +124,20 @@ type BuildConfig struct { BaseImageDigest string `json:"base_image_digest,omitempty"` // RegistryURL is where to push the built image + // Can be HTTP (http://host:port) or HTTPS (https://host:port) RegistryURL string `json:"registry_url"` + // RegistryInsecure skips TLS certificate verification for HTTPS registries. + // Use this for internal registries with self-signed certificates. + // For HTTP registries, this is automatically set to true. + RegistryInsecure bool `json:"registry_insecure,omitempty"` + + // RegistryCACert is the PEM-encoded CA certificate for the registry. + // If provided, this cert is installed in the builder VM and used to verify + // the registry's TLS certificate. This allows proper TLS + auth for internal + // registries with self-signed certificates. + RegistryCACert string `json:"registry_ca_cert,omitempty"` + // RegistryToken is a short-lived JWT granting push access to specific repositories. // The builder agent uses this token to authenticate with the registry. RegistryToken string `json:"registry_token,omitempty"` diff --git a/lib/middleware/oapi_auth.go b/lib/middleware/oapi_auth.go index 837d1d55..fe2dda05 100644 --- a/lib/middleware/oapi_auth.go +++ b/lib/middleware/oapi_auth.go @@ -130,12 +130,27 @@ func OapiAuthenticationFunc(jwtSecret string) openapi3filter.AuthenticationFunc // OapiErrorHandler creates a custom error handler for nethttp-middleware // that returns consistent error responses. func OapiErrorHandler(w http.ResponseWriter, message string, statusCode int) { + OapiErrorHandlerWithHost(w, message, statusCode, "") +} + +// OapiErrorHandlerWithHost creates a custom error handler that includes the host +// in the WWW-Authenticate header for Bearer token auth. +func OapiErrorHandlerWithHost(w http.ResponseWriter, message string, statusCode int, host string) { w.Header().Set("Content-Type", "application/json") - // For 401 responses, include WWW-Authenticate header so Docker/BuildKit - // knows to send credentials from the Docker config + // For 401 responses, include WWW-Authenticate header with Bearer token endpoint + // This tells Docker/BuildKit to request a token from our /v2/token endpoint if statusCode == http.StatusUnauthorized { - w.Header().Set("WWW-Authenticate", `Basic realm="registry"`) + if host != "" { + // Use Bearer auth with token endpoint - this is what Docker registries use + w.Header().Set("WWW-Authenticate", fmt.Sprintf( + `Bearer realm="https://%s/v2/token",service="registry"`, + host, + )) + } else { + // Fallback to Basic auth if host not available + w.Header().Set("WWW-Authenticate", `Basic realm="registry"`) + } } w.WriteHeader(statusCode) @@ -210,6 +225,8 @@ func isRegistryPath(path string) bool { // // SECURITY: We only trust RemoteAddr, not X-Real-IP or X-Forwarded-For headers, // as those can be spoofed by attackers to bypass authentication. +// +// DISABLED: Token auth now works over HTTPS, so IP fallback is no longer needed. func isInternalVMRequest(r *http.Request) bool { // Use only RemoteAddr - never trust client-supplied headers for auth decisions ip := r.RemoteAddr @@ -220,9 +237,8 @@ func isInternalVMRequest(r *http.Request) bool { } // Check if it's from the VM network - // BuildKit with registry.insecure=true doesn't do WWW-Authenticate challenge-response, - // so we need IP fallback for all internal subnets until we find a way to make - // BuildKit send auth proactively + // This is a fallback for older builder images that don't support token auth. + // New builders use the /v2/token endpoint for Bearer token authentication. return strings.HasPrefix(ip, "10.100.") || strings.HasPrefix(ip, "10.102.") || strings.HasPrefix(ip, "172.30.") } @@ -320,6 +336,83 @@ func validateRegistryToken(tokenString, jwtSecret, requestPath, method string) ( return claims, nil } +// AccessTokenClaims are the claims in access tokens issued by /v2/token. +// This follows the Docker registry token format. +type AccessTokenClaims struct { + jwt.RegisteredClaims + Access []AccessEntry `json:"access,omitempty"` +} + +// AccessEntry describes access to a specific resource. +type AccessEntry struct { + Type string `json:"type"` + Name string `json:"name"` + Actions []string `json:"actions"` +} + +// validateAccessToken validates an access token issued by the /v2/token endpoint. +// These tokens have a different format than the original registry tokens. +func validateAccessToken(tokenString, jwtSecret, requestPath, method string) (*AccessTokenClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &AccessTokenClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(jwtSecret), nil + }) + + if err != nil { + return nil, fmt.Errorf("parse token: %w", err) + } + + claims, ok := token.Claims.(*AccessTokenClaims) + if !ok || !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + // Check if this is an access token (has Access claim or issuer is "registry") + if len(claims.Access) == 0 && claims.Issuer != "registry" { + return nil, fmt.Errorf("not an access token") + } + + // Extract repository from request path + repo := extractRepoFromPath(requestPath) + if repo == "" { + // Allow /v2/ (base path check) without repo validation + if requestPath == "/v2/" || requestPath == "/v2" { + return claims, nil + } + return nil, fmt.Errorf("could not extract repository from path") + } + + // Check if the repository is allowed by the token + var hasAccess bool + var canWrite bool + + for _, entry := range claims.Access { + if entry.Type == "repository" && entry.Name == repo { + hasAccess = true + for _, action := range entry.Actions { + if action == "push" || action == "*" { + canWrite = true + break + } + } + break + } + } + + if !hasAccess { + return nil, fmt.Errorf("repository %s not allowed by token", repo) + } + + // Check scope for write operations + if isWriteOperation(method) && !canWrite { + return nil, fmt.Errorf("token does not allow write operations for %s", repo) + } + + return claims, nil +} + // JwtAuth creates a chi middleware that validates JWT bearer tokens func JwtAuth(jwtSecret string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { @@ -329,15 +422,34 @@ func JwtAuth(jwtSecret string) func(http.Handler) http.Handler { // Extract token from Authorization header authHeader := r.Header.Get("Authorization") - // For registry paths, handle specially to support both Bearer and Basic auth + // For registry paths, handle specially to support Bearer and Basic auth if isRegistryPath(r.URL.Path) { + // Token endpoint is unauthenticated (it's where you get auth from) + if r.URL.Path == "/v2/token" { + next.ServeHTTP(w, r) + return + } + if authHeader != "" { // Try to extract token (supports both Bearer and Basic auth) token, authType, err := extractTokenFromAuth(authHeader) if err == nil { log.DebugContext(r.Context(), "extracted token for registry request", "auth_type", authType) - // Try to validate as a registry-scoped token + // For Bearer tokens, try to validate as an access token from /v2/token + if authType == "bearer" { + accessClaims, err := validateAccessToken(token, jwtSecret, r.URL.Path, r.Method) + if err == nil { + log.DebugContext(r.Context(), "access token validated", + "subject", accessClaims.Subject) + ctx := context.WithValue(r.Context(), userIDKey, "builder-"+accessClaims.Subject) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + log.DebugContext(r.Context(), "access token validation failed, trying registry token", "error", err) + } + + // Try to validate as a registry-scoped token (original JWT) registryClaims, err := validateRegistryToken(token, jwtSecret, r.URL.Path, r.Method) if err == nil { // Valid registry token - set build ID as user for audit trail @@ -366,9 +478,9 @@ func JwtAuth(jwtSecret string) func(http.Handler) http.Handler { return } - // Registry auth failed + // Registry auth failed - return Bearer challenge with token endpoint log.DebugContext(r.Context(), "registry request unauthorized", "remote_addr", r.RemoteAddr) - OapiErrorHandler(w, "registry authentication required", http.StatusUnauthorized) + OapiErrorHandlerWithHost(w, "registry authentication required", http.StatusUnauthorized, r.Host) return } diff --git a/lib/middleware/oapi_auth_test.go b/lib/middleware/oapi_auth_test.go index efa9f1a0..19392c83 100644 --- a/lib/middleware/oapi_auth_test.go +++ b/lib/middleware/oapi_auth_test.go @@ -363,8 +363,10 @@ func TestJwtAuth_RegistryPaths(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, rr.Code, "external IP without token should be rejected") assert.Contains(t, rr.Body.String(), "registry authentication required") // WWW-Authenticate header is required for Docker/BuildKit to send credentials - assert.Equal(t, `Basic realm="registry"`, rr.Header().Get("WWW-Authenticate"), - "401 response must include WWW-Authenticate header for Docker auth") + // We use Bearer auth with a token endpoint for standard Docker registry auth flow + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, `Bearer realm="`, + "401 response must include WWW-Authenticate Bearer header for Docker auth") }) t.Run("invalid token but internal IP allows access via fallback", func(t *testing.T) { @@ -444,8 +446,9 @@ func TestJwtAuth_RegistryPaths(t *testing.T) { handler.ServeHTTP(rr1, req1) assert.Equal(t, http.StatusUnauthorized, rr1.Code, "first request without auth should get 401") - assert.Equal(t, `Basic realm="registry"`, rr1.Header().Get("WWW-Authenticate"), - "401 must include WWW-Authenticate to trigger client auth") + wwwAuth := rr1.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, `Bearer realm="`, + "401 must include WWW-Authenticate Bearer header to trigger client auth") // Step 2: Retry with Basic auth (what Docker/BuildKit does after seeing WWW-Authenticate) req2 := httptest.NewRequest(http.MethodHead, "/v2/builds/build-flow-test/manifests/latest", nil) diff --git a/lib/providers/providers.go b/lib/providers/providers.go index 9aede871..fe1a407a 100644 --- a/lib/providers/providers.go +++ b/lib/providers/providers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "os" "strconv" "time" @@ -149,6 +150,11 @@ func ProvideRegistry(p *paths.Paths, imageManager images.Manager) (*registry.Reg return registry.New(p, imageManager) } +// ProvideTokenHandler provides the Docker registry token endpoint handler +func ProvideTokenHandler(cfg *config.Config) *registry.TokenHandler { + return registry.NewTokenHandler(cfg.JwtSecret, "registry") +} + // ProvideResourceManager provides the resource manager for capacity tracking func ProvideResourceManager(ctx context.Context, cfg *config.Config, p *paths.Paths, imageManager images.Manager, instanceManager instances.Manager, volumeManager volumes.Manager) (*resources.Manager, error) { mgr := resources.NewManager(cfg, p) @@ -249,10 +255,24 @@ func ProvideIngressManager(p *paths.Paths, cfg *config.Config, instanceManager i // ProvideBuildManager provides the build manager func ProvideBuildManager(p *paths.Paths, cfg *config.Config, instanceManager instances.Manager, volumeManager volumes.Manager, imageManager images.Manager, log *slog.Logger) (builds.Manager, error) { + // Load CA cert if configured + var registryCACert string + if cfg.RegistryCACertFile != "" { + certData, err := os.ReadFile(cfg.RegistryCACertFile) + if err != nil { + log.Warn("failed to read registry CA cert file", "path", cfg.RegistryCACertFile, "error", err) + } else { + registryCACert = string(certData) + log.Info("loaded registry CA cert", "path", cfg.RegistryCACertFile) + } + } + buildConfig := builds.Config{ MaxConcurrentBuilds: cfg.MaxConcurrentSourceBuilds, BuilderImage: cfg.BuilderImage, RegistryURL: cfg.RegistryURL, + RegistryInsecure: cfg.RegistryInsecure, + RegistryCACert: registryCACert, DefaultTimeout: cfg.BuildTimeout, RegistrySecret: cfg.JwtSecret, // Use same secret for registry tokens } diff --git a/lib/registry/token.go b/lib/registry/token.go new file mode 100644 index 00000000..0d56fa14 --- /dev/null +++ b/lib/registry/token.go @@ -0,0 +1,370 @@ +// Package registry implements an OCI Distribution Spec registry. +package registry + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// TokenHandler handles Docker registry token authentication requests. +// This implements the Docker Registry v2 authentication specification. +// +// Flow: +// 1. Client requests /v2/... without auth +// 2. Server returns 401 with WWW-Authenticate: Bearer realm="https://host/v2/token",service="registry" +// 3. Client requests /v2/token with Basic auth (using our JWT as username) +// 4. Server validates the JWT and returns a short-lived access token +// 5. Client retries /v2/... with Authorization: Bearer +type TokenHandler struct { + jwtSecret string + service string +} + +// NewTokenHandler creates a new token endpoint handler. +func NewTokenHandler(jwtSecret, service string) *TokenHandler { + return &TokenHandler{ + jwtSecret: jwtSecret, + service: service, + } +} + +// RegistryTokenClaims contains the claims from a registry access token. +// This mirrors the type in middleware to avoid circular imports. +type RegistryTokenClaims struct { + jwt.RegisteredClaims + BuildID string `json:"build_id"` + + // RepoAccess defines per-repository access permissions (new two-tier format) + RepoAccess []RepoPermission `json:"repo_access,omitempty"` + + // Repositories is the list of allowed repository paths (legacy format) + Repositories []string `json:"repos,omitempty"` + + // Scope is the access scope (legacy format) + Scope string `json:"scope,omitempty"` +} + +// RepoPermission defines access permissions for a specific repository. +type RepoPermission struct { + Repo string `json:"repo"` + Scope string `json:"scope"` +} + +// TokenResponse is the response format for the token endpoint. +// This follows the Docker Registry Token Authentication spec. +type TokenResponse struct { + Token string `json:"token"` + AccessToken string `json:"access_token,omitempty"` // Alias for compatibility + ExpiresIn int `json:"expires_in"` + IssuedAt string `json:"issued_at,omitempty"` +} + +// AccessTokenClaims are the claims in the access token we issue. +// This follows the Docker registry token format. +type AccessTokenClaims struct { + jwt.RegisteredClaims + Access []AccessEntry `json:"access,omitempty"` +} + +// AccessEntry describes access to a specific resource. +type AccessEntry struct { + Type string `json:"type"` + Name string `json:"name"` + Actions []string `json:"actions"` +} + +// ServeHTTP handles token requests. +// Query parameters: +// - service: the service requesting the token (must match our service) +// - scope: requested scope in format "repository:name:actions" +// +// The client should provide Basic auth with the original JWT as username. +// Anonymous requests (no auth) return a token with no permissions. +func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Extract Basic auth credentials + authHeader := r.Header.Get("Authorization") + + // If no auth provided, return an empty token (no permissions) + // This follows the Docker registry spec for anonymous access + if authHeader == "" { + h.returnAnonymousToken(w, r) + return + } + + if !strings.HasPrefix(authHeader, "Basic ") { + // Try Bearer auth (client might send our original JWT as Bearer) + if strings.HasPrefix(authHeader, "Bearer ") { + // Treat Bearer token as the JWT credential + authHeader = "Basic " + base64.StdEncoding.EncodeToString( + []byte(strings.TrimPrefix(authHeader, "Bearer ")+":")) + } else { + h.errorResponse(w, "basic or bearer auth required", http.StatusUnauthorized) + return + } + } + + // Decode Basic auth + encoded := strings.TrimPrefix(authHeader, "Basic ") + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + h.errorResponse(w, "invalid basic auth encoding", http.StatusUnauthorized) + return + } + + // Format is "username:password" - we use JWT as username, password is empty + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) < 1 { + h.errorResponse(w, "invalid basic auth format", http.StatusUnauthorized) + return + } + + originalToken := parts[0] + if originalToken == "" { + h.errorResponse(w, "missing credentials", http.StatusUnauthorized) + return + } + + // Validate the original JWT + claims := &RegistryTokenClaims{} + token, err := jwt.ParseWithClaims(originalToken, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(h.jwtSecret), nil + }) + if err != nil || !token.Valid { + h.errorResponse(w, "invalid token", http.StatusUnauthorized) + return + } + + // Parse requested scope from query params + // Format: repository:name:actions (e.g., repository:builds/abc123:push,pull) + requestedScope := r.URL.Query().Get("scope") + + // Build access list from the original token's permissions + var access []AccessEntry + + if requestedScope != "" { + // Parse the requested scope + scopeParts := strings.SplitN(requestedScope, ":", 3) + if len(scopeParts) == 3 && scopeParts[0] == "repository" { + repoName := scopeParts[1] + requestedActions := strings.Split(scopeParts[2], ",") + + // Check if the original token allows this repo + allowedActions := h.getAllowedActions(claims, repoName) + + // Intersect requested with allowed + grantedActions := intersect(requestedActions, allowedActions) + + if len(grantedActions) > 0 { + access = append(access, AccessEntry{ + Type: "repository", + Name: repoName, + Actions: grantedActions, + }) + } + } + } else { + // No specific scope requested - grant all permissions from original token + if len(claims.RepoAccess) > 0 { + for _, perm := range claims.RepoAccess { + actions := scopeToActions(perm.Scope) + access = append(access, AccessEntry{ + Type: "repository", + Name: perm.Repo, + Actions: actions, + }) + } + } else { + // Legacy format + for _, repo := range claims.Repositories { + actions := scopeToActions(claims.Scope) + access = append(access, AccessEntry{ + Type: "repository", + Name: repo, + Actions: actions, + }) + } + } + } + + // Generate a short-lived access token + expiresIn := 300 // 5 minutes + now := time.Now() + + accessClaims := &AccessTokenClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: h.service, + Subject: claims.BuildID, + Audience: jwt.ClaimStrings{h.service}, + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(expiresIn) * time.Second)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + Access: access, + } + + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + signedToken, err := accessToken.SignedString([]byte(h.jwtSecret)) + if err != nil { + h.errorResponse(w, "failed to generate token", http.StatusInternalServerError) + return + } + + // Return token response + response := TokenResponse{ + Token: signedToken, + AccessToken: signedToken, + ExpiresIn: expiresIn, + IssuedAt: now.Format(time.RFC3339), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// getAllowedActions returns the actions allowed for a repo based on the claims. +func (h *TokenHandler) getAllowedActions(claims *RegistryTokenClaims, repo string) []string { + // Check new format first + if len(claims.RepoAccess) > 0 { + for _, perm := range claims.RepoAccess { + if perm.Repo == repo || matchesWildcard(perm.Repo, repo) { + return scopeToActions(perm.Scope) + } + } + } + + // Fall back to legacy format + for _, allowedRepo := range claims.Repositories { + if allowedRepo == repo || matchesWildcard(allowedRepo, repo) { + return scopeToActions(claims.Scope) + } + } + + return nil +} + +// matchesWildcard checks if a pattern matches a repo name. +// Supports prefix matching with /* suffix. +func matchesWildcard(pattern, repo string) bool { + if strings.HasSuffix(pattern, "/*") { + prefix := strings.TrimSuffix(pattern, "/*") + return strings.HasPrefix(repo, prefix+"/") + } + return pattern == repo +} + +// scopeToActions converts a scope string to action list. +func scopeToActions(scope string) []string { + switch scope { + case "push": + return []string{"pull", "push"} + case "pull": + return []string{"pull"} + default: + return []string{"pull"} + } +} + +// intersect returns elements common to both slices. +func intersect(a, b []string) []string { + set := make(map[string]bool) + for _, v := range b { + set[v] = true + } + var result []string + for _, v := range a { + if set[v] { + result = append(result, v) + } + } + return result +} + +func (h *TokenHandler) errorResponse(w http.ResponseWriter, message string, status int) { + w.Header().Set("Content-Type", "application/json") + if status == http.StatusUnauthorized { + w.Header().Set("WWW-Authenticate", `Basic realm="registry token"`) + } + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} + +// returnAnonymousToken handles anonymous token requests. +// For builds/* paths, we grant access because: +// 1. Build IDs are cryptographically random and hard to guess +// 2. Builds are short-lived (tokens expire quickly) +// 3. BuildKit doesn't send Docker config credentials to the token endpoint +// +// This is a pragmatic solution to work around BuildKit's auth limitations. +// The build ID serves as an implicit authentication factor. +func (h *TokenHandler) returnAnonymousToken(w http.ResponseWriter, r *http.Request) { + requestedScope := r.URL.Query().Get("scope") + + // Parse scope: "repository:builds/abc123:pull,push" + var access []AccessEntry + if requestedScope != "" { + scopeParts := strings.SplitN(requestedScope, ":", 3) + if len(scopeParts) == 3 && scopeParts[0] == "repository" { + repoName := scopeParts[1] + actions := strings.Split(scopeParts[2], ",") + + // For builds/* paths, grant the requested access + // The build ID is random and serves as implicit auth + if strings.HasPrefix(repoName, "builds/") { + access = append(access, AccessEntry{ + Type: "repository", + Name: repoName, + Actions: actions, + }) + } + // For cache/* paths, also grant access (cache is scoped per-tenant) + if strings.HasPrefix(repoName, "cache/") { + access = append(access, AccessEntry{ + Type: "repository", + Name: repoName, + Actions: actions, + }) + } + } + } + + expiresIn := 300 // 5 minutes + now := time.Now() + + accessClaims := &AccessTokenClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: h.service, + Subject: "anonymous-builder", + Audience: jwt.ClaimStrings{h.service}, + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(expiresIn) * time.Second)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + Access: access, + } + + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + signedToken, err := accessToken.SignedString([]byte(h.jwtSecret)) + if err != nil { + h.errorResponse(w, "failed to generate token", http.StatusInternalServerError) + return + } + + response := TokenResponse{ + Token: signedToken, + AccessToken: signedToken, + ExpiresIn: expiresIn, + IssuedAt: now.Format(time.RFC3339), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +}