diff --git a/cmd/api/config/config.go b/cmd/api/config/config.go index b0101356..0fd01f16 100644 --- a/cmd/api/config/config.go +++ b/cmd/api/config/config.go @@ -112,6 +112,8 @@ type Config struct { MaxConcurrentSourceBuilds int // Max concurrent source-to-image builds BuilderImage string // OCI image for builder VMs RegistryURL string // URL of registry for built images + RegistryInsecure bool // Skip TLS verification for registry (for self-signed certs) + RegistryCACertFile string // Path to CA certificate file for registry TLS verification 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), + RegistryCACertFile: getEnv("REGISTRY_CA_CERT_FILE", ""), // Path to CA cert for registry TLS 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..fef9f322 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -28,6 +28,7 @@ import ( mw "github.com/kernel/hypeman/lib/middleware" "github.com/kernel/hypeman/lib/oapi" "github.com/kernel/hypeman/lib/otel" + "github.com/kernel/hypeman/lib/registry" "github.com/kernel/hypeman/lib/vmm" nethttpmiddleware "github.com/oapi-codegen/nethttp-middleware" "github.com/riandyrn/otelchi" @@ -285,6 +286,12 @@ func run() error { mw.ResolveResource(app.ApiService.NewResolvers(), api.ResolverErrorResponder), ).Get("/instances/{id}/cp", app.ApiService.CpHandler) + // Create builder VM resolver for secure token authentication + // This validates that token requests from builder VMs are for their authorized repos only + // Create token handler for Docker Registry Token Authentication + // All clients must provide explicit credentials (Basic or Bearer auth with JWT) + tokenHandler := registry.NewTokenHandler(app.Config.JwtSecret) + // OCI Distribution registry endpoints for image push (outside OpenAPI spec) r.Route("/v2", func(r chi.Router) { r.Use(middleware.RequestID) @@ -292,6 +299,11 @@ func run() error { r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(mw.JwtAuth(app.Config.JwtSecret)) + + // Token endpoint for Docker Registry Token Authentication + // This is called by clients (like BuildKit) after receiving a 401 with WWW-Authenticate + r.Get("/token", tokenHandler.ServeHTTP) + r.Mount("/", app.Registry.Handler()) }) diff --git a/lib/builds/builder_agent/main.go b/lib/builds/builder_agent/main.go index 843aca22..beb5b182 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"` + RegistryToken string `json:"registry_token,omitempty"` + RegistryInsecure bool `json:"registry_insecure,omitempty"` + RegistryCACert string `json:"registry_ca_cert,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 @@ -367,7 +369,7 @@ func runBuildProcess() { buildConfigLock.Unlock() // Setup registry authentication before running the build - if err := setupRegistryAuth(config.RegistryURL, config.RegistryToken); err != nil { + if err := setupRegistryAuth(config); err != nil { setResult(BuildResult{ Success: false, Error: fmt.Sprintf("setup registry auth: %v", err), @@ -489,9 +491,20 @@ func loadConfig() (*BuildConfig, error) { return &config, nil } -// setupRegistryAuth creates a Docker config.json with the registry token for authentication. -// BuildKit uses this file to authenticate when pushing images. -func setupRegistryAuth(registryURL, token string) error { +// setupRegistryAuth creates a Docker config.json with the registry token for authentication, +// and a buildkitd.toml for TLS configuration. +// BuildKit uses these files to authenticate and configure TLS when pushing images. +func setupRegistryAuth(config *BuildConfig) error { + // Parse registry host (strip any scheme prefix for backwards compatibility) + registryHost := config.RegistryURL + if strings.HasPrefix(registryHost, "https://") { + registryHost = strings.TrimPrefix(registryHost, "https://") + } else if strings.HasPrefix(registryHost, "http://") { + registryHost = strings.TrimPrefix(registryHost, "http://") + } + + token := config.RegistryToken + if token == "" { log.Println("No registry token provided, skipping auth setup") return nil @@ -503,12 +516,17 @@ func setupRegistryAuth(registryURL, token string) error { authValue := base64.StdEncoding.EncodeToString([]byte(token + ":")) // Create the Docker config structure + // Note: Docker config uses host without scheme (e.g., "10.102.0.1:8443") + // We use both auth (Basic) and identitytoken (JWT) to support different BuildKit versions dockerConfig := map[string]interface{}{ "auths": map[string]interface{}{ - registryURL: map[string]string{ - "auth": authValue, + registryHost: map[string]string{ + "auth": authValue, // Basic auth: base64(jwt:) + "identitytoken": token, // JWT directly for OAuth2-style auth }, }, + "credsStore": "", + "credHelpers": map[string]string{}, } configData, err := json.MarshalIndent(dockerConfig, "", " ") @@ -528,24 +546,157 @@ func setupRegistryAuth(registryURL, token string) error { return fmt.Errorf("write docker config: %w", err) } - log.Printf("Registry auth configured for %s", registryURL) + log.Printf("Docker config created for registry %s (auth length: %d)", registryHost, len(authValue)) + + // Also write to /root/.docker for rootless buildkit that may run as root + rootDockerDir := "/root/.docker" + if err := os.MkdirAll(rootDockerDir, 0700); err == nil { + rootConfigPath := filepath.Join(rootDockerDir, "config.json") + if err := os.WriteFile(rootConfigPath, configData, 0600); err != nil { + log.Printf("Warning: failed to write root docker config: %v", err) + } else { + log.Printf("Registry auth configured at %s", rootConfigPath) + } + } + + log.Printf("Registry auth configured at %s", configPath) + + // Setup buildkitd.toml for TLS configuration + if err := setupBuildkitdConfig(config); err != nil { + return fmt.Errorf("setup buildkitd config: %w", err) + } + + return nil +} + +// setupBuildkitdConfig creates a buildkitd.toml configuration file for registry TLS settings. +// This configures BuildKit's TLS verification behavior for the registry. +func setupBuildkitdConfig(config *BuildConfig) error { + // Parse registry host from URL (strip any scheme prefix for backwards compatibility) + registryHost := config.RegistryURL + if strings.HasPrefix(registryHost, "https://") { + registryHost = strings.TrimPrefix(registryHost, "https://") + } else if strings.HasPrefix(registryHost, "http://") { + registryHost = strings.TrimPrefix(registryHost, "http://") + } + + // Determine protocol: + // - RegistryInsecure=true means use HTTP (plaintext) + // - RegistryInsecure=false (default) means use HTTPS + isHTTPS := !config.RegistryInsecure + hasCA := config.RegistryCACert != "" + + log.Printf("BuildKit config for registry %s (https=%v, insecure=%v, hasCA=%v)", + registryHost, isHTTPS, config.RegistryInsecure, hasCA) + + // Write CA certificate if provided + caCertPath := "" + if hasCA { + caCertPath = "/home/builder/.config/buildkit/registry-ca.crt" + certDir := filepath.Dir(caCertPath) + if err := os.MkdirAll(certDir, 0755); err != nil { + return fmt.Errorf("create cert dir: %w", err) + } + if err := os.WriteFile(caCertPath, []byte(config.RegistryCACert), 0644); err != nil { + return fmt.Errorf("write CA cert: %w", err) + } + log.Printf("Registry CA certificate written to %s", caCertPath) + + // Also install CA cert system-wide so BuildKit's HTTP client trusts it + // (needed for the /v2/token endpoint which uses Go's default HTTP client) + systemCADir := "/usr/local/share/ca-certificates" + if err := os.MkdirAll(systemCADir, 0755); err != nil { + log.Printf("Warning: failed to create system CA dir: %v", err) + } else { + systemCAPath := filepath.Join(systemCADir, "hypeman-registry.crt") + if err := os.WriteFile(systemCAPath, []byte(config.RegistryCACert), 0644); err != nil { + log.Printf("Warning: failed to write system CA cert: %v", err) + } else { + // Run update-ca-certificates to add to system trust store + cmd := exec.Command("update-ca-certificates") + if output, err := cmd.CombinedOutput(); err != nil { + log.Printf("Warning: update-ca-certificates failed: %v: %s", err, output) + } else { + log.Printf("Installed CA cert system-wide") + } + } + } + } + + // Build the buildkitd.toml content + var tomlContent strings.Builder + tomlContent.WriteString("# BuildKit daemon configuration\n") + tomlContent.WriteString("# Generated by builder-agent for registry TLS\n\n") + + // Registry configuration section + tomlContent.WriteString(fmt.Sprintf("[registry.\"%s\"]\n", registryHost)) + + if !isHTTPS { + // HTTP registry - mark as insecure (plaintext) + tomlContent.WriteString(" http = true\n") + tomlContent.WriteString(" insecure = true\n") + } else if config.RegistryInsecure { + // HTTPS but skip TLS verification + tomlContent.WriteString(" insecure = true\n") + } else if hasCA { + // HTTPS with custom CA + tomlContent.WriteString(fmt.Sprintf(" ca = [\"%s\"]\n", caCertPath)) + } + // If HTTPS without insecure and without CA, use system CA (no config needed) + + // Ensure config directory exists + buildkitDir := "/home/builder/.config/buildkit" + if err := os.MkdirAll(buildkitDir, 0755); err != nil { + return fmt.Errorf("create buildkit config dir: %w", err) + } + + // Write buildkitd.toml + tomlPath := filepath.Join(buildkitDir, "buildkitd.toml") + if err := os.WriteFile(tomlPath, []byte(tomlContent.String()), 0644); err != nil { + return fmt.Errorf("write buildkitd.toml: %w", err) + } + + log.Printf("BuildKit config written to %s for registry %s (https=%v, insecure=%v, hasCA=%v)", + tomlPath, registryHost, isHTTPS, config.RegistryInsecure, hasCA) + 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) + // Parse registry host (strip any scheme prefix for backwards compatibility) + registryHost := config.RegistryURL + if strings.HasPrefix(registryHost, "https://") { + registryHost = strings.TrimPrefix(registryHost, "https://") + } else if strings.HasPrefix(registryHost, "http://") { + registryHost = strings.TrimPrefix(registryHost, "http://") + } + + // Build output reference (use host without scheme) + outputRef := fmt.Sprintf("%s/builds/%s", registryHost, config.JobID) + + // Determine protocol: + // - RegistryInsecure=true means use HTTP (plaintext), needs registry.insecure=true in buildctl + // - RegistryInsecure=false (default) means use HTTPS, TLS config comes from buildkitd.toml + useInsecureFlag := config.RegistryInsecure // Build arguments - // Use registry.insecure=true for internal HTTP registries + var outputOpts string + if useInsecureFlag { + outputOpts = fmt.Sprintf("type=image,name=%s,push=true,registry.insecure=true,oci-mediatypes=true", outputRef) + log.Printf("Using HTTP registry (insecure mode): %s", registryHost) + } else { + outputOpts = fmt.Sprintf("type=image,name=%s,push=true,oci-mediatypes=true", outputRef) + log.Printf("Using HTTPS registry (secure mode): %s", registryHost) + } + 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", outputOpts, "--metadata-file", "/tmp/build-metadata.json", } @@ -556,15 +707,23 @@ 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) + cacheOpts := "type=registry,ref=" + globalCacheRef + if useInsecureFlag { + cacheOpts += ",registry.insecure=true" + } + args = append(args, "--import-cache", cacheOpts) 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) + cacheOpts := "type=registry,ref=" + tenantCacheRef + if useInsecureFlag { + cacheOpts += ",registry.insecure=true" + } + args = append(args, "--import-cache", cacheOpts) log.Printf("Importing from tenant cache: %s", tenantCacheRef) } @@ -572,15 +731,23 @@ 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) + cacheOpts := "type=registry,ref=" + globalCacheRef + ",mode=max" + if useInsecureFlag { + cacheOpts += ",registry.insecure=true" + } + args = append(args, "--export-cache", cacheOpts) 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) + cacheOpts := "type=registry,ref=" + tenantCacheRef + ",mode=max" + if useInsecureFlag { + cacheOpts += ",registry.insecure=true" + } + args = append(args, "--export-cache", cacheOpts) log.Printf("Exporting to tenant cache: %s", tenantCacheRef) } } @@ -596,16 +763,31 @@ func runBuild(ctx context.Context, config *BuildConfig, logWriter io.Writer) (st args = append(args, "--opt", fmt.Sprintf("build-arg:%s=%s", k, v)) } + // Set buildkitd config path + buildkitdConfig := "/home/builder/.config/buildkit/buildkitd.toml" + log.Printf("Using buildkitd config: %s", buildkitdConfig) + log.Printf("Running: buildctl-daemonless.sh %s", strings.Join(args, " ")) // Run buildctl-daemonless.sh 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 - env := os.Environ() - env = append(env, "DOCKER_CONFIG=/home/builder/.docker") + // Set environment: + // - HOME and DOCKER_CONFIG: ensures buildctl finds the auth config at /root/.docker/config.json + // - BUILDKITD_FLAGS: tells buildkitd to use our custom config for registry TLS settings + // Filter out existing values to avoid duplicates (first value wins in shell) + env := make([]string, 0, len(os.Environ())+3) + for _, e := range os.Environ() { + if !strings.HasPrefix(e, "DOCKER_CONFIG=") && + !strings.HasPrefix(e, "BUILDKITD_FLAGS=") && + !strings.HasPrefix(e, "HOME=") { + env = append(env, e) + } + } + env = append(env, "HOME=/root") + env = append(env, "DOCKER_CONFIG=/root/.docker") + env = append(env, fmt.Sprintf("BUILDKITD_FLAGS=--config=%s", buildkitdConfig)) cmd.Env = env if err := cmd.Run(); err != nil { diff --git a/lib/builds/images/generic/Dockerfile b/lib/builds/images/generic/Dockerfile index 83a080f9..5420641b 100644 --- a/lib/builds/images/generic/Dockerfile +++ b/lib/builds/images/generic/Dockerfile @@ -2,7 +2,8 @@ # Contains rootless BuildKit + builder agent + guest-agent for debugging # Builds any Dockerfile provided by the user -FROM moby/buildkit:rootless AS buildkit +# Use non-rootless buildkit to avoid potential credential handling issues +FROM moby/buildkit:latest AS buildkit # Build the builder-agent and guest-agent (multi-stage build from hypeman repo) FROM golang:1.25-alpine AS agent-builder @@ -28,6 +29,13 @@ RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /guest-agent ./lib/system/guest_a # Final builder image - minimal alpine base FROM alpine:3.21 +# Install minimal dependencies +RUN apk add --no-cache \ + ca-certificates \ + git \ + curl \ + fuse-overlayfs + # Copy BuildKit binaries from official image COPY --from=buildkit /usr/bin/buildctl /usr/bin/buildctl COPY --from=buildkit /usr/bin/buildctl-daemonless.sh /usr/bin/buildctl-daemonless.sh @@ -38,13 +46,6 @@ COPY --from=buildkit /usr/bin/buildkit-runc /usr/bin/runc COPY --from=agent-builder /builder-agent /usr/bin/builder-agent COPY --from=agent-builder /guest-agent /usr/bin/guest-agent -# Install minimal dependencies -RUN apk add --no-cache \ - ca-certificates \ - git \ - curl \ - fuse-overlayfs - # Create unprivileged user for rootless BuildKit RUN adduser -D -u 1000 builder && \ mkdir -p /home/builder/.local/share/buildkit /config /run/secrets /src && \ @@ -61,6 +62,3 @@ ENV XDG_RUNTIME_DIR=/home/builder/.local/share # Run builder agent as entrypoint ENTRYPOINT ["/usr/bin/builder-agent"] - - - diff --git a/lib/builds/manager.go b/lib/builds/manager.go index 6d6e744a..3a612baa 100644 --- a/lib/builds/manager.go +++ b/lib/builds/manager.go @@ -64,6 +64,13 @@ type Config struct { // RegistryURL is the URL of the registry to push built images to RegistryURL string + // RegistryInsecure skips TLS verification for the registry (for self-signed certs) + RegistryInsecure bool + + // RegistryCACert is the PEM-encoded CA certificate for verifying the registry's TLS cert + // If set, this is passed to the builder VM to enable TLS verification + RegistryCACert string + // DefaultTimeout is the default build timeout in seconds DefaultTimeout int @@ -82,6 +89,18 @@ func DefaultConfig() Config { } } +// stripRegistryScheme removes http:// or https:// prefix from registry URL. +// This is needed because image references should not contain the scheme. +func stripRegistryScheme(registryURL string) string { + if strings.HasPrefix(registryURL, "https://") { + return strings.TrimPrefix(registryURL, "https://") + } + if strings.HasPrefix(registryURL, "http://") { + return strings.TrimPrefix(registryURL, "http://") + } + return registryURL +} + type manager struct { config Config paths *paths.Paths @@ -237,19 +256,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, + RegistryToken: registryToken, + RegistryInsecure: m.config.RegistryInsecure, + RegistryCACert: m.config.RegistryCACert, + 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) @@ -327,7 +348,8 @@ func (m *manager) runBuild(ctx context.Context, id string, req CreateBuildReques } m.logger.Info("build succeeded", "id", id, "digest", result.ImageDigest, "duration", duration) - imageRef := fmt.Sprintf("%s/builds/%s", m.config.RegistryURL, id) + registryHost := stripRegistryScheme(m.config.RegistryURL) + imageRef := fmt.Sprintf("%s/builds/%s", registryHost, id) // Wait for image to be ready before reporting build as complete. // This fixes the race condition (KERNEL-863) where build reports "ready" @@ -700,7 +722,8 @@ func (m *manager) updateBuildComplete(id string, status string, digest *string, // This ensures that when a build reports "ready", the image is actually usable // for instance creation (fixes KERNEL-863 race condition). func (m *manager) waitForImageReady(ctx context.Context, id string) error { - imageRef := fmt.Sprintf("%s/builds/%s", m.config.RegistryURL, id) + registryHost := stripRegistryScheme(m.config.RegistryURL) + imageRef := fmt.Sprintf("%s/builds/%s", registryHost, id) // Poll for up to 60 seconds (image conversion is typically fast) const maxAttempts = 120 diff --git a/lib/builds/types.go b/lib/builds/types.go index 49c4fd0f..33d9ace0 100644 --- a/lib/builds/types.go +++ b/lib/builds/types.go @@ -130,6 +130,12 @@ type BuildConfig struct { // The builder agent uses this token to authenticate with the registry. RegistryToken string `json:"registry_token,omitempty"` + // RegistryInsecure skips TLS verification for the registry (for self-signed certs) + RegistryInsecure bool `json:"registry_insecure,omitempty"` + + // RegistryCACert is the PEM-encoded CA certificate for verifying the registry's TLS cert + RegistryCACert string `json:"registry_ca_cert,omitempty"` + // CacheScope is the tenant-specific cache key prefix CacheScope string `json:"cache_scope,omitempty"` diff --git a/lib/middleware/oapi_auth.go b/lib/middleware/oapi_auth.go index cf5f8305..6f8a8254 100644 --- a/lib/middleware/oapi_auth.go +++ b/lib/middleware/oapi_auth.go @@ -24,11 +24,22 @@ var registryRouter = v2.Router() // RegistryTokenClaims contains the claims for a scoped registry access token. // This mirrors the type in lib/builds/registry_token.go to avoid circular imports. +// RepoPermission defines access permissions for a specific repository +type RepoPermission struct { + Repo string `json:"repo"` + Scope string `json:"scope"` +} + type RegistryTokenClaims struct { jwt.RegisteredClaims - BuildID string `json:"build_id"` - Repositories []string `json:"repos"` - Scope string `json:"scope"` + BuildID string `json:"build_id"` + + // RepoAccess is the new format - array of repo permissions + RepoAccess []RepoPermission `json:"repo_access,omitempty"` + + // Legacy format fields (kept for backward compat) + Repositories []string `json:"repos,omitempty"` + Scope string `json:"scope,omitempty"` } // OapiAuthenticationFunc creates an AuthenticationFunc compatible with nethttp-middleware @@ -159,12 +170,21 @@ func extractTokenFromAuth(authHeader string) (string, string, error) { return "", "", fmt.Errorf("invalid basic auth encoding: %w", err) } // Split on colon to get username:password + // JWT can be in username (our auth field format) OR password (identitytoken format) + // BuildKit sends identitytoken as password with empty username credentials := strings.SplitN(string(decoded), ":", 2) if len(credentials) == 0 { return "", "", fmt.Errorf("invalid basic auth format") } - // The JWT is the username part - return credentials[0], "basic", nil + // Try username first (our auth field format: "jwt:") + if credentials[0] != "" { + return credentials[0], "basic", nil + } + // Fall back to password (identitytoken format: ":jwt") + if len(credentials) > 1 && credentials[1] != "" { + return credentials[1], "basic", nil + } + return "", "", fmt.Errorf("empty credentials") default: return "", "", fmt.Errorf("unsupported authorization scheme: %s", scheme) } @@ -183,24 +203,12 @@ func isRegistryPath(path string) bool { return strings.HasPrefix(path, "/v2/") } -// isInternalVMRequest checks if the request is from an internal VM network -// This is used as a fallback for builder VMs that don't have token auth yet. -// -// SECURITY: We only trust RemoteAddr, not X-Real-IP or X-Forwarded-For headers, -// as those can be spoofed by attackers to bypass authentication. -func isInternalVMRequest(r *http.Request) bool { - // Use only RemoteAddr - never trust client-supplied headers for auth decisions - ip := r.RemoteAddr - - // RemoteAddr is "IP:port" format, extract just the IP - if idx := strings.LastIndex(ip, ":"); idx != -1 { - 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.") +// isTokenEndpoint checks if the request is for the /v2/token endpoint +func isTokenEndpoint(path string) bool { + return path == "/v2/token" || path == "/v2/token/" } + // extractRepoFromPath extracts the repository name from a registry path. // e.g., "/v2/builds/abc123/manifests/latest" -> "builds/abc123" // extractRepoFromPath extracts the repository name from a registry path. @@ -227,6 +235,32 @@ func isWriteOperation(method string) bool { return method == http.MethodPut || method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete } +// writeRegistryUnauthorized writes a 401 response with proper WWW-Authenticate header. +// We use Bearer token flow because: +// 1. BuildKit expects to receive a Bearer challenge with a token endpoint URL +// 2. BuildKit will call /v2/token with Basic auth (JWT from docker config.json as username) +// 3. Our token handler validates the JWT and returns it as a Bearer token +// 4. BuildKit then retries the original request with the Bearer token +func writeRegistryUnauthorized(w http.ResponseWriter, r *http.Request) { + // Build the token endpoint URL from the request + // Detect scheme from the incoming request to support both HTTP and HTTPS registries + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + host := r.Host + tokenURL := fmt.Sprintf("%s://%s/v2/token", scheme, host) + + // Use Bearer challenge pointing to our token endpoint + challenge := fmt.Sprintf(`Bearer realm="%s",service="hypeman"`, tokenURL) + w.Header().Set("WWW-Authenticate", challenge) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + + // Return error in OCI Distribution format + fmt.Fprintf(w, `{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}`) +} + // validateRegistryToken validates a registry-scoped JWT token and checks repository access. // Returns the claims if valid, nil otherwise. func validateRegistryToken(tokenString, jwtSecret, requestPath, method string) (*RegistryTokenClaims, error) { @@ -246,8 +280,10 @@ 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 repo_access or repos claim) + hasRepoAccess := len(claims.RepoAccess) > 0 + hasLegacyRepos := len(claims.Repositories) > 0 + if !hasRepoAccess && !hasLegacyRepos { return nil, fmt.Errorf("not a registry token") } @@ -263,18 +299,34 @@ func validateRegistryToken(tokenString, jwtSecret, requestPath, method string) ( // Check if the repository is allowed by the token allowed := false - for _, allowedRepo := range claims.Repositories { - if allowedRepo == repo { - allowed = true - break + scope := "" + + // Check new format (repo_access) first + if hasRepoAccess { + for _, perm := range claims.RepoAccess { + if perm.Repo == repo { + allowed = true + scope = perm.Scope + break + } + } + } else { + // Fall back to legacy format + for _, allowedRepo := range claims.Repositories { + if allowedRepo == repo { + allowed = true + scope = claims.Scope + break + } } } + if !allowed { return nil, fmt.Errorf("repository %s not allowed by token", repo) } // Check scope for write operations - if isWriteOperation(method) && claims.Scope != "push" { + if isWriteOperation(method) && scope != "push" { return nil, fmt.Errorf("token does not allow write operations") } @@ -292,11 +344,25 @@ func JwtAuth(jwtSecret string) func(http.Handler) http.Handler { // For registry paths, handle specially to support both Bearer and Basic auth if isRegistryPath(r.URL.Path) { - 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) + // Allow /v2/token endpoint through without auth - it handles its own auth + // This implements the Docker Registry Token Authentication flow + if isTokenEndpoint(r.URL.Path) { + log.DebugContext(r.Context(), "allowing token endpoint request through", + "remote_addr", r.RemoteAddr) + next.ServeHTTP(w, r) + return + } + + if authHeader != "" { + // Try to extract token (supports both Bearer and Basic auth) + log.InfoContext(r.Context(), "registry request with auth header", + "path", r.URL.Path, + "method", r.Method, + "auth_type", strings.Split(authHeader, " ")[0], + "remote_addr", r.RemoteAddr) + 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 registryClaims, err := validateRegistryToken(token, jwtSecret, r.URL.Path, r.Method) @@ -316,20 +382,15 @@ func JwtAuth(jwtSecret string) func(http.Handler) http.Handler { } } - // Fallback: Allow internal VM network (10.102.x.x) for registry pushes - // This is a transitional fallback for older builder images without token auth - if isInternalVMRequest(r) { - log.DebugContext(r.Context(), "allowing internal VM request via IP fallback (deprecated)", - "remote_addr", r.RemoteAddr, - "path", r.URL.Path) - ctx := context.WithValue(r.Context(), userIDKey, "internal-builder-legacy") - next.ServeHTTP(w, r.WithContext(ctx)) - return + // Registry auth failed - return 401 with WWW-Authenticate header + // This tells clients (like BuildKit) where to get a token + if authHeader == "" { + log.InfoContext(r.Context(), "registry request WITHOUT auth header", + "path", r.URL.Path, + "method", r.Method, + "remote_addr", r.RemoteAddr) } - - // Registry auth failed - log.DebugContext(r.Context(), "registry request unauthorized", "remote_addr", r.RemoteAddr) - OapiErrorHandler(w, "registry authentication required", http.StatusUnauthorized) + writeRegistryUnauthorized(w, r) return } diff --git a/lib/middleware/oapi_auth_test.go b/lib/middleware/oapi_auth_test.go index 6fc12056..ad1c241d 100644 --- a/lib/middleware/oapi_auth_test.go +++ b/lib/middleware/oapi_auth_test.go @@ -203,6 +203,72 @@ func TestExtractRepoFromPath(t *testing.T) { } } +func TestJwtAuth_TokenEndpointBypass(t *testing.T) { + // Create a handler that returns 200 if the request gets through + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("token endpoint reached")) + }) + + handler := JwtAuth(testJWTSecret)(nextHandler) + + t.Run("token endpoint passes through without auth", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:builds/abc:push", nil) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "token endpoint reached") + }) + + t.Run("token endpoint with trailing slash passes through", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v2/token/", nil) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + }) +} + +func TestJwtAuth_RegistryUnauthorizedResponse(t *testing.T) { + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + handler := JwtAuth(testJWTSecret)(nextHandler) + + t.Run("registry 401 includes WWW-Authenticate header", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v2/builds/abc123/manifests/latest", nil) + req.Host = "localhost:8080" + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + + // Check WWW-Authenticate header - we use Bearer token flow so BuildKit + // calls /v2/token with credentials from docker config.json to get a token + // The scheme matches the incoming request (HTTP in tests, so http://) + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, "Bearer") + assert.Contains(t, wwwAuth, `realm="http://localhost:8080/v2/token"`) + assert.Contains(t, wwwAuth, `service="hypeman"`) + }) + + t.Run("registry 401 response is OCI Distribution format", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v2/builds/abc123/blobs/sha256:abc", nil) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + assert.Contains(t, rr.Body.String(), `"code":"UNAUTHORIZED"`) + assert.Contains(t, rr.Body.String(), `"message":"authentication required"`) + }) +} + 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..2c88ea00 100644 --- a/lib/providers/providers.go +++ b/lib/providers/providers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "os" "strconv" "time" @@ -249,10 +250,23 @@ 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) { + // Read CA cert file if specified + var registryCACert string + if cfg.RegistryCACertFile != "" { + certData, err := os.ReadFile(cfg.RegistryCACertFile) + if err != nil { + return nil, fmt.Errorf("read registry CA cert file: %w", err) + } + registryCACert = string(certData) + log.Info("registry CA certificate loaded", "file", 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/README.md b/lib/registry/README.md index 168412e3..17ad294c 100644 --- a/lib/registry/README.md +++ b/lib/registry/README.md @@ -146,13 +146,66 @@ hypeman push myimage:latest my-custom-name ## Authentication -The registry endpoints use JWT bearer token authentication. The hypeman CLI reads `HYPEMAN_API_KEY` or `HYPEMAN_BEARER_TOKEN` and passes it directly as a registry token using go-containerregistry's `RegistryToken` auth. +The registry implements [Docker Registry Token Authentication](https://distribution.github.io/distribution/spec/auth/token/): -**Note:** `docker push` will not work with this registry. Docker CLI expects the v2 registry token auth flow (WWW-Authenticate challenge → token endpoint → retry with JWT), which we don't implement. Use the hypeman CLI for pushing images. +```mermaid +sequenceDiagram + participant Client as BuildKit/Docker + participant Registry as Hypeman Registry + participant Token as /v2/token + + Client->>Registry: GET /v2/builds/xxx/manifests/latest + Registry-->>Client: 401 WWW-Authenticate: Bearer realm="/v2/token" + + Client->>Token: GET /v2/token?scope=repository:builds/xxx:push (Basic auth) + Token->>Token: Validate JWT, check scope + Token-->>Client: {"token": "bearer-token"} + + Client->>Registry: GET /v2/builds/xxx/manifests/latest (Bearer token) + Registry-->>Client: 200 OK +``` + +### Authentication Methods + +1. **Bearer Token**: Pass JWT directly in `Authorization: Bearer ` header +2. **Basic Auth**: Pass JWT as username or password in `Authorization: Basic base64(jwt:)` or `base64(:jwt)` header (BuildKit uses identitytoken format) + +### Token Endpoint (`/v2/token`) + +The token endpoint handles the OAuth2-style token exchange: + +- **With credentials**: Validates the JWT and returns a bearer token if the requested scope is allowed +- **Without credentials**: Returns 401 with `WWW-Authenticate: Basic` challenge + +### Registry Tokens + +Builder VMs receive scoped JWT tokens with: + +```json +{ + "sub": "builder-build-123", + "build_id": "build-123", + "repos": ["builds/build-123", "cache/tenant-x"], + "scope": "push" +} +``` + +Or with per-repo permissions: + +```json +{ + "sub": "builder-build-123", + "build_id": "build-123", + "repo_access": [ + {"repo": "builds/build-123", "scope": "push"}, + {"repo": "cache/global/node", "scope": "pull"} + ] +} +``` ## Limitations -- **No docker push support**: Docker CLI requires the v2 registry token auth flow. Use `hypeman push` instead. +- **BuildKit credential format**: BuildKit sends the `identitytoken` from `config.json` as the password in Basic auth (empty username). The token endpoint handles both formats: JWT as username (`jwt:`) and JWT as password (`:jwt`). ## Design Decisions diff --git a/lib/registry/auth_integration_test.go b/lib/registry/auth_integration_test.go new file mode 100644 index 00000000..62d6eabb --- /dev/null +++ b/lib/registry/auth_integration_test.go @@ -0,0 +1,193 @@ +package registry + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBuildKitAuthFlow simulates BuildKit's authentication flow with the registry. +// This test reproduces the issue where BuildKit makes anonymous token requests +// and expects a proper WWW-Authenticate challenge to retry with credentials. +func TestBuildKitAuthFlow(t *testing.T) { + jwtSecret := "test-secret-key" + tokenHandler := NewTokenHandler(jwtSecret) + + // Create a router that mimics the hypeman /v2 endpoint structure + r := chi.NewRouter() + r.Route("/v2", func(r chi.Router) { + r.Get("/token", tokenHandler.ServeHTTP) + // Mock registry endpoint that returns 401 with Bearer challenge + r.Get("/*", func(w http.ResponseWriter, r *http.Request) { + // Simulate registry returning 401 with WWW-Authenticate: Bearer + w.Header().Set("WWW-Authenticate", `Bearer realm="http://localhost/v2/token",service="hypeman"`) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}`)) + }) + }) + + server := httptest.NewServer(r) + defer server.Close() + + // Generate a valid registry token (like what builder VM would have) + registryToken := generateTestToken(t, jwtSecret, "build-123", []string{"builds/build-123", "cache/org-test"}, "push") + + t.Run("anonymous token request for cache import returns auth challenge", func(t *testing.T) { + // This simulates BuildKit's first request to token endpoint (anonymous) + // when trying to import cache + resp, err := http.Get(server.URL + "/v2/token?scope=repository:cache/org-test:pull&service=hypeman") + require.NoError(t, err) + defer resp.Body.Close() + + // Should get 401 with WWW-Authenticate header + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + wwwAuth := resp.Header.Get("WWW-Authenticate") + assert.NotEmpty(t, wwwAuth, "WWW-Authenticate header must be present") + assert.Contains(t, wwwAuth, "Basic", "should challenge with Basic auth") + assert.Contains(t, wwwAuth, "realm=", "should include realm") + }) + + t.Run("authenticated token request for cache import succeeds", func(t *testing.T) { + // This simulates BuildKit retrying with credentials + req, err := http.NewRequest(http.MethodGet, server.URL+"/v2/token?scope=repository:cache/org-test:pull&service=hypeman", nil) + require.NoError(t, err) + + // Add Basic auth with JWT as username (how BuildKit sends credentials) + basicAuth := base64.StdEncoding.EncodeToString([]byte(registryToken + ":")) + req.Header.Set("Authorization", "Basic "+basicAuth) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var tokenResp TokenResponse + err = json.NewDecoder(resp.Body).Decode(&tokenResp) + require.NoError(t, err) + assert.NotEmpty(t, tokenResp.Token) + }) + + t.Run("anonymous token request for image push returns auth challenge", func(t *testing.T) { + // This simulates BuildKit's first request when pushing an image + resp, err := http.Get(server.URL + "/v2/token?scope=repository:builds/build-123:push,pull&service=hypeman") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + wwwAuth := resp.Header.Get("WWW-Authenticate") + assert.NotEmpty(t, wwwAuth, "WWW-Authenticate header must be present") + assert.Contains(t, wwwAuth, "Basic") + }) + + t.Run("authenticated token request for image push succeeds", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, server.URL+"/v2/token?scope=repository:builds/build-123:push,pull&service=hypeman", nil) + require.NoError(t, err) + + basicAuth := base64.StdEncoding.EncodeToString([]byte(registryToken + ":")) + req.Header.Set("Authorization", "Basic "+basicAuth) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("authenticated request for unauthorized repo returns 403", func(t *testing.T) { + // Token only allows access to builds/build-123 and cache/org-test + // Request for a different repo should fail with 403 + req, err := http.NewRequest(http.MethodGet, server.URL+"/v2/token?scope=repository:builds/other-build:push&service=hypeman", nil) + require.NoError(t, err) + + basicAuth := base64.StdEncoding.EncodeToString([]byte(registryToken + ":")) + req.Header.Set("Authorization", "Basic "+basicAuth) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + }) +} + +// TestDockerConfigCredentialLookup tests that credentials stored in docker config.json +// format work correctly with the token endpoint. +func TestDockerConfigCredentialLookup(t *testing.T) { + jwtSecret := "test-secret" + tokenHandler := NewTokenHandler(jwtSecret) + + // Simulate docker config.json auth format: base64(username:password) + // For our use case: base64(jwt_token:) + registryToken := generateTestToken(t, jwtSecret, "build-456", []string{"builds/build-456", "cache/tenant-x"}, "push") + + // This is exactly how docker config.json stores the auth value + dockerConfigAuth := base64.StdEncoding.EncodeToString([]byte(registryToken + ":")) + + tests := []struct { + name string + scope string + expectedStatus int + }{ + { + name: "cache pull with valid credentials", + scope: "repository:cache/tenant-x:pull", + expectedStatus: http.StatusOK, + }, + { + name: "cache push with valid credentials", + scope: "repository:cache/tenant-x:push", + expectedStatus: http.StatusOK, + }, + { + name: "image push with valid credentials", + scope: "repository:builds/build-456:push,pull", + expectedStatus: http.StatusOK, + }, + { + name: "unauthorized repo", + scope: "repository:builds/other:push", + expectedStatus: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v2/token?scope="+tt.scope+"&service=hypeman", nil) + req.Header.Set("Authorization", "Basic "+dockerConfigAuth) + + rr := httptest.NewRecorder() + tokenHandler.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + }) + } +} + +// generateTestToken creates a JWT token for testing +func generateTestToken(t *testing.T, secret, buildID string, repos []string, scope string) string { + t.Helper() + 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, + "repos": repos, + "scope": scope, + }) + tokenString, err := token.SignedString([]byte(secret)) + require.NoError(t, err) + return tokenString +} diff --git a/lib/registry/token.go b/lib/registry/token.go new file mode 100644 index 00000000..029aedf1 --- /dev/null +++ b/lib/registry/token.go @@ -0,0 +1,273 @@ +// Package registry implements token authentication for OCI Distribution registries. +package registry + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/kernel/hypeman/lib/logger" +) + +// TokenResponse is the response from the /v2/token endpoint per Docker Registry Token spec. +// See: https://distribution.github.io/distribution/spec/auth/token/ +type TokenResponse struct { + // Token is the bearer token to use for registry requests + Token string `json:"token"` + + // AccessToken is an alias for Token (some clients expect this) + AccessToken string `json:"access_token,omitempty"` + + // ExpiresIn is the lifetime of the token in seconds + ExpiresIn int `json:"expires_in,omitempty"` + + // IssuedAt is the time the token was issued (RFC3339) + IssuedAt string `json:"issued_at,omitempty"` +} + +// TokenError is returned when token authentication fails. +type TokenError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// TokenErrorResponse wraps token errors. +type TokenErrorResponse struct { + Errors []TokenError `json:"errors"` +} + +// TokenHandler handles /v2/token requests implementing Docker Registry Token Authentication. +// This endpoint is called by Docker/BuildKit clients after receiving a 401 with WWW-Authenticate. +type TokenHandler struct { + jwtSecret string +} + +// NewTokenHandler creates a new token endpoint handler. +// All clients must provide explicit credentials (Basic or Bearer auth with JWT). +func NewTokenHandler(jwtSecret string) *TokenHandler { + return &TokenHandler{ + jwtSecret: jwtSecret, + } +} + +// ServeHTTP handles GET /v2/token requests. +// Query parameters: +// - scope: repository:name:actions (e.g., "repository:builds/abc123:push,pull") +// - service: the registry service name (optional) +// +// Authentication: +// - Basic auth: JWT as username (legacy) or password (identitytoken format) +// - Bearer auth: the JWT token directly +func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + log := logger.FromContext(r.Context()) + + // Parse scope parameter + scope := r.URL.Query().Get("scope") + + // Try to authenticate + token, authMethod := h.extractToken(r) + + if token != "" { + // Validate the JWT + claims, err := h.validateJWT(token) + if err != nil { + log.DebugContext(r.Context(), "token validation failed", "error", err, "auth_method", authMethod) + h.writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid credentials") + return + } + + // Check if requested scope is allowed by the token + if scope != "" { + repo, actions := parseScope(scope) + if repo != "" && !h.isScopeAllowed(claims, repo, actions) { + log.DebugContext(r.Context(), "scope not allowed by token", + "requested_repo", repo, + "requested_actions", actions, + "allowed_repos", claims["repos"]) + h.writeError(w, http.StatusForbidden, "DENIED", "requested scope not allowed") + return + } + } + + // Return the same token as a bearer token + // This is valid because our tokens are already bearer tokens + log.DebugContext(r.Context(), "token authenticated successfully", + "auth_method", authMethod, + "subject", claims["sub"], + "scope", scope) + h.writeToken(w, token) + return + } + + // IP-based fallback authentication has been removed. + // All clients must provide explicit credentials (Basic or Bearer auth with JWT). + // Builder VMs receive their registry token via the build config and should + // pass it via Basic auth (token as username, empty password). + + // No valid authentication + log.DebugContext(r.Context(), "token request without valid auth", + "remote_addr", r.RemoteAddr, + "has_auth_header", r.Header.Get("Authorization") != "") + h.writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required") +} + +// extractToken attempts to extract a JWT from the request. +// Supports both Basic auth (JWT as username) and Bearer auth. +func (h *TokenHandler) extractToken(r *http.Request) (string, string) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return "", "" + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 { + return "", "" + } + + scheme := strings.ToLower(parts[0]) + switch scheme { + case "bearer": + return parts[1], "bearer" + case "basic": + decoded, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return "", "" + } + // Format is username:password + // JWT can be in username (our auth field format) OR password (identitytoken format) + // BuildKit sends identitytoken as password with empty username + credentials := strings.SplitN(string(decoded), ":", 2) + if len(credentials) == 0 { + return "", "" + } + // Try username first (our auth field format: "jwt:") + if credentials[0] != "" { + return credentials[0], "basic" + } + // Fall back to password (identitytoken format: ":jwt") + if len(credentials) > 1 && credentials[1] != "" { + return credentials[1], "basic" + } + return "", "" + } + + return "", "" +} + +// validateJWT parses and validates a JWT token. +func (h *TokenHandler) validateJWT(tokenString string) (jwt.MapClaims, error) { + claims := jwt.MapClaims{} + token, err := jwt.ParseWithClaims(tokenString, 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 { + return nil, fmt.Errorf("parse token: %w", err) + } + + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + return claims, nil +} + +// isScopeAllowed checks if the requested scope is allowed by the token claims. +func (h *TokenHandler) isScopeAllowed(claims jwt.MapClaims, repo string, actions []string) bool { + // Check repo_access (new format) first + if repoAccess, ok := claims["repo_access"].([]interface{}); ok { + for _, ra := range repoAccess { + if perm, ok := ra.(map[string]interface{}); ok { + if perm["repo"] == repo { + permScope, _ := perm["scope"].(string) + return h.scopeAllowsActions(permScope, actions) + } + } + } + return false + } + + // Fall back to legacy repos/scope format + if repos, ok := claims["repos"].([]interface{}); ok { + for _, r := range repos { + if r == repo { + scope, _ := claims["scope"].(string) + return h.scopeAllowsActions(scope, actions) + } + } + } + + return false +} + +// scopeAllowsActions checks if a scope (push/pull) allows the requested actions. +func (h *TokenHandler) scopeAllowsActions(scope string, actions []string) bool { + for _, action := range actions { + switch action { + case "push": + if scope != "push" { + return false + } + case "pull": + if scope != "push" && scope != "pull" { + return false + } + } + } + return true +} + +// parseScope parses a Docker registry scope string. +// Format: "repository:name:actions" where actions is comma-separated. +// Example: "repository:builds/abc123:push,pull" +func parseScope(scope string) (repo string, actions []string) { + parts := strings.SplitN(scope, ":", 3) + if len(parts) < 3 || parts[0] != "repository" { + return "", nil + } + repo = parts[1] + actions = strings.Split(parts[2], ",") + return repo, actions +} + +// writeToken writes a successful token response. +func (h *TokenHandler) writeToken(w http.ResponseWriter, token string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + resp := TokenResponse{ + Token: token, + AccessToken: token, + ExpiresIn: 300, // 5 minutes + IssuedAt: time.Now().UTC().Format(time.RFC3339), + } + + json.NewEncoder(w).Encode(resp) +} + +// writeError writes an error response. +// For 401 responses, includes WWW-Authenticate header to tell clients how to authenticate. +func (h *TokenHandler) writeError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + + // For 401 Unauthorized, include WWW-Authenticate header + // This tells clients (like BuildKit) to retry with Basic auth credentials + if status == http.StatusUnauthorized { + w.Header().Set("WWW-Authenticate", `Basic realm="hypeman"`) + } + + w.WriteHeader(status) + + resp := TokenErrorResponse{ + Errors: []TokenError{{Code: code, Message: message}}, + } + + json.NewEncoder(w).Encode(resp) +} diff --git a/lib/registry/token_test.go b/lib/registry/token_test.go new file mode 100644 index 00000000..311f410b --- /dev/null +++ b/lib/registry/token_test.go @@ -0,0 +1,226 @@ +package registry + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testJWTSecret = "test-secret-key" + +// generateRegistryToken creates a registry token with the given repos and scope +func generateRegistryToken(t *testing.T, buildID string, repos []string, scope string, ttl time.Duration) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "builder-" + buildID, + "iat": time.Now().Unix(), + "exp": time.Now().Add(ttl).Unix(), + "iss": "hypeman", + "build_id": buildID, + "repos": repos, + "scope": scope, + }) + tokenString, err := token.SignedString([]byte(testJWTSecret)) + require.NoError(t, err) + return tokenString +} + +func TestTokenHandler_BasicAuth(t *testing.T) { + handler := NewTokenHandler(testJWTSecret) + + t.Run("valid basic auth returns token", func(t *testing.T) { + registryToken := generateRegistryToken(t, "build-123", []string{"builds/build-123"}, "push", time.Hour) + basicAuth := base64.StdEncoding.EncodeToString([]byte(registryToken + ":")) + + req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:builds/build-123:push", nil) + req.Header.Set("Authorization", "Basic "+basicAuth) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var resp TokenResponse + err := json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.NotEmpty(t, resp.Token) + assert.NotEmpty(t, resp.AccessToken) + }) + + t.Run("invalid basic auth returns 401", func(t *testing.T) { + basicAuth := base64.StdEncoding.EncodeToString([]byte("invalid-token:")) + + req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:builds/build-123:push", nil) + req.Header.Set("Authorization", "Basic "+basicAuth) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + }) +} + +func TestTokenHandler_BearerAuth(t *testing.T) { + handler := NewTokenHandler(testJWTSecret) + + t.Run("valid bearer auth returns token", func(t *testing.T) { + registryToken := generateRegistryToken(t, "build-456", []string{"builds/build-456"}, "push", time.Hour) + + req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:builds/build-456:push", nil) + req.Header.Set("Authorization", "Bearer "+registryToken) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var resp TokenResponse + err := json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.NotEmpty(t, resp.Token) + }) +} + +func TestTokenHandler_ScopeValidation(t *testing.T) { + handler := NewTokenHandler(testJWTSecret) + + t.Run("scope not in token is rejected", func(t *testing.T) { + // Token allows builds/build-123, but request is for builds/other + registryToken := generateRegistryToken(t, "build-123", []string{"builds/build-123"}, "push", time.Hour) + basicAuth := base64.StdEncoding.EncodeToString([]byte(registryToken + ":")) + + req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:builds/other:push", nil) + req.Header.Set("Authorization", "Basic "+basicAuth) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("push action with pull-only token is rejected", func(t *testing.T) { + // Token only has pull scope + registryToken := generateRegistryToken(t, "build-123", []string{"builds/build-123"}, "pull", time.Hour) + basicAuth := base64.StdEncoding.EncodeToString([]byte(registryToken + ":")) + + req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:builds/build-123:push", nil) + req.Header.Set("Authorization", "Basic "+basicAuth) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("pull action with push token is allowed", func(t *testing.T) { + // Push scope includes pull + registryToken := generateRegistryToken(t, "build-123", []string{"builds/build-123"}, "push", time.Hour) + basicAuth := base64.StdEncoding.EncodeToString([]byte(registryToken + ":")) + + req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:builds/build-123:pull", nil) + req.Header.Set("Authorization", "Basic "+basicAuth) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + }) +} + +func TestTokenHandler_NoAuth(t *testing.T) { + handler := NewTokenHandler(testJWTSecret) + + t.Run("no auth returns 401", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:builds/build-123:push", nil) + req.RemoteAddr = "10.102.0.5:12345" + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + }) + + t.Run("no auth returns WWW-Authenticate header for Basic auth challenge", func(t *testing.T) { + // This test verifies that anonymous token requests get a proper auth challenge. + // BuildKit needs this header to know it should retry with credentials. + req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:cache/org-123:pull", nil) + req.RemoteAddr = "172.30.0.5:12345" + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + + // Verify WWW-Authenticate header is present and correct + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.NotEmpty(t, wwwAuth, "WWW-Authenticate header should be present on 401") + assert.Contains(t, wwwAuth, "Basic", "should challenge with Basic auth") + assert.Contains(t, wwwAuth, `realm="hypeman"`, "should include realm") + }) +} + +func TestTokenHandler_ExpiredToken(t *testing.T) { + handler := NewTokenHandler(testJWTSecret) + + t.Run("expired token returns 401", func(t *testing.T) { + expiredToken := generateRegistryToken(t, "build-123", []string{"builds/build-123"}, "push", -time.Hour) + basicAuth := base64.StdEncoding.EncodeToString([]byte(expiredToken + ":")) + + req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:builds/build-123:push", nil) + req.Header.Set("Authorization", "Basic "+basicAuth) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + }) +} + +func TestParseScope(t *testing.T) { + tests := []struct { + scope string + expectedRepo string + expectedActions []string + }{ + { + scope: "repository:builds/abc123:push", + expectedRepo: "builds/abc123", + expectedActions: []string{"push"}, + }, + { + scope: "repository:builds/abc123:push,pull", + expectedRepo: "builds/abc123", + expectedActions: []string{"push", "pull"}, + }, + { + scope: "repository:myimage:pull", + expectedRepo: "myimage", + expectedActions: []string{"pull"}, + }, + { + scope: "invalid", + expectedRepo: "", + expectedActions: nil, + }, + { + scope: "", + expectedRepo: "", + expectedActions: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.scope, func(t *testing.T) { + repo, actions := parseScope(tt.scope) + assert.Equal(t, tt.expectedRepo, repo) + assert.Equal(t, tt.expectedActions, actions) + }) + } +}