|
| 1 | +/* |
| 2 | +Copyright The ORAS Authors. |
| 3 | +Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +you may not use this file except in compliance with the License. |
| 5 | +You may obtain a copy of the License at |
| 6 | +
|
| 7 | +http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +
|
| 9 | +Unless required by applicable law or agreed to in writing, software |
| 10 | +distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +See the License for the specific language governing permissions and |
| 13 | +limitations under the License. |
| 14 | +*/ |
| 15 | + |
| 16 | +package remote |
| 17 | + |
| 18 | +import ( |
| 19 | + "context" |
| 20 | + "crypto/tls" |
| 21 | + "crypto/x509" |
| 22 | + "fmt" |
| 23 | + "log/slog" |
| 24 | + "net/http" |
| 25 | + "os" |
| 26 | + "strings" |
| 27 | + |
| 28 | + "github.com/oras-project/oras-go/v3/registry" |
| 29 | + "github.com/oras-project/oras-go/v3/registry/remote/auth" |
| 30 | + "github.com/oras-project/oras-go/v3/registry/remote/credentials" |
| 31 | + "github.com/oras-project/oras-go/v3/registry/remote/policy" |
| 32 | + "github.com/oras-project/oras-go/v3/registry/remote/properties" |
| 33 | + "github.com/oras-project/oras-go/v3/registry/remote/retry" |
| 34 | +) |
| 35 | + |
| 36 | +// ClientBuilder creates auth.Client instances from registry properties. |
| 37 | +// It handles TLS configuration, retry policies, caching, and credential |
| 38 | +// resolution for connecting to container registries. |
| 39 | +type ClientBuilder struct { |
| 40 | + // BaseTransport is the underlying HTTP transport. |
| 41 | + // If nil, http.DefaultTransport is used. |
| 42 | + BaseTransport http.RoundTripper |
| 43 | + |
| 44 | + // RetryPolicy returns a retry policy for HTTP requests. |
| 45 | + // If nil, retry.DefaultPolicy is used. |
| 46 | + RetryPolicy func() retry.Policy |
| 47 | + |
| 48 | + // CacheFactory creates a cache for the given registry. |
| 49 | + // If nil, a new cache is created for each registry. |
| 50 | + CacheFactory func(registry string) auth.Cache |
| 51 | + |
| 52 | + // CredentialStore is used to resolve credentials when not specified |
| 53 | + // in the registry properties. |
| 54 | + // If nil, no credential store fallback is used. |
| 55 | + CredentialStore credentials.Store |
| 56 | + |
| 57 | + // UserAgent is the User-Agent header value for HTTP requests. |
| 58 | + // If empty, no User-Agent header is set. |
| 59 | + UserAgent string |
| 60 | + |
| 61 | + // TokenFetcher is an optional custom token fetcher. |
| 62 | + // If nil, the default token fetching behavior is used. |
| 63 | + TokenFetcher auth.TokenFetcher |
| 64 | + |
| 65 | + // PolicyEvaluator is an optional policy evaluator for allow/deny decisions. |
| 66 | + // If set, repositories created by NewRepositoryWithProperties will |
| 67 | + // automatically enforce policy on read/write operations. |
| 68 | + PolicyEvaluator *policy.Evaluator |
| 69 | + |
| 70 | + // Logger enables HTTP request/response debug logging when non-nil. |
| 71 | + // Each retry attempt is logged individually. If nil, no logging transport |
| 72 | + // is added. Use slog.Default() to log to the default handler. |
| 73 | + Logger *slog.Logger |
| 74 | +} |
| 75 | + |
| 76 | +// NewClientBuilder creates a new ClientBuilder with default settings. |
| 77 | +func NewClientBuilder() *ClientBuilder { |
| 78 | + return &ClientBuilder{ |
| 79 | + BaseTransport: http.DefaultTransport, |
| 80 | + RetryPolicy: func() retry.Policy { return retry.DefaultPolicy }, |
| 81 | + CacheFactory: func(registry string) auth.Cache { return auth.NewCache() }, |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +// Build creates an auth.Client for the given registry properties. |
| 86 | +func (b *ClientBuilder) Build(props *properties.Registry) (*auth.Client, error) { |
| 87 | + if props == nil { |
| 88 | + return nil, fmt.Errorf("registry properties cannot be nil") |
| 89 | + } |
| 90 | + |
| 91 | + // Build TLS configuration |
| 92 | + tlsConfig, err := b.buildTLSConfig(props.Transport) |
| 93 | + if err != nil { |
| 94 | + return nil, fmt.Errorf("failed to configure TLS: %w", err) |
| 95 | + } |
| 96 | + |
| 97 | + // Build transport with TLS |
| 98 | + transport := b.buildTransport(tlsConfig) |
| 99 | + |
| 100 | + // Build HTTP client with retry |
| 101 | + httpClient := b.buildHTTPClient(transport) |
| 102 | + |
| 103 | + // Build credential function |
| 104 | + credentialFunc := b.buildCredentialFunc(props) |
| 105 | + |
| 106 | + // Build headers |
| 107 | + header := b.buildHeader(props.Transport) |
| 108 | + |
| 109 | + // Build cache |
| 110 | + var cache auth.Cache |
| 111 | + if b.CacheFactory != nil { |
| 112 | + cache = b.CacheFactory(props.Reference.Registry) |
| 113 | + } |
| 114 | + |
| 115 | + // Create auth client |
| 116 | + client := &auth.Client{ |
| 117 | + Client: httpClient, |
| 118 | + Header: header, |
| 119 | + CredentialFunc: credentialFunc, |
| 120 | + Cache: cache, |
| 121 | + TokenFetcher: b.tokenFetcher(props, httpClient, header), |
| 122 | + } |
| 123 | + |
| 124 | + return client, nil |
| 125 | +} |
| 126 | + |
| 127 | +// tokenFetcher resolves the token acquisition strategy for a registry. |
| 128 | +// |
| 129 | +// An explicitly configured ClientBuilder.TokenFetcher wins: it is the caller |
| 130 | +// speaking directly, whereas the registry properties come from a config file. |
| 131 | +// Otherwise a distribution-spec flow requested via registries.conf is honoured |
| 132 | +// by handing the client a composite fetcher in legacy mode. A nil result leaves |
| 133 | +// the client on its own default, which is OAuth2. |
| 134 | +func (b *ClientBuilder) tokenFetcher(props *properties.Registry, client *http.Client, header http.Header) auth.TokenFetcher { |
| 135 | + if b.TokenFetcher != nil { |
| 136 | + return b.TokenFetcher |
| 137 | + } |
| 138 | + if props.Attributes.TokenFlow == properties.TokenFlowDistribution { |
| 139 | + return auth.NewCompositeTokenFetcher(client, header, "", true) |
| 140 | + } |
| 141 | + return nil |
| 142 | +} |
| 143 | + |
| 144 | +// buildTLSConfig creates a TLS configuration from transport properties. |
| 145 | +func (b *ClientBuilder) buildTLSConfig(transport properties.Transport) (*tls.Config, error) { |
| 146 | + tlsConfig := &tls.Config{ |
| 147 | + InsecureSkipVerify: transport.Insecure, |
| 148 | + } |
| 149 | + |
| 150 | + // Collect all CA certificate paths. |
| 151 | + var caPaths []string |
| 152 | + if transport.CACert != "" { |
| 153 | + caPaths = append(caPaths, transport.CACert) |
| 154 | + } |
| 155 | + caPaths = append(caPaths, transport.CACerts...) |
| 156 | + |
| 157 | + if len(caPaths) > 0 { |
| 158 | + caCertPool := x509.NewCertPool() |
| 159 | + for _, p := range caPaths { |
| 160 | + caCert, err := os.ReadFile(p) |
| 161 | + if err != nil { |
| 162 | + return nil, fmt.Errorf("failed to read CA certificate %s: %w", p, err) |
| 163 | + } |
| 164 | + if !caCertPool.AppendCertsFromPEM(caCert) { |
| 165 | + return nil, fmt.Errorf("failed to parse CA certificate %s", p) |
| 166 | + } |
| 167 | + } |
| 168 | + tlsConfig.RootCAs = caCertPool |
| 169 | + } |
| 170 | + |
| 171 | + // Load client certificate if specified |
| 172 | + if transport.Cert != "" && transport.Key != "" { |
| 173 | + cert, err := tls.LoadX509KeyPair(transport.Cert, transport.Key) |
| 174 | + if err != nil { |
| 175 | + return nil, fmt.Errorf("failed to load client certificate: %w", err) |
| 176 | + } |
| 177 | + tlsConfig.Certificates = []tls.Certificate{cert} |
| 178 | + } |
| 179 | + |
| 180 | + return tlsConfig, nil |
| 181 | +} |
| 182 | + |
| 183 | +// buildTransport creates an HTTP transport with TLS config. |
| 184 | +func (b *ClientBuilder) buildTransport(tlsConfig *tls.Config) http.RoundTripper { |
| 185 | + // Clone the base transport or use default |
| 186 | + base := b.BaseTransport |
| 187 | + if base == nil { |
| 188 | + base = http.DefaultTransport |
| 189 | + } |
| 190 | + |
| 191 | + // If we have TLS config and the base is an *http.Transport, configure it |
| 192 | + if tlsConfig != nil { |
| 193 | + if httpTransport, ok := base.(*http.Transport); ok { |
| 194 | + // Clone the transport to avoid modifying the original |
| 195 | + cloned := httpTransport.Clone() |
| 196 | + cloned.TLSClientConfig = tlsConfig |
| 197 | + base = cloned |
| 198 | + } |
| 199 | + } |
| 200 | + |
| 201 | + // Wrap with retry transport |
| 202 | + var transport http.RoundTripper = &retry.Transport{ |
| 203 | + Base: base, |
| 204 | + Policy: b.RetryPolicy, |
| 205 | + } |
| 206 | + |
| 207 | + // Wrap with logging transport if a logger is configured. |
| 208 | + // Placed outside retry so each attempt is individually logged. |
| 209 | + if b.Logger != nil { |
| 210 | + transport = NewLoggingTransport(transport, b.Logger) |
| 211 | + } |
| 212 | + |
| 213 | + return transport |
| 214 | +} |
| 215 | + |
| 216 | +// buildHTTPClient creates an HTTP client with the given transport. |
| 217 | +func (b *ClientBuilder) buildHTTPClient(transport http.RoundTripper) *http.Client { |
| 218 | + return &http.Client{ |
| 219 | + Transport: transport, |
| 220 | + } |
| 221 | +} |
| 222 | + |
| 223 | +// buildCredentialFunc creates a credential function that resolves credentials |
| 224 | +// from properties or falls back to the credential store. |
| 225 | +// |
| 226 | +// The credential carried in props belongs to one registry, so it is only |
| 227 | +// returned for that registry's host. Every other host — a mirror, a redirect |
| 228 | +// target, a bearer realm on another origin — resolves through the credential |
| 229 | +// store, which is keyed per host. Returning the static credential |
| 230 | +// unconditionally would hand it to whichever server the client happened to be |
| 231 | +// talking to. |
| 232 | +func (b *ClientBuilder) buildCredentialFunc(props *properties.Registry) credentials.CredentialFunc { |
| 233 | + // Match what the transport actually dials: Host() maps the "docker.io" |
| 234 | + // alias onto "registry-1.docker.io", and the credential func is called with |
| 235 | + // the request host. |
| 236 | + host := registry.Reference{Registry: props.Reference.Registry}.Host() |
| 237 | + |
| 238 | + return func(ctx context.Context, reg string) (credentials.Credential, error) { |
| 239 | + // Credential specified in properties, for its own registry only. |
| 240 | + if props.Credential != credentials.EmptyCredential && strings.EqualFold(reg, host) { |
| 241 | + return props.Credential, nil |
| 242 | + } |
| 243 | + |
| 244 | + // Fall back to credential store if available |
| 245 | + if b.CredentialStore != nil { |
| 246 | + cred, err := b.CredentialStore.Get(ctx, reg) |
| 247 | + if err != nil { |
| 248 | + return credentials.EmptyCredential, err |
| 249 | + } |
| 250 | + return cred, nil |
| 251 | + } |
| 252 | + |
| 253 | + return credentials.EmptyCredential, nil |
| 254 | + } |
| 255 | +} |
| 256 | + |
| 257 | +// buildHeader creates HTTP headers from transport properties. |
| 258 | +func (b *ClientBuilder) buildHeader(transport properties.Transport) http.Header { |
| 259 | + header := http.Header{} |
| 260 | + |
| 261 | + // Set User-Agent if specified |
| 262 | + if b.UserAgent != "" { |
| 263 | + header.Set("User-Agent", b.UserAgent) |
| 264 | + } |
| 265 | + |
| 266 | + // Add custom headers from properties |
| 267 | + for key, value := range transport.HeaderFlags { |
| 268 | + header.Set(key, value) |
| 269 | + } |
| 270 | + |
| 271 | + return header |
| 272 | +} |
| 273 | + |
| 274 | +// NewRegistryWithProperties creates a Registry from registry properties |
| 275 | +// using the given ClientBuilder. |
| 276 | +func NewRegistryWithProperties(props *properties.Registry, builder *ClientBuilder) (*Registry, error) { |
| 277 | + if props == nil { |
| 278 | + return nil, fmt.Errorf("registry properties cannot be nil") |
| 279 | + } |
| 280 | + if builder == nil { |
| 281 | + builder = NewClientBuilder() |
| 282 | + } |
| 283 | + |
| 284 | + // Build auth client |
| 285 | + client, err := builder.Build(props) |
| 286 | + if err != nil { |
| 287 | + return nil, err |
| 288 | + } |
| 289 | + |
| 290 | + // Create registry |
| 291 | + reg := &Registry{ |
| 292 | + Client: client, |
| 293 | + Reference: registry.Reference{Registry: props.Reference.Registry}, |
| 294 | + PlainHTTP: props.Transport.PlainHTTP, |
| 295 | + Policy: builder.PolicyEvaluator, |
| 296 | + } |
| 297 | + |
| 298 | + if builder.Logger != nil { |
| 299 | + reg.HandleWarning = NewWarningLogger(props.Reference.Registry, builder.Logger) |
| 300 | + } |
| 301 | + |
| 302 | + return reg, nil |
| 303 | +} |
| 304 | + |
| 305 | +// NewRepositoryWithProperties creates a Repository from registry properties |
| 306 | +// using the given ClientBuilder. |
| 307 | +func NewRepositoryWithProperties(props *properties.Registry, builder *ClientBuilder) (*Repository, error) { |
| 308 | + if builder == nil { |
| 309 | + builder = NewClientBuilder() |
| 310 | + } |
| 311 | + |
| 312 | + // Share one construction path with NewRegistryWithProperties so the two |
| 313 | + // cannot drift in what they configure. |
| 314 | + reg, err := NewRegistryWithProperties(props, builder) |
| 315 | + if err != nil { |
| 316 | + return nil, err |
| 317 | + } |
| 318 | + |
| 319 | + // Create repository |
| 320 | + repo := &Repository{ |
| 321 | + Registry: reg, |
| 322 | + RepositoryName: props.Reference.Repository, |
| 323 | + } |
| 324 | + |
| 325 | + // Set Referrers API capability if specified |
| 326 | + switch props.Attributes.ReferrersAPI { |
| 327 | + case properties.ReferrersAPISupported: |
| 328 | + repo.SetReferrersCapability(true) |
| 329 | + case properties.ReferrersAPIUnsupported: |
| 330 | + repo.SetReferrersCapability(false) |
| 331 | + } |
| 332 | + |
| 333 | + // Build mirror repositories |
| 334 | + mirrors, err := buildMirrorRepositories(props, builder) |
| 335 | + if err != nil { |
| 336 | + return nil, err |
| 337 | + } |
| 338 | + repo.mirrors = mirrors |
| 339 | + |
| 340 | + return repo, nil |
| 341 | +} |
| 342 | + |
| 343 | +// buildMirrorRepositories creates mirror Repository instances from the |
| 344 | +// mirror properties. Each mirror gets its own auth.Client built from the |
| 345 | +// mirror's transport settings. |
| 346 | +func buildMirrorRepositories(props *properties.Registry, builder *ClientBuilder) ([]mirrorRepository, error) { |
| 347 | + if len(props.Mirrors) == 0 { |
| 348 | + return nil, nil |
| 349 | + } |
| 350 | + |
| 351 | + mirrors := make([]mirrorRepository, 0, len(props.Mirrors)) |
| 352 | + for _, m := range props.Mirrors { |
| 353 | + // Build mirror properties from the mirror's own transport settings. |
| 354 | + // |
| 355 | + // The primary's credential is deliberately not propagated: a mirror is |
| 356 | + // a different host and may be operated by a different party. Mirror |
| 357 | + // credentials are resolved per host through the ClientBuilder's |
| 358 | + // credential store, the same way any other registry's are. |
| 359 | + mirrorProps := &properties.Registry{ |
| 360 | + Reference: properties.Reference{ |
| 361 | + Registry: m.Location, |
| 362 | + Repository: props.Reference.Repository, |
| 363 | + }, |
| 364 | + Transport: m.Transport, |
| 365 | + Attributes: props.Attributes, |
| 366 | + } |
| 367 | + |
| 368 | + mirrorClient, err := builder.Build(mirrorProps) |
| 369 | + if err != nil { |
| 370 | + return nil, fmt.Errorf("failed to build mirror client for %s: %w", m.Location, err) |
| 371 | + } |
| 372 | + |
| 373 | + mirrorReg := &Registry{ |
| 374 | + Client: mirrorClient, |
| 375 | + Reference: registry.Reference{Registry: m.Location}, |
| 376 | + PlainHTTP: m.Transport.PlainHTTP, |
| 377 | + } |
| 378 | + if builder.Logger != nil { |
| 379 | + mirrorReg.HandleWarning = NewWarningLogger(m.Location, builder.Logger) |
| 380 | + } |
| 381 | + |
| 382 | + pullPolicy := m.PullFromMirror |
| 383 | + if pullPolicy == "" { |
| 384 | + pullPolicy = PullFromMirrorAll |
| 385 | + } |
| 386 | + |
| 387 | + mirrors = append(mirrors, mirrorRepository{ |
| 388 | + Repository: &Repository{ |
| 389 | + Registry: mirrorReg, |
| 390 | + RepositoryName: props.Reference.Repository, |
| 391 | + }, |
| 392 | + pullFromMirror: pullPolicy, |
| 393 | + }) |
| 394 | + } |
| 395 | + |
| 396 | + return mirrors, nil |
| 397 | +} |
0 commit comments