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 5c835a14..d69c52bb 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -291,7 +291,13 @@ 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)) + + // 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 843aca22..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,39 +533,189 @@ 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) + // 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{ + "/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 +} + +// 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) @@ -556,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) } @@ -572,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) } } @@ -602,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 cf5f8305..fe2dda05 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 @@ -115,7 +130,29 @@ 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 with Bearer token endpoint + // This tells Docker/BuildKit to request a token from our /v2/token endpoint + if statusCode == http.StatusUnauthorized { + 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) // Return a simple JSON error response matching our Error schema @@ -188,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 @@ -197,8 +236,10 @@ 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 + // 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.") } // extractRepoFromPath extracts the repository name from a registry path. @@ -229,6 +270,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 +288,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 +303,111 @@ 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 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 repoScope == "" { + return nil, fmt.Errorf("repository %s not allowed by token", repo) + } + + // Check scope for write operations + if isWriteOperation(method) && repoScope != "push" { + return nil, fmt.Errorf("token does not allow write operations for %s", repo) + } + + 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 - allowed := false - for _, allowedRepo := range claims.Repositories { - if allowedRepo == repo { - allowed = true + 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 !allowed { + + if !hasAccess { 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) && !canWrite { + return nil, fmt.Errorf("token does not allow write operations for %s", repo) } return claims, nil @@ -290,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 @@ -327,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 6fc12056..19392c83 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" @@ -25,7 +26,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 +42,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 +219,290 @@ 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_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) + + // 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) { + 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") + // WWW-Authenticate header is required for Docker/BuildKit to send credentials + // 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) { + 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") + }) + + 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") + 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) + 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:") +func basicAuth(token string) string { + return base64.StdEncoding.EncodeToString([]byte(token + ":")) +} + +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 (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}, + {"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) 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) +}