From 245788f3f8f12dc471ff0374d3a29e87fea7fdde Mon Sep 17 00:00:00 2001 From: GRMrGecko Date: Tue, 5 Sep 2023 11:46:19 -0500 Subject: [PATCH] First commit --- .github/workflows/release.yaml | 20 + .github/workflows/test_golang.yaml | 21 + .gitignore | 2 + .vscode/launch.json | 7 + License.txt | 19 + config.go | 227 ++++++++++ flags.go | 54 +++ freeipa.go | 379 +++++++++++++++++ freeipa_metrics.go | 663 +++++++++++++++++++++++++++++ freeipa_test.go | 243 +++++++++++ go.mod | 61 +++ go.sum | 253 +++++++++++ http.go | 107 +++++ http_test.go | 86 ++++ influx.go | 325 ++++++++++++++ influx_test.go | 75 ++++ ldap.go | 305 +++++++++++++ ldap_metrics.go | 258 +++++++++++ ldap_test.go | 356 ++++++++++++++++ main.go | 137 ++++++ readme.md | 131 ++++++ test/cert.pem | 28 ++ test/freeipa.metrics | 45 ++ test/freeipa_ca_is_enabled.json | 11 + test/freeipa_ca_show.json | 29 ++ test/freeipa_config_show.json | 81 ++++ test/freeipa_fail.metrics | 45 ++ test/freeipa_fail_connect.metrics | 12 + test/freeipa_idrange_find.json | 31 ++ test/freeipa_invalid_json.json | 14 + test/http.metrics | 104 +++++ test/influx.json | 36 ++ test/influx.lp | 36 ++ test/ipa-getcert | 47 ++ test/ipa-pki-proxy.conf | 46 ++ test/key.pem | 52 +++ test/kinit | 34 ++ test/klist | 32 ++ test/krb5kdc | 3 + test/ldap.metrics | 59 +++ test/ldap_computers.ldif | 14 + test/ldap_deleted_sub.ldif | 3 + test/ldap_fail.metrics | 51 +++ test/ldap_fail_connect.metrics | 9 + test/ldap_groups.ldif | 11 + test/ldap_hbac.ldif | 5 + test/ldap_hostgroups.ldif | 3 + test/ldap_mapping_tree.ldif | 83 ++++ test/ldap_masters.ldif | 36 ++ test/ldap_netgroups.ldif | 2 + test/ldap_services.ldif | 27 ++ test/ldap_stagged_sub.ldif | 3 + test/ldap_sudo.ldif | 3 + test/ldap_user_sub.ldif | 3 + test/server.xml | 248 +++++++++++ test/test_config.yaml | 30 ++ test_utils.go | 28 ++ 57 files changed, 5033 insertions(+) create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/test_golang.yaml create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 License.txt create mode 100644 config.go create mode 100644 flags.go create mode 100644 freeipa.go create mode 100644 freeipa_metrics.go create mode 100644 freeipa_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 http.go create mode 100644 http_test.go create mode 100644 influx.go create mode 100644 influx_test.go create mode 100644 ldap.go create mode 100644 ldap_metrics.go create mode 100644 ldap_test.go create mode 100644 main.go create mode 100644 readme.md create mode 100644 test/cert.pem create mode 100644 test/freeipa.metrics create mode 100644 test/freeipa_ca_is_enabled.json create mode 100644 test/freeipa_ca_show.json create mode 100644 test/freeipa_config_show.json create mode 100644 test/freeipa_fail.metrics create mode 100644 test/freeipa_fail_connect.metrics create mode 100644 test/freeipa_idrange_find.json create mode 100644 test/freeipa_invalid_json.json create mode 100644 test/http.metrics create mode 100644 test/influx.json create mode 100644 test/influx.lp create mode 100755 test/ipa-getcert create mode 100644 test/ipa-pki-proxy.conf create mode 100644 test/key.pem create mode 100755 test/kinit create mode 100755 test/klist create mode 100644 test/krb5kdc create mode 100644 test/ldap.metrics create mode 100644 test/ldap_computers.ldif create mode 100644 test/ldap_deleted_sub.ldif create mode 100644 test/ldap_fail.metrics create mode 100644 test/ldap_fail_connect.metrics create mode 100644 test/ldap_groups.ldif create mode 100644 test/ldap_hbac.ldif create mode 100644 test/ldap_hostgroups.ldif create mode 100644 test/ldap_mapping_tree.ldif create mode 100644 test/ldap_masters.ldif create mode 100644 test/ldap_netgroups.ldif create mode 100644 test/ldap_services.ldif create mode 100644 test/ldap_stagged_sub.ldif create mode 100644 test/ldap_sudo.ldif create mode 100644 test/ldap_user_sub.ldif create mode 100644 test/server.xml create mode 100644 test/test_config.yaml create mode 100644 test_utils.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..1a7c39a --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,20 @@ +on: + release: + types: [created] + +permissions: + contents: write + packages: write + +jobs: + release-linux-amd64: + name: release linux/amd64 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: wangyoucao577/go-release-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + goos: linux + goarch: amd64 + goversion: '1.21' diff --git a/.github/workflows/test_golang.yaml b/.github/workflows/test_golang.yaml new file mode 100644 index 0000000..9cef003 --- /dev/null +++ b/.github/workflows/test_golang.yaml @@ -0,0 +1,21 @@ +name: Go package + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8709d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.yaml +freeipa-health-metrics diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5c7247b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,7 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [] +} \ No newline at end of file diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..3c9e3c8 --- /dev/null +++ b/License.txt @@ -0,0 +1,19 @@ +Copyright (c) 2023 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/config.go b/config.go new file mode 100644 index 0000000..b9ac3b8 --- /dev/null +++ b/config.go @@ -0,0 +1,227 @@ +package main + +import ( + "bytes" + "log" + "os" + "os/exec" + "os/user" + "path" + "path/filepath" + "strings" + "time" + + "github.com/kkyr/fig" +) + +// Main configuration structure. +type Config struct { + // Used as the FreeIPA server hostname in multiple checks. + // If no address configurations for metric exporters are defined, + // the hostname will be used to set defaults. + Hostname string `fig:"hostname"` + + // Metric exporters configurations. + LDAP LDAPConfig `fig:"ldap"` + FreeIPA FreeIPAConfig `fig:"freeipa"` + + // Metric outputs configurations. + HTTP HTTPOutputConfig `fig:"http_output"` + Influx InfluxOutputConfig `fig:"influx_output"` + + // File path configurations for binaries and config files. + Krb5SysConfigPath string `fig:"krb5_sysconfig_path"` + Krb5KeytabPath string `fig:"krb5_keytab_path"` + Krb5ConfigPath string `fig:"krb5_config_path"` + PKITomcatServerXML string `fig:"pki_tomcat_server_xml"` + HTTPDPKIProxyConf string `fig:"httpd_pki_proxy_conf"` + KInitBin string `fig:"kinit_bin"` + KListBin string `fig:"klist_bin"` + IPAGetCertBIN string `fig:"ipa_getcert_bin"` +} + +const ( + // Use standard LDAP connection. + LDAPMethodUnsecure = "Unsecure" + // Use LDAP over TLS. + LDAPMethodSecure = "Secure" + // Use StartTLS over standard LDAP connection. + LDAPMethodStartTLS = "StartTLS" +) + +// Configurations relating to LDAP. +type LDAPConfig struct { + Address string `fig:"address"` + CACertificate string `fig:"ca_certificate"` + InsecureSkipVerify bool `fig:"insecure_skip_verify"` + ConnectMethod string `fig:"connect_method"` + BaseDN string `fig:"base_dn"` + BindDN string `fig:"bind_dn"` + BindPassword string `fig:"bind_password"` + SearchSizeLimit int `fig:"search_size_limit"` + + DisabledMetrics []string `fig:"disabled_metrics"` +} + +// UNIX system group members for the FreeIPA configuration check of file system state. +type GroupMembers struct { + Name string `fig:"name"` + Members []string `fig:"members"` +} + +// Configurations relating to FreeIPA API and configuration testing. +type FreeIPAConfig struct { + // Kerberos config can be used for both API authentication and for kinit test. + // To use for API authentication, simply do not supply an username/password. + // It is recommended to have it configured for the kinit test to function. + Krb5Realm string `fig:"krb5_realm"` + Krb5Principal string `fig:"krb5_principal"` + + Host string `fig:"host"` + CACertificate string `fig:"ca_certificate"` + InsecureSkipVerify bool `fig:"insecure_skip_verify"` + Username string `fig:"username"` + Password string `fig:"password"` + + GroupMembers []GroupMembers `fig:"group_mebers"` + + DisabledMetrics []string `fig:"disabled_metrics"` +} + +// Configurations relating to HTTP server. +type HTTPOutputConfig struct { + Enabled bool `fig:"enabled"` + BindAddr string `fig:"bind_addr"` + Port uint `fig:"port"` + MetricsPath string `fig:"metrics_path"` +} + +// If you want to output to InfluxDB either via Kafka or to InfluxDB API directly, +// these configurations allow you to set output to occur at a specified frequency. +type InfluxOutputConfig struct { + Frequency time.Duration `fig:"frequency"` + + KafkaBrokers []string `fig:"kafka_brokers"` + KafkaTopic string `fig:"kafka_topic"` + KafkaUsername string `fig:"kafka_usernamne"` + KafkaPassword string `fig:"kafka_password"` + KafkaInsecureSkipVerify bool `fig:"kafka_insecure_skip_verify"` + KafkaOutputFormat string `fig:"kafka_output_format"` // Either lineprotocol or json. Default: lineprotocol + + InfluxServer string `fig:"influx_server"` + Token string `fig:"token"` + Org string `fig:"org"` + Bucket string `fig:"bucket"` +} + +// Read the configuration file. +func (a *App) ReadConfig() { + // Gets the current user for getting the home directory. + usr, err := user.Current() + if err != nil { + log.Fatal(err) + } + + // Configuration paths. + localConfig, _ := filepath.Abs("./config.yaml") + homeDirConfig := usr.HomeDir + "/.config/freeipa-health-metrics/config.yaml" + etcConfig := "/etc/ipa/freeipa-health-metrics.yaml" + + // Determine which configuration to use. + var configFile string + if _, err := os.Stat(app.flags.ConfigPath); err == nil && app.flags.ConfigPath != "" { + configFile = app.flags.ConfigPath + } else if _, err := os.Stat(localConfig); err == nil { + configFile = localConfig + } else if _, err := os.Stat(homeDirConfig); err == nil { + configFile = homeDirConfig + } else if _, err := os.Stat(etcConfig); err == nil { + configFile = etcConfig + } else { + log.Fatal("Unable to find a configuration file.") + } + + // Set defaults. + config := &Config{ + HTTP: HTTPOutputConfig{ + Enabled: true, + Port: 9101, + MetricsPath: "/metrics", + }, + LDAP: LDAPConfig{ + SearchSizeLimit: 100, + }, + FreeIPA: FreeIPAConfig{ + GroupMembers: []GroupMembers{ + { + Name: "apache", + Members: []string{"ipaapi"}, + }, + }, + }, + + Krb5SysConfigPath: "/etc/sysconfig/krb5kdc", + Krb5KeytabPath: "/etc/krb5.keytab", + Krb5ConfigPath: "/etc/krb5.conf", + PKITomcatServerXML: "/etc/pki/pki-tomcat/server.xml", + HTTPDPKIProxyConf: "/etc/httpd/conf.d/ipa-pki-proxy.conf", + KInitBin: "/usr/bin/kinit", + KListBin: "/usr/bin/klist", + IPAGetCertBIN: "/usr/bin/ipa-getcert", + } + + // Load configuration. + filePath, fileName := path.Split(configFile) + err = fig.Load(config, + fig.File(fileName), + fig.Dirs(filePath), + ) + if err != nil { + log.Printf("Error parsing configuration: %s\n", err) + return + } + + // If no hostname is defined in config, pull system hostname. + if config.Hostname == "" { + cmd := exec.Command("/bin/hostname", "-f") + var out bytes.Buffer + cmd.Stdout = &out + err = cmd.Run() + if err != nil { + log.Println("Error getting hostname:", err) + return + } + config.Hostname = strings.TrimRight(out.String(), "\n") + } + + // Use configured hostname as defaults for host related configs. + if config.FreeIPA.Krb5Principal == "" { + config.FreeIPA.Krb5Principal = "host/" + config.Hostname + } + if config.LDAP.Address == "" { + config.LDAP.Address = "ldaps://" + config.Hostname + ":636" + } + if config.FreeIPA.Host == "" { + config.FreeIPA.Host = config.Hostname + } + + // Flag Overrides. + if app.flags.HTTPBind != "" { + config.HTTP.BindAddr = app.flags.HTTPBind + } + if app.flags.HTTPPort != 0 { + config.HTTP.Port = app.flags.HTTPPort + } + if app.flags.HTTPMetricsPath != "" { + config.HTTP.MetricsPath = app.flags.HTTPMetricsPath + } + + // Verify at least one output is enabled. + if !config.HTTP.Enabled && (len(app.config.Influx.KafkaBrokers) == 0 || app.config.Influx.KafkaTopic == "") && (config.Influx.InfluxServer == "" && config.Influx.Token == "" && config.Influx.Org == "" && config.Influx.Bucket == "") { + log.Println("No output services are configured.") + return + } + + // Set global config structure. + app.config = config +} diff --git a/flags.go b/flags.go new file mode 100644 index 0000000..5decfc9 --- /dev/null +++ b/flags.go @@ -0,0 +1,54 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +// Flags supplied to cli. +type Flags struct { + ConfigPath string + HTTPBind string + HTTPPort uint + HTTPMetricsPath string + + TelegrafOutput bool +} + +// Parse the supplied flags. +func (a *App) ParseFlags() { + app.flags = new(Flags) + flag.Usage = func() { + fmt.Printf(serviceName + ": " + serviceDescription + ".\n\nUsage:\n") + flag.PrintDefaults() + } + + // If version is requested. + var printVersion bool + flag.BoolVar(&printVersion, "v", false, "Print version") + + // Override configuration path. + usage := "Load configuration from `FILE`" + flag.StringVar(&app.flags.ConfigPath, "config", "", usage) + flag.StringVar(&app.flags.ConfigPath, "c", "", usage+" (shorthand)") + + // Config overrides for http output. + flag.StringVar(&app.flags.HTTPBind, "http-bind", "", "Bind address for http server") + flag.UintVar(&app.flags.HTTPPort, "http-port", 0, "Bind port for http server") + flag.StringVar(&app.flags.HTTPMetricsPath, "http-metrics-path", "", "Path for pulling prometheus metrics") + + // Rather or not we should output lineprotocol data and exit for telegraf. + usage = "Output for telegraf execution." + flag.BoolVar(&app.flags.TelegrafOutput, "telegraf", false, usage) + flag.BoolVar(&app.flags.TelegrafOutput, "t", false, usage+" (shorthand)") + + // Parse the flags. + flag.Parse() + + // Print version and exit if requested. + if printVersion { + fmt.Println(serviceName + ": " + serviceVersion) + os.Exit(0) + } +} diff --git a/freeipa.go b/freeipa.go new file mode 100644 index 0000000..340e1a5 --- /dev/null +++ b/freeipa.go @@ -0,0 +1,379 @@ +package main + +import ( + "bufio" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "regexp" + "strings" + "sync" + "time" + + "github.com/grmrgecko/go-freeipa" + "github.com/prometheus/client_golang/prometheus" +) + +// The prometheus exporter for FreeIPA configurations. +type FreeIPAExporter struct { + config *FreeIPAConfig + conn *freeipa.Client + mutex sync.RWMutex + metrics []metricInfo + + // Basic metrics. + up prometheus.Gauge + totalScrapes, totalFailures prometheus.Counter + failedTests prometheus.Gauge + + // Caches so we do not have more load than neccessary. + apiConfigCache *freeipa.Response + caCertificateCache *x509.Certificate + ipaCertificateCache []*x509.Certificate + ldapCertificateCache []*x509.Certificate + certMongerCertsCache []*CertMongerCerts +} + +// Make the FreeIPA exporter. +func NewFreeIPAExporter() *FreeIPAExporter { + e := new(FreeIPAExporter) + e.Reload() + + return e +} + +// Reload the configurations. +func (e *FreeIPAExporter) Reload() { + e.config = &app.config.FreeIPA + e.metrics = nil + e.setupMetrics() +} + +// Get the API configuration from FreeIPA. +func (e *FreeIPAExporter) apiConfig() (*freeipa.Response, error) { + // If not cached, pull from the API. + if e.apiConfigCache == nil { + // Get the configuration from the API. + params := make(map[string]interface{}) + req := freeipa.NewRequest( + "config_show", + []interface{}{}, + params, + ) + res, err := e.conn.Do(req) + if err != nil { + return nil, err + } + e.apiConfigCache = res + } + // Return the cache. + return e.apiConfigCache, nil +} + +// Get the FreeIPA CA Certificate. +func (e *FreeIPAExporter) caCert() (*x509.Certificate, error) { + // If not cached, pull it. + if e.caCertificateCache == nil { + // Find the CA certificate in the API. + params := make(map[string]interface{}) + req := freeipa.NewRequest( + "ca_show", + []interface{}{"ipa"}, + params, + ) + res, err := e.conn.Do(req) + if err != nil { + return nil, err + } + + // Check if the certificate was returned. + certS, ok := res.GetString("certificate") + if !ok { + return nil, fmt.Errorf("unable to get certificate") + } + + // Parse the x509 certificate. + caPEM := "-----BEGIN CERTIFICATE-----\n" + certS + "\n-----END CERTIFICATE-----" + block, _ := pem.Decode([]byte(caPEM)) + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + + // If we found the certificate, cache it. + e.caCertificateCache = cert + } + // Return the cache. + return e.caCertificateCache, nil +} + +// Get the active certificate on the FreeIPA API http server. +func (e *FreeIPAExporter) ipaCerts() ([]*x509.Certificate, error) { + // If no cache, pull the certificates. + if len(e.ipaCertificateCache) == 0 { + // Determine the proper host name. + host := e.config.Host + if !strings.Contains(host, ":") { + host = host + ":443" + } + + // In this case, we don't care about security as we're just grabbing certificates. + tlsConf := &tls.Config{ + InsecureSkipVerify: true, + } + // Dial the host. + conn, err := tls.Dial("tcp", host, tlsConf) + if err != nil { + return nil, err + } + // Close the connection when done. + defer conn.Close() + // Get the certificates and cache them. + certs := conn.ConnectionState().PeerCertificates + e.ipaCertificateCache = append(e.ipaCertificateCache, certs...) + } + // Return cached certificates. + return e.ipaCertificateCache, nil +} + +// Get the active LDAP server certificates. +func (e *FreeIPAExporter) ldapCerts() ([]*x509.Certificate, error) { + // If not cached, pull them. + if len(e.ldapCertificateCache) == 0 { + // Determine the host from the LDAP config. + addr, err := url.Parse(app.config.LDAP.Address) + if err != nil { + return nil, err + } + port := addr.Port() + host := addr.Hostname() + ":" + port + // If no port defined in the URL, or if the ldaps protocol isn't defined. Use the default port. + if addr.Scheme != "ldaps" || port == "" { + host = addr.Hostname() + ":636" + } + + // In this case, we don't care about security as we're just grabbing certificates. + tlsConf := &tls.Config{ + InsecureSkipVerify: true, + } + // Dial the host. + conn, err := tls.Dial("tcp", host, tlsConf) + if err != nil { + return nil, err + } + // Close the connection when done. + defer conn.Close() + // Get the certificates and cache them. + certs := conn.ConnectionState().PeerCertificates + e.ldapCertificateCache = append(e.ldapCertificateCache, certs...) + } + // Return cached certificates. + return e.ldapCertificateCache, nil +} + +// Information on the certificates managed by cert monger. +type CertMongerCerts struct { + RequestID string + Status string + Stuck bool + KeyPairStorage string + Issuer string + Subject string + Expires time.Time + DNSNames []string + Track bool + AutoRenew bool +} + +// Pull certificates managed by cert monger. +func (e *FreeIPAExporter) certMongerCerts() ([]*CertMongerCerts, error) { + // If not cached, pull them. + if e.certMongerCertsCache == nil { + var cert *CertMongerCerts + // Parsing regex for cert monger. + requestRX := regexp.MustCompile(`Request ID '([A-Za-z0-9]+)':`) + keyValueRX := regexp.MustCompile(`\s([A-Za-z][A-Za-z- ]+): (.*)$`) + + // Setup the ipa-getcert list command. + cmd := exec.Command(app.config.IPAGetCertBIN, "list") + + // Get the pipes. + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + + // Start the command. + err = cmd.Start() + if err != nil { + return nil, err + } + + // Setup wait group to avoid race condition. + var wg sync.WaitGroup + wg.Add(2) + + // Scan the standard output for certificates. + stdoutScanner := bufio.NewScanner(stdout) + go func() { + // Scan each line of the output. + for stdoutScanner.Scan() { + line := stdoutScanner.Text() + // If this is a request line, setup a new certificate. + if requestRX.MatchString(line) { + match := requestRX.FindStringSubmatch(line) + // If match doesn't return expected count, continue. + if len(match) != 2 { + continue + } + // If the certificate was previously parsed, add it to the cache. + if cert != nil { + e.certMongerCertsCache = append(e.certMongerCertsCache, cert) + } + // Start a new certificate. + cert = &CertMongerCerts{ + RequestID: match[1], + } + } else if keyValueRX.MatchString(line) { + // Parse key value entry. + match := keyValueRX.FindStringSubmatch(line) + // If match doesn't return expected count, continue. + if len(match) != 3 { + continue + } + // Check if key is one we're parsing and store the parsed info. + switch match[1] { + case "status": + cert.Status = match[2] + case "stuck": + cert.Stuck = match[2] == "yes" + case "key pair storage": + cert.KeyPairStorage = match[2] + case "issuer": + cert.Issuer = match[2] + case "subject": + cert.Subject = match[2] + case "expires": + cert.Expires, _ = time.Parse("2006-01-02 15:04:05 MST", match[2]) + case "dns": + cert.DNSNames = strings.Split(match[2], ",") + case "track": + cert.Track = match[2] == "yes" + case "auto-renew": + cert.AutoRenew = match[2] == "yes" + } + } + } + // We're done parsing the standard output. + wg.Done() + }() + + // Scan the standard error output and pass to our standard error. + stderrScanner := bufio.NewScanner(stderr) + go func() { + for stderrScanner.Scan() { + line := stderrScanner.Text() + fmt.Fprintln(os.Stderr, line) + } + wg.Done() + }() + + // Wait for file reads to finish before waiting on command to finnish to avoid a race condition. + wg.Wait() + + // Wait for the command to and check if error returned. + err = cmd.Wait() + if err != nil { + return nil, err + } + + // If a certificate was parsed, add it to the cache. + if cert != nil { + e.certMongerCertsCache = append(e.certMongerCertsCache, cert) + } + } + // Return cached list. + return e.certMongerCertsCache, nil +} + +// Connect to the FreeIPA API. +func (e *FreeIPAExporter) connect() error { + var err error + + // Setup TLS configurations. + tlsConifg := tls.Config{InsecureSkipVerify: e.config.InsecureSkipVerify} + // Load CA certificates if configured. + if e.config.CACertificate != "" { + caCert, err := os.ReadFile(e.config.CACertificate) + if err != nil { + log.Println("Error reading CA certificate:", err) + } else { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + tlsConifg.RootCAs = caCertPool + } + } + + // Update the transport config with TLS config. + transportConfig := &http.Transport{ + TLSClientConfig: &tlsConifg, + } + + // If we're logging in with plain authentication, do so. + if e.config.Username != "" && e.config.Password != "" { + e.conn, err = freeipa.Connect(e.config.Host, transportConfig, e.config.Username, e.config.Password) + return err + } + + // Plain authentication wasn't used, so now we try logging in with Kerberos authentication. + // Read the keytab for kerberos. + krb5KtFd, err := os.Open(app.config.Krb5KeytabPath) + if err != nil { + return err + } + // Close keytab after we're done. + defer krb5KtFd.Close() + + // Open the kerberos config file. + krb5Fd, err := os.Open(app.config.Krb5ConfigPath) + if err != nil { + return err + } + // Close the config file afte we're done. + defer krb5Fd.Close() + + // Setup the kerberos connection options. + krb5ConnectOption := &freeipa.KerberosConnectOptions{ + Krb5ConfigReader: krb5Fd, + KeytabReader: krb5KtFd, + User: e.config.Krb5Principal, + Realm: e.config.Krb5Realm, + } + + // Attempt to connect with kerberos. + e.conn, err = freeipa.ConnectWithKerberos(e.config.Host, transportConfig, krb5ConnectOption) + return err +} + +// Disconnect from the API. +func (e *FreeIPAExporter) disconnect() { + if e.conn != nil { + e.conn = nil + // Clear caches. + e.apiConfigCache = nil + e.caCertificateCache = nil + e.ipaCertificateCache = nil + e.ldapCertificateCache = nil + e.certMongerCertsCache = nil + } +} diff --git a/freeipa_metrics.go b/freeipa_metrics.go new file mode 100644 index 0000000..1285d30 --- /dev/null +++ b/freeipa_metrics.go @@ -0,0 +1,663 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "log" + "math/rand" + "os" + "os/exec" + "regexp" + "runtime" + "strconv" + "strings" + "time" + + "github.com/antchfx/xmlquery" + "github.com/grmrgecko/go-freeipa" + UNIXAccounts "github.com/grmrgecko/go-unixaccounts" + "github.com/prometheus/client_golang/prometheus" +) + +// Creates a metric and appends it to the to the available metrics if enabled. +func (e *FreeIPAExporter) NewMetric(metricName string, docString string, t prometheus.ValueType, value func() (float64, error)) { + // If metric is disabled, stop here. + for _, metric := range e.config.DisabledMetrics { + if metric == metricName { + return + } + } + // Create info for this metric. + info := metricInfo{ + Desc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "config", metricName), + docString, + nil, + nil, + ), + Type: t, + Value: value, + } + // Add metric to list. + e.metrics = append(e.metrics, info) +} + +// Sets up the exporter with all needed metrics for FreeIPA. +func (e *FreeIPAExporter) setupMetrics() { + // Setup basic metrics. + e.up = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "up", + Help: "Was the last scrape of FreeIPA successful.", + }) + e.totalScrapes = prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: namespace, + Name: "scrapes_total", + Help: "Current total HAProxy scrapes.", + }) + e.totalFailures = prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: namespace, + Name: "failures_total", + Help: "Number of errors while scapping metrics.", + }) + e.failedTests = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "freeipa_failed_tests", + Help: "Number of failed tests in the most recent scrape.", + }) + + // Test: Ensure that kerberos authentication works with kinit and klist. + e.NewMetric("krb5_auth", "Kerberos can authenticate.", prometheus.GaugeValue, func() (float64, error) { + // Specific cache file for tokens to ensure we're not succeeding due to previous check. + krb5CacheFile := fmt.Sprintf("/tmp/krb5_cache_%d", rand.Int()) + // Remove file when test is done. + defer os.Remove(krb5CacheFile) + // Get pricipal with realm for authentication. + krb5Principal := e.config.Krb5Principal + "@" + e.config.Krb5Realm + + // Authenticate with kinit. + cmd := exec.Command(app.config.KInitBin, "-kt", app.config.Krb5KeytabPath, "-c", krb5CacheFile, krb5Principal) + + // Run kinit, which will error if exit code is not 0 as expected. + _, err := cmd.Output() + if err != nil { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, err + } + + // Verify the cache file contains the token with klist. + cmd = exec.Command(app.config.KListBin, "-c", krb5CacheFile) + + // Run klist, which will error if exit code is not 0 as expected. + _, err = cmd.Output() + if err != nil { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, err + } + + // Both tests suceeded, return 1. + return 1, nil + }) + + // Test: The number of workers configured for the directory server should equal number of cores. + e.NewMetric("krb5_workers", "Workers match processors.", prometheus.GaugeValue, func() (float64, error) { + // Open the kerberos server configuration file. + f, err := os.Open(app.config.Krb5SysConfigPath) + if err != nil { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, err + } + // Close file after test is done. + defer f.Close() + + // Scan the file for each line. + scanner := bufio.NewScanner(f) + scanner.Split(bufio.ScanLines) + + // Default to zero workers. + workers := 0 + // The workers are defined as `-w WORKERS` in the cli arguments. + rxWorkers := regexp.MustCompile(`=.*-w\s*([0-9]+)`) + // Scan each line for the number of workers. + for scanner.Scan() { + line := scanner.Text() + // If line is the arguments config, parse it. + if strings.HasPrefix(line, "KRB5KDC_ARGS") { + // Parse line for number of workers. + match := rxWorkers.FindStringSubmatch(line) + if len(match) == 2 { + workers, _ = strconv.Atoi(match[1]) + } + } + } + + // Check number of workers configured against number of CPU cores. + if workers != runtime.NumCPU() { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, fmt.Errorf("number of workers does not match CPU cores") + } + + // If successful, return 1. + return 1, nil + }) + + /* + Test: The DNA range specify the starting user ID, + there needs to be at least one master with a range set. + */ + e.NewMetric("dna_range", "DNA range is defined.", prometheus.GaugeValue, func() (float64, error) { + // Pull from FreeIPA API all DNA ranges found. + params := make(map[string]interface{}) + req := freeipa.NewRequest( + "idrange_find", + []interface{}{""}, + params, + ) + res, err := e.conn.Do(req) + if err != nil { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, err + } + + // If no DNA ranges found, fail. + if res.Result.Count == 0 { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, fmt.Errorf("no DNA ID range found") + } + + // Success, return 1. + return 1, nil + }) + + /* + Test: This ensures that groups specified in the config have specific members. + By default, we define this check to verify the ipaapi user is a member + of the apache group. This is critical for security and access to cache. + */ + e.NewMetric("group_members", "Group members are as expected.", prometheus.GaugeValue, func() (float64, error) { + // Get UNIX account list. + accounts, err := UNIXAccounts.NewUNIXAccounts() + if err != nil { + e.failedTests.Inc() + return 0, err + } + + // Check each ground member configuration. + for _, check := range e.config.GroupMembers { + // Find the group that matches the name configured. + group := accounts.GroupWithName(check.Name) + // If group not found, fail. + if group == nil { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, fmt.Errorf("unable to find group with name: %s", check.Name) + } + + // Get all users in this group. + users := accounts.UsersInGroup(group) + // Check each member configured to verify they are a group memebr. + for _, member := range check.Members { + // Check all users in this group to see if they are this member. + userIsMember := false + for _, user := range users { + if user.Name == member { + userIsMember = true + } + } + // If member isn't a user in the group, fail. + if !userIsMember { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, fmt.Errorf("user %s should be a member of %s", member, check.Name) + } + } + } + + // If we reached this point, no test failed. + return 1, nil + }) + + // Test: Confirm the shared secret between tomcat and Apache match. + e.NewMetric("proxy_secret", "Proxy secret is configured.", prometheus.GaugeValue, func() (float64, error) { + // Open the tomcat server configuration file. + xmlF, err := os.Open(app.config.PKITomcatServerXML) + if err != nil { + e.failedTests.Inc() + return 0, err + } + // Close config at end of test. + defer xmlF.Close() + + // Query XML for the AJP connector configuration. + p, err := xmlquery.CreateStreamParser(xmlF, `//Connector[@protocol="AJP/1.3"]`) + if err != nil { + e.failedTests.Inc() + return 0, err + } + // Variable to store found secrets. + var foundSecrets []string + + // Pull the first connector match if possible. + n, err := p.Read() + // If EOF reached, no connectors are defined. + if err == io.EOF { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, fmt.Errorf("no AJP/1.3 connectors defined") + } + // Other erros are general. + if err != nil { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, err + } + + // Get the secret attribute from the connector. This only may be configured on older installs. + secret := n.SelectAttr("secret") + if secret != "" { + foundSecrets = append(foundSecrets, secret) + } + // Get the required secret which is the newer attribute name. + secret = n.SelectAttr("requiredSecret") + if secret != "" { + foundSecrets = append(foundSecrets, secret) + } + + // If there are more than one secret, check if they both match. + if len(foundSecrets) > 1 { + if foundSecrets[0] != foundSecrets[1] { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, fmt.Errorf("the AJP secrets do not match") + } + } + // If no secrets were found, fail. + if len(foundSecrets) == 0 { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, fmt.Errorf("no AJP secrets found") + } + + // Open the Apache HTTPD proxy configuration file. + f, err := os.Open(app.config.HTTPDPKIProxyConf) + if err != nil { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, err + } + // Close this config file at end of test. + defer f.Close() + + // Create a new line scanner for the httpd configuration file. + scanner := bufio.NewScanner(f) + scanner.Split(bufio.ScanLines) + // Parse regex for expected configuration. + proxyRx := regexp.MustCompile(`\s+ProxyPassMatch ajp://localhost:8009 secret=(\w+)$`) + // List of found secrets. + var foundProxySecrets []string + + // Scan each line of the config for secrets. + for scanner.Scan() { + line := scanner.Text() + // If line matches a secret, get the secret. + if proxyRx.MatchString(line) { + match := proxyRx.FindStringSubmatch(line) + if len(match) == 2 { + // Add secret to the list. + foundProxySecrets = append(foundProxySecrets, match[1]) + } + } + } + + // If no secrets found in HTTPD config, fail. + if len(foundProxySecrets) == 0 { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, fmt.Errorf("no AJP proxy secrets found") + } + + // Check each found proxy secret against the tomcat secrets. + for _, secret := range foundProxySecrets { + foundMatch := false + for _, xmlSecret := range foundSecrets { + if secret == xmlSecret { + foundMatch = true + } + } + // If no match found, fail. + if !foundMatch { + // As this is a test, add to the tests failed counter. + e.failedTests.Inc() + return 0, fmt.Errorf("the AJP secrets configured do not match between tomcat and apache") + } + } + + // At this point, the test succeeded. + return 1, nil + }) + + // Info: Is this server the renewal master? + e.NewMetric("renewal_master", "This server is the renewal master.", prometheus.GaugeValue, func() (float64, error) { + // Get the FreeIPA config. + config, err := e.apiConfig() + if err != nil { + return 0, err + } + + // Get the configured renewal master. + masterServer, _ := config.GetString("ca_renewal_master_server") + // This is a renewal master if the configured hostname matches. + if masterServer != app.config.Hostname { + return 0, nil + } + + return 1, nil + }) + + // Info: Did the IPA CA sign the FreeIPA API certificate? + e.NewMetric("ipa_ca_issued_cert", "The FreeIPA API was issued a certificate by the CA cert.", prometheus.GaugeValue, func() (float64, error) { + // Get the CA certificate. + caCert, err := e.caCert() + if err != nil { + return 0, err + } + + // Get the FreeIPA API certificate. + ipaCerts, err := e.ipaCerts() + if err != nil { + return 0, err + } + + // Check each certificate returned by the API against the CA certificate. + countSuccess := 0 + for _, cert := range ipaCerts { + // Ignore the CA certificates. + if cert.IsCA { + continue + } + // If the signature is signed by the CA certificate, add to the successes. + err := cert.CheckSignatureFrom(caCert) + if err == nil { + countSuccess++ + } + } + // If no successful signature checks, there are no certificates signed by the CA certificate. + if countSuccess == 0 { + return 0, fmt.Errorf("certificates are not issued by ca certificate") + } + + // At this point, a certificate was found to be signed by the CA certificate. + return 1, nil + }) + + // Info: Is the FreeIPA API certificate in certmonger for autorenew? + e.NewMetric("ipa_cert_auto_renew", "The FreeIPA API certificate is managed and set to auto renew.", prometheus.GaugeValue, func() (float64, error) { + // Get certificates managed by cert monger. + certsFound, err := e.certMongerCerts() + if err != nil { + return 0, err + } + + // Get info about the httpd certificate. + var httpSubject string + autoRenew := false + for _, cert := range certsFound { + // If certificate storage path is in the httpd path, this is the httpd certificate. + if strings.Contains(cert.KeyPairStorage, "httpd") { + httpSubject = cert.Subject + autoRenew = cert.Status == "MONITORING" && cert.AutoRenew && !cert.Stuck && cert.Track + } + } + + // Get the FreeIPA API certificates. + ipaCerts, err := e.ipaCerts() + if err != nil { + return 0, err + } + + // Check if the certificate currently returned by FreeIPA is the one found in cert monger. + foundCerts := 0 + for _, cert := range ipaCerts { + // If is an CA certificate, skip. + if cert.IsCA { + continue + } + thisSubject := cert.Subject.String() + // OpenSSL seems to escape the comma. + thisSubject = strings.ReplaceAll(thisSubject, "\\,", ",") + // If subject matches, this is the certificate in certmonger.. + if thisSubject == httpSubject { + foundCerts++ + } + } + + // If no certificates found, return error. + if foundCerts == 0 { + return 0, fmt.Errorf("unable to determine if http cert is auto renew") + } + + // If not auto renew, return 0. + if !autoRenew { + return 0, nil + } + + // At this point, the certificate was found and has been determined to be autorenew. + return 1, nil + }) + + // Info: The unix timestamp of the earliest FreeIPA API certificate expiry date. + e.NewMetric("ipa_earliest_cert_expiry", "The earliest certificate expiry date for FreeIPA API.", prometheus.GaugeValue, func() (float64, error) { + // Get FreeIPA API certificates. + ipaCerts, err := e.ipaCerts() + if err != nil { + return 0, err + } + + // Find the earliest expiry date. + earliest := time.Time{} + for _, cert := range ipaCerts { + // If this is before the previously found earliest, update. + if earliest.IsZero() || (cert.NotAfter.Before(earliest) && !cert.NotAfter.IsZero()) { + earliest = cert.NotAfter + } + } + // If the earliest date found is zero, we did not find any expiry dates. + if earliest.IsZero() { + return 0, fmt.Errorf("unable to find earliest cert") + } + + // Return the earliest date in unix time format. + return float64(earliest.Unix()), nil + }) + + // Info: Did the IPA CA sign the LDAP certificate? + e.NewMetric("ipa_ca_issued_ldap_cert", "The LDAP cert was issued a certificate by the CA cert.", prometheus.GaugeValue, func() (float64, error) { + // Get the CA certificate. + caCert, err := e.caCert() + if err != nil { + return 0, err + } + + // Get the LDAP certificates. + ldapCerts, err := e.ldapCerts() + if err != nil { + return 0, err + } + + // Check each certificate. + countSuccess := 0 + for _, cert := range ldapCerts { + // If this is a CA certificate, ignore. + if cert.IsCA { + continue + } + // If the signature was signed by the CA certificate, add to the count. + err := cert.CheckSignatureFrom(caCert) + if err == nil { + countSuccess++ + } + } + // If no successful signature checks, there are no certificates signed by the CA certificate. + if countSuccess == 0 { + return 0, fmt.Errorf("certificates are not issued by ca certificate") + } + + // At this point, a certificate was found to be signed by the CA certificate. + return 1, nil + }) + + // Info: Is the LDAP certificate in certmonger for autorenew? + e.NewMetric("ldap_cert_auto_renew", "The LDAP certificate is managed and set to auto renew.", prometheus.GaugeValue, func() (float64, error) { + // Get the certificates managed by cert monger. + certsFound, err := e.certMongerCerts() + if err != nil { + return 0, err + } + + // Find info about the LDAP certificate in cert monger. + var httpSubject string + autoRenew := false + for _, cert := range certsFound { + // If the storage path is in the dirsrv folder, this is an LDAP certificate. + if strings.Contains(cert.KeyPairStorage, "dirsrv") { + httpSubject = cert.Subject + autoRenew = cert.Status == "MONITORING" && cert.AutoRenew && !cert.Stuck && cert.Track + } + } + + // Get the LDAP certificates. + ldapCerts, err := e.ldapCerts() + if err != nil { + return 0, err + } + + // Check each LDAP certificate to see if it matches the one in cert monger. + foundCerts := 0 + for _, cert := range ldapCerts { + // If CA certificate, ignore. + if cert.IsCA { + continue + } + thisSubject := cert.Subject.String() + // OpenSSL seems to escape the comma. + thisSubject = strings.ReplaceAll(thisSubject, "\\,", ",") + // If this certificate matches the cert monger certificate. + if thisSubject == httpSubject { + foundCerts++ + } + } + + // If no certificates found, return error. + if foundCerts == 0 { + return 0, fmt.Errorf("unable to determine if LDAP cert is auto renew") + } + + // If not auto renew, return 0. + if !autoRenew { + return 0, nil + } + + // At this point, the certificate was found and has been determined to be autorenew. + return 1, nil + }) + + // Info: The unix timestamp of the earliest LDAP certificate expiry date. + e.NewMetric("ldap_earliest_cert_expiry", "The earliest certificate expiry date for LDAP.", prometheus.GaugeValue, func() (float64, error) { + // Get the LDAP certificates. + ldapCerts, err := e.ldapCerts() + if err != nil { + return 0, err + } + + // Find the earliest expiry date. + earliest := time.Time{} + for _, cert := range ldapCerts { + // If this is before the previously found earliest, update. + if earliest.IsZero() || (cert.NotAfter.Before(earliest) && !cert.NotAfter.IsZero()) { + earliest = cert.NotAfter + } + } + + // If the earliest date found is zero, we did not find any expiry dates. + if earliest.IsZero() { + return 0, fmt.Errorf("unable to find earliest cert") + } + + // Return the earliest date in unix time format. + return float64(earliest.Unix()), nil + }) +} + +// Provide Promethues all descriptions of metrics exported. +func (e *FreeIPAExporter) Describe(ch chan<- *prometheus.Desc) { + for _, m := range e.metrics { + ch <- m.Desc + } + ch <- e.up.Desc() + ch <- e.totalScrapes.Desc() + ch <- e.totalFailures.Desc() + ch <- e.failedTests.Desc() +} + +// Collects metrics exported and provide values to Prometheus. +func (e *FreeIPAExporter) Collect(ch chan<- prometheus.Metric) { + // Protect metrics from concurrent collects. + e.mutex.Lock() + defer e.mutex.Unlock() + + // Scrape FreeIPA metrics. + up := e.scrape(ch) + // Update the up status. + e.up.Set(up) + // If not up, count as a failed scrape. + if up == 0 { + e.totalFailures.Inc() + } + + // Send basic metrics. + ch <- e.up + ch <- e.totalScrapes + ch <- e.totalFailures + ch <- e.failedTests +} + +// Test FreeIPA and pull metrics. +func (e *FreeIPAExporter) scrape(ch chan<- prometheus.Metric) float64 { + // Reset the number of failed tests. + e.failedTests.Set(0) + // Increment the total number of scrapes. + e.totalScrapes.Inc() + + // Attempt to connect to the FreeIPA API. + err := e.connect() + // If failure connecting, FreeIPA API is not up. + if err != nil { + log.Println("Error connecting to FreeIPA API:", err) + return 0 + } + // Disconnect after we're done scrapping information. + defer e.disconnect() + + // Update data for each metric. + for _, m := range e.metrics { + // Get the value of the metric. + value, err := m.Value() + // If an error occurred getting the value, log it for debug. + if err != nil { + log.Printf("Error retrieving value for metric %s: %s\n", m.Desc.String(), err) + } + + // Update the value. + ch <- prometheus.MustNewConstMetric(m.Desc, m.Type, value) + } + + // The FreeIPA API server is up. + return 1 +} diff --git a/freeipa_test.go b/freeipa_test.go new file mode 100644 index 0000000..c2e2331 --- /dev/null +++ b/freeipa_test.go @@ -0,0 +1,243 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "testing" + "time" + + "github.com/grmrgecko/go-freeipa" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +// Setup global app variable with test config for tests. +func setupFreeIPATestApp() { + app = new(App) + app.flags = new(Flags) + app.flags.ConfigPath = "test/test_config.yaml" + app.ReadConfig() + app.freeIPAExporter = NewFreeIPAExporter() + + // Set ldap address to https port for certificate tests. + app.config.LDAP.Address = fmt.Sprintf("ldaps://127.0.0.1:%d", httpsPort) +} + +// Unused port for testing. +const httpsPort = 8831 + +type FreeIPATestServer struct { + server *http.Server + mux *http.ServeMux + responses map[string]string +} + +func NewFreeIPATestServer() *FreeIPATestServer { + s := new(FreeIPATestServer) + + // Setup handlers. + mux := http.NewServeMux() + mux.HandleFunc("/ipa/session/login_password", s.handleLogin) + mux.HandleFunc("/ipa/session/json", s.handleJSON) + s.mux = mux + + // Setup server config. + srvAddr := fmt.Sprintf("127.0.0.1:%d", httpsPort) + s.server = &http.Server{ + Addr: srvAddr, + Handler: mux, + } + + // Method to response file map. + s.responses = map[string]string{ + "ca_is_enabled": "test/freeipa_ca_is_enabled.json", + "idrange_find": "test/freeipa_idrange_find.json", + "config_show": "test/freeipa_config_show.json", + "ca_show": "test/freeipa_ca_show.json", + } + return s +} + +// Test login handler. +func (s *FreeIPATestServer) handleLogin(w http.ResponseWriter, req *http.Request) { + // Logins are form data posts. + req.ParseForm() + + // Check username/password equals test credentials. + user := req.Form.Get("user") + password := req.Form.Get("password") + if user == app.config.FreeIPA.Username && password == app.config.FreeIPA.Password { + // Successful login send session cookie. + cookie := http.Cookie{} + cookie.Name = "ipa_session" + cookie.Value = "correct-session-secret" + cookie.Expires = time.Now().Add(30 * time.Minute) + cookie.Secure = true + cookie.HttpOnly = true + cookie.Path = "/ipa" + http.SetCookie(w, &cookie) + w.Header().Set("IPASESSION", "correct-session-secret") + } else { + // Invalid login, send rejection. + w.Header().Set("X-IPA-Rejection-Reason", "invalid-password") + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + fmt.Fprintf(w, ` + +401 Unauthorized + + +

Invalid Authentication

+

+kinit: Password incorrect while getting initial credentials + +

+ +`) + } +} + +// Send JSON file to HTTP request. +func (s *FreeIPATestServer) sendJSONFile(w http.ResponseWriter, filePath string) { + f, err := os.Open(filePath) + if err != nil { + log.Fatalln(err) + } + defer f.Close() + io.Copy(w, f) +} + +// General invalid json error response for testing error handling. +func (s *FreeIPATestServer) sendInvalidJSON(w http.ResponseWriter) { + s.sendJSONFile(w, "test/freeipa_invalid_json.json") +} + +// Handle the json session test request. +func (s *FreeIPATestServer) handleJSON(w http.ResponseWriter, req *http.Request) { + // If session cookie doesn't exist, something is wrong. Send unauthenticated response. + cookie, err := req.Cookie("ipa_session") + if err != nil || cookie.Value != "correct-session-secret" { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + // Generally json response from here. + w.Header().Set("Content-Type", "application/json") + + // Get the request body and parse it out. + res := new(freeipa.Request) + err = json.NewDecoder(req.Body).Decode(res) + if err != nil { + // If the json decode fails, send the error. + s.sendInvalidJSON(w) + return + } + + // For testing, we'll consider user_add/user_find as an accepted method, all others will error. + resFile, ok := s.responses[res.Method] + if ok { + s.sendJSONFile(w, resFile) + } else { + // Debug output. + // jsonD, _ := json.Marshal(res) + // fmt.Println(string(jsonD)) + + // An unexpected method received for testing, send error message. + s.sendInvalidJSON(w) + } +} + +// Run the http server. +func (s *FreeIPATestServer) Run() { + isListening := make(chan bool) + // Start server. + go s.Start(isListening) + // Allow the http server to initialize. + <-isListening +} + +// Stop the HTTP server. +func (s *FreeIPATestServer) Stop() { + s.server.Shutdown(context.Background()) +} + +// Start the HTTP server with a notification channel +// for when the server is listening. +func (s *FreeIPATestServer) Start(isListening chan bool) { + // Start server. + l, err := net.Listen("tcp", s.server.Addr) + if err != nil { + log.Fatal("Listen: ", err) + } + // Now notify we are listening. + isListening <- true + // Serve http server on the listening port. + err = s.server.ServeTLS(l, "test/cert.pem", "test/key.pem") + if err != nil && err != http.ErrServerClosed { + log.Fatal("Serve: ", err) + } +} + +// Main FreeIPA test function that verifies metrics for FreeIPA and its configurations works. +func TestFreeIPA(t *testing.T) { + // Setup configs. + setupFreeIPATestApp() + // Start http server. + server := NewFreeIPATestServer() + server.Run() + + // Open the expected prometheus metrics. + expected, err := os.Open("test/freeipa.metrics") + if err != nil { + t.Fatal("Error opening tests:", err) + } + defer expected.Close() + + // Test the LDAP exporter and verify metrics match what's expected. + err = testutil.CollectAndCompare(app.freeIPAExporter, expected) + // If results are not as expected, fail test with the error. + if err != nil { + t.Fatal("Unexpected metrics returned:", err) + } + + // Remove all responses to test failures. + server.responses = nil + + // Open the expected prometheus metrics. + expected, err = os.Open("test/freeipa_fail.metrics") + if err != nil { + t.Fatal("Error opening tests:", err) + } + defer expected.Close() + + // Test the LDAP exporter and verify metrics match what's expected. + err = testutil.CollectAndCompare(app.freeIPAExporter, expected) + // If results are not as expected, fail test with the error. + if err != nil { + t.Fatal("Unexpected metrics returned:", err) + } + + // Set server to an bad address to test failure to connect. + app.config.FreeIPA.Host = "bad-address" + + // Open the expected prometheus metrics. + expected, err = os.Open("test/freeipa_fail_connect.metrics") + if err != nil { + t.Fatal("Error opening tests:", err) + } + defer expected.Close() + + // Test the LDAP exporter and verify metrics match what's expected. + err = testutil.CollectAndCompare(app.freeIPAExporter, expected) + // If results are not as expected, fail test with the error. + if err != nil { + t.Fatal("Unexpected metrics returned:", err) + } + + // Stop as we're done. + server.Stop() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f0341b2 --- /dev/null +++ b/go.mod @@ -0,0 +1,61 @@ +module github.com/GRMrGecko/freeipa-health-metrics + +go 1.20 + +require ( + github.com/antchfx/xmlquery v1.3.17 + github.com/go-ldap/ldap/v3 v3.4.5 + github.com/gorilla/handlers v1.5.1 + github.com/grmrgecko/go-freeipa v0.0.0-20230814003934-9662b716120c + github.com/grmrgecko/go-unixaccounts v0.0.0-20230814023229-86c46cf9fa3b + github.com/influxdata/influxdb-client-go/v2 v2.12.3 + github.com/influxdata/line-protocol/v2 v2.2.1 + github.com/jimlambrt/gldap v0.1.7 + github.com/kkyr/fig v0.3.2 + github.com/kylelemons/godebug v1.1.0 + github.com/prometheus/client_golang v1.16.0 + github.com/prometheus/client_model v0.4.0 + github.com/segmentio/kafka-go v0.4.42 +) + +require ( + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/antchfx/xpath v1.2.4 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/deepmap/oapi-codegen v1.8.2 // indirect + github.com/fatih/color v1.14.1 // indirect + github.com/felixge/httpsnoop v1.0.1 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/hashicorp/go-hclog v1.4.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/goidentity/v6 v6.0.1 // indirect + github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect + github.com/klauspost/compress v1.15.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mitchellh/mapstructure v1.4.1 // indirect + github.com/pelletier/go-toml v1.9.3 // indirect + github.com/pierrec/lz4/v4 v4.1.15 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/common v0.42.0 // indirect + github.com/prometheus/procfs v0.10.1 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/stretchr/testify v1.8.1 // indirect + golang.org/x/crypto v0.7.0 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/sys v0.8.0 // indirect + golang.org/x/text v0.8.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b45895f --- /dev/null +++ b/go.sum @@ -0,0 +1,253 @@ +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/antchfx/xmlquery v1.3.17 h1:d0qWjPp/D+vtRw7ivCwT5ApH/3CkQU8JOeo3245PpTk= +github.com/antchfx/xmlquery v1.3.17/go.mod h1:Afkq4JIeXut75taLSuI31ISJ/zeq+3jG7TunF7noreA= +github.com/antchfx/xpath v1.2.4 h1:dW1HB/JxKvGtJ9WyVGJ0sIoEcqftV3SqIstujI+B9XY= +github.com/antchfx/xpath v1.2.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deepmap/oapi-codegen v1.8.2 h1:SegyeYGcdi0jLLrpbCMoJxnUUn8GBXHsvr4rbzjuhfU= +github.com/deepmap/oapi-codegen v1.8.2/go.mod h1:YLgSKSDv/bZQB7N4ws6luhozi3cEdRktEqrX88CvjIw= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= +github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= +github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.11.0/go.mod h1:K+q6oSqb0W0Ininfk863uOk1lMy69l/P6txr3mVT54s= +github.com/frankban/quicktest v1.11.2/go.mod h1:K+q6oSqb0W0Ininfk863uOk1lMy69l/P6txr3mVT54s= +github.com/frankban/quicktest v1.13.0 h1:yNZif1OkDfNoDfb9zZa9aXIpejNR4F23Wely0c+Qdqk= +github.com/frankban/quicktest v1.13.0/go.mod h1:qLE0fzW0VuyUAJgPU19zByoIr0HtCHN/r/VLSOOIySU= +github.com/getkin/kin-openapi v0.61.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A= +github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= +github.com/go-ldap/ldap/v3 v3.4.5 h1:ekEKmaDrpvR2yf5Nc/DClsGG9lAmdDixe44mLzlW5r8= +github.com/go-ldap/ldap/v3 v3.4.5/go.mod h1:bMGIq3AGbytbaMwf8wdv5Phdxz0FWHTIYMSzyrYgnQs= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/grmrgecko/go-freeipa v0.0.0-20230814003934-9662b716120c h1:i35K9mKZNYjj8A0kA/tMj0hh+Ms1V9O6x8MsYSb1Dvs= +github.com/grmrgecko/go-freeipa v0.0.0-20230814003934-9662b716120c/go.mod h1:bg9+b0lCJ2+XwgNfDOCG4gjMwsHH/nwTTKaYF/T3o/Q= +github.com/grmrgecko/go-unixaccounts v0.0.0-20230814023229-86c46cf9fa3b h1:4twMmYqPkuqUobzM7GkHECiEu6SuH06xgKgHRiviZ2U= +github.com/grmrgecko/go-unixaccounts v0.0.0-20230814023229-86c46cf9fa3b/go.mod h1:ND6FYE0L6uFGxbi3A+pVpb9doB5mAUiFHjfYEDpHIHI= +github.com/hashicorp/go-hclog v1.4.0 h1:ctuWFGrhFha8BnnzxqeRGidlEcQkDyL5u8J8t5eA11I= +github.com/hashicorp/go-hclog v1.4.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/influxdata/influxdb-client-go/v2 v2.12.3 h1:28nRlNMRIV4QbtIUvxhWqaxn0IpXeMSkY/uJa/O/vC4= +github.com/influxdata/influxdb-client-go/v2 v2.12.3/go.mod h1:IrrLUbCjjfkmRuaCiGQg4m2GbkaeJDcuWoxiWdQEbA0= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/influxdata/line-protocol-corpus v0.0.0-20210519164801-ca6fa5da0184/go.mod h1:03nmhxzZ7Xk2pdG+lmMd7mHDfeVOYFyhOgwO61qWU98= +github.com/influxdata/line-protocol-corpus v0.0.0-20210922080147-aa28ccfb8937 h1:MHJNQ+p99hFATQm6ORoLmpUCF7ovjwEFshs/NHzAbig= +github.com/influxdata/line-protocol-corpus v0.0.0-20210922080147-aa28ccfb8937/go.mod h1:BKR9c0uHSmRgM/se9JhFHtTT7JTO67X23MtKMHtZcpo= +github.com/influxdata/line-protocol/v2 v2.0.0-20210312151457-c52fdecb625a/go.mod h1:6+9Xt5Sq1rWx+glMgxhcg2c0DUaehK+5TDcPZ76GypY= +github.com/influxdata/line-protocol/v2 v2.1.0/go.mod h1:QKw43hdUBg3GTk2iC3iyCxksNj7PX9aUSeYOYE/ceHY= +github.com/influxdata/line-protocol/v2 v2.2.1 h1:EAPkqJ9Km4uAxtMRgUubJyqAr6zgWM0dznKMLRauQRE= +github.com/influxdata/line-protocol/v2 v2.2.1/go.mod h1:DmB3Cnh+3oxmG6LOBIxce4oaL4CPj3OmMPgvauXh+tM= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jimlambrt/gldap v0.1.7 h1:q6W1xyjnHax/JAhjsN/EQ88+DCOEYPy/GDM7/3tk7bA= +github.com/jimlambrt/gldap v0.1.7/go.mod h1:BRdefIDhx2uYBjxL0fRBGi3eyOvAkkRIXSJYMCyzCaI= +github.com/kkyr/fig v0.3.2 h1:+vMj52FL6RJUxeKOBB6JXIMyyi1/2j1ERDrZXjoBjzM= +github.com/kkyr/fig v0.3.2/go.mod h1:ItUILF8IIzgZOMhx5xpJ1W/bviQsWRKOwKXfE/tqUoA= +github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= +github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.2.1/go.mod h1:AA49e0DZ8kk5jTOOCKNuPR6oTnBS0dYiM4FW1e6jwpg= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= +github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= +github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= +github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/segmentio/kafka-go v0.4.42 h1:qffhBZCz4WcWyNuHEclHjIMLs2slp6mZO8px+5W5tfU= +github.com/segmentio/kafka-go v0.4.42/go.mod h1:d0g15xPMqoUookug0OU75DhGZxXwCFxSLeJ4uphwJzg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/http.go b/http.go new file mode 100644 index 0000000..36138b1 --- /dev/null +++ b/http.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + "os" + + "github.com/gorilla/handlers" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// The http server output. +type HTTPOutput struct { + server *http.Server + mux *http.ServeMux + config *HTTPOutputConfig +} + +// Make a new http output controller. +func NewHTTPOutput() *HTTPOutput { + // Create the server. + s := new(HTTPOutput) + s.server = &http.Server{} + // Add update configurations. + s.Reload() + + return s +} + +// Creates the handlers and configures the server. +func (s *HTTPOutput) AddHandlers() { + // Make a new handler to replace old. + mux := http.NewServeMux() + s.mux = mux + s.server.Handler = mux + + // Register handlers. + mux.Handle(s.config.MetricsPath, handlers.CombinedLoggingHandler(os.Stdout, promhttp.HandlerFor(app.registry, promhttp.HandlerOpts{}))) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(` + Metrics Exporter + +

Metrics Exporter

+

Metrics

+ + `)) + }) +} + +// Reload configurations. +func (s *HTTPOutput) Reload() { + // Update config reference. + s.config = &app.config.HTTP + // Update the address. + s.server.Addr = fmt.Sprintf("%s:%d", s.config.BindAddr, s.config.Port) + // Update handlers incase the path was re-configured. + s.AddHandlers() +} + +// Returns rather or not output is enabled. +func (s *HTTPOutput) OutputEnabled() bool { + return s.config.Enabled +} + +// Start the HTTP output server. +func (s *HTTPOutput) Start(ctx context.Context) { + isListening := make(chan bool) + // Start server. + go s.StartWithIsListening(ctx, isListening) + // Allow the http server to initialize. + <-isListening +} + +// Starts the HTTP output server with a listening channel. +func (s *HTTPOutput) StartWithIsListening(ctx context.Context, isListening chan bool) { + // If http is disabled, stop here. + if !s.config.Enabled { + return + } + + // Watch the background context for when we need to shutdown. + go func() { + <-ctx.Done() + err := s.server.Shutdown(context.Background()) + if err != nil { + // Error from closing listeners, or context timeout: + log.Println("Error shutting down http server:", err) + } + }() + + // Start the server. + log.Println("Starting http server:", s.server.Addr) + l, err := net.Listen("tcp", s.server.Addr) + if err != nil { + log.Fatal("Listen: ", err) + } + // Now notify we are listening. + isListening <- true + // Serve http server on the listening port. + err = s.server.Serve(l) + if err != nil { + log.Println("HTTP server failure:", err) + } +} diff --git a/http_test.go b/http_test.go new file mode 100644 index 0000000..6c0ad67 --- /dev/null +++ b/http_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + + "github.com/prometheus/client_golang/prometheus" +) + +// Setup global app variable with test config for tests. +func setupHTTPTestApp() { + app = new(App) + app.flags = new(Flags) + app.flags.ConfigPath = "test/test_config.yaml" + app.ReadConfig() + + // Load exporters. + app.ldapExporter = NewLDAPExporter() + app.freeIPAExporter = NewFreeIPAExporter() + + // Add exporters to registry. + reg := prometheus.NewPedanticRegistry() + reg.Register(app.ldapExporter) + reg.Register(app.freeIPAExporter) + app.registry = reg + + // Setup influx output. + app.httpOutput = NewHTTPOutput() +} + +// Main HTTP test function. +func TestHTTP(t *testing.T) { + // Setup configs. + setupHTTPTestApp() + // Run the LDAP test server. + server := NewLDAPTestServer() + server.Run() + // Start http server. + httpServer := NewFreeIPATestServer() + httpServer.Run() + + // Setup new background context. + ctx, ctxCancel := context.WithCancel(context.Background()) + + // Start http output server. + app.httpOutput.Start(ctx) + + // Make request for metrics. + httpConf := &app.config.HTTP + url := fmt.Sprintf("http://%s:%d%s", httpConf.BindAddr, httpConf.Port, httpConf.MetricsPath) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + t.Fatal(err) + } + + // Perform request. + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + + // Close body after we're done. + defer res.Body.Close() + // Read all data from the body. + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + + // Check difference. + difference, err := FileDiff(string(body), "test/http.metrics") + if err != nil { + t.Fatal(err) + } + if difference != "" { + t.Fatalf("Difference from expected result:\n%s", difference) + } + + // We're done, let's stop serving the test LDAP server. + server.Stop() + httpServer.Stop() + ctxCancel() +} diff --git a/influx.go b/influx.go new file mode 100644 index 0000000..37b3d1b --- /dev/null +++ b/influx.go @@ -0,0 +1,325 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "encoding/json" + "log" + "time" + + influxdb2 "github.com/influxdata/influxdb-client-go/v2" + "github.com/influxdata/line-protocol/v2/lineprotocol" + io_prometheus_client "github.com/prometheus/client_model/go" + "github.com/segmentio/kafka-go" + "github.com/segmentio/kafka-go/sasl/plain" +) + +// The influx output controller, used to get InfluxDB lineprotocol and +// json output of metrics, and publish metrics on a schedule. +type InfluxOutput struct { + kwriter *kafka.Writer + client *influxdb2.Client + config *InfluxOutputConfig + // Used for testing with a stable timestamp. + OverrideTimestamp time.Time +} + +// Creates a new influx output controller. +func NewInfluxOutput() *InfluxOutput { + i := new(InfluxOutput) + // Reload the config. + i.Reload() + + return i +} + +// Reloads the configuration. +func (i *InfluxOutput) Reload() { + // Update config state. + i.config = &app.config.Influx + i.kwriter = nil + i.client = nil + + // If kafka output is configured, setup kafka output. + if len(i.config.KafkaBrokers) != 0 && i.config.KafkaTopic != "" { + // Configure dialer with configured insecure skip verify. + dialer := &kafka.Dialer{ + Timeout: 10 * time.Second, + DualStack: true, + TLS: &tls.Config{InsecureSkipVerify: i.config.KafkaInsecureSkipVerify}, + } + + // If authentication configured, add to dialer. + if i.config.KafkaUsername != "" { + dialer.SASLMechanism = plain.Mechanism{ + Username: i.config.KafkaUsername, + Password: i.config.KafkaPassword, + } + } + + // Make the kafka writer. + i.kwriter = kafka.NewWriter(kafka.WriterConfig{ + Brokers: i.config.KafkaBrokers, + Topic: i.config.KafkaTopic, + Dialer: dialer, + }) + } + + // If influx output is configured, setup client. + if i.config.InfluxServer != "" && i.config.Token != "" && i.config.Org != "" && i.config.Bucket != "" { + c := influxdb2.NewClient(i.config.InfluxServer, i.config.Token) + // To allow us to detect rather or not the client is configured, we set the pointer value. + i.client = &c + + } +} + +// Collect metrics from prometheus, then parse into lineprotocol format. +func (i *InfluxOutput) CollectAndLineprotocolFormat() ([]byte, error) { + res, err := app.registry.Gather() + if err != nil { + return nil, err + } + return i.LineprotocolFormat(res) +} + +// Parse promteheus metrics into lineprotocol format. +func (i *InfluxOutput) LineprotocolFormat(res []*io_prometheus_client.MetricFamily) ([]byte, error) { + var enc lineprotocol.Encoder + + // Get prefix for transforming prometheus name to influx. + namePrefix := namespace + "_" + enc.SetPrecision(lineprotocol.Microsecond) + now := time.Now() + if !i.OverrideTimestamp.IsZero() { + now = i.OverrideTimestamp + } + + // Each metric, send to encoder. + for _, metric := range res { + // Get name, removing prefix. + name := metric.GetName() + if name[0:len(namePrefix)] == namePrefix { + name = name[len(namePrefix):] + } + mtype := metric.GetType() + + // There can be multiple results for a metric, with different tags. + // We need to make the influx metric on each result. + for _, m := range metric.GetMetric() { + // Start new line. + enc.StartLine(namespace) + + // Add tags. + enc.AddTag("host", app.config.Hostname) + for _, l := range m.Label { + enc.AddTag(l.GetName(), l.GetValue()) + } + + // Depending on type, add field. + switch mtype { + case io_prometheus_client.MetricType_COUNTER: + enc.AddField(name, lineprotocol.MustNewValue(m.Counter.GetValue())) + case io_prometheus_client.MetricType_GAUGE: + enc.AddField(name, lineprotocol.MustNewValue(m.Gauge.GetValue())) + case io_prometheus_client.MetricType_SUMMARY: + enc.AddField(name, lineprotocol.MustNewValue(m.Summary.GetSampleSum())) + case io_prometheus_client.MetricType_UNTYPED: + enc.AddField(name, lineprotocol.MustNewValue(m.Untyped.GetValue())) + case io_prometheus_client.MetricType_HISTOGRAM: + enc.AddField(name, lineprotocol.MustNewValue(m.Histogram.GetSampleSum())) + case io_prometheus_client.MetricType_GAUGE_HISTOGRAM: + enc.AddField(name, lineprotocol.MustNewValue(m.Histogram.GetSampleSum())) + } + + // End line for next metric. + enc.EndLine(now) + } + } + + // Check for errors. + err := enc.Err() + if err != nil { + return nil, err + } + + return enc.Bytes(), nil +} + +// Collect metrics from prometheus, then parse into influx json format. +func (i *InfluxOutput) CollectAndJSONFormat() ([]byte, error) { + res, err := app.registry.Gather() + if err != nil { + return nil, err + } + return i.JSONFormat(res) +} + +// Parse promteheus metrics into influx json format. +func (i *InfluxOutput) JSONFormat(res []*io_prometheus_client.MetricFamily) ([]byte, error) { + var buff bytes.Buffer + + // Get prefix for transforming prometheus name to influx. + namePrefix := namespace + "_" + now := time.Now() + if !i.OverrideTimestamp.IsZero() { + now = i.OverrideTimestamp + } + + // Each metric, send to encoder. + for _, metric := range res { + // Get name, removing prefix. + name := metric.GetName() + if name[0:len(namePrefix)] == namePrefix { + name = name[len(namePrefix):] + } + mtype := metric.GetType() + + // There can be multiple results for a metric, with different tags. + // We need to make the influx metric on each result. + for _, m := range metric.GetMetric() { + // Create a base dictionary for housing the metric. + metric := make(map[string]interface{}, 4) + + // Add tags. + tags := make(map[string]string, len(m.Label)+1) + tags["host"] = app.config.Hostname + for _, l := range m.Label { + tags[l.GetName()] = l.GetValue() + } + metric["tags"] = tags + + // Depending on type, add field. + fields := make(map[string]interface{}, 1) + switch mtype { + case io_prometheus_client.MetricType_COUNTER: + fields[name] = m.Counter.GetValue() + case io_prometheus_client.MetricType_GAUGE: + fields[name] = m.Gauge.GetValue() + case io_prometheus_client.MetricType_SUMMARY: + fields[name] = m.Summary.GetSampleSum() + case io_prometheus_client.MetricType_UNTYPED: + fields[name] = m.Untyped.GetValue() + case io_prometheus_client.MetricType_HISTOGRAM: + fields[name] = m.Histogram.GetSampleSum() + case io_prometheus_client.MetricType_GAUGE_HISTOGRAM: + fields[name] = m.Histogram.GetSampleSum() + } + metric["fields"] = fields + + // Set metric name and ending timestamp. + metric["name"] = namespace + metric["timestamp"] = now.UnixNano() / int64(time.Microsecond) + + // Serialize into json. + serialized, err := json.Marshal(metric) + if err != nil { + return nil, err + } + // Append new line for parsing into individual metrics. + serialized = append(serialized, '\n') + // Write the serialized metric. + buff.Write(serialized) + } + } + + return buff.Bytes(), nil +} + +// Returns rather or not output is enabled. +func (i *InfluxOutput) OutputEnabled() bool { + return (i.kwriter != nil || i.client != nil) && i.config.Frequency != 0 +} + +// Start the influx output schedule. +func (i *InfluxOutput) Start(ctx context.Context) { + // If no outputs configured, stop here. + if !i.OutputEnabled() { + return + } + + // Setup schedule. + ticker := time.NewTicker(i.config.Frequency) + for { + select { + // If schedule tick, gather metrics and send output. + case <-ticker.C: + res, err := app.registry.Gather() + if err != nil { + log.Println("Error collecting metric for influx output:", err) + continue + } + + // If kafka output enabled, send output to kafka. + if i.kwriter != nil { + var messages []kafka.Message + var data []byte + + // Parse metrics based on format. + if i.config.KafkaOutputFormat == "json" { + data, err = i.JSONFormat(res) + } else { + data, err = i.LineprotocolFormat(res) + } + if err != nil { + log.Println("Error formatting metrics for kafka:", err) + } + + // Setup parser for new lines. + r := bytes.NewReader(data) + scanner := bufio.NewScanner(r) + scanner.Split(bufio.ScanLines) + // Set routing key to hostname. + routingKey := []byte(app.config.Hostname) + + // Scan formatted metrics for each individual metric. + for scanner.Scan() { + b := scanner.Bytes() + // Add back the new line as Kafka output expects it. + b = append(b, '\n') + + // Add message. + messages = append(messages, kafka.Message{ + Key: routingKey, + Value: b, + }) + } + + // Write the messages to Kafka. + err := i.kwriter.WriteMessages(ctx, messages...) + if err != nil { + log.Println("Unable to write to Kafka:", err) + } + } + + // If influx configured, write metrics to Influx's API. + if i.client != nil { + c := *i.client + writeAPI := c.WriteAPIBlocking(i.config.Org, i.config.Bucket) + + // Parse metrics to lineprotocol. + data, err := i.LineprotocolFormat(res) + if err != nil { + log.Println("Error collecting metric for influx output:", err) + continue + } + + // Send all metrics to InfluxDB. + writeAPI.WriteRecord(ctx, string(data)) + } + + // If the context is done, we need to close out connections. + case <-ctx.Done(): + if i.kwriter != nil { + i.kwriter.Close() + } + if i.client != nil { + c := *i.client + c.Close() + } + return + } + } +} diff --git a/influx_test.go b/influx_test.go new file mode 100644 index 0000000..dccaa75 --- /dev/null +++ b/influx_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "log" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +// Setup global app variable with test config for tests. +func setupInfluxTestApp() { + app = new(App) + app.flags = new(Flags) + app.flags.ConfigPath = "test/test_config.yaml" + app.ReadConfig() + + // Load exporters. + app.ldapExporter = NewLDAPExporter() + app.freeIPAExporter = NewFreeIPAExporter() + + // Add exporters to registry. + reg := prometheus.NewPedanticRegistry() + reg.Register(app.ldapExporter) + reg.Register(app.freeIPAExporter) + app.registry = reg + + // Setup influx output. + app.influxOutput = NewInfluxOutput() + app.influxOutput.OverrideTimestamp, _ = time.Parse(time.RFC3339, "2006-01-02T15:04:05Z") +} + +// Main Influx test function. +func TestInflux(t *testing.T) { + // Setup configs. + setupInfluxTestApp() + // Run the LDAP test server. + server := NewLDAPTestServer() + server.Run() + // Start http server. + httpServer := NewFreeIPATestServer() + httpServer.Run() + + // Get metrics in influx line protocol format. + data, err := app.influxOutput.CollectAndLineprotocolFormat() + if err != nil { + log.Fatalln("Error collecting metrics for telegraf:", err) + } + // Check difference from . + difference, err := FileDiff(string(data), "test/influx.lp") + if err != nil { + t.Fatal(err) + } + if difference != "" { + t.Fatalf("Difference from expected result:\n%s", difference) + } + + // Get metrics in influx json format. + data, err = app.influxOutput.CollectAndJSONFormat() + if err != nil { + log.Fatalln("Error collecting metrics for telegraf:", err) + } + // Print the encoded data. + difference, err = FileDiff(string(data), "test/influx.json") + if err != nil { + t.Fatal(err) + } + if difference != "" { + t.Fatalf("Difference from expected result:\n%s", difference) + } + + // We're done, let's stop serving the test LDAP server. + server.Stop() + httpServer.Stop() +} diff --git a/ldap.go b/ldap.go new file mode 100644 index 0000000..8f52637 --- /dev/null +++ b/ldap.go @@ -0,0 +1,305 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "log" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-ldap/ldap/v3" + "github.com/prometheus/client_golang/prometheus" +) + +// Prometheus exporter for LDAP metrics. +type LDAPExporter struct { + config *LDAPConfig + conn *ldap.Conn + mutex sync.RWMutex + metrics []metricInfo + + // Basic metrics. + up prometheus.Gauge + totalScrapes, totalFailures prometheus.Counter + + // Replica metrics and cache. + replicaLastUpdate *prometheus.Desc + replicaErrorCode *prometheus.Desc + replicaSyncInfoCache []*ReplicaSyncInfo +} + +// Make the LDAP exporter. +func NewLDAPExporter() *LDAPExporter { + e := new(LDAPExporter) + e.Reload() + + return e +} + +// Reload the configurations. +func (e *LDAPExporter) Reload() { + e.config = &app.config.LDAP + e.metrics = nil + e.setupMetrics() +} + +// Connect to the LDAP server. +func (e *LDAPExporter) connect() error { + var err error + // Setup TLS configurations. + tlsConifg := tls.Config{InsecureSkipVerify: e.config.InsecureSkipVerify} + // Load CA certificates if configured. + if e.config.CACertificate != "" { + caCert, err := os.ReadFile(e.config.CACertificate) + if err != nil { + log.Println("Error reading CA certificate:", err) + } else { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + tlsConifg.RootCAs = caCertPool + } + } + + // Depending on connect method, connect to the LDAP server. + if e.config.ConnectMethod == LDAPMethodSecure { + e.conn, err = ldap.DialURL(e.config.Address, ldap.DialWithTLSConfig(&tlsConifg)) + } else if e.config.ConnectMethod == LDAPMethodStartTLS { + e.conn, err = ldap.DialURL(e.config.Address) + if err != nil { + return err + } + err = e.conn.StartTLS(&tlsConifg) + } else { + e.conn, err = ldap.DialURL(e.config.Address) + } + // If error, may be with StartTLS, so disconnect. + if err != nil { + e.disconnect() + return err + } + + // Attempt to authenticate. + if e.config.BindPassword == "" { + err = e.conn.UnauthenticatedBind(e.config.BindDN) + } else { + err = e.conn.Bind(e.config.BindDN, e.config.BindPassword) + } + // If error in authenticating, disconnect. + if err != nil { + e.disconnect() + } + // Return error if occurred or nil if no error. + return err +} + +// Disconnect from the LDAP server. +func (e *LDAPExporter) disconnect() { + if e.conn != nil { + // Close the connection. + e.conn.Close() + e.conn = nil + // Clear the cache. + e.replicaSyncInfoCache = nil + } +} + +// Helper function to pull the `numSubordinates` attribute from LDAP. +// This attribute is helpful in getting a count of records under a tree, +// which may be user accounts or otherwise. +func (e *LDAPExporter) countSubordinates(baseDN string) (float64, error) { + // Setup request for the `numSubordinates` attribute. + searchRequest := ldap.NewSearchRequest( + baseDN+e.config.BaseDN, + ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false, + "(objectClass=*)", + []string{"numSubordinates"}, + nil, + ) + + // Search for the records. + sr, err := e.conn.Search(searchRequest) + if err != nil { + return 0, err + } + + // Get the string of the entry. + var count string + for _, entry := range sr.Entries { + count = entry.GetAttributeValue("numSubordinates") + } + + // Parse received string as float64. + return strconv.ParseFloat(count, 64) +} + +// Short hand to append the BaseDN and request only the `dn` entry, as is all that is needed for most metrics. +// The countEntriesFull function just counts each sub entry from a record. +func (e *LDAPExporter) countEntries(baseDN, filter string) (float64, error) { + return e.countEntriesFull(baseDN+e.config.BaseDN, filter, []string{"dn"}) +} + +// Count sub entries of a record and return the count. +func (e *LDAPExporter) countEntriesFull(baseDN, filter string, attributes []string) (float64, error) { + // Setup request. + searchRequest := ldap.NewSearchRequest( + baseDN, + ldap.ScopeSingleLevel, ldap.NeverDerefAliases, 0, e.config.SearchSizeLimit, false, + filter, + attributes, + nil, + ) + + // Perform the search. + sr, err := e.conn.SearchWithPaging(searchRequest, uint32(e.config.SearchSizeLimit)) + + // If no such object error returned, return count of 0 with no error. + if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { + return 0, nil + } + + // Other errors, return the error code. + if err != nil { + return 0, err + } + + // Return a float64 representation of the number of entries found. + return float64(len(sr.Entries)), nil +} + +// The standard date format used in LDAP records. +const LDAPGeneralizedTimeFormat = "20060102150405Z" + +// Information on sync status to a replica. +type ReplicaSyncInfo struct { + Host string + LastUpdateStart time.Time + LastUpdateEnd time.Time + Status string +} + +// Pull and return replica sync information. +func (e *LDAPExporter) replicaSyncInfo() ([]*ReplicaSyncInfo, error) { + // If not cached, pull it. + if len(e.replicaSyncInfoCache) == 0 { + // Combined dictionary of available peers, both masters and replicas, with their config. + peers := make(map[string][]string) + + // Get the master servers. + masterRequest := ldap.NewSearchRequest( + "cn=masters,cn=ipa,cn=etc,"+e.config.BaseDN, + ldap.ScopeSingleLevel, ldap.NeverDerefAliases, 0, 0, false, + "(objectClass=*)", + []string{}, + nil, + ) + sr, err := e.conn.Search(masterRequest) + if err != nil { + return nil, err + } + // For each master replica, add them with a simple "master" config. + for _, entry := range sr.Entries { + cn := entry.GetAttributeValue("cn") + peers[cn] = []string{"master", ""} + } + + // Get replicas. + replicaRequest := ldap.NewSearchRequest( + "cn=replicas,cn=ipa,cn=etc,"+e.config.BaseDN, + ldap.ScopeSingleLevel, ldap.NeverDerefAliases, 0, 0, false, + "(objectClass=*)", + []string{}, + nil, + ) + sr, err = e.conn.Search(replicaRequest) + if err != nil { + return nil, err + } + // Add each replica with their configs. + for _, entry := range sr.Entries { + cn := entry.GetAttributeValue("cn") + configString := entry.GetAttributeValue("ipaConfigString") + peers[cn] = strings.Split(configString, ":") + } + + // Determine if this host is an existing peer and rather or not there + // is a windows sync peer configuration. + isReplica := false + winsyncPeer := "" + for k, v := range peers { + // If configured hostname matches this peer, this is a replica. + if app.config.Hostname == k { + isReplica = true + // If the config key is winsync, note the peer for finding the replication agreements. + if len(v) == 2 && v[0] == "winsync" { + winsyncPeer = v[1] + } + } + } + + // If this host isn't a replica, there is no syncing to/from other nodes. Fail here. + if !isReplica { + return nil, fmt.Errorf("this is not an replica/master node") + } + + if winsyncPeer != "" { + // Find replication agreements for winsync. + suffix := ldap.EscapeDN(e.config.BaseDN) + dn := "cn=meTo" + ldap.EscapeDN(app.config.Hostname) + "cn=replica,cn=" + suffix + ",cn=mapping tree,cn=config" + winsyncRequest := ldap.NewSearchRequest( + dn, + ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false, + "(objectclass=nsDSWindowsReplicationAgreement)", + []string{}, + nil, + ) + sr, err = e.conn.Search(winsyncRequest) + if err != nil { + return nil, err + } + } else { + // Find replication agreements for regular ds389 replications. + filter := "(|(&(objectclass=nsds5ReplicationAgreement)(nsDS5ReplicaRoot=" + e.config.BaseDN + "))(objectclass=nsDSWindowsReplicationAgreement))" + + winsyncRequest := ldap.NewSearchRequest( + "cn=mapping tree,cn=config", + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + filter, + []string{}, + nil, + ) + sr, err = e.conn.Search(winsyncRequest) + if err != nil { + return nil, err + } + } + + // For each replication agreement, parse replica info. + for _, entry := range sr.Entries { + // Parse the last update start. + startTime, err := time.Parse(LDAPGeneralizedTimeFormat, entry.GetAttributeValue("nsds5replicaLastUpdateStart")) + if err != nil { + return nil, err + } + // Parse the last update end. + endTime, err := time.Parse(LDAPGeneralizedTimeFormat, entry.GetAttributeValue("nsds5replicaLastUpdateEnd")) + if err != nil { + return nil, err + } + // Create the replica info. + replica := &ReplicaSyncInfo{ + Host: entry.GetAttributeValue("nsDS5ReplicaHost"), + LastUpdateStart: startTime, + LastUpdateEnd: endTime, + Status: entry.GetAttributeValue("nsds5replicaLastUpdateStatus"), + } + // Append to the cache. + e.replicaSyncInfoCache = append(e.replicaSyncInfoCache, replica) + } + } + // Return cached entries. + return e.replicaSyncInfoCache, nil +} diff --git a/ldap_metrics.go b/ldap_metrics.go new file mode 100644 index 0000000..6815dda --- /dev/null +++ b/ldap_metrics.go @@ -0,0 +1,258 @@ +package main + +import ( + "log" + "regexp" + "strconv" + "strings" + + "github.com/go-ldap/ldap/v3" + "github.com/prometheus/client_golang/prometheus" +) + +// Creates a metric and appends it to the to the available metrics if enabled. +func (e *LDAPExporter) NewMetric(metricName string, docString string, t prometheus.ValueType, value func() (float64, error)) { + // If metric is disabled, stop here. + for _, metric := range e.config.DisabledMetrics { + if metric == metricName { + return + } + } + // Create info for this metric. + info := metricInfo{ + Desc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "ldap", metricName), + docString, + nil, + nil, + ), + Type: t, + Value: value, + } + // Add metric to list. + e.metrics = append(e.metrics, info) +} + +// Sets up the exporter with all needed metrics for LDAP. +func (e *LDAPExporter) setupMetrics() { + // Setup basic metrics. + e.up = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "ldap_up", + Help: "Was the last scrape of FreeIPA successful.", + }) + e.totalScrapes = prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: namespace, + Name: "ldap_scrapes_total", + Help: "Current total HAProxy scrapes.", + }) + e.totalFailures = prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: namespace, + Name: "ldap_failures_total", + Help: "Number of errors while scapping metrics.", + }) + + // Info: Number of active users. + e.NewMetric("user_active_total", "Total number of active users.", prometheus.CounterValue, func() (float64, error) { + return e.countSubordinates("cn=users,cn=accounts,") + }) + + // Info: Number of stagged users. + e.NewMetric("user_stage_total", "Total number of staged users.", prometheus.CounterValue, func() (float64, error) { + return e.countSubordinates("cn=staged users,cn=accounts,cn=provisioning,") + }) + + // Info: Number of inactive, preserved users. + e.NewMetric("user_preserved_total", "Total number of preserved users.", prometheus.CounterValue, func() (float64, error) { + return e.countSubordinates("cn=deleted users,cn=accounts,cn=provisioning,") + }) + + // Info: Number of groups. + e.NewMetric("group_total", "Total number of groups.", prometheus.CounterValue, func() (float64, error) { + return e.countEntries("cn=groups,cn=accounts,", "(objectClass=ipausergroup)") + }) + + // Info: Number of hosts. + e.NewMetric("host_total", "Total number of hosts.", prometheus.CounterValue, func() (float64, error) { + return e.countEntries("cn=computers,cn=accounts,", "(fqdn=*)") + }) + + // Info: Number of services. + e.NewMetric("service_total", "Total number of services.", prometheus.CounterValue, func() (float64, error) { + return e.countEntries("cn=services,cn=accounts,", "(krbprincipalname=*)") + }) + + // Info: Number of net groups. + e.NewMetric("net_group_total", "Total number of net groups.", prometheus.CounterValue, func() (float64, error) { + return e.countEntries("cn=ng,cn=alt,", "(ipaUniqueID=*)") + }) + + // Info: Number of host groups. + e.NewMetric("host_group_total", "Total number of host groups.", prometheus.CounterValue, func() (float64, error) { + return e.countSubordinates("cn=hostgroups,cn=accounts,") + }) + + // Info: Number of host base access crontrols. + e.NewMetric("hbac_rule_total", "Total number of HBAC rules.", prometheus.CounterValue, func() (float64, error) { + return e.countEntries("cn=hbac,", "(ipaUniqueID=*)") + }) + + // Info: Number of sudo rules. + e.NewMetric("sudo_rule_total", "Total number of sudo rules.", prometheus.CounterValue, func() (float64, error) { + return e.countEntries("cn=sudorules,cn=sudo,", "(ipaUniqueID=*)") + }) + + // Info: Number of DNS zones. + e.NewMetric("dns_zone_total", "Total number of DNS zones.", prometheus.CounterValue, func() (float64, error) { + return e.countEntries("cn=dns,", "(|(objectClass=idnszone)(objectClass=idnsforwardzone))") + }) + + // Info: Number of certificates. + e.NewMetric("certificate_total", "Total number of certificates.", prometheus.CounterValue, func() (float64, error) { + return e.countEntriesFull( + "ou=certificateRepository,ou=ca,o=ipaca", + "(certStatus=*)", + []string{"subjectName"}, + ) + }) + + // Info: Number of conflicts. + e.NewMetric("conflicts_total", "Total number of LDAP conflicts.", prometheus.CounterValue, func() (float64, error) { + return e.countEntriesFull( + e.config.BaseDN, + "(|(nsds5ReplConflict=*)(&(objectclass=ldapsubentry)(nsds5ReplConflict=*)))", + []string{"nsds5ReplConflict"}, + ) + }) + + // Info: Number of ghost replicas. + e.NewMetric("ghost_replica_total", "Total number of ghost replicas.", prometheus.CounterValue, func() (float64, error) { + // Setup ghost record request. + searchRequest := ldap.NewSearchRequest( + e.config.BaseDN, + ldap.ScopeSingleLevel, ldap.NeverDerefAliases, 0, 0, false, + "(&(objectclass=nstombstone)(nsUniqueId=ffffffff-ffffffff-ffffffff-ffffffff))", + []string{"nscpentrywsi"}, + nil, + ) + + // Search for ghost records. + sr, err := e.conn.Search(searchRequest) + if err != nil { + return 0, err + } + + // Check each entry and count replica entries. + var count float64 + for _, entry := range sr.Entries { + // If the entry wsi is a replica but doesn't contain ldap, count it as a ghost. + nscpentrywsi := entry.GetAttributeValue("nscpentrywsi") + if strings.Contains(nscpentrywsi, "replica ") && !strings.Contains(nscpentrywsi, "ldap") { + count++ + } + } + + return count, nil + }) + + // Replica specific metrics. + e.replicaLastUpdate = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "ldap", "replica_last_update"), + "The last time a replica sync occurred.", + []string{"replica"}, + nil, + ) + e.replicaErrorCode = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "ldap", "replica_error_code"), + "Error code from last replica sync.", + []string{"replica"}, + nil, + ) +} + +// Provide Promethues all descriptions of metrics exported. +func (e *LDAPExporter) Describe(ch chan<- *prometheus.Desc) { + for _, m := range e.metrics { + ch <- m.Desc + } + ch <- e.up.Desc() + ch <- e.totalScrapes.Desc() + ch <- e.totalFailures.Desc() + ch <- e.replicaLastUpdate + ch <- e.replicaErrorCode +} + +// Collects metrics exported and provide values to Prometheus. +func (e *LDAPExporter) Collect(ch chan<- prometheus.Metric) { + // Protect metrics from concurrent collects. + e.mutex.Lock() + defer e.mutex.Unlock() + + // Scrape LDAP metrics. + up := e.scrape(ch) + // Update the up status. + e.up.Set(up) + // If not up, count as a failed scrape. + if up == 0 { + e.totalFailures.Inc() + } + + // Send basic metrics. + ch <- e.up + ch <- e.totalScrapes + ch <- e.totalFailures +} + +// Test LDAP and pull metrics. +func (e *LDAPExporter) scrape(ch chan<- prometheus.Metric) float64 { + // Increment the total number of scrapes. + e.totalScrapes.Inc() + + // Attempt to connect. + err := e.connect() + // If failure, LDAP is down. + if err != nil { + log.Println("Error connecting to ldap:", err) + return 0 + } + // Disconnect after done scrapping. + defer e.disconnect() + + // Update data for each metric. + for _, m := range e.metrics { + // Get the value of the metric. + value, err := m.Value() + // If an error occurred getting the value, log it for debug. + if err != nil { + log.Printf("Error retrieving value for metric %s: %s\n", m.Desc.String(), err) + } + + // Update the value. + ch <- prometheus.MustNewConstMetric(m.Desc, m.Type, value) + } + + // Get replica sync status. + replicaSyncInfo, err := e.replicaSyncInfo() + // If error returned, log it. + if err != nil { + log.Printf("Error retrieving replica sync info: %s\n", err) + } + // Error code parsing. + statusRx := regexp.MustCompile(`Error \(([0-9-]+)\)`) + // Update metric for each replica. + for _, replica := range replicaSyncInfo { + // Get the last update date UNIX time and send metric. + ch <- prometheus.MustNewConstMetric(e.replicaLastUpdate, prometheus.GaugeValue, float64(replica.LastUpdateEnd.Unix()), replica.Host) + // Check if status code can be parsed. + match := statusRx.FindStringSubmatch(replica.Status) + if len(match) == 2 { + // Make status code a float64 as is used by Prometheus. Ignoring errors as none should exist with the regex match being integers only. + errorCode, _ := strconv.ParseFloat(match[1], 64) + // Send the status code as a metric. + ch <- prometheus.MustNewConstMetric(e.replicaErrorCode, prometheus.GaugeValue, errorCode, replica.Host) + } + } + + // At this point, we were able to connect, so LDAP is up. + return 1 +} diff --git a/ldap_test.go b/ldap_test.go new file mode 100644 index 0000000..da6ed52 --- /dev/null +++ b/ldap_test.go @@ -0,0 +1,356 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "os" + "regexp" + "strings" + "testing" + "unicode" + + "github.com/jimlambrt/gldap" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +// Setup global app variable with test config for tests. +func setupLdapTestApp() { + app = new(App) + app.flags = new(Flags) + app.flags.ConfigPath = "test/test_config.yaml" + app.ReadConfig() + app.ldapExporter = NewLDAPExporter() +} + +// Base LDAP entry, using this as the library doesn't export variables that are useful +// for working the way I wanted with a generic parser. +type ldapEntry struct { + dn string + attributes map[string][]string +} + +// Prints the ldap entry with all attributes in ldif format. +// Mainly used in debugging. +func (e *ldapEntry) Print() { + fmt.Printf("DN: %s\n", e.dn) + for name, attr := range e.attributes { + for _, v := range attr { + fmt.Printf("%s: %s\n", name, v) + } + } +} + +// Parse ldif file and return all entries. +func ParseLDIF(ldifPath string) (res []*ldapEntry) { + // Open the file provided. + ldif, err := os.Open(ldifPath) + if err != nil { + log.Fatal("Error opening tests:", err) + } + defer ldif.Close() + + // Basic variables used in parsing. + var dn, fullLine string + attributes := make(map[string][]string) + + // Parsing handlers. + scanner := bufio.NewScanner(ldif) + scanner.Split(bufio.ScanLines) + parseRx := regexp.MustCompile(`([a-zA-Z0-9:]+):\s(.*)`) + + // Check each line of the file amd parse. + for scanner.Scan() { + line := scanner.Text() + // Ignore comment and blank lines. + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Check if first chracter is a space. + isWrapped := false + for _, c := range line { + isWrapped = unicode.IsSpace(c) + break + } + + // If this is wrapped from the last line, append and read next line. + if isWrapped { + // Remove leading spaces from line. + line = strings.TrimLeftFunc(line, unicode.IsSpace) + + // Add this string to the full line. + fullLine += line + continue + } + + // If the full line has data, parse it. + if fullLine != "" { + // Verify we can parse this line. + if !parseRx.MatchString(fullLine) { + log.Println("Unable to parse ldif line:", fullLine) + fullLine = line + continue + } + + // Parse line. + match := parseRx.FindStringSubmatch(fullLine) + + // If is a new entry, append the entry. + if match[1] == "dn" { + if dn != "" { + entry := &ldapEntry{ + dn: dn, + attributes: attributes, + } + res = append(res, entry) + } + // Clear attributes and change the DN to the newly discovered entry. + attributes = make(map[string][]string) + dn = match[2] + } else { + // This is an attribute, lets add it. + _, ok := attributes[match[1]] + if !ok { + attributes[match[1]] = []string{ + match[2], + } + } else { + attributes[match[1]] = append(attributes[match[1]], match[2]) + } + } + } + + // This is a new line, could have additional lines to add with LDIF wrapping. + fullLine = line + } + + // If the full line has data, parse it. + if fullLine != "" { + // Verify we can parse this line. + if !parseRx.MatchString(fullLine) { + log.Println("Unable to parse ldif line:", fullLine) + return + } + + // Parse line. + match := parseRx.FindStringSubmatch(fullLine) + + // If is a new entry, append the entry. + if match[1] != "dn" { + // This is an attribute, lets add it. + _, ok := attributes[match[1]] + if !ok { + attributes[match[1]] = []string{ + match[2], + } + } else { + attributes[match[1]] = append(attributes[match[1]], match[2]) + } + } + } + + // As this is the end, we need to create the last decoded entry. + if dn != "" { + entry := &ldapEntry{ + dn: dn, + attributes: attributes, + } + res = append(res, entry) + } + + return +} + +const ldapPort = 10389 + +// LDAP test server. +type LDAPTestServer struct { + server *gldap.Server + responses map[string]string +} + +func NewLDAPTestServer() *LDAPTestServer { + s := new(LDAPTestServer) + // Requested DN to response ldif file map. + s.responses = map[string]string{ + "cn=users,cn=accounts,dc=example,dc=com": "test/ldap_user_sub.ldif", + "cn=staged users,cn=accounts,cn=provisioning,dc=example,dc=com": "test/ldap_stagged_sub.ldif", + "cn=deleted users,cn=accounts,cn=provisioning,dc=example,dc=com": "test/ldap_deleted_sub.ldif", + "cn=groups,cn=accounts,dc=example,dc=com": "test/ldap_groups.ldif", + "cn=computers,cn=accounts,dc=example,dc=com": "test/ldap_computers.ldif", + "cn=services,cn=accounts,dc=example,dc=com": "test/ldap_services.ldif", + "cn=ng,cn=alt,dc=example,dc=com": "test/ldap_netgroups.ldif", + "cn=hostgroups,cn=accounts,dc=example,dc=com": "test/ldap_hostgroups.ldif", + "cn=hbac,dc=example,dc=com": "test/ldap_hbac.ldif", + "cn=sudorules,cn=sudo,dc=example,dc=com": "test/ldap_sudo.ldif", + "cn=masters,cn=ipa,cn=etc,dc=example,dc=com": "test/ldap_masters.ldif", + "cn=mapping tree,cn=config": "test/ldap_mapping_tree.ldif", + } + return s +} + +// Simple ldap bind to verify authentication with the ldap metrics work. +func (s *LDAPTestServer) bindHandler(w *gldap.ResponseWriter, r *gldap.Request) { + // Setup invalid response which will be sent unless the response code is changed by a successful login. + resp := r.NewBindResponse( + gldap.WithResponseCode(gldap.ResultInvalidCredentials), + ) + // Send response at the end of the function call. + defer func() { + w.Write(resp) + }() + + // Decode bind message from request. + m, err := r.GetSimpleBindMessage() + if err != nil { + log.Printf("not a simple bind message: %s", err) + return + } + + // If credentials match config, return success. + if m.UserName == app.config.LDAP.BindDN && string(m.Password) == app.config.LDAP.BindPassword { + resp.SetResultCode(gldap.ResultSuccess) + log.Println("bind success") + return + } +} + +// Write LDIF entries from file to LDAP request. +func (s *LDAPTestServer) writeLdif(w *gldap.ResponseWriter, r *gldap.Request, ldifPath string) { + // Parse entries. + entries := ParseLDIF(ldifPath) + // For each entry, write it to the request. + for _, entry := range entries { + // Print debug info. + // entry.Print() + + // Make a response entry for this request and write it. + w.Write(r.NewSearchResponseEntry(entry.dn, gldap.WithAttributes(entry.attributes))) + } +} + +// Handle LDAP search requests. +func (s *LDAPTestServer) searchHandler(w *gldap.ResponseWriter, r *gldap.Request) { + // Setup general response. + resp := r.NewSearchDoneResponse() + defer func() { + w.Write(resp) + }() + + // Get message from request. + m, err := r.GetSearchMessage() + if err != nil { + log.Printf("not a search message: %s", err) + return + } + + // Print debug info. + // log.Printf("search base dn: %s", m.BaseDN) + // log.Printf("search scope: %d", m.Scope) + // log.Printf("search filter: %s", m.Filter) + // log.Printf("search attributes: %v", m.Attributes) + + // Send test ldif response based on request DN. + ldifFile, ok := s.responses[m.BaseDN] + if ok { + s.writeLdif(w, r, ldifFile) + resp.SetResultCode(gldap.ResultSuccess) + } +} + +// Helper to start and wait for server to be running. +func (s *LDAPTestServer) Run() { + go s.Start() + for s.server == nil || !s.server.Ready() { + } +} + +// Helper to stop LDAP server. +func (s *LDAPTestServer) Stop() { + if s.server != nil { + s.server.Stop() + } +} + +// Setup LDAP test server for verifying metrics. +func (s *LDAPTestServer) Start() { + server, err := gldap.NewServer() + if err != nil { + log.Fatalf("unable to create server: %s", err.Error()) + } + // Set global variable for test function access. + s.server = server + + // create a router and add a bind handler + r, err := gldap.NewMux() + if err != nil { + log.Fatalf("unable to create router: %s", err.Error()) + } + r.Bind(s.bindHandler) + r.Search(s.searchHandler) + server.Router(r) + + // Run the LDAP test server. + server.Run(fmt.Sprintf("127.0.0.1:%d", ldapPort)) +} + +// Main LDAP test function that verifies metrics for LDAP works. +func TestLdap(t *testing.T) { + // Setup configs. + setupLdapTestApp() + // Run the LDAP test server. + server := NewLDAPTestServer() + server.Run() + + // Open the expected prometheus metrics. + expected, err := os.Open("test/ldap.metrics") + if err != nil { + t.Fatal("Error opening tests:", err) + } + defer expected.Close() + + // Test the LDAP exporter and verify metrics match what's expected. + err = testutil.CollectAndCompare(app.ldapExporter, expected) + // If results are not as expected, fail test with the error. + if err != nil { + t.Fatal("Unexpected metrics returned:", err) + } + + // Remove all responses from ldap server to cause failure in all metrics. + server.responses = nil + + // Open the expected prometheus metrics. + expected, err = os.Open("test/ldap_fail.metrics") + if err != nil { + t.Fatal("Error opening tests:", err) + } + defer expected.Close() + + // Test the LDAP exporter and verify metrics match what's expected. + err = testutil.CollectAndCompare(app.ldapExporter, expected) + // If results are not as expected, fail test with the error. + if err != nil { + t.Fatal("Unexpected metrics returned:", err) + } + + // Test failure to connect. + app.config.LDAP.Address = "bad-address" + + // Open the expected prometheus metrics. + expected, err = os.Open("test/ldap_fail_connect.metrics") + if err != nil { + t.Fatal("Error opening tests:", err) + } + defer expected.Close() + + // Test the LDAP exporter and verify metrics match what's expected. + err = testutil.CollectAndCompare(app.ldapExporter, expected) + // If results are not as expected, fail test with the error. + if err != nil { + t.Fatal("Unexpected metrics returned:", err) + } + + // We're done, let's stop serving the test LDAP server. + server.Stop() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7127e5d --- /dev/null +++ b/main.go @@ -0,0 +1,137 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/prometheus/client_golang/prometheus" +) + +// Basic application info. +const ( + serviceName = "freeipa-health-metrics" + serviceDescription = "Provides metrics of FreeIPA's health" + serviceVersion = "0.1" + namespace = "freeipa" +) + +// The standard prometheus metric info structure which includes the description, type, +// and a sub function to collect the value. +type metricInfo struct { + Desc *prometheus.Desc + Type prometheus.ValueType + Value func() (float64, error) +} + +// The global application structure used to access diffent state structures and configuration. +type App struct { + flags *Flags + config *Config + registry *prometheus.Registry + ldapExporter *LDAPExporter + freeIPAExporter *FreeIPAExporter + httpOutput *HTTPOutput + influxOutput *InfluxOutput +} + +// Global variable for the app structure to make it easy to get the active state. +var app *App + +// The main program function/run loop. +func main() { + // Setup the app structure. + app = new(App) + app.ParseFlags() + app.ReadConfig() + + // Load exporters. + app.ldapExporter = NewLDAPExporter() + app.freeIPAExporter = NewFreeIPAExporter() + + // Add exporters to registry. + reg := prometheus.NewPedanticRegistry() + reg.Register(app.ldapExporter) + reg.Register(app.freeIPAExporter) + app.registry = reg + + // Load outputs. + app.httpOutput = NewHTTPOutput() + app.influxOutput = NewInfluxOutput() + + // If requested telegraf output. + if app.flags.TelegrafOutput { + // Get metrics in influx line protocol format. + data, err := app.influxOutput.CollectAndLineprotocolFormat() + if err != nil { + log.Fatalln("Error collecting metrics for telegraf:", err) + } + // Print the encoded data. + fmt.Println(string(data)) + return + } + + // Setup context with cancellation function to allow background services to gracefully stop. + ctx, ctxCancel := context.WithCancel(context.Background()) + + // Start http output server. + go app.httpOutput.Start(ctx) + + // Start the influx output schedule. + go app.influxOutput.Start(ctx) + + // Monitor common signals. + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + + // Run program until cancelled. + for sig := range c { + switch sig { + // If hangup signal receivied, reload the configurations. + case syscall.SIGHUP: + log.Println("Reloading configurations") + // Capture old config for checks. + oldConfig := app.config + // Get prior state of influx output. + influxOutputWasEnabled := app.influxOutput.OutputEnabled() + + // Read new config. + app.ReadConfig() + + // Reload config on each exporter and output. + app.ldapExporter.Reload() + app.freeIPAExporter.Reload() + app.httpOutput.Reload() + app.influxOutput.Reload() + + // Check if httpd server config changes require restart. + httpNeedsRestart := oldConfig.HTTP.BindAddr != app.config.HTTP.BindAddr || oldConfig.HTTP.Port != app.config.HTTP.Port || oldConfig.HTTP.Enabled != app.config.HTTP.Enabled + // Check if influx output config changes require restart. + influxNeedsRestart := app.influxOutput.OutputEnabled() != influxOutputWasEnabled || oldConfig.Influx.Frequency != app.config.Influx.Frequency + + // If either output service requires restart, restart both. + if httpNeedsRestart || influxNeedsRestart { + // Cancel prior background context. + ctxCancel() + + // Setup new background context. + ctx, ctxCancel = context.WithCancel(context.Background()) + + // Start http output server. + go app.httpOutput.Start(ctx) + + // Start the influx output schedule. + go app.influxOutput.Start(ctx) + } + + // The default signal is either termination or interruption, so cancel the + // background context and exit this program. + default: + ctxCancel() + return + } + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..4530700 --- /dev/null +++ b/readme.md @@ -0,0 +1,131 @@ +# freeipa-health-metrics + +A prometheus/influxdb exporter for FreeIPA metrics to provide indication of cluster health. + +Requirements: + +- FreeIPA 4 or later +- Golang 1.20 or later +- FreeIPA user with admin privileges + +## Install + +You can install either by downloading the latest binary release or by building. + +### Building + +Building should be as simple as running: + +```bash +go build +``` + +### Running as a service + +You are likely going to want to run the exporter as a service to ensure it runs at boot and restarts in case of failures. Below is an example service config file you can place in `/etc/systemd/system/freeipa-health-metrics.service` on a linux system to run as a service if you install the binary in `/usr/local/bin/`. + +```systemd +[Unit] +Description=FreeIPA Health Metrics +After=network.target +StartLimitIntervalSec=500 +StartLimitBurst=5 + +[Service] +ExecStart=/usr/local/bin/freeipa-health-metrics +ExecReload=/bin/kill -s HUP $MAINPID +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target +``` + +Once the service file is installed, you can run the following to start it: + +```bash +systemctl daemon-reload +systemctl start freeipa-health-metrics.service +``` + +## Config + +The default configuration paths are: + +- `./config.yaml` - A file in the current working directory. +- `~/.config/freeipa-health-metrics/config.yaml` - A file in your home directory's config path. +- `/etc/ipa/freeipa-health-metrics.yaml` - A file in the IPA config folder. + +### For local monitoring + +```yaml +--- +ldap: + insecure_skip_verify: true + connect_method: Secure + base_dn: dc=example,dc=com + bind_dn: uid=freeipa-health-metrics,cn=users,cn=accounts,dc=example,dc=com + bind_password: PASSWORD + +freeipa: + krb5_realm: EXAMPLE.COM + insecure_skip_verify: true + username: freeipa-health-metrics + password: PASSWORD +``` + +### For remote monitoring + +```yaml +--- +hostname: ipa1.example.com +ldap: + insecure_skip_verify: true + connect_method: Secure + base_dn: dc=example,dc=com + bind_dn: uid=freeipa-health-metrics,cn=users,cn=accounts,dc=example,dc=com + bind_password: PASSWORD + +freeipa: + krb5_realm: EXAMPLE.COM + insecure_skip_verify: true + username: freeipa-health-metrics + password: PASSWORD + + # Disable metrics which only work locally. + disabled_metrics: + - krb5_auth + - krb5_workers + - proxy_secret + - group_members + - ipa_cert_auto_renew + - ldap_cert_auto_renew +``` + +### Output to InfluxDB only + +```yaml +--- +ldap: + insecure_skip_verify: true + connect_method: Secure + base_dn: dc=example,dc=com + bind_dn: uid=freeipa-health-metrics,cn=users,cn=accounts,dc=example,dc=com + bind_password: PASSWORD + +freeipa: + krb5_realm: EXAMPLE.COM + insecure_skip_verify: true + username: freeipa-health-metrics + password: PASSWORD + +influx_output: + frequency: 5m + influx_server: http://example.com:8086 + token: INFLUX_TOKEN + org: company + bucket: freeipa + +http: + enabled: false +``` diff --git a/test/cert.pem b/test/cert.pem new file mode 100644 index 0000000..b8aadb3 --- /dev/null +++ b/test/cert.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIE0DCCArgCCQCTUB7qC+C7MDANBgkqhkiG9w0BAQsFADApMScwJQYDVQQDDB5p +cGExLmV4YW1wbGUuY29tLE89RVhBTVBMRS5DT00wIBcNMjMwODMwMTcxNDA0WhgP +MjA1MzA4MjIxNzE0MDRaMCkxJzAlBgNVBAMMHmlwYTEuZXhhbXBsZS5jb20sTz1F +WEFNUExFLkNPTTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMRg/nBP +pAT/jQExqS9icLDkQwmTMX745VWKxLFGfIox2CnA5d7qvFKaeaZJ10qy3utIrTj8 +eEfA8Yhrn5Kwu7TUWmuVGi7I7hBFqXG1Qtp1SwGDw1r8RYLuwiByaCU/lS7KBpd2 +r7gGo0I0KBvE/HCd9pJ4ABv+ZpJgBBgvXJGrVISV8UV0W85cMwDrrNOBrbENIqG5 +383Gf3qlNgXL68tQBWRD5U58VlpYx4ETV2sY6ctWRK3VMW0WVM3lg6/IqNhRCwri +XvNyoX+KNC56sfGdAy9nLXjd5bgowSiC+0whH4qW2dS+FIIscfFgnAefN42Z84Co +CeYzcdwvp47almZe+iLQFgZCEwTCG3CxhA2C02pu2krjCpolSPZYNT6qgIhULXcE +VB1BKrno0Zkz9jac+1ffd/gD2F8ufGoRZHgOwMdP6EpHN2dKr9pj+JD1NANjSAfN +495VTS2D5681zu7qvDpmqHR6Jj9XaLflpIQlN/ULGpV0zw7pCejErq6YkqA4ES3p +1Nr5rnT7U2oz25++TMhlRzkcacVQGy8x6vuXpRA2rVJMH7jDfee1CZu2+C82cZeZ +eAHn1MYGMtz/6RUEEMuf5HfLMbiYuqktTKRfxxFxO6XpEiE3EyI0jTZjXmQUwn4w +4fKMcpgpiRLVnIrA9FCQhKJe5kA1pP69gIsfAgMBAAEwDQYJKoZIhvcNAQELBQAD +ggIBAMMmZoEvF4/koymO9aTBmLMMgZphXyss5ng7MDAFntyG/z+zBpVuzfPb1mhF +Nkege5qhMUfIUmMsuBCTdfVn1OlhwHrTNIggmEvWHeucniA2WLZy7HIucPqP+mNN +XVFE7PM9XVS9CayBU/k44/7sxJbxH1YK2gUYsAliBJKup73+jgKslqh0ISW0AhJS +4JFR02z0FLypyFcUmnRj7fatGsAr8NRajPDVaDE+F5VISwYuBzNGzgefj1O+xnlL +bWfRn95GKmMlA4k15JXwjSDV1nZqgh1JAw9Alp2scAZwMnoYoQhj882nkxLkNc9+ +uKApC6aNYgJEjrR/CUTDLagqcSkzETHUhVyMpXPDGWsp+u9jp7jEYIPhx4rbsAv/ +sCl3dZpB9IKjZUOprq4b1T/qF7rohUtrbawwYpY8ai5o+VlSeLVT7+j194JdZuSc +ROjlzw/47VVMlGdwYEYr1caTutFmVxnUnlaPrn27OnfIGEIKrE1WncveBgRNJ8WF +eG0WObRTP+RRhraR7tzigvWx9Yhjyk8tSyUbzvICmlnqWKewet4ZwCZ/sw9r0Kuk +TIC0/iPzd1EKxMNf3eoHw0xhumXbNAniXSsXSXAUtDltnZfCtzvcEQL1r8uOl4bK +JXfbb0+HmAw+PWWuNQm1g4MCPTEH5e/ij5WyGgDm36vu3HEm +-----END CERTIFICATE----- diff --git a/test/freeipa.metrics b/test/freeipa.metrics new file mode 100644 index 0000000..e938c88 --- /dev/null +++ b/test/freeipa.metrics @@ -0,0 +1,45 @@ +# HELP freeipa_config_dna_range DNA range is defined. +# TYPE freeipa_config_dna_range gauge +freeipa_config_dna_range 1 +# HELP freeipa_config_ipa_ca_issued_cert The FreeIPA API was issued a certificate by the CA cert. +# TYPE freeipa_config_ipa_ca_issued_cert gauge +freeipa_config_ipa_ca_issued_cert 1 +# HELP freeipa_config_ipa_ca_issued_ldap_cert The LDAP cert was issued a certificate by the CA cert. +# TYPE freeipa_config_ipa_ca_issued_ldap_cert gauge +freeipa_config_ipa_ca_issued_ldap_cert 1 +# HELP freeipa_config_ipa_cert_auto_renew The FreeIPA API certificate is managed and set to auto renew. +# TYPE freeipa_config_ipa_cert_auto_renew gauge +freeipa_config_ipa_cert_auto_renew 1 +# HELP freeipa_config_ipa_earliest_cert_expiry The earliest certificate expiry date for FreeIPA API. +# TYPE freeipa_config_ipa_earliest_cert_expiry gauge +freeipa_config_ipa_earliest_cert_expiry 2.639495644e+09 +# HELP freeipa_config_krb5_auth Kerberos can authenticate. +# TYPE freeipa_config_krb5_auth gauge +freeipa_config_krb5_auth 1 +# HELP freeipa_config_krb5_workers Workers match processors. +# TYPE freeipa_config_krb5_workers gauge +freeipa_config_krb5_workers 0 +# HELP freeipa_config_ldap_cert_auto_renew The LDAP certificate is managed and set to auto renew. +# TYPE freeipa_config_ldap_cert_auto_renew gauge +freeipa_config_ldap_cert_auto_renew 1 +# HELP freeipa_config_ldap_earliest_cert_expiry The earliest certificate expiry date for LDAP. +# TYPE freeipa_config_ldap_earliest_cert_expiry gauge +freeipa_config_ldap_earliest_cert_expiry 2.639495644e+09 +# HELP freeipa_config_proxy_secret Proxy secret is configured. +# TYPE freeipa_config_proxy_secret gauge +freeipa_config_proxy_secret 1 +# HELP freeipa_config_renewal_master This server is the renewal master. +# TYPE freeipa_config_renewal_master gauge +freeipa_config_renewal_master 1 +# HELP freeipa_failures_total Number of errors while scapping metrics. +# TYPE freeipa_failures_total counter +freeipa_failures_total 0 +# HELP freeipa_freeipa_failed_tests Number of failed tests in the most recent scrape. +# TYPE freeipa_freeipa_failed_tests gauge +freeipa_freeipa_failed_tests 1 +# HELP freeipa_scrapes_total Current total HAProxy scrapes. +# TYPE freeipa_scrapes_total counter +freeipa_scrapes_total 1 +# HELP freeipa_up Was the last scrape of FreeIPA successful. +# TYPE freeipa_up gauge +freeipa_up 1 diff --git a/test/freeipa_ca_is_enabled.json b/test/freeipa_ca_is_enabled.json new file mode 100644 index 0000000..d7fa8ba --- /dev/null +++ b/test/freeipa_ca_is_enabled.json @@ -0,0 +1,11 @@ +{ + "result": { + "result": true, + "value": null, + "summary": null + }, + "version": "4.6.8", + "error": null, + "id": null, + "principal": "freeipa-health-metrics@EXAMPLE.COM" +} \ No newline at end of file diff --git a/test/freeipa_ca_show.json b/test/freeipa_ca_show.json new file mode 100644 index 0000000..7dd1152 --- /dev/null +++ b/test/freeipa_ca_show.json @@ -0,0 +1,29 @@ +{ + "result": { + "result": { + "dn": "cn=ipa,cn=cas,cn=ca,dc=example,dc=com", + "certificate": "MIIE0DCCArgCCQCTUB7qC+C7MDANBgkqhkiG9w0BAQsFADApMScwJQYDVQQDDB5pcGExLmV4YW1wbGUuY29tLE89RVhBTVBMRS5DT00wIBcNMjMwODMwMTcxNDA0WhgPMjA1MzA4MjIxNzE0MDRaMCkxJzAlBgNVBAMMHmlwYTEuZXhhbXBsZS5jb20sTz1FWEFNUExFLkNPTTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMRg/nBPpAT/jQExqS9icLDkQwmTMX745VWKxLFGfIox2CnA5d7qvFKaeaZJ10qy3utIrTj8eEfA8Yhrn5Kwu7TUWmuVGi7I7hBFqXG1Qtp1SwGDw1r8RYLuwiByaCU/lS7KBpd2r7gGo0I0KBvE/HCd9pJ4ABv+ZpJgBBgvXJGrVISV8UV0W85cMwDrrNOBrbENIqG5383Gf3qlNgXL68tQBWRD5U58VlpYx4ETV2sY6ctWRK3VMW0WVM3lg6/IqNhRCwriXvNyoX+KNC56sfGdAy9nLXjd5bgowSiC+0whH4qW2dS+FIIscfFgnAefN42Z84CoCeYzcdwvp47almZe+iLQFgZCEwTCG3CxhA2C02pu2krjCpolSPZYNT6qgIhULXcEVB1BKrno0Zkz9jac+1ffd/gD2F8ufGoRZHgOwMdP6EpHN2dKr9pj+JD1NANjSAfN495VTS2D5681zu7qvDpmqHR6Jj9XaLflpIQlN/ULGpV0zw7pCejErq6YkqA4ES3p1Nr5rnT7U2oz25++TMhlRzkcacVQGy8x6vuXpRA2rVJMH7jDfee1CZu2+C82cZeZeAHn1MYGMtz/6RUEEMuf5HfLMbiYuqktTKRfxxFxO6XpEiE3EyI0jTZjXmQUwn4w4fKMcpgpiRLVnIrA9FCQhKJe5kA1pP69gIsfAgMBAAEwDQYJKoZIhvcNAQELBQADggIBAMMmZoEvF4/koymO9aTBmLMMgZphXyss5ng7MDAFntyG/z+zBpVuzfPb1mhFNkege5qhMUfIUmMsuBCTdfVn1OlhwHrTNIggmEvWHeucniA2WLZy7HIucPqP+mNNXVFE7PM9XVS9CayBU/k44/7sxJbxH1YK2gUYsAliBJKup73+jgKslqh0ISW0AhJS4JFR02z0FLypyFcUmnRj7fatGsAr8NRajPDVaDE+F5VISwYuBzNGzgefj1O+xnlLbWfRn95GKmMlA4k15JXwjSDV1nZqgh1JAw9Alp2scAZwMnoYoQhj882nkxLkNc9+uKApC6aNYgJEjrR/CUTDLagqcSkzETHUhVyMpXPDGWsp+u9jp7jEYIPhx4rbsAv/sCl3dZpB9IKjZUOprq4b1T/qF7rohUtrbawwYpY8ai5o+VlSeLVT7+j194JdZuScROjlzw/47VVMlGdwYEYr1caTutFmVxnUnlaPrn27OnfIGEIKrE1WncveBgRNJ8WFeG0WObRTP+RRhraR7tzigvWx9Yhjyk8tSyUbzvICmlnqWKewet4ZwCZ/sw9r0KukTIC0/iPzd1EKxMNf3eoHw0xhumXbNAniXSsXSXAUtDltnZfCtzvcEQL1r8uOl4bKJXfbb0+HmAw+PWWuNQm1g4MCPTEH5e/ij5WyGgDm36vu3HEm", + "cn": [ + "ipa" + ], + "ipacaissuerdn": [ + "CN=Certificate Authority,O=EXAMPLE.COM" + ], + "ipacasubjectdn": [ + "CN=Certificate Authority,O=EXAMPLE.COM" + ], + "ipacaid": [ + "c5b227ea-6490-45bf-9da4-f162e6896440" + ], + "description": [ + "IPA CA" + ] + }, + "value": "ipa", + "summary": null + }, + "version": "4.6.8", + "error": null, + "id": null, + "principal": "freeipa-health-metrics@EXAMPLE.COM" +} \ No newline at end of file diff --git a/test/freeipa_config_show.json b/test/freeipa_config_show.json new file mode 100644 index 0000000..124d0dc --- /dev/null +++ b/test/freeipa_config_show.json @@ -0,0 +1,81 @@ +{ + "result": { + "result": { + "ipausersearchfields": [ + "uid,givenname,sn,telephonenumber,ou,title" + ], + "ipauserauthtype": [ + "otp" + ], + "ca_renewal_master_server": "ipa1.example.com", + "ipadefaultprimarygroup": [ + "Example" + ], + "ipapwdexpadvnotify": [ + "14" + ], + "ipasearchtimelimit": [ + "60" + ], + "ipasearchrecordslimit": [ + "100" + ], + "ntp_server_server": [ + "ipa1.example.com", + "ipa2.example.com", + "ipa3.example.com" + ], + "ipadefaultloginshell": [ + "/bin/bash" + ], + "ipacertificatesubjectbase": [ + "O=EXAMPLE.COM" + ], + "ipa_master_server": [ + "ipa1.example.com", + "ipa2.example.com", + "ipa3.example.com" + ], + "ipaconfigstring": [ + "KDC:Disable Last Success" + ], + "dn": "cn=ipaConfig,cn=etc,dc=example,dc=com", + "ipakrbauthzdata": [ + "MS-PAC", + "nfs:NONE" + ], + "ipagroupsearchfields": [ + "cn,description" + ], + "ca_server_server": [ + "ipa1.example.com", + "ipa2.example.com", + "ipa3.example.com" + ], + "ipamaxusernamelength": [ + "32" + ], + "ipaselinuxusermapdefault": [ + "unconfined_u:s0-s0:c0.c1023" + ], + "ipahomesrootdir": [ + "/home/staff" + ], + "ipamigrationenabled": [ + "FALSE" + ], + "ipadefaultemaildomain": [ + "example.com" + ], + "ipaselinuxusermaporder": [ + "guest_u:s0$xguest_u:s0$user_u:s0$staff_u:s0-s0:c0.c1023$unconfined_u:s0-s0:c0.c1023" + ] + }, + "value": null, + "summary": null + }, + "version": "4.6.8", + "error": null, + "id": null, + "principal": "freeipa-health-metrics@EXAMPLE.COM" +} \ No newline at end of file diff --git a/test/freeipa_fail.metrics b/test/freeipa_fail.metrics new file mode 100644 index 0000000..638ee6f --- /dev/null +++ b/test/freeipa_fail.metrics @@ -0,0 +1,45 @@ +# HELP freeipa_config_dna_range DNA range is defined. +# TYPE freeipa_config_dna_range gauge +freeipa_config_dna_range 0 +# HELP freeipa_config_ipa_ca_issued_cert The FreeIPA API was issued a certificate by the CA cert. +# TYPE freeipa_config_ipa_ca_issued_cert gauge +freeipa_config_ipa_ca_issued_cert 0 +# HELP freeipa_config_ipa_ca_issued_ldap_cert The LDAP cert was issued a certificate by the CA cert. +# TYPE freeipa_config_ipa_ca_issued_ldap_cert gauge +freeipa_config_ipa_ca_issued_ldap_cert 0 +# HELP freeipa_config_ipa_cert_auto_renew The FreeIPA API certificate is managed and set to auto renew. +# TYPE freeipa_config_ipa_cert_auto_renew gauge +freeipa_config_ipa_cert_auto_renew 1 +# HELP freeipa_config_ipa_earliest_cert_expiry The earliest certificate expiry date for FreeIPA API. +# TYPE freeipa_config_ipa_earliest_cert_expiry gauge +freeipa_config_ipa_earliest_cert_expiry 2.639495644e+09 +# HELP freeipa_config_krb5_auth Kerberos can authenticate. +# TYPE freeipa_config_krb5_auth gauge +freeipa_config_krb5_auth 1 +# HELP freeipa_config_krb5_workers Workers match processors. +# TYPE freeipa_config_krb5_workers gauge +freeipa_config_krb5_workers 0 +# HELP freeipa_config_ldap_cert_auto_renew The LDAP certificate is managed and set to auto renew. +# TYPE freeipa_config_ldap_cert_auto_renew gauge +freeipa_config_ldap_cert_auto_renew 1 +# HELP freeipa_config_ldap_earliest_cert_expiry The earliest certificate expiry date for LDAP. +# TYPE freeipa_config_ldap_earliest_cert_expiry gauge +freeipa_config_ldap_earliest_cert_expiry 2.639495644e+09 +# HELP freeipa_config_proxy_secret Proxy secret is configured. +# TYPE freeipa_config_proxy_secret gauge +freeipa_config_proxy_secret 1 +# HELP freeipa_config_renewal_master This server is the renewal master. +# TYPE freeipa_config_renewal_master gauge +freeipa_config_renewal_master 0 +# HELP freeipa_failures_total Number of errors while scapping metrics. +# TYPE freeipa_failures_total counter +freeipa_failures_total 0 +# HELP freeipa_freeipa_failed_tests Number of failed tests in the most recent scrape. +# TYPE freeipa_freeipa_failed_tests gauge +freeipa_freeipa_failed_tests 2 +# HELP freeipa_scrapes_total Current total HAProxy scrapes. +# TYPE freeipa_scrapes_total counter +freeipa_scrapes_total 2 +# HELP freeipa_up Was the last scrape of FreeIPA successful. +# TYPE freeipa_up gauge +freeipa_up 1 diff --git a/test/freeipa_fail_connect.metrics b/test/freeipa_fail_connect.metrics new file mode 100644 index 0000000..0382551 --- /dev/null +++ b/test/freeipa_fail_connect.metrics @@ -0,0 +1,12 @@ +# HELP freeipa_failures_total Number of errors while scapping metrics. +# TYPE freeipa_failures_total counter +freeipa_failures_total 1 +# HELP freeipa_freeipa_failed_tests Number of failed tests in the most recent scrape. +# TYPE freeipa_freeipa_failed_tests gauge +freeipa_freeipa_failed_tests 0 +# HELP freeipa_scrapes_total Current total HAProxy scrapes. +# TYPE freeipa_scrapes_total counter +freeipa_scrapes_total 3 +# HELP freeipa_up Was the last scrape of FreeIPA successful. +# TYPE freeipa_up gauge +freeipa_up 0 diff --git a/test/freeipa_idrange_find.json b/test/freeipa_idrange_find.json new file mode 100644 index 0000000..18a9049 --- /dev/null +++ b/test/freeipa_idrange_find.json @@ -0,0 +1,31 @@ +{ + "result": { + "count": 1, + "truncated": false, + "result": [ + { + "dn": "cn=EXAMPLE.COM_id_range,cn=ranges,cn=etc,dc=example,dc=com", + "iparangetype": [ + "local domain range" + ], + "cn": [ + "EXAMPLE.COM_id_range" + ], + "ipabaseid": [ + "431800000" + ], + "iparangetyperaw": [ + "ipa-local" + ], + "ipaidrangesize": [ + "200000" + ] + } + ], + "summary": "1 range matched" + }, + "version": "4.6.8", + "error": null, + "id": null, + "principal": "freeipa-health-metrics@EXAMPLE.COM" +} \ No newline at end of file diff --git a/test/freeipa_invalid_json.json b/test/freeipa_invalid_json.json new file mode 100644 index 0000000..40da4bb --- /dev/null +++ b/test/freeipa_invalid_json.json @@ -0,0 +1,14 @@ +{ + "result": null, + "version": "4.6.8", + "error": { + "message": "Invalid JSON-RPC request: Expecting property name enclosed in double quotes: line 4 column 4 (char 44)", + "code": 909, + "data": { + "error": "Expecting property name enclosed in double quotes: line 4 column 4 (char 44)" + }, + "name": "JSONError" + }, + "id": null, + "principal": "admin@EXAMPLE.COM" +} \ No newline at end of file diff --git a/test/http.metrics b/test/http.metrics new file mode 100644 index 0000000..5515d18 --- /dev/null +++ b/test/http.metrics @@ -0,0 +1,104 @@ +# HELP freeipa_config_dna_range DNA range is defined. +# TYPE freeipa_config_dna_range gauge +freeipa_config_dna_range 1 +# HELP freeipa_config_ipa_ca_issued_cert The FreeIPA API was issued a certificate by the CA cert. +# TYPE freeipa_config_ipa_ca_issued_cert gauge +freeipa_config_ipa_ca_issued_cert 1 +# HELP freeipa_config_ipa_ca_issued_ldap_cert The LDAP cert was issued a certificate by the CA cert. +# TYPE freeipa_config_ipa_ca_issued_ldap_cert gauge +freeipa_config_ipa_ca_issued_ldap_cert 0 +# HELP freeipa_config_ipa_cert_auto_renew The FreeIPA API certificate is managed and set to auto renew. +# TYPE freeipa_config_ipa_cert_auto_renew gauge +freeipa_config_ipa_cert_auto_renew 1 +# HELP freeipa_config_ipa_earliest_cert_expiry The earliest certificate expiry date for FreeIPA API. +# TYPE freeipa_config_ipa_earliest_cert_expiry gauge +freeipa_config_ipa_earliest_cert_expiry 2.639495644e+09 +# HELP freeipa_config_krb5_auth Kerberos can authenticate. +# TYPE freeipa_config_krb5_auth gauge +freeipa_config_krb5_auth 1 +# HELP freeipa_config_krb5_workers Workers match processors. +# TYPE freeipa_config_krb5_workers gauge +freeipa_config_krb5_workers 0 +# HELP freeipa_config_ldap_cert_auto_renew The LDAP certificate is managed and set to auto renew. +# TYPE freeipa_config_ldap_cert_auto_renew gauge +freeipa_config_ldap_cert_auto_renew 0 +# HELP freeipa_config_ldap_earliest_cert_expiry The earliest certificate expiry date for LDAP. +# TYPE freeipa_config_ldap_earliest_cert_expiry gauge +freeipa_config_ldap_earliest_cert_expiry 0 +# HELP freeipa_config_proxy_secret Proxy secret is configured. +# TYPE freeipa_config_proxy_secret gauge +freeipa_config_proxy_secret 1 +# HELP freeipa_config_renewal_master This server is the renewal master. +# TYPE freeipa_config_renewal_master gauge +freeipa_config_renewal_master 1 +# HELP freeipa_failures_total Number of errors while scapping metrics. +# TYPE freeipa_failures_total counter +freeipa_failures_total 0 +# HELP freeipa_freeipa_failed_tests Number of failed tests in the most recent scrape. +# TYPE freeipa_freeipa_failed_tests gauge +freeipa_freeipa_failed_tests 1 +# HELP freeipa_ldap_certificate_total Total number of certificates. +# TYPE freeipa_ldap_certificate_total counter +freeipa_ldap_certificate_total 0 +# HELP freeipa_ldap_conflicts_total Total number of LDAP conflicts. +# TYPE freeipa_ldap_conflicts_total counter +freeipa_ldap_conflicts_total 0 +# HELP freeipa_ldap_dns_zone_total Total number of DNS zones. +# TYPE freeipa_ldap_dns_zone_total counter +freeipa_ldap_dns_zone_total 0 +# HELP freeipa_ldap_failures_total Number of errors while scapping metrics. +# TYPE freeipa_ldap_failures_total counter +freeipa_ldap_failures_total 0 +# HELP freeipa_ldap_ghost_replica_total Total number of ghost replicas. +# TYPE freeipa_ldap_ghost_replica_total counter +freeipa_ldap_ghost_replica_total 0 +# HELP freeipa_ldap_group_total Total number of groups. +# TYPE freeipa_ldap_group_total counter +freeipa_ldap_group_total 3 +# HELP freeipa_ldap_hbac_rule_total Total number of HBAC rules. +# TYPE freeipa_ldap_hbac_rule_total counter +freeipa_ldap_hbac_rule_total 1 +# HELP freeipa_ldap_host_group_total Total number of host groups. +# TYPE freeipa_ldap_host_group_total counter +freeipa_ldap_host_group_total 2 +# HELP freeipa_ldap_host_total Total number of hosts. +# TYPE freeipa_ldap_host_total counter +freeipa_ldap_host_total 4 +# HELP freeipa_ldap_net_group_total Total number of net groups. +# TYPE freeipa_ldap_net_group_total counter +freeipa_ldap_net_group_total 0 +# HELP freeipa_ldap_replica_error_code Error code from last replica sync. +# TYPE freeipa_ldap_replica_error_code gauge +freeipa_ldap_replica_error_code{replica="ipa2.example.com"} 0 +freeipa_ldap_replica_error_code{replica="ipa3.example.com"} 0 +# HELP freeipa_ldap_replica_last_update The last time a replica sync occurred. +# TYPE freeipa_ldap_replica_last_update gauge +freeipa_ldap_replica_last_update{replica="ipa2.example.com"} 1.693362478e+09 +freeipa_ldap_replica_last_update{replica="ipa3.example.com"} 1.693362636e+09 +# HELP freeipa_ldap_scrapes_total Current total HAProxy scrapes. +# TYPE freeipa_ldap_scrapes_total counter +freeipa_ldap_scrapes_total 1 +# HELP freeipa_ldap_service_total Total number of services. +# TYPE freeipa_ldap_service_total counter +freeipa_ldap_service_total 6 +# HELP freeipa_ldap_sudo_rule_total Total number of sudo rules. +# TYPE freeipa_ldap_sudo_rule_total counter +freeipa_ldap_sudo_rule_total 0 +# HELP freeipa_ldap_up Was the last scrape of FreeIPA successful. +# TYPE freeipa_ldap_up gauge +freeipa_ldap_up 1 +# HELP freeipa_ldap_user_active_total Total number of active users. +# TYPE freeipa_ldap_user_active_total counter +freeipa_ldap_user_active_total 7 +# HELP freeipa_ldap_user_preserved_total Total number of preserved users. +# TYPE freeipa_ldap_user_preserved_total counter +freeipa_ldap_user_preserved_total 3 +# HELP freeipa_ldap_user_stage_total Total number of staged users. +# TYPE freeipa_ldap_user_stage_total counter +freeipa_ldap_user_stage_total 1 +# HELP freeipa_scrapes_total Current total HAProxy scrapes. +# TYPE freeipa_scrapes_total counter +freeipa_scrapes_total 1 +# HELP freeipa_up Was the last scrape of FreeIPA successful. +# TYPE freeipa_up gauge +freeipa_up 1 diff --git a/test/influx.json b/test/influx.json new file mode 100644 index 0000000..8bcb052 --- /dev/null +++ b/test/influx.json @@ -0,0 +1,36 @@ +{"fields":{"config_dna_range":1},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"config_ipa_ca_issued_cert":1},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"config_ipa_ca_issued_ldap_cert":0},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"config_ipa_cert_auto_renew":1},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"config_ipa_earliest_cert_expiry":2639495644},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"config_krb5_auth":1},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"config_krb5_workers":0},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"config_ldap_cert_auto_renew":0},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"config_ldap_earliest_cert_expiry":0},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"config_proxy_secret":1},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"config_renewal_master":1},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"failures_total":0},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"freeipa_failed_tests":1},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_certificate_total":0},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_conflicts_total":0},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_dns_zone_total":0},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_failures_total":0},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_ghost_replica_total":0},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_group_total":3},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_hbac_rule_total":1},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_host_group_total":2},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_host_total":4},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_net_group_total":0},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_replica_error_code":0},"name":"freeipa","tags":{"host":"ipa1.example.com","replica":"ipa2.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_replica_error_code":0},"name":"freeipa","tags":{"host":"ipa1.example.com","replica":"ipa3.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_replica_last_update":1693362478},"name":"freeipa","tags":{"host":"ipa1.example.com","replica":"ipa2.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_replica_last_update":1693362636},"name":"freeipa","tags":{"host":"ipa1.example.com","replica":"ipa3.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_scrapes_total":2},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_service_total":6},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_sudo_rule_total":0},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_up":1},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_user_active_total":7},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_user_preserved_total":3},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"ldap_user_stage_total":1},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"scrapes_total":2},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} +{"fields":{"up":1},"name":"freeipa","tags":{"host":"ipa1.example.com"},"timestamp":1136214245000000} diff --git a/test/influx.lp b/test/influx.lp new file mode 100644 index 0000000..ab2b320 --- /dev/null +++ b/test/influx.lp @@ -0,0 +1,36 @@ +freeipa,host=ipa1.example.com config_dna_range=1 1136214245000000 +freeipa,host=ipa1.example.com config_ipa_ca_issued_cert=1 1136214245000000 +freeipa,host=ipa1.example.com config_ipa_ca_issued_ldap_cert=0 1136214245000000 +freeipa,host=ipa1.example.com config_ipa_cert_auto_renew=1 1136214245000000 +freeipa,host=ipa1.example.com config_ipa_earliest_cert_expiry=2.639495644e+09 1136214245000000 +freeipa,host=ipa1.example.com config_krb5_auth=1 1136214245000000 +freeipa,host=ipa1.example.com config_krb5_workers=0 1136214245000000 +freeipa,host=ipa1.example.com config_ldap_cert_auto_renew=0 1136214245000000 +freeipa,host=ipa1.example.com config_ldap_earliest_cert_expiry=0 1136214245000000 +freeipa,host=ipa1.example.com config_proxy_secret=1 1136214245000000 +freeipa,host=ipa1.example.com config_renewal_master=1 1136214245000000 +freeipa,host=ipa1.example.com failures_total=0 1136214245000000 +freeipa,host=ipa1.example.com freeipa_failed_tests=1 1136214245000000 +freeipa,host=ipa1.example.com ldap_certificate_total=0 1136214245000000 +freeipa,host=ipa1.example.com ldap_conflicts_total=0 1136214245000000 +freeipa,host=ipa1.example.com ldap_dns_zone_total=0 1136214245000000 +freeipa,host=ipa1.example.com ldap_failures_total=0 1136214245000000 +freeipa,host=ipa1.example.com ldap_ghost_replica_total=0 1136214245000000 +freeipa,host=ipa1.example.com ldap_group_total=3 1136214245000000 +freeipa,host=ipa1.example.com ldap_hbac_rule_total=1 1136214245000000 +freeipa,host=ipa1.example.com ldap_host_group_total=2 1136214245000000 +freeipa,host=ipa1.example.com ldap_host_total=4 1136214245000000 +freeipa,host=ipa1.example.com ldap_net_group_total=0 1136214245000000 +freeipa,host=ipa1.example.com,replica=ipa2.example.com ldap_replica_error_code=0 1136214245000000 +freeipa,host=ipa1.example.com,replica=ipa3.example.com ldap_replica_error_code=0 1136214245000000 +freeipa,host=ipa1.example.com,replica=ipa2.example.com ldap_replica_last_update=1.693362478e+09 1136214245000000 +freeipa,host=ipa1.example.com,replica=ipa3.example.com ldap_replica_last_update=1.693362636e+09 1136214245000000 +freeipa,host=ipa1.example.com ldap_scrapes_total=1 1136214245000000 +freeipa,host=ipa1.example.com ldap_service_total=6 1136214245000000 +freeipa,host=ipa1.example.com ldap_sudo_rule_total=0 1136214245000000 +freeipa,host=ipa1.example.com ldap_up=1 1136214245000000 +freeipa,host=ipa1.example.com ldap_user_active_total=7 1136214245000000 +freeipa,host=ipa1.example.com ldap_user_preserved_total=3 1136214245000000 +freeipa,host=ipa1.example.com ldap_user_stage_total=1 1136214245000000 +freeipa,host=ipa1.example.com scrapes_total=1 1136214245000000 +freeipa,host=ipa1.example.com up=1 1136214245000000 diff --git a/test/ipa-getcert b/test/ipa-getcert new file mode 100755 index 0000000..c20e7b3 --- /dev/null +++ b/test/ipa-getcert @@ -0,0 +1,47 @@ +#!/bin/bash + +if [[ $1 != "list" ]]; then + echo "Not listing." + exit 1 +fi + +# Return basic listing +cat < + NSSOptions +StdEnvVars +ExportCertData +StrictRequire +OptRenegotiate + NSSVerifyClient none + ProxyPassMatch ajp://localhost:8009 secret=testSecret + ProxyPassReverse ajp://localhost:8009 + + +# matches for admin port and installer + + NSSOptions +StdEnvVars +ExportCertData +StrictRequire +OptRenegotiate + NSSVerifyClient none + ProxyPassMatch ajp://localhost:8009 secret=testSecret + ProxyPassReverse ajp://localhost:8009 + + +# matches for agent port and eeca port + + NSSOptions +StdEnvVars +ExportCertData +StrictRequire +OptRenegotiate + NSSVerifyClient require + ProxyPassMatch ajp://localhost:8009 secret=testSecret + ProxyPassReverse ajp://localhost:8009 + + +# matches for CA REST API + + NSSOptions +StdEnvVars +ExportCertData +StrictRequire +OptRenegotiate + NSSVerifyClient optional + ProxyPassMatch ajp://localhost:8009 secret=testSecret + ProxyPassReverse ajp://localhost:8009 + + +# matches for KRA REST API + + NSSOptions +StdEnvVars +ExportCertData +StrictRequire +OptRenegotiate + NSSVerifyClient optional + ProxyPassMatch ajp://localhost:8009 secret=testSecret + ProxyPassReverse ajp://localhost:8009 + + +# Only enable this on servers that are not generating a CRL +RewriteRule ^/ipa/crl/MasterCRL.bin http://ipa1.example.com/ca/ee/ca/getCRL?op=getCRL&crlIssuingPoint=MasterCRL [L,R=301,NC] \ No newline at end of file diff --git a/test/key.pem b/test/key.pem new file mode 100644 index 0000000..4b89dc3 --- /dev/null +++ b/test/key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDEYP5wT6QE/40B +MakvYnCw5EMJkzF++OVVisSxRnyKMdgpwOXe6rxSmnmmSddKst7rSK04/HhHwPGI +a5+SsLu01FprlRouyO4QRalxtULadUsBg8Na/EWC7sIgcmglP5UuygaXdq+4BqNC +NCgbxPxwnfaSeAAb/maSYAQYL1yRq1SElfFFdFvOXDMA66zTga2xDSKhud/Nxn96 +pTYFy+vLUAVkQ+VOfFZaWMeBE1drGOnLVkSt1TFtFlTN5YOvyKjYUQsK4l7zcqF/ +ijQuerHxnQMvZy143eW4KMEogvtMIR+KltnUvhSCLHHxYJwHnzeNmfOAqAnmM3Hc +L6eO2pZmXvoi0BYGQhMEwhtwsYQNgtNqbtpK4wqaJUj2WDU+qoCIVC13BFQdQSq5 +6NGZM/Y2nPtX33f4A9hfLnxqEWR4DsDHT+hKRzdnSq/aY/iQ9TQDY0gHzePeVU0t +g+evNc7u6rw6Zqh0eiY/V2i35aSEJTf1CxqVdM8O6QnoxK6umJKgOBEt6dTa+a50 ++1NqM9ufvkzIZUc5HGnFUBsvMer7l6UQNq1STB+4w33ntQmbtvgvNnGXmXgB59TG +BjLc/+kVBBDLn+R3yzG4mLqpLUykX8cRcTul6RIhNxMiNI02Y15kFMJ+MOHyjHKY +KYkS1ZyKwPRQkISiXuZANaT+vYCLHwIDAQABAoICAQCrfYRUccfrMXtiUorLPWzp +nLxKDUdI+XPUKtWvdb1mNTbu52wWKekBPbMEGzGuItv2ncXfoOIszvpdxpZYVIvm +0xaPImr19jOm9B6PlNnnykwQ647a0rilKXlPOnlmJctSS8xL0rKKwwko1EE+VtyY +P+nGaJK334aVRtHsiNeOwg6RphtHKuDNKcjEggqvvWv/1Fes4ZPmr/Q9Fy9BCp5E +MwIyV/RUgNIsHaFDP6+0b9Ii5pgdMbLy73BpSYehJ1sDZGp/O8XtVOphZUBCYpUo +SJQyfijAhw6HrtdXWGK5TaessCVT2hYwwz1Rq6s2IL0zpAB4FsZmSACjZt4tKwfw +oFsJrw+Hbw1EYSmCxP+RWY0ohOo/tqJchuaPdcc9Z7RTFTRtt2W1c2zVcoaqa3/1 +kkb1Y+fY4pPPpqFmP4oKuSmG8VBYnUaMK6vPh4snpf0GyL9uj+oQ2BOsAGT52eOr +r/xN+Y4IFZtVIWagO/HNEujqFFBefepnkgVwTTD12sN+0+8VVYGupB9jFMHnIpGc +AhiJsnjOe0E30dIpXaIFRrNPIVnjBiI7mBWgvpOrlYiRuy8gsdUR+uLCEGoLG54u +te7I/oyWWp6buc9ml93IWKoxo4lqXU3dmy0d7ZYma/tf4FSUzs1GyWXxf/daJuPJ +pDMxFjtUBEoAF/W2ePRTwQKCAQEA4kv0Ew8gBlwPqDsXqHN1NadaFYDZK2QfHBCj +zEfhBD1CMRqMrXAlvCaUq6EF8cION03FYFrpVjTl/bCW4kCpfydfcy89jMVP8UbL +XkJJ5ZpP7IL116MJPiERAh62lqpR9uV4yMSLTxOCoIbyn15VBS4oGkid7AcKTHOv +NGjtGCAq+femCVuWp2d5y6BPDrpNnLOA2dVg5pY8aCHEl9eZi9yWVSxr6Y9HiUJ+ +UthD+yeSzk7GgPg6qowc8KbTTedSKKjd2JBhqtaITRo6lsBLEI+LDSOSYbEcSUoX +WsIo/B90DgZ0k9f6T/GiZ8svUmfpreUjfgsmJjrzbtrGwzMH4QKCAQEA3ie9KDAk +wpfphK9/U8zEi1raKDai3ksIJdIURURZUdtJ7GEGZ4hyqvywewVaFlEDcZ+QYaFX +XT/WquA1myVjoWxlm8SGBu0PKHT0y4FGY6tW6FYdEAdnQL40+XsX6V4IHbh289Bk +Ti0cmv0e6tgFPquNaIJnnrag/5Ak/Ac+fGJLRQGwHT2eD8VmJ8hF8i7E08uxNnQb +sPDXmw81Yb+rWRSD3KQkTllhMKLjfmqI+P4npqlgizHoK6k2a4GQYumHZmxZPALa ++SLgEGOL++3rbiHulGPOaox/iU2GtS9aRwSgfKlKSKdNylHz38CgL9Kidg8Fz1O1 +c7EOGYC8s1ny/wKCAQAoF6LPeZ+H4OmZOZbwbjw23EZ2htRy/pMQatZKS/XOxXej +sXt5AuR8mC1A1w9xjJruK2Yrsw+iCU8yCgZBYYlmELi1dIooFZEbQxqmwYHMHvHI +Ck+5+5WYn00fHgflW5mX74HduAyiXueGv0HfAFx5xXqvZWwtM/YcI2bIF0riOljC +3qBZChP/5rJKZEV9a35yo87RSR+Y2scq/8iPyk/W2qb7whoAUDUxWUl+LfilV5aH +3KcIlHH4Y0iBTl0jcTc6IujjBHl5RfbyChKVQM5LydKt6j519mX3ihvnJX0TZhMu +pPAkfWBIp5vJXdMte2GIQI9wNlN09H7KhhIu5SyhAoIBACAbnjswuh9l1VpYAw8Z +iU6a0uz8+I0oSwUsV8GrHz21c/m2DDbqgag03UzqeRrAmr7RUQzLRNU1ZNFNlnHV +9ZBfGlBpFvXpTUeLn9XJ2WKOYQEzcP/gEgxJcV6da9dOv92Ly6VxeQ3Td07vRoiq +sBdetBFmx5Mo0hwduTqz0VQo4LgYhluzjCS7Ywhc6b8XA1uZFQPJxDbOmFrQ1+ZI +zXsSe/xnvNeWE3X0FO0weJuEIDb2Q/3aOLQWwMbI8xVYqzkib8M8pmlboQa9XH4M +5PoF7XWE91Bu/f/aNJ37OhEJmihqT1Iw3A1hyt2L+Zrv1os5oJ1We+M8s8z7zkod +tgECggEBAI6GadDOWXjzcTPyf+yj9km0aCxxFq6oJ3veYMa4fo9ae0driQ/4MLrC +JxlD8Bnb9nWt6AKuQpK7YFGPbbE7FgPwBi7V/nHVXPSNvsNk+cMo580ZHJEjro3z ++4CiBuaNuA7avZlc7ie8dL7RqawngFwjwRnJkxkUoxGRPvgZMD16imPnuVgiwFYQ +VsLtFQUwk+kCSYD/iitDw6p9IYTWbgC6YFAmakL9jPQvPBsfDquwQxN2flG+heVZ +W4GVRXdttUNizAQRWrjS2eYCdyOcOXzBOlI8Bleau0GmpRwJioyG9vNJGSEzlViy +HEkW+ikazbeshT71rcSFAE4eOZzg+y4= +-----END PRIVATE KEY----- diff --git a/test/kinit b/test/kinit new file mode 100755 index 0000000..80bb2d2 --- /dev/null +++ b/test/kinit @@ -0,0 +1,34 @@ +#!/bin/bash + +keytab="" +cache="" +credentials="" + +# Parse arguments. +while (( $# > 0 )); do + case "$1" in + -kt) + shift + keytab=$1 + shift + ;; + -c) + shift + cache=$1 + shift + ;; + *) + credentials=$1 + shift + ;; + esac +done + +# Return basic kinit error if expected values do match. +if [[ $keytab != "/etc/krb5.keytab" ]] || [[ $credentials != "host/ipa1.example.com@EXAMPLE.COM" ]] || ! [[ $cache =~ \/tmp\/krb5_cache_.* ]]; then + echo "kinit: Keytab contains no suitable keys for $credentials while getting initial credentials" + exit 1 +fi + +# Return zero exit +exit 0 diff --git a/test/klist b/test/klist new file mode 100755 index 0000000..f9fbc5e --- /dev/null +++ b/test/klist @@ -0,0 +1,32 @@ +#!/bin/bash + +cache="" + +# Parse arguments. +while (( $# > 0 )); do + case "$1" in + -c) + shift + cache=$1 + shift + ;; + esac +done + +# If cache file isn't expected path, return error. +if ! [[ $cache =~ \/tmp\/krb5_cache_.* ]]; then + echo "klist: No credentials cache found (filename: $cache)" + exit 1 +fi + +# Return basic klist response. +cat < + + + + + + + + + +Secure Agent URL = https://ipa1.example.com:8443/ocsp/agent/ocsp +Secure EE URL = https://ipa1.example.com:8443/ocsp/ee/ocsp/ +Secure Admin URL = https://ipa1.example.com:8443/ocsp/services +PKI Console Command = pkiconsole https://ipa1.example.com:8443/ocsp +Tomcat Port = 8005 (for shutdown) +?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_config.yaml b/test/test_config.yaml new file mode 100644 index 0000000..bbb13e6 --- /dev/null +++ b/test/test_config.yaml @@ -0,0 +1,30 @@ +--- +hostname: ipa1.example.com +http: + bind_addr: 127.0.0.1 + port: 8832 +ldap: + address: ldap://127.0.0.1:10389 + connect_method: Unsecure + base_dn: dc=example,dc=com + bind_dn: uid=freeipa-health-metrics,cn=users,cn=accounts,dc=example,dc=com + bind_password: testPassword + search_size_limit: 10 + +freeipa: + krb5_realm: EXAMPLE.COM + + host: localhost:8831 + insecure_skip_verify: true + username: freeipa-health-metrics + password: testPassword + + disabled_metrics: + - group_members + +krb5_sysconfig_path: test/krb5kdc +pki_tomcat_server_xml: test/server.xml +httpd_pki_proxy_conf: test/ipa-pki-proxy.conf +kinit_bin: test/kinit +klist_bin: test/klist +ipa_getcert_bin: test/ipa-getcert diff --git a/test_utils.go b/test_utils.go new file mode 100644 index 0000000..45da352 --- /dev/null +++ b/test_utils.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "io" + "os" + + "github.com/kylelemons/godebug/diff" +) + +// Generate a diff between a string a a file. +func FileDiff(s string, fileToDiff string) (string, error) { + // Open file. + f, err := os.Open(fileToDiff) + if err != nil { + return "", fmt.Errorf("error opening file %s: %s", fileToDiff, err) + } + // Close file after done. + defer f.Close() + // Read all data from file. + expected, err := io.ReadAll(f) + if err != nil { + return "", fmt.Errorf("error reading file %s: %s", fileToDiff, err) + } + + // Compare expected file against provided string. + return diff.Diff(string(expected), s), nil +}