diff --git a/Makefile b/Makefile index 0a4076ad0..14c449156 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,8 @@ GO_BUILDINFO = -X '$(PKG_PREFIX)/lib/buildinfo.Version=$(APP_NAME)-$(shell date all: \ victoria-metrics-prod \ vmagent-prod \ - vmalert-prot \ + vmalert-prod \ + vmauth-prod \ vmbackup-prod \ vmrestore-prod @@ -27,6 +28,7 @@ publish: \ publish-victoria-metrics \ publish-vmagent \ publish-vmalert \ + publish-vmauth \ publish-vmbackup \ publish-vmrestore @@ -34,12 +36,14 @@ package: \ package-victoria-metrics \ package-vmagent \ package-vmalert \ + package-vmauth \ package-vmbackup \ package-vmrestore vmutils: \ vmagent \ vmalert \ + vmauth \ vmbackup \ vmrestore @@ -54,9 +58,10 @@ release-victoria-metrics: victoria-metrics-prod release-vmutils: \ vmagent-prod \ vmalert-prod \ + vmauth-prod \ vmbackup-prod \ vmrestore-prod - cd bin && tar czf vmutils-$(PKG_TAG).tar.gz vmagent-prod vmalert-prod vmbackup-prod vmrestore-prod && \ + cd bin && tar czf vmutils-$(PKG_TAG).tar.gz vmagent-prod vmalert-prod vmauth-prod vmbackup-prod vmrestore-prod && \ sha256sum vmutils-$(PKG_TAG).tar.gz > vmutils-$(PKG_TAG)_checksums.txt pprof-cpu: @@ -84,9 +89,9 @@ errcheck: install-errcheck errcheck -exclude=errcheck_excludes.txt ./app/vmstorage/... errcheck -exclude=errcheck_excludes.txt ./app/vmagent/... errcheck -exclude=errcheck_excludes.txt ./app/vmalert/... + errcheck -exclude=errcheck_excludes.txt ./app/vmauth/... errcheck -exclude=errcheck_excludes.txt ./app/vmbackup/... errcheck -exclude=errcheck_excludes.txt ./app/vmrestore/... - errcheck -exclude=errcheck_excludes.txt ./app/vmalert/... install-errcheck: which errcheck || GO111MODULE=off go get -u github.com/kisielk/errcheck diff --git a/app/vmauth/Makefile b/app/vmauth/Makefile new file mode 100644 index 000000000..5d7bdc238 --- /dev/null +++ b/app/vmauth/Makefile @@ -0,0 +1,74 @@ +# All these commands must run from repository root. + +vmauth: + APP_NAME=vmauth $(MAKE) app-local + +vmauth-race: + APP_NAME=vmauth RACE=-race $(MAKE) app-local + +vmauth-prod: + APP_NAME=vmauth $(MAKE) app-via-docker + +vmauth-pure-prod: + APP_NAME=vmauth $(MAKE) app-via-docker-pure + +vmauth-amd64-prod: + APP_NAME=vmauth $(MAKE) app-via-docker-amd64 + +vmauth-arm-prod: + APP_NAME=vmauth $(MAKE) app-via-docker-arm + +vmauth-arm64-prod: + APP_NAME=vmauth $(MAKE) app-via-docker-arm64 + +vmauth-ppc64le-prod: + APP_NAME=vmauth $(MAKE) app-via-docker-ppc64le + +vmauth-386-prod: + APP_NAME=vmauth $(MAKE) app-via-docker-386 + +package-vmauth: + APP_NAME=vmauth $(MAKE) package-via-docker + +package-vmauth-pure: + APP_NAME=vmauth $(MAKE) package-via-docker-pure + +package-vmauth-amd64: + APP_NAME=vmauth $(MAKE) package-via-docker-amd64 + +package-vmauth-arm: + APP_NAME=vmauth $(MAKE) package-via-docker-arm + +package-vmauth-arm64: + APP_NAME=vmauth $(MAKE) package-via-docker-arm64 + +package-vmauth-ppc64le: + APP_NAME=vmauth $(MAKE) package-via-docker-ppc64le + +package-vmauth-386: + APP_NAME=vmauth $(MAKE) package-via-docker-386 + +publish-vmauth: + APP_NAME=vmauth $(MAKE) publish-via-docker + +run-vmauth: + APP_NAME=vmauth \ + $(MAKE) run-via-docker + +vmauth-amd64: + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 GO111MODULE=on go build -mod=vendor -ldflags "$(GO_BUILDINFO)" -o bin/vmauth-amd64 ./app/vmauth + +vmauth-arm: + CGO_ENABLED=0 GOOS=linux GOARCH=arm GO111MODULE=on go build -mod=vendor -ldflags "$(GO_BUILDINFO)" -o bin/vmauth-arm ./app/vmauth + +vmauth-arm64: + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 GO111MODULE=on go build -mod=vendor -ldflags "$(GO_BUILDINFO)" -o bin/vmauth-arm64 ./app/vmauth + +vmauth-ppc64le: + CGO_ENABLED=0 GOOS=linux GOARCH=ppc64le GO111MODULE=on go build -mod=vendor -ldflags "$(GO_BUILDINFO)" -o bin/vmauth-ppc64le ./app/vmauth + +vmauth-386: + CGO_ENABLED=0 GOOS=linux GOARCH=386 GO111MODULE=on go build -mod=vendor -ldflags "$(GO_BUILDINFO)" -o bin/vmauth-386 ./app/vmauth + +vmauth-pure: + APP_NAME=vmauth $(MAKE) app-local-pure diff --git a/app/vmauth/README.md b/app/vmauth/README.md new file mode 100644 index 000000000..657608308 --- /dev/null +++ b/app/vmauth/README.md @@ -0,0 +1,103 @@ +## vmauth + +`vmauth` is a simple auth proxy and router. It reads username and password from [Basic Auth headers](https://en.wikipedia.org/wiki/Basic_access_authentication) +and matches them against configs pointed by `-auth.config` command-line flag and proxies incoming HTTP requests to the configured per-user `url_prefix` on successful match. + + +### Usage + +Just pass path to [auth config](#auth-config) file to `vmauth` binary: + +``` +/path/to/vmauth -auth.config=/path/to/auth/config.yml +``` + +After that `vmauth` starts accepting HTTP requests on port `8427` and routing them according to the provided `-auth.config`. +The port can be modified via `-httpListenAddr` command-line flag. + +The auth config can be reloaded by passing `SIGHUP` signal to `vmauth`. + +Pass `-help` to `vmauth` in order to see all the supported command-line flags with their descriptions. + + +### Auth config + +Auth config is represented in the following simple `yml` format: + +```yml + +# Arbitrary number of usernames may be put here. +# Usernames must be unique. + +users: + + # The user for querying local single-node VictoriaMetrics +- username: "local-single-node" + password: "***" + url_prefix: "http://localhost:8428" + + # The user for querying account 123 in VictoriaMetrics cluster + # See https://github.com/VictoriaMetrics/VictoriaMetrics/blob/cluster/README.md#url-format +- username: "cluster-select-account-123" + password: "***" + url_prefix: "http://vmselect:8481/select/123/prometheus" + + # The user for inserting Prometheus data into VictoriaMetrics cluster under account 42 + # See https://github.com/VictoriaMetrics/VictoriaMetrics/blob/cluster/README.md#url-format +- username: "cluster-insert-account-42" + password: "***" + url_prefix: "http://localhost:8480/insert/42/prometheus" +``` + + +### Security + +Do not transfer Basic Auth headers in plaintext over untrusted networks. Enable https. This can be done by passing the following `-tls*` command-line flags to `vmauth`: + +``` + -tls + Whether to enable TLS (aka HTTPS) for incoming requests. -tlsCertFile and -tlsKeyFile must be set if -tls is set + -tlsCertFile string + Path to file with TLS certificate. Used only if -tls is set. Prefer ECDSA certs instead of RSA certs, since RSA certs are slow + -tlsKeyFile string + Path to file with TLS key. Used only if -tls is set +``` + +Alternatively, [https termination proxy](https://en.wikipedia.org/wiki/TLS_termination_proxy) may be put in front of `vmauth`. + + +### Monitoring + +`vmauth` exports various metrics in Prometheus exposition format at `http://vmauth-host:8427/metrics` page. It is recommended setting up regular scraping of this page +either via [vmagent](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/app/vmagent/README.md) or via Prometheus, so the exported metrics could be analyzed later. + + +### How to build from sources + +It is recommended using [binary releases](https://github.com/VictoriaMetrics/VictoriaMetrics/releases) - `vmauth` is located in `vmutils-*` archives there. + + +#### Development build + +1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.13. +2. Run `make vmauth` from the root folder of the repository. + It builds `vmauth` binary and puts it into the `bin` folder. + +#### Production build + +1. [Install docker](https://docs.docker.com/install/). +2. Run `make vmauth-prod` from the root folder of the repository. + It builds `vmauth-prod` binary and puts it into the `bin` folder. + +#### Building docker images + +Run `make package-vmauth`. It builds `victoriametrics/vmauth:` docker image locally. +`` is auto-generated image tag, which depends on source code in the repository. +The `` may be manually set via `PKG_TAG=foobar make package-vmauth`. + +By default the image is built on top of `scratch` image. It is possible to build the package on top of any other base image +by setting it via `` environment variable. For example, the following command builds the image on top of `alpine:3.11` image: + +```bash +ROOT_IMAGE=alpine:3.11 make package-vmauth +``` diff --git a/app/vmauth/auth_config.go b/app/vmauth/auth_config.go new file mode 100644 index 000000000..af1b54846 --- /dev/null +++ b/app/vmauth/auth_config.go @@ -0,0 +1,127 @@ +package main + +import ( + "flag" + "fmt" + "io/ioutil" + "net/url" + "strings" + "sync" + "sync/atomic" + + "github.com/VictoriaMetrics/VictoriaMetrics/lib/logger" + "github.com/VictoriaMetrics/VictoriaMetrics/lib/procutil" + "github.com/VictoriaMetrics/metrics" + "gopkg.in/yaml.v2" +) + +var ( + authConfigPath = flag.String("auth.config", "", "Path to auth config. See https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/app/vmauth/README.md "+ + "for details on the format of this auth config") +) + +// AuthConfig represents auth config. +type AuthConfig struct { + Users []UserInfo `yaml:"users"` +} + +// UserInfo is user information read from authConfigPath +type UserInfo struct { + Username string `yaml:"username"` + Password string `yaml:"password"` + URLPrefix string `yaml:"url_prefix"` + + requests *metrics.Counter +} + +func initAuthConfig() { + if len(*authConfigPath) == 0 { + logger.Panicf("FATAL: missing required `-auth.config` command-line flag") + } + m, err := readAuthConfig(*authConfigPath) + if err != nil { + logger.Panicf("FATAL: cannot load auth config from `-auth.config=%s`: %s", *authConfigPath, err) + } + authConfig.Store(m) + stopCh = make(chan struct{}) + authConfigWG.Add(1) + go func() { + defer authConfigWG.Done() + authConfigReloader() + }() +} + +func stopAuthConfig() { + close(stopCh) + authConfigWG.Wait() +} + +func authConfigReloader() { + sighupCh := procutil.NewSighupChan() + for { + select { + case <-stopCh: + return + case <-sighupCh: + m, err := readAuthConfig(*authConfigPath) + if err != nil { + logger.Errorf("failed to load auth config; using the last successfully loaded config; error: %s", err) + continue + } + authConfig.Store(m) + } + } +} + +var authConfig atomic.Value +var authConfigWG sync.WaitGroup +var stopCh chan struct{} + +func readAuthConfig(path string) (map[string]*UserInfo, error) { + data, err := ioutil.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("cannot read %q: %s", path, err) + } + m, err := parseAuthConfig(data) + if err != nil { + return nil, fmt.Errorf("cannot parse %q: %s", path, err) + } + logger.Infof("Loaded information about %d users from %q", len(m), path) + return m, nil +} + +func parseAuthConfig(data []byte) (map[string]*UserInfo, error) { + var ac AuthConfig + if err := yaml.UnmarshalStrict(data, &ac); err != nil { + return nil, fmt.Errorf("cannot unmarshal AuthConfig data: %s", err) + } + uis := ac.Users + if len(uis) == 0 { + return nil, fmt.Errorf("`users` section cannot be empty in AuthConfig") + } + m := make(map[string]*UserInfo, len(uis)) + for i := range uis { + ui := &uis[i] + if m[ui.Username] != nil { + return nil, fmt.Errorf("duplicate username found; username: %q", ui.Username) + } + urlPrefix := ui.URLPrefix + // Remove trailing '/' from urlPrefix + for strings.HasSuffix(urlPrefix, "/") { + urlPrefix = urlPrefix[:len(urlPrefix)-1] + } + // Validate urlPrefix + target, err := url.Parse(urlPrefix) + if err != nil { + return nil, fmt.Errorf("invalid `url_prefix: %q`: %s", urlPrefix, err) + } + if target.Scheme != "http" && target.Scheme != "https" { + return nil, fmt.Errorf("unsupported scheme for `url_prefix: %q`: %q; must be `http` or `https`", urlPrefix, target.Scheme) + } + + ui.URLPrefix = urlPrefix + ui.requests = metrics.GetOrCreateCounter(fmt.Sprintf(`vmauth_user_requests_total{username=%q}`, ui.Username)) + m[ui.Username] = ui + } + return m, nil +} diff --git a/app/vmauth/auth_config_test.go b/app/vmauth/auth_config_test.go new file mode 100644 index 000000000..d83d4af3d --- /dev/null +++ b/app/vmauth/auth_config_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestParseAuthConfigFailure(t *testing.T) { + f := func(s string) { + t.Helper() + _, err := parseAuthConfig([]byte(s)) + if err == nil { + t.Fatalf("expecting non-nil error") + } + } + + // Empty config + f(``) + + // Invalid entry + f(`foobar`) + f(`foobar: baz`) + + // Empty users + f(`users: []`) + + // Missing url_prefix + f(` +users: +- username: foo +`) + + // Invalid url_prefix + f(` +users: +- username: foo + url_prefix: bar +`) + f(` +users: +- username: foo + url_prefix: ftp://bar +`) + f(` +users: +- username: foo + url_prefix: //bar +`) + + // Duplicate users + f(` +users: +- username: foo + url_prefix: http://foo.bar +- username: bar + url_prefix: http://xxx.yyy +- username: foo + url_prefix: https://sss.sss +`) +} + +func TestParseAuthConfigSuccess(t *testing.T) { + f := func(s string, expectedAuthConfig map[string]*UserInfo) { + t.Helper() + m, err := parseAuthConfig([]byte(s)) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + removeMetrics(m) + if !reflect.DeepEqual(m, expectedAuthConfig) { + t.Fatalf("unexpected auth config\ngot\n%v\nwant\n%v", m, expectedAuthConfig) + } + } + + // Single user + f(` +users: +- username: foo + password: bar + url_prefix: http://aaa:343/bbb +`, map[string]*UserInfo{ + "foo": { + Username: "foo", + Password: "bar", + URLPrefix: "http://aaa:343/bbb", + }, + }) + + // Multiple users + f(` +users: +- username: foo + url_prefix: http://foo +- username: bar + url_prefix: https://bar/x/// +`, map[string]*UserInfo{ + "foo": { + Username: "foo", + URLPrefix: "http://foo", + }, + "bar": { + Username: "bar", + URLPrefix: "https://bar/x", + }, + }) +} + +func removeMetrics(m map[string]*UserInfo) { + for _, info := range m { + info.requests = nil + } +} diff --git a/app/vmauth/deployment/Dockerfile b/app/vmauth/deployment/Dockerfile new file mode 100644 index 000000000..1bd6465d9 --- /dev/null +++ b/app/vmauth/deployment/Dockerfile @@ -0,0 +1,8 @@ +ARG base_image +FROM $base_image + +EXPOSE 8427 + +ENTRYPOINT ["/vmauth-prod"] +ARG src_binary +COPY $src_binary ./vmauth-prod diff --git a/app/vmauth/main.go b/app/vmauth/main.go new file mode 100644 index 000000000..cae24f821 --- /dev/null +++ b/app/vmauth/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "flag" + "net/http" + "net/http/httputil" + "net/url" + "time" + + "github.com/VictoriaMetrics/VictoriaMetrics/lib/buildinfo" + "github.com/VictoriaMetrics/VictoriaMetrics/lib/envflag" + "github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver" + "github.com/VictoriaMetrics/VictoriaMetrics/lib/logger" + "github.com/VictoriaMetrics/VictoriaMetrics/lib/procutil" +) + +var ( + httpListenAddr = flag.String("httpListenAddr", ":8427", "TCP address to listen for http connections") +) + +func main() { + envflag.Parse() + buildinfo.Init() + logger.Init() + logger.Infof("starting vmauth at %q...", *httpListenAddr) + startTime := time.Now() + initAuthConfig() + go httpserver.Serve(*httpListenAddr, requestHandler) + logger.Infof("started vmauth in %.3f seconds", time.Since(startTime).Seconds()) + + sig := procutil.WaitForSigterm() + logger.Infof("received signal %s", sig) + + startTime = time.Now() + logger.Infof("gracefully shutting down webservice at %q", *httpListenAddr) + if err := httpserver.Stop(*httpListenAddr); err != nil { + logger.Fatalf("cannot stop the webservice: %s", err) + } + logger.Infof("successfully shut down the webservice in %.3f seconds", time.Since(startTime).Seconds()) + stopAuthConfig() + logger.Infof("successfully stopped vmauth in %.3f seconds", time.Since(startTime).Seconds()) +} + +func requestHandler(w http.ResponseWriter, r *http.Request) bool { + username, password, ok := r.BasicAuth() + if !ok { + httpserver.Errorf(w, "Missing `Authorization: Basic *` header") + return true + } + ac := authConfig.Load().(map[string]*UserInfo) + info := ac[username] + if info == nil || info.Password != password { + httpserver.Errorf(w, "Cannot find the provided username %q or password in config", username) + return true + } + info.requests.Inc() + + targetURL := info.URLPrefix + r.RequestURI + if _, err := url.Parse(targetURL); err != nil { + httpserver.Errorf(w, "Invalid targetURL=%q: %s", targetURL, err) + return true + } + r.Header.Set("vm-target-url", targetURL) + reverseProxy.ServeHTTP(w, r) + return true +} + +var reverseProxy = &httputil.ReverseProxy{ + Director: func(r *http.Request) { + targetURL := r.Header.Get("vm-target-url") + target, err := url.Parse(targetURL) + if err != nil { + logger.Panicf("BUG: unexpected error when parsing targetURL=%q: %s", targetURL, err) + } + r.URL = target + }, + FlushInterval: time.Second, + ErrorLog: logger.StdErrorLogger(), +}