diff --git a/agent/metrics_agent.go b/agent/metrics_agent.go index 9b329357..d7fe5e2c 100644 --- a/agent/metrics_agent.go +++ b/agent/metrics_agent.go @@ -43,6 +43,7 @@ import ( _ "flashcat.cloud/categraf/inputs/kernel" _ "flashcat.cloud/categraf/inputs/kernel_vmstat" _ "flashcat.cloud/categraf/inputs/kubernetes" + _ "flashcat.cloud/categraf/inputs/ldap" _ "flashcat.cloud/categraf/inputs/linux_sysctl_fs" _ "flashcat.cloud/categraf/inputs/logstash" _ "flashcat.cloud/categraf/inputs/mem" diff --git a/conf/input.ldap/ldap.toml b/conf/input.ldap/ldap.toml new file mode 100644 index 00000000..06d26f6e --- /dev/null +++ b/conf/input.ldap/ldap.toml @@ -0,0 +1,37 @@ +# # collect interval +# interval = 15 + +[[instances]] +# # append some labels for series +# labels = { region="cloud", product="n9e" } + +# # interval = global.interval * interval_times +# interval_times = 1 + + ## 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 +# use_tls = false +# tls_ca = "/etc/categraf/ca.pem" +# tls_cert = "/etc/categraf/cert.pem" +# tls_key = "/etc/categraf/key.pem" +## Use TLS but skip chain & host verification +# insecure_skip_verify = false \ No newline at end of file diff --git a/inputs/ldap/389ds.go b/inputs/ldap/389ds.go new file mode 100644 index 00000000..22cca136 --- /dev/null +++ b/inputs/ldap/389ds.go @@ -0,0 +1,115 @@ +package ldap + +import ( + "strconv" + "strings" + "time" + + "github.com/go-ldap/ldap/v3" + + "flashcat.cloud/categraf/types" + "flashcat.cloud/categraf/types/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 (ins *Instance) 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, ins.convert389ds}} +} + +func (ins *Instance) convert389ds(result *ldap.SearchResult, ts time.Time) []types.Metric { + tags := map[string]string{ + "server": ins.host, + "port": ins.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 ins.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 []types.Metric{m} +} diff --git a/inputs/ldap/README.md b/inputs/ldap/README.md new file mode 100644 index 00000000..d075d568 --- /dev/null +++ b/inputs/ldap/README.md @@ -0,0 +1,114 @@ +# 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. + +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_modify_operations_completed agent_hostname=zy-fat port=389 server=localhost 0 +openldap_referrals_statistics agent_hostname=zy-fat port=389 server=localhost 0 +openldap_unbind_operations_initiated agent_hostname=zy-fat port=389 server=localhost 0 +openldap_delete_operations_completed agent_hostname=zy-fat port=389 server=localhost 0 +openldap_extended_operations_completed agent_hostname=zy-fat port=389 server=localhost 0 +openldap_pdu_statistics agent_hostname=zy-fat port=389 server=localhost 42 +openldap_starting_threads agent_hostname=zy-fat port=389 server=localhost 0 +openldap_active_threads agent_hostname=zy-fat port=389 server=localhost 1 +openldap_uptime_time agent_hostname=zy-fat port=389 server=localhost 102 +openldap_bytes_statistics agent_hostname=zy-fat port=389 server=localhost 3176 +openldap_compare_operations_completed agent_hostname=zy-fat port=389 server=localhost 0 +openldap_bind_operations_completed agent_hostname=zy-fat port=389 server=localhost 1 +openldap_total_connections agent_hostname=zy-fat port=389 server=localhost 1002 +openldap_search_operations_completed agent_hostname=zy-fat port=389 server=localhost 1 +openldap_abandon_operations_initiated agent_hostname=zy-fat port=389 server=localhost 0 +openldap_add_operations_initiated agent_hostname=zy-fat port=389 server=localhost 0 +openldap_open_threads agent_hostname=zy-fat port=389 server=localhost 1 +openldap_add_operations_completed agent_hostname=zy-fat port=389 server=localhost 0 +openldap_operations_initiated agent_hostname=zy-fat port=389 server=localhost 3 +openldap_write_waiters agent_hostname=zy-fat port=389 server=localhost 0 +openldap_entries_statistics agent_hostname=zy-fat port=389 server=localhost 41 +openldap_modrdn_operations_completed agent_hostname=zy-fat port=389 server=localhost 0 +openldap_pending_threads agent_hostname=zy-fat port=389 server=localhost 0 +openldap_max_pending_threads agent_hostname=zy-fat port=389 server=localhost 0 +openldap_bind_operations_initiated agent_hostname=zy-fat port=389 server=localhost 1 +openldap_max_file_descriptors_connections agent_hostname=zy-fat port=389 server=localhost 1024 +openldap_compare_operations_initiated agent_hostname=zy-fat port=389 server=localhost 0 +openldap_search_operations_initiated agent_hostname=zy-fat port=389 server=localhost 2 +openldap_modrdn_operations_initiated agent_hostname=zy-fat port=389 server=localhost 0 +openldap_read_waiters agent_hostname=zy-fat port=389 server=localhost 1 +openldap_backload_threads agent_hostname=zy-fat port=389 server=localhost 1 +openldap_current_connections agent_hostname=zy-fat port=389 server=localhost 1 +openldap_unbind_operations_completed agent_hostname=zy-fat port=389 server=localhost 0 +openldap_delete_operations_initiated agent_hostname=zy-fat port=389 server=localhost 0 +openldap_extended_operations_initiated agent_hostname=zy-fat port=389 server=localhost 0 +openldap_modify_operations_initiated agent_hostname=zy-fat port=389 server=localhost 0 +openldap_max_threads agent_hostname=zy-fat port=389 server=localhost 16 +openldap_abandon_operations_completed agent_hostname=zy-fat port=389 server=localhost 0 +openldap_operations_completed agent_hostname=zy-fat port=389 server=localhost 2 +openldap_database_2_databases agent_hostname=zy-fat port=389 server=localhost 0 +``` + +Using the `389ds` dialect + +```text +389ds_current_connections_at_max_threads agent_hostname=zy-fat port=389 server=localhost 0 +389ds_connections_max_threads agent_hostname=zy-fat port=389 server=localhost 0 +389ds_add_operations agent_hostname=zy-fat port=389 server=localhost 0 +389ds_dtablesize agent_hostname=zy-fat port=389 server=localhost 63936 +389ds_strongauth_binds agent_hostname=zy-fat port=389 server=localhost 13 +389ds_modrdn_operations agent_hostname=zy-fat port=389 server=localhost 0 +389ds_maxthreads_per_conn_hits agent_hostname=zy-fat port=389 server=localhost 0 +389ds_current_connections agent_hostname=zy-fat port=389 server=localhost 2 +389ds_security_errors agent_hostname=zy-fat port=389 server=localhost 0 +389ds_entries_sent agent_hostname=zy-fat port=389 server=localhost 13 +389ds_cache_entries agent_hostname=zy-fat port=389 server=localhost 0 +389ds_backends agent_hostname=zy-fat port=389 server=localhost 0 +389ds_threads agent_hostname=zy-fat port=389 server=localhost 17 +389ds_connections agent_hostname=zy-fat port=389 server=localhost 2 +389ds_read_operations agent_hostname=zy-fat port=389 server=localhost 0 +389ds_entries_returned agent_hostname=zy-fat port=389 server=localhost 13 +389ds_unauth_binds agent_hostname=zy-fat port=389 server=localhost 0 +389ds_search_operations agent_hostname=zy-fat port=389 server=localhost 14 +389ds_simpleauth_binds agent_hostname=zy-fat port=389 server=localhost 0 +389ds_operations_completed agent_hostname=zy-fat port=389 server=localhost 51 +389ds_connections_in_max_threads agent_hostname=zy-fat port=389 server=localhost 0 +389ds_modify_operations agent_hostname=zy-fat port=389 server=localhost 0 +389ds_wholesubtree_search_operations agent_hostname=zy-fat port=389 server=localhost 1 +389ds_read_waiters agent_hostname=zy-fat port=389 server=localhost 0 +389ds_compare_operations agent_hostname=zy-fat port=389 server=localhost 0 +389ds_errors agent_hostname=zy-fat port=389 server=localhost 13 +389ds_in_operations agent_hostname=zy-fat port=389 server=localhost 52 +389ds_total_connections agent_hostname=zy-fat port=389 server=localhost 15 +389ds_cache_hits agent_hostname=zy-fat port=389 server=localhost 0 +389ds_list_operations agent_hostname=zy-fat port=389 server=localhost 0 +389ds_referrals_returned agent_hostname=zy-fat port=389 server=localhost 0 +389ds_copy_entries agent_hostname=zy-fat port=389 server=localhost 0 +389ds_operations_initiated agent_hostname=zy-fat port=389 server=localhost 52 +389ds_chainings agent_hostname=zy-fat port=389 server=localhost 0 +389ds_bind_security_errors agent_hostname=zy-fat port=389 server=localhost 0 +389ds_onelevel_search_operations agent_hostname=zy-fat port=389 server=localhost 0 +389ds_bytes_sent agent_hostname=zy-fat port=389 server=localhost 1702 +389ds_bytes_received agent_hostname=zy-fat port=389 server=localhost 0 +389ds_referrals agent_hostname=zy-fat port=389 server=localhost 0 +389ds_delete_operations agent_hostname=zy-fat port=389 server=localhost 0 +389ds_anonymous_binds agent_hostname=zy-fat port=389 server=localhost 0 +``` + diff --git a/inputs/ldap/ldap.go b/inputs/ldap/ldap.go new file mode 100644 index 00000000..67240447 --- /dev/null +++ b/inputs/ldap/ldap.go @@ -0,0 +1,210 @@ +package ldap + +import ( + "crypto/tls" + "errors" + "fmt" + "log" + "net/url" + "time" + + "github.com/go-ldap/ldap/v3" + + commontls "flashcat.cloud/categraf/pkg/tls" + "flashcat.cloud/categraf/config" + "flashcat.cloud/categraf/inputs" + "flashcat.cloud/categraf/types" +) + +const inputName = "ldap" + +type LDAP struct { + config.PluginConfig + Instances []*Instance `toml:"instances"` +} + +type Instance struct { + config.InstanceConfig + 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) []types.Metric +} + +func (ins *Instance) Init() error { + if ins.Server == "" { + return types.ErrInstancesEmpty + //ins.Server = "ldap://localhost:389" + } + + u, err := url.Parse(ins.Server) + if err != nil { + return fmt.Errorf("parsing server failed: %w", err) + } + + // Verify the server setting and set the defaults + switch u.Scheme { + case "ldap": + if u.Port() == "" { + u.Host = u.Host + ":389" + } + ins.UseTLS = false + case "starttls": + if u.Port() == "" { + u.Host = u.Host + ":389" + } + ins.UseTLS = true + case "ldaps": + if u.Port() == "" { + u.Host = u.Host + ":636" + } + ins.UseTLS = true + default: + return fmt.Errorf("invalid scheme: %q", u.Scheme) + } + ins.mode = u.Scheme + ins.Server = u.Host + ins.host, ins.port = u.Hostname(), u.Port() + + // Setup TLS configuration + tlsCfg, err := ins.ClientConfig.TLSConfig() + if err != nil { + return fmt.Errorf("creating TLS config failed: %w", err) + } + + ins.tlsCfg = tlsCfg + + // Initialize the search request(s) + switch ins.Dialect { + case "", "openldap": + ins.requests = ins.newOpenLDAPConfig() + case "389ds": + ins.requests = ins.new389dsConfig() + default: + return fmt.Errorf("invalid dialect %q", ins.Dialect) + } + + return nil +} + +func (ins *Instance) Gather(slist *types.SampleList) { + conn, err := ins.connect() + if err != nil { + log.Println("E! failed to connect the server:", ins.Server, "error:", err) + return + } + defer conn.Close() + + for _, req := range ins.requests { + result, err := conn.Search(req.query) + if err != nil { + log.Println("E! failed to search the server:", ins.Server, "error:", err) + continue + } + s, err := ins.gather(req, result) + if err != nil { + log.Println("E! failed to gather metrics: ", err) + return + } + slist.PushFrontN(s) + } +} + +func (ins *Instance) gather(req request, result *ldap.SearchResult) ([]*types.Sample, error) { + if len(result.Entries) <= 0 { + return nil, errors.New("E! ldap Entries is less than or equal to 0") + } + now := time.Now() + samples := make([]*types.Sample, 0, len(req.convert(result, now))) + // Collect metrics + for _, m := range req.convert(result, now) { + for name, value := range m.Fields() { + sample := types.NewSample(m.Name(), name, value, m.Tags()). + SetTime(m.Time().Local()) + + samples = append(samples, sample) + } + } + return samples, nil +} + +func (ins *Instance) connect() (*ldap.Conn, error) { + var conn *ldap.Conn + switch ins.mode { + case "ldap": + var err error + conn, err = ldap.Dial("tcp", ins.Server) + if err != nil { + return nil, err + } + case "ldaps": + var err error + conn, err = ldap.DialTLS("tcp", ins.Server, ins.tlsCfg) + if err != nil { + return nil, err + } + case "starttls": + var err error + conn, err = ldap.Dial("tcp", ins.Server) + if err != nil { + return nil, err + } + if err := conn.StartTLS(ins.tlsCfg); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("invalid tls_mode: %s", ins.mode) + } + + if ins.BindDn == "" && ins.BindPassword.Empty() { + return conn, nil + } + + // Bind username and password + passwd, err := ins.BindPassword.Get() + if err != nil { + return nil, fmt.Errorf("getting password failed: %w", err) + } + defer passwd.Destroy() + + if err := conn.Bind(ins.BindDn, passwd.String()); err != nil { + return nil, fmt.Errorf("binding credentials failed: %w", err) + } + + return conn, nil +} + +func init() { + inputs.Add(inputName, func() inputs.Input { + return &LDAP{} + }) +} + +func (l *LDAP) Clone() inputs.Input { + return &LDAP{} +} + +func (l *LDAP) Name() string { + return inputName +} + +func (l *LDAP) GetInstances() []inputs.Instance { + ret := make([]inputs.Instance, len(l.Instances)) + for i := 0; i < len(l.Instances); i++ { + ret[i] = l.Instances[i] + } + return ret +} diff --git a/inputs/ldap/openldap.go b/inputs/ldap/openldap.go new file mode 100644 index 00000000..450de920 --- /dev/null +++ b/inputs/ldap/openldap.go @@ -0,0 +1,89 @@ +package ldap + +import ( + "strconv" + "strings" + "time" + + "github.com/go-ldap/ldap/v3" + + "flashcat.cloud/categraf/types" + "flashcat.cloud/categraf/types/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 (ins *Instance) newOpenLDAPConfig() []request { + req := ldap.NewSearchRequest( + "cn=Monitor", + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 0, + 0, + false, + "(|(objectClass=monitorCounterObject)(objectClass=monitorOperation)(objectClass=monitoredObject)(objectClass=monitorContainer))", + []string{"monitorCounter", "monitorOpInitiated", "monitorOpCompleted", "monitoredInfo"}, + nil, + ) + return []request{{req, ins.convertOpenLDAP}} +} + +func (ins *Instance) convertOpenLDAP(result *ldap.SearchResult, ts time.Time) []types.Metric { + tags := map[string]string{ + "server": ins.host, + "port": ins.port, + } + + fields := make(map[string]interface{}) + for _, entry := range result.Entries { + prefix := openLDAPAttrConvertDN(entry.DN, ins.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 []types.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, "_") +}