diff --git a/config.example.yaml b/config.example.yaml index 1df95b3..4e21775 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -120,6 +120,7 @@ upstream: # - bearer: Authorization header with Bearer token # - basic: Authorization header with Basic auth (username:password) # - header: Custom header name and value + # - ecr: AWS ECR auto-refreshing token via the AWS SDK credential chain auth: # Example: npm with bearer token # "https://registry.npmjs.org": @@ -143,6 +144,13 @@ upstream: # header_name: "X-Auth-Token" # header_value: "${MAVEN_TOKEN}" + # Example: private AWS ECR registry (12h tokens auto-refreshed via + # ecr:GetAuthorizationToken; credentials come from the AWS SDK default + # chain, so IRSA / instance profiles / AWS_* env vars all work) + # "https://123456789012.dkr.ecr.eu-west-1.amazonaws.com": + # type: ecr + # region: eu-west-1 + # Gradle HttpBuildCache configuration gradle: build_cache: diff --git a/docs/configuration.md b/docs/configuration.md index 3b8b935..30f156d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -216,6 +216,22 @@ upstream: header_value: "${MAVEN_TOKEN}" ``` +### AWS ECR + +Private ECR registries issue authorization tokens that expire after 12 hours. The `ecr` auth type calls `ecr:GetAuthorizationToken` on demand, caches the result, and refreshes it shortly before expiry, so no static credential appears in the config file: + +```yaml +upstream: + oci: + ecr: "https://123456789012.dkr.ecr.eu-west-1.amazonaws.com" + auth: + "https://123456789012.dkr.ecr.eu-west-1.amazonaws.com": + type: ecr + region: eu-west-1 +``` + +AWS credentials are resolved by the SDK's default chain, which covers EKS IAM Roles for Service Accounts (IRSA), EC2/ECS instance profiles, `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` environment variables, and `~/.aws/credentials`. The IAM identity needs the `ecr:GetAuthorizationToken` action plus the usual `ecr:BatchGetImage` / `ecr:GetDownloadUrlForLayer` permissions on the target repositories. If `region` is omitted the SDK's default region resolution applies. + ### URL Matching Auth keys must be absolute URLs. Matching compares the scheme, host, effective port, and path-segment prefix, preventing credentials for `registry.example.com` from being sent to a lookalike host such as `registry.example.com.evil.test`. The longest matching scope wins, so you can configure different credentials for different paths: diff --git a/go.mod b/go.mod index d95ee13..921a0f9 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ toolchain go1.26.6 require ( github.com/BurntSushi/toml v1.6.0 github.com/CycloneDX/cyclonedx-go v0.11.0 + github.com/aws/aws-sdk-go-v2/config v1.32.38 + github.com/aws/aws-sdk-go-v2/service/ecr v1.60.7 github.com/git-pkgs/archives v0.5.1 github.com/git-pkgs/cooldown v0.1.1 github.com/git-pkgs/enrichment v0.6.5 @@ -74,25 +76,24 @@ require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect github.com/ashanbrown/makezero/v2 v2.2.1 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect + github.com/aws/aws-sdk-go-v2 v1.43.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.37 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.38 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect - github.com/aws/smithy-go v1.26.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.7 // indirect + github.com/aws/smithy-go v1.27.8 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bkielbasa/cyclop v1.2.3 // indirect github.com/blizzy78/varnamelen v0.8.0 // indirect diff --git a/go.sum b/go.sum index e2fedc7..79d84c4 100644 --- a/go.sum +++ b/go.sum @@ -117,44 +117,46 @@ github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTi github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM= github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJUBUsejqM= github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY= -github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= -github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2 v1.43.7 h1:msCzvkeYJA9ehbV8mRRmkZLo/zJg/+yDVLNtflg83hQ= +github.com/aws/aws-sdk-go-v2 v1.43.7/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94= -github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= -github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= -github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= -github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= +github.com/aws/aws-sdk-go-v2/config v1.32.38 h1:n4yPHBjtQ3BrIIUyk0/LAqf/BL2iv0Tw6XZcMRzM0ps= +github.com/aws/aws-sdk-go-v2/config v1.32.38/go.mod h1:dencYsOS1R7rBy8zehCvwBYzdxxL4Q/nRK7In03wjN8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.37 h1:FJ8Iz4/xISMB/rwLlgfWujfGDFWr0oneQgtA6KPcYLY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.37/go.mod h1:Q6pWOgVUp49x4g5QVi29wHofUoICnZ+Zq4jHbRN/7ec= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.38 h1:Nqo2jU1wz5rnBM9XQyXfVD1RP8txkbP3EDx8hR/hbCE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.38/go.mod h1:PzJFHhjR2vWFKHe8HmY5Lxhvwyxnr5MERtk0nDxWNbk= github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 h1:w5OoDiMN6x53ROmiIImGzmVcxXv2q1GXY+aKV4WAJYM= github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3/go.mod h1:dAhgYp776bX3LuWvnSCFwQEjNs6fuFg7YXIy5PXcP3Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38 h1:MBMg0zJ6i4TkAJ0dVFLKKn2cOkY6FkicmUDM67BRr6g= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38/go.mod h1:9MWuJbyiUyj6eA7W1/zm1zuePDPSB3g+xcgRQeMWsXc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38 h1:lHm4jPf3k1Lz5ZWc+Vcn3MKVwym+26kWCba9FkJ4f0Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38/go.mod h1:Rn+P2XR+FbyZzjmWKjg/KUZNxmGfr5oZwh5jQiE+CzI= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 h1:vo4xvMRs/F6h1E52qsgLqCQgWIQXgIJUauG6rlZEh4U= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39/go.mod h1:jB03R1ij/A+OE2e1dz6vgj076gd7vlYcfstAzj3HcnU= +github.com/aws/aws-sdk-go-v2/service/ecr v1.60.7 h1:HgGvHNphJn16nP+JI6zzhYk1Y6f3BeDqbc7EYgpYKxU= +github.com/aws/aws-sdk-go-v2/service/ecr v1.60.7/go.mod h1:dCl1nYmyWmxJO6mBprZdwoFbIBEieBSifLNAt1xnSe4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 h1:H/5TI1jqaHsNoDQ60UwvPvJBg4GURkinXI3Qga29t2w= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38/go.mod h1:PTVFf+XH++7NJOky+RLBYQx0QA5NcaeEYFQ2fsi0nwo= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM= github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298= github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0= -github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= -github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= -github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= -github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.7 h1:YcczQ6zNH/ojIzD/ikDrO+RfW06wmdMp18d4NH5hXY4= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.7/go.mod h1:nl9RVnb9ulgAYzOkjLq1NyFxmWcnH2maCUEuOdESy98= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.7 h1:P+bMNiA93gyuYT3Oh+4dWtvrnGcu2bd9Uy5hRJM8BNo= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.7/go.mod h1:zy+397isDFLvleg9H18Zq2MGzMso7uKyJyzR7DWSgFk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.7 h1:WWkehGZ4nWtOKLMy0yi8+RqzzVqAGe60hGaxwF06JAw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.7/go.mod h1:T8AI4SbQYm9ybcVmki2T3n7Qg1g3kfWoeQlNwNYOyO8= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.7 h1:yU/9y2r7s9kSUPbHXbpQTa4LA8kt+CMgpu1OBrhx8p4= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.7/go.mod h1:0lQTDEBArMevQXpxu443LVGjKxxEeSsSnrw9n8YiTMg= +github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY= +github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= diff --git a/internal/config/config.go b/internal/config/config.go index a3dfbc6..c906682 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -430,7 +430,7 @@ func authURLPort(value *url.URL) string { // AuthConfig configures authentication for an upstream registry. type AuthConfig struct { - // Type is the authentication type: "bearer", "basic", or "header". + // Type is the authentication type: "bearer", "basic", "header", or "ecr". Type string `json:"type" yaml:"type"` // Token is used for bearer authentication. @@ -450,6 +450,11 @@ type AuthConfig struct { // HeaderValue is the custom header value (for type "header"). // Can reference environment variables with ${VAR_NAME} syntax. HeaderValue string `json:"header_value" yaml:"header_value"` + + // Region is the AWS region for ECR authentication (for type "ecr"). + // If empty, the AWS SDK default region chain is used (AWS_REGION, + // shared config, IMDS). + Region string `json:"region" yaml:"region"` } // Default returns a Config with sensible defaults. diff --git a/internal/server/ecr_auth.go b/internal/server/ecr_auth.go new file mode 100644 index 0000000..207475b --- /dev/null +++ b/internal/server/ecr_auth.go @@ -0,0 +1,126 @@ +package server + +import ( + "context" + "errors" + "log/slog" + "sync" + "time" + + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "golang.org/x/sync/singleflight" +) + +const ( + ecrTokenTimeout = 10 * time.Second + ecrTokenSkew = 5 * time.Minute + ecrDefaultTokenLifetime = 12 * time.Hour +) + +var errEmptyECRToken = errors.New("empty ECR authorization token") + +// ecrTokens caches AWS ECR authorization tokens per region and refreshes them +// on demand when they expire. Tokens are obtained via the AWS SDK default +// credential chain, so IAM roles for service accounts, instance profiles, and +// environment credentials all work without extra configuration. +type ecrTokens struct { + logger *slog.Logger + + mu sync.Mutex + cache map[string]ecrToken + sf singleflight.Group + + // getToken fetches a fresh authorization token for the given region and + // returns the raw base64 "AWS:password" value plus its expiry. Overridable + // in tests. + getToken func(ctx context.Context, region string) (string, time.Time, error) +} + +type ecrToken struct { + value string + expiresAt time.Time +} + +func newECRTokens(logger *slog.Logger) *ecrTokens { + return &ecrTokens{ + logger: logger, + cache: make(map[string]ecrToken), + getToken: fetchECRToken, + } +} + +// header returns an Authorization header for the given region, fetching and +// caching a token on first use and after expiry. Concurrent misses for the +// same region share a single GetAuthorizationToken call. On failure it logs +// and returns empty strings so the request proceeds unauthenticated; the OCI +// transport then follows the Bearer challenge and surfaces the token-endpoint +// error, matching the behaviour of any other misconfigured upstream credential. +func (e *ecrTokens) header(region string) (name, value string) { + if tok, ok := e.cached(region); ok { + return "Authorization", tok.value + } + + v, err, _ := e.sf.Do(region, func() (any, error) { + if tok, ok := e.cached(region); ok { + return tok, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), ecrTokenTimeout) + defer cancel() + + raw, expiresAt, err := e.getToken(ctx, region) + if err != nil { + e.logger.Error("fetching ECR authorization token", "region", region, "error", err) + return ecrToken{}, err + } + if raw == "" { + e.logger.Error("ECR authorization token response was empty", "region", region) + return ecrToken{}, errEmptyECRToken + } + + tok := ecrToken{value: "Basic " + raw, expiresAt: expiresAt.Add(-ecrTokenSkew)} + e.mu.Lock() + e.cache[region] = tok + e.mu.Unlock() + return tok, nil + }) + if err != nil { + return "", "" + } + + return "Authorization", v.(ecrToken).value +} + +func (e *ecrTokens) cached(region string) (ecrToken, bool) { + e.mu.Lock() + tok, ok := e.cache[region] + e.mu.Unlock() + return tok, ok && time.Now().Before(tok.expiresAt) +} + +func fetchECRToken(ctx context.Context, region string) (string, time.Time, error) { + var opts []func(*awsconfig.LoadOptions) error + if region != "" { + opts = append(opts, awsconfig.WithRegion(region)) + } + cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) + if err != nil { + return "", time.Time{}, err + } + + out, err := ecr.NewFromConfig(cfg).GetAuthorizationToken(ctx, &ecr.GetAuthorizationTokenInput{}) + if err != nil { + return "", time.Time{}, err + } + if len(out.AuthorizationData) == 0 || out.AuthorizationData[0].AuthorizationToken == nil { + return "", time.Time{}, nil + } + + data := out.AuthorizationData[0] + expiresAt := time.Now().Add(ecrDefaultTokenLifetime) + if data.ExpiresAt != nil { + expiresAt = *data.ExpiresAt + } + return *data.AuthorizationToken, expiresAt, nil +} diff --git a/internal/server/ecr_auth_test.go b/internal/server/ecr_auth_test.go new file mode 100644 index 0000000..da96026 --- /dev/null +++ b/internal/server/ecr_auth_test.go @@ -0,0 +1,159 @@ +package server + +import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/git-pkgs/proxy/internal/config" +) + +func testECRTokens() *ecrTokens { + return newECRTokens(slog.New(slog.NewTextHandler(io.Discard, nil))) +} + +func TestECRTokensCachesUntilExpiry(t *testing.T) { + e := testECRTokens() + calls := 0 + e.getToken = func(_ context.Context, region string) (string, time.Time, error) { + calls++ + if region != "eu-west-1" { + t.Errorf("region = %q, want eu-west-1", region) + } + return "QVdTOnNlY3JldA==", time.Now().Add(12 * time.Hour), nil + } + + name, value := e.header("eu-west-1") + if name != "Authorization" || value != "Basic QVdTOnNlY3JldA==" { + t.Fatalf("header() = %q, %q", name, value) + } + + e.header("eu-west-1") + e.header("eu-west-1") + if calls != 1 { + t.Fatalf("getToken called %d times, want 1", calls) + } +} + +func TestECRTokensRefreshesAfterExpiry(t *testing.T) { + e := testECRTokens() + calls := 0 + e.getToken = func(_ context.Context, _ string) (string, time.Time, error) { + calls++ + return "dG9rZW4=", time.Now().Add(time.Minute), nil + } + + e.header("us-east-1") + e.header("us-east-1") + if calls != 2 { + t.Fatalf("getToken called %d times, want 2 (token within skew window)", calls) + } +} + +func TestECRTokensPerRegion(t *testing.T) { + e := testECRTokens() + seen := map[string]int{} + e.getToken = func(_ context.Context, region string) (string, time.Time, error) { + seen[region]++ + return region + "-token", time.Now().Add(time.Hour), nil + } + + e.header("eu-west-1") + e.header("us-east-1") + e.header("eu-west-1") + + if seen["eu-west-1"] != 1 || seen["us-east-1"] != 1 { + t.Fatalf("per-region calls = %v, want one each", seen) + } +} + +func TestECRTokensConcurrentMissesShareOneFetch(t *testing.T) { + e := testECRTokens() + var calls atomic.Int32 + release := make(chan struct{}) + e.getToken = func(_ context.Context, _ string) (string, time.Time, error) { + calls.Add(1) + <-release + return "dG9rZW4=", time.Now().Add(time.Hour), nil + } + + const n = 10 + var wg sync.WaitGroup + wg.Add(n) + for range n { + go func() { + defer wg.Done() + name, value := e.header("eu-west-1") + if name != "Authorization" || value != "Basic dG9rZW4=" { + t.Errorf("header() = %q, %q", name, value) + } + }() + } + + close(release) + wg.Wait() + + if got := calls.Load(); got != 1 { + t.Fatalf("getToken called %d times, want 1", got) + } +} + +func TestECRTokensErrorReturnsNoAuth(t *testing.T) { + e := testECRTokens() + e.getToken = func(_ context.Context, _ string) (string, time.Time, error) { + return "", time.Time{}, errors.New("no credentials") + } + + name, value := e.header("eu-west-1") + if name != "" || value != "" { + t.Fatalf("header() = %q, %q; want empty on error", name, value) + } +} + +func TestAuthForURLRoutesECRType(t *testing.T) { + e := testECRTokens() + e.getToken = func(_ context.Context, region string) (string, time.Time, error) { + if region != "eu-west-1" { + t.Errorf("region = %q, want eu-west-1", region) + } + return "QVdTOnNlY3JldA==", time.Now().Add(time.Hour), nil + } + + s := &Server{ + ecr: e, + cfg: &config.Config{ + Upstream: config.UpstreamConfig{ + Auth: map[string]config.AuthConfig{ + "https://123456789012.dkr.ecr.eu-west-1.amazonaws.com": { + Type: "ecr", + Region: "eu-west-1", + }, + "https://ghcr.io": { + Type: "bearer", + Token: "ghcr-token", + }, + }, + }, + }, + } + + name, value := s.authForURL("https://123456789012.dkr.ecr.eu-west-1.amazonaws.com/v2/my/repo/manifests/latest") + if name != "Authorization" || value != "Basic QVdTOnNlY3JldA==" { + t.Fatalf("ecr authForURL() = %q, %q", name, value) + } + + name, value = s.authForURL("https://ghcr.io/v2/owner/repo/blobs/sha256:abc") + if name != "Authorization" || value != "Bearer ghcr-token" { + t.Fatalf("bearer authForURL() = %q, %q", name, value) + } + + name, value = s.authForURL("https://registry-1.docker.io/v2/") + if name != "" || value != "" { + t.Fatalf("unmatched authForURL() = %q, %q; want empty", name, value) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index bb964e8..cd18be9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -98,6 +98,7 @@ type Server struct { cancel context.CancelFunc healthCache *healthCache accessLog *accesslog.Logger + ecr *ecrTokens } // New creates a new Server with the given configuration. @@ -174,6 +175,7 @@ func New(cfg *config.Config, logger *slog.Logger, buildInfo BuildInfo) (*Server, templates: &Templates{}, healthCache: hc, accessLog: activityLog, + ecr: newECRTokens(logger), } closeAccessLog = false return server, nil @@ -425,6 +427,9 @@ func (s *Server) authForURL(url string) (headerName, headerValue string) { if auth == nil { return "", "" } + if strings.EqualFold(auth.Type, "ecr") { + return s.ecr.header(auth.Region) + } return auth.Header() }