package lkgateway // Поиск домена без вопросов (07.10.2026): человеку в мастере достаточно ввести учётную запись и пароль — домен и // его контроллеры bj находит сам. Откуда: суффикс в логине (ivanov@corp.example.ru), домены поиска DNS и имя // сервера bj; если их нет — DNS-серверы сервера bj (в сети Active Directory это обычно сами контроллеры): у // контроллера без пароля читается rootDSE — имя домена и самого контроллера. Список контроллеров — по DNS // (_ldap._tcp.dc._msdcs.<домен>). Не нашлось — мастер открывает расширенные настройки. import ( "context" "net" "net/http" "os" "sort" "strings" "time" "github.com/go-ldap/ldap/v3" ) // adFound — найденный домен. type adFound struct { Domain string `json:"domain"` DCs []string `json:"dcs"` // контроллеры (имена из DNS) From string `json:"from"` // откуда узнали домен — для подписи в мастере } // adDiscover — домен и контроллеры. domain — если уже известен (из логина или расширенных настроек). func adDiscover(ctx context.Context, domain string) *adFound { if domain = strings.ToLower(strings.Trim(strings.TrimSpace(domain), ".")); domain != "" { dcs := adLookupDCs(ctx, domain) return &adFound{Domain: domain, DCs: dcs, From: "указан"} } for _, c := range adDomainCandidates() { if dcs := adLookupDCs(ctx, c.name); len(dcs) > 0 { return &adFound{Domain: c.name, DCs: dcs, From: c.from} } } for _, ns := range adNameservers() { dom, host := adRootDSEDomain(ctx, ns) if dom == "" { continue } dcs := adLookupDCs(ctx, dom) if len(dcs) == 0 && host != "" { dcs = []string{host} } return &adFound{Domain: dom, DCs: dcs, From: "DNS-сервер " + ns + " — контроллер этого домена"} } return nil } type adCand struct{ name, from string } // adDomainCandidates — домены поиска DNS и домен в имени сервера (с родительскими, но не короче двух уровней). func adDomainCandidates() []adCand { var out []adCand seen := map[string]bool{} add := func(name, from string) { name = strings.ToLower(strings.Trim(name, ".")) for name != "" && strings.Count(name, ".") >= 1 { if !seen[name] { seen[name] = true out = append(out, adCand{name, from}) } _, name, _ = strings.Cut(name, ".") } } for _, f := range []string{"/run/systemd/resolve/resolv.conf", "/etc/resolv.conf"} { b, _ := os.ReadFile(f) for _, ln := range strings.Split(string(b), "\n") { fs := strings.Fields(ln) if len(fs) > 1 && (fs[0] == "search" || fs[0] == "domain") { for _, d := range fs[1:] { add(d, "домен поиска DNS сервера bj") } } } } if h, err := os.Hostname(); err == nil { if _, d, ok := strings.Cut(h, "."); ok { add(d, "имя сервера bj") } } return out } // adNameservers — DNS-серверы сервера bj (настоящие, не локальная заглушка systemd-resolved). func adNameservers() []string { var out []string seen := map[string]bool{} for _, f := range []string{"/run/systemd/resolve/resolv.conf", "/etc/resolv.conf"} { b, _ := os.ReadFile(f) for _, ln := range strings.Split(string(b), "\n") { fs := strings.Fields(ln) if len(fs) < 2 || fs[0] != "nameserver" { continue } ip := net.ParseIP(fs[1]) if ip == nil || ip.IsLoopback() || seen[fs[1]] { continue } seen[fs[1]] = true out = append(out, fs[1]) } } return out } // adLookupDCs — контроллеры домена по записям DNS SRV (по приоритету). func adLookupDCs(ctx context.Context, domain string) []string { ctx, cancel := context.WithTimeout(ctx, 4*time.Second) defer cancel() var srvs []*net.SRV for _, name := range []string{"dc._msdcs." + domain, domain} { if _, s, err := net.DefaultResolver.LookupSRV(ctx, "ldap", "tcp", name); err == nil && len(s) > 0 { srvs = s break } } sort.SliceStable(srvs, func(i, j int) bool { return srvs[i].Priority < srvs[j].Priority }) var out []string seen := map[string]bool{} for _, s := range srvs { h := strings.ToLower(strings.TrimSuffix(s.Target, ".")) if h != "" && !seen[h] { seen[h] = true out = append(out, h) } } return out } // adRootDSEDomain — спросить у сервера (порт 389, без пароля) rootDSE: контроллер AD отвечает именем домена // (defaultNamingContext) и своим именем (dnsHostName). Не контроллер — пусто. func adRootDSEDomain(ctx context.Context, host string) (domain, dnsHost string) { d := &net.Dialer{Timeout: 3 * time.Second} conn, err := ldap.DialURL("ldap://"+net.JoinHostPort(host, "389"), ldap.DialWithDialer(d)) if err != nil { return "", "" } defer conn.Close() conn.SetTimeout(4 * time.Second) sr, err := conn.Search(ldap.NewSearchRequest("", ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 4, false, "(objectClass=*)", []string{"defaultNamingContext", "dnsHostName"}, nil)) if err != nil || len(sr.Entries) == 0 { return "", "" } e := sr.Entries[0] return adDNToDomain(e.GetAttributeValue("defaultNamingContext")), strings.ToLower(e.GetAttributeValue("dnsHostName")) } // adDNToDomain — DC=corp,DC=example,DC=ru → corp.example.ru. func adDNToDomain(dn string) string { var parts []string for _, p := range strings.Split(dn, ",") { k, v, ok := strings.Cut(strings.TrimSpace(p), "=") if ok && strings.EqualFold(k, "DC") && v != "" { parts = append(parts, strings.ToLower(v)) } } return strings.Join(parts, ".") } // adDiscoverHTTP — GET /admin/setup/users/ad-discover: что bj нашёл сам (для подписи в мастере). func (h *setupHandlers) adDiscoverHTTP(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() f := adDiscover(ctx, r.URL.Query().Get("domain")) if f == nil || len(f.DCs) == 0 { jsonOut(w, map[string]any{"ok": false, "found": f}) return } jsonOut(w, map[string]any{"ok": true, "found": f}) }