Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-09-16 15:52:05 -04:00
pkg-proxy/internal/server/ecr_auth.go
Andrew Nesbitt b5ee6dd96c
Add ECR auto-refreshing upstream authentication (#278)
* Add ECR auto-refreshing upstream authentication

- Add "ecr" auth type to upstream.auth config with optional region
- Cache ecr:GetAuthorizationToken results per region and refresh
  shortly before expiry via the AWS SDK default credential chain
- Route type: ecr through the token cache in Server.authForURL
- Document the new type in config.example.yaml and docs/configuration.md

Fixes #276

* Collapse ecrTokens.header to a single return path

Drops the internal/server package below the goconst min-occurrences
threshold for the Authorization literal.

* Coalesce concurrent ECR token fetches with singleflight

Concurrent cache misses for the same region now share a single
GetAuthorizationToken call instead of each issuing their own, avoiding
a request burst against the ECR API at cold start and at each 12-hour
refresh. golang.org/x/sync is already a direct dependency.

* Improve ECR token refresh and region inference

* Back off failed ECR token refreshes
2026-09-02 12:43:43 +01:00

189 lines
4.9 KiB
Go

package server
import (
"context"
"log/slog"
"net/url"
"strings"
"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
ecrTokenFailureBackoff = 30 * time.Second
ecrDefaultTokenLifetime = 12 * time.Hour
)
// 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
refreshAt time.Time
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 shortly before expiry. Concurrent refreshes
// for the same region share a single GetAuthorizationToken call. If a refresh
// fails, a cached token remains available until its actual expiry and another
// refresh is delayed briefly.
func (e *ecrTokens) header(region string) (name, value string) {
if tok, ok := e.fresh(region); ok {
return tok.header()
}
v, err, _ := e.sf.Do(region, func() (any, error) {
if tok, ok := e.fresh(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 e.cacheFailure(region), nil
}
if raw == "" {
e.logger.Error("ECR authorization token response was empty", "region", region)
return e.cacheFailure(region), nil
}
tok := ecrToken{
value: "Basic " + raw,
refreshAt: expiresAt.Add(-ecrTokenSkew),
expiresAt: expiresAt,
}
e.store(region, tok)
return tok, nil
})
if err != nil {
return "", ""
}
return v.(ecrToken).header()
}
func (t ecrToken) header() (name, value string) {
if t.value == "" {
return "", ""
}
return "Authorization", t.value
}
func (e *ecrTokens) fresh(region string) (ecrToken, bool) {
tok, ok := e.cached(region)
return tok, ok && time.Now().Before(tok.refreshAt)
}
func (e *ecrTokens) cacheFailure(region string) ecrToken {
now := time.Now()
retryAt := now.Add(ecrTokenFailureBackoff)
tok, ok := e.cached(region)
if ok && now.Before(tok.expiresAt) {
if retryAt.After(tok.expiresAt) {
retryAt = tok.expiresAt
}
tok.refreshAt = retryAt
} else {
tok = ecrToken{refreshAt: retryAt, expiresAt: retryAt}
}
e.store(region, tok)
return tok
}
func (e *ecrTokens) cached(region string) (ecrToken, bool) {
e.mu.Lock()
tok, ok := e.cache[region]
e.mu.Unlock()
return tok, ok
}
func (e *ecrTokens) store(region string, tok ecrToken) {
e.mu.Lock()
e.cache[region] = tok
e.mu.Unlock()
}
func ecrRegion(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
labels := strings.Split(strings.ToLower(parsed.Hostname()), ".")
for i := 1; i < len(labels); i++ {
if labels[i] == "dkr" && i+4 < len(labels) && (labels[i+1] == "ecr" || labels[i+1] == "ecr-fips") {
region := labels[i+2]
suffix := strings.Join(labels[i+3:], ".")
if region != "" && (suffix == "amazonaws.com" || suffix == "amazonaws.com.cn") {
return region
}
}
if (labels[i] == "dkr-ecr" || labels[i] == "dkr-ecr-fips") && i+3 < len(labels) {
region := labels[i+1]
if region != "" && strings.Join(labels[i+2:], ".") == "on.aws" {
return region
}
}
}
return ""
}
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
}