diff --git a/internal/config/config.go b/internal/config/config.go index 265e4c05f2..a8c5a4f515 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -502,7 +502,19 @@ func registerDataPlaneFlags(fs *flag.FlagSet) { ) fs.String( - NginxApiTlsCa, + NginxApiURLKey, + "", + "The NGINX Plus API URL.", + ) + + fs.String( + NginxApiSocketKey, + "", + "The NGINX Plus API Unix socket path.", + ) + + fs.String( + NginxApiTlsCaKey, DefNginxApiTlsCa, "The NGINX Plus CA certificate file location needed to call the NGINX Plus API if SSL is enabled.", ) @@ -1090,12 +1102,16 @@ func parseJSON(value string) interface{} { } func resolveDataPlaneConfig() *DataPlaneConfig { - return &DataPlaneConfig{ + dataPlaneConfig := &DataPlaneConfig{ Nginx: &NginxDataPlaneConfig{ ReloadMonitoringPeriod: viperInstance.GetDuration(NginxReloadMonitoringPeriodKey), TreatWarningsAsErrors: viperInstance.GetBool(NginxTreatWarningsAsErrorsKey), ExcludeLogs: viperInstance.GetStringSlice(NginxExcludeLogsKey), - APITls: TLSConfig{Ca: viperInstance.GetString(NginxApiTlsCa)}, + API: &NginxAPI{ + URL: viperInstance.GetString(NginxApiURLKey), + Socket: viperInstance.GetString(NginxApiSocketKey), + TLS: TLSConfig{Ca: viperInstance.GetString(NginxApiTlsCaKey)}, + }, ReloadBackoff: &BackOff{ InitialInterval: viperInstance.GetDuration(NginxReloadBackoffInitialIntervalKey), MaxInterval: viperInstance.GetDuration(NginxReloadBackoffMaxIntervalKey), @@ -1105,6 +1121,12 @@ func resolveDataPlaneConfig() *DataPlaneConfig { }, }, } + + if dataPlaneConfig.Nginx.API.Socket != "" && !strings.HasPrefix(dataPlaneConfig.Nginx.API.Socket, "unix:") { + dataPlaneConfig.Nginx.API.Socket = "unix:" + dataPlaneConfig.Nginx.API.Socket + } + + return dataPlaneConfig } func resolveClient() *Client { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a64b68c889..e7b694cb0a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1212,6 +1212,9 @@ func createConfig() *Config { RandomizationFactor: 1.5, Multiplier: 1.5, }, + API: &NginxAPI{ + URL: "http://127.0.0.1:80/api", + }, }, }, Collector: &Collector{ diff --git a/internal/config/flags.go b/internal/config/flags.go index 8295c1af78..d363d14092 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -137,7 +137,9 @@ var ( NginxReloadBackoffRandomizationFactorKey = pre(NginxReloadBackoffKey) + "randomization_factor" NginxReloadBackoffMultiplierKey = pre(NginxReloadBackoffKey) + "multiplier" NginxExcludeLogsKey = pre(DataPlaneConfigRootKey, "nginx") + "exclude_logs" - NginxApiTlsCa = pre(DataPlaneConfigRootKey, "nginx") + "api_tls_ca" + NginxApiTlsCaKey = pre(DataPlaneConfigRootKey, "nginx") + "api_tls_ca" + NginxApiURLKey = pre(DataPlaneConfigRootKey, "nginx") + "api_url" + NginxApiSocketKey = pre(DataPlaneConfigRootKey, "nginx") + "api_socket" SyslogServerPort = pre("syslog_server") + "port" diff --git a/internal/config/testdata/nginx-agent.conf b/internal/config/testdata/nginx-agent.conf index e09a2a5f30..89c4197e44 100644 --- a/internal/config/testdata/nginx-agent.conf +++ b/internal/config/testdata/nginx-agent.conf @@ -18,29 +18,31 @@ labels: label3: 123 features: - - certificates - - file-watcher - - metrics - - api-action - - logs-nap + - certificates + - file-watcher + - metrics + - api-action + - logs-nap syslog_server: - port: 1512 + port: 1512 data_plane_config: - nginx: - reload_monitoring_period: 30s - treat_warnings_as_errors: true - exclude_logs: - - /var/log/nginx/error.log - - ^/var/log/nginx/.*.log$ - reload_backoff: - initial_interval: 100ms - max_interval: 20s - max_elapsed_time: 15s - randomization_factor: 1.5 - multiplier: 1.5 + nginx: + api: + url: "http://127.0.0.1:80/api" + reload_monitoring_period: 30s + treat_warnings_as_errors: true + exclude_logs: + - /var/log/nginx/error.log + - ^/var/log/nginx/.*.log$ + reload_backoff: + initial_interval: 100ms + max_interval: 20s + max_elapsed_time: 15s + randomization_factor: 1.5 + multiplier: 1.5 client: http: timeout: 15s diff --git a/internal/config/types.go b/internal/config/types.go index c62262fac4..8a787f4e7e 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -67,12 +67,18 @@ type ( } NginxDataPlaneConfig struct { ReloadBackoff *BackOff `yaml:"reload_backoff" mapstructure:"reload_backoff"` - APITls TLSConfig `yaml:"api_tls" mapstructure:"api_tls"` + API *NginxAPI `yaml:"api" mapstructure:"api"` 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"` } + NginxAPI struct { + URL string `yaml:"url" mapstructure:"url"` + Socket string `yaml:"socket" mapstructure:"socket"` + TLS TLSConfig `yaml:"tls" mapstructure:"tls"` + } + Client struct { HTTP *HTTP `yaml:"http" mapstructure:"http"` Grpc *GRPC `yaml:"grpc" mapstructure:"grpc"` @@ -496,3 +502,27 @@ func checkDirIsAllowed(path string, allowedDirs []string) bool { return checkDirIsAllowed(filepath.Dir(path), allowedDirs) } + +func (c *Config) IsNginxApiUrlConfigured() bool { + if !c.IsNginxApiConfigured() { + return false + } + + return c.DataPlaneConfig.Nginx.API.URL != "" +} + +func (c *Config) IsNginxApiSocketConfigured() bool { + if !c.IsNginxApiConfigured() { + return false + } + + return c.DataPlaneConfig.Nginx.API.Socket != "" +} + +func (c *Config) IsNginxApiConfigured() bool { + if c.DataPlaneConfig == nil || c.DataPlaneConfig.Nginx == nil || c.DataPlaneConfig.Nginx.API == nil { + return false + } + + return true +} diff --git a/internal/datasource/config/nginx_config_parser.go b/internal/datasource/config/nginx_config_parser.go index 057821119e..59740cc9ee 100644 --- a/internal/datasource/config/nginx_config_parser.go +++ b/internal/datasource/config/nginx_config_parser.go @@ -16,6 +16,7 @@ import ( "log/slog" "net" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -269,18 +270,20 @@ func (ncp *NginxConfigParser) createNginxConfigContext( return nginxConfigContext, fmt.Errorf("traverse nginx config: %w", err) } - stubStatuses := ncp.crossplaneConfigTraverseAPIDetails( - ctx, &conf, ncp.apiCallback, stubStatusAPIDirective, - ) - if stubStatuses != nil { - nginxConfigContext.StubStatuses = append(nginxConfigContext.StubStatuses, stubStatuses...) - } + if !ncp.agentConfig.IsNginxApiUrlConfigured() { + stubStatuses := ncp.crossplaneConfigTraverseAPIDetails( + ctx, &conf, ncp.apiCallback, stubStatusAPIDirective, + ) + if stubStatuses != nil { + nginxConfigContext.StubStatuses = append(nginxConfigContext.StubStatuses, stubStatuses...) + } - plusAPIs := ncp.crossplaneConfigTraverseAPIDetails( - ctx, &conf, ncp.apiCallback, plusAPIDirective, - ) - if plusAPIs != nil { - nginxConfigContext.PlusAPIs = append(nginxConfigContext.PlusAPIs, plusAPIs...) + plusAPIs := ncp.crossplaneConfigTraverseAPIDetails( + ctx, &conf, ncp.apiCallback, plusAPIDirective, + ) + if plusAPIs != nil { + nginxConfigContext.PlusAPIs = append(nginxConfigContext.PlusAPIs, plusAPIs...) + } } fileMeta, err := files.FileMeta(conf.File) @@ -300,13 +303,52 @@ func (ncp *NginxConfigParser) createNginxConfigContext( "server configured on port %s", ncp.agentConfig.SyslogServer.Port)) } - nginxConfigContext.PlusAPIs = ncp.sortPlusAPIs(ctx, nginxConfigContext.PlusAPIs) - nginxConfigContext.StubStatus = ncp.FindStubStatusAPI(ctx, nginxConfigContext) - nginxConfigContext.PlusAPI = ncp.FindPlusAPI(ctx, nginxConfigContext) + if !ncp.agentConfig.IsNginxApiUrlConfigured() { + nginxConfigContext.PlusAPIs = ncp.sortPlusAPIs(ctx, nginxConfigContext.PlusAPIs) + nginxConfigContext.StubStatus = ncp.FindStubStatusAPI(ctx, nginxConfigContext) + nginxConfigContext.PlusAPI = ncp.FindPlusAPI(ctx, nginxConfigContext) + } else { + nginxConfigContext = ncp.addApiToNginxConfigContext(ctx, nginxConfigContext) + } return nginxConfigContext, nil } +func (ncp *NginxConfigParser) addApiToNginxConfigContext( + ctx context.Context, + nginxConfigContext *model.NginxConfigContext, +) *model.NginxConfigContext { + apiDetails, err := parseURL(ncp.agentConfig.DataPlaneConfig.Nginx.API.URL) + if err != nil { + slog.ErrorContext( + ctx, + "Configured NGINX API URL is invalid", + "url", ncp.agentConfig.DataPlaneConfig.Nginx.API.URL, + "error", err, + ) + + return nginxConfigContext + } + + if ncp.agentConfig.IsNginxApiSocketConfigured() { + apiDetails.Listen = ncp.agentConfig.DataPlaneConfig.Nginx.API.Socket + } + + if ncp.pingAPIEndpoint(ctx, apiDetails, stubStatusAPIDirective) { + nginxConfigContext.StubStatus = apiDetails + } else if ncp.pingAPIEndpoint(ctx, apiDetails, plusAPIDirective) { + nginxConfigContext.PlusAPI = apiDetails + } else { + slog.WarnContext( + ctx, + "Configured NGINX API URL is not reachable", + "url", ncp.agentConfig.DataPlaneConfig.Nginx.API.URL, + ) + } + + return nginxConfigContext +} + func (ncp *NginxConfigParser) findLocalSysLogServers(sysLogServer string) string { re := regexp.MustCompile(`syslog:server=([\S]+)`) matches := re.FindStringSubmatch(sysLogServer) @@ -886,24 +928,26 @@ 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, + if ncp.agentConfig.IsNginxApiConfigured() { + caCertLocation := ncp.agentConfig.DataPlaneConfig.Nginx.API.TLS.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, + }, }, - }, + } } } @@ -912,15 +956,19 @@ func (ncp *NginxConfigParser) prepareHTTPClient(ctx context.Context) (*http.Clie // Populate the CA cert location based ondirectory allowance. func (ncp *NginxConfigParser) selfSignedCACertLocation(ctx context.Context) string { - caCertLocation := ncp.agentConfig.DataPlaneConfig.Nginx.APITls.Ca + if ncp.agentConfig.IsNginxApiConfigured() { + caCertLocation := ncp.agentConfig.DataPlaneConfig.Nginx.API.TLS.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 "" + 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 } - return caCertLocation + return "" } func (ncp *NginxConfigParser) isDuplicateFile(nginxConfigContextFiles []*mpi.File, newFile *mpi.File) bool { @@ -976,3 +1024,16 @@ func (ncp *NginxConfigParser) sortPlusAPIs(ctx context.Context, apis []*model.AP return apis } + +func parseURL(unparsedUrl string) (*model.APIDetails, error) { + parsedURL, err := url.Parse(unparsedUrl) + if err != nil { + return nil, err + } + + return &model.APIDetails{ + URL: unparsedUrl, + Listen: parsedURL.Host, + Location: parsedURL.Path, + }, nil +} diff --git a/internal/datasource/config/nginx_config_parser_test.go b/internal/datasource/config/nginx_config_parser_test.go index 37e915ab38..0ccf5bd612 100644 --- a/internal/datasource/config/nginx_config_parser_test.go +++ b/internal/datasource/config/nginx_config_parser_test.go @@ -9,8 +9,10 @@ import ( "bytes" "context" "fmt" + "net" "net/http" "net/http/httptest" + "net/url" "os" "sort" "testing" @@ -19,6 +21,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" + "github.com/nginx/agent/v3/internal/config" "github.com/nginx/agent/v3/internal/model" "github.com/nginx/agent/v3/pkg/files" "github.com/nginx/agent/v3/test/stub" @@ -731,6 +734,117 @@ func TestNginxConfigParser_SyslogServerParse(t *testing.T) { } } +func TestNginxConfigParser_PlusAPIParse(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + + // Create fake HTTP server for NGINX Plus API + handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/api" || req.URL.Path == "/api/" { + // Return a mock NGINX Plus API response + data := []byte(`[1,2,3,4,5,6,7,8]`) + rw.Header().Set("Content-Type", "application/json") + _, err := rw.Write(data) + assert.NoError(t, err) + } else { + rw.WriteHeader(http.StatusNotFound) + } + }) + + fakeServer := httptest.NewServer(handler) + defer fakeServer.Close() + + fakeServerUrl, err := url.Parse(fakeServer.URL) + require.NoError(t, err) + + // Create a unix socket listener for testing unix socket + socketFile, err := os.CreateTemp("/tmp", "nginx-plus-api-*.sock") + require.NoError(t, err) + socket := socketFile.Name() + require.NoError(t, socketFile.Close()) + require.NoError(t, os.Remove(socket)) + defer os.Remove(socket) + + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "unix", socket) + require.NoError(t, err) + defer listener.Close() + + server := httptest.NewUnstartedServer(handler) + server.Listener = listener + server.Start() + defer server.Close() + + file := helpers.CreateFileWithErrorCheck(t, dir, "nginx-parse-config.conf") + defer helpers.RemoveFileWithErrorCheck(t, file.Name()) + + errorLog := helpers.CreateFileWithErrorCheck(t, dir, "error.log") + defer helpers.RemoveFileWithErrorCheck(t, errorLog.Name()) + + accessLog := helpers.CreateFileWithErrorCheck(t, dir, "access.log") + defer helpers.RemoveFileWithErrorCheck(t, accessLog.Name()) + + instance := protos.NginxPlusInstance([]string{}) + instance.InstanceRuntime.ConfigPath = file.Name() + + agentConfigWithOverride := types.AgentConfig() + agentConfigWithOverride.DataPlaneConfig.Nginx.API = &config.NginxAPI{ + URL: fmt.Sprintf("http://localhost:%s/api/", fakeServerUrl.Port()), + } + agentConfigWithUnixOverride := types.AgentConfig() + agentConfigWithUnixOverride.DataPlaneConfig.Nginx.API = &config.NginxAPI{ + URL: "http://localhost/api/", + Socket: "unix:" + socket, + } + + tests := []struct { + agentConfig *config.Config + name string + content string + url string + listen string + }{ + { + name: "Test 1: No override of Plus API URL in agent config", + content: testconfig.NginxConfigWithPlusAPI(fakeServerUrl.Port()), + url: "http://localhost:" + fakeServerUrl.Port() + "/api/", + listen: "localhost:" + fakeServerUrl.Port(), + agentConfig: types.AgentConfig(), + }, + { + name: "Test 2: Override Plus API URL in agent config", + content: testconfig.NginxConfigWithPlusAPI("8080"), + url: "http://localhost:" + fakeServerUrl.Port() + "/api/", + listen: "localhost:" + fakeServerUrl.Port(), + agentConfig: agentConfigWithOverride, + }, + { + name: "Test 3: Override Plus API URL in agent config with unix socket", + content: testconfig.NginxConfigWithPlusAPI("8080"), + url: "http://localhost/api/", + listen: "unix:" + socket, + agentConfig: agentConfigWithUnixOverride, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + agentConfig := test.agentConfig + agentConfig.AllowedDirectories = []string{dir} + nginxConfig := NewNginxConfigParser(agentConfig) + + writeErr := os.WriteFile(file.Name(), []byte(test.content), 0o600) + require.NoError(t, writeErr) + + result, parseError := nginxConfig.Parse(ctx, instance) + require.NoError(t, parseError) + + assert.Equal(t, test.url, result.PlusAPI.URL) + assert.Equal(t, test.listen, result.PlusAPI.Listen) + assert.Equal(t, "/api/", result.PlusAPI.Location) + }) + } +} + func TestNginxConfigParser_findValidSysLogServers(t *testing.T) { servers := []string{ "syslog:server=192.168.12.34:1517", "syslog:server=my.domain.com:1517", "syslog:server=127.0.0.1:1514", diff --git a/test/config/nginx/nginx-plus-api.conf b/test/config/nginx/nginx-plus-api.conf new file mode 100644 index 0000000000..95810b781e --- /dev/null +++ b/test/config/nginx/nginx-plus-api.conf @@ -0,0 +1,48 @@ +worker_processes 1; +error_log /var/log/nginx/error.log; + +events { + worker_connections 1024; +} + +http { + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + '"$bytes_sent" "$request_length" "$request_time" ' + '"$gzip_ratio" $server_protocol '; + + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + + server { + listen %s; + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + } + + ## + # Enable Metrics + ## + location /api/ { + api write=on; + allow 127.0.0.1; + deny all; + status_zone my_location_zone1; + } + + # redirect server error pages to the static page /50x.html + # + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + } +} diff --git a/test/config/nginx_config.go b/test/config/nginx_config.go index 8eba52461e..8cedc4163f 100644 --- a/test/config/nginx_config.go +++ b/test/config/nginx_config.go @@ -28,6 +28,9 @@ var embedNginxConfWithMultipleSSLCerts string //go:embed nginx/nginx-ssl-certs-with-variables.conf var embedNginxConfWithSSLCertsWithVariables string +//go:embed nginx/nginx-plus-api.conf +var embedNginxConfWithPlusAPI string + //go:embed agent/nginx-agent-with-token.conf var agentConfigWithToken string @@ -82,6 +85,10 @@ func NginxConfigWithMultipleSSLCerts(errorLogFile, accessLogFile, certFile1, cer return fmt.Sprintf(embedNginxConfWithMultipleSSLCerts, errorLogFile, accessLogFile, certFile1, certFile2) } +func NginxConfigWithPlusAPI(port string) string { + return fmt.Sprintf(embedNginxConfWithPlusAPI, port) +} + func AgentConfigWithToken(value, path string) string { return fmt.Sprintf(agentConfigWithToken, value, path) }