diff --git a/api/grpc/mpi/v1/command.pb.go b/api/grpc/mpi/v1/command.pb.go index b39457738..7c137c5e9 100644 --- a/api/grpc/mpi/v1/command.pb.go +++ b/api/grpc/mpi/v1/command.pb.go @@ -2262,7 +2262,9 @@ type APIDetails struct { // the API location directive Location string `protobuf:"bytes,1,opt,name=location,proto3" json:"location,omitempty"` // the API listen directive - Listen string `protobuf:"bytes,2,opt,name=listen,proto3" json:"listen,omitempty"` + Listen string `protobuf:"bytes,2,opt,name=listen,proto3" json:"listen,omitempty"` + // the API CA file path + Ca string `protobuf:"bytes,3,opt,name=Ca,proto3" json:"Ca,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2311,6 +2313,13 @@ func (x *APIDetails) GetListen() string { return "" } +func (x *APIDetails) GetCa() string { + if x != nil { + return x.Ca + } + return "" +} + // A set of runtime NGINX App Protect settings type NGINXAppProtectRuntimeInfo struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2872,11 +2881,12 @@ const file_mpi_v1_command_proto_rawDesc = "" + "error_logs\x18\x03 \x03(\tR\terrorLogs\x12)\n" + "\x10loadable_modules\x18\x04 \x03(\tR\x0floadableModules\x12'\n" + "\x0fdynamic_modules\x18\x05 \x03(\tR\x0edynamicModules\x12-\n" + - "\bplus_api\x18\x06 \x01(\v2\x12.mpi.v1.APIDetailsR\aplusApi\"@\n" + + "\bplus_api\x18\x06 \x01(\v2\x12.mpi.v1.APIDetailsR\aplusApi\"P\n" + "\n" + "APIDetails\x12\x1a\n" + "\blocation\x18\x01 \x01(\tR\blocation\x12\x16\n" + - "\x06listen\x18\x02 \x01(\tR\x06listen\"\xe0\x01\n" + + "\x06listen\x18\x02 \x01(\tR\x06listen\x12\x0e\n" + + "\x02Ca\x18\x03 \x01(\tR\x02Ca\"\xe0\x01\n" + "\x1aNGINXAppProtectRuntimeInfo\x12\x18\n" + "\arelease\x18\x01 \x01(\tR\arelease\x128\n" + "\x18attack_signature_version\x18\x02 \x01(\tR\x16attackSignatureVersion\x126\n" + diff --git a/api/grpc/mpi/v1/command.pb.validate.go b/api/grpc/mpi/v1/command.pb.validate.go index 194284c7e..81f716548 100644 --- a/api/grpc/mpi/v1/command.pb.validate.go +++ b/api/grpc/mpi/v1/command.pb.validate.go @@ -4893,6 +4893,8 @@ func (m *APIDetails) validate(all bool) error { // no validation rules for Listen + // no validation rules for Ca + if len(errors) > 0 { return APIDetailsMultiError(errors) } diff --git a/api/grpc/mpi/v1/command.proto b/api/grpc/mpi/v1/command.proto index cdf3232da..84fb6a020 100644 --- a/api/grpc/mpi/v1/command.proto +++ b/api/grpc/mpi/v1/command.proto @@ -352,6 +352,8 @@ message APIDetails { string location = 1; // the API listen directive string listen = 2; + // the API CA file path + string Ca = 3; } // A set of runtime NGINX App Protect settings diff --git a/docs/proto/protos.md b/docs/proto/protos.md index b0e567fc2..40bdb1828 100644 --- a/docs/proto/protos.md +++ b/docs/proto/protos.md @@ -678,6 +678,7 @@ Perform an associated API action on an instance | ----- | ---- | ----- | ----------- | | location | [string](#string) | | the API location directive | | listen | [string](#string) | | the API listen directive | +| Ca | [string](#string) | | the API CA file path | diff --git a/internal/collector/nginxossreceiver/internal/config/config.go b/internal/collector/nginxossreceiver/internal/config/config.go index c09112f45..55b2bd0ad 100644 --- a/internal/collector/nginxossreceiver/internal/config/config.go +++ b/internal/collector/nginxossreceiver/internal/config/config.go @@ -33,6 +33,7 @@ type APIDetails struct { URL string `mapstructure:"url"` Listen string `mapstructure:"listen"` Location string `mapstructure:"location"` + Ca string `mapstructure:"ca"` } type AccessLog struct { @@ -56,6 +57,7 @@ func CreateDefaultConfig() component.Config { URL: "http://localhost:80/status", Listen: "localhost:80", Location: "status", + Ca: "", }, } } diff --git a/internal/collector/nginxossreceiver/internal/scraper/stubstatus/stub_status_scraper.go b/internal/collector/nginxossreceiver/internal/scraper/stubstatus/stub_status_scraper.go index f9173a1b8..384be6ba4 100644 --- a/internal/collector/nginxossreceiver/internal/scraper/stubstatus/stub_status_scraper.go +++ b/internal/collector/nginxossreceiver/internal/scraper/stubstatus/stub_status_scraper.go @@ -7,8 +7,11 @@ package stubstatus import ( "context" + "crypto/tls" + "crypto/x509" "net" "net/http" + "os" "strings" "sync" "time" @@ -63,6 +66,28 @@ func (s *NginxStubStatusScraper) ID() component.ID { func (s *NginxStubStatusScraper) Start(_ context.Context, _ component.Host) error { s.logger.Info("Starting NGINX stub status scraper") httpClient := http.DefaultClient + caCertLocation := s.cfg.APIDetails.Ca + if caCertLocation != "" { + s.settings.Logger.Debug("Reading CA certificate", zap.Any("file_path", caCertLocation)) + caCert, err := os.ReadFile(caCertLocation) + if err != nil { + s.settings.Logger.Error("Error starting NGINX stub status scraper. "+ + "Failed to read CA certificate", zap.Error(err)) + + return nil + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS13, + }, + }, + } + } httpClient.Timeout = s.cfg.ClientConfig.Timeout if strings.HasPrefix(s.cfg.APIDetails.Listen, "unix:") { diff --git a/internal/collector/nginxossreceiver/internal/scraper/stubstatus/stub_status_scraper_tls_test.go b/internal/collector/nginxossreceiver/internal/scraper/stubstatus/stub_status_scraper_tls_test.go new file mode 100644 index 000000000..b36db092c --- /dev/null +++ b/internal/collector/nginxossreceiver/internal/scraper/stubstatus/stub_status_scraper_tls_test.go @@ -0,0 +1,152 @@ +// Copyright (c) F5, Inc. +// +// This source code is licensed under the Apache License, Version 2.0 license found in the +// LICENSE file in the root directory of this source tree. + +package stubstatus + +import ( + "context" + "crypto/tls" + "crypto/x509" + "net" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/component/componenttest" + "go.opentelemetry.io/collector/receiver/receivertest" + + "github.com/nginx/agent/v3/internal/collector/nginxossreceiver/internal/config" + "github.com/nginx/agent/v3/test/helpers" +) + +func TestStubStatusScraperTLS(t *testing.T) { + // Generate self-signed certificate using helper + keyBytes, certBytes := helpers.GenerateSelfSignedCert(t) + + // Create a temporary directory for test files + tempDir := t.TempDir() + + // Save certificate to a file + certFile := helpers.WriteCertFiles(t, tempDir, helpers.Cert{ + Name: "server.crt", + Type: "CERTIFICATE", + Contents: certBytes, + }) + + // Parse the private key + key, err := x509.ParsePKCS1PrivateKey(keyBytes) + require.NoError(t, err) + + // Create a TLS config with our self-signed certificate + tlsCert := tls.Certificate{ + Certificate: [][]byte{certBytes}, + PrivateKey: key, + } + + serverTLSConfig := &tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{tlsCert}, + } + + // Create a test server with our custom TLS config + server := httptest.NewUnstartedServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/status" { + rw.WriteHeader(http.StatusOK) + _, _ = rw.Write([]byte(`Active connections: 291 +server accepts handled requests + 16630948 16630946 31070465 +Reading: 6 Writing: 179 Waiting: 106 +`)) + + return + } + rw.WriteHeader(http.StatusNotFound) + })) + + server.TLS = serverTLSConfig + server.StartTLS() + defer server.Close() + + // Test with TLS configuration using our self-signed certificate + t.Run("Test 1: self-signed TLS", func(t *testing.T) { + cfg, ok := config.CreateDefaultConfig().(*config.Config) + require.True(t, ok) + + cfg.APIDetails.URL = server.URL + "/status" + // Use the self-signed certificate for verification + cfg.APIDetails.Ca = certFile + + scraper := NewScraper(receivertest.NewNopSettings(component.Type{}), cfg) + + startErr := scraper.Start(context.Background(), componenttest.NewNopHost()) + require.NoError(t, startErr) + + _, err = scraper.Scrape(context.Background()) + assert.NoError(t, err, "Scraping with self-signed certificate should succeed") + }) +} + +func TestStubStatusScraperUnixSocket(t *testing.T) { + // Create a test server with a Unix domain socket + handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/status" { + rw.WriteHeader(http.StatusOK) + _, _ = rw.Write([]byte(`Active connections: 291 +server accepts handled requests + 16630948 16630946 31070465 +Reading: 6 Writing: 179 Waiting: 106 +`)) + + return + } + rw.WriteHeader(http.StatusNotFound) + }) + + // Create a socket file in a temporary directory with a shorter path + socketPath := "/tmp/nginx-test.sock" + + // Clean up any existing socket file + os.Remove(socketPath) + + // Create a listener for the Unix socket + listener, err := net.Listen("unix", socketPath) + require.NoError(t, err, "Failed to create Unix socket listener") + + // Create a test server with our custom listener + server := &httptest.Server{ + Listener: listener, + Config: &http.Server{Handler: handler}, + } + + // Start the server + server.Start() + + // Ensure cleanup of the socket file + t.Cleanup(func() { + server.Close() + os.Remove(socketPath) + }) + + // Test with Unix socket + t.Run("Test 1: Unix socket", func(t *testing.T) { + cfg, ok := config.CreateDefaultConfig().(*config.Config) + require.True(t, ok) + + cfg.APIDetails.URL = "http://unix/status" + cfg.APIDetails.Listen = "unix:" + socketPath + + scraper := NewScraper(receivertest.NewNopSettings(component.Type{}), cfg) + + startErr := scraper.Start(context.Background(), componenttest.NewNopHost()) + require.NoError(t, startErr) + + _, err = scraper.Scrape(context.Background()) + assert.NoError(t, err) + }) +} diff --git a/internal/collector/nginxplusreceiver/config.go b/internal/collector/nginxplusreceiver/config.go index a05dd6d6d..7689442c1 100644 --- a/internal/collector/nginxplusreceiver/config.go +++ b/internal/collector/nginxplusreceiver/config.go @@ -29,6 +29,7 @@ type APIDetails struct { URL string `mapstructure:"url"` Listen string `mapstructure:"listen"` Location string `mapstructure:"location"` + Ca string `mapstructure:"ca"` } // Validate checks if the receiver configuration is valid @@ -59,6 +60,7 @@ func createDefaultConfig() component.Config { URL: "http://localhost:80/api", Listen: "localhost:80", Location: "/api", + Ca: "", }, MetricsBuilderConfig: metadata.DefaultMetricsBuilderConfig(), } diff --git a/internal/collector/nginxplusreceiver/scraper.go b/internal/collector/nginxplusreceiver/scraper.go index fc41b0f7f..9f11b607c 100644 --- a/internal/collector/nginxplusreceiver/scraper.go +++ b/internal/collector/nginxplusreceiver/scraper.go @@ -6,9 +6,12 @@ package nginxplusreceiver import ( "context" + "crypto/tls" + "crypto/x509" "fmt" "net" "net/http" + "os" "strconv" "strings" "sync" @@ -82,6 +85,28 @@ func (nps *NginxPlusScraper) ID() component.ID { func (nps *NginxPlusScraper) Start(_ context.Context, _ component.Host) error { endpoint := strings.TrimPrefix(nps.cfg.APIDetails.URL, "unix:") httpClient := http.DefaultClient + caCertLocation := nps.cfg.APIDetails.Ca + if caCertLocation != "" { + nps.logger.Debug("Reading CA certificate", zap.Any("file_path", caCertLocation)) + caCert, err := os.ReadFile(caCertLocation) + if err != nil { + nps.logger.Error("Error starting NGINX stub status scraper. "+ + "Failed to read CA certificate", zap.Error(err)) + + return err + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS13, + }, + }, + } + } httpClient.Timeout = nps.cfg.ClientConfig.Timeout if strings.HasPrefix(nps.cfg.APIDetails.Listen, "unix:") { diff --git a/internal/collector/otel_collector_plugin.go b/internal/collector/otel_collector_plugin.go index 953f1838d..da7094417 100644 --- a/internal/collector/otel_collector_plugin.go +++ b/internal/collector/otel_collector_plugin.go @@ -419,6 +419,7 @@ func (oc *Collector) checkForNewReceivers(ctx context.Context, nginxConfigContex URL: nginxConfigContext.PlusAPI.URL, Listen: nginxConfigContext.PlusAPI.Listen, Location: nginxConfigContext.PlusAPI.Location, + Ca: nginxConfigContext.PlusAPI.Ca, }, CollectionInterval: defaultCollectionInterval, }, diff --git a/internal/collector/otelcol.tmpl b/internal/collector/otelcol.tmpl index 572e8c222..cd8e691dd 100644 --- a/internal/collector/otelcol.tmpl +++ b/internal/collector/otelcol.tmpl @@ -84,6 +84,7 @@ receivers: url: "{{- .StubStatus.URL -}}" listen: "{{- .StubStatus.Listen -}}" location: "{{- .StubStatus.Location -}}" + ca: "{{- .StubStatus.Ca -}}" {{- if .CollectionInterval }} collection_interval: {{ .CollectionInterval }} {{- end }} @@ -102,6 +103,7 @@ receivers: url: "{{- .PlusAPI.URL -}}" listen: "{{- .PlusAPI.Listen -}}" location: "{{- .PlusAPI.Location -}}" + ca: "{{- .PlusAPI.Ca -}}" {{- if .CollectionInterval }} collection_interval: {{ .CollectionInterval }} {{- end }} diff --git a/internal/config/config.go b/internal/config/config.go index 79e661efc..867865d0c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -376,6 +376,12 @@ func registerFlags() { "Warning messages in the NGINX errors logs after a NGINX reload will be treated as an error.", ) + fs.String( + NginxApiTlsCa, + DefNginxApiTlsCa, + "The NGINX Plus CA certificate file location needed to call the NGINX Plus API if SSL is enabled.", + ) + fs.StringSlice( NginxExcludeLogsKey, []string{}, "A comma-separated list of one or more NGINX log paths that you want to exclude from metrics "+ @@ -877,6 +883,7 @@ func resolveDataPlaneConfig() *DataPlaneConfig { ReloadMonitoringPeriod: viperInstance.GetDuration(NginxReloadMonitoringPeriodKey), TreatWarningsAsErrors: viperInstance.GetBool(NginxTreatWarningsAsErrorsKey), ExcludeLogs: viperInstance.GetStringSlice(NginxExcludeLogsKey), + APITls: TLSConfig{Ca: viperInstance.GetString(NginxApiTlsCa)}, }, } } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index c52ebbd5f..072052431 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -14,6 +14,7 @@ const ( DefGracefulShutdownPeriod = 5 * time.Second DefNginxReloadMonitoringPeriod = 10 * time.Second DefTreatErrorsAsWarnings = false + DefNginxApiTlsCa = "" DefCommandServerHostKey = "" DefCommandServerPortKey = 0 diff --git a/internal/config/flags.go b/internal/config/flags.go index 189046907..e6da27573 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -115,6 +115,7 @@ var ( NginxReloadMonitoringPeriodKey = pre(DataPlaneConfigRootKey, "nginx") + "reload_monitoring_period" NginxTreatWarningsAsErrorsKey = pre(DataPlaneConfigRootKey, "nginx") + "treat_warnings_as_errors" NginxExcludeLogsKey = pre(DataPlaneConfigRootKey, "nginx") + "exclude_logs" + NginxApiTlsCa = pre(DataPlaneConfigRootKey, "nginx") + "api_tls_ca" FileWatcherMonitoringFrequencyKey = pre(FileWatcherKey) + "monitoring_frequency" NginxExcludeFilesKey = pre(FileWatcherKey) + "exclude_files" diff --git a/internal/config/types.go b/internal/config/types.go index b6ccebc61..01e71ead3 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -61,6 +61,7 @@ type ( } NginxDataPlaneConfig struct { + APITls TLSConfig `yaml:"api_tls" mapstructure:"api_tls"` ExcludeLogs []string `yaml:"exclude_logs" mapstructure:"exclude_logs"` ReloadMonitoringPeriod time.Duration `yaml:"reload_monitoring_period" mapstructure:"reload_monitoring_period"` TreatWarningsAsErrors bool `yaml:"treat_warnings_as_errors" mapstructure:"treat_warnings_as_errors"` @@ -242,6 +243,7 @@ type ( URL string `yaml:"url" mapstructure:"url"` Listen string `yaml:"listen" mapstructure:"listen"` Location string `yaml:"location" mapstructure:"location"` + Ca string `yaml:"ca" mapstructure:"ca"` } AccessLog struct { diff --git a/internal/datasource/config/nginx_config_parser.go b/internal/datasource/config/nginx_config_parser.go index 74ddaf9bf..872147a30 100644 --- a/internal/datasource/config/nginx_config_parser.go +++ b/internal/datasource/config/nginx_config_parser.go @@ -7,6 +7,8 @@ package config import ( "context" + "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "io" @@ -555,7 +557,7 @@ func (ncp *NginxConfigParser) sslCert(ctx context.Context, file, rootDir string) func (ncp *NginxConfigParser) apiCallback(ctx context.Context, parent, current *crossplane.Directive, apiType string, ) *model.APIDetails { - urls := ncp.urlsForLocationDirectiveAPIDetails(parent, current, apiType) + urls := ncp.urlsForLocationDirectiveAPIDetails(ctx, parent, current, apiType) if len(urls) > 0 { slog.DebugContext(ctx, fmt.Sprintf("%d potential %s urls", len(urls), apiType), "urls", urls) } @@ -578,7 +580,11 @@ func (ncp *NginxConfigParser) apiCallback(ctx context.Context, parent, func (ncp *NginxConfigParser) pingAPIEndpoint(ctx context.Context, statusAPIDetail *model.APIDetails, apiType string, ) bool { - httpClient := http.DefaultClient + httpClient, err := ncp.prepareHTTPClient(ctx) + if err != nil { + slog.ErrorContext(ctx, "Failed to prepare HTTP client", "error", err) + return false + } listen := statusAPIDetail.Listen statusAPI := statusAPIDetail.URL @@ -640,10 +646,17 @@ func (ncp *NginxConfigParser) pingAPIEndpoint(ctx context.Context, statusAPIDeta // nolint: revive func (ncp *NginxConfigParser) urlsForLocationDirectiveAPIDetails( - parent, current *crossplane.Directive, + ctx context.Context, parent, current *crossplane.Directive, locationDirectiveName string, ) []*model.APIDetails { var urls []*model.APIDetails + // Check if SSL is enabled in the server block + isSSL := ncp.isSSLEnabled(parent) + caCertLocation := "" + // If SSl is enabled, check if CA cert is provided and the location is allowed + if isSSL { + caCertLocation = ncp.selfSignedCACertLocation(ctx) + } // process from the location block if current.Directive != locationDirective { return urls @@ -671,12 +684,15 @@ func (ncp *NginxConfigParser) urlsForLocationDirectiveAPIDetails( URL: fmt.Sprintf(format, path), Listen: address, Location: path, + Ca: caCertLocation, }) } else { urls = append(urls, &model.APIDetails{ - URL: fmt.Sprintf(apiFormat, address, path), + URL: fmt.Sprintf("%s://%s%s", map[bool]string{true: "https", false: "http"}[isSSL], + address, path), Listen: address, Location: path, + Ca: caCertLocation, }) } } @@ -776,6 +792,37 @@ func (ncp *NginxConfigParser) isPort(value string) bool { return err == nil && port >= 1 && port <= 65535 } +// checks if any of the arguments contain "ssl". +func (ncp *NginxConfigParser) hasSSLArgument(args []string) bool { + for i := 1; i < len(args); i++ { + if args[i] == "ssl" { + return true + } + } + + return false +} + +// checks if a directive is a listen directive with ssl enabled. +func (ncp *NginxConfigParser) isSSLListenDirective(dir *crossplane.Directive) bool { + return dir.Directive == "listen" && ncp.hasSSLArgument(dir.Args) +} + +// checks if SSL is enabled for a given server block. +func (ncp *NginxConfigParser) isSSLEnabled(serverBlock *crossplane.Directive) bool { + if serverBlock == nil { + return false + } + + for _, dir := range serverBlock.Block { + if ncp.isSSLListenDirective(dir) { + return true + } + } + + return false +} + func (ncp *NginxConfigParser) socketClient(socketPath string) *http.Client { return &http.Client{ Timeout: ncp.agentConfig.Client.Grpc.KeepAlive.Timeout, @@ -787,6 +834,46 @@ func (ncp *NginxConfigParser) socketClient(socketPath string) *http.Client { } } +// prepareHTTPClient handles TLS config +func (ncp *NginxConfigParser) prepareHTTPClient(ctx context.Context) (*http.Client, error) { + httpClient := http.DefaultClient + caCertLocation := ncp.agentConfig.DataPlaneConfig.Nginx.APITls.Ca + + if caCertLocation != "" && ncp.agentConfig.IsDirectoryAllowed(caCertLocation) { + slog.DebugContext(ctx, "Reading CA certificate", "file_path", caCertLocation) + caCert, err := os.ReadFile(caCertLocation) + if err != nil { + return nil, err + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS13, + }, + }, + } + } + + return httpClient, nil +} + +// Populate the CA cert location based ondirectory allowance. +func (ncp *NginxConfigParser) selfSignedCACertLocation(ctx context.Context) string { + caCertLocation := ncp.agentConfig.DataPlaneConfig.Nginx.APITls.Ca + + if caCertLocation != "" && !ncp.agentConfig.IsDirectoryAllowed(caCertLocation) { + // If SSL is enabled but CA cert is provided and not allowed, treat it as if no CA cert + slog.WarnContext(ctx, "CA certificate location is not allowed, treating as if no CA cert provided.") + return "" + } + + return caCertLocation +} + func (ncp *NginxConfigParser) isDuplicateFile(nginxConfigContextFiles []*mpi.File, newFile *mpi.File) bool { for _, nginxConfigContextFile := range nginxConfigContextFiles { if nginxConfigContextFile.GetFileMeta().GetName() == newFile.GetFileMeta().GetName() { diff --git a/internal/datasource/config/nginx_config_parser_test.go b/internal/datasource/config/nginx_config_parser_test.go index 5587add3c..9729266bd 100644 --- a/internal/datasource/config/nginx_config_parser_test.go +++ b/internal/datasource/config/nginx_config_parser_test.go @@ -1172,9 +1172,9 @@ func TestNginxConfigParser_urlsForLocationDirective(t *testing.T) { assert.Len(t, xpConf.Parsed, 1) err = ncp.crossplaneConfigTraverse(ctx, &xpConf, func(ctx context.Context, parent, directive *crossplane.Directive) error { - _oss := ncp.urlsForLocationDirectiveAPIDetails(parent, directive, + _oss := ncp.urlsForLocationDirectiveAPIDetails(ctx, parent, directive, stubStatusAPIDirective) - _plus := ncp.urlsForLocationDirectiveAPIDetails(parent, directive, plusAPIDirective) + _plus := ncp.urlsForLocationDirectiveAPIDetails(ctx, parent, directive, plusAPIDirective) oss = append(oss, _oss...) plus = append(plus, _plus...) diff --git a/internal/model/config.go b/internal/model/config.go index 67dea7449..e4d3ba3e9 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -26,6 +26,7 @@ type APIDetails struct { URL string Listen string Location string + Ca string } type ManifestFile struct { diff --git a/internal/resource/resource_service.go b/internal/resource/resource_service.go index ed41c48a2..792c28c7a 100644 --- a/internal/resource/resource_service.go +++ b/internal/resource/resource_service.go @@ -7,12 +7,15 @@ package resource import ( "context" + "crypto/tls" + "crypto/x509" "encoding/json" "errors" "fmt" "log/slog" "net" "net/http" + "os" "strings" "sync" @@ -348,6 +351,25 @@ func (r *ResourceService) createPlusClient(instance *mpi.Instance) (*client.Ngin } httpClient := http.DefaultClient + caCertLocation := plusAPI.GetCa() + if caCertLocation != "" { + slog.Debug("Reading CA certificate", "file_path", caCertLocation) + caCert, err := os.ReadFile(caCertLocation) + if err != nil { + return nil, err + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS13, + }, + }, + } + } if strings.HasPrefix(plusAPI.GetListen(), "unix:") { httpClient = socketClient(strings.TrimPrefix(plusAPI.GetListen(), "unix:")) } diff --git a/internal/resource/resource_service_test.go b/internal/resource/resource_service_test.go index 0123220ac..f8fe52244 100644 --- a/internal/resource/resource_service_test.go +++ b/internal/resource/resource_service_test.go @@ -9,6 +9,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "testing" "github.com/nginx/agent/v3/internal/model" @@ -26,6 +28,7 @@ import ( "github.com/nginx/agent/v3/api/grpc/mpi/v1" "github.com/nginx/agent/v3/test/protos" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestResourceService_AddInstance(t *testing.T) { @@ -237,6 +240,13 @@ func TestResourceService_GetResource(t *testing.T) { } func TestResourceService_createPlusClient(t *testing.T) { + // Create a temporary file for testing CA certificate + tempDir := t.TempDir() + caFile := filepath.Join(tempDir, "test-ca.crt") + + err := os.WriteFile(caFile, []byte("-----BEGIN CERTIFICATE-----\nMII...\n-----END CERTIFICATE-----"), 0o600) + require.NoError(t, err) + instanceWithAPI := protos.NginxPlusInstance([]string{}) instanceWithAPI.InstanceRuntime.GetNginxPlusRuntimeInfo().PlusApi = &v1.APIDetails{ Location: "/api", @@ -249,6 +259,13 @@ func TestResourceService_createPlusClient(t *testing.T) { Location: "/api", } + instanceWithCACert := protos.NginxPlusInstance([]string{}) + instanceWithCACert.InstanceRuntime.GetNginxPlusRuntimeInfo().PlusApi = &v1.APIDetails{ + Location: "/api", + Listen: "localhost:443", + Ca: caFile, + } + ctx := context.Background() tests := []struct { err error @@ -266,7 +283,12 @@ func TestResourceService_createPlusClient(t *testing.T) { err: nil, }, { - name: "Test 3: Fail Creating Client - API not Configured", + name: "Test 3: Create Plus Client with CA Certificate", + instance: instanceWithCACert, + err: nil, + }, + { + name: "Test 4: Fail Creating Client - API not Configured", instance: protos.NginxPlusInstance([]string{}), err: errors.New("failed to preform API action, NGINX Plus API is not configured"), }, @@ -280,8 +302,15 @@ func TestResourceService_createPlusClient(t *testing.T) { protos.NginxPlusInstance([]string{}), } - _, err := resourceService.createPlusClient(test.instance) - assert.Equal(tt, test.err, err) + _, clientErr := resourceService.createPlusClient(test.instance) + if test.err != nil { + require.Error(tt, clientErr) + assert.Contains(tt, clientErr.Error(), test.err.Error()) + } else { + require.NoError(tt, clientErr) + // For the CA cert test, we can't easily verify the internal http.Client configuration + // without exporting it or adding test hooks, so we'll just verify no error is returned + } }) } } diff --git a/test/config/collector/test-opentelemetry-collector-agent.yaml b/test/config/collector/test-opentelemetry-collector-agent.yaml index ed9acec91..aa72f4a4b 100644 --- a/test/config/collector/test-opentelemetry-collector-agent.yaml +++ b/test/config/collector/test-opentelemetry-collector-agent.yaml @@ -31,6 +31,7 @@ receivers: url: "http://localhost:80/status" listen: "" location: "" + ca: "" collection_interval: 30s access_logs: - log_format: "$remote_addr - $remote_user [$time_local] \"$request\" $status $body_bytes_sent \"$http_referer\" \"$http_user_agent\" \"$http_x_forwarded_for\"\"$upstream_cache_status\"" diff --git a/test/helpers/cert_utils.go b/test/helpers/cert_utils.go index f4516349c..3b9e249e4 100644 --- a/test/helpers/cert_utils.go +++ b/test/helpers/cert_utils.go @@ -12,6 +12,7 @@ import ( "crypto/x509/pkix" "encoding/pem" "math/big" + "net" "os" "path" "testing" @@ -55,6 +56,8 @@ func GenerateSelfSignedCert(t testing.TB) (keyBytes, certBytes []byte) { ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, IsCA: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.IPv6loopback}, + DNSNames: []string{"localhost"}, } certBytes, err = x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) if err != nil {