diff --git a/.github/workflows/golangci-lint.yaml b/.github/workflows/golangci-lint.yaml index a0af8f7c..450d0efd 100644 --- a/.github/workflows/golangci-lint.yaml +++ b/.github/workflows/golangci-lint.yaml @@ -31,7 +31,7 @@ jobs: # Optional: golangci-lint command line arguments. # args: --issues-exit-code=0 - args: --config ./golangcilint.yaml --enable-all --build-tags debug,needprivileges,sync,scrub,search,metrics,containers_image_openpgp,lint ./cmd/... ./pkg/... + args: --config ./golangcilint.yaml --enable-all --build-tags debug,needprivileges,sync,scrub,search,metrics,containers_image_openpgp,lint,mgmt ./cmd/... ./pkg/... # Optional: show only new issues if it's a pull request. The default value is `false`. # only-new-issues: true diff --git a/Makefile b/Makefile index 95b5e77d..98744df7 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ TESTDATA := $(TOP_LEVEL)/test/data OS ?= linux ARCH ?= amd64 BENCH_OUTPUT ?= stdout -EXTENSIONS ?= sync,search,scrub,metrics,lint,ui +EXTENSIONS ?= sync,search,scrub,metrics,lint,ui,mgmt comma:= , hyphen:= - extended-name:= diff --git a/examples/config-allextensions.json b/examples/config-allextensions.json index 5ee0ec3d..202641ed 100644 --- a/examples/config-allextensions.json +++ b/examples/config-allextensions.json @@ -48,6 +48,9 @@ "scrub": { "enable": true, "interval": "24h" + }, + "mgmt": { + "enable": true } } } diff --git a/pkg/api/authn.go b/pkg/api/authn.go index 55df9730..aa94ad71 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -18,6 +18,7 @@ import ( "zotregistry.io/zot/errors" "zotregistry.io/zot/pkg/api/config" + "zotregistry.io/zot/pkg/api/constants" localCtx "zotregistry.io/zot/pkg/requestcontext" ) @@ -186,12 +187,17 @@ func basicAuthHandler(ctlr *Controller) mux.MiddlewareFunc { return } - if request.Header.Get("Authorization") == "" && anonymousPolicyExists(ctlr.Config.HTTP.AccessControl) { - // Process request - ctx := getReqContextWithAuthorization("", []string{}, request) - next.ServeHTTP(response, request.WithContext(ctx)) //nolint:contextcheck + // we want to bypass auth for mgmt route + isMgmtRequested := request.RequestURI == constants.FullMgmtPrefix - return + if request.Header.Get("Authorization") == "" { + if anonymousPolicyExists(ctlr.Config.HTTP.AccessControl) || isMgmtRequested { + // Process request + ctx := getReqContextWithAuthorization("", []string{}, request) + next.ServeHTTP(response, request.WithContext(ctx)) //nolint:contextcheck + + return + } } username, passphrase, err := getUsernamePasswordBasicAuth(request) @@ -204,12 +210,14 @@ 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.HTTP.AccessControl) { - // Process request - ctx := getReqContextWithAuthorization("", []string{}, request) - next.ServeHTTP(response, request.WithContext(ctx)) //nolint:contextcheck + if username == "" && passphrase == "" { + if anonymousPolicyExists(ctlr.Config.HTTP.AccessControl) || isMgmtRequested { + // Process request + ctx := getReqContextWithAuthorization("", []string{}, request) + next.ServeHTTP(response, request.WithContext(ctx)) //nolint:contextcheck - return + return + } } // first, HTTPPassword authN (which is local) diff --git a/pkg/api/authz.go b/pkg/api/authz.go index c7e0d780..7725417b 100644 --- a/pkg/api/authz.go +++ b/pkg/api/authz.go @@ -240,9 +240,13 @@ func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc { return } + /* we want to bypass auth/authz for mgmt in case of authFail() authzFail() + unauthenticated users should have access to this route, but we also need to know if the user is an admin + */ + isMgmtRequested := request.RequestURI == constants.FullMgmtPrefix + acCtrlr := NewAccessController(ctlr.Config) - // allow anonymous authz if no authn present and only default policies are present var identity string var err error @@ -264,6 +268,7 @@ func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc { for _, cert := range request.TLS.PeerCertificates { identity = cert.Subject.CommonName } + // if we still don't have an identity if identity == "" { acCtrlr.Log.Info().Msg("couldn't get identity from TLS certificate") @@ -274,14 +279,10 @@ func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc { ctx := acCtrlr.getContext(identity, request) - // will return only repos on which client is authorized to read - if request.RequestURI == fmt.Sprintf("%s%s", constants.RoutePrefix, constants.ExtCatalogPrefix) { - next.ServeHTTP(response, request.WithContext(ctx)) //nolint:contextcheck - - return - } - - if strings.Contains(request.RequestURI, constants.FullSearchPrefix) { + // for extensions we only need to know if the user is admin and what repos he can read, so run next() + if request.RequestURI == fmt.Sprintf("%s%s", constants.RoutePrefix, constants.ExtCatalogPrefix) || + strings.Contains(request.RequestURI, constants.FullSearchPrefix) || + isMgmtRequested { next.ServeHTTP(response, request.WithContext(ctx)) //nolint:contextcheck return diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index 2ac7e3e9..0fa7fc2b 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -69,7 +69,7 @@ type HTTPConfig struct { AllowOrigin string // comma separated TLS *TLSConfig Auth *AuthConfig - AccessControl *AccessControlConfig + AccessControl *AccessControlConfig `mapstructure:"accessControl,omitempty"` Realm string Ratelimit *RatelimitConfig `mapstructure:",omitempty"` } diff --git a/pkg/api/constants/extensions.go b/pkg/api/constants/extensions.go index 44bf1809..69cb2a80 100644 --- a/pkg/api/constants/extensions.go +++ b/pkg/api/constants/extensions.go @@ -7,4 +7,6 @@ const ( // zot specific extensions. ExtSearchPrefix = "/_zot/ext/search" FullSearchPrefix = RoutePrefix + ExtSearchPrefix + ExtMgmtPrefix = "/_zot/ext/mgmt" + FullMgmtPrefix = RoutePrefix + ExtMgmtPrefix ) diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index 156c41ee..fb8ec62e 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -3138,6 +3138,12 @@ func TestAuthorizationWithMultiplePolicies(t *testing.T) { So(resp, ShouldNotBeNil) So(resp.StatusCode(), ShouldEqual, http.StatusOK) + // with empty username:password + resp, err = resty.R().SetHeader("Authorization", "Basic Og==").Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + // add "test" user to global policy with create permission repoPolicy.Policies[0].Users = append(repoPolicy.Policies[0].Users, "test") repoPolicy.Policies[0].Actions = append(repoPolicy.Policies[0].Actions, "create") @@ -7290,6 +7296,60 @@ func TestDistSpecExtensions(t *testing.T) { So(extensionList.Extensions[0].Endpoints[0], ShouldEqual, constants.FullSearchPrefix) }) + Convey("start zot server with search and mgmt extensions", t, func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf.HTTP.Port = port + + defaultVal := true + + searchConfig := &extconf.SearchConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } + + mgmtConfg := &extconf.MgmtConfig{ + BaseConfig: extconf.BaseConfig{Enable: &defaultVal}, + } + + conf.Extensions = &extconf.ExtensionConfig{ + Search: searchConfig, + Mgmt: mgmtConfg, + } + + logFile, err := os.CreateTemp("", "zot-log*.txt") + So(err, ShouldBeNil) + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // clean up + + ctlr := makeController(conf, t.TempDir(), "") + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + var extensionList distext.ExtensionList + + resp, err := resty.R().Get(baseURL + constants.RoutePrefix + constants.ExtOciDiscoverPrefix) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + err = json.Unmarshal(resp.Body(), &extensionList) + So(err, ShouldBeNil) + So(len(extensionList.Extensions), ShouldEqual, 2) + So(len(extensionList.Extensions[0].Endpoints), ShouldEqual, 1) + So(len(extensionList.Extensions[1].Endpoints), ShouldEqual, 1) + So(extensionList.Extensions[0].Name, ShouldEqual, "_zot") + So(extensionList.Extensions[0].URL, ShouldContainSubstring, "_zot.md") + So(extensionList.Extensions[0].Description, ShouldNotBeEmpty) + So(extensionList.Extensions[0].Endpoints[0], ShouldEqual, constants.FullSearchPrefix) + So(extensionList.Extensions[1].Name, ShouldEqual, "_zot") + So(extensionList.Extensions[1].URL, ShouldContainSubstring, "_zot.md") + So(extensionList.Extensions[1].Description, ShouldNotBeEmpty) + So(extensionList.Extensions[1].Endpoints[0], ShouldEqual, constants.FullMgmtPrefix) + }) + Convey("start minimal zot server", t, func(c C) { conf := config.New() port := test.GetFreePort() diff --git a/pkg/api/routes.go b/pkg/api/routes.go index b4ce6f09..37642d2e 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -128,6 +128,7 @@ func (rh *RouteHandler) SetupRoutes() { ext.SetupMetricsRoutes(rh.c.Config, rh.c.Router, rh.c.StoreController, AuthHandler(rh.c), rh.c.Log) ext.SetupSearchRoutes(rh.c.Config, prefixedRouter, rh.c.StoreController, rh.c.RepoDB, rh.c.CveInfo, rh.c.Log) ext.SetupUIRoutes(rh.c.Config, rh.c.Router, rh.c.StoreController, rh.c.Log) + ext.SetupMgmtRoutes(rh.c.Config, prefixedRouter, rh.c.Log) gqlPlayground.SetupGQLPlaygroundRoutes(rh.c.Config, prefixedRouter, rh.c.StoreController, rh.c.Log) } } diff --git a/pkg/cli/extensions_test.go b/pkg/cli/extensions_test.go index 5a8e005a..7d935103 100644 --- a/pkg/cli/extensions_test.go +++ b/pkg/cli/extensions_test.go @@ -108,7 +108,7 @@ func TestServeExtensions(t *testing.T) { data, err := os.ReadFile(logFile.Name()) So(err, ShouldBeNil) So(string(data), ShouldContainSubstring, - "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":null,\"UI\":null") //nolint:lll // gofumpt conflicts with lll + "\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":null,\"UI\":null,\"Mgmt\":null") //nolint:lll // gofumpt conflicts with lll }) } @@ -776,6 +776,67 @@ func TestServeSearchDisabled(t *testing.T) { }) } +func TestServeMgmtExtension(t *testing.T) { + oldArgs := os.Args + + defer func() { os.Args = oldArgs }() + + Convey("Mgmt implicitly enabled", t, func(c C) { + content := `{ + "storage": { + "rootDirectory": "%s" + }, + "http": { + "address": "127.0.0.1", + "port": "%s" + }, + "log": { + "level": "debug", + "output": "%s" + }, + "extensions": { + "Mgmt": { + } + } + }` + + 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, "\"Mgmt\":{\"Enable\":true}") + }) + + Convey("Mgmt disabled", t, func(c C) { + content := `{ + "storage": { + "rootDirectory": "%s" + }, + "http": { + "address": "127.0.0.1", + "port": "%s" + }, + "log": { + "level": "debug", + "output": "%s" + }, + "extensions": { + "Mgmt": { + "enable": "false" + } + } + }` + + 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, "\"Mgmt\":{\"Enable\":false}") + }) +} + func readLogFileAndSearchString(logPath string, stringToMatch string, timeout time.Duration) (bool, error) { //nolint:unparam,lll ctx, cancelFunc := context.WithTimeout(context.Background(), timeout) defer cancelFunc() diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 20f36544..f29cdf5c 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -461,6 +461,13 @@ func applyDefaultValues(config *config.Config, viperInstance *viper.Viper) { // 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{} } + + _, ok = extMap["mgmt"] + if ok { + // we found a config like `"extensions": {"mgmt:": {}}` + // Note: In case mgmt is not empty the config.Extensions will not be nil and we will not reach here + config.Extensions.Mgmt = &extconf.MgmtConfig{} + } } if config.Extensions != nil { @@ -492,6 +499,12 @@ func applyDefaultValues(config *config.Config, viperInstance *viper.Viper) { } } + if config.Extensions.Mgmt != nil { + if config.Extensions.Mgmt.Enable == nil { + config.Extensions.Mgmt.Enable = &defaultVal + } + } + if config.Extensions.Scrub != nil { if config.Extensions.Scrub.Enable == nil { config.Extensions.Scrub.Enable = &defaultVal diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index 3321a82e..e5fe38ff 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -616,7 +616,7 @@ 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"}}}}, + "/b": {"rootDirectory": "/zot-a","dedupe":"false"}}}, "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) @@ -626,7 +626,7 @@ 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": "/zot-a"}}}, "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) @@ -636,7 +636,7 @@ func TestVerify(t *testing.T) { content = []byte(`{"storage":{"rootDirectory":"/tmp/zot", "subPaths": {"/a": {"rootDirectory": "/zot-a","dedupe":"true","gc":"true"}, - "/b": {"rootDirectory": "/zot-a","dedupe":"true","gc":"false"}}}}, + "/b": {"rootDirectory": "/zot-a","dedupe":"true","gc":"false"}}}, "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) @@ -646,7 +646,7 @@ func TestVerify(t *testing.T) { 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"}}}}, + "/b": {"rootDirectory": "/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}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) @@ -656,7 +656,7 @@ func TestVerify(t *testing.T) { 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"}}}}, + "/b": {"rootDirectory": "/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}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) @@ -666,7 +666,7 @@ 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": "/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}}}`) err = os.WriteFile(tmpfile.Name(), content, 0o0600) @@ -741,7 +741,7 @@ func TestVerify(t *testing.T) { "actions":["read","create","update","delete"] } } - }}`) + }`) _, err = tmpfile.Write(content) So(err, ShouldBeNil) err = tmpfile.Close() @@ -893,7 +893,7 @@ func TestVerify(t *testing.T) { 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":[]}}}}`) + "accessControl":{"repositories":{"[":{"policies":[],"anonymousPolicy":[]}}}}}`) _, err = tmpfile.Write(content) So(err, ShouldBeNil) err = tmpfile.Close() diff --git a/pkg/common/common.go b/pkg/common/common.go index 6f49d618..60e7979e 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -211,3 +211,23 @@ func DirExists(d string) bool { return true } + +// Used to filter a json fields by using an intermediate struct. +func MarshalThroughStruct(obj interface{}, throughStruct interface{}) ([]byte, error) { + toJSON, err := json.Marshal(obj) + if err != nil { + return []byte{}, err + } + + err = json.Unmarshal(toJSON, throughStruct) + if err != nil { + return []byte{}, err + } + + toJSON, err = json.Marshal(throughStruct) + if err != nil { + return []byte{}, err + } + + return toJSON, nil +} diff --git a/pkg/common/common_test.go b/pkg/common/common_test.go index 78c5106e..8c456357 100644 --- a/pkg/common/common_test.go +++ b/pkg/common/common_test.go @@ -24,6 +24,28 @@ func TestCommon(t *testing.T) { So(common.Contains([]string{}, "apple"), ShouldBeFalse) }) + Convey("test MarshalThroughStruct()", t, func() { + cfg := config.New() + + newCfg := struct { + DistSpecVersion string + }{} + + _, err := common.MarshalThroughStruct(cfg, &newCfg) + So(err, ShouldBeNil) + So(newCfg.DistSpecVersion, ShouldEqual, cfg.DistSpecVersion) + + // negative + obj := make(chan int) + toObj := config.New() + + _, err = common.MarshalThroughStruct(obj, &toObj) + So(err, ShouldNotBeNil) + + _, err = common.MarshalThroughStruct(toObj, &obj) + So(err, ShouldNotBeNil) + }) + Convey("test getTLSConfig()", t, func() { caCertPool, _ := x509.SystemCertPool() tlsConfig, err := common.GetTLSConfig("wrongPath", caCertPool) diff --git a/pkg/extensions/_zot.md b/pkg/extensions/_zot.md index 1cf8d3f5..0cc4fb24 100644 --- a/pkg/extensions/_zot.md +++ b/pkg/extensions/_zot.md @@ -6,6 +6,7 @@ Component | Endpoint | Description --- | --- | --- [`search`](search/search.md) | `/v2/_zot/ext/search` | efficient and enhanced registry search capabilities using graphQL backend +[`mgmt`](mgmt.md) | `/v2/_zot/ext/mgmt` | config management # References diff --git a/pkg/extensions/config/config.go b/pkg/extensions/config/config.go index fc06f342..d750f1d9 100644 --- a/pkg/extensions/config/config.go +++ b/pkg/extensions/config/config.go @@ -18,6 +18,11 @@ type ExtensionConfig struct { Scrub *ScrubConfig Lint *LintConfig UI *UIConfig + Mgmt *MgmtConfig +} + +type MgmtConfig struct { + BaseConfig `mapstructure:",squash"` } type LintConfig struct { diff --git a/pkg/extensions/extension_mgmt.go b/pkg/extensions/extension_mgmt.go new file mode 100644 index 00000000..17fee099 --- /dev/null +++ b/pkg/extensions/extension_mgmt.go @@ -0,0 +1,90 @@ +//go:build mgmt +// +build mgmt + +package extensions + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "zotregistry.io/zot/pkg/api/config" + "zotregistry.io/zot/pkg/api/constants" + "zotregistry.io/zot/pkg/common" + "zotregistry.io/zot/pkg/log" +) + +type HTPasswd struct { + Path string `json:"path,omitempty"` +} + +type BearerConfig struct { + Realm string `json:"realm,omitempty"` + Service string `json:"service,omitempty"` +} + +type Auth struct { + HTPasswd *HTPasswd `json:"htpasswd,omitempty" mapstructure:"htpasswd"` + Bearer *BearerConfig `json:"bearer,omitempty" mapstructure:"bearer"` + LDAP *struct { + Address string `json:"address,omitempty" mapstructure:"address"` + } `json:"ldap,omitempty" mapstructure:"ldap"` +} + +type StrippedConfig struct { + DistSpecVersion string `json:"distSpecVersion" mapstructure:"distSpecVersion"` + BinaryType string `json:"binaryType" mapstructure:"binaryType"` + HTTP struct { + Auth *Auth `json:"auth,omitempty" mapstructure:"auth"` + } `json:"http" mapstructure:"http"` +} + +func (auth Auth) MarshalJSON() ([]byte, error) { + type localAuth Auth + + if auth.Bearer == nil && auth.LDAP == nil && + auth.HTPasswd.Path == "" { + auth.HTPasswd = nil + + return json.Marshal((localAuth)(auth)) + } + + if auth.HTPasswd.Path == "" && auth.LDAP == nil { + auth.HTPasswd = nil + } else { + auth.HTPasswd.Path = "" + } + + auth.LDAP = nil + + return json.Marshal((localAuth)(auth)) +} + +type mgmt struct { + config *config.Config + log log.Logger +} + +func (mgmt *mgmt) handler(response http.ResponseWriter, request *http.Request) { + sanitizedConfig := mgmt.config.Sanitize() + + buf, err := common.MarshalThroughStruct(sanitizedConfig, &StrippedConfig{}) + if err != nil { + mgmt.log.Error().Err(err).Msg("mgmt: couldn't marshal config response") + + response.WriteHeader(http.StatusInternalServerError) + } + + _, _ = response.Write(buf) +} + +func SetupMgmtRoutes(config *config.Config, router *mux.Router, log log.Logger) { + if config.Extensions.Mgmt != nil && *config.Extensions.Mgmt.Enable { + log.Info().Msg("setting up mgmt routes") + + mgmt := mgmt{config: config, log: log} + + router.PathPrefix(constants.ExtMgmtPrefix).Methods("GET").HandlerFunc(mgmt.handler) + } +} diff --git a/pkg/extensions/extension_mgmt_disabled.go b/pkg/extensions/extension_mgmt_disabled.go new file mode 100644 index 00000000..8c0a7bb6 --- /dev/null +++ b/pkg/extensions/extension_mgmt_disabled.go @@ -0,0 +1,16 @@ +//go:build !mgmt +// +build !mgmt + +package extensions + +import ( + "github.com/gorilla/mux" + + "zotregistry.io/zot/pkg/api/config" + "zotregistry.io/zot/pkg/log" +) + +func SetupMgmtRoutes(config *config.Config, router *mux.Router, log log.Logger) { + log.Warn().Msg("skipping setting up mgmt routes because given zot binary doesn't include this feature," + + "please build a binary that does so") +} diff --git a/pkg/extensions/extension_search.go b/pkg/extensions/extension_search.go index 6147eae5..7012f2cb 100644 --- a/pkg/extensions/extension_search.go +++ b/pkg/extensions/extension_search.go @@ -197,6 +197,16 @@ func GetExtensions(config *config.Config) distext.ExtensionList { extensions = append(extensions, searchExt) } + if config.Extensions != nil && config.Extensions.Mgmt != nil { + endpoints := []string{constants.FullMgmtPrefix} + mgmtExt := getExtension("_zot", + "https://github.com/project-zot/zot/blob/"+config.ReleaseTag+"/pkg/extensions/_zot.md", + "zot registry extensions", + endpoints) + + extensions = append(extensions, mgmtExt) + } + extensionList.Extensions = extensions return extensionList diff --git a/pkg/extensions/extensions_test.go b/pkg/extensions/extensions_test.go index 098192df..9288752d 100644 --- a/pkg/extensions/extensions_test.go +++ b/pkg/extensions/extensions_test.go @@ -1,16 +1,21 @@ -//go:build sync || metrics -// +build sync metrics +//go:build sync || metrics || mgmt +// +build sync metrics mgmt package extensions_test import ( + "encoding/json" + "net/http" "os" "testing" . "github.com/smartystreets/goconvey/convey" + "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/extensions" extconf "zotregistry.io/zot/pkg/extensions/config" syncconf "zotregistry.io/zot/pkg/extensions/config/sync" "zotregistry.io/zot/pkg/test" @@ -92,3 +97,415 @@ func TestMetricsExtension(t *testing.T) { "Prometheus instrumentation Path not set, changing to '/metrics'.") }) } + +func TestMgmtExtension(t *testing.T) { + globalDir := t.TempDir() + conf := config.New() + port := test.GetFreePort() + conf.HTTP.Port = port + baseURL := test.GetBaseURL(port) + + logFile, err := os.CreateTemp(globalDir, "zot-log*.txt") + if err != nil { + panic(err) + } + + defaultValue := true + + Convey("Verify mgmt route enabled with htpasswd", t, func() { + htpasswdPath := test.MakeHtpasswdFile() + conf.HTTP.Auth.HTPasswd.Path = htpasswdPath + + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Mgmt = &extconf.MgmtConfig{ + BaseConfig: extconf.BaseConfig{ + Enable: &defaultValue, + }, + } + + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // cleanup + + ctlr := api.NewController(conf) + + subPaths := make(map[string]config.StorageConfig) + subPaths["/a"] = config.StorageConfig{} + + ctlr.Config.Storage.RootDirectory = globalDir + ctlr.Config.Storage.SubPaths = subPaths + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + data, _ := os.ReadFile(logFile.Name()) + + So(string(data), ShouldContainSubstring, "setting up mgmt routes") + + // without credentials + resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + mgmtResp := extensions.StrippedConfig{} + err = json.Unmarshal(resp.Body(), &mgmtResp) + So(err, ShouldBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd, ShouldNotBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd.Path, ShouldEqual, "") + So(mgmtResp.HTTP.Auth.Bearer, ShouldBeNil) + So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) + + // with credentials + resp, err = resty.R().SetBasicAuth("test", "test").Get(baseURL + constants.FullMgmtPrefix) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + mgmtResp = extensions.StrippedConfig{} + err = json.Unmarshal(resp.Body(), &mgmtResp) + So(err, ShouldBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd, ShouldNotBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd.Path, ShouldEqual, "") + So(mgmtResp.HTTP.Auth.Bearer, ShouldBeNil) + So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) + + // with wrong credentials + resp, err = resty.R().SetBasicAuth("test", "wrong").Get(baseURL + constants.FullMgmtPrefix) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + + Convey("Verify mgmt route enabled with ldap", t, func() { + conf.HTTP.Auth.LDAP = &config.LDAPConfig{ + BindDN: "binddn", + BaseDN: "basedn", + Address: "ldapexample", + } + + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Mgmt = &extconf.MgmtConfig{ + BaseConfig: extconf.BaseConfig{ + Enable: &defaultValue, + }, + } + + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // cleanup + + ctlr := api.NewController(conf) + + subPaths := make(map[string]config.StorageConfig) + subPaths["/a"] = config.StorageConfig{} + + ctlr.Config.Storage.RootDirectory = globalDir + ctlr.Config.Storage.SubPaths = subPaths + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + data, _ := os.ReadFile(logFile.Name()) + + So(string(data), ShouldContainSubstring, "setting up mgmt routes") + + // without credentials + resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + mgmtResp := extensions.StrippedConfig{} + err = json.Unmarshal(resp.Body(), &mgmtResp) + So(err, ShouldBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd.Path, ShouldEqual, "") + // ldap is always nil, htpasswd should be populated when ldap is used + So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) + So(mgmtResp.HTTP.Auth.Bearer, ShouldBeNil) + }) + + Convey("Verify mgmt route enabled with htpasswd + ldap", t, func() { + htpasswdPath := test.MakeHtpasswdFile() + conf.HTTP.Auth.HTPasswd.Path = htpasswdPath + conf.HTTP.Auth.LDAP = &config.LDAPConfig{ + BindDN: "binddn", + BaseDN: "basedn", + Address: "ldapexample", + } + + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Mgmt = &extconf.MgmtConfig{ + BaseConfig: extconf.BaseConfig{ + Enable: &defaultValue, + }, + } + + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // cleanup + + ctlr := api.NewController(conf) + + subPaths := make(map[string]config.StorageConfig) + subPaths["/a"] = config.StorageConfig{} + + ctlr.Config.Storage.RootDirectory = globalDir + ctlr.Config.Storage.SubPaths = subPaths + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + data, _ := os.ReadFile(logFile.Name()) + + So(string(data), ShouldContainSubstring, "setting up mgmt routes") + + // without credentials + resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + mgmtResp := extensions.StrippedConfig{} + err = json.Unmarshal(resp.Body(), &mgmtResp) + So(err, ShouldBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd, ShouldNotBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd.Path, ShouldEqual, "") + So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) + So(mgmtResp.HTTP.Auth.Bearer, ShouldBeNil) + + // with credentials + resp, err = resty.R().SetBasicAuth("test", "test").Get(baseURL + constants.FullMgmtPrefix) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + mgmtResp = extensions.StrippedConfig{} + err = json.Unmarshal(resp.Body(), &mgmtResp) + So(err, ShouldBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd, ShouldNotBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd.Path, ShouldEqual, "") + So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) + So(mgmtResp.HTTP.Auth.Bearer, ShouldBeNil) + }) + + Convey("Verify mgmt route enabled with htpasswd + ldap + bearer", t, func() { + htpasswdPath := test.MakeHtpasswdFile() + conf.HTTP.Auth.HTPasswd.Path = htpasswdPath + conf.HTTP.Auth.LDAP = &config.LDAPConfig{ + BindDN: "binddn", + BaseDN: "basedn", + Address: "ldapexample", + } + + conf.HTTP.Auth.Bearer = &config.BearerConfig{ + Realm: "realm", + Service: "service", + } + + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Mgmt = &extconf.MgmtConfig{ + BaseConfig: extconf.BaseConfig{ + Enable: &defaultValue, + }, + } + + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // cleanup + + ctlr := api.NewController(conf) + + subPaths := make(map[string]config.StorageConfig) + subPaths["/a"] = config.StorageConfig{} + + ctlr.Config.Storage.RootDirectory = globalDir + ctlr.Config.Storage.SubPaths = subPaths + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + data, _ := os.ReadFile(logFile.Name()) + + So(string(data), ShouldContainSubstring, "setting up mgmt routes") + + // without credentials + resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + mgmtResp := extensions.StrippedConfig{} + err = json.Unmarshal(resp.Body(), &mgmtResp) + So(err, ShouldBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd, ShouldNotBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd.Path, ShouldEqual, "") + So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) + So(mgmtResp.HTTP.Auth.Bearer, ShouldNotBeNil) + So(mgmtResp.HTTP.Auth.Bearer.Realm, ShouldEqual, "realm") + So(mgmtResp.HTTP.Auth.Bearer.Service, ShouldEqual, "service") + + // with credentials + resp, err = resty.R().SetBasicAuth("test", "test").Get(baseURL + constants.FullMgmtPrefix) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + mgmtResp = extensions.StrippedConfig{} + err = json.Unmarshal(resp.Body(), &mgmtResp) + So(err, ShouldBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd, ShouldNotBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd.Path, ShouldEqual, "") + So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) + So(mgmtResp.HTTP.Auth.Bearer, ShouldNotBeNil) + So(mgmtResp.HTTP.Auth.Bearer.Realm, ShouldEqual, "realm") + So(mgmtResp.HTTP.Auth.Bearer.Service, ShouldEqual, "service") + }) + + Convey("Verify mgmt route enabled with ldap + bearer", t, func() { + conf.HTTP.Auth.HTPasswd.Path = "" + conf.HTTP.Auth.LDAP = &config.LDAPConfig{ + BindDN: "binddn", + BaseDN: "basedn", + Address: "ldapexample", + } + + conf.HTTP.Auth.Bearer = &config.BearerConfig{ + Realm: "realm", + Service: "service", + } + + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Mgmt = &extconf.MgmtConfig{ + BaseConfig: extconf.BaseConfig{ + Enable: &defaultValue, + }, + } + + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // cleanup + + ctlr := api.NewController(conf) + + subPaths := make(map[string]config.StorageConfig) + subPaths["/a"] = config.StorageConfig{} + + ctlr.Config.Storage.RootDirectory = globalDir + ctlr.Config.Storage.SubPaths = subPaths + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + data, _ := os.ReadFile(logFile.Name()) + + So(string(data), ShouldContainSubstring, "setting up mgmt routes") + + // without credentials + resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + mgmtResp := extensions.StrippedConfig{} + err = json.Unmarshal(resp.Body(), &mgmtResp) + So(err, ShouldBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd, ShouldNotBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd.Path, ShouldEqual, "") + So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) + So(mgmtResp.HTTP.Auth.Bearer, ShouldNotBeNil) + So(mgmtResp.HTTP.Auth.Bearer.Realm, ShouldEqual, "realm") + So(mgmtResp.HTTP.Auth.Bearer.Service, ShouldEqual, "service") + }) + + Convey("Verify mgmt route enabled with bearer", t, func() { + conf.HTTP.Auth.HTPasswd.Path = "" + conf.HTTP.Auth.LDAP = nil + conf.HTTP.Auth.Bearer = &config.BearerConfig{ + Realm: "realm", + Service: "service", + } + + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Mgmt = &extconf.MgmtConfig{ + BaseConfig: extconf.BaseConfig{ + Enable: &defaultValue, + }, + } + + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // cleanup + + ctlr := api.NewController(conf) + + subPaths := make(map[string]config.StorageConfig) + subPaths["/a"] = config.StorageConfig{} + + ctlr.Config.Storage.RootDirectory = globalDir + ctlr.Config.Storage.SubPaths = subPaths + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + data, _ := os.ReadFile(logFile.Name()) + + So(string(data), ShouldContainSubstring, "setting up mgmt routes") + + // without credentials + resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + mgmtResp := extensions.StrippedConfig{} + err = json.Unmarshal(resp.Body(), &mgmtResp) + So(err, ShouldBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd, ShouldBeNil) + So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) + So(mgmtResp.HTTP.Auth.Bearer, ShouldNotBeNil) + So(mgmtResp.HTTP.Auth.Bearer.Realm, ShouldEqual, "realm") + So(mgmtResp.HTTP.Auth.Bearer.Service, ShouldEqual, "service") + }) + + Convey("Verify mgmt route enabled without any auth", t, func() { + globalDir := t.TempDir() + conf := config.New() + port := test.GetFreePort() + conf.HTTP.Port = port + baseURL := test.GetBaseURL(port) + + logFile, err := os.CreateTemp(globalDir, "zot-log*.txt") + So(err, ShouldBeNil) + defaultValue := true + + conf.Commit = "v1.0.0" + + conf.Extensions = &extconf.ExtensionConfig{} + conf.Extensions.Mgmt = &extconf.MgmtConfig{ + BaseConfig: extconf.BaseConfig{ + Enable: &defaultValue, + }, + } + + conf.Log.Output = logFile.Name() + defer os.Remove(logFile.Name()) // cleanup + + ctlr := api.NewController(conf) + + subPaths := make(map[string]config.StorageConfig) + subPaths["/a"] = config.StorageConfig{} + + ctlr.Config.Storage.RootDirectory = globalDir + ctlr.Config.Storage.SubPaths = subPaths + + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + resp, err := resty.R().Get(baseURL + constants.FullMgmtPrefix) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + mgmtResp := extensions.StrippedConfig{} + err = json.Unmarshal(resp.Body(), &mgmtResp) + So(err, ShouldBeNil) + So(mgmtResp.DistSpecVersion, ShouldResemble, conf.DistSpecVersion) + So(mgmtResp.HTTP.Auth.Bearer, ShouldBeNil) + So(mgmtResp.HTTP.Auth.HTPasswd, ShouldBeNil) + So(mgmtResp.HTTP.Auth.LDAP, ShouldBeNil) + + data, _ := os.ReadFile(logFile.Name()) + So(string(data), ShouldContainSubstring, "setting up mgmt routes") + }) +} diff --git a/pkg/extensions/mgmt.md b/pkg/extensions/mgmt.md new file mode 100644 index 00000000..c1fe37c0 --- /dev/null +++ b/pkg/extensions/mgmt.md @@ -0,0 +1,44 @@ +# `mgmt` + +`mgmt` component provides an endpoint for configuration management + +Response depends on the user privileges: +- unauthenticated and authenticated users will get a stripped config +- admins will get full configuration with passwords hidden (not implemented yet) + + +| Supported queries | Input | Output | Description | +| --- | --- | --- | --- | +| [Get current configuration](#get-current-configuration) | None | config json | Get current zot configuration | + + +## Get current configuration + +**Sample request** + +```bash +curl http://localhost:8080/v2/_zot/ext/mgmt | jq +``` + +**Sample response** + +```json +{ + "distSpecVersion": "1.1.0-dev", + "binaryType": "-sync-search-scrub-metrics-lint-ui-mgmt", + "http": { + "auth": { + "htpasswd": {}, + "bearer": { + "realm": "https://auth.myreg.io/auth/token", + "service": "myauth" + } + } + } +} +``` + +If ldap or htpasswd are enabled mgmt will return `{"htpasswd": {}}` indicating that clients can authenticate with basic auth credentials. + +If any key is present under `'auth'` key, in the mgmt response, it means that particular authentication method is enabled. +