diff --git a/internal/lkgateway/adfailover_test.go b/internal/lkgateway/adfailover_test.go new file mode 100644 index 0000000..a143fc2 --- /dev/null +++ b/internal/lkgateway/adfailover_test.go @@ -0,0 +1,62 @@ +package lkgateway + +import ( + "errors" + "fmt" + "testing" + + "github.com/go-ldap/ldap/v3" +) + +func TestADServerURLs(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", nil}, + {"ldaps://dc1:636", []string{"ldaps://dc1:636"}}, + {" ldaps://dc1:636 ", []string{"ldaps://dc1:636"}}, + {"dc1, dc2 dc3", []string{"dc1", "dc2", "dc3"}}, + {"ldaps://dc1:636\nldaps://dc2:636; ldaps://dc3:636", []string{"ldaps://dc1:636", "ldaps://dc2:636", "ldaps://dc3:636"}}, + } + for _, c := range cases { + got := adServerURLs(AuthSettings{ADServer: c.in}) + if len(got) != len(c.want) { + t.Fatalf("adServerURLs(%q) = %v, want %v", c.in, got, c.want) + } + for i := range got { + if got[i] != c.want[i] { + t.Fatalf("adServerURLs(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i]) + } + } + } +} + +func TestADErrTransient(t *testing.T) { + // окончательные отказы — повтор не нужен + final := []error{ + &adFinalError{"неверный пароль"}, + fmt.Errorf("обёртка: %w", &adFinalError{"нет пользователя"}), + errADNotFound, + &adNoGroupError{sam: "ivanov"}, + } + for _, e := range final { + if adErrTransient(e) { + t.Errorf("adErrTransient(%v) = true, ожидался окончательный отказ", e) + } + } + // сетевые/прочие — можно повторить/перейти на резерв + transient := []error{ + fmt.Errorf("подключение к ldaps://dc1:636: i/o timeout"), + fmt.Errorf("AD bind: %v", ldap.NewError(ldap.ErrorNetwork, errors.New("connection reset"))), + errors.New("StartTLS: handshake failure"), + } + for _, e := range transient { + if !adErrTransient(e) { + t.Errorf("adErrTransient(%v) = false, ожидался временный сбой", e) + } + } + if adErrTransient(nil) { + t.Error("adErrTransient(nil) должно быть false") + } +} diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go index 4c753b9..2ea0953 100644 --- a/internal/lkgateway/auth.go +++ b/internal/lkgateway/auth.go @@ -646,8 +646,29 @@ type adResult struct { MatchedBy string } -func adDial(a AuthSettings) (*ldap.Conn, error) { - server := strings.TrimSpace(a.ADServer) +// adServerURLs — список контроллеров домена из настройки ad_server. Можно указать +// несколько адресов (резерв) через запятую, точку с запятой, пробел или перенос строки — +// они перебираются по порядку, пока один не ответит. Одна запись — поведение как раньше. +func adServerURLs(a AuthSettings) []string { + parts := strings.FieldsFunc(a.ADServer, func(r rune) bool { + switch r { + case ',', ';', '\n', '\r', '\t', ' ': + return true + } + return false + }) + out := make([]string, 0, len(parts)) + for _, s := range parts { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + return out +} + +// adDialServer — подключение к одному контроллеру домена. +func adDialServer(a AuthSettings, server string) (*ldap.Conn, error) { + server = strings.TrimSpace(server) if server == "" { return nil, fmt.Errorf("не задан адрес контроллера домена") } @@ -682,6 +703,54 @@ func adDial(a AuthSettings) (*ldap.Conn, error) { return conn, nil } +// adDial — подключение к домену с перебором резервных контроллеров (первый ответивший). +// Используется проверками/мастером; вход (adAuthenticate) перебирает контроллеры сам, +// чтобы повтор охватывал и bind. +func adDial(a AuthSettings) (*ldap.Conn, error) { + servers := adServerURLs(a) + if len(servers) == 0 { + return nil, fmt.Errorf("не задан адрес контроллера домена") + } + var lastErr error + for _, server := range servers { + conn, err := adDialServer(a, server) + if err == nil { + return conn, nil + } + lastErr = err + } + return nil, lastErr +} + +// adFinalError — окончательный отказ входа в AD (неверный пароль, учётка заблокирована/ +// отключена, срок пароля истёк, пользователь не найден). Повтор и резервный контроллер не +// помогут — такой отказ не маскируем под «домен недоступен» и не пробуем ещё раз. +type adFinalError struct{ msg string } + +func (e *adFinalError) Error() string { return e.msg } + +// adErrTransient — ошибку стоит повторить/проверить на другом контроллере (сеть, TLS, +// контроллер недоступен). Окончательные отказы (неверный пароль, нет пользователя/группы) +// — нет: иначе исправный ввод выглядел бы как «домен недоступен», а неверный зря ждал бы +// повторов. Неклассифицированное считаем временным — это почти всегда сбой связи. +func adErrTransient(err error) bool { + if err == nil { + return false + } + var fin *adFinalError + if errors.As(err, &fin) { + return false + } + var noGroup *adNoGroupError + if errors.As(err, &noGroup) { + return false + } + if errors.Is(err, errADNotFound) { + return false + } + return true +} + // adAuthenticate — bind учётными данными пользователя, затем поиск его карточки // и проверка членства в сопоставленных группах (с вложенностью, // LDAP_MATCHING_RULE_IN_CHAIN). @@ -690,7 +759,38 @@ func adAuthenticate(a AuthSettings, login, password string) (*adResult, error) { if password == "" { return nil, fmt.Errorf("пустой пароль") } - conn, err := adDial(a) + servers := adServerURLs(a) + if len(servers) == 0 { + return nil, fmt.Errorf("не задан адрес контроллера домена") + } + // Порядок попыток — контроллеры по очереди (резерв). Единственный контроллер пробуем + // дважды: одиночный сбой сети/TLS не должен выглядеть для пользователя как неверный пароль. + attempts := servers + if len(attempts) == 1 { + attempts = []string{servers[0], servers[0]} + } + var lastErr error + for i, server := range attempts { + if i > 0 { + time.Sleep(400 * time.Millisecond) + } + res, err := adAuthenticateOn(a, server, login, password) + if err == nil { + return res, nil + } + if !adErrTransient(err) { + return res, err // неверный пароль / нет пользователя / нет группы — повтор не нужен + } + lastErr = err + log.Printf("auth: контроллер домена %s не ответил, пробуем следующий: %v", server, err) + } + return nil, fmt.Errorf("контроллер домена недоступен, вход по AD временно невозможен: %v", lastErr) +} + +// adAuthenticateOn — одна попытка входа на конкретном контроллере: bind, поиск карточки, +// проверка роли по группам. +func adAuthenticateOn(a AuthSettings, server, login, password string) (*adResult, error) { + conn, err := adDialServer(a, server) if err != nil { return nil, err } @@ -698,19 +798,19 @@ func adAuthenticate(a AuthSettings, login, password string) (*adResult, error) { sam, upn := adSplitLogin(a, login) if err := conn.Bind(upn, password); err != nil { if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) { - return nil, fmt.Errorf("%s", adBindReason(err)) + return nil, &adFinalError{adBindReason(err)} // неверный пароль/блокировка — окончательно } - return nil, fmt.Errorf("AD bind: %v", err) + return nil, fmt.Errorf("AD bind: %v", err) // прочее (сеть/TLS) — можно повторить } res, base, err := adLookup(conn, a, sam, upn) if err == errADNotFound { - return nil, fmt.Errorf("пользователь %s не найден в %s", sam, base) + return nil, &adFinalError{fmt.Sprintf("пользователь %s не найден в %s", sam, base)} } if err != nil { return res, err } if err := adResolveRole(conn, a, base, sam, res); err != nil { - return res, err + return res, err // в т.ч. adNoGroupError — его разбирает вызывающий authenticate } return res, nil }