diff --git a/api/aws/ecr/authorization_token.go b/api/aws/ecr/authorization_token.go new file mode 100644 index 000000000..3e5968a99 --- /dev/null +++ b/api/aws/ecr/authorization_token.go @@ -0,0 +1,61 @@ +package ecr + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "time" +) + +func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Time, err error) { + getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(context.TODO(), nil) + if err != nil { + return + } + + if len(getAuthorizationTokenOutput.AuthorizationData) == 0 { + err = fmt.Errorf("AuthorizationData is empty") + return + } + + authData := getAuthorizationTokenOutput.AuthorizationData[0] + + token = authData.AuthorizationToken + expiry = authData.ExpiresAt + + return +} + +func (s *Service) GetAuthorizationToken() (token *string, expiry *time.Time, err error) { + tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken() + if err != nil { + return + } + + tokenByte, err := base64.StdEncoding.DecodeString(*tokenEncodedStr) + if err != nil { + return + } + tokenStr := string(tokenByte) + token = &tokenStr + + return +} + +func (s *Service) ParseAuthorizationToken(token string) (username string, password string, err error) { + if len(token) == 0 { + return + } + + splitToken := strings.Split(token, ":") + if len(splitToken) < 2 { + err = fmt.Errorf("invalid ECR authorization token") + return + } + + username = splitToken[0] + password = splitToken[1] + + return +} diff --git a/api/aws/ecr/ecr.go b/api/aws/ecr/ecr.go new file mode 100644 index 000000000..13b58521b --- /dev/null +++ b/api/aws/ecr/ecr.go @@ -0,0 +1,32 @@ +package ecr + +import ( + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/ecr" +) + +type ( + Service struct { + accessKey string + secretKey string + region string + client *ecr.Client + } +) + +func NewService(accessKey, secretKey, region string) *Service { + options := ecr.Options{ + Region: region, + Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), + } + + client := ecr.New(options) + + return &Service{ + accessKey: accessKey, + secretKey: secretKey, + region: region, + client: client, + } +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 4d685a182..7f5305cca 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -105,8 +105,15 @@ func initComposeStackManager(assetsPath string, configPath string, reverseTunnel return composeWrapper } -func initSwarmStackManager(assetsPath string, configPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (portainer.SwarmStackManager, error) { - return exec.NewSwarmStackManager(assetsPath, configPath, signatureService, fileService, reverseTunnelService) +func initSwarmStackManager( + assetsPath string, + configPath string, + signatureService portainer.DigitalSignatureService, + fileService portainer.FileService, + reverseTunnelService portainer.ReverseTunnelService, + dataStore portainer.DataStore, +) (portainer.SwarmStackManager, error) { + return exec.NewSwarmStackManager(assetsPath, configPath, signatureService, fileService, reverseTunnelService, dataStore) } func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, assetsPath string) portainer.KubernetesDeployer { @@ -532,7 +539,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { composeStackManager := initComposeStackManager(*flags.Assets, dockerConfigPath, reverseTunnelService, proxyManager) - swarmStackManager, err := initSwarmStackManager(*flags.Assets, dockerConfigPath, digitalSignatureService, fileService, reverseTunnelService) + swarmStackManager, err := initSwarmStackManager(*flags.Assets, dockerConfigPath, digitalSignatureService, fileService, reverseTunnelService, dataStore) if err != nil { log.Fatalf("failed initializing swarm stack manager: %s", err) } diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index 6872d2555..02aff5834 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -12,6 +12,7 @@ import ( "strings" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/registryutils" "github.com/portainer/portainer/api/internal/stackutils" ) @@ -22,17 +23,25 @@ type SwarmStackManager struct { signatureService portainer.DigitalSignatureService fileService portainer.FileService reverseTunnelService portainer.ReverseTunnelService + dataStore portainer.DataStore } // NewSwarmStackManager initializes a new SwarmStackManager service. // It also updates the configuration of the Docker CLI binary. -func NewSwarmStackManager(binaryPath, configPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (*SwarmStackManager, error) { +func NewSwarmStackManager( + binaryPath, configPath string, + signatureService portainer.DigitalSignatureService, + fileService portainer.FileService, + reverseTunnelService portainer.ReverseTunnelService, + datastore portainer.DataStore, +) (*SwarmStackManager, error) { manager := &SwarmStackManager{ binaryPath: binaryPath, configPath: configPath, signatureService: signatureService, fileService: fileService, reverseTunnelService: reverseTunnelService, + dataStore: datastore, } err := manager.updateDockerCLIConfiguration(manager.configPath) @@ -51,7 +60,17 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin } for _, registry := range registries { if registry.Authentication { - registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL) + err = registryutils.EnsureRegTokenValid(manager.dataStore, ®istry) + if err != nil { + return err + } + + username, password, err := registryutils.GetRegEffectiveCredential(®istry) + if err != nil { + return err + } + + registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL) runCommandAndCaptureStdErr(command, registryArgs, nil, "") } } diff --git a/api/go.mod b/api/go.mod index 2934ad9de..5b2b62994 100644 --- a/api/go.mod +++ b/api/go.mod @@ -6,6 +6,9 @@ require ( github.com/Microsoft/go-winio v0.4.17 github.com/alecthomas/units v0.0.0-20210208195552-ff826a37aa15 // indirect github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 + github.com/aws/aws-sdk-go-v2 v1.11.1 + github.com/aws/aws-sdk-go-v2/credentials v1.6.2 + github.com/aws/aws-sdk-go-v2/service/ecr v1.10.1 github.com/boltdb/bolt v1.3.1 github.com/containerd/containerd v1.5.7 // indirect github.com/coreos/go-semver v0.3.0 diff --git a/api/go.sum b/api/go.sum index 225cecd0d..6cadf65a7 100644 --- a/api/go.sum +++ b/api/go.sum @@ -86,6 +86,22 @@ github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:l github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 h1:4daAzAu0S6Vi7/lbWECcX0j45yZReDZ56BQsrVBOEEY= github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= +github.com/aws/aws-sdk-go-v2 v1.11.1 h1:GzvOVAdTbWxhEMRK4FfiblkGverOkAT0UodDxC1jHQM= +github.com/aws/aws-sdk-go-v2 v1.11.1/go.mod h1:SQfA+m2ltnu1cA0soUkj4dRSsmITiVQUJvBIZjzfPyQ= +github.com/aws/aws-sdk-go-v2/credentials v1.6.2 h1:2faRNX8JgZVy7dDxERkaGBqb/xo5Rgmc8JMPL5j1o58= +github.com/aws/aws-sdk-go-v2/credentials v1.6.2/go.mod h1:8kRH9fthlxHEeNJ3g1N3NTSUMBba+KtTM8hp6SvUWn8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.8.1/go.mod h1:MYiG3oeEcmrdBOV7JOIWhionzyRZJWCnByS5FmvhAoU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.1 h1:LZwqhOyqQ2w64PZk04V0Om9AEExtW8WMkCRoE1h9/94= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.1/go.mod h1:22SEiBSQm5AyKEjoPcG1hzpeTI+m9CXfE6yt1h49wBE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.0.1 h1:ObMfGNk0xjOWduPxsrRWVwZZia3e9fOcO6zlKCkt38s= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.0.1/go.mod h1:1xvCD+I5BcDuQUc+psZr7LI1a9pclAWZs3S3Gce5+lg= +github.com/aws/aws-sdk-go-v2/service/ecr v1.10.1 h1:onTF83DG9dsRv6UzuhYb7phiktjwQ++s/n+ZtNlTQnM= +github.com/aws/aws-sdk-go-v2/service/ecr v1.10.1/go.mod h1:9RH1zeu1Ls3x2EQew/eCDuq2AlC0M8RzYfYy5+5gSLc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.5.1/go.mod h1:fEaHB2bi+wVZw4uKMHEXTL9LwtT4EL//DOhTeflqIVo= +github.com/aws/aws-sdk-go-v2/service/sso v1.6.1/go.mod h1:/73aFBwUl60wKBKhdth2pEOkut5ZNjVHGF9hjXz0bM0= +github.com/aws/aws-sdk-go-v2/service/sts v1.10.1/go.mod h1:+BmlPeQ1Y+PuIho93MMKDby12PoUnt1SZXQdEHCzSlw= +github.com/aws/smithy-go v1.9.0 h1:c7FUdEqrQA1/UVKKCNDFQPNKGp4FQg3YW4Ck5SLTG58= +github.com/aws/smithy-go v1.9.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= @@ -380,6 +396,8 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -443,6 +461,9 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= diff --git a/api/http/handler/registries/registry_configure.go b/api/http/handler/registries/registry_configure.go index d45bbca38..69879e2df 100644 --- a/api/http/handler/registries/registry_configure.go +++ b/api/http/handler/registries/registry_configure.go @@ -21,7 +21,8 @@ type registryConfigurePayload struct { Username string `example:"registry_user"` // Password used to authenticate against this registry. required when Authentication is true Password string `example:"registry_password"` - + // ECR region + Region string // Use TLS TLS bool `example:"true"` // Skip the verification of the server TLS certificate @@ -47,6 +48,9 @@ func (payload *registryConfigurePayload) Validate(r *http.Request) error { password, _ := request.RetrieveMultiPartFormValue(r, "Password", true) payload.Password = password + + region, _ := request.RetrieveMultiPartFormValue(r, "Region", true) + payload.Region = region } useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true) @@ -134,6 +138,10 @@ func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request } else { registry.ManagementConfiguration.Password = payload.Password } + + if payload.Region != "" { + registry.ManagementConfiguration.Ecr.Region = payload.Region + } } if payload.TLS { diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go index 60d50edd1..177c788b8 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -17,8 +17,15 @@ import ( type registryCreatePayload struct { // Name that will be used to identify this registry Name string `example:"my-registry" validate:"required"` - // Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry) or 6 (DockerHub) - Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4,5,6"` + // Registry Type. Valid values are: + // 1 (Quay.io), + // 2 (Azure container registry), + // 3 (custom registry), + // 4 (Gitlab registry), + // 5 (ProGet registry), + // 6 (DockerHub) + // 7 (ECR) + Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4,5,6,7"` // URL or IP address of the Docker registry URL string `example:"registry.mydomain.tld:2375/feed" validate:"required"` // BaseURL required for ProGet registry @@ -33,6 +40,8 @@ type registryCreatePayload struct { Gitlab portainer.GitlabRegistryData // Quay specific details, required when type = 1 Quay portainer.QuayRegistryData + // ECR specific details, required when type = 7 + Ecr portainer.EcrData } func (payload *registryCreatePayload) Validate(_ *http.Request) error { @@ -42,14 +51,22 @@ func (payload *registryCreatePayload) Validate(_ *http.Request) error { if govalidator.IsNull(payload.URL) { return errors.New("Invalid registry URL") } - if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { - return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled") + + if payload.Authentication { + if govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password) { + return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled") + } + if payload.Type == portainer.EcrRegistry { + if govalidator.IsNull(payload.Ecr.Region) { + return errors.New("invalid credentials: access key ID, secret access key and region must be specified when authentication is enabled") + } + } } switch payload.Type { - case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry, portainer.DockerHubRegistry: + case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry, portainer.DockerHubRegistry, portainer.EcrRegistry: default: - return errors.New("invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry) or 6 (DockerHub)") + return errors.New("invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry), 6 (DockerHub), 7 (ECR)") } if payload.Type == portainer.ProGetRegistry && payload.BaseURL == "" { @@ -96,9 +113,10 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) * Authentication: payload.Authentication, Username: payload.Username, Password: payload.Password, - RegistryAccesses: portainer.RegistryAccesses{}, Gitlab: payload.Gitlab, Quay: payload.Quay, + RegistryAccesses: portainer.RegistryAccesses{}, + Ecr: payload.Ecr, } registries, err := handler.DataStore.Registry().Registries() diff --git a/api/http/handler/registries/registry_create_test.go b/api/http/handler/registries/registry_create_test.go index 722301164..9a4399dcf 100644 --- a/api/http/handler/registries/registry_create_test.go +++ b/api/http/handler/registries/registry_create_test.go @@ -33,6 +33,20 @@ func Test_registryCreatePayload_Validate(t *testing.T) { err := payload.Validate(nil) assert.NoError(t, err) }) + t.Run("Can't create a AWS ECR registry if authentication required, but access key ID, secret access key or region is empty", func(t *testing.T) { + payload := basePayload + payload.Type = portainer.EcrRegistry + payload.Authentication = true + err := payload.Validate(nil) + assert.Error(t, err) + }) + t.Run("Do not require access key ID, secret access key, region for public AWS ECR registry", func(t *testing.T) { + payload := basePayload + payload.Type = portainer.EcrRegistry + payload.Authentication = false + err := payload.Validate(nil) + assert.NoError(t, err) + }) } type testRegistryService struct { diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go index 31c2b164b..c39ba3e12 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -2,6 +2,7 @@ package registries import ( "errors" + "github.com/portainer/portainer/api/internal/endpointutils" "net/http" httperror "github.com/portainer/libhttp/error" @@ -26,8 +27,12 @@ type registryUpdatePayload struct { Username *string `example:"registry_user"` // Password used to authenticate against this registry. required when Authentication is true Password *string `example:"registry_password"` - RegistryAccesses *portainer.RegistryAccesses + // Quay data Quay *portainer.QuayRegistryData + // Registry access control + RegistryAccesses *portainer.RegistryAccesses `json:",omitempty"` + // ECR data + Ecr *portainer.EcrData `json:",omitempty"` } func (payload *registryUpdatePayload) Validate(r *http.Request) error { @@ -56,6 +61,7 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} } + if !securityContext.IsAdmin { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update registry", httperrors.ErrResourceAccessDenied} } @@ -82,29 +88,41 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * registry.Name = *payload.Name } - shouldUpdateSecrets := false - if registry.Type == portainer.ProGetRegistry && payload.BaseURL != nil { registry.BaseURL = *payload.BaseURL } + shouldUpdateSecrets := false + if payload.Authentication != nil { + shouldUpdateSecrets = shouldUpdateSecrets || (registry.Authentication != *payload.Authentication) + if *payload.Authentication { registry.Authentication = true - shouldUpdateSecrets = shouldUpdateSecrets || (payload.Username != nil && *payload.Username != registry.Username) || (payload.Password != nil && *payload.Password != registry.Password) if payload.Username != nil { + shouldUpdateSecrets = shouldUpdateSecrets || (registry.Username != *payload.Username) registry.Username = *payload.Username } if payload.Password != nil && *payload.Password != "" { + shouldUpdateSecrets = shouldUpdateSecrets || (registry.Password != *payload.Password) registry.Password = *payload.Password } + if registry.Type == portainer.EcrRegistry && payload.Ecr != nil && payload.Ecr.Region != "" { + shouldUpdateSecrets = shouldUpdateSecrets || (registry.Ecr.Region != payload.Ecr.Region) + registry.Ecr.Region = payload.Ecr.Region + } } else { registry.Authentication = false registry.Username = "" registry.Password = "" + + registry.Ecr.Region = "" + + registry.AccessToken = "" + registry.AccessTokenExpiry = 0 } } @@ -116,6 +134,7 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} } + for _, r := range registries { if r.ID != registry.ID && handler.registriesHaveSameURLAndCredentials(&r, registry) { return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL and credentials already exists", errors.New("A registry is already defined for this URL and credentials")} @@ -124,13 +143,16 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * } if shouldUpdateSecrets { + registry.AccessToken = "" + registry.AccessTokenExpiry = 0 + for endpointID, endpointAccess := range registry.RegistryAccesses { endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update access to registry", err} } - if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + if endpointutils.IsKubernetesEndpoint(endpoint) { err = handler.updateEndpointRegistryAccess(endpoint, registry, endpointAccess) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update access to registry", err} diff --git a/api/http/proxy/factory/docker/registry.go b/api/http/proxy/factory/docker/registry.go index 38f0bd903..3b559ce35 100644 --- a/api/http/proxy/factory/docker/registry.go +++ b/api/http/proxy/factory/docker/registry.go @@ -3,6 +3,7 @@ package docker import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/registryutils" ) type ( @@ -25,13 +26,13 @@ type ( } ) -func createRegistryAuthenticationHeader(registryId portainer.RegistryID, accessContext *registryAccessContext) *registryAuthenticationHeader { - var authenticationHeader *registryAuthenticationHeader - +func createRegistryAuthenticationHeader( + dataStore portainer.DataStore, + registryId portainer.RegistryID, + accessContext *registryAccessContext, +) (authenticationHeader registryAuthenticationHeader, err error) { if registryId == 0 { // dockerhub (anonymous) - authenticationHeader = ®istryAuthenticationHeader{ - Serveraddress: "docker.io", - } + authenticationHeader.Serveraddress = "docker.io" } else { // any "custom" registry var matchingRegistry *portainer.Registry for _, registry := range accessContext.registries { @@ -44,13 +45,14 @@ func createRegistryAuthenticationHeader(registryId portainer.RegistryID, accessC } if matchingRegistry != nil { - authenticationHeader = ®istryAuthenticationHeader{ - Username: matchingRegistry.Username, - Password: matchingRegistry.Password, - Serveraddress: matchingRegistry.URL, + err = registryutils.EnsureRegTokenValid(dataStore, matchingRegistry) + if (err != nil) { + return } + authenticationHeader.Serveraddress = matchingRegistry.URL + authenticationHeader.Username, authenticationHeader.Password, err = registryutils.GetRegEffectiveCredential(matchingRegistry) } } - return authenticationHeader + return } diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index ca2935347..ac3453051 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -414,7 +414,10 @@ func (transport *Transport) replaceRegistryAuthenticationHeader(request *http.Re return nil, err } - authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.RegistryId, accessContext) + authenticationHeader, err := createRegistryAuthenticationHeader(transport.dataStore, originalHeaderData.RegistryId, accessContext) + if err != nil { + return nil, err + } headerData, err := json.Marshal(authenticationHeader) if err != nil { diff --git a/api/http/proxy/factory/kubernetes/deployments.go b/api/http/proxy/factory/kubernetes/deployments.go new file mode 100644 index 000000000..ee8e85e0b --- /dev/null +++ b/api/http/proxy/factory/kubernetes/deployments.go @@ -0,0 +1,15 @@ +package kubernetes + +import ( + "net/http" +) + +func (transport *baseTransport) proxyDeploymentsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) { + switch request.Method { + case http.MethodPost, http.MethodPatch: + transport.refreshRegistry(request, namespace) + return transport.executeKubernetesRequest(request) + default: + return transport.executeKubernetesRequest(request) + } +} \ No newline at end of file diff --git a/api/http/proxy/factory/kubernetes/pods.go b/api/http/proxy/factory/kubernetes/pods.go new file mode 100644 index 000000000..743c716b7 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/pods.go @@ -0,0 +1,15 @@ +package kubernetes + +import ( + "net/http" +) + +func (transport *baseTransport) proxyPodsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) { + switch request.Method { + case "DELETE": + transport.refreshRegistry(request, namespace) + return transport.executeKubernetesRequest(request) + default: + return transport.executeKubernetesRequest(request) + } +} \ No newline at end of file diff --git a/api/http/proxy/factory/kubernetes/refresh_registry.go b/api/http/proxy/factory/kubernetes/refresh_registry.go new file mode 100644 index 000000000..7e9025bfb --- /dev/null +++ b/api/http/proxy/factory/kubernetes/refresh_registry.go @@ -0,0 +1,17 @@ +package kubernetes + +import ( + "github.com/portainer/portainer/api/internal/registryutils" + "net/http" +) + +func (transport *baseTransport) refreshRegistry(request *http.Request, namespace string) (err error) { + cli, err := transport.k8sClientFactory.GetKubeClient(transport.endpoint) + if err != nil { + return + } + + err = registryutils.RefreshEcrSecret(cli, transport.endpoint, transport.dataStore, namespace) + + return +} \ No newline at end of file diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index ffb836fc7..550a22e5f 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -42,7 +42,10 @@ func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, // proxyKubernetesRequest intercepts a Kubernetes API request and apply logic based // on the requested operation. func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*http.Response, error) { - apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/api/v[0-9](\.[0-9])?`) + // URL path examples: + // http://localhost:9000/api/endpoints/3/kubernetes/api/v1/namespaces + // http://localhost:9000/api/endpoints/3/kubernetes/apis/apps/v1/namespaces/default/deployments + apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/(api|apis/apps)/v[0-9](\.[0-9])?`) requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "") switch { @@ -66,6 +69,10 @@ func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fu } switch { + case strings.HasPrefix(requestPath, "pods"): + return transport.proxyPodsRequest(request, namespace, requestPath) + case strings.HasPrefix(requestPath, "deployments"): + return transport.proxyDeploymentsRequest(request, namespace, requestPath) case requestPath == "" && request.Method == "DELETE": return transport.proxyNamespaceDeleteOperation(request, namespace) default: diff --git a/api/internal/registryutils/ecr_kube_secret.go b/api/internal/registryutils/ecr_kube_secret.go new file mode 100644 index 000000000..647b73cec --- /dev/null +++ b/api/internal/registryutils/ecr_kube_secret.go @@ -0,0 +1,49 @@ +package registryutils + +import ( + portainer "github.com/portainer/portainer/api" +) + +func isRegistryAssignedToNamespace(registry portainer.Registry, endpointID portainer.EndpointID, namespace string) (in bool){ + for _, ns := range registry.RegistryAccesses[endpointID].Namespaces { + if ns == namespace { + return true + } + } + + return +} + +func RefreshEcrSecret(cli portainer.KubeClient, endpoint *portainer.Endpoint, dataStore portainer.DataStore, namespace string) (err error) { + registries, err := dataStore.Registry().Registries() + if err != nil { + return + } + + for _, registry := range registries { + if registry.Type != portainer.EcrRegistry { + continue + } + + if !isRegistryAssignedToNamespace(registry, endpoint.ID, namespace) { + continue + } + + err = EnsureRegTokenValid(dataStore, ®istry) + if err != nil { + return + } + + err = cli.DeleteRegistrySecret(®istry, namespace) + if err != nil { + return + } + + err = cli.CreateRegistrySecret(®istry, namespace) + if err != nil { + return + } + } + + return +} diff --git a/api/internal/registryutils/ecr_reg_token.go b/api/internal/registryutils/ecr_reg_token.go new file mode 100644 index 000000000..1bfc8524e --- /dev/null +++ b/api/internal/registryutils/ecr_reg_token.go @@ -0,0 +1,58 @@ +package registryutils + +import ( + "time" + log "github.com/sirupsen/logrus" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/aws/ecr" +) + +func isRegTokenValid(registry *portainer.Registry) (valid bool) { + return registry.AccessToken != "" && registry.AccessTokenExpiry > time.Now().Unix(); +} + +func doGetRegToken(dataStore portainer.DataStore, registry *portainer.Registry) (err error) { + ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region) + accessToken, expiryAt, err := ecrClient.GetAuthorizationToken() + if err != nil { + return + } + + registry.AccessToken = *accessToken + registry.AccessTokenExpiry = expiryAt.Unix() + + err = dataStore.Registry().UpdateRegistry(registry.ID, registry) + + return +} + +func parseRegToken(registry *portainer.Registry) (username, password string, err error) { + ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region) + return ecrClient.ParseAuthorizationToken(registry.AccessToken) +} + +func EnsureRegTokenValid(dataStore portainer.DataStore, registry *portainer.Registry) (err error) { + if registry.Type == portainer.EcrRegistry { + if isRegTokenValid(registry) { + log.Println("[DEBUG] [registry, GetEcrAccessToken] [message: curretn ECR token is still valid]") + } else { + err = doGetRegToken(dataStore, registry) + if err != nil { + log.Println("[DEBUG] [registry, GetEcrAccessToken] [message: refresh ECR token]") + } + } + } + + return +} + +func GetRegEffectiveCredential(registry *portainer.Registry) (username, password string, err error) { + if registry.Type == portainer.EcrRegistry { + username, password, err = parseRegToken(registry) + } else { + username = registry.Username + password = registry.Password + } + return +} diff --git a/api/kubernetes/cli/registries.go b/api/kubernetes/cli/registries.go index 06a4af5fb..b8c7e5278 100644 --- a/api/kubernetes/cli/registries.go +++ b/api/kubernetes/cli/registries.go @@ -8,6 +8,7 @@ import ( "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/registryutils" v1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -15,6 +16,8 @@ import ( const ( secretDockerConfigKey = ".dockerconfigjson" + labelRegistryType = "io.portainer.kubernetes.registry.type" + annotationRegistryID = "portainer.io/registry.id" ) type ( @@ -38,12 +41,17 @@ func (kcl *KubeClient) DeleteRegistrySecret(registry *portainer.Registry, namesp return nil } -func (kcl *KubeClient) CreateRegistrySecret(registry *portainer.Registry, namespace string) error { +func (kcl *KubeClient) CreateRegistrySecret(registry *portainer.Registry, namespace string) (err error) { + username, password, err := registryutils.GetRegEffectiveCredential(registry) + if err != nil { + return + } + config := dockerConfig{ Auths: map[string]registryDockerConfig{ registry.URL: { - Username: registry.Username, - Password: registry.Password, + Username: username, + Password: password, }, }, } @@ -57,8 +65,11 @@ func (kcl *KubeClient) CreateRegistrySecret(registry *portainer.Registry, namesp TypeMeta: metav1.TypeMeta{}, ObjectMeta: metav1.ObjectMeta{ Name: registrySecretName(registry), + Labels: map[string]string{ + labelRegistryType: strconv.Itoa(int(registry.Type)), + }, Annotations: map[string]string{ - "portainer.io/registry.id": strconv.Itoa(int(registry.ID)), + annotationRegistryID: strconv.Itoa(int(registry.ID)), }, }, Data: map[string][]byte{ diff --git a/api/portainer.go b/api/portainer.go index 294f17fc1..7393e7435 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -155,8 +155,8 @@ type ( ImageCount int `json:"ImageCount"` ServiceCount int `json:"ServiceCount"` StackCount int `json:"StackCount"` - NodeCount int `json:"NodeCount"` SnapshotRaw DockerSnapshotRaw `json:"DockerSnapshotRaw"` + NodeCount int `json:"NodeCount"` } // DockerSnapshotRaw represents all the information related to a snapshot as returned by the Docker API @@ -444,6 +444,11 @@ type ( OrganisationName string `json:"OrganisationName"` } + // EcrData represents data required for ECR registry + EcrData struct { + Region string `json:"Region" example:"ap-southeast-2"` + } + // JobType represents a job type JobType int @@ -589,8 +594,8 @@ type ( Registry struct { // Registry Identifier ID RegistryID `json:"Id" example:"1"` - // Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet, 6 - DockerHub) - Type RegistryType `json:"Type" enums:"1,2,3,4,5,6"` + // Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet, 6 - DockerHub, 7 - ECR) + Type RegistryType `json:"Type" enums:"1,2,3,4,5,6,7"` // Registry Name Name string `json:"Name" example:"my-registry"` // URL or IP address of the Docker registry @@ -599,13 +604,14 @@ type ( BaseURL string `json:"BaseURL" example:"registry.mydomain.tld:2375"` // Is authentication against this registry enabled Authentication bool `json:"Authentication" example:"true"` - // Username used to authenticate against this registry + // Username or AccessKeyID used to authenticate against this registry Username string `json:"Username" example:"registry user"` - // Password used to authenticate against this registry + // Password or SecretAccessKey used to authenticate against this registry Password string `json:"Password,omitempty" example:"registry_password"` ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"` Gitlab GitlabRegistryData `json:"Gitlab"` Quay QuayRegistryData `json:"Quay"` + Ecr EcrData `json:"Ecr"` RegistryAccesses RegistryAccesses `json:"RegistryAccesses"` // Deprecated fields @@ -618,6 +624,10 @@ type ( AuthorizedUsers []UserID `json:"AuthorizedUsers"` // Deprecated in DBVersion == 18 AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + + // Stores temporary access token + AccessToken string `json:"AccessToken,omitempty"` + AccessTokenExpiry int64 `json:"AccessTokenExpiry,omitempty"` } RegistryAccesses map[EndpointID]RegistryAccessPolicies @@ -634,11 +644,14 @@ type ( // RegistryManagementConfiguration represents a configuration that can be used to query // the registry API via the registry management extension. RegistryManagementConfiguration struct { - Type RegistryType `json:"Type"` - Authentication bool `json:"Authentication"` - Username string `json:"Username"` - Password string `json:"Password"` - TLSConfig TLSConfiguration `json:"TLSConfig"` + Type RegistryType `json:"Type"` + Authentication bool `json:"Authentication"` + Username string `json:"Username"` + Password string `json:"Password"` + TLSConfig TLSConfiguration `json:"TLSConfig"` + Ecr EcrData `json:"Ecr"` + AccessToken string `json:"AccessToken,omitempty"` + AccessTokenExpiry int64 `json:"AccessTokenExpiry,omitempty"` } // RegistryType represents a type of registry @@ -1714,6 +1727,8 @@ const ( ProGetRegistry // DockerHubRegistry represents a dockerhub registry DockerHubRegistry + // EcrRegistry represents an ECR registry + EcrRegistry ) const ( diff --git a/app/portainer/components/forms/registry-form-aws-ecr/registry-form-ecr.html b/app/portainer/components/forms/registry-form-aws-ecr/registry-form-ecr.html new file mode 100644 index 000000000..abf0c02ea --- /dev/null +++ b/app/portainer/components/forms/registry-form-aws-ecr/registry-form-ecr.html @@ -0,0 +1,143 @@ +
+
+ Important notice +
+
+ + For information on how to generate an Access Key, follow the + AWS guide. + +
+ +
+ ECR connection details +
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+ + +
+ Actions +
+
+
+ +
+
+ +
diff --git a/app/portainer/components/forms/registry-form-aws-ecr/registry-form-ecr.js b/app/portainer/components/forms/registry-form-aws-ecr/registry-form-ecr.js new file mode 100644 index 000000000..cf0b829ca --- /dev/null +++ b/app/portainer/components/forms/registry-form-aws-ecr/registry-form-ecr.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('registryFormEcr', { + templateUrl: './registry-form-ecr.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<', + }, +}); diff --git a/app/portainer/models/registry.js b/app/portainer/models/registry.js index a9effdc33..22174f411 100644 --- a/app/portainer/models/registry.js +++ b/app/portainer/models/registry.js @@ -14,6 +14,7 @@ export function RegistryViewModel(data) { this.Checked = false; this.Gitlab = data.Gitlab; this.Quay = data.Quay; + this.Ecr = data.Ecr; } export function RegistryManagementConfigurationDefaultModel(registry) { @@ -25,7 +26,12 @@ export function RegistryManagementConfigurationDefaultModel(registry) { this.TLSCertFile = null; this.TLSKeyFile = null; - if (registry.Type === RegistryTypes.QUAY || registry.Type === RegistryTypes.AZURE) { + if (registry.Type === RegistryTypes.ECR) { + this.Region = registry.Ecr.Region; + this.TLSSkipVerify = true; + } + + if (registry.Type === RegistryTypes.QUAY || registry.Type === RegistryTypes.AZURE || registry.Type === RegistryTypes.ECR) { this.Authentication = true; this.Username = registry.Username; this.TLS = true; @@ -63,6 +69,9 @@ export function RegistryCreateRequest(model) { ProjectPath: model.Gitlab.ProjectPath, }; } + if (model.Type === RegistryTypes.ECR) { + this.Ecr = model.Ecr; + } if (model.Type === RegistryTypes.QUAY) { this.Quay = { useOrganisation: model.Quay.useOrganisation, diff --git a/app/portainer/models/registryTypes.js b/app/portainer/models/registryTypes.js index ef5ded0ae..1d4057aec 100644 --- a/app/portainer/models/registryTypes.js +++ b/app/portainer/models/registryTypes.js @@ -6,4 +6,5 @@ export const RegistryTypes = Object.freeze({ GITLAB: 4, PROGET: 5, DOCKERHUB: 6, + ECR: 7, }); diff --git a/app/portainer/views/registries/create/createRegistry.html b/app/portainer/views/registries/create/createRegistry.html index aab5b0bdd..98b7e6384 100644 --- a/app/portainer/views/registries/create/createRegistry.html +++ b/app/portainer/views/registries/create/createRegistry.html @@ -26,6 +26,16 @@

DockerHub authenticated account

+
+ + +
+
@@ -43,11 +44,14 @@
+
- +
@@ -55,7 +59,9 @@
- +
@@ -87,6 +93,24 @@
+
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+