0
Fork 0
mirror of https://github.com/project-zot/zot.git synced 2025-01-20 22:52:51 -05:00

feat(mgmt): added mgmt extension which returns current zot configuration (#1198)

Signed-off-by: Petu Eusebiu <peusebiu@cisco.com>
This commit is contained in:
peusebiu 2023-03-09 20:43:26 +02:00 committed by GitHub
parent 4c156234cb
commit f04e66a5e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 807 additions and 33 deletions

View file

@ -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

View file

@ -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:=

View file

@ -48,6 +48,9 @@
"scrub": {
"enable": true,
"interval": "24h"
},
"mgmt": {
"enable": true
}
}
}

View file

@ -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)

View file

@ -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

View file

@ -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"`
}

View file

@ -7,4 +7,6 @@ const (
// zot specific extensions.
ExtSearchPrefix = "/_zot/ext/search"
FullSearchPrefix = RoutePrefix + ExtSearchPrefix
ExtMgmtPrefix = "/_zot/ext/mgmt"
FullMgmtPrefix = RoutePrefix + ExtMgmtPrefix
)

View file

@ -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()

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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
}

View file

@ -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)

View file

@ -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

View file

@ -18,6 +18,11 @@ type ExtensionConfig struct {
Scrub *ScrubConfig
Lint *LintConfig
UI *UIConfig
Mgmt *MgmtConfig
}
type MgmtConfig struct {
BaseConfig `mapstructure:",squash"`
}
type LintConfig struct {

View file

@ -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)
}
}

View file

@ -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")
}

View file

@ -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

View file

@ -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")
})
}

44
pkg/extensions/mgmt.md Normal file
View file

@ -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.