@@ -3,8 +3,10 @@ package ldap
33
44import (
55 "crypto/tls"
6+ "crypto/x509"
67 "fmt"
78 "net"
9+ "os"
810 "strings"
911 "sync"
1012 "time"
@@ -52,47 +54,123 @@ type cacheEntry struct {
5254
5355// Client provides LDAP search functionality with in-memory caching.
5456type Client struct {
55- url string
56- baseDN string
57- groupBaseDN string
58- skipTLSVerify bool
59- cache sync.Map
60- cacheTTL time.Duration
57+ url string // explicit LDAP URL (fallback if srvDomain is empty)
58+ srvDomain string // domain for DNS SRV lookup (e.g. "ipa.redhat.com")
59+ baseDN string
60+ groupBaseDN string
61+ bindDN string
62+ bindPassword string
63+ tlsConfig * tls.Config
64+ cache sync.Map
65+ cacheTTL time.Duration
6166}
6267
6368// NewClient creates a new LDAP client.
64- // baseDN is the base DN for user searches (e.g. "ou=users,dc=redhat,dc=com").
69+ // url is the LDAP server URL, used as a fallback when srvDomain is empty.
70+ // srvDomain enables DNS SRV discovery (e.g. "ipa.redhat.com" queries _ldap._tcp.ipa.redhat.com).
71+ // baseDN is the base DN for user searches (e.g. "cn=users,cn=accounts,dc=ipa,dc=redhat,dc=com").
6572// groupBaseDN is the base DN for group searches. If empty, it is derived from
66- // baseDN by replacing the first OU with "ou=managedGroups".
67- func NewClient (url , baseDN , groupBaseDN string , skipTLSVerify bool ) * Client {
73+ // baseDN by replacing the first component with "cn=groups".
74+ // bindDN and bindPassword are used for authenticated binds (required for IPA).
75+ // caCertPath is an optional path to a PEM CA certificate file to trust.
76+ func NewClient (url , srvDomain , baseDN , groupBaseDN , bindDN , bindPassword , caCertPath string ) (* Client , error ) {
6877 if groupBaseDN == "" {
69- groupBaseDN = "ou=managedGroups,dc=redhat,dc=com"
7078 if parts := strings .SplitN (baseDN , "," , 2 ); len (parts ) == 2 {
71- groupBaseDN = "ou=managedGroups ," + parts [1 ]
79+ groupBaseDN = "cn=groups ," + parts [1 ]
7280 }
7381 }
7482
75- return & Client {
76- url : url ,
77- baseDN : baseDN ,
78- groupBaseDN : groupBaseDN ,
79- skipTLSVerify : skipTLSVerify ,
80- cacheTTL : defaultCacheTTL ,
83+ tlsConfig := & tls.Config {
84+ MinVersion : tls .VersionTLS12 ,
85+ }
86+ if caCertPath != "" {
87+ rootCAs , err := x509 .SystemCertPool ()
88+ if err != nil {
89+ rootCAs = x509 .NewCertPool ()
90+ }
91+ caCert , err := os .ReadFile (caCertPath )
92+ if err != nil {
93+ return nil , fmt .Errorf ("ldap read CA cert %s: %w" , caCertPath , err )
94+ }
95+ if ! rootCAs .AppendCertsFromPEM (caCert ) {
96+ return nil , fmt .Errorf ("ldap CA cert %s: no valid PEM certificates found" , caCertPath )
97+ }
98+ tlsConfig .RootCAs = rootCAs
8199 }
100+
101+ return & Client {
102+ url : url ,
103+ srvDomain : srvDomain ,
104+ baseDN : baseDN ,
105+ groupBaseDN : groupBaseDN ,
106+ bindDN : bindDN ,
107+ bindPassword : bindPassword ,
108+ tlsConfig : tlsConfig ,
109+ cacheTTL : defaultCacheTTL ,
110+ }, nil
82111}
83112
84- // connect dials the LDAP server and returns a connection .
113+ // connect dials an LDAP server (discovered via SRV or explicit URL) and performs an authenticated bind .
85114func (c * Client ) connect () (* goldap.Conn , error ) {
86- conn , err := goldap .DialURL (c .url , goldap .DialWithTLSConfig (& tls.Config {
87- MinVersion : tls .VersionTLS12 ,
88- InsecureSkipVerify : c .skipTLSVerify , //nolint:gosec // controlled by LDAP_SKIP_TLS_VERIFY env var for dev
89- }), goldap .DialWithDialer (& net.Dialer {Timeout : defaultConnTimeout }))
115+ conn , err := c .dial ()
90116 if err != nil {
91- return nil , fmt .Errorf ("ldap dial %s: %w" , c .url , err )
117+ return nil , err
118+ }
119+
120+ if c .bindDN != "" {
121+ if err := conn .Bind (c .bindDN , c .bindPassword ); err != nil {
122+ conn .Close ()
123+ return nil , fmt .Errorf ("ldap bind: %w" , err )
124+ }
92125 }
126+
93127 return conn , nil
94128}
95129
130+ // dial connects to an LDAP server. If srvDomain is configured, it discovers
131+ // servers via DNS SRV records (_ldap._tcp.<domain>) and tries each in
132+ // priority/weight order using LDAPS (port 636). Falls back to the explicit URL.
133+ func (c * Client ) dial () (* goldap.Conn , error ) {
134+ dialOpts := []goldap.DialOpt {
135+ goldap .DialWithTLSConfig (c .tlsConfig ),
136+ goldap .DialWithDialer (& net.Dialer {Timeout : defaultConnTimeout }),
137+ }
138+
139+ if c .srvDomain == "" {
140+ conn , err := goldap .DialURL (c .url , dialOpts ... )
141+ if err != nil {
142+ return nil , fmt .Errorf ("ldap dial %s: %w" , c .url , err )
143+ }
144+ return conn , nil
145+ }
146+
147+ _ , addrs , err := net .LookupSRV ("ldap" , "tcp" , c .srvDomain )
148+ if err != nil || len (addrs ) == 0 {
149+ if c .url != "" {
150+ conn , dialErr := goldap .DialURL (c .url , dialOpts ... )
151+ if dialErr != nil {
152+ return nil , fmt .Errorf ("ldap SRV lookup failed (%v) and fallback dial %s failed: %w" , err , c .url , dialErr )
153+ }
154+ return conn , nil
155+ }
156+ return nil , fmt .Errorf ("ldap SRV lookup _ldap._tcp.%s: %w" , c .srvDomain , err )
157+ }
158+
159+ // SRV records return port 389 (plain LDAP); connect on 636 for LDAPS.
160+ var lastErr error
161+ for _ , addr := range addrs {
162+ host := strings .TrimSuffix (addr .Target , "." )
163+ url := fmt .Sprintf ("ldaps://%s:636" , host )
164+ conn , dialErr := goldap .DialURL (url , dialOpts ... )
165+ if dialErr != nil {
166+ lastErr = fmt .Errorf ("ldap dial %s: %w" , url , dialErr )
167+ continue
168+ }
169+ return conn , nil
170+ }
171+ return nil , fmt .Errorf ("ldap all SRV targets for %s failed, last error: %w" , c .srvDomain , lastErr )
172+ }
173+
96174// cacheGet returns a cached value if it exists and hasn't expired.
97175func (c * Client ) cacheGet (key string ) (any , bool ) {
98176 val , ok := c .cache .Load (key )
@@ -133,9 +211,13 @@ func entryToUser(entry *goldap.Entry) LDAPUser {
133211 break
134212 }
135213 }
214+ seen := make (map [string ]struct {})
136215 for _ , dn := range entry .GetAttributeValues ("memberOf" ) {
137216 if cn := extractCNFromDN (dn ); cn != "" {
138- user .Groups = append (user .Groups , cn )
217+ if _ , dup := seen [cn ]; ! dup {
218+ seen [cn ] = struct {}{}
219+ user .Groups = append (user .Groups , cn )
220+ }
139221 }
140222 }
141223 return user
@@ -188,7 +270,7 @@ func (c *Client) SearchUsers(query string) ([]LDAPUser, error) {
188270}
189271
190272// SearchGroups searches for groups matching the query string.
191- // Searches the cn attribute with a prefix match in ou=managedGroups .
273+ // Searches the cn attribute with a prefix match in the groups base DN .
192274func (c * Client ) SearchGroups (query string ) ([]LDAPGroup , error ) {
193275 query = sanitizeQuery (query )
194276 if len (query ) < MinQueryLength {
0 commit comments