diff --git a/docs/content/storage-drivers/s3.md b/docs/content/storage-drivers/s3.md index c1308f210..7c564cd16 100644 --- a/docs/content/storage-drivers/s3.md +++ b/docs/content/storage-drivers/s3.md @@ -18,6 +18,7 @@ Amazon S3 or S3 compatible services for object storage. | `secretkey` | no | Your AWS Secret Key. If you use [IAM roles](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html), omit to fetch temporary credentials from IAM. | | `region` | yes | The AWS region in which your bucket exists. | | `regionendpoint` | no | Endpoint for S3 compatible storage services (Minio, etc). | +| `redirectendpoint` | no | The public-facing endpoint used exclusively for generating presigned redirect URLs for object downloads. | | `forcepathstyle` | no | To enable path-style addressing when the value is set to `true`. The default is `false`. | | `bucket` | yes | The bucket name in which you want to store the registry's data. | | `encrypt` | no | Specifies whether the registry stores the image in encrypted format or not. A boolean value. The default is `false`. | @@ -47,6 +48,9 @@ Amazon S3 or S3 compatible services for object storage. `regionendpoint`: (optional) Endpoint URL for S3 compatible APIs, from version 3+ it's required to be used with `forcepathstyle: true`. Given the `regionendpoint` overrides the API host domain, forcing the path style is necessary, see [more about](https://github.com/distribution/distribution/issues/4528). **This option should not be provided when using Amazon S3.** +`redirectendpoint`: (optional) A public-facing S3 or CDN endpoint URL used exclusively for generating presigned redirect URLs for object downloads. This is useful in architectures where image uploads travel over an isolated internal network endpoint (`regionendpoint`), while image downloads are routed through a separate, accelerated public endpoint. Must include a valid scheme (`http://` or `https://`) and host, and cannot contain a base path or query parameters. Note that replacing the host only works seamlessly for path-style addressing, effectively requiring `forcepathstyle: true` if using custom non-S3 domains. + + `forcepathstyle`: (optional) Force path style for S3 compatible APIs. Some manufacturers only support force path style, while others only support DNS based bucket routing. Amazon S3 supports both. The value of this parameter applies, regardless of the region settings. `bucket`: The name of your S3 bucket where you wish to store objects. The bucket must exist prior to the driver initialization. diff --git a/registry/storage/driver/s3-aws/s3.go b/registry/storage/driver/s3-aws/s3.go index 215ca0b97..161b94fd3 100644 --- a/registry/storage/driver/s3-aws/s3.go +++ b/registry/storage/driver/s3-aws/s3.go @@ -20,6 +20,7 @@ import ( "io" "math" "net/http" + "net/url" "path/filepath" "slices" "sort" @@ -117,6 +118,7 @@ type DriverParameters struct { Accelerate bool UseFIPSEndpoint bool LogLevel aws.LogLevelType + RedirectEndpoint string } func init() { @@ -165,6 +167,7 @@ type driver struct { RootDirectory string StorageClass string ObjectACL string + RedirectEndpoint *url.URL pool *sync.Pool } @@ -335,6 +338,11 @@ func FromParameters(ctx context.Context, parameters map[string]any) (*Driver, er return nil, err } + redirectEndpoint := parameters["redirectendpoint"] + if redirectEndpoint == nil { + redirectEndpoint = "" + } + params := DriverParameters{ AccessKey: fmt.Sprint(accessKey), SecretKey: fmt.Sprint(secretKey), @@ -360,6 +368,7 @@ func FromParameters(ctx context.Context, parameters map[string]any) (*Driver, er Accelerate: accelerateBool, UseFIPSEndpoint: useFIPSEndpointBool, LogLevel: getS3LogLevelFromParam(parameters["loglevel"]), + RedirectEndpoint: fmt.Sprint(redirectEndpoint), } return New(ctx, params) @@ -537,6 +546,29 @@ func New(ctx context.Context, params DriverParameters) (*Driver, error) { }, } + if params.RedirectEndpoint != "" { + u, err := url.Parse(params.RedirectEndpoint) + if err != nil { + return nil, fmt.Errorf("unable to parse redirectendpoint: %w", err) + } + if u.Scheme == "" { + return nil, fmt.Errorf("no scheme specified for redirectendpoint") + } + if u.Host == "" { + return nil, fmt.Errorf("no host specified for redirectendpoint") + } + // Edge Case: Reject trailing paths (e.g., /cdn/v1) because the hot path ignores them + if u.Path != "" && u.Path != "/" { + return nil, fmt.Errorf("redirectendpoint cannot contain a base path: %s", u.Path) + } + // Edge Case: Reject query parameters because they break AWS SigV4 signatures + if u.RawQuery != "" { + return nil, fmt.Errorf("redirectendpoint cannot contain query parameters") + } + + d.RedirectEndpoint = u + } + return &Driver{ baseEmbed: baseEmbed{ Base: base.Base{ @@ -1041,6 +1073,13 @@ func (d *driver) RedirectURL(r *http.Request, path string) (string, error) { return "", nil } + // If a public redirect endpoint is configured, use it for the signed URL + // This allows using a different public endpoint for downloads with signed URLs + if d.RedirectEndpoint != nil && req.HTTPRequest != nil { + req.HTTPRequest.URL.Host = d.RedirectEndpoint.Host + req.HTTPRequest.URL.Scheme = d.RedirectEndpoint.Scheme + } + return req.Presign(expiresIn) }