diff --git a/api/grpc/mpi/v1/command.pb.go b/api/grpc/mpi/v1/command.pb.go index b39457738..ed5f0b665 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 directive + 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..9577b8de8 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 directive + 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..a8995742c 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 directive | 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..06e7bfbd2 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 from Location for Ca Cert : ", zap.Any(caCertLocation, caCertLocation)) + caCert, err := os.ReadFile(caCertLocation) + if err != nil { + s.settings.Logger.Error("Error starting NGINX stub 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/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..cf0c577f2 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,26 @@ 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 from Location for Ca Cert : ", zap.Any(caCertLocation, caCertLocation)) + caCert, err := os.ReadFile(caCertLocation) + if err != nil { + nps.logger.Error("Unable to start NGINX Plus scraper. Failed to read CA certificate: %v", 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 a1c6a9876..38d2ba1d8 100644 --- a/internal/collector/otel_collector_plugin.go +++ b/internal/collector/otel_collector_plugin.go @@ -418,6 +418,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 6af00e9d2..36edcea84 100644 --- a/internal/collector/otelcol.tmpl +++ b/internal/collector/otelcol.tmpl @@ -81,6 +81,7 @@ receivers: url: "{{- .StubStatus.URL -}}" listen: "{{- .StubStatus.Listen -}}" location: "{{- .StubStatus.Location -}}" + ca: "{{- .StubStatus.Ca -}}" {{- if .CollectionInterval }} collection_interval: {{ .CollectionInterval }} {{- end }} @@ -98,6 +99,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 4ebd75900..194206404 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -267,6 +267,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 "+ @@ -786,6 +792,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 6c4a1ab3d..b6aed8905 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 58a2d8454..24e6a340d 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -118,6 +118,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 ae0558045..95755a4e1 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"` @@ -230,6 +231,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 ebdef5441..c9f9e321d 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" @@ -526,7 +528,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 @@ -592,6 +598,13 @@ func (ncp *NginxConfigParser) urlsForLocationDirectiveAPIDetails( 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.getCACertLocation() + } // process from the location block if current.Directive != locationDirective { return urls @@ -619,12 +632,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, }) } } @@ -724,6 +740,37 @@ func (ncp *NginxConfigParser) isPort(value string) bool { return err == nil && port >= 1 && port <= 65535 } +// hasSSLArgument 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 +} + +// isSSLListenDirective 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) +} + +// isSSLEnabled 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, @@ -735,6 +782,47 @@ func (ncp *NginxConfigParser) socketClient(socketPath string) *http.Client { } } +// New helper: 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 from Location for Ca Cert : ", "cacertlocation", caCertLocation) + caCert, err := os.ReadFile(caCertLocation) + if err != nil { + slog.ErrorContext(ctx, "Failed to read CA certificate", "error", err) + 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 +} + +// New helper: Populate the CA cert location based ondirectory allowance. +func (ncp *NginxConfigParser) getCACertLocation() 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.Warn("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/model/config.go b/internal/model/config.go index ed1d92487..9dd24aeac 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..5d57d970d 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,26 @@ func (r *ResourceService) createPlusClient(instance *mpi.Instance) (*client.Ngin } httpClient := http.DefaultClient + caCertLocation := plusAPI.GetCa() + if caCertLocation != "" { + slog.Debug("Reading from Location for Ca Cert : ", "cacertlocation", caCertLocation) + caCert, err := os.ReadFile(caCertLocation) + if err != nil { + slog.Error("Unable to Create NGINX Plus client. Failed to read CA certificate : ", "err", err) + 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/test/config/collector/test-opentelemetry-collector-agent.yaml b/test/config/collector/test-opentelemetry-collector-agent.yaml index 9a17adc3f..46625a905 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\""