package client import ( "bytes" "crypto/tls" "crypto/x509" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strings" "sync" "time" "github.com/rs/zerolog" ) // FreeIPAClient talks to the FreeIPA JSON-RPC management API // (/ipa/session/json). It is used for MUTATIONS (user/group add, modify, // delete, membership, password, enable/disable) because the IPA framework // assigns uidNumber/gidNumber/ipaUniqueID, sets the correct objectClasses and // enforces IPA's own logic — none of which a raw LDAP add does. Reads stay on // direct LDAP (LDAPClient). // // Auth: form login (login_password) with the svc account uid + password → // ipa_session cookie. The cookie is reused and refreshed on 401. Requests fail // over across the configured IPA servers. type FreeIPAClient struct { servers []string // bare hostnames, e.g. fihelvid01.gosec.auth user string // uid (extracted from the bind DN) pass string http *http.Client logger zerolog.Logger mu sync.Mutex cookie string // "ipa_session=..." active string // hostname the current cookie belongs to } // NewFreeIPAClient builds the client from the LDAP config. `servers` are the // ldap(s):// URLs; the IPA API host is the same host on https/443. `bindDN` is // the full svc DN (uid=svc-ops-api,...); the uid is extracted for the API login. func NewFreeIPAClient(servers []string, bindDN, password, caFile string, logger zerolog.Logger) (*FreeIPAClient, error) { hosts := make([]string, 0, len(servers)) for _, s := range servers { h := s if i := strings.Index(h, "://"); i >= 0 { h = h[i+3:] } if i := strings.IndexByte(h, ':'); i >= 0 { h = h[:i] } if h != "" { hosts = append(hosts, h) } } if len(hosts) == 0 { return nil, fmt.Errorf("no IPA servers configured") } uid := bindDN for _, part := range strings.Split(bindDN, ",") { part = strings.TrimSpace(part) if strings.HasPrefix(strings.ToLower(part), "uid=") { uid = part[4:] break } } tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} if caFile != "" { caCert, err := os.ReadFile(caFile) if err != nil { return nil, fmt.Errorf("read IPA CA file: %w", err) } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(caCert) { return nil, fmt.Errorf("parse IPA CA file %s", caFile) } tlsCfg.RootCAs = pool } return &FreeIPAClient{ servers: hosts, user: uid, pass: password, http: &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsCfg}, }, logger: logger.With().Str("component", "freeipa").Logger(), }, nil } // login authenticates against one host and stores the session cookie. func (c *FreeIPAClient) login(host string) error { form := url.Values{"user": {c.user}, "password": {c.pass}} req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/ipa/session/login_password", host), strings.NewReader(form.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "text/plain") req.Header.Set("Referer", fmt.Sprintf("https://%s/ipa", host)) resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return fmt.Errorf("IPA login failed on %s: HTTP %d %s", host, resp.StatusCode, strings.TrimSpace(string(body))) } for _, ck := range resp.Cookies() { if ck.Name == "ipa_session" { c.cookie = ck.Name + "=" + ck.Value c.active = host return nil } } return fmt.Errorf("IPA login on %s returned no ipa_session cookie", host) } // ensureSession logs in (trying each server) if there is no current cookie. func (c *FreeIPAClient) ensureSession() error { if c.cookie != "" { return nil } var lastErr error for _, h := range c.servers { if err := c.login(h); err != nil { lastErr = err c.logger.Warn().Err(err).Str("host", h).Msg("IPA login attempt failed") continue } return nil } return fmt.Errorf("all IPA logins failed: %w", lastErr) } type ipaError struct { Code int `json:"code"` Name string `json:"name"` Message string `json:"message"` } type ipaResponse struct { Result json.RawMessage `json:"result"` Error *ipaError `json:"error"` } // Command invokes an IPA method. `args` are the positional primary keys (e.g. // the cn or uid); `options` are the keyword options. Returns the raw `result`. // Re-authenticates once on 401. Surfaces IPA's structured error on failure. func (c *FreeIPAClient) Command(method string, args []interface{}, options map[string]interface{}) (json.RawMessage, error) { c.mu.Lock() defer c.mu.Unlock() if options == nil { options = map[string]interface{}{} } payload, err := json.Marshal(map[string]interface{}{ "method": method, "params": []interface{}{args, options}, "id": 0, }) if err != nil { return nil, err } if err := c.ensureSession(); err != nil { return nil, err } res, status, err := c.post(payload) if (status == http.StatusUnauthorized) || (err == nil && status == http.StatusForbidden) { // Session expired — re-login once and retry. c.cookie = "" if err2 := c.ensureSession(); err2 != nil { return nil, err2 } res, status, err = c.post(payload) } if err != nil { return nil, err } if status != http.StatusOK { return nil, fmt.Errorf("IPA %s: HTTP %d: %s", method, status, strings.TrimSpace(string(res))) } var parsed ipaResponse if err := json.Unmarshal(res, &parsed); err != nil { return nil, fmt.Errorf("IPA %s: invalid response: %w", method, err) } if parsed.Error != nil { return nil, fmt.Errorf("IPA %s: %s (%s)", method, parsed.Error.Message, parsed.Error.Name) } return parsed.Result, nil } // post sends one JSON-RPC request to the active server. func (c *FreeIPAClient) post(payload []byte) ([]byte, int, error) { req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/ipa/session/json", c.active), bytes.NewReader(payload)) if err != nil { return nil, 0, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("Referer", fmt.Sprintf("https://%s/ipa", c.active)) req.Header.Set("Cookie", c.cookie) resp, err := c.http.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) return body, resp.StatusCode, err }