From b101b0df8dd358a4ac7d3464c7dddd86163448e7 Mon Sep 17 00:00:00 2001 From: Igor Beliakov <46579601+weisdd@users.noreply.github.com> Date: Mon, 25 Apr 2022 23:32:56 +0200 Subject: [PATCH] Migrated to urfave CLI (#24) --- .github/workflows/main.yml | 3 + CHANGELOG.md | 8 +- Dockerfile | 11 +- README.md | 8 +- cmd/lfgw/logwrapper.go | 26 -- cmd/lfgw/main.go | 303 ++++++++++++---------- go.mod | 4 +- go.sum | 8 +- {cmd => internal}/lfgw/helpers.go | 40 +-- {cmd => internal}/lfgw/helpers_test.go | 2 +- internal/lfgw/logging.go | 87 +++++++ internal/lfgw/main.go | 158 +++++++++++ internal/lfgw/main_test.go | 142 ++++++++++ {cmd => internal}/lfgw/middleware.go | 6 +- {cmd => internal}/lfgw/middleware_test.go | 4 +- {cmd => internal}/lfgw/routes.go | 2 +- {cmd => internal}/lfgw/server.go | 8 +- internal/querymodifier/qm_test.go | 10 +- 18 files changed, 612 insertions(+), 218 deletions(-) delete mode 100644 cmd/lfgw/logwrapper.go rename {cmd => internal}/lfgw/helpers.go (64%) rename {cmd => internal}/lfgw/helpers_test.go (99%) create mode 100644 internal/lfgw/logging.go create mode 100644 internal/lfgw/main.go create mode 100644 internal/lfgw/main_test.go rename {cmd => internal}/lfgw/middleware.go (98%) rename {cmd => internal}/lfgw/middleware_test.go (96%) rename {cmd => internal}/lfgw/routes.go (97%) rename {cmd => internal}/lfgw/server.go (83%) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 642fbfa..44f535d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -52,3 +52,6 @@ jobs: with: push: ${{ github.actor == 'weisdd' }} tags: ${{ steps.meta.outputs.tags }} + build-args: | + COMMIT=${{ github.sha }} + VERSION=${{ steps.meta.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ae3fa72..83cbcb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,15 @@ # CHANGELOG +## 0.12.0 + +- Key changes: + - Migrated to urfave CLI; + - `VictoriaMetrics/metricsql` bumped from `0.41.0` to `0.42.0`. + ## 0.11.3 - Key changes: - - Fixed deduplication for negative non-regexp filters (previously, those would be let through without request modifications); + - Fixed deduplication for negative non-regexp filters (previously, some of those would be let through without request modifications); - Internal refactoring: - Moved acl + lf logic to a new internal package `querymodifier`; - Added more tests. diff --git a/Dockerfile b/Dockerfile index 661dcf3..34acb31 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ FROM golang:1.18.1-alpine3.15 as builder +ARG COMMIT ARG VERSION WORKDIR /go/src/lfgw/ @@ -7,15 +8,15 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -ENV CGO_ENABLED=0 +ENV CGO_ENABLED=0 \ + GOOS=linux RUN go install \ -installsuffix "static" \ -ldflags " \ - -X main.Version=${VERSION} \ - -X main.GoVersion=$(go version | cut -d " " -f 3) \ - -X main.Compiler=$(go env CC) \ - -X main.Platform=$(go env GOOS)/$(go env GOARCH) \ + -X main.commit=${COMMIT} \ + -X main.version=${VERSION} \ + -X main.goVersion=$(go version | cut -d " " -f 3) \ " \ ./... diff --git a/README.md b/README.md index 3957a30..5cceca6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Target setup: `grafana -> lfgw -> Prometheus/VictoriaMetrics`. * a user can have multiple roles; * support for autoconfiguration in environments, where OIDC-role names match names of namespaces ("assumed roles" mode; thanks to [@aberestyak](https://github.com/aberestyak/) for the idea); * [automatic expression optimizations](https://pkg.go.dev/github.com/VictoriaMetrics/metricsql#Optimize) for non-full access requests; -* support for different headers with access tokens (`X-Forwarded-Access-Token`, `X-Auth-Request-Access-Token`, `Authorization`); +* support for different headers with access tokens (`Authorization`, `X-Forwarded-Access-Token`, `X-Auth-Request-Access-Token`), which can be useful for [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy) or other tools; * requests to both `/api/*` and `/federate` endpoints are protected (=rewritten); * requests to sensitive endpoints are blocked by default; * compatible with both [PromQL](https://prometheus.io/docs/prometheus/latest/querying/basics/) and [MetricsQL](https://github.com/VictoriaMetrics/VictoriaMetrics/wiki/MetricsQL). @@ -110,13 +110,15 @@ Note: a user is free to have multiple roles matching the contents of `acl.yaml`. * multiple "limited" roles => definitions of all those roles are merged together, and then LFGW generates a new LF. The process is the same as if this meta-definition was loaded through `acl.yaml`. +## Licensing + +lfgw code is licensed under MIT, though its dependencies might have other licenses. Please, inspect the modules listed in [go.mod](./go.mod) if needed. + ## TODO * tests for handlers; * improve naming; * log slow requests; * metrics; -* add CLI interface (currently, only environment variables are used); -* configurable JMESPath for the `roles` attribute; * OIDC callback to support for proxying Prometheus web-interface itself; * add a helm chart. diff --git a/cmd/lfgw/logwrapper.go b/cmd/lfgw/logwrapper.go deleted file mode 100644 index 14161a1..0000000 --- a/cmd/lfgw/logwrapper.go +++ /dev/null @@ -1,26 +0,0 @@ -package main - -import ( - "strings" - - "github.com/rs/zerolog" -) - -type stdErrorLogWrapper struct { - logger *zerolog.Logger -} - -// TODO: new? - -// Write implements io.Writer interface to redirect standard logger entries to zerolog. Also, it cuts caller from a log entry and passes it to zerolog's caller. -func (s stdErrorLogWrapper) Write(p []byte) (n int, err error) { - caller, errorMsg, _ := strings.Cut(string(p), " ") - caller = strings.TrimRight(caller, ":") - - s.logger.Error(). - Str("caller", caller). - Str("error", errorMsg). - Msg("") - - return len(p), nil -} diff --git a/cmd/lfgw/main.go b/cmd/lfgw/main.go index 5d1c01f..8bb8826 100644 --- a/cmd/lfgw/main.go +++ b/cmd/lfgw/main.go @@ -1,148 +1,183 @@ package main import ( - "context" - "log" - "net/http/httputil" - "net/url" - "os" - "runtime" "time" - "github.com/caarlos0/env/v6" - oidc "github.com/coreos/go-oidc/v3/oidc" - "github.com/rs/zerolog" - zlog "github.com/rs/zerolog/log" - "github.com/weisdd/lfgw/internal/querymodifier" - "go.uber.org/automaxprocs/maxprocs" -) - -// Define an application struct to hold the application-wide dependencies for the -// web application. -type application struct { - errorLog *log.Logger - logger *zerolog.Logger - ACLs querymodifier.ACLs - proxy *httputil.ReverseProxy - verifier *oidc.IDTokenVerifier - Debug bool `env:"DEBUG" envDefault:"false"` - LogFormat string `env:"LOG_FORMAT" envDefault:"pretty"` - LogRequests bool `env:"LOG_REQUESTS" envDefault:"false"` - LogNoColor bool `env:"LOG_NO_COLOR" envDefault:"false"` - UpstreamURL *url.URL `env:"UPSTREAM_URL,required"` - OptimizeExpressions bool `env:"OPTIMIZE_EXPRESSIONS" envDefault:"true"` - EnableDeduplication bool `env:"ENABLE_DEDUPLICATION" envDefault:"true"` - SafeMode bool `env:"SAFE_MODE" envDefault:"true"` - SetProxyHeaders bool `env:"SET_PROXY_HEADERS" envDefault:"false"` - SetGomaxProcs bool `env:"SET_GOMAXPROCS" envDefault:"true"` - ACLPath string `env:"ACL_PATH" envDefault:"./acl.yaml"` - AssumedRolesEnabled bool `env:"ASSUMED_ROLES" envDefault:"false"` - OIDCRealmURL string `env:"OIDC_REALM_URL,required"` - OIDCClientID string `env:"OIDC_CLIENT_ID,required"` - Port int `env:"PORT" envDefault:"8080"` - ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"10s"` - WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"10s"` - GracefulShutdownTimeout time.Duration `env:"GRACEFUL_SHUTDOWN_TIMEOUT" envDefault:"20s"` -} + "github.com/urfave/cli/v2" + "github.com/weisdd/lfgw/internal/lfgw" -type contextKey string + "fmt" + "os" +) -const contextKeyACL = contextKey("acl") +var ( + commit = "none" + goVersion = "unknown" + version = "dev" +) func main() { - zlog.Logger = zlog.Output(os.Stdout) - - logWrapper := stdErrorLogWrapper{logger: &zlog.Logger} - // NOTE: don't delete log.Lshortfile - errorLog := log.New(logWrapper, "", log.Lshortfile) - - app := &application{ - logger: &zlog.Logger, - errorLog: errorLog, - } - - zerolog.CallerMarshalFunc = app.lshortfile - zerolog.DurationFieldUnit = time.Second - - err := env.Parse(app) - if err != nil { - app.logger.Fatal().Caller(). - Err(err).Msg("") - } - - // TODO: think of something better? - if app.LogFormat == "pretty" { - zlog.Logger = zlog.Output(zerolog.ConsoleWriter{Out: os.Stdout, NoColor: app.LogNoColor}) - } - - if app.Debug { - zerolog.SetGlobalLevel(zerolog.DebugLevel) - } - - if app.SetGomaxProcs { - undo, err := maxprocs.Set() - defer undo() - if err != nil { - app.logger.Error().Caller(). - Msgf("failed to set GOMAXPROCS: %v", err) - } - } - app.logger.Info().Caller(). - Msgf("Runtime settings: GOMAXPROCS = %d", runtime.GOMAXPROCS(0)) - - if app.AssumedRolesEnabled { - app.logger.Info().Caller(). - Msg("Assumed roles mode is on") - } else { - app.logger.Info().Caller(). - Msg("Assumed roles mode is off") - } - - if app.ACLPath != "" { - app.ACLs, err = querymodifier.NewACLsFromFile(app.ACLPath) - if err != nil { - app.logger.Fatal().Caller(). - Err(err).Msgf("Failed to load ACL") - } - - for role, acl := range app.ACLs { - app.logger.Info().Caller(). - Msgf("Loaded role definition for %s: %q (converted to %s)", role, acl.RawACL, acl.LabelFilter.AppendString(nil)) - } - } else { - if !app.AssumedRolesEnabled { - app.logger.Fatal().Caller(). - Msgf("The app cannot run without at least one source of configuration (Non-empty ACL_PATH and/or ASSUMED_ROLES set to true)") - } - - app.logger.Info().Caller(). - Msgf("ACL_PATH is empty, thus predefined roles are not loaded") - } - - app.logger.Info().Caller(). - Msgf("Connecting to OIDC backend (%q)", app.OIDCRealmURL) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - provider, err := oidc.NewProvider(ctx, app.OIDCRealmURL) - if err != nil { - app.logger.Fatal().Caller(). - Err(err).Msg("") - } - - oidcConfig := &oidc.Config{ - ClientID: app.OIDCClientID, + app := &cli.App{ + Name: "lfgw", + Version: fmt.Sprintf("%s (commit: %s; runtime: %s)", version, commit, goVersion), + Compiled: time.Now(), + Authors: []*cli.Author{ + { + Name: "weisdd", + }, + }, + Copyright: "© 2021-2022 weisdd", + HelpName: "lfgw", + Usage: "A reverse proxy aimed at PromQL / MetricsQL metrics filtering based on OIDC roles", + UsageText: "lfgw [flags]", + // UseShortOptionHandling: true, + // EnableBashCompletion: true, + HideHelpCommand: true, + Action: lfgw.Run, + Before: func(c *cli.Context) error { + nonEmptyStrings := []string{"upstream-url", "oidc-realm-url", "oidc-client-id"} + + for _, key := range nonEmptyStrings { + if c.String(key) == "" { + return fmt.Errorf("%s cannot be empty", key) + } + } + + if c.String("acl-path") == "" && !c.Bool("assumed-roles") { + return fmt.Errorf("the app cannot run without at least one configuration source: defined acl-path or assumed-roles set to true") + } + + return nil + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "upstream-url", + Usage: "Prometheus URL, e.g. http://prometheus.microk8s.localhost", + EnvVars: []string{"UPSTREAM_URL"}, + Required: true, + }, + &cli.StringFlag{ + Name: "oidc-realm-url", + Usage: "OIDC Realm URL, e.g. `https://auth.microk8s.localhost/auth/realms/cicd", + EnvVars: []string{"OIDC_REALM_URL"}, + Required: true, + }, + &cli.StringFlag{ + Name: "oidc-client-id", + Usage: "OIDC Client ID (used for token audience validation)", + EnvVars: []string{"OIDC_CLIENT_ID"}, + Required: true, + }, + &cli.StringFlag{ + Name: "acl-path", + Usage: "path to a file with ACL definitions (OIDC role to namespace bindings), skipped if empty", + EnvVars: []string{"ACL_PATH"}, + Value: "./acl.yaml", + Required: false, + }, + &cli.BoolFlag{ + Name: "assumed-roles", + Usage: "whether to treat unknown OIDC-role names as acl definitions (also known as autoconfiguration)", + EnvVars: []string{"ASSUMED_ROLES"}, + Value: false, + Required: false, + }, + &cli.BoolFlag{ + Name: "enable-deduplication", + Usage: "whether to enable deduplication, which leaves some of the requests unmodified if they match the target policy", + EnvVars: []string{"ENABLE_DEDUPLICATION"}, + Value: true, + Required: false, + }, + &cli.BoolFlag{ + Name: "optimize-expressions", + Usage: "whether to automatically optimize expressions for non-full access requests", + EnvVars: []string{"OPTIMIZE_EXPRESSIONS"}, + Value: true, + Required: false, + }, + &cli.BoolFlag{ + Name: "safe-mode", + Usage: "whether to block requests to sensitive endpoints (tsdb admin, insert)", + EnvVars: []string{"SAFE_MODE"}, + Value: true, + Required: false, + }, + &cli.BoolFlag{ + Name: "set-proxy-headers", + Usage: "whether to set proxy headers (X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host)", + EnvVars: []string{"SET_PROXY_HEADERS"}, + Value: false, + Required: false, + }, + &cli.BoolFlag{ + Name: "set-gomax-procs", + Usage: "automatically set GOMAXPROCS to match Linux container CPU quota", + EnvVars: []string{"SET_GOMAXPROCS"}, + Value: true, + Required: false, + }, + &cli.BoolFlag{ + Name: "debug", + Usage: "whether to print out debug log messages", + EnvVars: []string{"DEBUG"}, + Value: false, + Required: false, + }, + &cli.StringFlag{ + Name: "log-format", + Usage: "log format: pretty, json", + EnvVars: []string{"LOG_FORMAT"}, + Value: "pretty", + Required: false, + }, + &cli.BoolFlag{ + Name: "log-no-color", + Usage: "whether to disable colors for pretty format", + EnvVars: []string{"LOG_NO_COLOR"}, + Value: false, + Required: false, + }, + &cli.BoolFlag{ + Name: "log-requests", + Usage: "whether to log HTTP requests", + EnvVars: []string{"LOG_REQUESTS"}, + Value: false, + Required: false, + }, + &cli.IntFlag{ + Name: "port", + Usage: "port the web server will listen on", + EnvVars: []string{"PORT"}, + Value: 8080, + Required: false, + }, + &cli.DurationFlag{ + Name: "read-timeout", + Usage: "the maximum time the from when the connection is accepted to when the request body is fully read", + EnvVars: []string{"READ_TIMEOUT"}, + Value: 10 * time.Second, + Required: false, + }, + &cli.DurationFlag{ + Name: "write-timeout", + Usage: "the maximum time from the end of the request header read to the end of the response write", + EnvVars: []string{"WRITE_TIMEOUT"}, + Value: 10 * time.Second, + Required: false, + }, + &cli.DurationFlag{ + Name: "graceful-shutdown-timeout", + Usage: "the maximum amount of time to wait for all connections to be closed", + EnvVars: []string{"GRACEFUL_SHUTDOWN_TIMEOUT"}, + Value: 20 * time.Second, + Required: false, + }, + }, } - app.verifier = provider.Verifier(oidcConfig) - - app.proxy = httputil.NewSingleHostReverseProxy(app.UpstreamURL) - // TODO: somehow pass more context to ErrorLog (unsafe?) - app.proxy.ErrorLog = app.errorLog - app.proxy.FlushInterval = time.Millisecond * 200 - err = app.serve() + err := app.Run(os.Args) if err != nil { - app.logger.Fatal().Caller(). - Err(err).Msg("") + fmt.Printf("\n%+v: %+v\n", os.Args[0], err) } } diff --git a/go.mod b/go.mod index e479291..8a20cde 100644 --- a/go.mod +++ b/go.mod @@ -5,20 +5,22 @@ go 1.18 require ( github.com/VictoriaMetrics/metrics v1.18.1 github.com/VictoriaMetrics/metricsql v0.42.0 - github.com/caarlos0/env/v6 v6.9.1 github.com/coreos/go-oidc/v3 v3.1.0 github.com/gorilla/mux v1.8.0 github.com/rs/zerolog v1.26.1 github.com/stretchr/testify v1.7.1 + github.com/urfave/cli/v2 v2.4.4 go.uber.org/automaxprocs v1.5.1 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) require ( + github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang/protobuf v1.4.3 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/xid v1.3.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e // indirect diff --git a/go.sum b/go.sum index d12413f..37cceea 100644 --- a/go.sum +++ b/go.sum @@ -37,8 +37,6 @@ github.com/VictoriaMetrics/metrics v1.18.1 h1:OZ0+kTTto8oPfHnVAnTOoyl0XlRhRkoQrD github.com/VictoriaMetrics/metrics v1.18.1/go.mod h1:ArjwVz7WpgpegX/JpB0zpNF2h2232kErkEnzH1sxMmA= github.com/VictoriaMetrics/metricsql v0.42.0 h1:E+NZWdpZHSLapQTuT9g+MB4vvD9JB6dSd/0L8QDkcQ4= github.com/VictoriaMetrics/metricsql v0.42.0/go.mod h1:6pP1ZeLVJHqJrHlF6Ij3gmpQIznSsgktEcZgsAWYel0= -github.com/caarlos0/env/v6 v6.9.1 h1:zOkkjM0F6ltnQ5eBX6IPI41UP/KDGEK7rRPwGCNos8k= -github.com/caarlos0/env/v6 v6.9.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -48,6 +46,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/coreos/go-oidc/v3 v3.1.0 h1:6avEvcdvTa1qYsOZ6I5PRkSYHzpTNWgKYmaJfaYbrRw= github.com/coreos/go-oidc/v3 v3.1.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= +github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -130,11 +130,15 @@ github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4= github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc= github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/urfave/cli/v2 v2.4.4 h1:IvwT3XfI6RytTmIzC35UAu9oyK+bHgUPXDDZNqribkI= +github.com/urfave/cli/v2 v2.4.4/go.mod h1:oDzoM7pVwz6wHn5ogWgFUU1s4VJayeQS+aEZDqXIEJs= github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8= github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ= github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ= diff --git a/cmd/lfgw/helpers.go b/internal/lfgw/helpers.go similarity index 64% rename from cmd/lfgw/helpers.go rename to internal/lfgw/helpers.go index b9f73c7..01cf9a7 100644 --- a/cmd/lfgw/helpers.go +++ b/internal/lfgw/helpers.go @@ -1,13 +1,11 @@ -package main +package lfgw import ( "fmt" "net/http" "net/url" - "strconv" "strings" - "github.com/rs/zerolog" "github.com/rs/zerolog/hlog" ) @@ -48,42 +46,6 @@ func (app *application) getRawAccessToken(r *http.Request) (string, error) { return "", fmt.Errorf("no bearer token found") } -// lshortfile implements Lshortfile equivalent for zerolog's CallerMarshalFunc. -func (app *application) lshortfile(file string, line int) string { - // Copied from the standard library: https://cs.opensource.google/go/go/+/refs/tags/go1.17.8:src/log/log.go;drc=926994fd7cf65b2703552686965fb05569699897;l=134 - short := file - for i := len(file) - 1; i > 0; i-- { - if file[i] == '/' { - short = file[i+1:] - break - } - } - file = short - return file + ":" + strconv.Itoa(line) -} - -// enrichLogContext adds a custom field and a value to zerolog context. -func (app *application) enrichLogContext(r *http.Request, field string, value string) { - if field != "" && value != "" { - log := zerolog.Ctx(r.Context()) - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str(field, value) - }) - } -} - -// enrichDebugLogContext adds a custom field and a value to zerolog context if logging level is set to Debug. -func (app *application) enrichDebugLogContext(r *http.Request, field string, value string) { - if app.Debug { - if field != "" && value != "" { - log := zerolog.Ctx(r.Context()) - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str(field, value) - }) - } - } -} - // isNotAPIRequest returns true if the requested path does not target API or federate endpoints. func (app *application) isNotAPIRequest(path string) bool { return !strings.Contains(path, "/api/") && !strings.Contains(path, "/federate") diff --git a/cmd/lfgw/helpers_test.go b/internal/lfgw/helpers_test.go similarity index 99% rename from cmd/lfgw/helpers_test.go rename to internal/lfgw/helpers_test.go index 5b3ae2e..9869221 100644 --- a/cmd/lfgw/helpers_test.go +++ b/internal/lfgw/helpers_test.go @@ -1,4 +1,4 @@ -package main +package lfgw import ( "fmt" diff --git a/internal/lfgw/logging.go b/internal/lfgw/logging.go new file mode 100644 index 0000000..06ee2d5 --- /dev/null +++ b/internal/lfgw/logging.go @@ -0,0 +1,87 @@ +package lfgw + +import ( + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog" + zlog "github.com/rs/zerolog/log" +) + +type stdErrorLogWrapper struct { + logger *zerolog.Logger +} + +// TODO: new? + +// Write implements io.Writer interface to redirect standard logger entries to zerolog. Also, it cuts caller from a log entry and passes it to zerolog's caller. +func (s stdErrorLogWrapper) Write(p []byte) (n int, err error) { + caller, errorMsg, _ := strings.Cut(string(p), " ") + caller = strings.TrimRight(caller, ":") + + s.logger.Error(). + Str("caller", caller). + Str("error", errorMsg). + Msg("") + + return len(p), nil +} + +func (app *application) configureLogging() { + zlog.Logger = zlog.Output(os.Stdout) + app.logger = &zlog.Logger + logWrapper := stdErrorLogWrapper{logger: app.logger} + // NOTE: don't delete log.Lshortfile + app.errorLog = log.New(logWrapper, "", log.Lshortfile) + + zerolog.CallerMarshalFunc = app.lshortfile + zerolog.DurationFieldUnit = time.Second + + if app.LogFormat == "pretty" { + zlog.Logger = zlog.Output(zerolog.ConsoleWriter{Out: os.Stdout, NoColor: app.LogNoColor}) + } + + if app.Debug { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } +} + +// lshortfile implements Lshortfile equivalent for zerolog's CallerMarshalFunc. +func (app *application) lshortfile(file string, line int) string { + // Copied from the standard library: https://cs.opensource.google/go/go/+/refs/tags/go1.17.8:src/log/log.go;drc=926994fd7cf65b2703552686965fb05569699897;l=134 + short := file + for i := len(file) - 1; i > 0; i-- { + if file[i] == '/' { + short = file[i+1:] + break + } + } + file = short + return file + ":" + strconv.Itoa(line) +} + +// enrichLogContext adds a custom field and a value to zerolog context. +func (app *application) enrichLogContext(r *http.Request, field string, value string) { + if field != "" && value != "" { + log := zerolog.Ctx(r.Context()) + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str(field, value) + }) + } +} + +// enrichDebugLogContext adds a custom field and a value to zerolog context if logging level is set to Debug. +func (app *application) enrichDebugLogContext(r *http.Request, field string, value string) { + if app.Debug { + if field != "" && value != "" { + log := zerolog.Ctx(r.Context()) + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str(field, value) + }) + } + } +} diff --git a/internal/lfgw/main.go b/internal/lfgw/main.go new file mode 100644 index 0000000..3daf1b9 --- /dev/null +++ b/internal/lfgw/main.go @@ -0,0 +1,158 @@ +package lfgw + +import ( + "context" + "fmt" + "log" + "net/http/httputil" + "net/url" + "runtime" + "time" + + oidc "github.com/coreos/go-oidc/v3/oidc" + "github.com/rs/zerolog" + "github.com/urfave/cli/v2" + "github.com/weisdd/lfgw/internal/querymodifier" + "go.uber.org/automaxprocs/maxprocs" +) + +// Define an application struct to hold the application-wide dependencies for the +// web application. +type application struct { + UpstreamURL *url.URL + OIDCRealmURL string + OIDCClientID string + ACLPath string + AssumedRolesEnabled bool + EnableDeduplication bool + OptimizeExpressions bool + SafeMode bool + SetProxyHeaders bool + SetGomaxProcs bool + Debug bool + LogFormat string + LogNoColor bool + LogRequests bool + Port int + ReadTimeout time.Duration + WriteTimeout time.Duration + GracefulShutdownTimeout time.Duration + errorLog *log.Logger + ACLs querymodifier.ACLs + proxy *httputil.ReverseProxy + verifier *oidc.IDTokenVerifier + logger *zerolog.Logger +} + +// newApplication returns application struct built from *cli.Context +func newApplication(c *cli.Context) (application, error) { + upstreamURL, err := url.Parse(c.String("upstream-url")) + if err != nil { + return application{}, fmt.Errorf("failed to parse upstream-url: %s", err) + } + + app := application{ + UpstreamURL: upstreamURL, + OIDCRealmURL: c.String("oidc-realm-url"), + OIDCClientID: c.String("oidc-client-id"), + ACLPath: c.String("acl-path"), + AssumedRolesEnabled: c.Bool("assumed-roles"), + EnableDeduplication: c.Bool("enable-deduplication"), + OptimizeExpressions: c.Bool("optimize-expressions"), + SafeMode: c.Bool("safe-mode"), + SetProxyHeaders: c.Bool("set-proxy-headers"), + SetGomaxProcs: c.Bool("set-gomax-procs"), + Debug: c.Bool("debug"), + LogFormat: c.String("log-format"), + LogNoColor: c.Bool("log-no-color"), + LogRequests: c.Bool("log-requests"), + Port: c.Int("port"), + ReadTimeout: c.Duration("read-timeout"), + WriteTimeout: c.Duration("write-timeout"), + GracefulShutdownTimeout: c.Duration("graceful-shutdown-timeout"), + } + + return app, nil +} + +// Run is used as an entrypoint for cli +func Run(c *cli.Context) error { + app, err := newApplication(c) + if err != nil { + return err + } + + app.Run() + + return nil +} + +// Run starts lfgw (main-like function) +func (app *application) Run() { + app.configureLogging() + + if app.SetGomaxProcs { + undo, err := maxprocs.Set() + defer undo() + if err != nil { + app.logger.Error().Caller(). + Msgf("failed to set GOMAXPROCS: %v", err) + } + } + app.logger.Info().Caller(). + Msgf("Runtime settings: GOMAXPROCS = %d", runtime.GOMAXPROCS(0)) + + if app.AssumedRolesEnabled { + app.logger.Info().Caller(). + Msg("Assumed roles mode is on") + } else { + app.logger.Info().Caller(). + Msg("Assumed roles mode is off") + } + + var err error + + if app.ACLPath != "" { + app.ACLs, err = querymodifier.NewACLsFromFile(app.ACLPath) + if err != nil { + app.logger.Fatal().Caller(). + Err(err).Msgf("Failed to load ACL") + } + + for role, acl := range app.ACLs { + app.logger.Info().Caller(). + Msgf("Loaded role definition for %s: %q (converted to %s)", role, acl.RawACL, acl.LabelFilter.AppendString(nil)) + } + } else { + // NOTE: the condition should never happen as it's filtered out by "Before" functionality of cli, though left just in case + if !app.AssumedRolesEnabled { + app.logger.Fatal().Caller(). + Msgf("The app cannot run without at least one source of configuration (Non-empty ACL_PATH and/or ASSUMED_ROLES set to true)") + } + + app.logger.Info().Caller(). + Msgf("ACL_PATH is empty, thus predefined roles are not loaded") + } + + app.logger.Info().Caller(). + Msgf("Connecting to OIDC backend (%q)", app.OIDCRealmURL) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + provider, err := oidc.NewProvider(ctx, app.OIDCRealmURL) + if err != nil { + app.logger.Fatal().Caller(). + Err(err).Msg("") + } + + oidcConfig := &oidc.Config{ + ClientID: app.OIDCClientID, + } + app.verifier = provider.Verifier(oidcConfig) + + err = app.serve() + if err != nil { + app.logger.Fatal().Caller(). + Err(err).Msg("") + } +} diff --git a/internal/lfgw/main_test.go b/internal/lfgw/main_test.go new file mode 100644 index 0000000..d93bc83 --- /dev/null +++ b/internal/lfgw/main_test.go @@ -0,0 +1,142 @@ +package lfgw + +import ( + "flag" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" +) + +func Test_newApplication(t *testing.T) { + // All these tests make sure only one boolean gets changed to true. Otherwise, there's always a risk that value for one field overrides another one. + tests := []struct { + name string + want application + }{ + { + name: "debug", + want: application{Debug: true}, + }, + { + name: "log-no-color", + want: application{LogNoColor: true}, + }, + { + name: "log-requests", + want: application{LogRequests: true}, + }, + { + name: "optimize-expressions", + want: application{OptimizeExpressions: true}, + }, + { + name: "enable-deduplication", + want: application{EnableDeduplication: true}, + }, + { + name: "safe-mode", + want: application{SafeMode: true}, + }, + { + name: "set-proxy-headers", + want: application{SetProxyHeaders: true}, + }, + { + name: "set-gomax-procs", + want: application{SetGomaxProcs: true}, + }, + { + name: "assumed-roles", + want: application{AssumedRolesEnabled: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + set := flag.NewFlagSet("test", 0) + set.Bool(tt.name, true, "doc") + c := cli.NewContext(nil, set, nil) + + // Needed since Parse is called in the function + tt.want.UpstreamURL = &url.URL{} + + got, err := newApplication(c) + assert.Nil(t, err) + assert.Equal(t, tt.want, got) + }) + } + + t.Run("Full application struct", func(t *testing.T) { + upstreamURL := "http://localhost" + oidcRealmURL := "http://localhost2" + oidcClientID := "grafana" + aclPath := "ACL.yaml" + assumedRoles := true + enableDeduplication := true + optimizeExpression := true + safeMode := true + setProxyHeaders := true + setGomaxProcs := true + debug := true + logFormat := "json" + logNoColor := true + logRequests := true + port := 9999 + readTimeout := 6 * time.Second + writeTimeout := 7 * time.Second + gracefulShutdownTimeout := 8 * time.Second + + set := flag.NewFlagSet("test", 0) + set.String("upstream-url", upstreamURL, "doc") + set.String("oidc-realm-url", oidcRealmURL, "doc") + set.String("oidc-client-id", oidcClientID, "doc") + set.String("acl-path", aclPath, "doc") + set.Bool("assumed-roles", assumedRoles, "doc") + set.Bool("enable-deduplication", enableDeduplication, "doc") + set.Bool("optimize-expressions", optimizeExpression, "doc") + set.Bool("safe-mode", safeMode, "doc") + set.Bool("set-proxy-headers", setProxyHeaders, "doc") + set.Bool("set-gomax-procs", setGomaxProcs, "doc") + set.Bool("debug", debug, "doc") + set.String("log-format", logFormat, "doc") + set.Bool("log-no-color", logNoColor, "doc") + set.Bool("log-requests", logRequests, "doc") + set.Int("port", port, "doc") + set.Duration("read-timeout", readTimeout, "doc") + set.Duration("write-timeout", writeTimeout, "doc") + set.Duration("graceful-shutdown-timeout", gracefulShutdownTimeout, "doc") + c := cli.NewContext(nil, set, nil) + + appUpstreamURL, err := url.Parse(upstreamURL) + assert.Nil(t, err) + + want := application{ + UpstreamURL: appUpstreamURL, + OIDCRealmURL: oidcRealmURL, + OIDCClientID: oidcClientID, + ACLPath: aclPath, + AssumedRolesEnabled: assumedRoles, + OptimizeExpressions: optimizeExpression, + EnableDeduplication: enableDeduplication, + SafeMode: safeMode, + SetProxyHeaders: setProxyHeaders, + SetGomaxProcs: setGomaxProcs, + Debug: debug, + LogFormat: logFormat, + LogNoColor: logNoColor, + LogRequests: logRequests, + Port: port, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + GracefulShutdownTimeout: gracefulShutdownTimeout, + } + + got, err := newApplication(c) + assert.Nil(t, err) + + assert.Equal(t, want, got) + }) +} diff --git a/cmd/lfgw/middleware.go b/internal/lfgw/middleware.go similarity index 98% rename from cmd/lfgw/middleware.go rename to internal/lfgw/middleware.go index d9bf467..0ac0d58 100644 --- a/cmd/lfgw/middleware.go +++ b/internal/lfgw/middleware.go @@ -1,4 +1,4 @@ -package main +package lfgw import ( "context" @@ -13,6 +13,10 @@ import ( "github.com/weisdd/lfgw/internal/querymodifier" ) +type contextKey string + +const contextKeyACL = contextKey("acl") + // nonProxiedEndpointsMiddleware is a workaround to support healthz and metrics endpoints while forwarding everything else to an upstream. func (app *application) nonProxiedEndpointsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/lfgw/middleware_test.go b/internal/lfgw/middleware_test.go similarity index 96% rename from cmd/lfgw/middleware_test.go rename to internal/lfgw/middleware_test.go index ee65c21..03214b5 100644 --- a/cmd/lfgw/middleware_test.go +++ b/internal/lfgw/middleware_test.go @@ -1,4 +1,4 @@ -package main +package lfgw import ( "net/http" @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestSafeModeMiddleware(t *testing.T) { +func Test_safeModeMiddleware(t *testing.T) { tests := []struct { name string path string diff --git a/cmd/lfgw/routes.go b/internal/lfgw/routes.go similarity index 97% rename from cmd/lfgw/routes.go rename to internal/lfgw/routes.go index 068de07..33c4412 100644 --- a/cmd/lfgw/routes.go +++ b/internal/lfgw/routes.go @@ -1,4 +1,4 @@ -package main +package lfgw import ( "github.com/gorilla/mux" diff --git a/cmd/lfgw/server.go b/internal/lfgw/server.go similarity index 83% rename from cmd/lfgw/server.go rename to internal/lfgw/server.go index 3920b43..ba8c95e 100644 --- a/cmd/lfgw/server.go +++ b/internal/lfgw/server.go @@ -1,10 +1,11 @@ -package main +package lfgw import ( "context" "errors" "fmt" "net/http" + "net/http/httputil" "os" "os/signal" "syscall" @@ -13,6 +14,11 @@ import ( // serve starts a web server and ensures graceful shutdown func (app *application) serve() error { + app.proxy = httputil.NewSingleHostReverseProxy(app.UpstreamURL) + // TODO: somehow pass more context to ErrorLog (unsafe?) + app.proxy.ErrorLog = app.errorLog + app.proxy.FlushInterval = time.Millisecond * 200 + // TODO: somehow pass more context to ErrorLog srv := &http.Server{ Addr: fmt.Sprintf(":%d", app.Port), diff --git a/internal/querymodifier/qm_test.go b/internal/querymodifier/qm_test.go index 1fd5fff..fc45367 100644 --- a/internal/querymodifier/qm_test.go +++ b/internal/querymodifier/qm_test.go @@ -440,13 +440,21 @@ func TestQueryModifier_shouldNotBeModified(t *testing.T) { want: false, }, { - name: "Original filter is a negative non-regexp", + name: "Original filter is a negative non-regexp, ACL is a regexp", comment: "Original expression should be modified, because it is a negative non-regexp", rawACL: "min.*", isNegativeACL: false, filters: filtersNegativeNonRegexp, want: false, }, + { + name: "Original filter is a negative non-regexp, ACL is not a regexp", + comment: "Original expression should be modified, because it is a negative non-regexp", + rawACL: "minio", + isNegativeACL: false, + filters: filtersNegativeNonRegexp, + want: false, + }, { name: "Original filter is a regexp and not a subfilter of the new ACL", comment: "Original expression should be modified, because the original filter is a regexp and not a subfilter of the new ACL",