From ffa9e91854c63b12e701a3d9051d57800e2103e2 Mon Sep 17 00:00:00 2001 From: Petu Eusebiu Date: Thu, 21 Jul 2022 17:48:18 +0300 Subject: [PATCH] feat: added support for changing config via API closes #268, closes #698 Signed-off-by: Petu Eusebiu --- Makefile | 6 +- examples/config-allextensions.json | 23 +- examples/config-anonymous-authz.json | 46 +- examples/config-cfg-extension.json | 36 ++ examples/config-policy.json | 228 ++++----- examples/config-tls.json | 4 +- go.mod | 2 +- pkg/api/authn.go | 4 +- pkg/api/authz.go | 14 +- pkg/api/config/config.go | 58 +-- pkg/api/config/loader.go | 180 ++++++++ pkg/api/config/validator.go | 412 +++++++++++++++++ pkg/api/constants/extensions.go | 1 + pkg/api/controller.go | 71 ++- pkg/api/controller_test.go | 106 ++--- pkg/api/routes.go | 3 +- pkg/cli/config_reloader.go | 124 +++-- pkg/cli/config_reloader_test.go | 340 +++++++++++--- pkg/cli/extensions_test.go | 27 +- pkg/cli/root.go | 486 ++------------------ pkg/cli/root_test.go | 248 +++++++--- pkg/extensions/config/config.go | 15 +- pkg/extensions/extension-config-disabled.go | 19 + pkg/extensions/extension-config.go | 88 ++++ pkg/extensions/extensions_test.go | 291 +++++++++++- pkg/extensions/search/common/common_test.go | 4 +- pkg/extensions/sync/on_demand.go | 2 +- pkg/extensions/sync/sync.go | 3 +- pkg/extensions/sync/sync_internal_test.go | 6 +- pkg/extensions/sync/sync_test.go | 121 ----- pkg/extensions/sync/utils.go | 2 +- test/blackbox/anonymous_policiy.bats | 30 +- 32 files changed, 1919 insertions(+), 1081 deletions(-) create mode 100644 examples/config-cfg-extension.json create mode 100644 pkg/api/config/loader.go create mode 100644 pkg/api/config/validator.go create mode 100644 pkg/extensions/extension-config-disabled.go create mode 100644 pkg/extensions/extension-config.go diff --git a/Makefile b/Makefile index da31dbfdd9..42d5e2f39e 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ TESTDATA := $(TOP_LEVEL)/test/data OS ?= linux ARCH ?= amd64 BENCH_OUTPUT ?= stdout -EXTENSIONS ?= sync,search,scrub,metrics,lint +EXTENSIONS ?= sync,search,scrub,metrics,lint,config comma:= , hyphen:= - extended-name:= @@ -86,7 +86,7 @@ privileged-test: check-skopeo $(TESTDATA) $(NOTATION) go test -failfast -tags needprivileges,$(EXTENSIONS),containers_image_openpgp -v -trimpath -race -timeout 15m -cover -coverpkg ./... -coverprofile=coverage-dev-needprivileges.txt -covermode=atomic ./pkg/storage/... ./pkg/cli/... -run ^TestElevatedPrivileges $(TESTDATA): check-skopeo - $(shell mkdir -p ${TESTDATA}; cd ${TESTDATA}; mkdir -p noidentity; ../scripts/gen_certs.sh; cd ${TESTDATA}/noidentity; ../../scripts/gen_nameless_certs.sh; cd ${TOP_LEVEL}; skopeo --insecure-policy copy -q docker://public.ecr.aws/t0x7q1g8/centos:7 oci:${TESTDATA}/zot-test:0.0.1;skopeo --insecure-policy copy -q docker://public.ecr.aws/t0x7q1g8/centos:8 oci:${TESTDATA}/zot-cve-test:0.0.1) + $(shell mkdir -p ${TESTDATA}; cd ${TESTDATA}; touch htpasswd; mkdir -p noidentity; ../scripts/gen_certs.sh; cd ${TESTDATA}/noidentity; ../../scripts/gen_nameless_certs.sh; cd ${TOP_LEVEL}; skopeo --insecure-policy copy -q docker://public.ecr.aws/t0x7q1g8/centos:7 oci:${TESTDATA}/zot-test:0.0.1;skopeo --insecure-policy copy -q docker://public.ecr.aws/t0x7q1g8/centos:8 oci:${TESTDATA}/zot-cve-test:0.0.1) $(shell chmod -R a=rwx ${TESTDATA}) .PHONY: run-bench @@ -198,7 +198,7 @@ run: binary test verify-config: _verify-config verify-config-warnings verify-config-commited .PHONY: _verify-config -_verify-config: binary +_verify-config: binary $(TESTDATA) rm -f output.txt $(foreach file, $(wildcard examples/config-*), ./bin/zot-$(OS)-$(ARCH) verify $(file) 2>&1 | tee -a output.txt || exit 1;) diff --git a/examples/config-allextensions.json b/examples/config-allextensions.json index 1f9bd49177..fdc336cfc3 100644 --- a/examples/config-allextensions.json +++ b/examples/config-allextensions.json @@ -5,12 +5,33 @@ }, "http": { "address": "127.0.0.1", - "port": "8080" + "port": "8080", + "auth": { + "htpasswd": { + "path": "test/data/htpasswd" + } + }, + "accesscontrol": { + "adminpolicy": { + "actions": [ + "read", + "create", + "update", + "delete" + ], + "users": [ + "admin" + ] + } + } }, "log": { "level": "debug" }, "extensions": { + "sysconfig": { + "enable": true + }, "metrics": {}, "sync": { "credentialsFile": "./examples/sync-auth-filepath.json", diff --git a/examples/config-anonymous-authz.json b/examples/config-anonymous-authz.json index f530e3cdf6..42ea6af4cb 100644 --- a/examples/config-anonymous-authz.json +++ b/examples/config-anonymous-authz.json @@ -8,28 +8,30 @@ "port": "8080", "realm": "zot", "accessControl": { - "**": { - "anonymousPolicy": [ - "read", - "create" - ] - }, - "tmp/**": { - "anonymousPolicy": [ - "read", - "create", - "update" - ] - }, - "infra/**": { - "anonymousPolicy": [ - "read" - ] - }, - "repos2/repo": { - "anonymousPolicy": [ - "read" - ] + "repositories": { + "**": { + "anonymousPolicy": [ + "read", + "create" + ] + }, + "tmp/**": { + "anonymousPolicy": [ + "read", + "create", + "update" + ] + }, + "infra/**": { + "anonymousPolicy": [ + "read" + ] + }, + "repos2/repo": { + "anonymousPolicy": [ + "read" + ] + } } } }, diff --git a/examples/config-cfg-extension.json b/examples/config-cfg-extension.json new file mode 100644 index 0000000000..90f04c1be1 --- /dev/null +++ b/examples/config-cfg-extension.json @@ -0,0 +1,36 @@ +{ + "distspecversion": "1.0.1-dev", + "extensions": { + "sysconfig": { + "enable": true + } + }, + "http": { + "accesscontrol": { + "adminpolicy": { + "actions": [ + "read", + "create", + "update", + "delete" + ], + "users": [ + "admin" + ] + } + }, + "address": "127.0.0.1", + "auth": { + "htpasswd": { + "path": "test/data/htpasswd" + } + }, + "port": "5000" + }, + "log": { + "level": "debug" + }, + "storage": { + "rootdirectory": "/tmp/zot" + } +} diff --git a/examples/config-policy.json b/examples/config-policy.json index a23931770b..b9f254ac48 100644 --- a/examples/config-policy.json +++ b/examples/config-policy.json @@ -1,113 +1,115 @@ -{ - "distSpecVersion": "1.0.1-dev", - "storage": { - "rootDirectory": "/tmp/zot" - }, - "http": { - "address": "127.0.0.1", - "port": "8080", - "realm": "zot", - "auth": { - "htpasswd": { - "path": "test/data/htpasswd" - }, - "failDelay": 1 - }, - "accessControl": { - "**": { - "anonymousPolicy": ["read"], - "policies": [ - { - "users": [ - "charlie" - ], - "actions": [ - "read", - "create", - "update" - ] - } - ], - "defaultPolicy": [ - "read", - "create" - ] - }, - "tmp/**": { - "defaultPolicy": [ - "read", - "create", - "update" - ] - }, - "infra/**": { - "policies": [ - { - "users": [ - "alice", - "bob" - ], - "actions": [ - "create", - "read", - "update", - "delete" - ] - }, - { - "users": [ - "mallory" - ], - "actions": [ - "create", - "read" - ] - } - ], - "defaultPolicy": [ - "read" - ] - }, - "repos2/repo": { - "policies": [ - { - "users": [ - "charlie" - ], - "actions": [ - "read", - "create" - ] - }, - { - "users": [ - "mallory" - ], - "actions": [ - "create", - "read" - ] - } - ], - "defaultPolicy": [ - "read" - ] - }, - "adminPolicy": { - "users": [ - "admin" - ], - "actions": [ - "read", - "create", - "update", - "delete" - ] - } - } - }, - "log": { - "level": "debug", - "output": "/tmp/zot.log" - } -} +{ + "distSpecVersion": "1.0.1-dev", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "realm": "zot", + "auth": { + "htpasswd": { + "path": "test/data/htpasswd" + }, + "failDelay": 1 + }, + "accessControl": { + "repositories": { + "**": { + "anonymousPolicy": ["read"], + "policies": [ + { + "users": [ + "charlie" + ], + "actions": [ + "read", + "create", + "update" + ] + } + ], + "defaultPolicy": [ + "read", + "create" + ] + }, + "tmp/**": { + "defaultPolicy": [ + "read", + "create", + "update" + ] + }, + "infra/**": { + "policies": [ + { + "users": [ + "alice", + "bob" + ], + "actions": [ + "create", + "read", + "update", + "delete" + ] + }, + { + "users": [ + "mallory" + ], + "actions": [ + "create", + "read" + ] + } + ], + "defaultPolicy": [ + "read" + ] + }, + "repos2/repo": { + "policies": [ + { + "users": [ + "charlie" + ], + "actions": [ + "read", + "create" + ] + }, + { + "users": [ + "mallory" + ], + "actions": [ + "create", + "read" + ] + } + ], + "defaultPolicy": [ + "read" + ] + } + }, + "adminPolicy": { + "users": [ + "admin" + ], + "actions": [ + "read", + "create", + "update", + "delete" + ] + } + } + }, + "log": { + "level": "debug", + "output": "/tmp/zot.log" + } +} diff --git a/examples/config-tls.json b/examples/config-tls.json index 901a166b10..6ebfa697c5 100644 --- a/examples/config-tls.json +++ b/examples/config-tls.json @@ -8,8 +8,8 @@ "port": "8080", "realm": "zot", "tls": { - "cert": "../../test/data/server.cert", - "key": "../../test/data/server.key" + "cert": "test/data/server.cert", + "key": "test/data/server.key" } }, "log": { diff --git a/go.mod b/go.mod index 832f079e66..a207017df4 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,6 @@ require ( github.com/gofrs/uuid v4.3.0+incompatible github.com/google/go-containerregistry v0.12.0 github.com/google/uuid v1.3.0 - github.com/gorilla/handlers v1.5.1 github.com/gorilla/mux v1.8.0 github.com/json-iterator/go v1.1.12 github.com/minio/sha256-simd v1.0.0 @@ -53,6 +52,7 @@ require ( require ( github.com/aquasecurity/trivy v0.0.0-00010101000000-000000000000 github.com/containers/image/v5 v5.23.0 + github.com/gorilla/handlers v1.5.1 github.com/notaryproject/notation-go v0.11.0-alpha.4 github.com/opencontainers/distribution-spec/specs-go v0.0.0-20220620172159-4ab4752c3b86 github.com/sigstore/cosign v1.13.0 diff --git a/pkg/api/authn.go b/pkg/api/authn.go index 7ad0d26ead..6c61a3a86a 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -181,7 +181,7 @@ func basicAuthHandler(ctlr *Controller) mux.MiddlewareFunc { return } - if request.Header.Get("Authorization") == "" && anonymousPolicyExists(ctlr.Config.AccessControl) { + if request.Header.Get("Authorization") == "" && anonymousPolicyExists(ctlr.Config.HTTP.AccessControl) { // Process request next.ServeHTTP(response, request) @@ -198,7 +198,7 @@ func basicAuthHandler(ctlr *Controller) mux.MiddlewareFunc { // some client tools might send Authorization: Basic Og== (decoded into ":") // empty username and password - if username == "" && passphrase == "" && anonymousPolicyExists(ctlr.Config.AccessControl) { + if username == "" && passphrase == "" && anonymousPolicyExists(ctlr.Config.HTTP.AccessControl) { // Process request next.ServeHTTP(response, request) diff --git a/pkg/api/authz.go b/pkg/api/authz.go index 4204007de4..5bf3c1fde9 100644 --- a/pkg/api/authz.go +++ b/pkg/api/authz.go @@ -33,7 +33,7 @@ type AccessController struct { func NewAccessController(config *config.Config) *AccessController { return &AccessController{ - Config: config.AccessControl, + Config: config.HTTP.AccessControl, Log: log.NewLogger(config.Log.Level, config.Log.Output), } } @@ -229,6 +229,18 @@ func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc { return } + if request.RequestURI == constants.ExtConfigPrefix { + if acCtrlr.isAdmin(identity) { + next.ServeHTTP(response, request) + + return + } + + authzFail(response, ctlr.Config.HTTP.Realm, ctlr.Config.HTTP.Auth.FailDelay) + + return + } + var action string if request.Method == http.MethodGet || request.Method == http.MethodHead { action = READ diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index 117fef574b..d57a4a5a4b 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -1,13 +1,11 @@ package config import ( - "fmt" "os" "time" "github.com/getlantern/deepcopy" distspec "github.com/opencontainers/distribution-spec/specs-go" - "github.com/spf13/viper" extconf "zotregistry.io/zot/pkg/extensions/config" "zotregistry.io/zot/pkg/storage" @@ -64,14 +62,14 @@ type RatelimitConfig struct { } type HTTPConfig struct { - Address string - Port string - AllowOrigin string // comma separated - TLS *TLSConfig - Auth *AuthConfig - RawAccessControl map[string]interface{} `mapstructure:"accessControl,omitempty"` - Realm string - Ratelimit *RatelimitConfig `mapstructure:",omitempty"` + Address string + Port string + AllowOrigin string // comma separated + TLS *TLSConfig + Auth *AuthConfig + AccessControl *AccessControlConfig + Realm string + Ratelimit *RatelimitConfig `mapstructure:",omitempty"` } type LDAPConfig struct { @@ -129,7 +127,6 @@ type Config struct { Commit string ReleaseTag string BinaryType string - AccessControl *AccessControlConfig Storage GlobalStorageConfig HTTP HTTPConfig Log *LogConfig @@ -189,42 +186,3 @@ func (c *Config) Sanitize() *Config { return sanitizedConfig } - -// LoadAccessControlConfig populates config.AccessControl struct with values from config. -func (c *Config) LoadAccessControlConfig(viperInstance *viper.Viper) error { - if c.HTTP.RawAccessControl == nil { - return nil - } - - c.AccessControl = &AccessControlConfig{} - c.AccessControl.Repositories = make(map[string]PolicyGroup) - - for policy := range c.HTTP.RawAccessControl { - var policies []Policy - - var policyGroup PolicyGroup - - if policy == "adminpolicy" { - adminPolicy := viperInstance.GetStringMapStringSlice("http::accessControl::adminPolicy") - c.AccessControl.AdminPolicy.Actions = adminPolicy["actions"] - c.AccessControl.AdminPolicy.Users = adminPolicy["users"] - - continue - } - - err := viperInstance.UnmarshalKey(fmt.Sprintf("http::accessControl::%s::policies", policy), &policies) - if err != nil { - return err - } - - defaultPolicy := viperInstance.GetStringSlice(fmt.Sprintf("http::accessControl::%s::defaultPolicy", policy)) - policyGroup.DefaultPolicy = defaultPolicy - - anonymousPolicy := viperInstance.GetStringSlice(fmt.Sprintf("http::accessControl::%s::anonymousPolicy", policy)) - policyGroup.Policies = policies - policyGroup.AnonymousPolicy = anonymousPolicy - c.AccessControl.Repositories[policy] = policyGroup - } - - return nil -} diff --git a/pkg/api/config/loader.go b/pkg/api/config/loader.go new file mode 100644 index 0000000000..a129f36a63 --- /dev/null +++ b/pkg/api/config/loader.go @@ -0,0 +1,180 @@ +package config + +import ( + "io" + "time" + + "github.com/mitchellh/mapstructure" + "github.com/rs/zerolog/log" + "github.com/spf13/viper" + + "zotregistry.io/zot/errors" + "zotregistry.io/zot/pkg/api/constants" + extconf "zotregistry.io/zot/pkg/extensions/config" +) + +func LoadFromBufferWithWriter(configPath string, in io.Reader, config *Config) (func() error, error) { + viperInstance := viper.NewWithOptions(viper.KeyDelimiter("::")) + viperInstance.SetConfigFile(configPath) + + if err := viperInstance.ReadConfig(in); err != nil { + log.Error().Err(err).Msg("error while reading configuration") + + return nil, err + } + + if err := unmarshal(viperInstance, config); err != nil { + return nil, err + } + + // defaults + applyDefaultValues(config, viperInstance) + + if err := Validate(config); err != nil { + log.Error().Err(err).Msg("config is not valid") + + return nil, err + } + + writer := viperInstance.WriteConfig + + return writer, nil +} + +func LoadFromFile(configPath string, config *Config) error { + viperInstance := viper.NewWithOptions(viper.KeyDelimiter("::")) + viperInstance.SetConfigFile(configPath) + + return loadFromFile(viperInstance, config) +} + +func loadFromFile(viperInstance *viper.Viper, config *Config) error { + if err := viperInstance.ReadInConfig(); err != nil { + log.Error().Err(err).Msg("error while reading configuration") + + return err + } + + if err := unmarshal(viperInstance, config); err != nil { + return err + } + + // defaults + applyDefaultValues(config, viperInstance) + + if err := Validate(config); err != nil { + log.Error().Err(err).Msg("config is not valid") + + return err + } + + return nil +} + +func unmarshal(viperInstance *viper.Viper, config *Config) error { + metaData := &mapstructure.Metadata{} + if err := viperInstance.Unmarshal(&config, metadataConfig(metaData)); err != nil { + log.Error().Err(err).Msg("error while unmarshalling new config") + + return err + } + + if len(metaData.Keys) == 0 { + log.Error().Err(errors.ErrBadConfig).Interface("config", config).Msgf("config doesn't contain any key:value pair") + + return errors.ErrBadConfig + } + + if len(metaData.Unused) > 0 { + log.Error().Err(errors.ErrBadConfig).Msgf("unknown keys: %v", metaData.Unused) + + return errors.ErrBadConfig + } + + return nil +} + +func metadataConfig(md *mapstructure.Metadata) viper.DecoderConfigOption { + return func(c *mapstructure.DecoderConfig) { + c.Metadata = md + } +} + +func applyDefaultValues(config *Config, viperInstance *viper.Viper) { + defaultVal := true + + if config.Extensions == nil && viperInstance.Get("extensions") != nil { + config.Extensions = &extconf.ExtensionConfig{} + + extMap := viperInstance.GetStringMap("extensions") + _, ok := extMap["metrics"] + + if ok { + // we found a config like `"extensions": {"metrics": {}}` + // Note: In case metrics is not empty the config.Extensions will not be nil and we will not reach here + config.Extensions.Metrics = &extconf.MetricsConfig{} + } + + _, ok = extMap["search"] + if ok { + // we found a config like `"extensions": {"search": {}}` + // Note: In case search is not empty the config.Extensions will not be nil and we will not reach here + config.Extensions.Search = &extconf.SearchConfig{} + } + + _, ok = extMap["scrub"] + if ok { + // we found a config like `"extensions": {"scrub:": {}}` + // Note: In case scrub is not empty the config.Extensions will not be nil and we will not reach here + config.Extensions.Scrub = &extconf.ScrubConfig{} + } + } + + if config.Extensions != nil { + if config.Extensions.Sync != nil { + if config.Extensions.Sync.Enable == nil { + config.Extensions.Sync.Enable = &defaultVal + } + + for id, regCfg := range config.Extensions.Sync.Registries { + if regCfg.TLSVerify == nil { + config.Extensions.Sync.Registries[id].TLSVerify = &defaultVal + } + } + } + + if config.Extensions.Search != nil { + if config.Extensions.Search.Enable == nil { + config.Extensions.Search.Enable = &defaultVal + } + + if config.Extensions.Search.CVE == nil { + config.Extensions.Search.CVE = &extconf.CVEConfig{UpdateInterval: 24 * time.Hour} //nolint: gomnd + } + } + + if config.Extensions.Metrics != nil { + if config.Extensions.Metrics.Enable == nil { + config.Extensions.Metrics.Enable = &defaultVal + } + + if config.Extensions.Metrics.Prometheus == nil { + config.Extensions.Metrics.Prometheus = &extconf.PrometheusConfig{Path: constants.DefaultMetricsExtensionRoute} + } + } + + if config.Extensions.Scrub != nil { + if config.Extensions.Scrub.Enable == nil { + config.Extensions.Scrub.Enable = &defaultVal + } + + if config.Extensions.Scrub.Interval == 0 { + config.Extensions.Scrub.Interval = 24 * time.Hour //nolint: gomnd + } + } + } + + if !config.Storage.GC && viperInstance.Get("storage::gcdelay") == nil { + config.Storage.GCDelay = 0 + } +} diff --git a/pkg/api/config/validator.go b/pkg/api/config/validator.go new file mode 100644 index 0000000000..8c9a69d92e --- /dev/null +++ b/pkg/api/config/validator.go @@ -0,0 +1,412 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + + glob "github.com/bmatcuk/doublestar/v4" + distspec "github.com/opencontainers/distribution-spec/specs-go" + "github.com/rs/zerolog/log" + + "zotregistry.io/zot/errors" + "zotregistry.io/zot/pkg/extensions/sync" + "zotregistry.io/zot/pkg/storage" + "zotregistry.io/zot/pkg/storage/local" +) + +func Validate(config *Config) error { + if err := validateHTTP(config); err != nil { + return err + } + + if err := validateGC(config); err != nil { + return err + } + + if err := validateLDAP(config); err != nil { + return err + } + + if err := validateSync(config); err != nil { + return err + } + + if err := validateStorageConfig(config); err != nil { + return err + } + + if err := validateLocalStorage(config.Storage.RootDirectory); err != nil { + return err + } + + if config.Storage.SubPaths != nil { + for _, subPath := range config.Storage.SubPaths { + if err := validateLocalStorage(subPath.RootDirectory); err != nil { + return err + } + } + } + + if err := validateHtpasswd(config); err != nil { + return err + } + + if err := validateTLSCerts(config); err != nil { + return err + } + + // check authorization config, it should have basic auth enabled or ldap + if config.HTTP.AccessControl != nil { + // checking for anonymous policy only authorization config: no users, no policies but anonymous policy + if err := validateAuthzPolicies(config); err != nil { + return err + } + } + + if err := validateConfigExtensions(config); err != nil { + return err + } + + if err := validateRemoteStorage(config); err != nil { + return err + } + + // check glob patterns in authz config are compilable + if config.HTTP.AccessControl != nil { + for pattern := range config.HTTP.AccessControl.Repositories { + ok := glob.ValidatePattern(pattern) + if !ok { + log.Error().Err(glob.ErrBadPattern).Str("pattern", pattern).Msg("authorization pattern could not be compiled") + + return glob.ErrBadPattern + } + } + } + + updateDistSpecVersion(config) + + return nil +} + +func updateDistSpecVersion(config *Config) { + if config.DistSpecVersion == distspec.Version { + return + } + + log.Warn().Msgf("config dist-spec version: %s differs from version actually used: %s", + config.DistSpecVersion, distspec.Version) + + config.DistSpecVersion = distspec.Version +} + +func validateLDAP(config *Config) error { + // LDAP mandatory configuration + if config.HTTP.Auth != nil && config.HTTP.Auth.LDAP != nil { + ldap := config.HTTP.Auth.LDAP + if ldap.UserAttribute == "" { + log.Error().Str("userAttribute", ldap.UserAttribute). + Msg("invalid LDAP configuration, missing mandatory key: userAttribute") + + return errors.ErrLDAPConfig + } + + if ldap.Address == "" { + log.Error().Str("address", ldap.Address). + Msg("invalid LDAP configuration, missing mandatory key: address") + + return errors.ErrLDAPConfig + } + + if ldap.BaseDN == "" { + log.Error().Str("basedn", ldap.BaseDN). + Msg("invalid LDAP configuration, missing mandatory key: basedn") + + return errors.ErrLDAPConfig + } + } + + return nil +} + +func validateGC(config *Config) error { + // enforce GC params + if config.Storage.GCDelay < 0 { + log.Error().Err(errors.ErrBadConfig). + Msgf("invalid garbage-collect delay %v specified", config.Storage.GCDelay) + + return errors.ErrBadConfig + } + + if config.Storage.GCInterval < 0 { + log.Error().Err(errors.ErrBadConfig). + Msgf("invalid garbage-collect interval %v specified", config.Storage.GCInterval) + + return errors.ErrBadConfig + } + + if !config.Storage.GC { + if config.Storage.GCDelay != 0 { + log.Warn().Err(errors.ErrBadConfig). + Msg("garbage-collect delay specified without enabling garbage-collect, will be ignored") + } + + if config.Storage.GCInterval != 0 { + log.Warn().Err(errors.ErrBadConfig). + Msg("periodic garbage-collect interval specified without enabling garbage-collect, will be ignored") + } + } + + return nil +} + +func validateSync(config *Config) error { + // check glob patterns in sync config are compilable + if config.Extensions != nil && config.Extensions.Sync != nil { + if config.Extensions.Sync.CredentialsFile != "" { + _, err := sync.GetFileCredentials(config.Extensions.Sync.CredentialsFile) + if err != nil { + log.Error().Err(err).Msg("sync: couldn't read credentials file") + + return err + } + } + + for id, regCfg := range config.Extensions.Sync.Registries { + // check retry options are configured for sync + if regCfg.MaxRetries != nil && regCfg.RetryDelay == nil { + log.Error().Err(errors.ErrBadConfig).Msgf("extensions.sync.registries[%d].retryDelay"+ + " is required when using extensions.sync.registries[%d].maxRetries", id, id) + + return errors.ErrBadConfig + } + + if regCfg.Content != nil { + for _, content := range regCfg.Content { + ok := glob.ValidatePattern(content.Prefix) + if !ok { + log.Error().Err(glob.ErrBadPattern).Str("pattern", content.Prefix).Msg("sync pattern could not be compiled") + + return glob.ErrBadPattern + } + + if content.StripPrefix && !strings.Contains(content.Prefix, "/*") && content.Destination == "/" { + log.Error().Err(errors.ErrBadConfig). + Interface("sync content", content). + Msg("sync config: can not use stripPrefix true and destination '/' without using glob patterns in prefix") + + return errors.ErrBadConfig + } + } + } + } + } + + return nil +} + +func validateAuthzPolicies(config *Config) error { + if (config.HTTP.Auth == nil || (config.HTTP.Auth.HTPasswd.Path == "" && config.HTTP.Auth.LDAP == nil)) && + !authzContainsOnlyAnonymousPolicy(config) { + log.Error().Err(errors.ErrBadConfig). + Msg("access control config requires httpasswd, ldap authentication " + + "or using only 'anonymousPolicy' policies") + + return errors.ErrBadConfig + } + + return nil +} + +func authzContainsOnlyAnonymousPolicy(cfg *Config) bool { + adminPolicy := cfg.HTTP.AccessControl.AdminPolicy + anonymousPolicyPresent := false + + log.Info().Msg("checking if anonymous authorization is the only type of authorization policy configured") + + if len(adminPolicy.Actions)+len(adminPolicy.Users) > 0 { + log.Info().Msg("admin policy detected, anonymous authorization is not the only authorization policy configured") + + return false + } + + for _, repository := range cfg.HTTP.AccessControl.Repositories { + if len(repository.DefaultPolicy) > 0 { + log.Info().Interface("repository", repository). + Msg("default policy detected, anonymous authorization is not the only authorization policy configured") + + return false + } + + if len(repository.AnonymousPolicy) > 0 { + log.Info().Msg("anonymous authorization detected") + + anonymousPolicyPresent = true + } + + for _, policy := range repository.Policies { + if len(policy.Actions)+len(policy.Users) > 0 { + log.Info().Interface("repository", repository). + Msg("repository with non-empty policy detected, " + + "anonymous authorization is not the only authorization policy configured") + + return false + } + } + } + + return anonymousPolicyPresent +} + +func validateLocalStorage(rootDir string) error { + if _, err := os.Stat(rootDir); os.IsNotExist(err) { + if err := os.MkdirAll(rootDir, local.DefaultDirPerms); err != nil { + log.Error().Err(err).Str("rootDir", rootDir).Msg("unable to create root dir") + + return err + } + } + + return nil +} + +func validateRemoteStorage(config *Config) error { + if len(config.Storage.StorageDriver) != 0 { + // enforce s3 driver in case of using storage driver + if config.Storage.StorageDriver["name"] != storage.S3StorageDriverName { + log.Error().Err(errors.ErrBadConfig).Msgf("unsupported storage driver: %s", config.Storage.StorageDriver["name"]) + + return errors.ErrBadConfig + } + + // enforce filesystem storage in case sync feature is enabled + if config.Extensions != nil && config.Extensions.Sync != nil { + log.Error().Err(errors.ErrBadConfig).Msg("sync supports only filesystem storage") + + return errors.ErrBadConfig + } + } + + // enforce s3 driver on subpaths in case of using storage driver + if config.Storage.SubPaths != nil { + if len(config.Storage.SubPaths) > 0 { + subPaths := config.Storage.SubPaths + + for route, storageConfig := range subPaths { + if len(storageConfig.StorageDriver) != 0 { + if storageConfig.StorageDriver["name"] != storage.S3StorageDriverName { + log.Error().Err(errors.ErrBadConfig).Str("subpath", + route).Msgf("unsupported storage driver: %s", storageConfig.StorageDriver["name"]) + + return errors.ErrBadConfig + } + } + } + } + } + + return nil +} + +func validateHtpasswd(config *Config) error { + if config.HTTP.Auth != nil && config.HTTP.Auth.HTPasswd.Path != "" { + _, err := os.Stat(config.HTTP.Auth.HTPasswd.Path) + if err != nil { + log.Error().Err(err).Str("path", config.HTTP.Auth.HTPasswd.Path).Msg("authn: couldn't read htpasswd file") + + return err + } + } + + return nil +} + +func validateHTTP(config *Config) error { + if config.HTTP.Port != "" { + port, err := strconv.ParseInt(config.HTTP.Port, 10, 64) + if err != nil || (port < 0 || port > 65535) { + log.Error().Str("port", config.HTTP.Port).Msg("invalid port") + + return errors.ErrBadConfig + } + + fmt.Printf("HTTP port %d\n", port) + } + + return nil +} + +func validateTLSCerts(config *Config) error { + if config.HTTP.TLS != nil { + if config.HTTP.TLS.CACert != "" { + _, err := os.Open(config.HTTP.TLS.CACert) + if err != nil { + log.Error().Err(err).Str("path", config.HTTP.TLS.CACert).Msg("authn: couldn't read TLS cacert file") + + return err + } + } + + if config.HTTP.TLS.Cert != "" { + _, err := os.Open(config.HTTP.TLS.Cert) + if err != nil { + log.Error().Err(err).Str("path", config.HTTP.TLS.Cert).Msg("authn: couldn't read TLS cert file") + + return err + } + } + + if config.HTTP.TLS.Key != "" { + _, err := os.Open(config.HTTP.TLS.Key) + if err != nil { + log.Error().Err(err).Str("path", config.HTTP.TLS.Key).Msg("authn: couldn't read TLS key file") + + return err + } + } + } + + return nil +} + +func validateConfigExtensions(config *Config) error { + if config.Extensions != nil && config.Extensions.SysConfig != nil { + if (config.HTTP.Auth == nil || (config.HTTP.Auth.HTPasswd.Path == "" && config.HTTP.Auth.LDAP == nil)) || + config.HTTP.AccessControl == nil { + log.Error().Err(errors.ErrBadConfig).Msgf("config extensions needs auth and authorization enabled") + + return errors.ErrBadConfig + } + } + + return nil +} + +func validateStorageConfig(cfg *Config) error { + expConfigMap := make(map[string]StorageConfig, 0) + + defaultRootDir := cfg.Storage.RootDirectory + + for _, storageConfig := range cfg.Storage.SubPaths { + if strings.EqualFold(defaultRootDir, storageConfig.RootDirectory) { + log.Error().Err(errors.ErrBadConfig).Msg("storage subpaths cannot use default storage root directory") + + return errors.ErrBadConfig + } + + expConfig, ok := expConfigMap[storageConfig.RootDirectory] + if ok { + equal := expConfig.ParamsEqual(storageConfig) + if !equal { + log.Error().Err(errors.ErrBadConfig).Msg("storage config with same root directory should have same parameters") + + return errors.ErrBadConfig + } + } else { + expConfigMap[storageConfig.RootDirectory] = storageConfig + } + } + + return nil +} diff --git a/pkg/api/constants/extensions.go b/pkg/api/constants/extensions.go index 44bf1809bd..4fd849c57e 100644 --- a/pkg/api/constants/extensions.go +++ b/pkg/api/constants/extensions.go @@ -7,4 +7,5 @@ const ( // zot specific extensions. ExtSearchPrefix = "/_zot/ext/search" FullSearchPrefix = RoutePrefix + ExtSearchPrefix + ExtConfigPrefix = RoutePrefix + "/_zot/ext/config" ) diff --git a/pkg/api/controller.go b/pkg/api/controller.go index 48211b08fc..4d85912224 100644 --- a/pkg/api/controller.go +++ b/pkg/api/controller.go @@ -37,6 +37,7 @@ const ( type Controller struct { Config *config.Config + ConfigPath string Router *mux.Router StoreController storage.StoreController Log log.Logger @@ -61,6 +62,15 @@ func NewController(config *config.Config) *Controller { controller.Audit = audit } + addr := fmt.Sprintf("%s:%s", controller.Config.HTTP.Address, controller.Config.HTTP.Port) + server := &http.Server{ + Addr: addr, + Handler: controller.Router, + IdleTimeout: idleTimeout, + ReadHeaderTimeout: readHeaderTimeout, + } + controller.Server = server + return &controller } @@ -112,6 +122,10 @@ func (c *Controller) GetPort() int { return c.chosenPort } +func (c *Controller) SetConfigPath(path string) { + c.ConfigPath = path +} + func (c *Controller) Run(reloadCtx context.Context) error { // print the current configuration, but strip secrets c.Log.Info().Interface("params", c.Config.Sanitize()).Msg("configuration settings") @@ -144,6 +158,7 @@ func (c *Controller) Run(reloadCtx context.Context) error { } c.Router = engine + c.Server.Handler = engine c.Router.UseEncodedPath() var enabled bool @@ -156,27 +171,20 @@ func (c *Controller) Run(reloadCtx context.Context) error { c.Metrics = monitoring.NewMetricsServer(enabled, c.Log) - if err := c.InitImageStore(reloadCtx); err != nil { + if err := c.InitImageStore(); err != nil { return err } - monitoring.SetServerInfo(c.Metrics, c.Config.Commit, c.Config.BinaryType, c.Config.GoVersion, - c.Config.DistSpecVersion) + c.StartBackgroundTasks(reloadCtx) //nolint: contextcheck _ = NewRouteHandler(c) - addr := fmt.Sprintf("%s:%s", c.Config.HTTP.Address, c.Config.HTTP.Port) - server := &http.Server{ - Addr: addr, - Handler: c.Router, - IdleTimeout: idleTimeout, - ReadHeaderTimeout: readHeaderTimeout, - } - c.Server = server + monitoring.SetServerInfo(c.Metrics, c.Config.Commit, c.Config.BinaryType, c.Config.GoVersion, + c.Config.DistSpecVersion) // Create the listener - listener, err := net.Listen("tcp", addr) + listener, err := net.Listen("tcp", c.Server.Addr) if err != nil { return err } @@ -201,7 +209,7 @@ func (c *Controller) Run(reloadCtx context.Context) error { } if c.Config.HTTP.TLS != nil && c.Config.HTTP.TLS.Key != "" && c.Config.HTTP.TLS.Cert != "" { - server.TLSConfig = &tls.Config{ + c.Server.TLSConfig = &tls.Config{ CipherSuites: []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, @@ -221,7 +229,7 @@ func (c *Controller) Run(reloadCtx context.Context) error { if c.Config.HTTP.TLS.CACert != "" { clientAuth := tls.VerifyClientCertIfGiven if (c.Config.HTTP.Auth == nil || c.Config.HTTP.Auth.HTPasswd.Path == "") && - !anonymousPolicyExists(c.Config.AccessControl) { + !anonymousPolicyExists(c.Config.HTTP.AccessControl) { clientAuth = tls.RequireAndVerifyClientCert } @@ -236,17 +244,17 @@ func (c *Controller) Run(reloadCtx context.Context) error { panic(errors.ErrBadCACert) } - server.TLSConfig.ClientAuth = clientAuth - server.TLSConfig.ClientCAs = caCertPool + c.Server.TLSConfig.ClientAuth = clientAuth + c.Server.TLSConfig.ClientCAs = caCertPool } - return server.ServeTLS(listener, c.Config.HTTP.TLS.Cert, c.Config.HTTP.TLS.Key) + return c.Server.ServeTLS(listener, c.Config.HTTP.TLS.Cert, c.Config.HTTP.TLS.Key) } - return server.Serve(listener) + return c.Server.Serve(listener) } -func (c *Controller) InitImageStore(reloadCtx context.Context) error { +func (c *Controller) InitImageStore() error { c.StoreController = storage.StoreController{} linter := ext.GetLinter(c.Config, c.Log) @@ -322,8 +330,6 @@ func (c *Controller) InitImageStore(reloadCtx context.Context) error { } } - c.StartBackgroundTasks(reloadCtx) - return nil } @@ -421,23 +427,6 @@ func compareImageStore(root1, root2 string) bool { return isSameFile } -func (c *Controller) LoadNewConfig(reloadCtx context.Context, config *config.Config) { - // reload access control config - c.Config.AccessControl = config.AccessControl - c.Config.HTTP.RawAccessControl = config.HTTP.RawAccessControl - - // Enable extensions if extension config is provided - if config.Extensions != nil && config.Extensions.Sync != nil { - // reload sync config - c.Config.Extensions.Sync = config.Extensions.Sync - ext.EnableSyncExtension(reloadCtx, c.Config, c.wgShutDown, c.StoreController, c.Log) - } else if c.Config.Extensions != nil { - c.Config.Extensions.Sync = nil - } - - c.Log.Info().Interface("reloaded params", c.Config.Sanitize()).Msg("new configuration settings") -} - func (c *Controller) Shutdown() { // wait gracefully c.wgShutDown.Wait() @@ -446,9 +435,9 @@ func (c *Controller) Shutdown() { _ = c.Server.Shutdown(ctx) } -func (c *Controller) StartBackgroundTasks(reloadCtx context.Context) { +func (c *Controller) StartBackgroundTasks(ctx context.Context) { taskScheduler := scheduler.NewScheduler(c.Log) - taskScheduler.RunScheduler(reloadCtx) + taskScheduler.RunScheduler(ctx) // Enable running garbage-collect periodically for DefaultStore if c.Config.Storage.GC && c.Config.Storage.GCInterval != 0 { @@ -478,7 +467,7 @@ func (c *Controller) StartBackgroundTasks(reloadCtx context.Context) { // Enable extensions if extension config is provided for storeController if c.Config.Extensions != nil { if c.Config.Extensions.Sync != nil { - ext.EnableSyncExtension(reloadCtx, c.Config, c.wgShutDown, c.StoreController, c.Log) + ext.EnableSyncExtension(ctx, c.Config, c.wgShutDown, c.StoreController, c.Log) } } diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index 1b90a7e94e..2817793fbe 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -1042,7 +1042,7 @@ func TestTLSWithBasicAuthAllowReadAccess(t *testing.T) { Key: ServerKey, } - conf.AccessControl = &config.AccessControlConfig{ + conf.HTTP.AccessControl = &config.AccessControlConfig{ Repositories: config.Repositories{ AuthorizationAllRepos: config.PolicyGroup{ AnonymousPolicy: []string{"read"}, @@ -1108,7 +1108,7 @@ func TestMutualTLSAuthWithUserPermissions(t *testing.T) { CACert: CACert, } - conf.AccessControl = &config.AccessControlConfig{ + conf.HTTP.AccessControl = &config.AccessControlConfig{ Repositories: config.Repositories{ AuthorizationAllRepos: config.PolicyGroup{ Policies: []config.Policy{ @@ -1133,7 +1133,7 @@ func TestMutualTLSAuthWithUserPermissions(t *testing.T) { So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) - repoPolicy := conf.AccessControl.Repositories[AuthorizationAllRepos] + repoPolicy := conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] // setup TLS mutual auth cert, err := tls.LoadX509KeyPair("../../test/data/client.cert", "../../test/data/client.key") @@ -1160,7 +1160,7 @@ func TestMutualTLSAuthWithUserPermissions(t *testing.T) { // empty default authorization and give user the permission to create repoPolicy.Policies[0].Actions = append(repoPolicy.Policies[0].Actions, "create") - conf.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy resp, err = resty.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/") So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) @@ -1191,7 +1191,7 @@ func TestMutualTLSAuthWithoutCN(t *testing.T) { CACert: "../../test/data/noidentity/ca.crt", } - conf.AccessControl = &config.AccessControlConfig{ + conf.HTTP.AccessControl = &config.AccessControlConfig{ Repositories: config.Repositories{ AuthorizationAllRepos: config.PolicyGroup{ Policies: []config.Policy{ @@ -1312,7 +1312,7 @@ func TestTLSMutualAuthAllowReadAccess(t *testing.T) { CACert: CACert, } - conf.AccessControl = &config.AccessControlConfig{ + conf.HTTP.AccessControl = &config.AccessControlConfig{ Repositories: config.Repositories{ AuthorizationAllRepos: config.PolicyGroup{ AnonymousPolicy: []string{"read"}, @@ -1478,7 +1478,7 @@ func TestTLSMutualAndBasicAuthAllowReadAccess(t *testing.T) { CACert: CACert, } - conf.AccessControl = &config.AccessControlConfig{ + conf.HTTP.AccessControl = &config.AccessControlConfig{ Repositories: config.Repositories{ AuthorizationAllRepos: config.PolicyGroup{ AnonymousPolicy: []string{"read"}, @@ -1897,7 +1897,7 @@ func TestBearerAuthWithAllowReadAccess(t *testing.T) { ctlr := api.NewController(conf) ctlr.Config.Storage.RootDirectory = t.TempDir() - conf.AccessControl = &config.AccessControlConfig{ + conf.HTTP.AccessControl = &config.AccessControlConfig{ Repositories: config.Repositories{ AuthorizationAllRepos: config.PolicyGroup{ AnonymousPolicy: []string{"read"}, @@ -2113,7 +2113,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { Path: htpasswdPath, }, } - conf.AccessControl = &config.AccessControlConfig{ + conf.HTTP.AccessControl = &config.AccessControlConfig{ Repositories: config.Repositories{ AuthorizationAllRepos: config.PolicyGroup{ Policies: []config.Policy{ @@ -2178,9 +2178,9 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { // first let's use global based policies // add test user to global policy with create perm - conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Users = append(conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Users, "test") //nolint:lll // gofumpt conflicts with lll + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Users = append(conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Users, "test") //nolint:lll // gofumpt conflicts with lll - conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions = append(conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "create") //nolint:lll // gofumpt conflicts with lll + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "create") //nolint:lll // gofumpt conflicts with lll // now it should get 202 resp, err = resty.R().SetBasicAuth(username, passphrase). @@ -2216,7 +2216,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) // get tags with read access should get 200 - conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions = append(conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "read") //nolint:lll // gofumpt conflicts with lll + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "read") //nolint:lll // gofumpt conflicts with lll resp, err = resty.R().SetBasicAuth(username, passphrase). Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") @@ -2246,7 +2246,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) // add delete perm on repo - conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions = append(conf.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "delete") //nolint:lll // gofumpt conflicts with lll + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos].Policies[0].Actions, "delete") //nolint:lll // gofumpt conflicts with lll // delete blob should get 202 resp, err = resty.R().SetBasicAuth(username, passphrase). @@ -2258,7 +2258,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { // now let's use only repository based policies // add test user to repo's policy with create perm // longest path matching should match the repo and not **/* - conf.AccessControl.Repositories[AuthorizationNamespace] = config.PolicyGroup{ + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] = config.PolicyGroup{ Policies: []config.Policy{ { Users: []string{}, @@ -2268,8 +2268,8 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { DefaultPolicy: []string{}, } - conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users = append(conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users, "test") //nolint:lll // gofumpt conflicts with lll - conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = append(conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "create") //nolint:lll // gofumpt conflicts with lll + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users = append(conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Users, "test") //nolint:lll // gofumpt conflicts with lll + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "create") //nolint:lll // gofumpt conflicts with lll // now it should get 202 resp, err = resty.R().SetBasicAuth(username, passphrase). @@ -2305,7 +2305,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) // get tags with read access should get 200 - conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = append(conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "read") //nolint:lll // gofumpt conflicts with lll + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "read") //nolint:lll // gofumpt conflicts with lll resp, err = resty.R().SetBasicAuth(username, passphrase). Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") @@ -2341,7 +2341,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) // add delete perm on repo - conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = append(conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "delete") //nolint:lll // gofumpt conflicts with lll + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions, "delete") //nolint:lll // gofumpt conflicts with lll // delete blob should get 202 resp, err = resty.R().SetBasicAuth(username, passphrase). @@ -2351,10 +2351,10 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) // remove permissions on **/* so it will not interfere with zot-test namespace - repoPolicy := conf.AccessControl.Repositories[AuthorizationAllRepos] + repoPolicy := conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] repoPolicy.Policies = []config.Policy{} repoPolicy.DefaultPolicy = []string{} - conf.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy // get manifest should get 403, we don't have perm at all on this repo resp, err = resty.R().SetBasicAuth(username, passphrase). @@ -2364,7 +2364,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) // add read perm on repo - conf.AccessControl.Repositories["zot-test"] = config.PolicyGroup{Policies: []config.Policy{ + conf.HTTP.AccessControl.Repositories["zot-test"] = config.PolicyGroup{Policies: []config.Policy{ { Users: []string{"test"}, Actions: []string{"read"}, @@ -2391,7 +2391,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) // add create perm on repo - conf.AccessControl.Repositories["zot-test"].Policies[0].Actions = append(conf.AccessControl.Repositories["zot-test"].Policies[0].Actions, "create") //nolint:lll // gofumpt conflicts with lll + conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions, "create") //nolint:lll // gofumpt conflicts with lll // should get 201 with create perm resp, err = resty.R().SetBasicAuth(username, passphrase). @@ -2477,7 +2477,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.Body(), ShouldResemble, manifestBlob) // add update perm on repo - conf.AccessControl.Repositories["zot-test"].Policies[0].Actions = append(conf.AccessControl.Repositories["zot-test"].Policies[0].Actions, "update") //nolint:lll // gofumpt conflicts with lll + conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions = append(conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions, "update") //nolint:lll // gofumpt conflicts with lll // update manifest should get 201 with update perm resp, err = resty.R().SetBasicAuth(username, passphrase). @@ -2497,10 +2497,10 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.Body(), ShouldResemble, updatedManifestBlob) // now use default repo policy - conf.AccessControl.Repositories["zot-test"].Policies[0].Actions = []string{} - repoPolicy = conf.AccessControl.Repositories["zot-test"] + conf.HTTP.AccessControl.Repositories["zot-test"].Policies[0].Actions = []string{} + repoPolicy = conf.HTTP.AccessControl.Repositories["zot-test"] repoPolicy.DefaultPolicy = []string{"update"} - conf.AccessControl.Repositories["zot-test"] = repoPolicy + conf.HTTP.AccessControl.Repositories["zot-test"] = repoPolicy // update manifest should get 201 with update perm on repo's default policy resp, err = resty.R().SetBasicAuth(username, passphrase). @@ -2512,10 +2512,10 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusCreated) // with default read on repo should still get 200 - conf.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = []string{} - repoPolicy = conf.AccessControl.Repositories[AuthorizationNamespace] + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace].Policies[0].Actions = []string{} + repoPolicy = conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] repoPolicy.DefaultPolicy = []string{"read"} - conf.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy resp, err = resty.R().SetBasicAuth(username, passphrase). Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") @@ -2525,7 +2525,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { // upload blob without user create but with default create should get 200 repoPolicy.DefaultPolicy = append(repoPolicy.DefaultPolicy, "create") - conf.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy resp, err = resty.R().SetBasicAuth(username, passphrase). Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") @@ -2534,15 +2534,15 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusAccepted) // remove per repo policy - repoPolicy = conf.AccessControl.Repositories[AuthorizationNamespace] + repoPolicy = conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] repoPolicy.Policies = []config.Policy{} repoPolicy.DefaultPolicy = []string{} - conf.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy + conf.HTTP.AccessControl.Repositories[AuthorizationNamespace] = repoPolicy - repoPolicy = conf.AccessControl.Repositories["zot-test"] + repoPolicy = conf.HTTP.AccessControl.Repositories["zot-test"] repoPolicy.Policies = []config.Policy{} repoPolicy.DefaultPolicy = []string{} - conf.AccessControl.Repositories["zot-test"] = repoPolicy + conf.HTTP.AccessControl.Repositories["zot-test"] = repoPolicy resp, err = resty.R().SetBasicAuth(username, passphrase). Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") @@ -2558,8 +2558,8 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) // add read perm - conf.AccessControl.AdminPolicy.Users = append(conf.AccessControl.AdminPolicy.Users, "test") - conf.AccessControl.AdminPolicy.Actions = append(conf.AccessControl.AdminPolicy.Actions, "read") + conf.HTTP.AccessControl.AdminPolicy.Users = append(conf.HTTP.AccessControl.AdminPolicy.Users, "test") + conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "read") // with read perm should get 200 resp, err = resty.R().SetBasicAuth(username, passphrase). Get(baseURL + "/v2/" + AuthorizationNamespace + "/tags/list") @@ -2575,7 +2575,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) // add create perm - conf.AccessControl.AdminPolicy.Actions = append(conf.AccessControl.AdminPolicy.Actions, "create") + conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "create") // with create perm should get 202 resp, err = resty.R().SetBasicAuth(username, passphrase). Post(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/uploads/") @@ -2603,7 +2603,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) // add delete perm - conf.AccessControl.AdminPolicy.Actions = append(conf.AccessControl.AdminPolicy.Actions, "delete") + conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "delete") // with delete perm should get http.StatusAccepted resp, err = resty.R().SetBasicAuth(username, passphrase). Delete(baseURL + "/v2/" + AuthorizationNamespace + "/blobs/" + digest) @@ -2619,7 +2619,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) // add update perm - conf.AccessControl.AdminPolicy.Actions = append(conf.AccessControl.AdminPolicy.Actions, "update") + conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "update") // update manifest should get 201 with update perm resp, err = resty.R().SetBasicAuth(username, passphrase). SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). @@ -2629,7 +2629,7 @@ func TestAuthorizationWithBasicAuth(t *testing.T) { So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusCreated) - conf.AccessControl = &config.AccessControlConfig{} + conf.HTTP.AccessControl = &config.AccessControlConfig{} resp, err = resty.R().SetBasicAuth(username, passphrase). SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). @@ -2703,7 +2703,7 @@ func TestAuthorizationWithOnlyAnonymousPolicy(t *testing.T) { conf := config.New() conf.HTTP.Port = port conf.HTTP.Auth = &config.AuthConfig{} - conf.AccessControl = &config.AccessControlConfig{ + conf.HTTP.AccessControl = &config.AccessControlConfig{ Repositories: config.Repositories{ TestRepo: config.PolicyGroup{ AnonymousPolicy: []string{}, @@ -2741,9 +2741,9 @@ func TestAuthorizationWithOnlyAnonymousPolicy(t *testing.T) { So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) - if entry, ok := conf.AccessControl.Repositories[TestRepo]; ok { + if entry, ok := conf.HTTP.AccessControl.Repositories[TestRepo]; ok { entry.AnonymousPolicy = []string{"create", "read"} - conf.AccessControl.Repositories[TestRepo] = entry + conf.HTTP.AccessControl.Repositories[TestRepo] = entry } // now it should get 202 @@ -2860,9 +2860,9 @@ func TestAuthorizationWithOnlyAnonymousPolicy(t *testing.T) { So(resp.Body(), ShouldResemble, manifestBlob) // add update perm on repo - if entry, ok := conf.AccessControl.Repositories[TestRepo]; ok { + if entry, ok := conf.HTTP.AccessControl.Repositories[TestRepo]; ok { entry.AnonymousPolicy = []string{"create", "read", "update"} - conf.AccessControl.Repositories[TestRepo] = entry + conf.HTTP.AccessControl.Repositories[TestRepo] = entry } // update manifest should get 201 with update perm @@ -2902,7 +2902,7 @@ func TestAuthorizationWithMultiplePolicies(t *testing.T) { }, } // config with all policy types, to test that the correct one is applied in each case - conf.AccessControl = &config.AccessControlConfig{ + conf.HTTP.AccessControl = &config.AccessControlConfig{ Repositories: config.Repositories{ AuthorizationAllRepos: config.PolicyGroup{ Policies: []config.Policy{ @@ -2942,9 +2942,9 @@ func TestAuthorizationWithMultiplePolicies(t *testing.T) { So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, 401) - repoPolicy := conf.AccessControl.Repositories[AuthorizationAllRepos] + repoPolicy := conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] repoPolicy.AnonymousPolicy = append(repoPolicy.AnonymousPolicy, "read") - conf.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy // should have access to /v2/, anonymous policy is applied, "read" allowed resp, err = resty.R().Get(baseURL + "/v2/") @@ -2990,7 +2990,7 @@ func TestAuthorizationWithMultiplePolicies(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) repoPolicy.DefaultPolicy = append(repoPolicy.DefaultPolicy, "read") - conf.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy + conf.HTTP.AccessControl.Repositories[AuthorizationAllRepos] = repoPolicy // with read permission should get 200, because default policy allows reading now resp, err = resty.R().SetBasicAuth(username, passphrase). @@ -3026,8 +3026,8 @@ func TestAuthorizationWithMultiplePolicies(t *testing.T) { So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) // add read permission to user "bob" - conf.AccessControl.AdminPolicy.Users = append(conf.AccessControl.AdminPolicy.Users, "bob") - conf.AccessControl.AdminPolicy.Actions = append(conf.AccessControl.AdminPolicy.Actions, "create") + conf.HTTP.AccessControl.AdminPolicy.Users = append(conf.HTTP.AccessControl.AdminPolicy.Users, "bob") + conf.HTTP.AccessControl.AdminPolicy.Actions = append(conf.HTTP.AccessControl.AdminPolicy.Actions, "create") // added create permission to user "bob", should be allowed now resp, err = resty.R().SetBasicAuth("bob", passphrase). @@ -3111,7 +3111,7 @@ func TestHTTPReadOnly(t *testing.T) { conf := config.New() conf.HTTP.Port = port // enable read-only mode - conf.AccessControl = &config.AccessControlConfig{ + conf.HTTP.AccessControl = &config.AccessControlConfig{ Repositories: config.Repositories{ AuthorizationAllRepos: config.PolicyGroup{ DefaultPolicy: []string{"read"}, @@ -6125,7 +6125,7 @@ func TestSearchRoutes(t *testing.T) { Search: searchConfig, } - conf.AccessControl = &config.AccessControlConfig{ + conf.HTTP.AccessControl = &config.AccessControlConfig{ Repositories: config.Repositories{ repoName: config.PolicyGroup{ Policies: []config.Policy{ diff --git a/pkg/api/routes.go b/pkg/api/routes.go index 30d6e826ac..9b4468a19f 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -57,7 +57,7 @@ func (rh *RouteHandler) SetupRoutes() { rh.c.Router.Use(AuthHandler(rh.c)) // authz is being enabled if AccessControl is specified // if Authn is not present AccessControl will have only default policies - if rh.c.Config.AccessControl != nil && !isBearerAuthEnabled(rh.c.Config) { + if rh.c.Config.HTTP.AccessControl != nil && !isBearerAuthEnabled(rh.c.Config) { if isAuthnEnabled(rh.c.Config) { rh.c.Log.Info().Msg("access control is being enabled") } else { @@ -121,6 +121,7 @@ func (rh *RouteHandler) SetupRoutes() { ext.SetupMetricsRoutes(rh.c.Config, rh.c.Router, rh.c.StoreController, rh.c.Log) ext.SetupSearchRoutes(rh.c.Config, rh.c.Router, rh.c.StoreController, rh.c.Log) gqlPlayground.SetupGQLPlaygroundRoutes(rh.c.Config, rh.c.Router, rh.c.StoreController, rh.c.Log) + ext.SetupConfigRoutes(rh.c.Config, rh.c.ConfigPath, rh.c.Router, rh.c.StoreController, rh.c.Log) } } } diff --git a/pkg/cli/config_reloader.go b/pkg/cli/config_reloader.go index f6dddc5b1c..7d937256aa 100644 --- a/pkg/cli/config_reloader.go +++ b/pkg/cli/config_reloader.go @@ -2,6 +2,9 @@ package cli import ( "context" + "errors" + "net/http" + "time" "github.com/fsnotify/fsnotify" "github.com/rs/zerolog/log" @@ -10,13 +13,14 @@ import ( "zotregistry.io/zot/pkg/api/config" ) +const fsnotifyRateLimit = 500 * time.Millisecond + type HotReloader struct { - watcher *fsnotify.Watcher - filePath string - ctlr *api.Controller + watcher *fsnotify.Watcher + configPath string } -func NewHotReloader(ctlr *api.Controller, filePath string) (*HotReloader, error) { +func NewHotReloader(configPath string) (*HotReloader, error) { // creates a new file watcher watcher, err := fsnotify.NewWatcher() if err != nil { @@ -24,60 +28,100 @@ func NewHotReloader(ctlr *api.Controller, filePath string) (*HotReloader, error) } hotReloader := &HotReloader{ - watcher: watcher, - filePath: filePath, - ctlr: ctlr, + watcher: watcher, + configPath: configPath, } return hotReloader, nil } -func (hr *HotReloader) Start() context.Context { +func (hr *HotReloader) Start() { + defer hr.watcher.Close() done := make(chan bool) + cfg := config.New() + if err := config.LoadFromFile(hr.configPath, cfg); err != nil { + panic(err) + } + + ctlr := api.NewController(cfg) + ctlr.SetConfigPath(hr.configPath) + reloadCtx, cancelOnReloadFunc := context.WithCancel(context.Background()) - // run watcher + + // start server go func() { - defer hr.watcher.Close() + if err := ctlr.Run(reloadCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { + panic(err) + } + }() - go func() { - for { - select { - // watch for events - case event := <-hr.watcher.Events: - if event.Op == fsnotify.Write { - log.Info().Msg("config file changed, trying to reload config") + go func() { + var ( + timer *time.Timer + lastEvent fsnotify.Event + ) + + // this is an workaround for fsnotify firing 2 write events instead of 1 + timer = time.NewTimer(time.Millisecond) + <-timer.C // timer should be expired at first + + for { + select { + // watch for events + case event := <-hr.watcher.Events: + lastEvent = event + + timer.Reset(fsnotifyRateLimit) + case <-timer.C: + if lastEvent.Op == fsnotify.Write { + log.Info().Msg("reloader: config file changed, trying to hot reload config") + + newConfig := &config.Config{} + if err := config.LoadFromFile(hr.configPath, newConfig); err != nil { + log.Error().Err(err).Msg("reloader: couldn't hot reload config, retry writing it.") + + continue + } - newConfig := config.New() + // create new context + reloadCtx, cancelOnReloadFunc = context.WithCancel(context.Background()) - err := LoadConfiguration(newConfig, hr.filePath) - if err != nil { - log.Error().Err(err).Msg("couldn't reload config, retry writing it.") + shutdownFunc := ctlr.Shutdown + ctlr = api.NewController(newConfig) + ctlr.SetConfigPath(hr.configPath) - continue - } - // if valid config then reload - cancelOnReloadFunc() + // if valid config then reload + // stop go routines + cancelOnReloadFunc() + // stop server + shutdownFunc() - // create new context - reloadCtx, cancelOnReloadFunc = context.WithCancel(context.Background()) - hr.ctlr.LoadNewConfig(reloadCtx, newConfig) + // wait for server to shutdown + //nolint: contextcheck + for isServerRunning(cfg.HTTP.Address, cfg.HTTP.Port) { + continue } - // watch for errors - case err := <-hr.watcher.Errors: - log.Error().Err(err).Msgf("fsnotfy error while watching config %s", hr.filePath) - panic(err) + + // start new server + go func() { + if err := ctlr.Run(reloadCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { + panic(err) + } + }() } + // watch for errors + case err := <-hr.watcher.Errors: + log.Error().Err(err).Msgf("reloader: fsnotfy error while watching config %s", hr.configPath) + panic(err) } - }() - - if err := hr.watcher.Add(hr.filePath); err != nil { - log.Error().Err(err).Msgf("error adding config file %s to FsNotify watcher", hr.filePath) - panic(err) } - - <-done }() - return reloadCtx + if err := hr.watcher.Add(hr.configPath); err != nil { + log.Error().Err(err).Msgf("reloader: error adding config file %s to FsNotify watcher", hr.configPath) + panic(err) + } + + <-done } diff --git a/pkg/cli/config_reloader_test.go b/pkg/cli/config_reloader_test.go index 27e3ccc479..e9cf928727 100644 --- a/pkg/cli/config_reloader_test.go +++ b/pkg/cli/config_reloader_test.go @@ -14,6 +14,11 @@ import ( "zotregistry.io/zot/pkg/test" ) +const ( + username = "test" + password = "test" +) + func TestConfigReloader(t *testing.T) { oldArgs := os.Args @@ -26,9 +31,6 @@ func TestConfigReloader(t *testing.T) { logFile, err := os.CreateTemp("", "zot-log*.txt") So(err, ShouldBeNil) - username := "alice" - password := "alice" - hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) if err != nil { panic(err) @@ -44,7 +46,7 @@ func TestConfigReloader(t *testing.T) { content := fmt.Sprintf(`{ "distSpecVersion": "0.1.0-dev", "storage": { - "rootDirectory": "/tmp/zot" + "rootDirectory": "%s" }, "http": { "address": "127.0.0.1", @@ -57,14 +59,16 @@ func TestConfigReloader(t *testing.T) { "failDelay": 1 }, "accessControl": { - "**": { - "policies": [ - { - "users": ["charlie"], - "actions": ["read"] + "repositories": { + "**": { + "policies": [ + { + "users": ["other"], + "actions": ["read"] + } + ], + "defaultPolicy": ["read", "create"] } - ], - "defaultPolicy": ["read", "create"] }, "adminPolicy": { "users": ["admin"], @@ -76,7 +80,7 @@ func TestConfigReloader(t *testing.T) { "level": "debug", "output": "%s" } - }`, port, htpasswdPath, logFile.Name()) + }`, t.TempDir(), port, htpasswdPath, logFile.Name()) cfgfile, err := os.CreateTemp("", "zot-test*.json") So(err, ShouldBeNil) @@ -100,7 +104,7 @@ func TestConfigReloader(t *testing.T) { content = fmt.Sprintf(`{ "distSpecVersion": "0.1.0-dev", "storage": { - "rootDirectory": "/tmp/zot" + "rootDirectory": "%s" }, "http": { "address": "127.0.0.1", @@ -113,14 +117,16 @@ func TestConfigReloader(t *testing.T) { "failDelay": 1 }, "accessControl": { - "**": { - "policies": [ - { - "users": ["alice"], - "actions": ["read", "create", "update", "delete"] + "repositories": { + "**": { + "policies": [ + { + "users": ["test"], + "actions": ["read", "create", "update", "delete"] + } + ], + "defaultPolicy": ["read"] } - ], - "defaultPolicy": ["read"] }, "adminPolicy": { "users": ["admin"], @@ -132,7 +138,7 @@ func TestConfigReloader(t *testing.T) { "level": "debug", "output": "%s" } - }`, port, htpasswdPath, logFile.Name()) + }`, t.TempDir(), port, htpasswdPath, logFile.Name()) err = cfgfile.Truncate(0) So(err, ShouldBeNil) @@ -147,13 +153,11 @@ func TestConfigReloader(t *testing.T) { So(err, ShouldBeNil) // wait for config reload - time.Sleep(2 * time.Second) + time.Sleep(3 * time.Second) data, err := os.ReadFile(logFile.Name()) So(err, ShouldBeNil) - So(string(data), ShouldContainSubstring, "reloaded params") - So(string(data), ShouldContainSubstring, "new configuration settings") - So(string(data), ShouldContainSubstring, "\"Users\":[\"alice\"]") + So(string(data), ShouldContainSubstring, "\"Users\":[\"test\"]") So(string(data), ShouldContainSubstring, "\"Actions\":[\"read\",\"create\",\"update\",\"delete\"]") }) @@ -169,7 +173,7 @@ func TestConfigReloader(t *testing.T) { content := fmt.Sprintf(`{ "distSpecVersion": "0.1.0-dev", "storage": { - "rootDirectory": "/tmp/zot" + "rootDirectory": "%s" }, "http": { "address": "127.0.0.1", @@ -185,9 +189,7 @@ func TestConfigReloader(t *testing.T) { "urls": ["http://localhost:8080"], "tlsVerify": false, "onDemand": true, - "maxRetries": 3, - "retryDelay": "15m", - "certDir": "", + "pollInterval": "1m", "content":[ { "prefix": "zot-test", @@ -200,7 +202,7 @@ func TestConfigReloader(t *testing.T) { }] } } - }`, port, logFile.Name()) + }`, t.TempDir(), port, logFile.Name()) cfgfile, err := os.CreateTemp("", "zot-test*.json") So(err, ShouldBeNil) @@ -224,7 +226,7 @@ func TestConfigReloader(t *testing.T) { content = fmt.Sprintf(`{ "distSpecVersion": "0.1.0-dev", "storage": { - "rootDirectory": "/tmp/zot" + "rootDirectory": "%s" }, "http": { "address": "127.0.0.1", @@ -237,12 +239,10 @@ func TestConfigReloader(t *testing.T) { "extensions": { "sync": { "registries": [{ - "urls": ["http://localhost:9999"], + "urls": ["localhost:9999"], "tlsVerify": true, "onDemand": false, - "maxRetries": 10, - "retryDelay": "5m", - "certDir": "certs", + "pollInterval": "1m", "content":[ { "prefix": "zot-cve-test", @@ -255,7 +255,7 @@ func TestConfigReloader(t *testing.T) { }] } } - }`, port, logFile.Name()) + }`, t.TempDir(), port, logFile.Name()) err = cfgfile.Truncate(0) So(err, ShouldBeNil) @@ -270,18 +270,13 @@ func TestConfigReloader(t *testing.T) { So(err, ShouldBeNil) // wait for config reload - time.Sleep(2 * time.Second) + time.Sleep(3 * time.Second) data, err := os.ReadFile(logFile.Name()) So(err, ShouldBeNil) - So(string(data), ShouldContainSubstring, "reloaded params") - So(string(data), ShouldContainSubstring, "new configuration settings") - So(string(data), ShouldContainSubstring, "\"URLs\":[\"http://localhost:9999\"]") + So(string(data), ShouldContainSubstring, "\"URLs\":[\"localhost:9999\"]") So(string(data), ShouldContainSubstring, "\"TLSVerify\":true") So(string(data), ShouldContainSubstring, "\"OnDemand\":false") - So(string(data), ShouldContainSubstring, "\"MaxRetries\":10") - So(string(data), ShouldContainSubstring, "\"RetryDelay\":300000000000") - So(string(data), ShouldContainSubstring, "\"CertDir\":\"certs\"") So(string(data), ShouldContainSubstring, "\"Prefix\":\"zot-cve-test\"") So(string(data), ShouldContainSubstring, "\"Regex\":\"tag\"") So(string(data), ShouldContainSubstring, "\"Semver\":false") @@ -299,7 +294,7 @@ func TestConfigReloader(t *testing.T) { content := fmt.Sprintf(`{ "distSpecVersion": "0.1.0-dev", "storage": { - "rootDirectory": "/tmp/zot" + "rootDirectory": "%s" }, "http": { "address": "127.0.0.1", @@ -330,7 +325,224 @@ func TestConfigReloader(t *testing.T) { }] } } - }`, port, logFile.Name()) + }`, t.TempDir(), port, logFile.Name()) + + cfgfile, err := os.CreateTemp("", "zot-test*.json") + So(err, ShouldBeNil) + + defer os.Remove(cfgfile.Name()) // clean up + + _, err = cfgfile.Write([]byte(content)) + So(err, ShouldBeNil) + + // err = cfgfile.Close() + // So(err, ShouldBeNil) + + os.Args = []string{"cli_test", "serve", cfgfile.Name()} + go func() { + err = cli.NewServerRootCmd().Execute() + So(err, ShouldBeNil) + }() + + test.WaitTillServerReady(baseURL) + + Convey("reload with unparsable config", func(c C) { + content = "[]" + + err = cfgfile.Truncate(0) + So(err, ShouldBeNil) + + _, err = cfgfile.Seek(0, io.SeekStart) + So(err, ShouldBeNil) + + _, err = cfgfile.WriteString(content) + So(err, ShouldBeNil) + + err = cfgfile.Close() + So(err, ShouldBeNil) + + // wait for config reload + time.Sleep(3 * time.Second) + + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + So(string(data), ShouldContainSubstring, "\"URLs\":[\"http://localhost:8080\"]") + So(string(data), ShouldContainSubstring, "\"TLSVerify\":false") + So(string(data), ShouldContainSubstring, "\"OnDemand\":true") + So(string(data), ShouldContainSubstring, "\"MaxRetries\":3") + So(string(data), ShouldContainSubstring, "\"CertDir\":\"\"") + So(string(data), ShouldContainSubstring, "\"Prefix\":\"zot-test\"") + So(string(data), ShouldContainSubstring, "\"Regex\":\".*\"") + So(string(data), ShouldContainSubstring, "\"Semver\":true") + }) + + Convey("reload with invalid config", func(c C) { + // config with sysconfig enabled but no auth and autz + content := fmt.Sprintf(` + { + "distSpecVersion": "1.0.1-dev", + "storage": { + "rootDirectory": "%s" + }, + "http": { + "address": "127.0.0.1", + "port": "%s" + }, + "log": { + "level": "debug", + "output": "%s" + }, + "extensions": { + "sysconfig": { + "enable": true + } + } + }`, t.TempDir(), port, logFile.Name()) + + err = cfgfile.Truncate(0) + So(err, ShouldBeNil) + + _, err = cfgfile.Seek(0, io.SeekStart) + So(err, ShouldBeNil) + + _, err = cfgfile.WriteString(content) + So(err, ShouldBeNil) + + err = cfgfile.Close() + So(err, ShouldBeNil) + + // wait for config reload + time.Sleep(3 * time.Second) + + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + So(string(data), ShouldContainSubstring, "\"URLs\":[\"http://localhost:8080\"]") + So(string(data), ShouldContainSubstring, "\"TLSVerify\":false") + So(string(data), ShouldContainSubstring, "\"OnDemand\":true") + So(string(data), ShouldContainSubstring, "\"MaxRetries\":3") + So(string(data), ShouldContainSubstring, "\"CertDir\":\"\"") + So(string(data), ShouldContainSubstring, "\"Prefix\":\"zot-test\"") + So(string(data), ShouldContainSubstring, "\"Regex\":\".*\"") + So(string(data), ShouldContainSubstring, "\"Semver\":true") + }) + + Convey("reload with valid config but wrong values", func(c C) { + // config with sysconfig enabled but no auth and autz + content := fmt.Sprintf(` + { + "distSpecVersion": "1.0.1-dev", + "storage": { + "rootDirectory": "%s" + }, + "http": { + "address": "127.0.0.1", + "port": "%s", + "auth": { + "htpasswd": { + "path": "/wrong/path/htpasswd" + } + } + }, + "log": { + "level": "debug", + "output": "%s" + } + }`, t.TempDir(), port, logFile.Name()) + + err = cfgfile.Truncate(0) + So(err, ShouldBeNil) + + _, err = cfgfile.Seek(0, io.SeekStart) + So(err, ShouldBeNil) + + _, err = cfgfile.WriteString(content) + So(err, ShouldBeNil) + + err = cfgfile.Close() + So(err, ShouldBeNil) + + // wait for config reload + time.Sleep(3 * time.Second) + + data, err := os.ReadFile(logFile.Name()) + So(err, ShouldBeNil) + So(string(data), ShouldContainSubstring, "\"URLs\":[\"http://localhost:8080\"]") + So(string(data), ShouldContainSubstring, "\"TLSVerify\":false") + So(string(data), ShouldContainSubstring, "\"OnDemand\":true") + So(string(data), ShouldContainSubstring, "\"MaxRetries\":3") + So(string(data), ShouldContainSubstring, "\"CertDir\":\"\"") + So(string(data), ShouldContainSubstring, "\"Prefix\":\"zot-test\"") + So(string(data), ShouldContainSubstring, "\"Regex\":\".*\"") + So(string(data), ShouldContainSubstring, "\"Semver\":true") + }) + }) +} + +func TestConfigReloaderAllExtensions(t *testing.T) { + oldArgs := os.Args + + defer func() { os.Args = oldArgs }() + + Convey("reload access control config", t, func(c C) { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + logFile, err := os.CreateTemp("", "zot-log*.txt") + So(err, ShouldBeNil) + + hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) + if err != nil { + panic(err) + } + + usernameAndHash := fmt.Sprintf("%s:%s", username, string(hash)) + + htpasswdPath := test.MakeHtpasswdFileFromString(usernameAndHash) + defer os.Remove(htpasswdPath) + + defer os.Remove(logFile.Name()) // clean up + + content := fmt.Sprintf(`{ + "distSpecVersion": "0.1.0-dev", + "storage": { + "rootDirectory": "%s", + "gc": true, + "gcDelay": "1h", + "gcInterval": "24h" + }, + "http": { + "address": "127.0.0.1", + "port": "%s", + "realm": "zot", + "auth": { + "htpasswd": { + "path": "%s" + }, + "failDelay": 1 + }, + "accessControl": { + "adminPolicy": { + "users": ["admin"], + "actions": ["read", "create", "update", "delete"] + } + } + }, + "extensions": { + "metrics": {}, + "search": { + "cve": { + "updateInterval": "2h" + } + }, + "scrub": { + "interval": "24h" + } + }, + "log": { + "level": "debug", + "output": "%s" + } + }`, t.TempDir(), port, htpasswdPath, logFile.Name()) cfgfile, err := os.CreateTemp("", "zot-test*.json") So(err, ShouldBeNil) @@ -351,7 +563,30 @@ func TestConfigReloader(t *testing.T) { test.WaitTillServerReady(baseURL) - content = "[]" + // wait for cve db to be downloaded + time.Sleep(30 * time.Second) + + content = fmt.Sprintf(`{ + "distSpecVersion": "0.1.0-dev", + "storage": { + "rootDirectory": "%s" + }, + "http": { + "address": "127.0.0.1", + "port": "%s", + "realm": "zot", + "auth": { + "htpasswd": { + "path": "%s" + }, + "failDelay": 1 + } + }, + "log": { + "level": "debug", + "output": "%s" + } + }`, t.TempDir(), port, htpasswdPath, logFile.Name()) err = cfgfile.Truncate(0) So(err, ShouldBeNil) @@ -366,19 +601,10 @@ func TestConfigReloader(t *testing.T) { So(err, ShouldBeNil) // wait for config reload - time.Sleep(2 * time.Second) + time.Sleep(3 * time.Second) data, err := os.ReadFile(logFile.Name()) So(err, ShouldBeNil) - So(string(data), ShouldNotContainSubstring, "reloaded params") - So(string(data), ShouldNotContainSubstring, "new configuration settings") - So(string(data), ShouldContainSubstring, "\"URLs\":[\"http://localhost:8080\"]") - So(string(data), ShouldContainSubstring, "\"TLSVerify\":false") - So(string(data), ShouldContainSubstring, "\"OnDemand\":true") - So(string(data), ShouldContainSubstring, "\"MaxRetries\":3") - So(string(data), ShouldContainSubstring, "\"CertDir\":\"\"") - So(string(data), ShouldContainSubstring, "\"Prefix\":\"zot-test\"") - So(string(data), ShouldContainSubstring, "\"Regex\":\".*\"") - So(string(data), ShouldContainSubstring, "\"Semver\":true") + So(string(data), ShouldContainSubstring, "\"Extensions\":null") }) } diff --git a/pkg/cli/extensions_test.go b/pkg/cli/extensions_test.go index 55e8a6166b..bdceb82ecc 100644 --- a/pkg/cli/extensions_test.go +++ b/pkg/cli/extensions_test.go @@ -148,7 +148,7 @@ func testWithMetricsEnabled(cfgContentFormat string) { data, err := os.ReadFile(logFile.Name()) So(err, ShouldBeNil) So(string(data), ShouldContainSubstring, - "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":{\"Enable\":true,\"Prometheus\":{\"Path\":\"/metrics\"}},\"Scrub\":null,\"Lint\":null}") //nolint:lll // gofumpt conflicts with lll + "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":{\"Enable\":true,\"Prometheus\":{\"Path\":\"/metrics\"}},\"Scrub\":null,\"Lint\":null,\"SysConfig\":null}") //nolint:lll // gofumpt conflicts with lll } func TestServeMetricsExtension(t *testing.T) { @@ -272,7 +272,7 @@ func TestServeMetricsExtension(t *testing.T) { data, err := os.ReadFile(logFile.Name()) So(err, ShouldBeNil) So(string(data), ShouldContainSubstring, - "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":{\"Enable\":false,\"Prometheus\":{\"Path\":\"/metrics\"}},\"Scrub\":null,\"Lint\":null}}") //nolint:lll // gofumpt conflicts with lll + "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":{\"Enable\":false,\"Prometheus\":{\"Path\":\"/metrics\"}},\"Scrub\":null,\"Lint\":null,\"SysConfig\":null}}") //nolint:lll // gofumpt conflicts with lll }) } @@ -473,7 +473,7 @@ func TestServeScrubExtension(t *testing.T) { // Even if in config we specified scrub interval=1h, the minimum interval is 2h dataStr := string(data) So(dataStr, ShouldContainSubstring, - "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":null,\"Scrub\":{\"Enable\":true,\"Interval\":3600000000000},\"Lint\":null") //nolint:lll // gofumpt conflicts with lll + "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":null,\"Scrub\":{\"Enable\":true,\"Interval\":3600000000000},\"Lint\":null,\"SysConfig\":null") //nolint:lll // gofumpt conflicts with lll So(dataStr, ShouldContainSubstring, "Scrub interval set to too-short interval < 2h, changing scrub duration to 2 hours and continuing.") }) @@ -534,10 +534,9 @@ func TestServeScrubExtension(t *testing.T) { So(err, ShouldBeNil) data, err := os.ReadFile(logPath) So(err, ShouldBeNil) - defer os.Remove(logPath) // clean up dataStr := string(data) So(dataStr, ShouldContainSubstring, - "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":null,\"Scrub\":{\"Enable\":false,\"Interval\":86400000000000},\"Lint\":null}") //nolint:lll // gofumpt conflicts with lll + "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":null,\"Scrub\":{\"Enable\":false,\"Interval\":86400000000000},\"Lint\":null,\"SysConfig\":null") //nolint:lll // gofumpt conflicts with lll So(dataStr, ShouldContainSubstring, "Scrub config not provided, skipping scrub") So(dataStr, ShouldNotContainSubstring, "Scrub interval set to too-short interval < 2h, changing scrub duration to 2 hours and continuing.") @@ -572,14 +571,14 @@ func TestServeLintExtension(t *testing.T) { logPath, err := runCLIWithConfig(t.TempDir(), content) So(err, ShouldBeNil) + data, err := os.ReadFile(logPath) So(err, ShouldBeNil) - defer os.Remove(logPath) // clean up So(string(data), ShouldContainSubstring, - "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":{\"Enable\":true,\"MandatoryAnnotations\":") //nolint:lll // gofumpt conflicts with lll + "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":{\"Enable\":true,\"MandatoryAnnotations\":[\"annot1\"]},\"SysConfig\":null") //nolint:lll // gofumpt conflicts with lll }) - Convey("lint enabled", t, func(c C) { + Convey("lint disabled", t, func(c C) { content := `{ "storage": { "rootDirectory": "%s" @@ -603,9 +602,8 @@ func TestServeLintExtension(t *testing.T) { So(err, ShouldBeNil) data, err := os.ReadFile(logPath) So(err, ShouldBeNil) - defer os.Remove(logPath) // clean up So(string(data), ShouldContainSubstring, - "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":{\"Enable\":false,\"MandatoryAnnotations\":null}") //nolint:lll // gofumpt conflicts with lll + "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":{\"Enable\":false,\"MandatoryAnnotations\":null},\"SysConfig\":null") //nolint:lll // gofumpt conflicts with lll }) } @@ -640,7 +638,7 @@ func TestServeSearchEnabled(t *testing.T) { WaitTillTrivyDBDownloadStarted(tempDir) defer os.Remove(logPath) // clean up - substring := "\"Extensions\":{\"Search\":{\"Enable\":true,\"CVE\":{\"UpdateInterval\":86400000000000}},\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":null}" //nolint:lll // gofumpt conflicts with lll + substring := "\"Extensions\":{\"Search\":{\"Enable\":true,\"CVE\":{\"UpdateInterval\":86400000000000}}" found, err := readLogFileAndSearchString(logPath, substring, readLogFileTimeout) So(found, ShouldBeTrue) So(err, ShouldBeNil) @@ -685,7 +683,7 @@ func TestServeSearchEnabledCVE(t *testing.T) { // to avoid data race when multiple go routines write to trivy DB instance. WaitTillTrivyDBDownloadStarted(tempDir) - substring := "\"Extensions\":{\"Search\":{\"Enable\":true,\"CVE\":{\"UpdateInterval\":3600000000000}},\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":null}" //nolint:lll // gofumpt conflicts with lll + substring := "\"Extensions\":{\"Search\":{\"Enable\":true,\"CVE\":{\"UpdateInterval\":3600000000000}}" found, err := readLogFileAndSearchString(logPath, substring, readLogFileTimeout) So(found, ShouldBeTrue) So(err, ShouldBeNil) @@ -733,7 +731,7 @@ func TestServeSearchEnabledNoCVE(t *testing.T) { // to avoid data race when multiple go routines write to trivy DB instance. WaitTillTrivyDBDownloadStarted(tempDir) - substring := "\"Extensions\":{\"Search\":{\"Enable\":true,\"CVE\":{\"UpdateInterval\":86400000000000}},\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":null}" //nolint:lll // gofumpt conflicts with lll + substring := "\"Extensions\":{\"Search\":{\"Enable\":true,\"CVE\":{\"UpdateInterval\":86400000000000}}" found, err := readLogFileAndSearchString(logPath, substring, readLogFileTimeout) So(found, ShouldBeTrue) So(err, ShouldBeNil) @@ -776,10 +774,9 @@ func TestServeSearchDisabled(t *testing.T) { So(err, ShouldBeNil) data, err := os.ReadFile(logPath) So(err, ShouldBeNil) - defer os.Remove(logPath) // clean up dataStr := string(data) So(dataStr, ShouldContainSubstring, - "\"Extensions\":{\"Search\":{\"Enable\":false,\"CVE\":{\"UpdateInterval\":10800000000000}},\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":null}") //nolint:lll // gofumpt conflicts with lll + "\"Extensions\":{\"Search\":{\"Enable\":false,\"CVE\":{\"UpdateInterval\":10800000000000}}") //nolint:lll // gofumpt conflicts with lll So(dataStr, ShouldContainSubstring, "CVE config not provided, skipping CVE update") So(dataStr, ShouldNotContainSubstring, "CVE update interval set to too-short interval < 2h, changing update duration to 2 hours and continuing.") diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 3411db4d9d..4ffa71e615 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -5,35 +5,17 @@ import ( "fmt" "net" "net/http" - "strconv" - "strings" - "time" - glob "github.com/bmatcuk/doublestar/v4" - "github.com/mitchellh/mapstructure" distspec "github.com/opencontainers/distribution-spec/specs-go" "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "github.com/spf13/viper" - "zotregistry.io/zot/errors" "zotregistry.io/zot/pkg/api" "zotregistry.io/zot/pkg/api/config" - "zotregistry.io/zot/pkg/api/constants" - extconf "zotregistry.io/zot/pkg/extensions/config" "zotregistry.io/zot/pkg/extensions/monitoring" - "zotregistry.io/zot/pkg/storage" ) -// metadataConfig reports metadata after parsing, which we use to track -// errors. -func metadataConfig(md *mapstructure.Metadata) viper.DecoderConfigOption { - return func(c *mapstructure.DecoderConfig) { - c.Metadata = md - } -} - -func newServeCmd(conf *config.Config) *cobra.Command { +func newServeCmd() *cobra.Command { // "serve" serveCmd := &cobra.Command{ Use: "serve ", @@ -42,25 +24,18 @@ func newServeCmd(conf *config.Config) *cobra.Command { Long: "`serve` stores and distributes OCI images", Run: func(cmd *cobra.Command, args []string) { if len(args) > 0 { - if err := LoadConfiguration(conf, args[0]); err != nil { + hotReloader, err := NewHotReloader(args[0]) + if err != nil { panic(err) } - } - - ctlr := api.NewController(conf) - // config reloader - hotReloader, err := NewHotReloader(ctlr, args[0]) - if err != nil { - panic(err) - } - - /* context used to cancel go routines so that - we can change their config on the fly (restart routines with different config) */ - reloaderCtx := hotReloader.Start() + hotReloader.Start() + } else { + if err := cmd.Usage(); err != nil { + panic(err) + } - if err := ctlr.Run(reloaderCtx); err != nil { - panic(err) + return } }, } @@ -77,7 +52,7 @@ func newScrubCmd(conf *config.Config) *cobra.Command { Long: "`scrub` checks manifest/blob integrity", Run: func(cmd *cobra.Command, args []string) { if len(args) > 0 { - if err := LoadConfiguration(conf, args[0]); err != nil { + if err := config.LoadFromFile(args[0], conf); err != nil { panic(err) } } else { @@ -89,18 +64,8 @@ func newScrubCmd(conf *config.Config) *cobra.Command { } // checking if the server is already running - req, err := http.NewRequestWithContext(context.Background(), - http.MethodGet, - fmt.Sprintf("http://%s/v2", net.JoinHostPort(conf.HTTP.Address, conf.HTTP.Port)), - nil) - if err != nil { - log.Error().Err(err).Msg("unable to create a new http request") - panic(err) - } - - response, err := http.DefaultClient.Do(req) - if err == nil { - response.Body.Close() + ok := isServerRunning(conf.HTTP.Address, conf.HTTP.Port) + if ok { log.Warn().Msg("The server is running, in order to perform the scrub command the server should be shut down") panic("Error: server is running") } else { @@ -108,7 +73,7 @@ func newScrubCmd(conf *config.Config) *cobra.Command { ctlr := api.NewController(conf) ctlr.Metrics = monitoring.NewMetricsServer(false, ctlr.Log) - if err := ctlr.InitImageStore(context.Background()); err != nil { + if err := ctlr.InitImageStore(); err != nil { panic(err) } @@ -134,11 +99,13 @@ func newVerifyCmd(conf *config.Config) *cobra.Command { Long: "`verify` validates a zot config file", Run: func(cmd *cobra.Command, args []string) { if len(args) > 0 { - if err := LoadConfiguration(conf, args[0]); err != nil { + if err := config.LoadFromFile(args[0], conf); err != nil { + log.Error().Err(err).Str("path", args[0]).Msgf("invalid config file %s", args[0]) + panic(err) } - log.Info().Msgf("Config file %s is valid", args[0]) + log.Info().Msgf("config file %s is valid", args[0]) } }, } @@ -167,7 +134,7 @@ func NewServerRootCmd() *cobra.Command { } // "serve" - rootCmd.AddCommand(newServeCmd(conf)) + rootCmd.AddCommand(newServeCmd()) // "verify" rootCmd.AddCommand(newVerifyCmd(conf)) // "scrub" @@ -205,416 +172,23 @@ func NewCliRootCmd() *cobra.Command { return rootCmd } -func validateStorageConfig(cfg *config.Config) error { - expConfigMap := make(map[string]config.StorageConfig, 0) - - defaultRootDir := cfg.Storage.RootDirectory - - for _, storageConfig := range cfg.Storage.SubPaths { - if strings.EqualFold(defaultRootDir, storageConfig.RootDirectory) { - log.Error().Err(errors.ErrBadConfig).Msg("storage subpaths cannot use default storage root directory") - - return errors.ErrBadConfig - } - - expConfig, ok := expConfigMap[storageConfig.RootDirectory] - if ok { - equal := expConfig.ParamsEqual(storageConfig) - if !equal { - log.Error().Err(errors.ErrBadConfig).Msg("storage config with same root directory should have same parameters") - - return errors.ErrBadConfig - } - } else { - expConfigMap[storageConfig.RootDirectory] = storageConfig - } - } - - return nil -} - -func validateConfiguration(config *config.Config) error { - if err := validateHTTP(config); err != nil { - return err - } - - if err := validateGC(config); err != nil { - return err - } - - if err := validateLDAP(config); err != nil { - return err - } - - if err := validateSync(config); err != nil { - return err - } - - if err := validateStorageConfig(config); err != nil { - return err - } - - // check authorization config, it should have basic auth enabled or ldap - if config.HTTP.RawAccessControl != nil { - // checking for anonymous policy only authorization config: no users, no policies but anonymous policy - if err := validateAuthzPolicies(config); err != nil { - return err - } - } - - if len(config.Storage.StorageDriver) != 0 { - // enforce s3 driver in case of using storage driver - if config.Storage.StorageDriver["name"] != storage.S3StorageDriverName { - log.Error().Err(errors.ErrBadConfig).Msgf("unsupported storage driver: %s", config.Storage.StorageDriver["name"]) - - return errors.ErrBadConfig - } - - // enforce filesystem storage in case sync feature is enabled - if config.Extensions != nil && config.Extensions.Sync != nil { - log.Error().Err(errors.ErrBadConfig).Msg("sync supports only filesystem storage") - - return errors.ErrBadConfig - } - } - - // enforce s3 driver on subpaths in case of using storage driver - if config.Storage.SubPaths != nil { - if len(config.Storage.SubPaths) > 0 { - subPaths := config.Storage.SubPaths - - for route, storageConfig := range subPaths { - if len(storageConfig.StorageDriver) != 0 { - if storageConfig.StorageDriver["name"] != storage.S3StorageDriverName { - log.Error().Err(errors.ErrBadConfig).Str("subpath", - route).Msgf("unsupported storage driver: %s", storageConfig.StorageDriver["name"]) - - return errors.ErrBadConfig - } - } - } - } - } - - // check glob patterns in authz config are compilable - if config.AccessControl != nil { - for pattern := range config.AccessControl.Repositories { - ok := glob.ValidatePattern(pattern) - if !ok { - log.Error().Err(glob.ErrBadPattern).Str("pattern", pattern).Msg("authorization pattern could not be compiled") - - return glob.ErrBadPattern - } - } - } - - return nil -} - -func validateAuthzPolicies(config *config.Config) error { - if (config.HTTP.Auth == nil || (config.HTTP.Auth.HTPasswd.Path == "" && config.HTTP.Auth.LDAP == nil)) && - !authzContainsOnlyAnonymousPolicy(config) { - log.Error().Err(errors.ErrBadConfig). - Msg("access control config requires httpasswd, ldap authentication " + - "or using only 'anonymousPolicy' policies") - - return errors.ErrBadConfig - } - - return nil -} - -func applyDefaultValues(config *config.Config, viperInstance *viper.Viper) { - defaultVal := true - - if config.Extensions == nil && viperInstance.Get("extensions") != nil { - config.Extensions = &extconf.ExtensionConfig{} - - extMap := viperInstance.GetStringMap("extensions") - _, ok := extMap["metrics"] - - if ok { - // we found a config like `"extensions": {"metrics": {}}` - // Note: In case metrics is not empty the config.Extensions will not be nil and we will not reach here - config.Extensions.Metrics = &extconf.MetricsConfig{} - } - - _, ok = extMap["search"] - if ok { - // we found a config like `"extensions": {"search": {}}` - // Note: In case search is not empty the config.Extensions will not be nil and we will not reach here - config.Extensions.Search = &extconf.SearchConfig{} - } - - _, ok = extMap["scrub"] - if ok { - // we found a config like `"extensions": {"scrub:": {}}` - // Note: In case scrub is not empty the config.Extensions will not be nil and we will not reach here - config.Extensions.Scrub = &extconf.ScrubConfig{} - } - } - - if config.Extensions != nil { - if config.Extensions.Sync != nil { - if config.Extensions.Sync.Enable == nil { - config.Extensions.Sync.Enable = &defaultVal - } - - for id, regCfg := range config.Extensions.Sync.Registries { - if regCfg.TLSVerify == nil { - config.Extensions.Sync.Registries[id].TLSVerify = &defaultVal - } - } - } - - if config.Extensions.Search != nil { - if config.Extensions.Search.Enable == nil { - config.Extensions.Search.Enable = &defaultVal - } - - if config.Extensions.Search.CVE == nil { - config.Extensions.Search.CVE = &extconf.CVEConfig{UpdateInterval: 24 * time.Hour} //nolint: gomnd - } - } - - if config.Extensions.Metrics != nil { - if config.Extensions.Metrics.Enable == nil { - config.Extensions.Metrics.Enable = &defaultVal - } - - if config.Extensions.Metrics.Prometheus == nil { - config.Extensions.Metrics.Prometheus = &extconf.PrometheusConfig{Path: constants.DefaultMetricsExtensionRoute} - } - } - - if config.Extensions.Scrub != nil { - if config.Extensions.Scrub.Enable == nil { - config.Extensions.Scrub.Enable = &defaultVal - } - - if config.Extensions.Scrub.Interval == 0 { - config.Extensions.Scrub.Interval = 24 * time.Hour //nolint: gomnd - } - } - } - - if !config.Storage.GC && viperInstance.Get("storage::gcdelay") == nil { - config.Storage.GCDelay = 0 - } -} - -func updateDistSpecVersion(config *config.Config) { - if config.DistSpecVersion == distspec.Version { - return - } - - log.Warn(). - Msgf("config dist-spec version: %s differs from version actually used: %s", - config.DistSpecVersion, distspec.Version) - - config.DistSpecVersion = distspec.Version -} - -func LoadConfiguration(config *config.Config, configPath string) error { - // Default is dot (.) but because we allow glob patterns in authz - // we need another key delimiter. - viperInstance := viper.NewWithOptions(viper.KeyDelimiter("::")) - - viperInstance.SetConfigFile(configPath) - - if err := viperInstance.ReadInConfig(); err != nil { - log.Error().Err(err).Msg("error while reading configuration") - - return err - } - - metaData := &mapstructure.Metadata{} - if err := viperInstance.Unmarshal(&config, metadataConfig(metaData)); err != nil { - log.Error().Err(err).Msg("error while unmarshalling new config") - - return err - } - - if len(metaData.Keys) == 0 { - log.Error().Err(errors.ErrBadConfig).Msgf("config doesn't contain any key:value pair") - - return errors.ErrBadConfig - } - - if len(metaData.Unused) > 0 { - log.Error().Err(errors.ErrBadConfig).Msgf("unknown keys: %v", metaData.Unused) - - return errors.ErrBadConfig - } - - err := config.LoadAccessControlConfig(viperInstance) +func isServerRunning(address, port string) bool { + // checking if the server is already running + req, err := http.NewRequestWithContext(context.Background(), + http.MethodGet, + fmt.Sprintf("http://%s/v2", net.JoinHostPort(address, port)), + nil) if err != nil { - log.Error().Err(err).Msg("unable to unmarshal config's accessControl") - - return err + log.Error().Err(err).Msg("unable to create a new http request") + panic(err) } - // defaults - applyDefaultValues(config, viperInstance) - - // various config checks - if err := validateConfiguration(config); err != nil { - return err - } - - // update distSpecVersion - updateDistSpecVersion(config) - - return nil -} - -func authzContainsOnlyAnonymousPolicy(cfg *config.Config) bool { - adminPolicy := cfg.AccessControl.AdminPolicy - anonymousPolicyPresent := false - - log.Info().Msg("checking if anonymous authorization is the only type of authorization policy configured") - - if len(adminPolicy.Actions)+len(adminPolicy.Users) > 0 { - log.Info().Msg("admin policy detected, anonymous authorization is not the only authorization policy configured") - + response, err := http.DefaultClient.Do(req) + if err != nil { return false } - for _, repository := range cfg.AccessControl.Repositories { - if len(repository.DefaultPolicy) > 0 { - log.Info().Interface("repository", repository). - Msg("default policy detected, anonymous authorization is not the only authorization policy configured") - - return false - } - - if len(repository.AnonymousPolicy) > 0 { - log.Info().Msg("anonymous authorization detected") - - anonymousPolicyPresent = true - } - - for _, policy := range repository.Policies { - if len(policy.Actions)+len(policy.Users) > 0 { - log.Info().Interface("repository", repository). - Msg("repository with non-empty policy detected, " + - "anonymous authorization is not the only authorization policy configured") - - return false - } - } - } - - return anonymousPolicyPresent -} - -func validateLDAP(config *config.Config) error { - // LDAP mandatory configuration - if config.HTTP.Auth != nil && config.HTTP.Auth.LDAP != nil { - ldap := config.HTTP.Auth.LDAP - if ldap.UserAttribute == "" { - log.Error().Str("userAttribute", ldap.UserAttribute). - Msg("invalid LDAP configuration, missing mandatory key: userAttribute") - - return errors.ErrLDAPConfig - } - - if ldap.Address == "" { - log.Error().Str("address", ldap.Address). - Msg("invalid LDAP configuration, missing mandatory key: address") - - return errors.ErrLDAPConfig - } - - if ldap.BaseDN == "" { - log.Error().Str("basedn", ldap.BaseDN). - Msg("invalid LDAP configuration, missing mandatory key: basedn") - - return errors.ErrLDAPConfig - } - } - - return nil -} - -func validateHTTP(config *config.Config) error { - if config.HTTP.Port != "" { - port, err := strconv.ParseInt(config.HTTP.Port, 10, 64) - if err != nil || (port < 0 || port > 65535) { - log.Error().Str("port", config.HTTP.Port).Msg("invalid port") - - return errors.ErrBadConfig - } - - fmt.Printf("HTTP port %d\n", port) - } - - return nil -} - -func validateGC(config *config.Config) error { - // enforce GC params - if config.Storage.GCDelay < 0 { - log.Error().Err(errors.ErrBadConfig). - Msgf("invalid garbage-collect delay %v specified", config.Storage.GCDelay) - - return errors.ErrBadConfig - } - - if config.Storage.GCInterval < 0 { - log.Error().Err(errors.ErrBadConfig). - Msgf("invalid garbage-collect interval %v specified", config.Storage.GCInterval) - - return errors.ErrBadConfig - } - - if !config.Storage.GC { - if config.Storage.GCDelay != 0 { - log.Warn().Err(errors.ErrBadConfig). - Msg("garbage-collect delay specified without enabling garbage-collect, will be ignored") - } - - if config.Storage.GCInterval != 0 { - log.Warn().Err(errors.ErrBadConfig). - Msg("periodic garbage-collect interval specified without enabling garbage-collect, will be ignored") - } - } - - return nil -} - -func validateSync(config *config.Config) error { - // check glob patterns in sync config are compilable - if config.Extensions != nil && config.Extensions.Sync != nil { - for id, regCfg := range config.Extensions.Sync.Registries { - // check retry options are configured for sync - if regCfg.MaxRetries != nil && regCfg.RetryDelay == nil { - log.Error().Err(errors.ErrBadConfig).Msgf("extensions.sync.registries[%d].retryDelay"+ - " is required when using extensions.sync.registries[%d].maxRetries", id, id) - - return errors.ErrBadConfig - } - - if regCfg.Content != nil { - for _, content := range regCfg.Content { - ok := glob.ValidatePattern(content.Prefix) - if !ok { - log.Error().Err(glob.ErrBadPattern).Str("prefix", content.Prefix).Msg("sync prefix could not be compiled") - - return glob.ErrBadPattern - } - - if content.StripPrefix && !strings.Contains(content.Prefix, "/*") && content.Destination == "/" { - log.Error().Err(errors.ErrBadConfig). - Interface("sync content", content). - Msg("sync config: can not use stripPrefix true and destination '/' without using glob patterns in prefix") - - return errors.ErrBadConfig - } - } - } - } - } + response.Body.Close() - return nil + return true } diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index f46f613db5..5779cfcbeb 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -89,6 +89,22 @@ func TestServe(t *testing.T) { os.Args = []string{"cli_test", "serve", tmpfile.Name()} So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) }) + + Convey("wrong path to htpasswd", func(c C) { + tmpfile, err := os.CreateTemp("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) // clean up + content := []byte(`{"distSpecVersion":"1.0.1-dev", + "http":{"address":"127.0.0.1","port":"8080", + "auth":{"htpasswd":{"path":"/A/B/C/htpasswd"}}}, + "log":{"level":"debug"}}`) + _, err = tmpfile.Write(content) + So(err, ShouldBeNil) + err = tmpfile.Close() + So(err, ShouldBeNil) + os.Args = []string{"cli_test", "serve", tmpfile.Name()} + So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) + }) }) } @@ -110,13 +126,67 @@ func TestVerify(t *testing.T) { So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) }) + Convey("Test verify unwritable rootDir", t, func() { + tmpfile, err := os.CreateTemp("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) // clean up + content := []byte(`{"storage":{"rootDirectory":"/a/b/c/d"}, + "http":{"address":"127.0.0.1","port":"8080","realm":"zot", + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) + err = os.WriteFile(tmpfile.Name(), content, 0o0600) + So(err, ShouldBeNil) + os.Args = []string{"cli_test", "verify", tmpfile.Name()} + So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) + + content = []byte(`{"storage":{"rootDirectory":"/tmp/zot", + "subPaths": {"/a": {"rootDirectory": "/a/b/c/d"}}}, + "http":{"address":"127.0.0.1","port":"8080","realm":"zot", + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) + err = os.WriteFile(tmpfile.Name(), content, 0o0600) + So(err, ShouldBeNil) + os.Args = []string{"cli_test", "verify", tmpfile.Name()} + So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) + }) + + Convey("Test verify invalid certs", t, func() { + tmpfile, err := os.CreateTemp("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) // clean up + content := []byte(`{"distSpecVersion":"1.0.1-dev","storage":{"rootDirectory":"/tmp/zot"}, + "http":{"address":"127.0.0.1","port":"8080","realm":"zot", + "tls":{"cert":"server.cert"}}, + "log":{"level":"debug"}}`) + err = os.WriteFile(tmpfile.Name(), content, 0o0600) + So(err, ShouldBeNil) + os.Args = []string{"cli_test", "verify", tmpfile.Name()} + So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) + + content = []byte(`{"distSpecVersion":"1.0.1-dev","storage":{"rootDirectory":"/tmp/zot"}, + "http":{"address":"127.0.0.1","port":"8080","realm":"zot", + "tls":{"cacert":"ca.crt"}}, + "log":{"level":"debug"}}`) + err = os.WriteFile(tmpfile.Name(), content, 0o0600) + So(err, ShouldBeNil) + os.Args = []string{"cli_test", "verify", tmpfile.Name()} + So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) + + content = []byte(`{"distSpecVersion":"1.0.1-dev","storage":{"rootDirectory":"/tmp/zot"}, + "http":{"address":"127.0.0.1","port":"8080","realm":"zot", + "tls":{"key":"server.key"}}, + "log":{"level":"debug"}}`) + err = os.WriteFile(tmpfile.Name(), content, 0o0600) + So(err, ShouldBeNil) + os.Args = []string{"cli_test", "verify", tmpfile.Name()} + So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) + }) + Convey("Test verify storage driver different than s3", t, func(c C) { tmpfile, err := os.CreateTemp("", "zot-test*.json") So(err, ShouldBeNil) defer os.Remove(tmpfile.Name()) // clean up content := []byte(`{"storage":{"rootDirectory":"/tmp/zot", "storageDriver": {"name": "gcs"}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) _, err = tmpfile.Write(content) So(err, ShouldBeNil) err = tmpfile.Close() @@ -130,9 +200,9 @@ func TestVerify(t *testing.T) { So(err, ShouldBeNil) defer os.Remove(tmpfile.Name()) // clean up content := []byte(`{"storage":{"rootDirectory":"/tmp/zot", "storageDriver": {"name": "s3"}, - "subPaths": {"/a": {"rootDirectory": "/zot-a","storageDriver": {"name": "gcs"}}}}, + "subPaths": {"/a": {"rootDirectory": "/tmp/zot-a","storageDriver": {"name": "gcs"}}}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) _, err = tmpfile.Write(content) So(err, ShouldBeNil) err = tmpfile.Close() @@ -146,9 +216,9 @@ func TestVerify(t *testing.T) { So(err, ShouldBeNil) defer os.Remove(tmpfile.Name()) // clean up content := []byte(`{"storage":{"rootDirectory":"/tmp/zot", - "subPaths": {"/a": {"rootDirectory": "/zot-a"},"/b": {"rootDirectory": "/zot-a"}}}, + "subPaths": {"/a": {"rootDirectory": "/tmp/zot-a"},"/b": {"rootDirectory": "/tmp/zot-a"}}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) os.Args = []string{"cli_test", "verify", tmpfile.Name()} @@ -157,10 +227,10 @@ func TestVerify(t *testing.T) { // sub paths that point to same directory should have same storage config. content = []byte(`{"storage":{"rootDirectory":"/tmp/zot", - "subPaths": {"/a": {"rootDirectory": "/zot-a","dedupe":"true"}, - "/b": {{"rootDirectory": "/zot-a","dedupe":"false"}}}}, + "subPaths": {"/a": {"rootDirectory": "/tmp/zot-a","dedupe":"true"}, + "/b": {"rootDirectory": "/tmp/zot-a","dedupe":"false"}}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) os.Args = []string{"cli_test", "verify", tmpfile.Name()} @@ -168,39 +238,39 @@ func TestVerify(t *testing.T) { // sub paths that point to default root directory should not be allowed. content = []byte(`{"storage":{"rootDirectory":"/tmp/zot", - "subPaths": {"/a": {"rootDirectory": "/tmp/zot","dedupe":"true"},"/b": {{"rootDirectory": "/zot-a"}}}}, + "subPaths": {"/a": {"rootDirectory": "/tmp/zot","dedupe":"true"},"/b": {"rootDirectory": "/tmp/zot-a"}}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) os.Args = []string{"cli_test", "verify", tmpfile.Name()} So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) content = []byte(`{"storage":{"rootDirectory":"/tmp/zot", - "subPaths": {"/a": {"rootDirectory": "/zot-a","dedupe":"true","gc":"true"}, - "/b": {"rootDirectory": "/zot-a","dedupe":"true","gc":"false"}}}}, + "subPaths": {"/a": {"rootDirectory": "/tmp/zot-a","dedupe":"true","gc":"true"}, + "/b": {"rootDirectory": "/tmp/zot-a","dedupe":"true","gc":"false"}}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) os.Args = []string{"cli_test", "verify", tmpfile.Name()} So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) content = []byte(`{"storage":{"rootDirectory":"/tmp/zot", - "subPaths": {"/a": {"rootDirectory": "/zot-a","dedupe":"true","gc":"true"}, - "/b": {"rootDirectory": "/zot-a","dedupe":"true","gc":"true","gcDelay":"1s"}}}}, + "subPaths": {"/a": {"rootDirectory": "/tmp/zot-a","dedupe":"true","gc":"true"}, + "/b": {"rootDirectory": "/tmp/zot-a","dedupe":"true","gc":"true","gcDelay":"1s"}}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) os.Args = []string{"cli_test", "verify", tmpfile.Name()} So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) content = []byte(`{"storage":{"rootDirectory":"/tmp/zot", - "subPaths": {"/a": {"rootDirectory": "/zot-a","dedupe":"true","gc":"true","gcDelay":"1s","gcInterval":"1s"}, - "/b": {"rootDirectory": "/zot-a","dedupe":"true","gc":"true","gcDelay":"1s"}}}}, + "subPaths": {"/a": {"rootDirectory": "/tmp/zot-a","dedupe":"true","gc":"true","gcDelay":"1s","gcInterval":"1s"}, + "/b": {"rootDirectory": "/tmp/zot-a","dedupe":"true","gc":"true","gcDelay":"1s"}}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) os.Args = []string{"cli_test", "verify", tmpfile.Name()} @@ -208,9 +278,9 @@ func TestVerify(t *testing.T) { content = []byte(`{"storage":{"rootDirectory":"/tmp/zot", "subPaths": {"/a": {"rootDirectory": "/tmp/zot","dedupe":"true","gc":"true","gcDelay":"1s","gcInterval":"1s"}, - "/b": {"rootDirectory": "/zot-a","dedupe":"true","gc":"true","gcDelay":"1s"}}}}, + "/b": {"rootDirectory": "/tmp/zot-a","dedupe":"true","gc":"true","gcDelay":"1s"}}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) os.Args = []string{"cli_test", "verify", tmpfile.Name()} @@ -239,7 +309,7 @@ func TestVerify(t *testing.T) { defer os.Remove(tmpfile.Name()) // clean up content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}, + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}, "accessControl":{"adminPolicy":{"users":["admin"], "actions":["read","create","update","delete"]}}}}`) _, err = tmpfile.Write(content) @@ -256,8 +326,8 @@ func TestVerify(t *testing.T) { defer os.Remove(tmpfile.Name()) // clean up content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "accessControl":{"**":{"anonymousPolicy": ["read", "create"]}, - "/repo":{"anonymousPolicy": ["read", "create"]} + "accessControl":{"repositories":{"**":{"anonymousPolicy": ["read", "create"]}, + "/repo":{"anonymousPolicy": ["read", "create"]}} }}}`) _, err = tmpfile.Write(content) So(err, ShouldBeNil) @@ -333,13 +403,30 @@ func TestVerify(t *testing.T) { So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) }) + Convey("Test verify anonymous-only authorization fail", t, func(c C) { + tmpfile, err := os.CreateTemp("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) // clean up + content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"}, + "http":{"address":"127.0.0.1","port":"8080","realm":"zot", + "accessControl":{"repositories":{"**":{"defaultPolicy": ["read", "create"]}, + "/repo":{"anonymousPolicy": ["read", "create"]} + }}}}`) + _, err = tmpfile.Write(content) + So(err, ShouldBeNil) + err = tmpfile.Close() + So(err, ShouldBeNil) + os.Args = []string{"cli_test", "verify", tmpfile.Name()} + So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) + }) + Convey("Test verify w/ sync and w/o filesystem storage", t, func(c C) { tmpfile, err := os.CreateTemp("", "zot-test*.json") So(err, ShouldBeNil) defer os.Remove(tmpfile.Name()) // clean up content := []byte(`{"storage":{"rootDirectory":"/tmp/zot", "storageDriver": {"name": "s3"}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}, + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}, "extensions":{"sync": {"registries": [{"urls":["localhost:9999"], "maxRetries": 1, "retryDelay": "10s"}]}}}`) _, err = tmpfile.Write(content) @@ -356,7 +443,7 @@ func TestVerify(t *testing.T) { defer os.Remove(tmpfile.Name()) // clean up content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}, + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}, "extensions":{"sync": {"registries": [{"urls":["localhost:9999"], "maxRetries": 1, "retryDelay": "10s"}]}}}`) _, err = tmpfile.Write(content) @@ -373,7 +460,7 @@ func TestVerify(t *testing.T) { defer os.Remove(tmpfile.Name()) // clean up content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}, + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}, "extensions":{"sync": {"registries": [{"urls":["localhost:9999"], "maxRetries": 1, "retryDelay": "10s", "content": [{"prefix":"[repo%^&"}]}]}}}`) @@ -391,7 +478,7 @@ func TestVerify(t *testing.T) { defer os.Remove(tmpfile.Name()) // clean up content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}, + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}, "extensions":{"sync": {"registries": [{"urls":["localhost:9999"], "maxRetries": 1, "retryDelay": "10s", "content": [{"prefix":"zot-repo","stripPrefix":true,"destination":"/"}]}]}}}`) @@ -409,7 +496,7 @@ func TestVerify(t *testing.T) { defer os.Remove(tmpfile.Name()) // clean up content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}, + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}, "extensions":{"sync": {"registries": [{"urls":["localhost:9999"], "maxRetries": 1, "retryDelay": "10s", "content": [{"prefix":"zot-repo/*","stripPrefix":true,"destination":"/"}]}]}}}`) @@ -428,8 +515,8 @@ func TestVerify(t *testing.T) { defer os.Remove(tmpfile.Name()) // clean up content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}, - "accessControl":{"[":{"policies":[],"anonymousPolicy":[]}}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}, + "accessControl":{"repositories":{"[":{"policies":[],"anonymousPolicy":[]}}}}}`) _, err = tmpfile.Write(content) So(err, ShouldBeNil) err = tmpfile.Close() @@ -444,7 +531,7 @@ func TestVerify(t *testing.T) { defer os.Remove(tmpfile.Name()) // clean up content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}, + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}, "extensions":{"sync": {"registries": [{"urls":["localhost:9999"], "maxRetries": 1, "retryDelay": "10s", "content": [{"prefix":"repo**"}]}]}}}`) @@ -463,7 +550,7 @@ func TestVerify(t *testing.T) { defer os.Remove(tmpfile.Name()) // clean up content := []byte(`{"storage":{"rootDirectory":"/tmp/zot"}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}, + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}, "extensions":{"sync": {"registries": [{"urls":["localhost:9999"], "maxRetries": 10, "content": [{"prefix":"repo**"}]}]}}}`) _, err = tmpfile.Write(content) @@ -537,6 +624,22 @@ func TestVerify(t *testing.T) { So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) }) + Convey("Test verify config extension w/o authz", t, func(c C) { + tmpfile, err := os.CreateTemp("", "zot-test*.json") + So(err, ShouldBeNil) + defer os.Remove(tmpfile.Name()) // clean up + content := []byte(`{"storage":{"rootDirectory":"/tmp/zot", "storageDriver": {"name": "s3"}}, + "http":{"address":"127.0.0.1","port":"8080","realm":"zot", + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}, + "extensions":{"sysconfig":{"enable": true}}}`) + _, err = tmpfile.Write(content) + So(err, ShouldBeNil) + err = tmpfile.Close() + So(err, ShouldBeNil) + os.Args = []string{"cli_test", "verify", tmpfile.Name()} + So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic) + }) + Convey("Test verify good config", t, func(c C) { tmpfile, err := os.CreateTemp("", "zot-test*.json") So(err, ShouldBeNil) @@ -554,12 +657,11 @@ func TestVerify(t *testing.T) { }) } +func loadConfiguration(cfg *config.Config, configPath string) error { + return config.LoadFromFile(configPath, cfg) +} + func TestLoadConfig(t *testing.T) { - Convey("Test viper load config", t, func(c C) { - config := config.New() - err := cli.LoadConfiguration(config, "../../examples/config-policy.json") - So(err, ShouldBeNil) - }) Convey("Test subpath config combination", t, func(c C) { config := config.New() tmpfile, err := os.CreateTemp("", "zot-test*.json") @@ -567,42 +669,42 @@ func TestLoadConfig(t *testing.T) { defer os.Remove(tmpfile.Name()) content := []byte(`{"storage":{"rootDirectory":"/tmp/zot", "subPaths": {"/a": {"rootDirectory": "/tmp/zot","dedupe":"true","gc":"true","gcDelay":"1s","gcInterval":"1s"}, - "/b": {"rootDirectory": "/zot-a","dedupe":"true","gc":"true","gcDelay":"1s"}}}, + "/b": {"rootDirectory": "/tmp/zot-a","dedupe":"true","gc":"true","gcDelay":"1s"}}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) - err = cli.LoadConfiguration(config, tmpfile.Name()) + err = loadConfiguration(config, tmpfile.Name()) So(err, ShouldNotBeNil) content = []byte(`{"storage":{"rootDirectory":"/tmp/zot", - "subPaths": {"/a": {"rootDirectory": "/zot-a","dedupe":"true","gc":"true","gcDelay":"1s","gcInterval":"1s"}, - "/b": {"rootDirectory": "/zot-a","dedupe":"true","gc":"true","gcDelay":"1s"}}}, + "subPaths": {"/a": {"rootDirectory": "/tmp/zot-a","dedupe":"true","gc":"true","gcDelay":"1s","gcInterval":"1s"}, + "/b": {"rootDirectory": "/tmp/zot-a","dedupe":"true","gc":"true","gcDelay":"1s"}}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) - err = cli.LoadConfiguration(config, tmpfile.Name()) + err = loadConfiguration(config, tmpfile.Name()) So(err, ShouldNotBeNil) content = []byte(`{"storage":{"rootDirectory":"/tmp/zot", - "subPaths": {"/a": {"rootDirectory": "/zot-a","dedupe":"true"}, - "/b": {"rootDirectory": "/zot-a","dedupe":"false"}}}, + "subPaths": {"/a": {"rootDirectory": "/tmp/zot-a","dedupe":"true"}, + "/b": {"rootDirectory": "/tmp/zot-a","dedupe":"false"}}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) - err = cli.LoadConfiguration(config, tmpfile.Name()) + err = loadConfiguration(config, tmpfile.Name()) So(err, ShouldNotBeNil) content = []byte(`{"storage":{"rootDirectory":"/tmp/zot", - "subPaths": {"/a": {"rootDirectory": "/zot-a","dedupe":"true"}, - "/b": {"rootDirectory": "/zot-a","dedupe":"true"}}}, + "subPaths": {"/a": {"rootDirectory": "/tmp/zot-a","dedupe":"true"}, + "/b": {"rootDirectory": "/tmp/zot-a","dedupe":"true"}}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) - err = cli.LoadConfiguration(config, tmpfile.Name()) + err = loadConfiguration(config, tmpfile.Name()) So(err, ShouldBeNil) }) @@ -613,33 +715,33 @@ func TestLoadConfig(t *testing.T) { defer os.Remove(tmpfile.Name()) content := []byte(`{"storage":{"rootDirectory":"/tmp/zot", - "subPaths": {"/a": {"rootDirectory": "/zot-a","dedupe":"true"}, - "/b": {"rootDirectory": "/zot-a","dedupe":"true"}}}, + "subPaths": {"/a": {"rootDirectory": "/tmp/zot-a","dedupe":"true"}, + "/b": {"rootDirectory": "/tmp/zot-a","dedupe":"true"}}}, "http":{"address":"127.0.0.1","port":"8080","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) - err = cli.LoadConfiguration(config, tmpfile.Name()) + err = loadConfiguration(config, tmpfile.Name()) So(err, ShouldBeNil) content = []byte(`{"storage":{"rootDirectory":"/tmp/zot", - "subPaths": {"/a": {"rootDirectory": "/zot-a","dedupe":"true"}, - "/b": {"rootDirectory": "/zot-a","dedupe":"true"}}}, + "subPaths": {"/a": {"rootDirectory": "/tmp/zot-a","dedupe":"true"}, + "/b": {"rootDirectory": "/tmp/zot-a","dedupe":"true"}}}, "http":{"address":"127.0.0.1","port":"-1","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) - err = cli.LoadConfiguration(config, tmpfile.Name()) + err = loadConfiguration(config, tmpfile.Name()) So(err, ShouldNotBeNil) content = []byte(`{"storage":{"rootDirectory":"/tmp/zot", - "subPaths": {"/a": {"rootDirectory": "/zot-a","dedupe":"true"}, - "/b": {"rootDirectory": "/zot-a","dedupe":"true"}}}, + "subPaths": {"/a": {"rootDirectory": "/tmp/zot-a","dedupe":"true"}, + "/b": {"rootDirectory": "/tmp/zot-a","dedupe":"true"}}}, "http":{"address":"127.0.0.1","port":"65536","realm":"zot", - "auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}}}`) + "auth":{"htpasswd":{"path":"../../test/data/htpasswd"},"failDelay":1}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) So(err, ShouldBeNil) - err = cli.LoadConfiguration(config, tmpfile.Name()) + err = loadConfiguration(config, tmpfile.Name()) So(err, ShouldNotBeNil) }) } @@ -647,13 +749,13 @@ func TestLoadConfig(t *testing.T) { func TestGC(t *testing.T) { Convey("Test GC config", t, func(c C) { config := config.New() - err := cli.LoadConfiguration(config, "../../examples/config-multiple.json") + err := loadConfiguration(config, "../../examples/config-multiple.json") So(err, ShouldBeNil) So(config.Storage.GCDelay, ShouldEqual, storage.DefaultGCDelay) - err = cli.LoadConfiguration(config, "../../examples/config-gc.json") + err = loadConfiguration(config, "../../examples/config-gc.json") So(err, ShouldBeNil) So(config.Storage.GCDelay, ShouldNotEqual, storage.DefaultGCDelay) - err = cli.LoadConfiguration(config, "../../examples/config-gc-periodic.json") + err = loadConfiguration(config, "../../examples/config-gc-periodic.json") So(err, ShouldBeNil) }) @@ -675,7 +777,7 @@ func TestGC(t *testing.T) { err = os.WriteFile(file.Name(), contents, 0o600) So(err, ShouldBeNil) - err = cli.LoadConfiguration(config, file.Name()) + err = loadConfiguration(config, file.Name()) So(err, ShouldBeNil) }) @@ -695,7 +797,7 @@ func TestGC(t *testing.T) { err = os.WriteFile(file.Name(), contents, 0o600) So(err, ShouldBeNil) - err = cli.LoadConfiguration(config, file.Name()) + err = loadConfiguration(config, file.Name()) So(err, ShouldBeNil) }) @@ -713,7 +815,7 @@ func TestGC(t *testing.T) { err = os.WriteFile(file.Name(), contents, 0o600) So(err, ShouldBeNil) - err = cli.LoadConfiguration(config, file.Name()) + err = loadConfiguration(config, file.Name()) So(err, ShouldNotBeNil) }) @@ -730,7 +832,7 @@ func TestGC(t *testing.T) { err = os.WriteFile(file.Name(), content, 0o600) So(err, ShouldBeNil) - err = cli.LoadConfiguration(config, file.Name()) + err = loadConfiguration(config, file.Name()) So(err, ShouldBeNil) So(config.Storage.GCDelay, ShouldEqual, 0) }) @@ -749,7 +851,7 @@ func TestGC(t *testing.T) { err = os.WriteFile(file.Name(), contents, 0o600) So(err, ShouldBeNil) - err = cli.LoadConfiguration(config, file.Name()) + err = loadConfiguration(config, file.Name()) So(err, ShouldNotBeNil) }) }) diff --git a/pkg/extensions/config/config.go b/pkg/extensions/config/config.go index 3527756e17..e5df611da7 100644 --- a/pkg/extensions/config/config.go +++ b/pkg/extensions/config/config.go @@ -12,11 +12,12 @@ type BaseConfig struct { } type ExtensionConfig struct { - Search *SearchConfig - Sync *sync.Config - Metrics *MetricsConfig - Scrub *ScrubConfig - Lint *LintConfig + Search *SearchConfig + Sync *sync.Config + Metrics *MetricsConfig + Scrub *ScrubConfig + Lint *LintConfig + SysConfig *SysConfig } type LintConfig struct { @@ -24,6 +25,10 @@ type LintConfig struct { MandatoryAnnotations []string } +type SysConfig struct { + Enable *bool +} + type SearchConfig struct { BaseConfig `mapstructure:",squash"` // CVE search diff --git a/pkg/extensions/extension-config-disabled.go b/pkg/extensions/extension-config-disabled.go new file mode 100644 index 0000000000..527c909879 --- /dev/null +++ b/pkg/extensions/extension-config-disabled.go @@ -0,0 +1,19 @@ +//go:build !config +// +build !config + +package extensions + +import ( + "github.com/gorilla/mux" + + "zotregistry.io/zot/pkg/api/config" + "zotregistry.io/zot/pkg/log" + "zotregistry.io/zot/pkg/storage" +) + +func SetupConfigRoutes(config *config.Config, configPath string, router *mux.Router, + storeController storage.StoreController, log log.Logger, +) { + log.Warn().Msg("skipping enabling config extension because given zot binary doesn't include this feature," + + "please build a binary that does so") +} diff --git a/pkg/extensions/extension-config.go b/pkg/extensions/extension-config.go new file mode 100644 index 0000000000..6af64c9d85 --- /dev/null +++ b/pkg/extensions/extension-config.go @@ -0,0 +1,88 @@ +//go:build config +// +build config + +package extensions + +import ( + "net/http" + "os" + "sync" + + "github.com/gorilla/mux" + + "zotregistry.io/zot/pkg/api/config" + "zotregistry.io/zot/pkg/api/constants" + "zotregistry.io/zot/pkg/log" + "zotregistry.io/zot/pkg/storage" +) + +type ConfigHandler struct { + configPath string + lock *sync.RWMutex + log log.Logger +} + +func NewConfigHandler(configPath string, log log.Logger) ConfigHandler { + return ConfigHandler{ + configPath: configPath, + lock: &sync.RWMutex{}, + log: log, + } +} + +func (handler *ConfigHandler) Handler(response http.ResponseWriter, request *http.Request) { + switch request.Method { + case http.MethodGet: + handler.log.Info().Msg("config ext: GET request") + handler.lock.RLock() + defer handler.lock.RUnlock() + + config, err := os.ReadFile(handler.configPath) + if err != nil { + handler.log.Error().Err(err).Msg("config ext: couldn't read config file") + response.WriteHeader(http.StatusInternalServerError) + + return + } + + _, _ = response.Write(config) + + return + case http.MethodPost: + handler.log.Info().Msg("config ext: POST request") + handler.lock.Lock() + defer handler.lock.Unlock() + + cfg := &config.Config{} + + writeConfig, err := config.LoadFromBufferWithWriter(handler.configPath, request.Body, cfg) + if err != nil { + handler.log.Error().Err(err).Msg("config ext: invalid config") + http.Error(response, "Invalid config", http.StatusBadRequest) + + return + } + + if err := writeConfig(); err != nil { + handler.log.Error().Err(err).Msg("config ext: couldn't write config") + http.Error(response, "Invalid config", http.StatusInternalServerError) + + return + } + + response.WriteHeader(http.StatusAccepted) + } +} + +func SetupConfigRoutes(config *config.Config, configPath string, router *mux.Router, + storeController storage.StoreController, l log.Logger, +) { + if config.Extensions.SysConfig != nil && *config.Extensions.SysConfig.Enable { + log := log.Logger{Logger: l.With().Caller().Timestamp().Logger()} + log.Info().Msg("setting up extensions routes") + + handler := NewConfigHandler(configPath, log) + + router.PathPrefix(constants.ExtConfigPrefix).Methods("GET", "POST").HandlerFunc(handler.Handler) + } +} diff --git a/pkg/extensions/extensions_test.go b/pkg/extensions/extensions_test.go index b84d77cc81..378e76feda 100644 --- a/pkg/extensions/extensions_test.go +++ b/pkg/extensions/extensions_test.go @@ -1,22 +1,33 @@ -//go:build sync || metrics -// +build sync metrics +//go:build sync || metrics || config +// +build sync metrics config package extensions_test import ( "context" + "fmt" + "net/http" "os" "testing" . "github.com/smartystreets/goconvey/convey" + "golang.org/x/crypto/bcrypt" + "gopkg.in/resty.v1" "zotregistry.io/zot/pkg/api" "zotregistry.io/zot/pkg/api/config" + "zotregistry.io/zot/pkg/api/constants" + "zotregistry.io/zot/pkg/cli" extconf "zotregistry.io/zot/pkg/extensions/config" "zotregistry.io/zot/pkg/extensions/sync" "zotregistry.io/zot/pkg/test" ) +const ( + username = "test" + password = "test" +) + func TestEnableExtension(t *testing.T) { Convey("Verify log if sync disabled in config", t, func() { globalDir := t.TempDir() @@ -107,3 +118,279 @@ func TestMetricsExtension(t *testing.T) { "Prometheus instrumentation Path not set, changing to '/metrics'.") }) } + +func TestConfigExtensionAPI(t *testing.T) { + testCases := []struct { + configContent string + getStatus int + postStatus int + putStatus int + }{ + { + configContent: `{ + "distSpecVersion": "0.1.0-dev", + "storage": { + "rootDirectory": "%s" + }, + "http": { + "address": "127.0.0.1", + "port": "%s", + "realm": "zot", + "auth": { + "htpasswd": { + "path": "%s" + }, + "failDelay": 1 + }, + "accessControl": { + "adminPolicy": { + "users": ["other"] + } + } + }, + "extensions":{ + "sysconfig": { + "enable": true + } + }, + "log": { + "level": "debug" + } + }`, + getStatus: http.StatusForbidden, + postStatus: http.StatusForbidden, + putStatus: http.StatusMethodNotAllowed, + }, + { + configContent: `{ + "distSpecVersion": "0.1.0-dev", + "storage": { + "rootDirectory": "%s" + }, + "http": { + "address": "127.0.0.1", + "port": "%s", + "realm": "zot", + "auth": { + "htpasswd": { + "path": "%s" + }, + "failDelay": 1 + }, + "accessControl": { + "adminPolicy": { + "users": ["test"], + "actions": ["read", "create", "update", "delete"] + } + } + }, + "extensions":{ + "sysconfig": { + "enable": true + } + }, + "log": { + "level": "debug" + } + }`, + getStatus: http.StatusOK, + postStatus: http.StatusAccepted, + putStatus: http.StatusMethodNotAllowed, + }, + } + + Convey("Verify config http handler", t, func() { + for _, testCase := range testCases { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + logFile, err := os.CreateTemp("", "zot-log*.txt") + So(err, ShouldBeNil) + + hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) + if err != nil { + panic(err) + } + + usernameAndHash := fmt.Sprintf("%s:%s\n%s:%s", username, string(hash), "nonadmin", string(hash)) + + htpasswdPath := test.MakeHtpasswdFileFromString(usernameAndHash) + defer os.Remove(htpasswdPath) + + defer os.Remove(logFile.Name()) // clean up + + content := fmt.Sprintf(testCase.configContent, t.TempDir(), port, htpasswdPath) + cfgfile, err := os.CreateTemp("", "zot-test*.json") + So(err, ShouldBeNil) + + defer os.Remove(cfgfile.Name()) // clean up + + _, err = cfgfile.Write([]byte(content)) + So(err, ShouldBeNil) + + err = cfgfile.Close() + So(err, ShouldBeNil) + + os.Args = []string{"cli_test", "serve", cfgfile.Name()} + + go func() { + err = cli.NewServerRootCmd().Execute() + So(err, ShouldBeNil) + }() + + test.WaitTillServerReady(baseURL) + + // get config + resp, err := resty.R().SetBasicAuth("nonadmin", password). + Get(baseURL + constants.ExtConfigPrefix) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusForbidden) + + // get config + resp, err = resty.R().SetBasicAuth(username, password). + Get(baseURL + constants.ExtConfigPrefix) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, testCase.getStatus) + + // post config + resp, err = resty.R().SetBasicAuth(username, password). + SetHeader("Content-Type", "application/json"). + SetBody([]byte(content)). + Post(baseURL + constants.ExtConfigPrefix) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, testCase.postStatus) + + // put config should fail + resp, err = resty.R().SetBasicAuth(username, password). + Put(baseURL + constants.ExtConfigPrefix) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, testCase.putStatus) + } + }) +} + +func TestConfigExtensionAPIErrors(t *testing.T) { + Convey("Verify config http handler", t, func() { + username := "test" + password := "test" + + configContent := `{ + "distSpecVersion": "0.1.0-dev", + "storage": { + "rootDirectory": "%s" + }, + "http": { + "address": "127.0.0.1", + "port": "%s", + "realm": "zot", + "auth": { + "htpasswd": { + "path": "%s" + }, + "failDelay": 1 + }, + "accessControl": { + "adminPolicy": { + "users": ["test"] + } + } + }, + "extensions":{ + "sysconfig": { + "enable": true + } + }, + "log": { + "level": "debug" + } + }` + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + logFile, err := os.CreateTemp("", "zot-log*.txt") + So(err, ShouldBeNil) + + hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) + if err != nil { + panic(err) + } + + usernameAndHash := fmt.Sprintf("%s:%s", username, string(hash)) + + htpasswdPath := test.MakeHtpasswdFileFromString(usernameAndHash) + defer os.Remove(htpasswdPath) + + defer os.Remove(logFile.Name()) // clean up + + content := fmt.Sprintf(configContent, t.TempDir(), port, htpasswdPath) + cfgfile, err := os.CreateTemp("", "zot-test*.json") + So(err, ShouldBeNil) + + defer os.Remove(cfgfile.Name()) // clean up + + _, err = cfgfile.Write([]byte(content)) + So(err, ShouldBeNil) + + err = cfgfile.Close() + So(err, ShouldBeNil) + + os.Args = []string{"cli_test", "serve", cfgfile.Name()} + go func() { + err = cli.NewServerRootCmd().Execute() + So(err, ShouldBeNil) + }() + + test.WaitTillServerReady(baseURL) + + Convey("GET read config error", func() { + // trigger permission denied on reading config file when GET + err = os.Chmod(cfgfile.Name(), 0o000) + So(err, ShouldBeNil) + + // get config + resp, err := resty.R().SetBasicAuth(username, password). + Get(baseURL + constants.ExtConfigPrefix) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + }) + Convey("POST errors", func() { + // trigger unmarshall error + resp, err := resty.R().SetBasicAuth(username, password). + SetHeader("Content-Type", "application/json"). + SetBody([]byte("{}")). + Post(baseURL + constants.ExtConfigPrefix) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // trigger invalid config error + badConfig := ` + {"log": {"level":"debug"}} + ` + resp, err = resty.R().SetBasicAuth(username, password). + SetHeader("Content-Type", "application/json"). + SetBody([]byte(badConfig)). + Post(baseURL + constants.ExtConfigPrefix) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest) + + // trigger write config error + err = os.Chmod(cfgfile.Name(), 0o000) + So(err, ShouldBeNil) + + resp, err = resty.R().SetBasicAuth(username, password). + SetHeader("Content-Type", "application/json"). + SetBody([]byte(content)). + Post(baseURL + constants.ExtConfigPrefix) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError) + }) + }) +} diff --git a/pkg/extensions/search/common/common_test.go b/pkg/extensions/search/common/common_test.go index 496293213b..46b5b4f928 100644 --- a/pkg/extensions/search/common/common_test.go +++ b/pkg/extensions/search/common/common_test.go @@ -604,7 +604,7 @@ func TestRepoListWithNewestImage(t *testing.T) { _ = ctlr.Server.Shutdown(ctx) }() - substring := "\"Extensions\":{\"Search\":{\"Enable\":true,\"CVE\":{\"UpdateInterval\":3600000000000}},\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":null}" //nolint:lll // gofumpt conflicts with lll + substring := "\"Extensions\":{\"Search\":{\"Enable\":true,\"CVE\":{\"UpdateInterval\":3600000000000}},\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":null" //nolint:lll // gofumpt conflicts with lll found, err := readFileAndSearchString(logPath, substring, 2*time.Minute) So(found, ShouldBeTrue) So(err, ShouldBeNil) @@ -2239,7 +2239,7 @@ func TestGlobalSearch(t *testing.T) { }() // Wait for trivy db to download - substring := "\"Extensions\":{\"Search\":{\"Enable\":true,\"CVE\":{\"UpdateInterval\":3600000000000}},\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":null}" //nolint:lll // gofumpt conflicts with lll + substring := "\"Extensions\":{\"Search\":{\"Enable\":true,\"CVE\":{\"UpdateInterval\":3600000000000}},\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":null" //nolint:lll // gofumpt conflicts with lll found, err := readFileAndSearchString(logPath, substring, 2*time.Minute) So(found, ShouldBeTrue) So(err, ShouldBeNil) diff --git a/pkg/extensions/sync/on_demand.go b/pkg/extensions/sync/on_demand.go index abe5eede5d..1f7378e3be 100644 --- a/pkg/extensions/sync/on_demand.go +++ b/pkg/extensions/sync/on_demand.go @@ -91,7 +91,7 @@ func syncOneImage(imageChannel chan error, cfg Config, storeController storage.S if cfg.CredentialsFile != "" { var err error - credentialsFile, err = getFileCredentials(cfg.CredentialsFile) + credentialsFile, err = GetFileCredentials(cfg.CredentialsFile) if err != nil { log.Error().Str("errorType", TypeOf(err)). Err(err).Msgf("couldn't get registry credentials from %s", cfg.CredentialsFile) diff --git a/pkg/extensions/sync/sync.go b/pkg/extensions/sync/sync.go index 399005c33a..2d3b673248 100644 --- a/pkg/extensions/sync/sync.go +++ b/pkg/extensions/sync/sync.go @@ -439,7 +439,7 @@ func Run(ctx context.Context, cfg Config, var err error if cfg.CredentialsFile != "" { - credentialsFile, err = getFileCredentials(cfg.CredentialsFile) + credentialsFile, err = GetFileCredentials(cfg.CredentialsFile) if err != nil { logger.Error().Str("errortype", TypeOf(err)). Err(err).Msgf("couldn't get registry credentials from %s", cfg.CredentialsFile) @@ -505,6 +505,7 @@ func Run(ctx context.Context, cfg Config, select { case <-ctx.Done(): ticker.Stop() + logger.Info().Msg("sync routine will exit, config reloaded") return case <-ticker.C: diff --git a/pkg/extensions/sync/sync_internal_test.go b/pkg/extensions/sync/sync_internal_test.go index 13a5b94867..105ad27be2 100644 --- a/pkg/extensions/sync/sync_internal_test.go +++ b/pkg/extensions/sync/sync_internal_test.go @@ -97,7 +97,7 @@ func TestSyncInternal(t *testing.T) { _, err = parseRepositoryReference(repositoryReference) So(err, ShouldNotBeNil) - _, err = getFileCredentials("/path/to/inexistent/file") + _, err = GetFileCredentials("/path/to/inexistent/file") So(err, ShouldNotBeNil) tempFile, err := os.CreateTemp("", "sync-credentials-") @@ -110,7 +110,7 @@ func TestSyncInternal(t *testing.T) { panic(err) } - _, err = getFileCredentials(tempFile.Name()) + _, err = GetFileCredentials(tempFile.Name()) So(err, ShouldNotBeNil) srcCtx := &types.SystemContext{} @@ -155,7 +155,7 @@ func TestSyncInternal(t *testing.T) { So(Run(ctx, cfg, storage.StoreController{}, new(goSync.WaitGroup), log.NewLogger("debug", "")), ShouldNotBeNil) - _, err = getFileCredentials("/invalid/path/to/file") + _, err = GetFileCredentials("/invalid/path/to/file") So(err, ShouldNotBeNil) }) diff --git a/pkg/extensions/sync/sync_test.go b/pkg/extensions/sync/sync_test.go index 93d61d0066..f44473b695 100644 --- a/pkg/extensions/sync/sync_test.go +++ b/pkg/extensions/sync/sync_test.go @@ -39,7 +39,6 @@ import ( "zotregistry.io/zot/pkg/api" "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/api/constants" - "zotregistry.io/zot/pkg/cli" extconf "zotregistry.io/zot/pkg/extensions/config" "zotregistry.io/zot/pkg/extensions/sync" "zotregistry.io/zot/pkg/storage" @@ -741,126 +740,6 @@ func TestOnDemandPermsDenied(t *testing.T) { }) } -func TestConfigReloader(t *testing.T) { - Convey("Verify periodically sync config reloader works", t, func() { - duration, _ := time.ParseDuration("3s") - - sctlr, srcBaseURL, srcDir, _, _ := startUpstreamServer(t, false, false) - defer os.RemoveAll(srcDir) - - defer func() { - sctlr.Shutdown() - }() - - var tlsVerify bool - - syncRegistryConfig := sync.RegistryConfig{ - Content: []sync.Content{ - { - Prefix: testImage, - }, - }, - URLs: []string{srcBaseURL}, - PollInterval: duration, - TLSVerify: &tlsVerify, - CertDir: "", - OnDemand: true, - } - - defaultVal := true - syncConfig := &sync.Config{ - Enable: &defaultVal, - Registries: []sync.RegistryConfig{syncRegistryConfig}, - } - - destPort := test.GetFreePort() - destConfig := config.New() - destBaseURL := test.GetBaseURL(destPort) - - destConfig.HTTP.Port = destPort - - destDir, err := os.MkdirTemp("", "oci-dest-repo-test") - if err != nil { - panic(err) - } - - defer os.RemoveAll(destDir) - - destConfig.Storage.RootDirectory = destDir - - destConfig.Extensions = &extconf.ExtensionConfig{} - destConfig.Extensions.Search = nil - destConfig.Extensions.Sync = syncConfig - - logFile, err := os.CreateTemp("", "zot-log*.txt") - So(err, ShouldBeNil) - - defer os.Remove(logFile.Name()) // clean up - - destConfig.Log.Output = logFile.Name() - - dctlr := api.NewController(destConfig) - - defer func() { - dctlr.Shutdown() - }() - - content := fmt.Sprintf(`{"distSpecVersion": "0.1.0-dev", "storage": {"rootDirectory": "%s"}, - "http": {"address": "127.0.0.1", "port": "%s"}, - "log": {"level": "debug", "output": "%s"}}`, destDir, destPort, logFile.Name()) - - cfgfile, err := os.CreateTemp("", "zot-test*.json") - So(err, ShouldBeNil) - - defer os.Remove(cfgfile.Name()) // clean up - - _, err = cfgfile.Write([]byte(content)) - So(err, ShouldBeNil) - - hotReloader, err := cli.NewHotReloader(dctlr, cfgfile.Name()) - So(err, ShouldBeNil) - - reloadCtx := hotReloader.Start() - - go func() { - // this blocks - if err := dctlr.Run(reloadCtx); err != nil { - return - } - }() - - // wait till ready - for { - _, err := resty.R().Get(destBaseURL) - if err == nil { - break - } - - time.Sleep(100 * time.Millisecond) - } - - // let it sync - time.Sleep(3 * time.Second) - - // modify config - _, err = cfgfile.WriteString(" ") - So(err, ShouldBeNil) - - err = cfgfile.Close() - So(err, ShouldBeNil) - - time.Sleep(2 * time.Second) - - data, err := os.ReadFile(logFile.Name()) - t.Logf("downstream log: %s", string(data)) - So(err, ShouldBeNil) - So(string(data), ShouldContainSubstring, "reloaded params") - So(string(data), ShouldContainSubstring, "new configuration settings") - So(string(data), ShouldContainSubstring, "\"Sync\":null") - So(string(data), ShouldNotContainSubstring, "sync:") - }) -} - func TestMandatoryAnnotations(t *testing.T) { Convey("Verify mandatory annotations failing - on demand disabled", t, func() { updateDuration, _ := time.ParseDuration("30m") diff --git a/pkg/extensions/sync/utils.go b/pkg/extensions/sync/utils.go index b24a5d653b..3385d6c1cf 100644 --- a/pkg/extensions/sync/utils.go +++ b/pkg/extensions/sync/utils.go @@ -250,7 +250,7 @@ func getRepoDestination(remoteRepo string, content Content) string { } // Get sync.FileCredentials from file. -func getFileCredentials(filepath string) (CredentialsFile, error) { +func GetFileCredentials(filepath string) (CredentialsFile, error) { credsFile, err := os.ReadFile(filepath) if err != nil { return nil, err diff --git a/test/blackbox/anonymous_policiy.bats b/test/blackbox/anonymous_policiy.bats index ccbfb4c885..bce91ecb84 100644 --- a/test/blackbox/anonymous_policiy.bats +++ b/test/blackbox/anonymous_policiy.bats @@ -31,20 +31,22 @@ function setup_file() { } }, "accessControl": { - "**": { - "anonymousPolicy": ["read"], - "policies": [ - { - "users": [ - "test" - ], - "actions": [ - "read", - "create", - "update" - ] - } - ] + "repositories": { + "**": { + "anonymousPolicy": ["read"], + "policies": [ + { + "users": [ + "test" + ], + "actions": [ + "read", + "create", + "update" + ] + } + ] + } } } },