diff --git a/pkg/config/setup/system_probe.go b/pkg/config/setup/system_probe.go index 492a6f853276d..8053233311656 100644 --- a/pkg/config/setup/system_probe.go +++ b/pkg/config/setup/system_probe.go @@ -230,6 +230,7 @@ func InitSystemProbeConfig(cfg pkgconfigmodel.Config) { cfg.BindEnvAndSetDefault(join(smNS, "enable_http2_monitoring"), false) cfg.BindEnvAndSetDefault(join(smNS, "enable_kafka_monitoring"), false) cfg.BindEnvAndSetDefault(join(smNS, "tls", "istio", "enabled"), false) + cfg.BindEnv(join(smNS, "tls", "nodejs", "enabled")) cfg.BindEnvAndSetDefault(join(smjtNS, "enabled"), false) cfg.BindEnvAndSetDefault(join(smjtNS, "debug"), false) cfg.BindEnvAndSetDefault(join(smjtNS, "args"), defaultServiceMonitoringJavaAgentArgs) diff --git a/pkg/network/config/config.go b/pkg/network/config/config.go index 8713ce3f9facc..3db323a8b2986 100644 --- a/pkg/network/config/config.go +++ b/pkg/network/config/config.go @@ -90,6 +90,9 @@ type Config struct { // EnableIstioMonitoring specifies whether USM should monitor Istio traffic EnableIstioMonitoring bool + // EnableNodeJSMonitoring specifies whether USM should monitor NodeJS TLS traffic + EnableNodeJSMonitoring bool + // EnableGoTLSSupport specifies whether the tracer should monitor HTTPS // traffic done through Go's standard library's TLS implementation EnableGoTLSSupport bool @@ -333,6 +336,7 @@ func New() *Config { EnableKafkaMonitoring: cfg.GetBool(join(smNS, "enable_kafka_monitoring")), EnableNativeTLSMonitoring: cfg.GetBool(join(smNS, "tls", "native", "enabled")), EnableIstioMonitoring: cfg.GetBool(join(smNS, "tls", "istio", "enabled")), + EnableNodeJSMonitoring: cfg.GetBool(join(smNS, "tls", "nodejs", "enabled")), MaxUSMConcurrentRequests: uint32(cfg.GetInt(join(smNS, "max_concurrent_requests"))), MaxHTTPStatsBuffered: cfg.GetInt(join(smNS, "max_http_stats_buffered")), MaxKafkaStatsBuffered: cfg.GetInt(join(smNS, "max_kafka_stats_buffered")), diff --git a/pkg/network/config/config_test.go b/pkg/network/config/config_test.go index 9c3480401e170..84beea16064b7 100644 --- a/pkg/network/config/config_test.go +++ b/pkg/network/config/config_test.go @@ -1286,6 +1286,33 @@ service_monitoring_config: }) } +func TestNodeJSMonitoring(t *testing.T) { + t.Run("default value", func(t *testing.T) { + aconfig.ResetSystemProbeConfig(t) + cfg := New() + assert.False(t, cfg.EnableNodeJSMonitoring) + }) + + t.Run("via yaml", func(t *testing.T) { + aconfig.ResetSystemProbeConfig(t) + cfg := configurationFromYAML(t, ` +service_monitoring_config: + tls: + nodejs: + enabled: true +`) + assert.True(t, cfg.EnableNodeJSMonitoring) + }) + + t.Run("via deprecated ENV variable", func(t *testing.T) { + aconfig.ResetSystemProbeConfig(t) + t.Setenv("DD_SERVICE_MONITORING_CONFIG_TLS_NODEJS_ENABLED", "true") + + cfg := New() + assert.True(t, cfg.EnableNodeJSMonitoring) + }) +} + func TestMaxUSMConcurrentRequests(t *testing.T) { t.Run("default value", func(t *testing.T) { aconfig.ResetSystemProbeConfig(t) diff --git a/pkg/network/ebpf/c/protocols/tls/native-tls.h b/pkg/network/ebpf/c/protocols/tls/native-tls.h index f046f499c0b1f..42776de78e009 100644 --- a/pkg/network/ebpf/c/protocols/tls/native-tls.h +++ b/pkg/network/ebpf/c/protocols/tls/native-tls.h @@ -154,6 +154,11 @@ int istio_uretprobe__SSL_read(struct pt_regs *ctx) { return SSL_read_ret(ctx, ISTIO); } +SEC("uretprobe/SSL_read") +int nodejs_uretprobe__SSL_read(struct pt_regs *ctx) { + return SSL_read_ret(ctx, NODEJS); +} + SEC("uprobe/SSL_write") int uprobe__SSL_write(struct pt_regs* ctx) { ssl_write_args_t args = {0}; @@ -209,6 +214,11 @@ int istio_uretprobe__SSL_write(struct pt_regs* ctx) { return SSL_write_ret(ctx, ISTIO); } +SEC("uretprobe/SSL_write") +int nodejs_uretprobe__SSL_write(struct pt_regs* ctx) { + return SSL_write_ret(ctx, NODEJS); +} + SEC("uprobe/SSL_read_ex") int uprobe__SSL_read_ex(struct pt_regs* ctx) { ssl_read_ex_args_t args = {0}; @@ -224,8 +234,7 @@ int uprobe__SSL_read_ex(struct pt_regs* ctx) { return 0; } -SEC("uretprobe/SSL_read_ex") -int uretprobe__SSL_read_ex(struct pt_regs* ctx) { +static __always_inline int SSL_read_ex_ret(struct pt_regs* ctx, __u64 tags) { u64 pid_tgid = bpf_get_current_pid_tgid(); const int return_code = (int)PT_REGS_RC(ctx); if (return_code != 1) { @@ -267,13 +276,23 @@ int uretprobe__SSL_read_ex(struct pt_regs* ctx) { // We want to guarantee write-TLS hooks generates the same connection tuple, while read-TLS hooks generate // the inverse direction, thus we're normalizing the tuples into a client <-> server direction. normalize_tuple(©); - tls_process(ctx, ©, buffer_ptr, bytes_count, LIBSSL); + tls_process(ctx, ©, buffer_ptr, bytes_count, tags); return 0; cleanup: bpf_map_delete_elem(&ssl_read_ex_args, &pid_tgid); return 0; } +SEC("uretprobe/SSL_read_ex") +int uretprobe__SSL_read_ex(struct pt_regs* ctx, __u64 tags) { + return SSL_read_ex_ret(ctx, LIBSSL); +} + +SEC("uretprobe/SSL_read_ex") +int nodejs_uretprobe__SSL_read_ex(struct pt_regs *ctx) { + return SSL_read_ex_ret(ctx, NODEJS); +} + SEC("uprobe/SSL_write_ex") int uprobe__SSL_write_ex(struct pt_regs* ctx) { ssl_write_ex_args_t args = {0}; @@ -286,8 +305,7 @@ int uprobe__SSL_write_ex(struct pt_regs* ctx) { return 0; } -SEC("uretprobe/SSL_write_ex") -int uretprobe__SSL_write_ex(struct pt_regs* ctx) { +static __always_inline int SSL_write_ex_ret(struct pt_regs* ctx, __u64 tags) { u64 pid_tgid = bpf_get_current_pid_tgid(); const int return_code = (int)PT_REGS_RC(ctx); if (return_code != 1) { @@ -328,13 +346,23 @@ int uretprobe__SSL_write_ex(struct pt_regs* ctx) { // to the server <-> client direction. normalize_tuple(©); flip_tuple(©); - tls_process(ctx, ©, buffer_ptr, bytes_count, LIBSSL); + tls_process(ctx, ©, buffer_ptr, bytes_count, tags); return 0; cleanup: bpf_map_delete_elem(&ssl_write_ex_args, &pid_tgid); return 0; } +SEC("uretprobe/SSL_write_ex") +int uretprobe__SSL_write_ex(struct pt_regs* ctx) { + return SSL_write_ex_ret(ctx, LIBSSL); +} + +SEC("uretprobe/SSL_write_ex") +int nodejs_uretprobe__SSL_write_ex(struct pt_regs *ctx) { + return SSL_write_ex_ret(ctx, NODEJS); +} + SEC("uprobe/SSL_shutdown") int uprobe__SSL_shutdown(struct pt_regs *ctx) { void *ssl_ctx = (void *)PT_REGS_PARM1(ctx); diff --git a/pkg/network/ebpf/c/protocols/tls/tags-types.h b/pkg/network/ebpf/c/protocols/tls/tags-types.h index 2fcca31dfa4b7..913a806d626a1 100644 --- a/pkg/network/ebpf/c/protocols/tls/tags-types.h +++ b/pkg/network/ebpf/c/protocols/tls/tags-types.h @@ -10,6 +10,7 @@ enum static_tags { JAVA_TLS = (1<<3), CONN_TLS = (1<<4), ISTIO = (1<<5), + NODEJS = (1<<6), }; #endif diff --git a/pkg/network/protocols/http/testutil/pythonserver.go b/pkg/network/protocols/http/testutil/pythonserver.go index 5b4a72ebe7346..ccd2b6566a014 100644 --- a/pkg/network/protocols/http/testutil/pythonserver.go +++ b/pkg/network/protocols/http/testutil/pythonserver.go @@ -31,7 +31,10 @@ class RequestHandler(http.server.BaseHTTPRequestHandler): daemon_threads = True def do_GET(self): - status_code = int(self.path.split("/")[1]) + path = self.path + if self.path.startswith("/status"): + path = self.path.split("/status")[1] + status_code = int(path.split("/")[1]) self.send_response(status_code) self.send_header('Content-type', 'application/octet-stream') self.send_header('Content-Length', '0') diff --git a/pkg/network/protocols/http/tls_counter.go b/pkg/network/protocols/http/tls_counter.go index c26290935cae7..bd9461bc73f3d 100644 --- a/pkg/network/protocols/http/tls_counter.go +++ b/pkg/network/protocols/http/tls_counter.go @@ -14,23 +14,25 @@ import ( // TLSCounter is a TLS aware counter, it has a plain counter and a counter for each TLS library // It enables the use of a single metric that increments based on the TLS library, avoiding the need for separate metrics for each TLS library type TLSCounter struct { - counterPlain *libtelemetry.Counter - counterGnuTLS *libtelemetry.Counter - counterOpenSLL *libtelemetry.Counter - counterJavaTLS *libtelemetry.Counter - counterGoTLS *libtelemetry.Counter - counterIstioTLS *libtelemetry.Counter + counterPlain *libtelemetry.Counter + counterGnuTLS *libtelemetry.Counter + counterOpenSLL *libtelemetry.Counter + counterJavaTLS *libtelemetry.Counter + counterGoTLS *libtelemetry.Counter + counterIstioTLS *libtelemetry.Counter + counterNodeJSTLS *libtelemetry.Counter } // NewTLSCounter creates and returns a new instance of TLSCounter func NewTLSCounter(metricGroup *libtelemetry.MetricGroup, metricName string, tags ...string) *TLSCounter { return &TLSCounter{ // tls_library:none is a must, as prometheus metrics must have the same cardinality of tags - counterPlain: metricGroup.NewCounter(metricName, append(tags, "encrypted:false", "tls_library:none")...), - counterGnuTLS: metricGroup.NewCounter(metricName, append(tags, "encrypted:true", "tls_library:gnutls")...), - counterOpenSLL: metricGroup.NewCounter(metricName, append(tags, "encrypted:true", "tls_library:openssl")...), - counterJavaTLS: metricGroup.NewCounter(metricName, append(tags, "encrypted:true", "tls_library:java")...), - counterGoTLS: metricGroup.NewCounter(metricName, append(tags, "encrypted:true", "tls_library:go")...), - counterIstioTLS: metricGroup.NewCounter(metricName, append(tags, "encrypted:true", "tls_library:istio")...), + counterPlain: metricGroup.NewCounter(metricName, append(tags, "encrypted:false", "tls_library:none")...), + counterGnuTLS: metricGroup.NewCounter(metricName, append(tags, "encrypted:true", "tls_library:gnutls")...), + counterOpenSLL: metricGroup.NewCounter(metricName, append(tags, "encrypted:true", "tls_library:openssl")...), + counterJavaTLS: metricGroup.NewCounter(metricName, append(tags, "encrypted:true", "tls_library:java")...), + counterGoTLS: metricGroup.NewCounter(metricName, append(tags, "encrypted:true", "tls_library:go")...), + counterIstioTLS: metricGroup.NewCounter(metricName, append(tags, "encrypted:true", "tls_library:istio")...), + counterNodeJSTLS: metricGroup.NewCounter(metricName, append(tags, "encrypted:true", "tls_library:nodejs")...), } } diff --git a/pkg/network/protocols/http/tls_counter_linux.go b/pkg/network/protocols/http/tls_counter_linux.go index 6925314111628..46745f641b8b4 100644 --- a/pkg/network/protocols/http/tls_counter_linux.go +++ b/pkg/network/protocols/http/tls_counter_linux.go @@ -20,6 +20,8 @@ func (t *TLSCounter) Add(tx Transaction) { t.counterGoTLS.Add(1) case Istio: t.counterIstioTLS.Add(1) + case NodeJS: + t.counterNodeJSTLS.Add(1) default: t.counterPlain.Add(1) } diff --git a/pkg/network/protocols/http/types.go b/pkg/network/protocols/http/types.go index 1718c50b72f71..13620a9bfcba8 100644 --- a/pkg/network/protocols/http/types.go +++ b/pkg/network/protocols/http/types.go @@ -34,6 +34,7 @@ const ( Java ConnTag = C.JAVA_TLS TLS ConnTag = C.CONN_TLS Istio ConnTag = C.ISTIO + NodeJS ConnTag = C.NODEJS ) var ( @@ -44,5 +45,6 @@ var ( Java: "tls.library:java", TLS: "tls.connection:encrypted", Istio: "tls.library:istio", + NodeJS: "tls.library:nodejs", } ) diff --git a/pkg/network/protocols/http/types_linux.go b/pkg/network/protocols/http/types_linux.go index 035aa420c1c44..0f219b6de1a9c 100644 --- a/pkg/network/protocols/http/types_linux.go +++ b/pkg/network/protocols/http/types_linux.go @@ -52,6 +52,7 @@ const ( Java ConnTag = 0x8 TLS ConnTag = 0x10 Istio ConnTag = 0x20 + NodeJS ConnTag = 0x40 ) var ( @@ -62,5 +63,6 @@ var ( Java: "tls.library:java", TLS: "tls.connection:encrypted", Istio: "tls.library:istio", + NodeJS: "tls.library:nodejs", } ) diff --git a/pkg/network/protocols/testutil/serverutils.go b/pkg/network/protocols/testutil/serverutils.go index a94f972a1c394..a8f2fd9ab7de7 100644 --- a/pkg/network/protocols/testutil/serverutils.go +++ b/pkg/network/protocols/testutil/serverutils.go @@ -6,10 +6,13 @@ package testutil import ( + "bytes" "context" "fmt" "os/exec" "regexp" + "strconv" + "strings" "testing" "time" @@ -21,6 +24,19 @@ const ( DefaultTimeout = time.Minute ) +// GetDockerPID returns the PID of a docker container. +func GetDockerPID(dockerName string) (int64, error) { + // Ensuring no previous instances exists. + c := exec.Command("docker", "inspect", "-f", "{{.State.Pid}}", dockerName) + var stdout, stderr bytes.Buffer + c.Stdout = &stdout + c.Stderr = &stderr + if err := c.Run(); err != nil { + return 0, fmt.Errorf("failed to get %s pid: %s", dockerName, stderr.String()) + } + return strconv.ParseInt(strings.TrimSpace(stdout.String()), 10, 64) +} + // RunDockerServer is a template for running a protocols server in a docker. // - serverName is a friendly name of the server we are setting (AMQP, mongo, etc.). // - dockerPath is the path for the docker-compose. diff --git a/pkg/network/protocols/tls/nodejs/nodejs.go b/pkg/network/protocols/tls/nodejs/nodejs.go new file mode 100644 index 0000000000000..5d2e225829131 --- /dev/null +++ b/pkg/network/protocols/tls/nodejs/nodejs.go @@ -0,0 +1,68 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +// Package nodejs provides helpers to run nodejs HTTPs server. +package nodejs + +import ( + "io" + "os" + "regexp" + "testing" + + "github.com/DataDog/datadog-agent/pkg/network/protocols/http/testutil" + protocolsUtils "github.com/DataDog/datadog-agent/pkg/network/protocols/testutil" +) + +func copyFile(src, dst string) error { + source, err := os.Open(src) + if err != nil { + return err + } + defer source.Close() + + destination, err := os.Create(dst) + if err != nil { + return err + } + defer destination.Close() + + _, err = io.Copy(destination, source) + return err +} + +func linkFile(t *testing.T, src, dst string) error { + t.Helper() + _ = os.Remove(dst) + if err := copyFile(src, dst); err != nil { + return err + } + t.Cleanup(func() { os.Remove(dst) }) + return nil +} + +// RunServerNodeJS launches an HTTPs server written in NodeJS. +func RunServerNodeJS(t *testing.T, key, cert, serverPort string) error { + t.Helper() + dir, _ := testutil.CurDir() + if err := linkFile(t, key, dir+"/testdata/certs/srv.key"); err != nil { + return err + } + if err := linkFile(t, cert, dir+"/testdata/certs/srv.crt"); err != nil { + return err + } + env := []string{ + "ADDR=0.0.0.0", + "PORT=" + serverPort, + "CERTS_DIR=/v/certs", + "TESTDIR=" + dir + "/testdata", + } + return protocolsUtils.RunDockerServer(t, "nodejs-server", dir+"/testdata/docker-compose.yml", env, regexp.MustCompile("Server running at https.*"), protocolsUtils.DefaultTimeout, 3) +} + +// GetNodeJSDockerPID returns the PID of the nodejs docker container. +func GetNodeJSDockerPID() (int64, error) { + return protocolsUtils.GetDockerPID("node-node-1") +} diff --git a/pkg/network/protocols/tls/nodejs/testdata/certs/.keep b/pkg/network/protocols/tls/nodejs/testdata/certs/.keep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/pkg/network/protocols/tls/nodejs/testdata/docker-compose.yml b/pkg/network/protocols/tls/nodejs/testdata/docker-compose.yml new file mode 100644 index 0000000000000..b9d5fc005d53c --- /dev/null +++ b/pkg/network/protocols/tls/nodejs/testdata/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3' +name: node +services: + node: + image: node:lts-alpine3.19 + command: ["node", "/v/server.js"] + ports: + - ${PORT}:4141 + environment: + - ADDR + - CERTS_DIR + volumes: + - ${TESTDIR}:/v:z diff --git a/pkg/network/protocols/tls/nodejs/testdata/server.js b/pkg/network/protocols/tls/nodejs/testdata/server.js new file mode 100644 index 0000000000000..a1289e74f86da --- /dev/null +++ b/pkg/network/protocols/tls/nodejs/testdata/server.js @@ -0,0 +1,44 @@ +const https = require('https'); +const fs = require('fs'); +const url = require('url'); + +// Read the SSL certificate and private key +const options = { + key: fs.readFileSync(`${process.env.CERTS_DIR}/srv.key`), + cert: fs.readFileSync(`${process.env.CERTS_DIR}/srv.crt`) +}; + +// Create the HTTPS echo server +const server = https.createServer(options, (req, res) => { + const parsedUrl = url.parse(req.url, true); + const pathname = parsedUrl.pathname; + let statusCode = 200; + console.log(`Request received: ${req.method} ${pathname}`) + if (pathname.startsWith('/status/')) { + const newStatusCode = parseInt(pathname.split('/')[2]); + if (!isNaN(newStatusCode)) { + statusCode = newStatusCode; + } + } + + let body = ''; + + req.on('data', (chunk) => { + body += chunk; + }); + + req.on('end', () => { + let headers = {} + if (req.headers['content-type'] !== undefined) { + headers['Content-Type'] = req.headers['content-type'] + } + res.writeHead(statusCode, headers); + res.end(body); + }); +}); + +const port = 4141 +// Start the server +server.listen(port, () => { + console.log(`Server running at https://${process.env.ADDR}:${port}/`); +}); diff --git a/pkg/network/tags_linux.go b/pkg/network/tags_linux.go index 79d3ecde3d321..f3f08ec235fcc 100644 --- a/pkg/network/tags_linux.go +++ b/pkg/network/tags_linux.go @@ -24,6 +24,8 @@ const ( ConnTagTLS = http.TLS //nolint:revive // TODO(NET) Fix revive linter ConnTagIstio = http.Istio + // ConnTagNodeJS is the tag for NodeJS TLS connections + ConnTagNodeJS = http.NodeJS ) // GetStaticTags return the string list of static tags from network.ConnectionStats.Tags @@ -38,5 +40,5 @@ func GetStaticTags(staticTags uint64) (tags []string) { //nolint:revive // TODO(NET) Fix revive linter func IsTLSTag(staticTags uint64) bool { - return staticTags&(ConnTagGnuTLS|ConnTagOpenSSL|ConnTagGo|ConnTagJava|ConnTagTLS|ConnTagIstio) > 0 + return staticTags&(ConnTagGnuTLS|ConnTagOpenSSL|ConnTagGo|ConnTagJava|ConnTagTLS|ConnTagIstio|ConnTagNodeJS) > 0 } diff --git a/pkg/network/usm/ebpf_ssl.go b/pkg/network/usm/ebpf_ssl.go index 27de81f39f88b..965f9f44ab2eb 100644 --- a/pkg/network/usm/ebpf_ssl.go +++ b/pkg/network/usm/ebpf_ssl.go @@ -417,14 +417,15 @@ var opensslSpec = &protocols.ProtocolSpec{ } type sslProgram struct { - cfg *config.Config - watcher *sharedlibraries.Watcher - istioMonitor *istioMonitor + cfg *config.Config + watcher *sharedlibraries.Watcher + istioMonitor *istioMonitor + nodeJSMonitor *nodeJSMonitor } func newSSLProgramProtocolFactory(m *manager.Manager) protocols.ProtocolFactory { return func(c *config.Config) (protocols.Protocol, error) { - if (!c.EnableNativeTLSMonitoring || !http.TLSSupported(c)) && !c.EnableIstioMonitoring { + if (!c.EnableNativeTLSMonitoring || !http.TLSSupported(c)) && !c.EnableIstioMonitoring && !c.EnableNodeJSMonitoring { return nil, nil } @@ -459,9 +460,10 @@ func newSSLProgramProtocolFactory(m *manager.Manager) protocols.ProtocolFactory } return &sslProgram{ - cfg: c, - watcher: watcher, - istioMonitor: newIstioMonitor(c, m), + cfg: c, + watcher: watcher, + istioMonitor: newIstioMonitor(c, m), + nodeJSMonitor: newNodeJSMonitor(c, m), }, nil } } @@ -480,6 +482,7 @@ func (o *sslProgram) ConfigureOptions(_ *manager.Manager, options *manager.Optio func (o *sslProgram) PreStart(*manager.Manager) error { o.watcher.Start() o.istioMonitor.Start() + o.nodeJSMonitor.Start() return nil } @@ -490,6 +493,7 @@ func (o *sslProgram) PostStart(*manager.Manager) error { func (o *sslProgram) Stop(*manager.Manager) { o.watcher.Stop() o.istioMonitor.Stop() + o.nodeJSMonitor.Stop() } func (o *sslProgram) DumpMaps(w io.Writer, mapName string, currentMap *ebpf.Map) { diff --git a/pkg/network/usm/monitor.go b/pkg/network/usm/monitor.go index 8d549a4ae8cff..7c9b7ae032174 100644 --- a/pkg/network/usm/monitor.go +++ b/pkg/network/usm/monitor.go @@ -139,7 +139,7 @@ func (m *Monitor) Start() error { } // Need to explicitly save the error in `err` so the defer function could save the startup error. - if m.cfg.EnableNativeTLSMonitoring || m.cfg.EnableGoTLSSupport || m.cfg.EnableJavaTLSSupport || m.cfg.EnableIstioMonitoring { + if m.cfg.EnableNativeTLSMonitoring || m.cfg.EnableGoTLSSupport || m.cfg.EnableJavaTLSSupport || m.cfg.EnableIstioMonitoring || m.cfg.EnableNodeJSMonitoring { err = m.processMonitor.Initialize() } diff --git a/pkg/network/usm/monitor_tls_test.go b/pkg/network/usm/monitor_tls_test.go index f4be865166e24..db698b7d70381 100644 --- a/pkg/network/usm/monitor_tls_test.go +++ b/pkg/network/usm/monitor_tls_test.go @@ -36,6 +36,7 @@ import ( "github.com/DataDog/datadog-agent/pkg/network/protocols/http2" gotlstestutil "github.com/DataDog/datadog-agent/pkg/network/protocols/tls/gotls/testutil" javatestutil "github.com/DataDog/datadog-agent/pkg/network/protocols/tls/java/testutil" + "github.com/DataDog/datadog-agent/pkg/network/protocols/tls/nodejs" nettestutil "github.com/DataDog/datadog-agent/pkg/network/testutil" usmtestutil "github.com/DataDog/datadog-agent/pkg/network/usm/testutil" "github.com/DataDog/datadog-agent/pkg/network/usm/utils" @@ -387,7 +388,7 @@ func simpleGetRequestsGenerator(t *testing.T, targetAddr string) (*nethttp.Clien return client, func() *nethttp.Request { idx++ status := statusCodes[random.Intn(len(statusCodes))] - req, err := nethttp.NewRequest(nethttp.MethodGet, fmt.Sprintf("https://%s/%d/request-%d", targetAddr, status, idx), nil) + req, err := nethttp.NewRequest(nethttp.MethodGet, fmt.Sprintf("https://%s/status/%d/request-%d", targetAddr, status, idx), nil) require.NoError(t, err) resp, err := client.Do(req) @@ -780,3 +781,72 @@ func getHTTPLikeProtocolStats(monitor *Monitor, protocolType protocols.ProtocolT } return res } + +func (s *tlsSuite) TestNodeJSTLS() { + const ( + expectedOccurrences = 10 + serverPort = "4444" + ) + + t := s.T() + + cert, key, err := testutil.GetCertsPaths() + require.NoError(t, err) + + require.NoError(t, nodejs.RunServerNodeJS(t, key, cert, serverPort)) + nodeJSPID, err := nodejs.GetNodeJSDockerPID() + require.NoError(t, err) + + cfg := config.New() + cfg.EnableHTTPMonitoring = true + cfg.EnableNodeJSMonitoring = true + + usmMonitor := setupUSMTLSMonitor(t, cfg) + utils.WaitForProgramsToBeTraced(t, "nodejs", int(nodeJSPID)) + + // This maps will keep track of whether the tracer saw this request already or not + client, requestFn := simpleGetRequestsGenerator(t, fmt.Sprintf("localhost:%s", serverPort)) + var requests []*nethttp.Request + for i := 0; i < expectedOccurrences; i++ { + requests = append(requests, requestFn()) + } + + client.CloseIdleConnections() + requestsExist := make([]bool, len(requests)) + + assert.Eventually(t, func() bool { + stats := getHTTPLikeProtocolStats(usmMonitor, protocols.HTTP) + if stats == nil { + return false + } + + if len(stats) == 0 { + return false + } + + for reqIndex, req := range requests { + if !requestsExist[reqIndex] { + requestsExist[reqIndex] = isRequestIncluded(stats, req) + } + } + + // Slight optimization here, if one is missing, then go into another cycle of checking the new connections. + // otherwise, if all present, abort. + for reqIndex, exists := range requestsExist { + if !exists { + // reqIndex is 0 based, while the number is requests[reqIndex] is 1 based. + t.Logf("request %d was not found (req %v)", reqIndex+1, requests[reqIndex]) + return false + } + } + + return true + }, 3*time.Second, 100*time.Millisecond, "connection not found") + + for reqIndex, exists := range requestsExist { + if !exists { + // reqIndex is 0 based, while the number is requests[reqIndex] is 1 based. + t.Logf("request %d was not found (req %v)", reqIndex+1, requests[reqIndex]) + } + } +} diff --git a/pkg/network/usm/nodejs.go b/pkg/network/usm/nodejs.go new file mode 100644 index 0000000000000..78081ccb98dc7 --- /dev/null +++ b/pkg/network/usm/nodejs.go @@ -0,0 +1,277 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +//go:build linux_bpf + +package usm + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + manager "github.com/DataDog/ebpf-manager" + + "github.com/DataDog/datadog-agent/pkg/network/config" + "github.com/DataDog/datadog-agent/pkg/network/usm/utils" + "github.com/DataDog/datadog-agent/pkg/process/monitor" + "github.com/DataDog/datadog-agent/pkg/util/kernel" + "github.com/DataDog/datadog-agent/pkg/util/log" +) + +const ( + nodeJSPath = "/bin/node" + + nodejsSslReadRetprobe = "nodejs_uretprobe__SSL_read" + nodejsSslReadExRetprobe = "nodejs_uretprobe__SSL_read_ex" + nodejsSslWriteRetprobe = "nodejs_uretprobe__SSL_write" + nodejsSslWriteExRetprobe = "nodejs_uretprobe__SSL_write_ex" +) + +var ( + nodeJSProbes = []manager.ProbesSelector{ + &manager.AllOf{ + Selectors: []manager.ProbesSelector{ + &manager.ProbeSelector{ + ProbeIdentificationPair: manager.ProbeIdentificationPair{ + EBPFFuncName: sslDoHandshakeProbe, + }, + }, + &manager.ProbeSelector{ + ProbeIdentificationPair: manager.ProbeIdentificationPair{ + EBPFFuncName: sslDoHandshakeRetprobe, + }, + }, + &manager.ProbeSelector{ + ProbeIdentificationPair: manager.ProbeIdentificationPair{ + EBPFFuncName: sslSetBioProbe, + }, + }, + &manager.ProbeSelector{ + ProbeIdentificationPair: manager.ProbeIdentificationPair{ + EBPFFuncName: sslSetFDProbe, + }, + }, + &manager.ProbeSelector{ + ProbeIdentificationPair: manager.ProbeIdentificationPair{ + EBPFFuncName: bioNewSocketProbe, + }, + }, + &manager.ProbeSelector{ + ProbeIdentificationPair: manager.ProbeIdentificationPair{ + EBPFFuncName: bioNewSocketRetprobe, + }, + }, + &manager.ProbeSelector{ + ProbeIdentificationPair: manager.ProbeIdentificationPair{ + EBPFFuncName: sslReadProbe, + }, + }, + &manager.ProbeSelector{ + ProbeIdentificationPair: manager.ProbeIdentificationPair{ + EBPFFuncName: nodejsSslReadRetprobe, + }, + }, + &manager.ProbeSelector{ + ProbeIdentificationPair: manager.ProbeIdentificationPair{ + EBPFFuncName: nodejsSslReadExRetprobe, + }, + }, + &manager.ProbeSelector{ + ProbeIdentificationPair: manager.ProbeIdentificationPair{ + EBPFFuncName: sslWriteProbe, + }, + }, + &manager.ProbeSelector{ + ProbeIdentificationPair: manager.ProbeIdentificationPair{ + EBPFFuncName: nodejsSslWriteRetprobe, + }, + }, + &manager.ProbeSelector{ + ProbeIdentificationPair: manager.ProbeIdentificationPair{ + EBPFFuncName: nodejsSslWriteExRetprobe, + }, + }, + &manager.ProbeSelector{ + ProbeIdentificationPair: manager.ProbeIdentificationPair{ + EBPFFuncName: sslShutdownProbe, + }, + }, + }, + }, + } +) + +// nodejsMonitor essentially scans for Node processes and attaches SSL uprobes +// to them. +type nodeJSMonitor struct { + registry *utils.FileRegistry + procRoot string + + // `utils.FileRegistry` callbacks + registerCB func(utils.FilePath) error + unregisterCB func(utils.FilePath) error + + // Termination + wg sync.WaitGroup + done chan struct{} +} + +func newNodeJSMonitor(c *config.Config, mgr *manager.Manager) *nodeJSMonitor { + if !c.EnableNodeJSMonitoring { + return nil + } + + procRoot := kernel.ProcFSRoot() + return &nodeJSMonitor{ + registry: utils.NewFileRegistry("nodejs"), + procRoot: procRoot, + done: make(chan struct{}), + + // Callbacks + registerCB: addHooks(mgr, procRoot, nodeJSProbes), + unregisterCB: removeHooks(mgr, nodeJSProbes), + } +} + +func (m *nodeJSMonitor) Start() { + if m == nil { + return + } + + processMonitor := monitor.GetProcessMonitor() + + // Subscribe to process events + doneExec := processMonitor.SubscribeExec(m.handleProcessExec) + doneExit := processMonitor.SubscribeExit(m.handleProcessExit) + + // Attach to existing processes + m.sync() + + m.wg.Add(1) + go func() { + // This ticker is responsible for controlling the rate at which + // we scrape the whole procFS again in order to ensure that we + // terminate any dangling uprobes and register new processes + // missed by the process monitor stream + processSync := time.NewTicker(scanTerminatedProcessesInterval) + + defer func() { + processSync.Stop() + // Execute process monitor callback termination functions + doneExec() + doneExit() + // Stopping the process monitor (if we're the last instance) + processMonitor.Stop() + // Cleaning up all active hooks + m.registry.Clear() + // marking we're finished. + m.wg.Done() + }() + + for { + select { + case <-m.done: + return + case <-processSync.C: + m.sync() + m.registry.Log() + } + } + }() + + log.Info("Node JS TLS monitoring enabled") +} + +func (m *nodeJSMonitor) Stop() { + if m == nil { + return + } + + close(m.done) + m.wg.Wait() +} + +// sync state of nodeJSMonitor with the current state of procFS +// the purpose of this method is two-fold: +// 1) register processes for which we missed exec events (targeted mostly at startup) +// 2) unregister processes for which we missed exit events +func (m *nodeJSMonitor) sync() { + deletionCandidates := m.registry.GetRegisteredProcesses() + + _ = kernel.WithAllProcs(m.procRoot, func(pid int) error { + if _, ok := deletionCandidates[uint32(pid)]; ok { + // We have previously hooked into this process and it remains active, + // so we remove it from the deletionCandidates list, and move on to the next PID + delete(deletionCandidates, uint32(pid)) + return nil + } + + // This is a new PID so we attempt to attach SSL probes to it + m.handleProcessExec(uint32(pid)) + return nil + }) + + // At this point all entries from deletionCandidates are no longer alive, so + // we should dettach our SSL probes from them + for pid := range deletionCandidates { + m.handleProcessExit(pid) + } +} + +func (m *nodeJSMonitor) handleProcessExec(pid uint32) { + path := m.getNodeJSPath(pid) + if path == "" { + return + } + + m.registry.Register( + path, + pid, + m.registerCB, + m.unregisterCB, + ) +} + +func (m *nodeJSMonitor) handleProcessExit(pid uint32) { + // We avoid filtering PIDs here because it's cheaper to simply do a registry lookup + // instead of fetching a process name in order to determine whether it is an + // envoy process or not (which at the very minimum involves syscalls) + m.registry.Unregister(pid) +} + +// getNodeJSPath returns the executable path of the nodejs binary for a given PID. +// In case the PID doesn't represent a nodejs process, an empty string is returned. +func (m *nodeJSMonitor) getNodeJSPath(pid uint32) string { + pidAsStr := strconv.FormatUint(uint64(pid), 10) + exePath := filepath.Join(m.procRoot, pidAsStr, "exe") + + binPath, err := os.Readlink(exePath) + if err != nil { + // We receive the Exec event, /proc could be slow to update + end := time.Now().Add(10 * time.Millisecond) + for end.After(time.Now()) { + binPath, err = os.Readlink(exePath) + if err == nil { + break + } + time.Sleep(time.Millisecond) + } + } + if err != nil { + // we can't access to the binary path here (pid probably ended already) + // there are not much we can do, and we don't want to flood the logs + return "" + } + + if strings.Contains(binPath, nodeJSPath) { + return binPath + } + + return "" +} diff --git a/releasenotes/notes/usm-capture-tls-from-nodejs-e280a9aa6fca3304.yaml b/releasenotes/notes/usm-capture-tls-from-nodejs-e280a9aa6fca3304.yaml new file mode 100644 index 0000000000000..340d5b46c9594 --- /dev/null +++ b/releasenotes/notes/usm-capture-tls-from-nodejs-e280a9aa6fca3304.yaml @@ -0,0 +1,12 @@ +# Each section from every release note are combined when the +# CHANGELOG.rst is rendered. So the text needs to be worded so that +# it does not depend on any information only available in another +# section. This may mean repeating some details, but each section +# must be readable independently of the other. +# +# Each section note must be formatted as reStructuredText. +--- +features: + - | + USM now captures TLS traffic from NodeJS applications. +