diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml index 2f340a2..762b1f0 100644 --- a/.github/workflows/pr-checks.yaml +++ b/.github/workflows/pr-checks.yaml @@ -53,6 +53,9 @@ jobs: args: --timeout=10m --out-format=line-number skip-cache: true # https://github.com/golangci/golangci-lint-action/issues/244#issuecomment-1052197778 + - name: unit tests + run: make test + build-sentryflow-image: name: Build SentryFlow container image runs-on: ubuntu-latest diff --git a/sentryflow/Makefile b/sentryflow/Makefile index 17a8bce..53d9647 100644 --- a/sentryflow/Makefile +++ b/sentryflow/Makefile @@ -44,6 +44,9 @@ lint: golangci-lint ## Run golangci-lint linter license: ## Check and fix license header on all go files @../scripts/add-license-header +.PHONY: test +test: ## Run unit tests + @go test -v ./... ##@ Build .PHONY: build diff --git a/sentryflow/go.mod b/sentryflow/go.mod index 3d6f8e3..e4d822b 100644 --- a/sentryflow/go.mod +++ b/sentryflow/go.mod @@ -71,6 +71,7 @@ require ( gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/sentryflow/go.sum b/sentryflow/go.sum index 2f19e5e..10f7c11 100644 --- a/sentryflow/go.sum +++ b/sentryflow/go.sum @@ -1,5 +1,3 @@ -github.com/5GSEC/SentryFlow/protobuf v0.0.0-20240513071927-c6689c164ec8 h1:vOjDsj/1zs1O4V2UG2SINC7/maAx3WEQsE0bz5n0skI= -github.com/5GSEC/SentryFlow/protobuf v0.0.0-20240513071927-c6689c164ec8/go.mod h1:cvmCAKkLBDXx6Rlk97XQQuAtcOhkM/wsWNbxGOC3yfE= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= diff --git a/sentryflow/pkg/config/config.go b/sentryflow/pkg/config/config.go index 27fe25d..e4bf8c6 100644 --- a/sentryflow/pkg/config/config.go +++ b/sentryflow/pkg/config/config.go @@ -73,6 +73,9 @@ func (c *Config) validate() error { if c.Exporter.Grpc == nil { return fmt.Errorf("no exporter's gRPC configuration provided") } + if c.Exporter.Grpc != nil && c.Exporter.Grpc.Port == 0 { + return fmt.Errorf("no exporter's gRPC port provided") + } if c.Exporter.Http != nil { return fmt.Errorf("http exporter is not supported") } diff --git a/sentryflow/pkg/config/config_test.go b/sentryflow/pkg/config/config_test.go new file mode 100644 index 0000000..ccddc1f --- /dev/null +++ b/sentryflow/pkg/config/config_test.go @@ -0,0 +1,449 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Authors of SentryFlow + +package config + +import ( + "path/filepath" + "reflect" + "testing" + + "go.uber.org/zap" +) + +func TestConfig_validate(t *testing.T) { + type fields struct { + Filters *filters + Receivers *receivers + Exporter *base + } + tests := []struct { + name string + fields fields + wantErr bool + expectedErrMessage string + }{ + { + name: "with nil filter config should return error", + fields: fields{ + Filters: nil, + Receivers: &receivers{ + ServiceMeshes: []*serviceMesh{ + { + Name: "istio-sidecar", + Namespace: "istio-system", + }, + }, + }, + Exporter: &base{ + Name: "default gRPC exporter", + Grpc: &endpoint{ + Port: 11111, + }, + }, + }, + wantErr: true, + expectedErrMessage: "no filter configuration provided", + }, + { + name: "with empty envoy URI in filter should return error", + fields: fields{ + Filters: &filters{ + Envoy: &envoyFilterConfig{ + Uri: "", + }, + }, + Receivers: &receivers{ + ServiceMeshes: []*serviceMesh{ + { + Name: "istio-sidecar", + Namespace: "istio-system", + }, + }, + }, + Exporter: &base{ + Name: "default gRPC exporter", + Grpc: &endpoint{ + Port: 11111, + }, + }, + }, + wantErr: true, + expectedErrMessage: "no envoy filter URI provided", + }, + { + name: "with nil exporter config should return error", + fields: fields{ + Filters: &filters{ + Envoy: &envoyFilterConfig{ + Uri: "5gsec/http-filter:v0.1", + }, + Server: &server{ + Port: SentryFlowDefaultFilterServerPort, + }, + }, + Receivers: &receivers{ + ServiceMeshes: []*serviceMesh{ + { + Name: "istio-sidecar", + Namespace: "istio-system", + }, + }, + }, + Exporter: nil, + }, + wantErr: true, + expectedErrMessage: "no exporter configuration provided", + }, + { + name: "with nil exporter gRPC config should return error", + fields: fields{ + Filters: &filters{ + Envoy: &envoyFilterConfig{ + Uri: "5gsec/http-filter:v0.1", + }, + Server: &server{ + Port: SentryFlowDefaultFilterServerPort, + }, + }, + Receivers: &receivers{ + ServiceMeshes: []*serviceMesh{ + { + Name: "istio-sidecar", + Namespace: "istio-system", + }, + }, + }, + Exporter: &base{ + Name: "default gRPC exporter", + Grpc: nil, + }, + }, + wantErr: true, + expectedErrMessage: "no exporter's gRPC configuration provided", + }, + { + name: "without exporter's gRPC port config should return error", + fields: fields{ + Filters: &filters{ + Envoy: &envoyFilterConfig{ + Uri: "5gsec/http-filter:v0.1", + }, + Server: &server{ + Port: SentryFlowDefaultFilterServerPort, + }, + }, + Receivers: &receivers{ + ServiceMeshes: []*serviceMesh{ + { + Name: "istio-sidecar", + Namespace: "istio-system", + }, + }, + }, + Exporter: &base{ + Name: "gRPC exporter without port", + Grpc: &endpoint{}, + }, + }, + wantErr: true, + expectedErrMessage: "no exporter's gRPC port provided", + }, + { + name: "with HTTP exporter should return error", + fields: fields{ + Filters: &filters{ + Envoy: &envoyFilterConfig{ + Uri: "5gsec/http-filter:v0.1", + }, + Server: &server{ + Port: SentryFlowDefaultFilterServerPort, + }, + }, + Receivers: &receivers{ + ServiceMeshes: []*serviceMesh{ + { + Name: "istio-sidecar", + Namespace: "istio-system", + }, + }, + }, + Exporter: &base{ + Name: "unsupported HTTP exporter", + Grpc: &endpoint{ + Port: 11111, + }, + Http: &endpoint{ + Port: 65432, + }, + }, + }, + wantErr: true, + expectedErrMessage: "http exporter is not supported", + }, + { + name: "with nil receiver config should return error", + fields: fields{ + Filters: &filters{ + Envoy: &envoyFilterConfig{ + Uri: "5gsec/http-filter:v0.1", + }, + Server: &server{ + Port: SentryFlowDefaultFilterServerPort, + }, + }, + Receivers: nil, + Exporter: &base{ + Name: "default gRPC exporter", + Grpc: &endpoint{ + Port: 11111, + }, + }, + }, + wantErr: true, + expectedErrMessage: "no receiver configuration provided", + }, + { + name: "with empty service mesh name receiver should return error", + fields: fields{ + Filters: &filters{ + Envoy: &envoyFilterConfig{ + Uri: "5gsec/http-filter:v0.1", + }, + Server: &server{ + Port: SentryFlowDefaultFilterServerPort, + }, + }, + Receivers: &receivers{ + ServiceMeshes: []*serviceMesh{ + { + Namespace: "istio-system", + }, + }, + }, + Exporter: &base{ + Name: "default gRPC exporter", + Grpc: &endpoint{ + Port: 11111, + }, + }, + }, + wantErr: true, + expectedErrMessage: "no service mesh name provided", + }, + { + name: "with empty service mesh namespace receiver should return error", + fields: fields{ + Filters: &filters{ + Envoy: &envoyFilterConfig{ + Uri: "5gsec/http-filter:v0.1", + }, + Server: &server{ + Port: SentryFlowDefaultFilterServerPort, + }, + }, + Receivers: &receivers{ + ServiceMeshes: []*serviceMesh{ + { + Name: "istio-sidecar", + }, + }, + }, + Exporter: &base{ + Name: "default gRPC exporter", + Grpc: &endpoint{ + Port: 11111, + }, + }, + }, + wantErr: true, + expectedErrMessage: "no service mesh namespace provided", + }, + { + name: "with valid config should not return error", + fields: fields{ + Filters: &filters{ + Envoy: &envoyFilterConfig{ + Uri: "5gsec/http-filter:v0.1", + }, + Server: &server{ + Port: SentryFlowDefaultFilterServerPort, + }, + }, + Receivers: &receivers{ + ServiceMeshes: []*serviceMesh{ + { + Name: "istio-sidecar", + Namespace: "istio-system", + }, + }, + }, + Exporter: &base{ + Name: "default gRPC exporter", + Grpc: &endpoint{ + Port: 11111, + }, + }, + }, + wantErr: false, + expectedErrMessage: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Config{ + Filters: tt.fields.Filters, + Receivers: tt.fields.Receivers, + Exporter: tt.fields.Exporter, + } + + err := c.validate() + if tt.wantErr && err == nil { + t.Errorf("validate() expected error but got nil") + } else if !tt.wantErr && err != nil { + t.Errorf("validate() expected no error but got error = %v", err) + } else if tt.wantErr && err != nil && tt.expectedErrMessage != err.Error() { + t.Errorf("validate() expected error message to be %v but got %v", tt.expectedErrMessage, err.Error()) + } + }) + } +} + +func TestNew(t *testing.T) { + logger := zap.S() + + type args struct { + configFilePath string + logger *zap.SugaredLogger + } + tests := []struct { + name string + args args + want *Config + wantErr bool + }{ + { + name: "with valid configFilePath should return config", + args: args{ + configFilePath: filepath.Join(".", "test-configs", "default-config.yaml"), + logger: logger, + }, + want: &Config{ + Filters: &filters{ + Envoy: &envoyFilterConfig{ + Uri: "anuragrajawat/httpfilter:v0.1", + }, + Server: &server{ + Port: 8081, + }, + }, + Receivers: &receivers{ + ServiceMeshes: []*serviceMesh{ + { + Name: "istio-sidecar", + Namespace: "istio-system", + }, + }, + }, + Exporter: &base{ + Grpc: &endpoint{ + Port: 8080, + }, + }, + }, + wantErr: false, + }, + { + name: "with invalid configFilePath should return error", + args: args{ + configFilePath: filepath.Join(".", "path-doesnt-exist", "invalid-config.yaml"), + logger: logger, + }, + want: nil, + wantErr: true, + }, + { + name: "with nil filter server configFilePath should return config with default filter server", + args: args{ + configFilePath: filepath.Join(".", "test-configs", "without-filter-server.yaml"), + logger: logger, + }, + want: &Config{ + Filters: &filters{ + Envoy: &envoyFilterConfig{ + Uri: "anuragrajawat/httpfilter:v0.1", + }, + Server: &server{ + Port: 8081, + }, + }, + Receivers: &receivers{ + ServiceMeshes: []*serviceMesh{ + { + Name: "istio-sidecar", + Namespace: "istio-system", + }, + }, + }, + Exporter: &base{ + Grpc: &endpoint{ + Port: 8080, + }, + }, + }, + wantErr: false, + }, + { + name: "without filter server port config should return config with default port", + args: args{ + configFilePath: filepath.Join(".", "test-configs", "without-filter-server.yaml"), + logger: logger, + }, + want: &Config{ + Filters: &filters{ + Envoy: &envoyFilterConfig{ + Uri: "anuragrajawat/httpfilter:v0.1", + }, + Server: &server{ + Port: 8081, + }, + }, + Receivers: &receivers{ + ServiceMeshes: []*serviceMesh{ + { + Name: "istio-sidecar", + Namespace: "istio-system", + }, + }, + }, + Exporter: &base{ + Grpc: &endpoint{ + Port: 8080, + }, + }, + }, + wantErr: false, + }, + { + name: "with invalid config should return error", + args: args{ + configFilePath: filepath.Join(".", "test-configs", "invalid-config.yaml"), + logger: logger, + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := New(tt.args.configFilePath, tt.args.logger) + if (err != nil) != tt.wantErr { + t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("New() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/sentryflow/pkg/config/test-configs/default-config.yaml b/sentryflow/pkg/config/test-configs/default-config.yaml new file mode 100644 index 0000000..c5cd3b3 --- /dev/null +++ b/sentryflow/pkg/config/test-configs/default-config.yaml @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2024 Authors of SentryFlow + +filters: + server: + port: 8081 + + envoy: + uri: anuragrajawat/httpfilter:v0.1 + +receivers: # aka sources + serviceMeshes: + - name: istio-sidecar + namespace: istio-system +exporter: + grpc: + port: 8080 diff --git a/sentryflow/pkg/config/test-configs/invalid-config.yaml b/sentryflow/pkg/config/test-configs/invalid-config.yaml new file mode 100644 index 0000000..e072c77 --- /dev/null +++ b/sentryflow/pkg/config/test-configs/invalid-config.yaml @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2024 Authors of SentryFlow + +filters: + envoy: + uri: anuragrajawat/httpfilter:v0.1 + +receivers: # aka sources + serviceMeshes: + - name: istio-sidecar + namespace: istio-system diff --git a/sentryflow/pkg/config/test-configs/without-filter-server-port.yaml b/sentryflow/pkg/config/test-configs/without-filter-server-port.yaml new file mode 100644 index 0000000..90955c2 --- /dev/null +++ b/sentryflow/pkg/config/test-configs/without-filter-server-port.yaml @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2024 Authors of SentryFlow + +filters: + envoy: + uri: anuragrajawat/httpfilter:v0.1 + +receivers: # aka sources + serviceMeshes: + - name: istio-sidecar + namespace: istio-system +exporter: + grpc: diff --git a/sentryflow/pkg/config/test-configs/without-filter-server.yaml b/sentryflow/pkg/config/test-configs/without-filter-server.yaml new file mode 100644 index 0000000..04d2732 --- /dev/null +++ b/sentryflow/pkg/config/test-configs/without-filter-server.yaml @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2024 Authors of SentryFlow + +filters: + envoy: + uri: anuragrajawat/httpfilter:v0.1 + +receivers: # aka sources + serviceMeshes: + - name: istio-sidecar + namespace: istio-system +exporter: + grpc: + port: 8080 diff --git a/sentryflow/pkg/core/server.go b/sentryflow/pkg/core/server.go index 4f84aa4..469fd05 100644 --- a/sentryflow/pkg/core/server.go +++ b/sentryflow/pkg/core/server.go @@ -32,11 +32,19 @@ func (m *Manager) startGrpcServer(port uint16) { func (m *Manager) startHttpServer(port uint16) { m.Logger.Info("Starting HTTP server") + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", m.healthzHandler) + mux.HandleFunc("/api/v1/events", m.eventsHandler) + m.HttpServer = &http.Server{ Addr: fmt.Sprintf(":%d", port), + Handler: mux, + ReadTimeout: 3 * time.Second, ReadHeaderTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + IdleTimeout: 30 * time.Second, } - m.registerRoutes() m.Logger.Infof("HTTP server listening on port %d", port) if err := m.HttpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -44,6 +52,44 @@ func (m *Manager) startHttpServer(port uint16) { } } +func (m *Manager) eventsHandler(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost { + writer.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if request.Body == nil { + m.Logger.Info("Body is nil") + writer.WriteHeader(http.StatusBadRequest) + return + } + + body, err := io.ReadAll(request.Body) + if err != nil { + m.Logger.Errorf("failed to read request body, error: %v", err) + http.Error(writer, "failed to read request body", http.StatusInternalServerError) + return + } + + apiEvent := &protobuf.APIEvent{} + if err := protojson.Unmarshal(body, apiEvent); err != nil { + m.Logger.Info("failed to unmarshal api event, error:", err) + http.Error(writer, "failed to unmarshal request body", http.StatusBadRequest) + return + } + + m.ApiEvents <- apiEvent + writer.WriteHeader(http.StatusAccepted) +} + +func (m *Manager) healthzHandler(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.WriteHeader(http.StatusMethodNotAllowed) + return + } + writer.WriteHeader(http.StatusOK) +} + func (m *Manager) stopServers() { m.Logger.Info("Stopping servers") if err := m.HttpServer.Shutdown(context.Background()); err != nil { @@ -52,43 +98,3 @@ func (m *Manager) stopServers() { m.GrpcServer.GracefulStop() m.Logger.Info("Stopped servers") } - -func (m *Manager) registerRoutes() { - http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - w.WriteHeader(http.StatusOK) - }) - - // Register an endpoint to receive API events from EnvoyFilter - http.HandleFunc("/api/v1/events", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - - if r.Body == nil { - m.Logger.Info("Body is nil") - w.WriteHeader(http.StatusBadRequest) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - m.Logger.Errorf("failed to read request body, error: %v", err) - http.Error(w, "failed to read request body", http.StatusInternalServerError) - return - } - - apiEvent := &protobuf.APIEvent{} - if err := protojson.Unmarshal(body, apiEvent); err != nil { - m.Logger.Info("failed to unmarshal api event, error:", err) - http.Error(w, "failed to parse request body", http.StatusBadRequest) - return - } - - m.ApiEvents <- apiEvent - }) -} diff --git a/sentryflow/pkg/core/server_test.go b/sentryflow/pkg/core/server_test.go new file mode 100644 index 0000000..2d8fca8 --- /dev/null +++ b/sentryflow/pkg/core/server_test.go @@ -0,0 +1,295 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Authors of SentryFlow + +package core + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "go.uber.org/zap" + + protobuf "github.com/5GSEC/SentryFlow/protobuf/golang" +) + +func Test_healthzHandler(t *testing.T) { + tests := []struct { + name string + method string + want int + }{ + { + name: "with GET method should return StatusOK", + method: http.MethodGet, + want: http.StatusOK, + }, + { + name: "with POST method should return StatusMethodNotAllowed", + method: http.MethodPost, + want: http.StatusMethodNotAllowed, + }, + { + name: "with PUT method should return StatusMethodNotAllowed", + method: http.MethodPut, + want: http.StatusMethodNotAllowed, + }, + { + name: "with DELETE method should return StatusMethodNotAllowed", + method: http.MethodDelete, + want: http.StatusMethodNotAllowed, + }, + { + name: "with PATCH method should return StatusMethodNotAllowed", + method: http.MethodPatch, + want: http.StatusMethodNotAllowed, + }, + { + name: "with HEAD method should return StatusMethodNotAllowed", + method: http.MethodHead, + want: http.StatusMethodNotAllowed, + }, + { + name: "with TRACE method should return StatusMethodNotAllowed", + method: http.MethodTrace, + want: http.StatusMethodNotAllowed, + }, + { + name: "with OPTIONS method should return StatusMethodNotAllowed", + method: http.MethodOptions, + want: http.StatusMethodNotAllowed, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{} + request := httptest.NewRequest(tt.method, "/healthz", nil) + response := httptest.NewRecorder() + manager.healthzHandler(response, request) + if got := response.Code; got != tt.want { + t.Errorf("healthzHandler() gotStatusCode = %v, want %v", got, tt.want) + } + }) + } +} + +func TestManager_eventsHandler(t *testing.T) { + logger := zap.S() + + validApiEvent := getDummyValidApiEvent() + invalidApiEvent := getDummyInvalidApiEvent() + + type fields struct { + Logger *zap.SugaredLogger + ApiEvents chan *protobuf.APIEvent + } + tests := []struct { + name string + fields fields + body []byte + method string + wantApiEvent []byte + wantStatusCode int + }{ + { + name: "with valid apiEvent and GET method should return StatusMethodNotAllowed", + fields: fields{ + Logger: logger, + ApiEvents: make(chan *protobuf.APIEvent, 1), + }, + body: validApiEvent, + method: http.MethodGet, + wantApiEvent: nil, + wantStatusCode: http.StatusMethodNotAllowed, + }, + { + name: "with valid apiEvent and POST method should return StatusAccepted", + fields: fields{ + Logger: logger, + ApiEvents: make(chan *protobuf.APIEvent, 1), + }, + body: validApiEvent, + method: http.MethodPost, + wantApiEvent: validApiEvent, + wantStatusCode: http.StatusAccepted, + }, + { + name: "with valid apiEvent and PUT method should return StatusMethodNotAllowed", + fields: fields{ + Logger: logger, + ApiEvents: make(chan *protobuf.APIEvent, 1), + }, + body: validApiEvent, + method: http.MethodPut, + wantApiEvent: nil, + wantStatusCode: http.StatusMethodNotAllowed, + }, + { + name: "with valid apiEvent and DELETE method should return StatusMethodNotAllowed", + fields: fields{ + Logger: logger, + ApiEvents: make(chan *protobuf.APIEvent, 1), + }, + body: validApiEvent, + method: http.MethodDelete, + wantApiEvent: nil, + wantStatusCode: http.StatusMethodNotAllowed, + }, + { + name: "with valid apiEvent and PATCH method should return StatusMethodNotAllowed", + fields: fields{ + Logger: logger, + ApiEvents: make(chan *protobuf.APIEvent, 1), + }, + body: validApiEvent, + method: http.MethodPatch, + wantApiEvent: nil, + wantStatusCode: http.StatusMethodNotAllowed, + }, + { + name: "with valid apiEvent and OPTIONS method should return StatusMethodNotAllowed", + fields: fields{ + Logger: logger, + ApiEvents: make(chan *protobuf.APIEvent, 1), + }, + body: validApiEvent, + method: http.MethodOptions, + wantApiEvent: nil, + wantStatusCode: http.StatusMethodNotAllowed, + }, + { + name: "with valid apiEvent and TRACE method should return StatusMethodNotAllowed", + fields: fields{ + Logger: logger, + ApiEvents: make(chan *protobuf.APIEvent, 1), + }, + body: validApiEvent, + method: http.MethodTrace, + wantApiEvent: nil, + wantStatusCode: http.StatusMethodNotAllowed, + }, + { + name: "with valid apiEvent and TRACE method should return StatusMethodNotAllowed", + fields: fields{ + Logger: logger, + ApiEvents: make(chan *protobuf.APIEvent, 1), + }, + body: validApiEvent, + method: http.MethodHead, + wantApiEvent: nil, + wantStatusCode: http.StatusMethodNotAllowed, + }, + { + name: "with empty apiEvent body should return StatusBadRequest", + fields: fields{ + Logger: logger, + ApiEvents: make(chan *protobuf.APIEvent, 1), + }, + body: nil, + method: http.MethodPost, + wantApiEvent: nil, + wantStatusCode: http.StatusBadRequest, + }, + { + name: "with invalid apiEvent body should return StatusBadRequest", + fields: fields{ + Logger: logger, + ApiEvents: make(chan *protobuf.APIEvent, 1), + }, + body: invalidApiEvent, + method: http.MethodPost, + wantApiEvent: nil, + wantStatusCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Avoid using `t.Cleanup()` as it executes after all subtests complete. To + // prevent processing stale API events, close the `apiEvent` channel proactively. + defer close(tt.fields.ApiEvents) + + m := &Manager{ + Logger: tt.fields.Logger, + ApiEvents: tt.fields.ApiEvents, + } + + request := httptest.NewRequest(tt.method, "/api/v1/events", bytes.NewReader(tt.body)) + response := httptest.NewRecorder() + m.eventsHandler(response, request) + + if gotStatusCode := response.Code; gotStatusCode != tt.wantStatusCode { + t.Errorf("eventsHandler() gotStatusCode = %v, want %v", gotStatusCode, tt.wantStatusCode) + } + if len(tt.fields.ApiEvents) > 0 { + gotApiEvent, _ := json.Marshal(<-m.ApiEvents) + if !bytes.Equal(gotApiEvent, tt.wantApiEvent) { + t.Errorf("eventsHandler() gotApiEvent = %v, want %v", gotApiEvent, tt.wantApiEvent) + } + } + }) + } +} + +func getDummyInvalidApiEvent() []byte { + apiEvent := ` +{ + "metadata": { + "timestamp": 1729179252 + }, + "http": { + "request": { + "method": "GET", + "path": "/_-flags-1x1-hr.svg" + }, + "response": { + "status_code": 200 + } + }, +} +` + body, _ := json.Marshal(apiEvent) + return body +} + +func getDummyValidApiEvent() []byte { + apiEvent := &protobuf.APIEvent{ + Metadata: &protobuf.Metadata{ + ContextId: 1, + Timestamp: uint64(time.Now().Unix()), + }, + Source: &protobuf.Workload{ + Name: "source-workload", + Namespace: "source-namespace", + Ip: "1.1.1.1", + Port: 11111, + }, + Destination: &protobuf.Workload{ + Name: "destination-workload", + Namespace: "destination-namespace", + Ip: "93.184.215.14", + Port: 80, + }, + Request: &protobuf.Request{ + Headers: map[string]string{ + ":authority": "example.com", + ":method": "GET", + ":path": "/", + ":scheme": "http", + }, + Body: "request body", + }, + Response: &protobuf.Response{ + Headers: map[string]string{ + ":status": "200", + }, + Body: "response body", + }, + Protocol: "HTTP/1.1", + } + body, _ := json.Marshal(apiEvent) + return body +} diff --git a/sentryflow/pkg/exporter/exporter.go b/sentryflow/pkg/exporter/exporter.go index 1396ce5..7b63b0a 100644 --- a/sentryflow/pkg/exporter/exporter.go +++ b/sentryflow/pkg/exporter/exporter.go @@ -109,11 +109,11 @@ func (e *exporter) putApiEventOnClientsChannel(ctx context.Context) { } eventToSend := apiEvent e.clients.Lock() - for _, clientChan := range e.clients.client { + for uid, clientChan := range e.clients.client { select { case clientChan <- eventToSend: default: - e.logger.Warn("Event dropped") + e.logger.Warnf("event dropped for %v client", uid) } } e.clients.Unlock() diff --git a/sentryflow/pkg/exporter/exporter_test.go b/sentryflow/pkg/exporter/exporter_test.go new file mode 100644 index 0000000..0da30e8 --- /dev/null +++ b/sentryflow/pkg/exporter/exporter_test.go @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Authors of SentryFlow + +package exporter + +import ( + "bytes" + "context" + "encoding/json" + "net" + "os" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/resolver" + "google.golang.org/grpc/test/bufconn" + "k8s.io/apimachinery/pkg/util/rand" + + protobuf "github.com/5GSEC/SentryFlow/protobuf/golang" +) + +func Test_exporter_GetAPIEvent(t *testing.T) { + // Use timeout to make sure this doesn't run indefinitely. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + e := getExporter() + + sfClient, closer := getSentryFlowClientAndCloser(t, e) + defer closer() + + // Given + stream, err := sfClient.GetAPIEvent(ctx, getClientInfo(t)) + if err != nil { + t.Fatal(err) + } + + wg := &sync.WaitGroup{} + wg.Add(1) + + // Simulate some API events generation + want := 100 + go func(numOfEvents int) { + defer wg.Done() + time.Sleep(100 * time.Millisecond) + for i := 0; i < numOfEvents; i++ { + select { + case <-ctx.Done(): + return + default: + e.apiEvents <- getDummyApiEvent(i) + time.Sleep(10 * time.Millisecond) + } + } + }(want) + + wg.Add(1) + go func() { + defer wg.Done() + select { + case <-ctx.Done(): + return + default: + e.putApiEventOnClientsChannel(ctx) + } + }() + + // When + got := 0 + for got < want { + _, err := stream.Recv() + if err != nil { + break + } + got++ + } + + // Then + if got != want { + t.Errorf("GetAPIEvent() want = %v events, got = %v", want, got) + } + + cancel() + wg.Wait() +} + +func Test_exporter_SendAPIEvent(t *testing.T) { + e := getExporter() + + // Given + sfClient, closer := getSentryFlowClientAndCloser(t, e) + defer closer() + + // When + eventToSend := getDummyApiEvent(1) + receivedEvent, err := sfClient.SendAPIEvent(context.Background(), eventToSend) + if err != nil { + t.Error(err) + } + + // Since API events are reference types and their addresses differ, we need to + // compare their serialized representations to ensure equality. So serialize both + // events and then compare. + want, _ := json.Marshal(eventToSend) + got, _ := json.Marshal(receivedEvent) + + // Then + if !bytes.Equal(want, got) { + t.Errorf("SendAPIEvent() want = %v, got = %v", string(want), string(got)) + } +} + +func Test_exporter_addClientToList(t *testing.T) { + // Given + e := exporter{ + clients: &clientList{ + Mutex: &sync.Mutex{}, + client: make(map[string]chan *protobuf.APIEvent), + }, + } + uid := uuid.Must(uuid.NewRandom()).String() + + // When + want := e.addClientToList(uid) + + // Then + e.clients.Lock() + got, exists := e.clients.client[uid] + e.clients.Unlock() + if !exists || got != want || got == nil { + t.Errorf("addClientToList() client not added to the client list correctly") + } +} + +func Test_exporter_deleteClientFromList(t *testing.T) { + // Given + e := exporter{ + clients: &clientList{ + Mutex: &sync.Mutex{}, + client: make(map[string]chan *protobuf.APIEvent), + }, + } + uid := uuid.Must(uuid.NewRandom()).String() + + // When + e.deleteClientFromList(uid, e.addClientToList(uid)) + + // Then + e.clients.Lock() + got, exists := e.clients.client[uid] + e.clients.Unlock() + if exists || got != nil { + t.Errorf("deleteClientFromList() client not deleted from the client list correctly") + } +} + +func Test_exporter_add_and_delete_client_fromList_concurrently(t *testing.T) { + // Given + e := exporter{ + clients: &clientList{ + Mutex: &sync.Mutex{}, + client: make(map[string]chan *protobuf.APIEvent), + }, + } + + numOfClients := 1000 + wg := sync.WaitGroup{} + wg.Add(numOfClients) + + // When + for i := 0; i < numOfClients; i++ { + go func() { + defer wg.Done() + + uid := uuid.Must(uuid.NewRandom()).String() + connChan := e.addClientToList(uid) + + // Simulate some work + time.Sleep(time.Duration(rand.IntnRange(1, 100)) * time.Millisecond) + + e.deleteClientFromList(uid, connChan) + }() + } + + wg.Wait() + + // Then + e.clients.Lock() + if len(e.clients.client) != 0 { + t.Errorf("client list is not empty after concurrent access") + } + e.clients.Unlock() +} + +func getDummyApiEvent(ctxId int) *protobuf.APIEvent { + return &protobuf.APIEvent{ + Metadata: &protobuf.Metadata{ + ContextId: uint32(ctxId), + Timestamp: uint64(time.Now().Unix()), + }, + Source: &protobuf.Workload{ + Name: "source-workload", + Namespace: "source-namespace", + Ip: "1.1.1.1", + Port: int32(rand.IntnRange(1025, 65536)), + }, + Destination: &protobuf.Workload{ + Name: "destination-workload", + Namespace: "destination-namespace", + Ip: "93.184.215.14", + Port: int32(rand.IntnRange(80, 65536)), + }, + Request: &protobuf.Request{ + Headers: map[string]string{ + ":authority": "example.com", + ":method": "GET", + ":path": "/", + ":scheme": "http", + }, + Body: "request body", + }, + Response: &protobuf.Response{ + Headers: map[string]string{ + ":status": "200", + }, + Body: "response body", + }, + Protocol: "HTTP/1.1", + } +} + +func getExporter() *exporter { + return &exporter{ + apiEvents: make(chan *protobuf.APIEvent, 100), + logger: zap.S(), + clients: &clientList{ + Mutex: &sync.Mutex{}, + client: make(map[string]chan *protobuf.APIEvent), + }, + } +} + +func getClientInfo(t *testing.T) *protobuf.ClientInfo { + hostname, err := os.Hostname() + if err != nil { + t.Errorf("failed to get hostname: %v", err) + } + + ips, err := net.LookupIP(hostname) + if err != nil { + t.Errorf("failed to get IP address: %v", err) + } + var ip string + if len(ips) > 0 { + ip = ips[0].String() + } + + clientInfo := &protobuf.ClientInfo{ + HostName: hostname, + IPAddress: ip, + } + + return clientInfo +} + +func getSentryFlowClientAndCloser(t *testing.T, e *exporter) (protobuf.SentryFlowClient, func()) { + listener := bufconn.Listen(101024 * 1024) + baseServer := grpc.NewServer() + protobuf.RegisterSentryFlowServer(baseServer, e) + go func() { + if err := baseServer.Serve(listener); err != nil { + t.Errorf("failed to start exporter server: %v", err) + return + } + }() + + resolver.SetDefaultScheme("passthrough") + conn, err := grpc.NewClient("bufnet", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Errorf("failed to dial to server: %v", err) + return nil, nil + } + + closer := func() { + if err := listener.Close(); err != nil { + t.Errorf("failed to close listener: %v", err) + } + baseServer.Stop() + } + + return protobuf.NewSentryFlowClient(conn), closer +} diff --git a/sentryflow/pkg/k8s/client_test.go b/sentryflow/pkg/k8s/client_test.go new file mode 100644 index 0000000..8094f2e --- /dev/null +++ b/sentryflow/pkg/k8s/client_test.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Authors of SentryFlow + +package k8s + +import ( + "fmt" + "os" + "testing" + + "k8s.io/apimachinery/pkg/runtime" +) + +func TestNewClient(t *testing.T) { + type args struct { + scheme *runtime.Scheme + kubeConfig string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "with invalid kubeconfig path should return error", + args: args{ + scheme: runtime.NewScheme(), + kubeConfig: "invalid-kubeconfig.yaml", + }, + wantErr: true, + }, + { + name: "with valid kubeconfig path should return no error", + args: args{ + scheme: runtime.NewScheme(), + kubeConfig: fmt.Sprintf("%s/.kube/config", os.Getenv("HOME")), + }, + wantErr: false, + }, + { + name: "with empty kubeconfig path should use default config and return no error", + args: args{ + scheme: runtime.NewScheme(), + kubeConfig: "", + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewClient(tt.args.scheme, tt.args.kubeConfig) + if (err != nil) != tt.wantErr { + t.Errorf("NewClient() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} diff --git a/sentryflow/pkg/receiver/svcmesh/istio/sidecar/sidecar.go b/sentryflow/pkg/receiver/svcmesh/istio/sidecar/sidecar.go index 4a576b9..6d57ef3 100644 --- a/sentryflow/pkg/receiver/svcmesh/istio/sidecar/sidecar.go +++ b/sentryflow/pkg/receiver/svcmesh/istio/sidecar/sidecar.go @@ -46,7 +46,7 @@ func StartMonitoring(ctx context.Context, cfg *config.Config, k8sClient client.C logger.Info("Starting istio sidecar mesh monitoring") - if err := setupWasmPlugin(ctx, cfg, k8sClient); err != nil { + if err := createResources(ctx, cfg, k8sClient); err != nil { logger.Error(err) return } @@ -59,7 +59,7 @@ func StartMonitoring(ctx context.Context, cfg *config.Config, k8sClient client.C logger.Info("Stopped istio sidecar mesh monitoring") } -func setupWasmPlugin(ctx context.Context, cfg *config.Config, k8sClient client.Client) error { +func createResources(ctx context.Context, cfg *config.Config, k8sClient client.Client) error { if err := createEnvoyFilter(ctx, cfg, k8sClient); err != nil { return fmt.Errorf("failed to create EnvoyFilter. Stopping istio sidecar mesh monitoring, error: %v", err) } @@ -269,5 +269,7 @@ func getIstioRootNamespaceFromConfig(cfg *config.Config) string { return svcMesh.Namespace } } + // The `namespace` field is always non-empty due to validation during config + // initialization. return "" } diff --git a/sentryflow/pkg/receiver/svcmesh/istio/sidecar/sidecar_test.go b/sentryflow/pkg/receiver/svcmesh/istio/sidecar/sidecar_test.go new file mode 100644 index 0000000..02325a6 --- /dev/null +++ b/sentryflow/pkg/receiver/svcmesh/istio/sidecar/sidecar_test.go @@ -0,0 +1,377 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Authors of SentryFlow + +package sidecar + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "path/filepath" + "testing" + "text/template" + + _struct "github.com/golang/protobuf/ptypes/struct" + "go.uber.org/zap" + extensionsv1alpha1 "istio.io/api/extensions/v1alpha1" + "istio.io/api/type/v1beta1" + "istio.io/client-go/pkg/apis/extensions/v1alpha1" + networkingv1alpha3 "istio.io/client-go/pkg/apis/networking/v1alpha3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/yaml" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/5GSEC/SentryFlow/pkg/config" + "github.com/5GSEC/SentryFlow/pkg/util" +) + +var istioRootNs = getIstioRootNamespaceFromConfig(getConfig()) + +func Test_createEnvoyFilter(t *testing.T) { + cfg := getConfig() + ctx := context.WithValue(context.Background(), util.LoggerContextKey{}, zap.S()) + fakeClient := getFakeClient() + + t.Run("when filter doesn't exist should create it", func(t *testing.T) { + // Given + envoyFilter := getEnvoyFilter() + want, _ := json.Marshal(envoyFilter) + defer func() { + if err := fakeClient.Delete(ctx, envoyFilter); err != nil { + t.Errorf("createEnvoyFilter() failed to delete filter = %v", err) + } + }() + + // When + if err := createEnvoyFilter(ctx, cfg, fakeClient); err != nil { + t.Errorf("createEnvoyFilter() error = %v, wantErr = nil", err) + } + + // Then + latestFilter := &networkingv1alpha3.EnvoyFilter{} + _ = fakeClient.Get(ctx, client.ObjectKeyFromObject(envoyFilter), latestFilter) + got, _ := json.Marshal(latestFilter) + if !bytes.Equal(got, want) { + t.Errorf("createEnvoyFilter() got = %v, want = %v", string(got), string(want)) + } + }) + + t.Run("when filter already exists should not create new one", func(t *testing.T) { + // Given + envoyFilter := getEnvoyFilter() + + want, _ := json.Marshal(envoyFilter) + envoyFilter.ResourceVersion = "" + + if err := fakeClient.Create(ctx, envoyFilter); err != nil { + t.Errorf("createEnvoyFilter() failed to create filter = %v", err) + } + defer func() { + if err := fakeClient.Delete(ctx, getEnvoyFilter()); err != nil { + t.Errorf("createEnvoyFilter() failed to delete filter = %v", err) + } + }() + + // When + if err := createEnvoyFilter(ctx, cfg, fakeClient); err != nil { + t.Errorf("createEnvoyFilter() error = %v, wantErr = nil", err) + } + + // Then + filter := &networkingv1alpha3.EnvoyFilter{} + _ = fakeClient.Get(ctx, client.ObjectKeyFromObject(envoyFilter), filter) + got, _ := json.Marshal(filter) + if !bytes.Equal(got, want) { + t.Errorf("createEnvoyFilter() got = %v, want = %v", string(got), string(want)) + } + }) +} + +func Test_createWasmPlugin(t *testing.T) { + cfg := getConfig() + ctx := context.WithValue(context.Background(), util.LoggerContextKey{}, zap.S()) + fakeClient := getFakeClient() + + t.Run("when wasm plugin doesn't exist should create it", func(t *testing.T) { + // Given + wasmPlugin := getWasmPlugin() + want, _ := json.Marshal(wasmPlugin) + defer func() { + if err := fakeClient.Delete(ctx, wasmPlugin); err != nil { + t.Errorf("createWasmPlugin() failed to delete plugin = %v", err) + } + }() + + // When + if err := createWasmPlugin(ctx, cfg, fakeClient); err != nil { + t.Errorf("createWasmPlugin() error = %v, wantErr = nil", err) + } + + // Then + latestWasmPlugin := &v1alpha1.WasmPlugin{} + _ = fakeClient.Get(ctx, client.ObjectKeyFromObject(wasmPlugin), latestWasmPlugin) + got, _ := json.Marshal(latestWasmPlugin) + if !bytes.Equal(got, want) { + t.Errorf("createWasmPlugin() got = %v, want = %v", string(got), string(want)) + } + }) + + t.Run("when wasm plugin already exist should not create new one", func(t *testing.T) { + // Given + wasmPlugin := getWasmPlugin() + + want, _ := json.Marshal(wasmPlugin) + wasmPlugin.ResourceVersion = "" + + if err := fakeClient.Create(ctx, wasmPlugin); err != nil { + t.Errorf("createWasmPlugin() failed to create error = %v, wantErr = nil", err) + } + defer func() { + if err := fakeClient.Delete(ctx, wasmPlugin); err != nil { + t.Errorf("createWasmPlugin() failed to delete plugin = %v", err) + } + }() + + // When + if err := createWasmPlugin(ctx, cfg, fakeClient); err != nil { + t.Errorf("createWasmPlugin() error = %v, wantErr = nil", err) + } + + // Then + latestWasmPlugin := &v1alpha1.WasmPlugin{} + _ = fakeClient.Get(ctx, client.ObjectKeyFromObject(wasmPlugin), latestWasmPlugin) + got, _ := json.Marshal(latestWasmPlugin) + if !bytes.Equal(got, want) { + t.Errorf("createWasmPlugin() got = %v, want = %v", string(got), string(want)) + } + }) +} + +func Test_deleteEnvoyFilter(t *testing.T) { + ctx := context.WithValue(context.Background(), util.LoggerContextKey{}, zap.S()) + fakeClient := getFakeClient() + + t.Run("when filter exists should delete it and return no error", func(t *testing.T) { + // Given + envoyFilter := getEnvoyFilter() + envoyFilter.ResourceVersion = "" + if err := fakeClient.Create(ctx, envoyFilter); err != nil { + t.Errorf("deleteEnvoyFilter() failed to create filter error = %v, wantErr = nil", err) + } + + // When & Then + if err := deleteEnvoyFilter(zap.S(), fakeClient, istioRootNs); err != nil { + t.Errorf("deleteEnvoyFilter() error = %v, wantErr = nil", err) + } + }) + + t.Run("when filter doesn't exist should return error", func(t *testing.T) { + // Given + errMessage := `envoyfilters.networking.istio.io "http-filter" not found` + + // When + err := deleteEnvoyFilter(zap.S(), fakeClient, istioRootNs) + + // Then + if err == nil { + t.Errorf("deleteEnvoyFilter() error = nil, wantErr = %v", errMessage) + } + if err.Error() != errMessage { + t.Errorf("deleteEnvoyFilter() errorMessage = %v, wantErrMessage = %v", err, errMessage) + } + + }) +} + +func Test_deleteWasmPlugin(t *testing.T) { + ctx := context.WithValue(context.Background(), util.LoggerContextKey{}, zap.S()) + fakeClient := getFakeClient() + + t.Run("when wasm plugin exists should delete it and return no error", func(t *testing.T) { + // Given + wasmPlugin := getWasmPlugin() + wasmPlugin.ResourceVersion = "" + if err := fakeClient.Create(ctx, wasmPlugin); err != nil { + t.Errorf("deleteWasmPlugin() failed to create wasm plugin error = %v, wantErr = nil", err) + } + + // When & Then + if err := deleteWasmPlugin(zap.S(), fakeClient, istioRootNs); err != nil { + t.Errorf("deleteWasmPlugin() error = %v, wantErr = nil", err) + } + }) + + t.Run("when wasm plugin doesn't exist should return error", func(t *testing.T) { + // Given + errMessage := `wasmplugins.extensions.istio.io "http-filter" not found` + + // When + err := deleteWasmPlugin(zap.S(), fakeClient, istioRootNs) + + // Then + if err == nil { + t.Errorf("deleteWasmPlugin() error = nil, wantErr = %v", errMessage) + } + if err.Error() != errMessage { + t.Errorf("deleteWasmPlugin() errorMessage = %v, wantErrMessage = %v", err, errMessage) + } + + }) +} + +func Test_getIstioRootNamespaceFromConfig(t *testing.T) { + t.Run("with valid istio-sidecar receiver config should return its namespace", func(t *testing.T) { + if got := getIstioRootNamespaceFromConfig(getConfig()); got != "istio-system" { + t.Errorf("getIstioRootNamespaceFromConfig() got = %v, want %v", got, "istio-system") + } + }) +} + +func getConfig() *config.Config { + configFilePath, err := filepath.Abs(filepath.Join("..", "..", "..", "..", "config", "test-configs", "default-config.yaml")) + if err != nil { + panic(fmt.Errorf("failed to get absolute path of config file: %v", err)) + } + + cfg, err := config.New(configFilePath, zap.S()) + if err != nil { + panic(fmt.Errorf("failed to create config: %v", err)) + } + + return cfg +} + +func getFakeClient() client.WithWatch { + scheme := runtime.NewScheme() + utilruntime.Must(networkingv1alpha3.AddToScheme(scheme)) + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + + return fake. + NewClientBuilder(). + WithScheme(scheme). + Build() +} + +func getEnvoyFilter() *networkingv1alpha3.EnvoyFilter { + const httpFilter = ` +apiVersion: networking.istio.io/v1alpha3 +kind: EnvoyFilter +metadata: + name: {{ .FilterName }} + # Deploy the filter to whatever istio considers its "root" namespace so that we + # don't have to create the ConfigMap(s) containing the WASM filter binary, + # and the associated annotations/configuration for the Istio sidecar(s). + # https://istio.io/latest/docs/reference/config/istio.mesh.v1alpha1/#MeshConfig:~:text=No-,rootNamespace,-string + namespace: {{ .IstioRootNs }} + labels: + app.kubernetes.io/managed-by: sentryflow +spec: + configPatches: + - applyTo: CLUSTER + match: + context: SIDECAR_OUTBOUND + patch: + operation: ADD + value: + name: {{ .UpstreamAndClusterName }} + type: LOGICAL_DNS + connect_timeout: 1s + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: {{ .UpstreamAndClusterName }} + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + protocol: TCP + address: {{ .UpstreamAndClusterName }}.{{ .UpstreamAndClusterName }} + port_value: {{ .SentryFlowFilterServerPort }} +` + + data := envoyFilterData{ + FilterName: FilterName, + IstioRootNs: "istio-system", + UpstreamAndClusterName: UpstreamAndClusterName, + SentryFlowFilterServerPort: 8081, + } + + tmpl, err := template.New("envoyHttpFilter").Parse(httpFilter) + if err != nil { + return nil + } + + envoyFilter := &bytes.Buffer{} + if err := tmpl.Execute(envoyFilter, data); err != nil { + return nil + } + + filter := &networkingv1alpha3.EnvoyFilter{ + TypeMeta: metav1.TypeMeta{ + Kind: "EnvoyFilter", + APIVersion: "networking.istio.io/v1alpha3", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: FilterName, + Namespace: istioRootNs, + ResourceVersion: "1", + }, + } + if err := yaml.UnmarshalStrict(envoyFilter.Bytes(), filter); err != nil { + return nil + } + return filter +} + +func getWasmPlugin() *v1alpha1.WasmPlugin { + cfg := getConfig() + + return &v1alpha1.WasmPlugin{ + TypeMeta: metav1.TypeMeta{ + Kind: "WasmPlugin", + APIVersion: "extensions.istio.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: FilterName, + Namespace: istioRootNs, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "sentryflow", + }, + ResourceVersion: "1", + }, + Spec: extensionsv1alpha1.WasmPlugin{ + Url: cfg.Filters.Envoy.Uri, + PluginConfig: &_struct.Struct{ + Fields: map[string]*_struct.Value{ + "upstream_name": { + Kind: &_struct.Value_StringValue{ + StringValue: UpstreamAndClusterName, + }, + }, + "authority": { + Kind: &_struct.Value_StringValue{ + StringValue: UpstreamAndClusterName, + }, + }, + "api_path": { + Kind: &_struct.Value_StringValue{ + StringValue: ApiPath, + }, + }, + }, + }, + PluginName: FilterName, + FailStrategy: extensionsv1alpha1.FailStrategy_FAIL_OPEN, + Match: []*extensionsv1alpha1.WasmPlugin_TrafficSelector{ + { + Mode: v1beta1.WorkloadMode_CLIENT, + }, + }, + Type: extensionsv1alpha1.PluginType_HTTP, + }, + } +} diff --git a/sentryflow/pkg/util/util_test.go b/sentryflow/pkg/util/util_test.go new file mode 100644 index 0000000..026b0cc --- /dev/null +++ b/sentryflow/pkg/util/util_test.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Authors of SentryFlow + +package util + +import ( + "context" + "reflect" + "testing" + + "go.uber.org/zap" +) + +func TestLoggerFromCtx(t *testing.T) { + logger := zap.S() + type args struct { + ctx context.Context + } + tests := []struct { + name string + args args + want *zap.SugaredLogger + }{ + { + name: "with logger in context should return logger", + args: args{ + ctx: context.WithValue(context.Background(), LoggerContextKey{}, logger), + }, + want: logger, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := LoggerFromCtx(tt.args.ctx); !reflect.DeepEqual(got, tt.want) { + t.Errorf("LoggerFromCtx() = %v, want %v", got, tt.want) + } + }) + } +}