Skip to content

Commit

Permalink
feat(aws): add support for AWS IAM Roles Anywhere authentication [EE-…
Browse files Browse the repository at this point in the history
…4614] (#423)

* feat(aws): add support for AWS IAM Roles Anywhere authentication

* feat(aws): update aws config detection

* feat(aws): add logging

* feat(aws): also run aws logic for non async stacks

* feat(aws): update logging

* feat(aws): skip lookup handler if aws config found

* feat(aws): do not start registry server if aws config is found

* feat(aws): remove docker config if aws config is found

* feat(aws): update credentials logic to hook into portainer helper

* chore(aws): code cleanup

* chore(aws): code cleanup

* feat(aws): update temp credentials logic

* feat(aws): update imports and dependencies

* feat(aws): add missing error handler

* feat(aws): update comment

* feat(agent): base agent version on 2.16.0

* chore(agent): bump agent version to 2.17.0
  • Loading branch information
deviantony authored Feb 23, 2023
1 parent 8ae5b6a commit d07dcb4
Show file tree
Hide file tree
Showing 7 changed files with 235 additions and 31 deletions.
24 changes: 23 additions & 1 deletion agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ import (
)

type (
// AWSConfig is a configuration used to authenticate against AWS IAM Roles Anywhere
AWSConfig struct {
ClientCertPath string
ClientKeyPath string
ClientBundlePath string
RoleARN string
TrustAnchorARN string
ProfileARN string
Region string
}

// ClusterMember is the representation of an agent inside a cluster.
ClusterMember struct {
IPAddress string
Expand Down Expand Up @@ -101,6 +112,13 @@ type (
SSLCACert string
UpdateID int
CertRetryInterval time.Duration
AWSClientCert string
AWSClientKey string
AWSClientBundle string
AWSRoleARN string
AWSTrustAnchorARN string
AWSProfileARN string
AWSRegion string
}

NomadConfig struct {
Expand Down Expand Up @@ -237,7 +255,7 @@ type (

var (
// Version represents the version of the agent.
Version = "2.18.0"
Version = "2.17.0"
)

const (
Expand Down Expand Up @@ -349,6 +367,10 @@ const (
KubernetesServiceHost = "KUBERNETES_SERVICE_HOST"
// KubernetesServicePortHttps is the environment variable of the kubernetes API server https port
KubernetesServicePortHttps = "KUBERNETES_SERVICE_PORT_HTTPS"
// DefaultAWSClientCertPath is the default path to the AWS client certificate file
DefaultAWSClientCertPath = "/certs/aws-client.crt"
// DefaultAWSClientKeyPath is the default path to the AWS client key file
DefaultAWSClientKeyPath = "/certs/aws-client.key"
)

const (
Expand Down
17 changes: 16 additions & 1 deletion cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ func main() {
DockerInfoService: dockerInfoService,
ContainerPlatform: containerPlatform,
}

edgeManager = edge.NewManager(edgeManagerParameters)

edgeKey, err := edge.RetrieveEdgeKey(options.EdgeKey, clusterService, options.DataPath)
Expand Down Expand Up @@ -377,7 +378,21 @@ func main() {
if options.EdgeMode {
config.Addr = advertiseAddr
}
err = registry.StartRegistryServer(edgeManager)

var awsConfig agent.AWSConfig
if os.IsValidAWSConfig(options) {
log.Info().Msg("AWS configuration detected")
awsConfig = agent.AWSConfig{
ClientCertPath: options.AWSClientCert,
ClientKeyPath: options.AWSClientKey,
RoleARN: options.AWSRoleARN,
TrustAnchorARN: options.AWSTrustAnchorARN,
ProfileARN: options.AWSProfileARN,
Region: options.AWSRegion,
}
}

err = registry.StartRegistryServer(edgeManager, &awsConfig)
if err != nil {
log.Fatal().Err(err).Msg("unable to start registry server")
}
Expand Down
73 changes: 73 additions & 0 deletions edge/registry/aws.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package registry

import (
"context"

"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
iamra "github.com/aws/rolesanywhere-credential-helper/aws_signing_helper"
"github.com/awslabs/amazon-ecr-credential-helper/ecr-login/api"
"github.com/portainer/agent"
"github.com/rs/zerolog/log"
)

func doAWSIAMRolesAnywhereAuthAndGetECRCredentials(serverURL string, awsConfig *agent.AWSConfig) (*agent.RegistryCredentials, error) {
iamraCreds, err := authenticateAgainstIAMRA(awsConfig)
if err != nil {
return nil, err
}

factory := api.DefaultClientFactory{}

cfg, err := config.LoadDefaultConfig(
context.TODO(),
config.WithRegion(awsConfig.Region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(iamraCreds.AccessKeyId, iamraCreds.SecretAccessKey, iamraCreds.SessionToken)),
)
if err != nil {
log.Err(err).Msg("unable to build AWS client config")
return nil, err
}

client := factory.NewClient(cfg)

creds, err := client.GetCredentials(serverURL)
if err != nil {
// This might not be an ECR registry
// Therefore we deliberately not return an error here so that the upstream logic can fallback to other credential providers
log.Warn().Str("server_url", serverURL).Err(err).Msg("unable to retrieve credentials from server")
return nil, nil
}

return &agent.RegistryCredentials{
ServerURL: serverURL,
Username: creds.Username,
Secret: creds.Password,
}, nil
}

func authenticateAgainstIAMRA(awsConfig *agent.AWSConfig) (*iamra.CredentialProcessOutput, error) {
credentialsOptions := iamra.CredentialsOpts{
PrivateKeyId: awsConfig.ClientKeyPath,
CertificateId: awsConfig.ClientCertPath,
RoleArn: awsConfig.RoleARN,
ProfileArnStr: awsConfig.ProfileARN,
TrustAnchorArnStr: awsConfig.TrustAnchorARN,
SessionDuration: 3600,
NoVerifySSL: false,
WithProxy: false,
Debug: false,
}

if awsConfig.ClientBundlePath != "" {
credentialsOptions.CertificateBundleId = awsConfig.ClientBundlePath
}

credentialProcessOutput, err := iamra.GenerateCredentials(&credentialsOptions)
if err != nil {
log.Err(err).Msg("unable to authenticate against AWS IAM Roles Anywhere")
return nil, err
}

return &credentialProcessOutput, nil
}
51 changes: 25 additions & 26 deletions edge/registry/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package registry

import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
Expand All @@ -21,12 +20,14 @@ import (
type Handler struct {
*mux.Router
EdgeManager *edge.Manager
awsConfig *agent.AWSConfig
}

func NewEdgeRegistryHandler(edgeManager *edge.Manager) *Handler {
func NewEdgeRegistryHandler(edgeManager *edge.Manager, awsConfig *agent.AWSConfig) *Handler {
h := &Handler{
Router: mux.NewRouter(),
EdgeManager: edgeManager,
awsConfig: awsConfig,
}

h.Handle("/lookup", httperror.LoggerHandler(h.LookupHandler)).Methods(http.MethodGet)
Expand All @@ -47,6 +48,26 @@ func (handler *Handler) LookupHandler(rw http.ResponseWriter, r *http.Request) *
return response.Empty(rw)
}

// We could technically filter out non ECR registry URLs here and not apply this logic to all the registries
// The cost of going through this logic for all server/registries is to authenticate against IAM RA for each registry
// We could filter non ECR registries based on a URL pattern: https://docs.aws.amazon.com/AmazonECR/latest/userguide/Registries.html
// BUT, to keep support for DNS aliases with ECR registries (e.g. mapping a custom domain such as myregistry.portainer.io to an ECR registry) I've decided to avoid the filter
if handler.awsConfig != nil {
log.Info().Msg("using local AWS config for credential lookup")

c, err := doAWSIAMRolesAnywhereAuthAndGetECRCredentials(serverUrl, handler.awsConfig)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve credentials", err}
}

// Only write credentials if credentials are found
// For non ECR registries, credentials will be set to nil
// Therefore we want to fallback to the default credential lookup
if c != nil {
return response.JSON(rw, c)
}
}

credentials := stackManager.GetEdgeRegistryCredentials()
if len(credentials) > 0 {
var key string
Expand Down Expand Up @@ -75,32 +96,10 @@ func (handler *Handler) LookupHandler(rw http.ResponseWriter, r *http.Request) *
return response.Empty(rw)
}

func LookupCredentials(credentials []agent.RegistryCredentials, serverUrl string) (*agent.RegistryCredentials, error) {
u, err := url.Parse(serverUrl)
if err != nil {
return nil, err
}

var key string
if strings.HasSuffix(u.Hostname(), ".docker.io") {
key = "docker.io"
} else {
key = u.Hostname()
}

for _, c := range credentials {
if key == c.ServerURL {
return &c, nil
}
}

return nil, fmt.Errorf("No credentials found for %s", serverUrl)
}

func StartRegistryServer(edgeManager *edge.Manager) (err error) {
func StartRegistryServer(edgeManager *edge.Manager, awsConfig *agent.AWSConfig) (err error) {
log.Info().Msg("starting registry credential server")

h := NewEdgeRegistryHandler(edgeManager)
h := NewEdgeRegistryHandler(edgeManager, awsConfig)

server := &http.Server{
Addr: "127.0.0.1:9005",
Expand Down
20 changes: 19 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ go 1.18
require (
github.com/Microsoft/go-winio v0.5.1
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
github.com/aws/aws-sdk-go-v2/config v1.18.2
github.com/aws/aws-sdk-go-v2/credentials v1.13.2
github.com/aws/rolesanywhere-credential-helper v1.0.2
github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20221118222346-4177265fa425
github.com/docker/distribution v2.8.1+incompatible
github.com/docker/docker v20.10.16+incompatible
github.com/gorilla/mux v1.8.0
Expand Down Expand Up @@ -39,6 +43,19 @@ require (
github.com/alecthomas/units v0.0.0-20210208195552-ff826a37aa15 // indirect
github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878 // indirect
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 // indirect
github.com/aws/aws-sdk-go v1.44.57 // indirect
github.com/aws/aws-sdk-go-v2 v1.17.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26 // indirect
github.com/aws/aws-sdk-go-v2/service/ecr v1.17.22 // indirect
github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.11.25 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.17.4 // indirect
github.com/aws/smithy-go v1.13.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
Expand Down Expand Up @@ -67,6 +84,7 @@ require (
github.com/hashicorp/go-uuid v1.0.2 // indirect
github.com/hashicorp/memberlist v0.1.4 // indirect
github.com/jaypipes/pcidb v1.0.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/jpillora/sizestr v1.0.0 // indirect
Expand All @@ -83,7 +101,7 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/tidwall/gjson v1.14.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
Expand Down
Loading

0 comments on commit d07dcb4

Please sign in to comment.