diff --git a/plugins/inputs/all/ldap.go b/plugins/inputs/all/ldap.go new file mode 100644 index 0000000000000..28c00d819ca6c --- /dev/null +++ b/plugins/inputs/all/ldap.go @@ -0,0 +1,5 @@ +//go:build !custom || inputs || inputs.ldap + +package all + +import _ "github.com/influxdata/telegraf/plugins/inputs/ldap" // register plugin diff --git a/plugins/inputs/ldap/389ds.go b/plugins/inputs/ldap/389ds.go new file mode 100644 index 0000000000000..7371637fa6a5a --- /dev/null +++ b/plugins/inputs/ldap/389ds.go @@ -0,0 +1,114 @@ +package ldap + +import ( + "strconv" + "strings" + "time" + + "github.com/go-ldap/ldap/v3" + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/metric" +) + +// Empty mappings are identity mappings +var attrMap389ds = map[string]string{ + "addentryops": "add_operations", + "anonymousbinds": "anonymous_binds", + "bindsecurityerrors": "bind_security_errors", + "bytesrecv": "bytes_received", + "bytessent": "bytes_sent", + "cacheentries": "cache_entries", + "cachehits": "cache_hits", + "chainings": "", + "compareops": "compare_operations", + "connections": "", + "connectionsinmaxthreads": "connections_in_max_threads", + "connectionsmaxthreadscount": "connections_max_threads", + "copyentries": "copy_entries", + "currentconnections": "current_connections", + "currentconnectionsatmaxthreads": "current_connections_at_max_threads", + "dtablesize": "", + "entriesreturned": "entries_returned", + "entriessent": "entries_sent", + "errors": "", + "inops": "in_operations", + "listops": "list_operations", + "removeentryops": "delete_operations", + "masterentries": "master_entries", + "maxthreadsperconnhits": "maxthreads_per_conn_hits", + "modifyentryops": "modify_operations", + "modifyrdnops": "modrdn_operations", + "nbackends": "backends", + "onelevelsearchops": "onelevel_search_operations", + "opscompleted": "operations_completed", + "opsinitiated": "operations_initiated", + "readops": "read_operations", + "readwaiters": "read_waiters", + "referrals": "referrals", + "referralsreturned": "referrals_returned", + "searchops": "search_operations", + "securityerrors": "security_errors", + "simpleauthbinds": "simpleauth_binds", + "slavehits": "slave_hits", + "strongauthbinds": "strongauth_binds", + "threads": "", + "totalconnections": "total_connections", + "unauthbinds": "unauth_binds", + "wholesubtreesearchops": "wholesubtree_search_operations", +} + +func (l *LDAP) new389dsConfig() []request { + attributes := make([]string, 0, len(attrMap389ds)) + for k := range attrMap389ds { + attributes = append(attributes, k) + } + + req := ldap.NewSearchRequest( + "cn=Monitor", + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 0, + 0, + false, + "(objectClass=*)", + attributes, + nil, + ) + return []request{{req, l.convert389ds}} +} + +func (l *LDAP) convert389ds(result *ldap.SearchResult, ts time.Time) []telegraf.Metric { + tags := map[string]string{ + "server": l.host, + "port": l.port, + } + fields := make(map[string]interface{}) + for _, entry := range result.Entries { + for _, attr := range entry.Attributes { + if len(attr.Values[0]) == 0 { + continue + } + // Map the attribute-name to the field-name + name := attrMap389ds[attr.Name] + if name == "" { + name = attr.Name + } + // Reverse the name if requested + if l.ReverseFieldNames { + parts := strings.Split(name, "_") + for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { + parts[i], parts[j] = parts[j], parts[i] + } + name = strings.Join(parts, "_") + } + + // Convert the number + if v, err := strconv.ParseInt(attr.Values[0], 10, 64); err == nil { + fields[name] = v + } + } + } + + m := metric.New("389ds", tags, fields, ts) + return []telegraf.Metric{m} +} diff --git a/plugins/inputs/ldap/README.md b/plugins/inputs/ldap/README.md new file mode 100644 index 0000000000000..3aa38d49cfe39 --- /dev/null +++ b/plugins/inputs/ldap/README.md @@ -0,0 +1,81 @@ +# LDAP Input Plugin + +This plugin gathers metrics from LDAP servers' monitoring (`cn=Monitor`) +backend. Currently this plugin supports [OpenLDAP](https://www.openldap.org/) +and [389ds](https://www.port389.org/) servers. + +## Global configuration options + +In addition to the plugin-specific configuration settings, plugins support +additional global and plugin configuration settings. These settings are used to +modify metrics, tags, and field or create aliases and configure ordering, etc. +See the [CONFIGURATION.md][CONFIGURATION.md] for more details. + +[CONFIGURATION.md]: ../../../docs/CONFIGURATION.md#plugins + +## Configuration + +```toml @sample.conf +# LDAP monitoring plugin +[[inputs.openldap]] + ## Server to monitor + ## The scheme determines the mode to use for connection with + ## ldap://... -- unencrypted (non-TLS) connection + ## ldaps://... -- TLS connection + ## starttls://... -- StartTLS connection + ## If no port is given, the default ports, 389 for ldap and starttls and + ## 636 for ldaps, are used. + server = "ldap://localhost" + + ## Server dialect, can be "openldap" or "389ds" + # dialect = "openldap" + + # DN and password to bind with + ## If bind_dn is empty an anonymous bind is performed. + bind_dn = "" + bind_password = "" + + ## Reverse the field names constructed from the monitoring DN + # reverse_field_names = false + + ## Optional TLS Config + ## Trusted root certificates for server + # tls_ca = "/path/to/cafile" + ## Used for TLS client certificate authentication + # tls_cert = "/path/to/certfile" + ## Used for TLS client certificate authentication + # tls_key = "/path/to/keyfile" + ## Send the specified TLS server name via SNI + # tls_server_name = "kubernetes.example.com" + ## Use TLS but skip chain & host verification + # insecure_skip_verify = false +``` + +To use this plugin you must enable the monitoring backend/plugin of your LDAP +server. See +[OpenLDAP](https://www.openldap.org/devel/admin/monitoringslapd.html) or 389ds +documentation for details. + +## Metrics + +Depending on the server dialect, different metrics are produced. The metrics +are usually named according to the selected dialect. + +### Tags + +- server -- Server name or IP +- port -- Port used for connecting + +## Example Output + +Using the `openldap` dialect + +```text +openldap,server=localhost,port=389 operations_bind_initiated=10i,operations_unbind_initiated=6i,operations_modrdn_completed=0i,operations_delete_initiated=0i,operations_add_completed=2i,operations_delete_completed=0i,operations_abandon_completed=0i,statistics_entries=1516i,threads_open=2i,threads_active=1i,waiters_read=1i,operations_modify_completed=0i,operations_extended_initiated=4i,threads_pending=0i,operations_search_initiated=36i,operations_compare_initiated=0i,connections_max_file_descriptors=4096i,operations_modify_initiated=0i,operations_modrdn_initiated=0i,threads_max=16i,time_uptime=6017i,connections_total=1037i,connections_current=1i,operations_add_initiated=2i,statistics_bytes=162071i,operations_unbind_completed=6i,operations_abandon_initiated=0i,statistics_pdu=1566i,threads_max_pending=0i,threads_backload=1i,waiters_write=0i,operations_bind_completed=10i,operations_search_completed=35i,operations_compare_completed=0i,operations_extended_completed=4i,statistics_referrals=0i,threads_starting=0i 1516912070000000000 +``` + +Using the `389ds` dialect + +```text +389ds,port=32805,server=localhost add_operations=0i,anonymous_binds=0i,backends=0i,bind_security_errors=0i,bytes_received=0i,bytes_sent=256i,cache_entries=0i,cache_hits=0i,chainings=0i,compare_operations=0i,connections=1i,connections_in_max_threads=0i,connections_max_threads=0i,copy_entries=0i,current_connections=1i,current_connections_at_max_threads=0i,delete_operations=0i,dtablesize=63936i,entries_returned=2i,entries_sent=2i,errors=2i,in_operations=11i,list_operations=0i,maxthreads_per_conn_hits=0i,modify_operations=1i,modrdn_operations=0i,onelevel_search_operations=0i,operations_completed=10i,operations_initiated=11i,read_operations=0i,read_waiters=0i,referrals=0i,referrals_returned=0i,search_operations=3i,security_errors=0i,simpleauth_binds=1i,strongauth_binds=2i,threads=17i,total_connections=4i,unauth_binds=0i,wholesubtree_search_operations=1i 1695637234047087280 +``` diff --git a/plugins/inputs/ldap/ldap.go b/plugins/inputs/ldap/ldap.go new file mode 100644 index 0000000000000..68e3dee85e56c --- /dev/null +++ b/plugins/inputs/ldap/ldap.go @@ -0,0 +1,178 @@ +//go:generate ../../../tools/readme_config_includer/generator +package ldap + +import ( + "crypto/tls" + _ "embed" + "fmt" + "net/url" + "time" + + "github.com/go-ldap/ldap/v3" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/config" + commontls "github.com/influxdata/telegraf/plugins/common/tls" + "github.com/influxdata/telegraf/plugins/inputs" +) + +//go:embed sample.conf +var sampleConfig string + +type LDAP struct { + Server string `toml:"server"` + Dialect string `toml:"dialect"` + BindDn string `toml:"bind_dn"` + BindPassword config.Secret `toml:"bind_password"` + ReverseFieldNames bool `toml:"reverse_field_names"` + commontls.ClientConfig + + tlsCfg *tls.Config + requests []request + mode string + host string + port string +} + +type request struct { + query *ldap.SearchRequest + convert func(*ldap.SearchResult, time.Time) []telegraf.Metric +} + +func (*LDAP) SampleConfig() string { + return sampleConfig +} + +func (l *LDAP) Init() error { + if l.Server == "" { + l.Server = "ldap://localhost:389" + } + + u, err := url.Parse(l.Server) + if err != nil { + return fmt.Errorf("parsing server failed: %w", err) + } + + // Verify the server setting and set the defaults + var tlsEnable bool + switch u.Scheme { + case "ldap": + if u.Port() == "" { + u.Host = u.Host + ":389" + } + tlsEnable = false + case "starttls": + if u.Port() == "" { + u.Host = u.Host + ":389" + } + tlsEnable = true + case "ldaps": + if u.Port() == "" { + u.Host = u.Host + ":636" + } + tlsEnable = true + default: + return fmt.Errorf("invalid scheme: %q", u.Scheme) + } + l.mode = u.Scheme + l.Server = u.Host + l.host, l.port = u.Hostname(), u.Port() + + // Force TLS depending on the selected mode + l.ClientConfig.Enable = &tlsEnable + + // Setup TLS configuration + tlsCfg, err := l.ClientConfig.TLSConfig() + if err != nil { + return fmt.Errorf("creating TLS config failed: %w", err) + } + l.tlsCfg = tlsCfg + + // Initialize the search request(s) + switch l.Dialect { + case "", "openldap": + l.requests = l.newOpenLDAPConfig() + case "389ds": + l.requests = l.new389dsConfig() + default: + return fmt.Errorf("invalid dialect %q", l.Dialect) + } + + return nil +} + +func (l *LDAP) Gather(acc telegraf.Accumulator) error { + // Connect + conn, err := l.connect() + if err != nil { + return fmt.Errorf("connection failed: %w", err) + } + defer conn.Close() + + // Query the server + for _, req := range l.requests { + now := time.Now() + result, err := conn.Search(req.query) + if err != nil { + acc.AddError(err) + continue + } + + // Collect metrics + for _, m := range req.convert(result, now) { + acc.AddMetric(m) + } + } + + return nil +} + +func (l *LDAP) connect() (*ldap.Conn, error) { + var conn *ldap.Conn + switch l.mode { + case "ldap": + var err error + conn, err = ldap.Dial("tcp", l.Server) + if err != nil { + return nil, err + } + case "ldaps": + var err error + conn, err = ldap.DialTLS("tcp", l.Server, l.tlsCfg) + if err != nil { + return nil, err + } + case "starttls": + var err error + conn, err = ldap.Dial("tcp", l.Server) + if err != nil { + return nil, err + } + if err := conn.StartTLS(l.tlsCfg); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("invalid tls_mode: %s", l.mode) + } + + if l.BindDn == "" && l.BindPassword.Empty() { + return conn, nil + } + + // Bind username and password + passwd, err := l.BindPassword.Get() + if err != nil { + return nil, fmt.Errorf("getting password failed: %w", err) + } + defer passwd.Destroy() + + if err := conn.Bind(l.BindDn, passwd.String()); err != nil { + return nil, fmt.Errorf("binding credentials failed: %w", err) + } + + return conn, nil +} + +func init() { + inputs.Add("ldap", func() telegraf.Input { return &LDAP{} }) +} diff --git a/plugins/inputs/ldap/ldap_test.go b/plugins/inputs/ldap/ldap_test.go new file mode 100644 index 0000000000000..fb4ad777af122 --- /dev/null +++ b/plugins/inputs/ldap/ldap_test.go @@ -0,0 +1,573 @@ +package ldap + +import ( + "testing" + "time" + + "github.com/docker/go-connections/nat" + "github.com/go-ldap/ldap/v3" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/config" + "github.com/influxdata/telegraf/metric" + commontls "github.com/influxdata/telegraf/plugins/common/tls" + "github.com/influxdata/telegraf/testutil" +) + +const ( + servicePortOpenLDAP = "1389" + servicePortOpenLDAPSecure = "1636" + + servicePort389DS = "3389" + servicePort389DSSecure = "3636" +) + +func TestMockResult(t *testing.T) { + // mock a query result + mockSearchResult := &ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: "cn=Total,cn=Connections,cn=Monitor", + Attributes: []*ldap.EntryAttribute{{Name: "monitorCounter", Values: []string{"1"}}}, + }, + }, + Referrals: []string{}, + Controls: []ldap.Control{}, + } + + // Setup the plugin + plugin := &LDAP{} + require.NoError(t, plugin.Init()) + + // Setup the expectations + expected := []telegraf.Metric{ + metric.New( + "openldap", + map[string]string{ + "server": "localhost", + "port": "389", + }, + map[string]interface{}{ + "total_connections": int64(1), + }, + time.Unix(0, 0), + ), + } + + // Retrieve the converter + requests := plugin.newOpenLDAPConfig() + require.Len(t, requests, 1) + converter := requests[0].convert + require.NotNil(t, converter) + + // Test metric conversion + actual := converter(mockSearchResult, time.Unix(0, 0)) + testutil.RequireMetricsEqual(t, expected, actual) +} + +func TestInvalidTLSMode(t *testing.T) { + plugin := &LDAP{ + Server: "foo://localhost", + } + require.ErrorContains(t, plugin.Init(), "invalid scheme") +} + +func TestNoConnection(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Setup the plugin + plugin := &LDAP{Server: "ldap://nosuchhost"} + require.NoError(t, plugin.Init()) + + // Collect the metrics and compare + var acc testutil.Accumulator + require.ErrorContains(t, plugin.Gather(&acc), "connection failed") + require.Empty(t, acc.GetTelegrafMetrics()) +} + +func TestOpenLDAPIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Start the docker container + container := testutil.Container{ + Image: "bitnami/openldap", + ExposedPorts: []string{servicePortOpenLDAP}, + Env: map[string]string{ + "LDAP_ADMIN_USERNAME": "manager", + "LDAP_ADMIN_PASSWORD": "secret", + }, + WaitingFor: wait.ForAll( + wait.ForLog("slapd starting"), + wait.ForListeningPort(nat.Port(servicePortOpenLDAP)), + ), + } + require.NoError(t, container.Start(), "failed to start container") + defer container.Terminate() + + // Setup the plugin + port := container.Ports[servicePortOpenLDAP] + plugin := &LDAP{ + Server: "ldap://" + container.Address + ":" + port, + BindDn: "CN=manager,DC=example,DC=org", + BindPassword: config.NewSecret([]byte("secret")), + } + require.NoError(t, plugin.Init()) + + // Setup the expectations + expected := []telegraf.Metric{ + metric.New( + "openldap", + map[string]string{ + "server": container.Address, + "port": port, + }, + map[string]interface{}{ + "abandon_operations_completed": int64(0), + "abandon_operations_initiated": int64(0), + "active_threads": int64(0), + "add_operations_completed": int64(0), + "add_operations_initiated": int64(0), + "backload_threads": int64(0), + "bind_operations_completed": int64(0), + "bind_operations_initiated": int64(0), + "bytes_statistics": int64(0), + "compare_operations_completed": int64(0), + "compare_operations_initiated": int64(0), + "current_connections": int64(0), + "delete_operations_completed": int64(0), + "delete_operations_initiated": int64(0), + "entries_statistics": int64(0), + "extended_operations_completed": int64(0), + "extended_operations_initiated": int64(0), + "max_file_descriptors_connections": int64(0), + "max_pending_threads": int64(0), + "max_threads": int64(0), + "modify_operations_completed": int64(0), + "modify_operations_initiated": int64(0), + "modrdn_operations_completed": int64(0), + "modrdn_operations_initiated": int64(0), + "open_threads": int64(0), + "pdu_statistics": int64(0), + "pending_threads": int64(0), + "read_waiters": int64(0), + "referrals_statistics": int64(0), + "search_operations_completed": int64(0), + "search_operations_initiated": int64(0), + "starting_threads": int64(0), + "total_connections": int64(0), + "unbind_operations_completed": int64(0), + "unbind_operations_initiated": int64(0), + "uptime_time": int64(0), + "write_waiters": int64(0), + }, + time.Unix(0, 0), + ), + } + + // Collect the metrics and compare + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + + actual := acc.GetTelegrafMetrics() + testutil.RequireMetricsStructureEqual(t, expected, actual, testutil.IgnoreTime()) +} + +func TestOpenLDAPReverseDNIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Start the docker container + container := testutil.Container{ + Image: "bitnami/openldap", + ExposedPorts: []string{servicePortOpenLDAP}, + Env: map[string]string{ + "LDAP_ADMIN_USERNAME": "manager", + "LDAP_ADMIN_PASSWORD": "secret", + }, + WaitingFor: wait.ForAll( + wait.ForLog("slapd starting"), + wait.ForListeningPort(nat.Port(servicePortOpenLDAP)), + ), + } + require.NoError(t, container.Start(), "failed to start container") + defer container.Terminate() + + // Setup the plugin + port := container.Ports[servicePortOpenLDAP] + plugin := &LDAP{ + Server: "ldap://" + container.Address + ":" + port, + BindDn: "CN=manager,DC=example,DC=org", + BindPassword: config.NewSecret([]byte("secret")), + ReverseFieldNames: true, + } + require.NoError(t, plugin.Init()) + + // Setup the expectations + expected := []telegraf.Metric{ + metric.New( + "openldap", + map[string]string{ + "server": container.Address, + "port": port, + }, + map[string]interface{}{ + "connections_max_file_descriptors": int64(0), + "connections_total": int64(0), + "connections_current": int64(0), + "operations_bind_initiated": int64(0), + "operations_bind_completed": int64(0), + "operations_unbind_initiated": int64(0), + "operations_unbind_completed": int64(0), + "operations_search_initiated": int64(0), + "operations_search_completed": int64(0), + "operations_compare_initiated": int64(0), + "operations_compare_completed": int64(0), + "operations_modify_initiated": int64(0), + "operations_modify_completed": int64(0), + "operations_modrdn_initiated": int64(0), + "operations_modrdn_completed": int64(0), + "operations_add_initiated": int64(0), + "operations_add_completed": int64(0), + "operations_delete_initiated": int64(0), + "operations_delete_completed": int64(0), + "operations_abandon_initiated": int64(0), + "operations_abandon_completed": int64(0), + "operations_extended_initiated": int64(0), + "operations_extended_completed": int64(0), + "statistics_bytes": int64(0), + "statistics_pdu": int64(0), + "statistics_entries": int64(0), + "statistics_referrals": int64(0), + "threads_max": int64(0), + "threads_max_pending": int64(0), + "threads_open": int64(0), + "threads_starting": int64(0), + "threads_active": int64(0), + "threads_pending": int64(0), + "threads_backload": int64(0), + "time_uptime": int64(0), + "waiters_read": int64(0), + "waiters_write": int64(0), + }, + time.Unix(0, 0), + ), + } + + // Collect the metrics and compare + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + + actual := acc.GetTelegrafMetrics() + testutil.RequireMetricsStructureEqual(t, expected, actual, testutil.IgnoreTime()) +} + +func TestOpenLDAPStartTLSIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Setup PKI for TLS testing + pkiPaths, err := testutil.NewPKI("../../../testutil/pki").AbsolutePaths() + require.NoError(t, err) + + // Start the docker container + container := testutil.Container{ + Image: "bitnami/openldap", + ExposedPorts: []string{servicePortOpenLDAP}, + Env: map[string]string{ + "LDAP_ADMIN_USERNAME": "manager", + "LDAP_ADMIN_PASSWORD": "secret", + "LDAP_ENABLE_TLS": "yes", + "LDAP_TLS_CA_FILE": "server.pem", + "LDAP_TLS_CERT_FILE": "server.crt", + "LDAP_TLS_KEY_FILE": "server.key", + }, + BindMounts: map[string]string{ + "/server.pem": pkiPaths.ServerPem, + "/server.crt": pkiPaths.ServerCert, + "/server.key": pkiPaths.ServerKey, + }, + WaitingFor: wait.ForAll( + wait.ForLog("slapd starting"), + wait.ForListeningPort(nat.Port(servicePortOpenLDAP)), + ), + } + require.NoError(t, container.Start(), "failed to start container") + defer container.Terminate() + + // Setup the plugin + port := container.Ports[servicePortOpenLDAP] + plugin := &LDAP{ + Server: "starttls://" + container.Address + ":" + port, + BindDn: "CN=manager,DC=example,DC=org", + BindPassword: config.NewSecret([]byte("secret")), + ClientConfig: commontls.ClientConfig{ + TLSCA: pkiPaths.ClientCert, + InsecureSkipVerify: true, + }, + } + require.NoError(t, plugin.Init()) + + // Setup the expectations + expected := []telegraf.Metric{ + metric.New( + "openldap", + map[string]string{ + "server": container.Address, + "port": port, + }, + map[string]interface{}{ + "abandon_operations_completed": int64(0), + "abandon_operations_initiated": int64(0), + "active_threads": int64(0), + "add_operations_completed": int64(0), + "add_operations_initiated": int64(0), + "backload_threads": int64(0), + "bind_operations_completed": int64(0), + "bind_operations_initiated": int64(0), + "bytes_statistics": int64(0), + "compare_operations_completed": int64(0), + "compare_operations_initiated": int64(0), + "current_connections": int64(0), + "delete_operations_completed": int64(0), + "delete_operations_initiated": int64(0), + "entries_statistics": int64(0), + "extended_operations_completed": int64(0), + "extended_operations_initiated": int64(0), + "max_file_descriptors_connections": int64(0), + "max_pending_threads": int64(0), + "max_threads": int64(0), + "modify_operations_completed": int64(0), + "modify_operations_initiated": int64(0), + "modrdn_operations_completed": int64(0), + "modrdn_operations_initiated": int64(0), + "open_threads": int64(0), + "pdu_statistics": int64(0), + "pending_threads": int64(0), + "read_waiters": int64(0), + "referrals_statistics": int64(0), + "search_operations_completed": int64(0), + "search_operations_initiated": int64(0), + "starting_threads": int64(0), + "total_connections": int64(0), + "unbind_operations_completed": int64(0), + "unbind_operations_initiated": int64(0), + "uptime_time": int64(0), + "write_waiters": int64(0), + }, + time.Unix(0, 0), + ), + } + + // Collect the metrics and compare + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + + actual := acc.GetTelegrafMetrics() + testutil.RequireMetricsStructureEqual(t, expected, actual, testutil.IgnoreTime()) +} + +func TestOpenLDAPLDAPSIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Setup PKI for TLS testing + pkiPaths, err := testutil.NewPKI("../../../testutil/pki").AbsolutePaths() + require.NoError(t, err) + + // Start the docker container + container := testutil.Container{ + Image: "bitnami/openldap", + ExposedPorts: []string{servicePortOpenLDAPSecure}, + Env: map[string]string{ + "LDAP_ADMIN_USERNAME": "manager", + "LDAP_ADMIN_PASSWORD": "secret", + "LDAP_ENABLE_TLS": "yes", + "LDAP_TLS_CA_FILE": "server.pem", + "LDAP_TLS_CERT_FILE": "server.crt", + "LDAP_TLS_KEY_FILE": "server.key", + }, + BindMounts: map[string]string{ + "/server.pem": pkiPaths.ServerPem, + "/server.crt": pkiPaths.ServerCert, + "/server.key": pkiPaths.ServerKey, + }, + WaitingFor: wait.ForAll( + wait.ForLog("slapd starting"), + wait.ForListeningPort(nat.Port(servicePortOpenLDAPSecure)), + ), + } + require.NoError(t, container.Start(), "failed to start container") + defer container.Terminate() + + // Setup the plugin + port := container.Ports[servicePortOpenLDAPSecure] + plugin := &LDAP{ + Server: "ldaps://" + container.Address + ":" + port, + BindDn: "CN=manager,DC=example,DC=org", + BindPassword: config.NewSecret([]byte("secret")), + ClientConfig: commontls.ClientConfig{ + InsecureSkipVerify: true, + }, + } + require.NoError(t, plugin.Init()) + + // Setup the expectations + expected := []telegraf.Metric{ + metric.New( + "openldap", + map[string]string{ + "server": container.Address, + "port": port, + }, + map[string]interface{}{ + "abandon_operations_completed": int64(0), + "abandon_operations_initiated": int64(0), + "active_threads": int64(0), + "add_operations_completed": int64(0), + "add_operations_initiated": int64(0), + "backload_threads": int64(0), + "bind_operations_completed": int64(0), + "bind_operations_initiated": int64(0), + "bytes_statistics": int64(0), + "compare_operations_completed": int64(0), + "compare_operations_initiated": int64(0), + "current_connections": int64(0), + "delete_operations_completed": int64(0), + "delete_operations_initiated": int64(0), + "entries_statistics": int64(0), + "extended_operations_completed": int64(0), + "extended_operations_initiated": int64(0), + "max_file_descriptors_connections": int64(0), + "max_pending_threads": int64(0), + "max_threads": int64(0), + "modify_operations_completed": int64(0), + "modify_operations_initiated": int64(0), + "modrdn_operations_completed": int64(0), + "modrdn_operations_initiated": int64(0), + "open_threads": int64(0), + "pdu_statistics": int64(0), + "pending_threads": int64(0), + "read_waiters": int64(0), + "referrals_statistics": int64(0), + "search_operations_completed": int64(0), + "search_operations_initiated": int64(0), + "starting_threads": int64(0), + "total_connections": int64(0), + "unbind_operations_completed": int64(0), + "unbind_operations_initiated": int64(0), + "uptime_time": int64(0), + "write_waiters": int64(0), + }, + time.Unix(0, 0), + ), + } + + // Collect the metrics and compare + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + + actual := acc.GetTelegrafMetrics() + testutil.RequireMetricsStructureEqual(t, expected, actual, testutil.IgnoreTime()) +} + +func Test389dsIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Start the docker container + container := testutil.Container{ + Image: "389ds/dirsrv", + ExposedPorts: []string{servicePort389DS}, + Env: map[string]string{ + "DS_DM_PASSWORD": "secret", + }, + WaitingFor: wait.ForAll( + wait.ForLog("389-ds-container started"), + wait.ForListeningPort(nat.Port(servicePort389DS)), + ), + } + require.NoError(t, container.Start(), "failed to start container") + defer container.Terminate() + + // Setup the plugin + port := container.Ports[servicePort389DS] + plugin := &LDAP{ + Server: "ldap://" + container.Address + ":" + port, + Dialect: "389ds", + BindDn: "cn=Directory Manager", + BindPassword: config.NewSecret([]byte("secret")), + } + require.NoError(t, plugin.Init()) + + // Setup the expectations + expected := []telegraf.Metric{ + metric.New( + "389ds", + map[string]string{ + "server": container.Address, + "port": port, + }, + map[string]interface{}{ + "add_operations": int64(0), + "anonymous_binds": int64(0), + "backends": int64(0), + "bind_security_errors": int64(0), + "bytes_received": int64(0), + "bytes_sent": int64(0), + "cache_entries": int64(0), + "cache_hits": int64(0), + "chainings": int64(0), + "compare_operations": int64(0), + "connections": int64(0), + "connections_in_max_threads": int64(0), + "connections_max_threads": int64(0), + "copy_entries": int64(0), + "current_connections": int64(0), + "current_connections_at_max_threads": int64(0), + "delete_operations": int64(0), + "dtablesize": int64(0), + "entries_returned": int64(0), + "entries_sent": int64(0), + "errors": int64(0), + "in_operations": int64(0), + "list_operations": int64(0), + "maxthreads_per_conn_hits": int64(0), + "modify_operations": int64(0), + "modrdn_operations": int64(0), + "onelevel_search_operations": int64(0), + "operations_completed": int64(0), + "operations_initiated": int64(0), + "read_operations": int64(0), + "read_waiters": int64(0), + "referrals": int64(0), + "referrals_returned": int64(0), + "search_operations": int64(0), + "security_errors": int64(0), + "simpleauth_binds": int64(0), + "strongauth_binds": int64(0), + "threads": int64(0), + "total_connections": int64(0), + "unauth_binds": int64(0), + "wholesubtree_search_operations": int64(0), + }, + time.Unix(0, 0), + ), + } + + // Collect the metrics and compare + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + + actual := acc.GetTelegrafMetrics() + testutil.RequireMetricsStructureEqual(t, expected, actual, testutil.IgnoreTime()) +} diff --git a/plugins/inputs/ldap/openldap.go b/plugins/inputs/ldap/openldap.go new file mode 100644 index 0000000000000..eb94d92455270 --- /dev/null +++ b/plugins/inputs/ldap/openldap.go @@ -0,0 +1,88 @@ +package ldap + +import ( + "strconv" + "strings" + "time" + + "github.com/go-ldap/ldap/v3" + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/metric" +) + +var attrMapOpenLDAP = map[string]string{ + "monitorCounter": "", + "monitoredInfo": "", + "monitorOpInitiated": "_initiated", + "monitorOpCompleted": "_completed", + "olmMDBPagesMax": "_mdb_pages_max", + "olmMDBPagesUsed": "_mdb_pages_used", + "olmMDBPagesFree": "_mdb_pages_free", + "olmMDBReadersMax": "_mdb_readers_max", + "olmMDBReadersUsed": "_mdb_readers_used", + "olmMDBEntries": "_mdb_entries", +} + +func (l *LDAP) newOpenLDAPConfig() []request { + req := ldap.NewSearchRequest( + "cn=Monitor", + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 0, + 0, + false, + "(|(objectClass=monitorCounterObject)(objectClass=monitorOperation)(objectClass=monitoredObject))", + []string{"monitorCounter", "monitorOpInitiated", "monitorOpCompleted", "monitoredInfo"}, + nil, + ) + return []request{{req, l.convertOpenLDAP}} +} + +func (l *LDAP) convertOpenLDAP(result *ldap.SearchResult, ts time.Time) []telegraf.Metric { + tags := map[string]string{ + "server": l.host, + "port": l.port, + } + + fields := make(map[string]interface{}) + for _, entry := range result.Entries { + prefix := openLDAPAttrConvertDN(entry.DN, l.ReverseFieldNames) + for _, attr := range entry.Attributes { + if len(attr.Values[0]) == 0 { + continue + } + if v, err := strconv.ParseInt(attr.Values[0], 10, 64); err == nil { + fields[prefix+attrMapOpenLDAP[attr.Name]] = v + } + } + } + + m := metric.New("openldap", tags, fields, ts) + return []telegraf.Metric{m} +} + +// Convert a DN to a field prefix, eg cn=Read,cn=Waiters,cn=Monitor becomes waiters_read +// Assumes the last part of the DN is cn=Monitor and we want to drop it +func openLDAPAttrConvertDN(dn string, reverse bool) string { + // Normalize DN + prefix := strings.TrimSpace(dn) + prefix = strings.ToLower(prefix) + prefix = strings.ReplaceAll(prefix, " ", "_") + prefix = strings.ReplaceAll(prefix, "cn=", "") + + // Filter the base + parts := strings.Split(prefix, ",") + for i, p := range parts { + if p == "monitor" { + parts = append(parts[:i], parts[i+1:]...) + break + } + } + + if reverse { + for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { + parts[i], parts[j] = parts[j], parts[i] + } + } + return strings.Join(parts, "_") +} diff --git a/plugins/inputs/ldap/sample.conf b/plugins/inputs/ldap/sample.conf new file mode 100644 index 0000000000000..c5fc06c84c0b7 --- /dev/null +++ b/plugins/inputs/ldap/sample.conf @@ -0,0 +1,33 @@ +# LDAP monitoring plugin +[[inputs.openldap]] + ## Server to monitor + ## The scheme determines the mode to use for connection with + ## ldap://... -- unencrypted (non-TLS) connection + ## ldaps://... -- TLS connection + ## starttls://... -- StartTLS connection + ## If no port is given, the default ports, 389 for ldap and starttls and + ## 636 for ldaps, are used. + server = "ldap://localhost" + + ## Server dialect, can be "openldap" or "389ds" + # dialect = "openldap" + + # DN and password to bind with + ## If bind_dn is empty an anonymous bind is performed. + bind_dn = "" + bind_password = "" + + ## Reverse the field names constructed from the monitoring DN + # reverse_field_names = false + + ## Optional TLS Config + ## Trusted root certificates for server + # tls_ca = "/path/to/cafile" + ## Used for TLS client certificate authentication + # tls_cert = "/path/to/certfile" + ## Used for TLS client certificate authentication + # tls_key = "/path/to/keyfile" + ## Send the specified TLS server name via SNI + # tls_server_name = "kubernetes.example.com" + ## Use TLS but skip chain & host verification + # insecure_skip_verify = false diff --git a/testutil/tls.go b/testutil/tls.go index 13ce22069bd48..30cfd114f4f3a 100644 --- a/testutil/tls.go +++ b/testutil/tls.go @@ -5,10 +5,18 @@ import ( "io" "os" "path" + "path/filepath" "github.com/influxdata/telegraf/plugins/common/tls" ) +type PKIPaths struct { + ServerPem string + ServerCert string + ServerKey string + ClientCert string +} + type pki struct { keyPath string } @@ -112,6 +120,32 @@ func (p *pki) ServerCertAndKeyPath() string { return path.Join(p.keyPath, "server.pem") } +func (p *pki) AbsolutePaths() (*PKIPaths, error) { + tlsPem, err := filepath.Abs(p.ServerCertAndKeyPath()) + if err != nil { + return nil, err + } + tlsCert, err := filepath.Abs(p.ServerCertPath()) + if err != nil { + return nil, err + } + tlsKey, err := filepath.Abs(p.ServerKeyPath()) + if err != nil { + return nil, err + } + cert, err := filepath.Abs(p.ClientCertPath()) + if err != nil { + return nil, err + } + + return &PKIPaths{ + ServerPem: tlsPem, + ServerCert: tlsCert, + ServerKey: tlsKey, + ClientCert: cert, + }, nil +} + func readCertificate(filename string) string { file, err := os.Open(filename) if err != nil {