Skip to content
This repository has been archived by the owner on Nov 1, 2022. It is now read-only.

Commit

Permalink
Merge pull request #1619 from weaveworks/issue/539-native-ecr-auth
Browse files Browse the repository at this point in the history
Native ECR auth
  • Loading branch information
squaremo authored Jan 8, 2019
2 parents 491540b + 1b5a3f2 commit 3ca823d
Show file tree
Hide file tree
Showing 6 changed files with 345 additions and 36 deletions.
52 changes: 52 additions & 0 deletions Gopkg.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 26 additions & 6 deletions cmd/fluxd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ func main() {
registryTrace = fs.Bool("registry-trace", false, "output trace of image registry requests to log")
registryInsecure = fs.StringSlice("registry-insecure-host", []string{}, "use HTTP for this image registry domain (e.g., registry.cluster.local), instead of HTTPS")

// AWS authentication
registryAWSRegions = fs.StringSlice("registry-ecr-region", nil, "Restrict ECR scanning to these AWS regions; if empty, only the cluster's region will be scanned")
registryAWSAccountIDs = fs.StringSlice("registry-ecr-include-id", nil, "Restrict ECR scanning to these AWS account IDs; if empty, all account IDs that aren't excluded may be scanned")
registryAWSBlockAccountIDs = fs.StringSlice("registry-ecr-exclude-id", []string{registry.EKS_SYSTEM_ACCOUNT}, "Do not scan ECR for images in these AWS account IDs; the default is to exclude the EKS system account")

// k8s-secret backed ssh keyring configuration
k8sSecretName = fs.String("k8s-secret-name", "flux-git-deploy", "Name of the k8s secret used to store the private SSH key")
k8sSecretVolumeMountPath = fs.String("k8s-secret-volume-mount-path", "/etc/fluxd/ssh", "Mount location of the k8s secret storing the private SSH key")
Expand Down Expand Up @@ -182,8 +187,8 @@ func main() {
var clusterVersion string
var sshKeyRing ssh.KeyRing
var k8s cluster.Cluster
var imageCreds func() registry.ImageCreds
var k8sManifests cluster.Manifests
var imageCreds func() registry.ImageCreds
{
restClientConfig, err := rest.InClusterConfig()
if err != nil {
Expand Down Expand Up @@ -261,19 +266,34 @@ func main() {
logger.Log("ping", true)
}

k8s = k8sInst
imageCreds = k8sInst.ImagesToFetch
// There is only one way we currently interpret a repo of
// files as manifests, and that's as Kubernetes yamels.
k8sManifests = &kubernetes.Manifests{}
}

// Wrap the procedure for collecting images to scan
{
awsConf := registry.AWSRegistryConfig{
Regions: *registryAWSRegions,
AccountIDs: *registryAWSAccountIDs,
BlockIDs: *registryAWSBlockAccountIDs,
}
credsWithAWSAuth, err := registry.ImageCredsWithAWSAuth(imageCreds, log.With(logger, "component", "aws"), awsConf)
if err != nil {
logger.Log("warning", "AWS authorization not used; pre-flight check failed")
} else {
imageCreds = credsWithAWSAuth
}
if *dockerConfig != "" {
credsWithDefaults, err := registry.ImageCredsWithDefaults(imageCreds, *dockerConfig)
if err != nil {
logger.Log("msg", "--docker-config not used", "err", err)
logger.Log("warning", "--docker-config not used; pre-flight check failed", "err", err)
} else {
imageCreds = credsWithDefaults
}
}
k8s = k8sInst
// There is only one way we currently interpret a repo of
// files as manifests, and that's as Kubernetes yamels.
k8sManifests = &kubernetes.Manifests{}
}

// Registry components
Expand Down
222 changes: 222 additions & 0 deletions registry/aws.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package registry

// References:
// - https://github.com/bzon/ecr-k8s-secret-creator
// - https://github.com/kubernetes/kubernetes/blob/master/pkg/credentialprovider/aws/aws_credentials.go
// - https://github.com/weaveworks/flux/pull/1455

import (
"strings"
"time"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/ec2metadata"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ecr"
"github.com/go-kit/kit/log"
)

const (
// For recognising ECR hosts
ecrHostSuffix = ".amazonaws.com"
// How long AWS tokens remain valid, according to AWS docs; this
// is used as an upper bound, overridden by any sooner expiry
// returned in the API response.
defaultTokenValid = 12 * time.Hour
// how long to skip refreshing a region after we've failed
embargoDuration = 10 * time.Minute

EKS_SYSTEM_ACCOUNT = "602401143452"
)

type AWSRegistryConfig struct {
Regions []string
AccountIDs []string
BlockIDs []string
}

func contains(strs []string, str string) bool {
for _, s := range strs {
if s == str {
return true
}
}
return false
}

// ECR registry URLs look like this:
//
// <account-id>.dkr.ecr.<region>.amazonaws.com
//
// i.e., they can differ in the account ID and in the region. It's
// possible to refer to any registry from any cluster (although, being
// AWS, there will be a cost incurred).

func ImageCredsWithAWSAuth(lookup func() ImageCreds, logger log.Logger, config AWSRegistryConfig) (func() ImageCreds, error) {
awsCreds := NoCredentials()

if len(config.Regions) == 0 {
// this forces the AWS SDK to load config, so we can get the default region
sess := session.Must(session.NewSessionWithOptions(session.Options{
SharedConfigState: session.SharedConfigEnable,
}))
clusterRegion := *sess.Config.Region
if clusterRegion == "" {
// no region set in config; in that case, use the EC2 metadata service to find where we are running.
ec2 := ec2metadata.New(sess)
instanceRegion, err := ec2.Region()
if err != nil {
logger.Log("warn", "no AWS region configured, or detected as cluster region", "err", err)
return nil, err
}
clusterRegion = instanceRegion
}
logger.Log("info", "detected cluster region", "region", clusterRegion)
config.Regions = []string{clusterRegion}
}

logger.Log("info", "restricting ECR registry scans",
"regions", strings.Join(config.Regions, ", "),
"include-ids", strings.Join(config.AccountIDs, ", "),
"exclude-ids", strings.Join(config.BlockIDs, ", "))

// this has the expiry time from the last request made per region. We request new tokens whenever
// - we don't have credentials for the particular registry URL
// - the credentials have expired
// and when we do, we get new tokens for all account IDs in the
// region that we've seen. This means that credentials are
// fetched, and expire, per region.
regionExpire := map[string]time.Time{}
// we can get an error when refreshing the credentials; to avoid
// spamming the log, keep track of failed refreshes.
regionEmbargo := map[string]time.Time{}

// should this registry be scanned?
var shouldScan func(string, string) bool
if len(config.AccountIDs) == 0 {
shouldScan = func(region, accountID string) bool {
return contains(config.Regions, region) && !contains(config.BlockIDs, accountID)
}
} else {
shouldScan = func(region, accountID string) bool {
return contains(config.Regions, region) &&
contains(config.AccountIDs, accountID) &&
!contains(config.BlockIDs, accountID)
}
}

ensureCreds := func(domain, region, accountID string, now time.Time) error {
// if we had an error getting a token before, don't try again
// until the embargo has passed
if embargo, ok := regionEmbargo[region]; ok {
if embargo.After(now) {
return nil // i.e., fail silently
}
delete(regionEmbargo, region)
}

// if we don't have the entry at all, we need to get a
// token. NB we can't check the inverse and return early,
// since if the creds do exist, we need to check their expiry.
if c := awsCreds.credsFor(domain); c == (creds{}) {
goto refresh
}

// otherwise, check if the tokens have expired
if expiry, ok := regionExpire[region]; !ok || expiry.Before(now) {
goto refresh
}

// the creds exist and are before the use-by; nothing to be done.
return nil

refresh:
// unconditionally append the sought-after account, and let
// the AWS API figure out if it's a duplicate.
accountIDs := append(allAccountIDsInRegion(awsCreds.Hosts(), region), accountID)
logger.Log("info", "attempting to refresh auth tokens", "region", region, "account-ids", strings.Join(accountIDs, ", "))
regionCreds, expiry, err := fetchAWSCreds(region, accountIDs)
if err != nil {
regionEmbargo[region] = now.Add(embargoDuration)
logger.Log("error", "fetching credentials for AWS region", "region", region, "err", err, "embargo", embargoDuration)
return err
}
regionExpire[region] = expiry
awsCreds.Merge(regionCreds)
return nil
}

return func() ImageCreds {
imageCreds := lookup()

for name, creds := range imageCreds {
domain := name.Domain
if strings.HasSuffix(domain, ecrHostSuffix) {
bits := strings.Split(domain, ".")
if len(bits) != 6 {
logger.Log("warning", "AWS registry domain not in expected format <account-id>.dkr.ecr.<region>.amazonaws.com", "domain", domain)
continue
}
accountID := bits[0]
region := bits[3]

if !shouldScan(region, accountID) {
delete(imageCreds, name)
continue
}
if err := ensureCreds(domain, region, accountID, time.Now()); err != nil {
logger.Log("warning", "unable to ensure credentials for ECR", "domain", domain, "err", err)
}
newCreds := NoCredentials()
newCreds.Merge(awsCreds)
newCreds.Merge(creds)
imageCreds[name] = newCreds
}
}
return imageCreds
}, nil
}

func allAccountIDsInRegion(hosts []string, region string) []string {
var ids []string
// this returns a list of unique accountIDs, assuming that the input is unique hostnames
for _, host := range hosts {
bits := strings.Split(host, ".")
if len(bits) != 6 {
continue
}
if bits[3] == region {
ids = append(ids, bits[0])
}
}
return ids
}

func fetchAWSCreds(region string, accountIDs []string) (Credentials, time.Time, error) {
sess := session.Must(session.NewSession(&aws.Config{Region: aws.String(region)}))
svc := ecr.New(sess)
ecrToken, err := svc.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{
RegistryIds: aws.StringSlice(accountIDs),
})
if err != nil {
return Credentials{}, time.Time{}, err
}
auths := make(map[string]creds)
expiry := time.Now().Add(defaultTokenValid)
for _, v := range ecrToken.AuthorizationData {
// Remove the https prefix
host := strings.TrimPrefix(*v.ProxyEndpoint, "https://")
creds, err := parseAuth(*v.AuthorizationToken)
if err != nil {
return Credentials{}, time.Time{}, err
}
creds.provenance = "AWS API"
creds.registry = host
auths[host] = creds
ex := *v.ExpiresAt
if ex.Before(expiry) {
expiry = ex
}
}
return Credentials{m: auths}, expiry, nil
}
Loading

0 comments on commit 3ca823d

Please sign in to comment.