From 6085753d849ea01b25764c56f932792aa3e44efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Connes?= Date: Thu, 24 Feb 2022 18:14:46 +0100 Subject: [PATCH 1/2] Only allow namespaces matching -namespace-regexp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This allows to forbid access from canaries in non-whitelisted namespaces. In a multi-tenant context, this can be combined with network policies to maintain isolation between namespaces. Signed-off-by: Cédric Connes --- cmd/loadtester/main.go | 12 ++++++++- pkg/loadtester/authorizer.go | 37 +++++++++++++++++++++++++++ pkg/loadtester/server.go | 48 ++++++++++++++++++++++++++++++++--- pkg/loadtester/server_test.go | 6 ++--- 4 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 pkg/loadtester/authorizer.go diff --git a/cmd/loadtester/main.go b/cmd/loadtester/main.go index 03d676322..648116e43 100644 --- a/cmd/loadtester/main.go +++ b/cmd/loadtester/main.go @@ -19,6 +19,7 @@ package main import ( "flag" "log" + "regexp" "time" "github.com/fluxcd/flagger/pkg/loadtester" @@ -32,6 +33,7 @@ var ( logLevel string port string timeout time.Duration + namespaceRegexp string zapReplaceGlobals bool zapEncoding string ) @@ -40,6 +42,7 @@ func init() { flag.StringVar(&logLevel, "log-level", "debug", "Log level can be: debug, info, warning, error.") flag.StringVar(&port, "port", "9090", "Port to listen on.") flag.DurationVar(&timeout, "timeout", time.Hour, "Load test exec timeout.") + flag.StringVar(&namespaceRegexp, "namespace-regexp", "", "Restrict access to canaries in matching namespaces.") flag.BoolVar(&zapReplaceGlobals, "zap-replace-globals", false, "Whether to change the logging level of the global zap logger.") flag.StringVar(&zapEncoding, "zap-encoding", "json", "Zap logger encoding.") } @@ -66,5 +69,12 @@ func main() { logger.Infof("Starting load tester v%s API on port %s", VERSION, port) gateStorage := loadtester.NewGateStorage("in-memory") - loadtester.ListenAndServe(port, time.Minute, logger, taskRunner, gateStorage, stopCh) + + var namespaceRegexpCompiled *regexp.Regexp + if namespaceRegexp != "" { + namespaceRegexpCompiled = regexp.MustCompile(namespaceRegexp) + } + authorizer := loadtester.NewAuthorizer(namespaceRegexpCompiled) + + loadtester.ListenAndServe(port, time.Minute, logger, taskRunner, gateStorage, authorizer, stopCh) } diff --git a/pkg/loadtester/authorizer.go b/pkg/loadtester/authorizer.go new file mode 100644 index 000000000..106eea63d --- /dev/null +++ b/pkg/loadtester/authorizer.go @@ -0,0 +1,37 @@ +/* +Copyright 2020 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package loadtester + +import ( + "regexp" + + flaggerv1 "github.com/fluxcd/flagger/pkg/apis/flagger/v1beta1" +) + +type Authorizer struct { + namespaceRegexp *regexp.Regexp +} + +func NewAuthorizer(namespaceRegexp *regexp.Regexp) *Authorizer { + return &Authorizer{ + namespaceRegexp: namespaceRegexp, + } +} + +func (a *Authorizer) Authorize(payload *flaggerv1.CanaryWebhookPayload) bool { + return a.namespaceRegexp == nil || a.namespaceRegexp.MatchString(payload.Namespace) +} diff --git a/pkg/loadtester/server.go b/pkg/loadtester/server.go index b1bad4eea..779cc5edc 100644 --- a/pkg/loadtester/server.go +++ b/pkg/loadtester/server.go @@ -31,7 +31,7 @@ import ( ) // ListenAndServe starts a web server and waits for SIGTERM -func ListenAndServe(port string, timeout time.Duration, logger *zap.SugaredLogger, taskRunner *TaskRunner, gate *GateStorage, stopCh <-chan struct{}) { +func ListenAndServe(port string, timeout time.Duration, logger *zap.SugaredLogger, taskRunner *TaskRunner, gate *GateStorage, authorizer *Authorizer, stopCh <-chan struct{}) { mux := http.DefaultServeMux mux.Handle("/metrics", promhttp.Handler()) mux.HandleFunc("/healthz", HandleHealthz) @@ -60,6 +60,12 @@ func ListenAndServe(port string, timeout time.Duration, logger *zap.SugaredLogge return } + if !authorizer.Authorize(canary) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Forbidden")) + return + } + canaryName := fmt.Sprintf("%s.%s", canary.Name, canary.Namespace) approved := gate.isOpen(canaryName) if approved { @@ -90,6 +96,12 @@ func ListenAndServe(port string, timeout time.Duration, logger *zap.SugaredLogge return } + if !authorizer.Authorize(canary) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Forbidden")) + return + } + canaryName := fmt.Sprintf("%s.%s", canary.Name, canary.Namespace) gate.open(canaryName) @@ -115,6 +127,12 @@ func ListenAndServe(port string, timeout time.Duration, logger *zap.SugaredLogge return } + if !authorizer.Authorize(canary) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Forbidden")) + return + } + canaryName := fmt.Sprintf("%s.%s", canary.Name, canary.Namespace) gate.close(canaryName) @@ -140,6 +158,12 @@ func ListenAndServe(port string, timeout time.Duration, logger *zap.SugaredLogge return } + if !authorizer.Authorize(canary) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Forbidden")) + return + } + canaryName := fmt.Sprintf("rollback.%s.%s", canary.Name, canary.Namespace) approved := gate.isOpen(canaryName) if approved { @@ -169,6 +193,12 @@ func ListenAndServe(port string, timeout time.Duration, logger *zap.SugaredLogge return } + if !authorizer.Authorize(canary) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Forbidden")) + return + } + canaryName := fmt.Sprintf("rollback.%s.%s", canary.Name, canary.Namespace) gate.open(canaryName) @@ -193,6 +223,12 @@ func ListenAndServe(port string, timeout time.Duration, logger *zap.SugaredLogge return } + if !authorizer.Authorize(canary) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Forbidden")) + return + } + canaryName := fmt.Sprintf("rollback.%s.%s", canary.Name, canary.Namespace) gate.close(canaryName) @@ -201,7 +237,7 @@ func ListenAndServe(port string, timeout time.Duration, logger *zap.SugaredLogge logger.Infof("%s rollback closed", canaryName) }) - mux.HandleFunc("/", HandleNewTask(logger, taskRunner)) + mux.HandleFunc("/", HandleNewTask(logger, taskRunner, authorizer)) srv := &http.Server{ Addr: ":" + port, Handler: mux, @@ -233,7 +269,7 @@ func HandleHealthz(w http.ResponseWriter, r *http.Request) { } // HandleNewTask handles task creation requests -func HandleNewTask(logger *zap.SugaredLogger, taskRunner TaskRunnerInterface) func(w http.ResponseWriter, r *http.Request) { +func HandleNewTask(logger *zap.SugaredLogger, taskRunner TaskRunnerInterface, authorizer *Authorizer) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { @@ -251,6 +287,12 @@ func HandleNewTask(logger *zap.SugaredLogger, taskRunner TaskRunnerInterface) fu return } + if !authorizer.Authorize(payload) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Forbidden")) + return + } + if len(payload.Metadata) > 0 { metadata := payload.Metadata var typ, ok = metadata["type"] diff --git a/pkg/loadtester/server_test.go b/pkg/loadtester/server_test.go index 281b770b6..f45fe8b14 100644 --- a/pkg/loadtester/server_test.go +++ b/pkg/loadtester/server_test.go @@ -46,7 +46,7 @@ func TestServer_HandleNewBashTaskCmdExitZero(t *testing.T) { "cmd": "echo some-output-not-to-be-returned", }, }) - HandleNewTask(mocks.logger, mocks.taskRunner)(resp, req) + HandleNewTask(mocks.logger, mocks.taskRunner, NewAuthorizer(nil))(resp, req) assert.Equal(t, http.StatusOK, resp.Code) assert.Empty(t, resp.Body.String()) @@ -62,7 +62,7 @@ func TestServer_HandleNewBashTaskCmdExitZeroReturnCmdOutput(t *testing.T) { "returnCmdOutput": "true", }, }) - HandleNewTask(mocks.logger, mocks.taskRunner)(resp, req) + HandleNewTask(mocks.logger, mocks.taskRunner, NewAuthorizer(nil))(resp, req) assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, "some-output-to-be-returned\n", resp.Body.String()) @@ -78,7 +78,7 @@ func TestServer_HandleNewBashTaskCmdExitNonZero(t *testing.T) { }, }) - HandleNewTask(mocks.logger, mocks.taskRunner)(resp, req) + HandleNewTask(mocks.logger, mocks.taskRunner, NewAuthorizer(nil))(resp, req) assert.Equal(t, http.StatusInternalServerError, resp.Code) assert.Equal(t, "command false failed: : exit status 1", resp.Body.String()) From 96fd359b999534763e0e8c411cd82c6eacf33d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Connes?= Date: Tue, 8 Mar 2022 11:42:23 +0100 Subject: [PATCH 2/2] Add cmd.namespaceRegexp to loadtester Helm chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cédric Connes --- charts/loadtester/README.md | 1 + charts/loadtester/templates/deployment.yaml | 1 + charts/loadtester/values.yaml | 1 + docs/gitbook/usage/webhooks.md | 3 ++- 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/charts/loadtester/README.md b/charts/loadtester/README.md index 2b6bc54ec..4f6924cd0 100644 --- a/charts/loadtester/README.md +++ b/charts/loadtester/README.md @@ -59,6 +59,7 @@ Parameter | Description | Default `service.type` | Type of service | `ClusterIP` `service.port` | ClusterIP port | `80` `cmd.timeout` | Command execution timeout | `1h` +`cmd.namespaceRegexp` | Restrict access to canaries in matching namespaces | "" `logLevel` | Log level can be debug, info, warning, error or panic | `info` `appmesh.enabled` | Create AWS App Mesh v1beta2 virtual node | `false` `appmesh.backends` | AWS App Mesh virtual services | `none` diff --git a/charts/loadtester/templates/deployment.yaml b/charts/loadtester/templates/deployment.yaml index 38b86f20c..a65b75b77 100644 --- a/charts/loadtester/templates/deployment.yaml +++ b/charts/loadtester/templates/deployment.yaml @@ -51,6 +51,7 @@ spec: - -port=8080 - -log-level={{ .Values.logLevel }} - -timeout={{ .Values.cmd.timeout }} + - -namespace-regexp={{ .Values.cmd.namespaceRegexp }} livenessProbe: exec: command: diff --git a/charts/loadtester/values.yaml b/charts/loadtester/values.yaml index 4c18ec98a..bc7a9bff3 100644 --- a/charts/loadtester/values.yaml +++ b/charts/loadtester/values.yaml @@ -17,6 +17,7 @@ podPriorityClassName: "" logLevel: info cmd: timeout: 1h + namespaceRegexp: "" nameOverride: "" fullnameOverride: "" diff --git a/docs/gitbook/usage/webhooks.md b/docs/gitbook/usage/webhooks.md index d883a3e9c..4288dcae6 100644 --- a/docs/gitbook/usage/webhooks.md +++ b/docs/gitbook/usage/webhooks.md @@ -143,7 +143,8 @@ helm repo add flagger https://flagger.app helm upgrade -i flagger-loadtester flagger/loadtester \ --namespace=test \ ---set cmd.timeout=1h +--set cmd.timeout=1h \ +--set cmd.namespaceRegexp='' ``` When deployed the load tester API will be available at `http://flagger-loadtester.test/`.