357 lines
9.6 KiB
Go
357 lines
9.6 KiB
Go
|
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()
|
||
|
}
|