From 773fa8e0bf1af82121861b5e6626a846b27c66f7 Mon Sep 17 00:00:00 2001 From: zuevav Date: Wed, 7 Oct 2026 01:03:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=B0=D1=81=D1=82=D0=B5=D1=80:=20=D1=88?= =?UTF-8?q?=D0=B0=D0=BF=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B5;=20=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=20=E2=80=94=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D0=BD=20=D0=B8=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8C;=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=C2=AB1=D0=A1:=20=D0=B1?= =?UTF-8?q?=D0=B0=D0=B7=D0=B0=20=D0=BC=D0=B8=D0=B4=D0=BB-=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=C2=BB=20(=D1=82=D0=B5=D1=81=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Шапка мастера: правый блок (шлюз НРД, пользователь, тема) снова справа — после правила [hidden] скрытое меню перестало его распирать. - Домен в шаге «Учётные записи»: bj сам находит домен и контроллеры (логин с @домен, домены поиска DNS, rootDSE DNS-серверов, SRV _ldap._tcp.dc._msdcs); адрес и домен — в расширенных настройках, открываются сами, если не нашлось. Контроллеры по очереди, LDAPS → LDAP+StartTLS. - Неудачная попытка ничего не теряет: введённое (и пароль) — черновиком, форма заполнена; причины словами (неизвестный ЦС, имя в сертификате, IP вместо имени, порт закрыт, нет в DNS). - Сертификат неизвестного центра: карточка «кем выдан, отпечаток (SHA-1 как в Windows и SHA-256)» и «Доверять и подключить»; после входа bj берёт из домена сертификат центра (AIA / Certification Authorities), подписавший сертификат контроллера, — подходят все контроллеры. То же в Настройка → Пользователи при «Проверить». - Лицензия: модуль onec-middle «1С: база мидл-офиса (тест)»; раздел настроек — адрес публикации, пользователь, пароль, проверка через OData $metadata; в мастере — в «Модулях». - ЛК (callback, API, готовность) — только при модуле «ЕСИА Финанс»; карточка режима QUIK и стили настроек как в мастере. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi --- internal/lkgateway/addiscover.go | 175 ++++++++ internal/lkgateway/addiscover_live_test.go | 44 ++ internal/lkgateway/admin.go | 15 +- internal/lkgateway/adwizard.go | 387 +++++++++++++++++- internal/lkgateway/adwizard_test.go | 89 ++++ internal/lkgateway/auth.go | 11 +- internal/lkgateway/checks.go | 2 +- internal/lkgateway/depoevents.go | 4 +- internal/lkgateway/licensecheck.go | 1 + internal/lkgateway/onec.go | 128 ++++++ internal/lkgateway/onec_test.go | 56 +++ internal/lkgateway/prepare.go | 2 +- internal/lkgateway/runtimeconfig.go | 19 +- internal/lkgateway/setup.go | 1 + .../lkgateway/web/templates/admin_help.html | 2 +- .../lkgateway/web/templates/admin_setup.html | 30 +- .../lkgateway/web/templates/admin_wizard.html | 105 ++--- internal/lkgateway/web/templates/layout.html | 75 +++- .../lkgateway/web/templates/part_adtrust.html | 19 + .../lkgateway/web/templates/part_quik.html | 2 +- internal/lkgateway/wizchapters.go | 15 +- 21 files changed, 1064 insertions(+), 118 deletions(-) create mode 100644 internal/lkgateway/addiscover.go create mode 100644 internal/lkgateway/addiscover_live_test.go create mode 100644 internal/lkgateway/adwizard_test.go create mode 100644 internal/lkgateway/onec.go create mode 100644 internal/lkgateway/onec_test.go create mode 100644 internal/lkgateway/web/templates/part_adtrust.html diff --git a/internal/lkgateway/addiscover.go b/internal/lkgateway/addiscover.go new file mode 100644 index 0000000..4c2910e --- /dev/null +++ b/internal/lkgateway/addiscover.go @@ -0,0 +1,175 @@ +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}) +} diff --git a/internal/lkgateway/addiscover_live_test.go b/internal/lkgateway/addiscover_live_test.go new file mode 100644 index 0000000..a281551 --- /dev/null +++ b/internal/lkgateway/addiscover_live_test.go @@ -0,0 +1,44 @@ +package lkgateway + +import ( + "context" + "os" + "testing" +) + +// BJ_AD_LIVE=1 go test -run TestADDiscoverLive — поиск домена в настоящей сети (только DNS и rootDSE, без паролей). +func TestADDiscoverLive(t *testing.T) { + if os.Getenv("BJ_AD_LIVE") == "" { + t.Skip("BJ_AD_LIVE не задан") + } + f := adDiscover(context.Background(), "") + if f == nil { + t.Fatal("домен не найден") + } + t.Logf("домен %s (%s), контроллеры %v", f.Domain, f.From, f.DCs) +} + +func TestADCertLive(t *testing.T) { + if os.Getenv("BJ_AD_LIVE") == "" { + t.Skip("BJ_AD_LIVE не задан") + } + for _, dc := range []string{"dc1.corp.fontvielle.ru", "dc2.corp.fontvielle.ru"} { + a := AuthSettings{ADServer: "ldaps://" + dc, ADDomain: "corp.fontvielle.ru"} + _, err := adProbe(a) + c, ferr := adFetchCert(a) + if c != nil { + t.Logf("%s: probe=%v; cert %q от %q до %s CA=%v", dc, err, c.Subject, c.Issuer, c.Until, c.IsCA) + } else { + t.Logf("%s: probe=%v fetch=%v", dc, err, ferr) + } + if err != nil { + t.Logf(" объяснение: %s", adExplain(err, a, c)) + } + if c != nil { + b := a + b.ADCAPEM = c.PEM + _, err2 := adProbe(b) + t.Logf(" после «Доверять»: probe=%v", err2) + } + } +} diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 6c9f363..d761e6f 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -87,12 +87,13 @@ var templateFuncs = template.FuncMap{ v, certs := setupProbes(globalRC) return v != "" && certs > 0 }, - "withRet": func(d any, ret string) map[string]any { return map[string]any{"D": d, "Ret": ret} }, - "whForm": func(w WorkHoursSettings, ret string) map[string]any { return map[string]any{"W": w, "Ret": ret} }, - "weekdayNames": func() []string { return weekdayShort }, - "depoAccPrefix": depoAccPrefix, - "certDaysLeft": certDaysLeft, - "currentContour": func() string { return contourOf(currentNSDProfile()) }, + "adDraft": adDraftForView, // неудачная попытка подключить домен — форма открывается заполненной + "withRet": func(d any, ret string) map[string]any { return map[string]any{"D": d, "Ret": ret} }, + "whForm": func(w WorkHoursSettings, ret string) map[string]any { return map[string]any{"W": w, "Ret": ret} }, + "weekdayNames": func() []string { return weekdayShort }, + "depoAccPrefix": depoAccPrefix, + "certDaysLeft": certDaysLeft, + "currentContour": func() string { return contourOf(currentNSDProfile()) }, "httpsCert": func() map[string]any { cn, until, err := tlsCertInfo() return map[string]any{"CN": cn, "Until": until, "Err": err, "Days": certDaysLeft(until)} @@ -549,7 +550,7 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts case p == "help/database": render(w, a.helpDatabase, nowPage("База данных", "help")) case p == "help/lk-api": - if !moduleAllowed(globalRC, "web-cabinet") && !devMode(globalRC) { // без модуля ЛК описания его API нет + if !moduleAllowed(globalRC, "esia-finance") && !devMode(globalRC) { // без модуля «ЕСИА Финанс» описания его API нет http.NotFound(w, r) return } diff --git a/internal/lkgateway/adwizard.go b/internal/lkgateway/adwizard.go index ad89ac5..f63a36a 100644 --- a/internal/lkgateway/adwizard.go +++ b/internal/lkgateway/adwizard.go @@ -5,9 +5,19 @@ package lkgateway // выбрать сотрудника поиском: он входит своим доменным логином и паролем, bj проверяет пароль в домене. import ( + "context" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" "fmt" + "log" "net/http" + "net/url" "strings" + "sync" "time" "github.com/go-ldap/ldap/v3" @@ -16,39 +26,121 @@ import ( func (h *setupHandlers) registerADWizard(mux *http.ServeMux) { mux.HandleFunc("/admin/setup/users/ad-connect", h.adConnect) mux.HandleFunc("/admin/setup/users/ad-search", h.adSearch) + mux.HandleFunc("/admin/setup/users/ad-trust", h.adTrust) + mux.HandleFunc("/admin/setup/users/ad-discover", h.adDiscoverHTTP) } -// adConnect — POST: адрес контроллера, домен, сервисная учётная запись (для поиска сотрудников); остальные -// настройки входа не трогает. Сразу проверяет связь: контроллер отвечает и сервисная запись входит. +// adConnect — POST: учётная запись домена и пароль; домен и контроллер — из расширенных настроек или bj находит +// их сам (addiscover.go). Контроллеры пробуются по очереди: сначала LDAPS (636), если порт закрыт — LDAP с +// StartTLS (389). Остальные настройки входа не трогает. func (h *setupHandlers) adConnect(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } - a := h.rc.Snapshot().Auth - a.ADServer = strings.TrimSpace(r.FormValue("ad_server")) - a.ADDomain = strings.TrimSpace(strings.TrimPrefix(r.FormValue("ad_domain"), "@")) - a.ADBindUser = strings.TrimSpace(r.FormValue("ad_bind_user")) - if pw := r.FormValue("ad_bind_password"); pw != "" { - a.ADBindPassword = pw + cur := h.rc.Snapshot().Auth + draft := adDraftAuth() + login := strings.TrimSpace(r.FormValue("ad_bind_user")) + pw := r.FormValue("ad_bind_password") + if pw == "" && draft != nil && draft.ADBindUser == login { + pw = draft.ADBindPassword // пароль, введённый в прошлой неудачной попытке } - if a.ADServer != "" && !strings.Contains(a.ADServer, "://") { - a.ADServer = "ldaps://" + a.ADServer // как в проводнике вводят имя — по умолчанию защищённое подключение + if pw == "" && cur.ADBindUser == login { + pw = cur.ADBindPassword + } + form := adForm{Server: strings.TrimSpace(r.FormValue("ad_server")), Domain: strings.ToLower(strings.Trim(strings.TrimSpace(strings.TrimPrefix(r.FormValue("ad_domain"), "@")), "."))} + a := cur + a.ADBindUser, a.ADBindPassword = login, pw + keep := func(b AuthSettings, msg string, cert *adCertInfo, adv bool) { + form.Adv = adv || form.Server != "" || form.Domain != "" + adDraftSet(b, msg, cert, form) + h.usersFlash(w, r, "Домен: "+msg) } var miss []string - for _, f := range []struct{ v, n string }{{a.ADServer, "контроллер домена"}, {a.ADDomain, "домен"}, {a.ADBindUser, "сервисная учётная запись"}, {a.ADBindPassword, "её пароль"}} { - if f.v == "" { - miss = append(miss, f.n) - } + if login == "" { + miss = append(miss, "учётная запись домена") + } + if pw == "" { + miss = append(miss, "пароль") } if len(miss) > 0 { - h.usersFlash(w, r, "Домен: не заполнено: "+strings.Join(miss, ", ")) + keep(a, "не заполнено: "+strings.Join(miss, ", "), nil, false) return } + domain := form.Domain + if _, d, ok := strings.Cut(login, "@"); ok && domain == "" { + domain = strings.ToLower(d) + } + var servers []string + if form.Server != "" { + servers = []string{form.Server} + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + if domain == "" || len(servers) == 0 { + if f := adDiscover(ctx, domain); f != nil { + if domain == "" { + domain = f.Domain + } + if len(servers) == 0 { + servers = f.DCs + } + } + } + cancel() + if domain == "" { + keep(a, "bj не нашёл домен сам: в сетевых настройках сервера bj его нет, а DNS-серверы не отвечают как контроллеры домена. Укажите домен в «Расширенных настройках» (например corp.example.ru) или учётную запись с доменом: svc-bj@corp.example.ru", nil, true) + return + } + a.ADDomain = domain + if len(servers) == 0 { + keep(a, "контроллеры домена "+domain+" не нашлись в DNS (_ldap._tcp.dc._msdcs."+domain+") — укажите контроллер в «Расширенных настройках»", nil, true) + return + } + if draft != nil && draft.ADCAPEM != "" && draft.ADDomain == domain { + a.ADCAPEM = draft.ADCAPEM // сертификат, которому уже доверились + } + var errs []string + for _, srv := range servers { + for _, v := range adVariants(srv) { + b := a + b.ADServer, b.ADStartTLS = v.url, v.startTLS + probe, cert, msg, next := adAttempt(b) + if probe != "" { + h.adSave(w, r, b, probe) + return + } + if !next { + keep(b, msg, cert, false) + return + } + errs = append(errs, msg) + } + } + keep(a, "ни один контроллер домена "+domain+" не подключился: "+strings.Join(errs, "; "), nil, true) +} + +type adVariant struct { + url string + startTLS bool +} + +// adVariants — как подключаться к контроллеру: адрес со схемой — как указан; имя — LDAPS, затем LDAP + StartTLS. +func adVariants(srv string) []adVariant { + if strings.Contains(srv, "://") { + return []adVariant{{srv, strings.HasPrefix(srv, "ldap://")}} + } + return []adVariant{{"ldaps://" + srv, false}, {"ldap://" + srv, true}} +} + +// adAttempt — подключиться к одному контроллеру и войти сервисной записью. Успех — описание контроллера; иначе +// причина словами, сертификат (если дело в нём) и стоит ли пробовать другой контроллер или способ. +func adAttempt(a AuthSettings) (probe string, cert *adCertInfo, msg string, next bool) { probe, err := adProbe(a) if err != nil { - h.usersFlash(w, r, "Домен: контроллер не отвечает — "+err.Error()+". Настройки не сохранены.") - return + if adCertUntrusted(err) { + cert, _ = adFetchCert(a) + } + return "", cert, adExplain(err, a, cert), cert == nil } conn, err := adDial(a) if err == nil { @@ -56,15 +148,268 @@ func (h *setupHandlers) adConnect(w http.ResponseWriter, r *http.Request) { conn.Close() } if err != nil { - h.usersFlash(w, r, "Домен: "+err.Error()+". Настройки не сохранены.") - return + return "", nil, err.Error() + " — поправьте учётную запись или пароль", false + } + return probe, nil, "", false +} + +// adSave — подключение прошло: сохранить. Если доверились сертификату самого контроллера, bj берёт из домена +// сертификат центра, который его выдал: тогда подходят все контроллеры и их новые сертификаты. +func (h *setupHandlers) adSave(w http.ResponseWriter, r *http.Request, a AuthSettings, probe string) { + note := "" + if a.ADCAPEM != "" { + if caPEM, ca, err := adUpgradeCA(a); err == nil && caPEM != "" { + a.ADCAPEM = caPEM + note = " Сертификаты контроллеров проверяются по центру сертификации «" + ca + "» — подойдут все контроллеры домена." + } else if err != nil { + log.Printf("ad: сертификат ЦС из домена не получен (%v) — проверка по сертификату контроллера", err) + note = " Сертификат контроллера сохранён; если его заменят, bj попросит подтвердить новый." + } + } + if safeReturnPath(r.FormValue("return_to")) != "" { + a.ADEnabled = true // в мастере подключение домена и есть включение входа через домен; в настройках — своя галочка } - a.ADEnabled = true if err := h.rc.UpdateAuth(a); err != nil { + adDraftSet(a, "настройки не сохранены — "+err.Error(), nil, adForm{}) h.usersFlash(w, r, "Домен: настройки не сохранены — "+err.Error()) return } - h.usersFlash(w, r, "Домен подключён ("+probe+"): можно добавлять сотрудников из домена — они входят своим доменным логином и паролем.") + adDraftClear() + h.usersFlash(w, r, "Домен подключён ("+probe+"): можно добавлять сотрудников из домена — они входят своим доменным логином и паролем."+note) +} + +// adUpgradeCA — сохранён сертификат самого контроллера (контроллер не прислал цепочку): войти по нему и прочитать +// в домене сертификат выдавшего центра (адрес — в сертификате, AIA; иначе — контейнер центров сертификации). +// Берётся только центр, чьей подписью подписан сертификат контроллера. Уже центр — ничего не делает. +func adUpgradeCA(a AuthSettings) (string, string, error) { + blk, _ := pem.Decode([]byte(a.ADCAPEM)) + if blk == nil { + return "", "", fmt.Errorf("сертификат не разобран") + } + leaf, err := x509.ParseCertificate(blk.Bytes) + if err != nil || leaf.IsCA { + return "", "", err + } + conn, err := adDial(a) + if err != nil { + return "", "", err + } + defer conn.Close() + if err := adBindService(conn, a); err != nil { + return "", "", err + } + var bases []string + for _, u := range leaf.IssuingCertificateURL { + if dn, ok := strings.CutPrefix(u, "ldap:///"); ok { + dn, _, _ = strings.Cut(dn, "?") + if d, err := url.PathUnescape(dn); err == nil { + bases = append(bases, d) + } + } + } + if def := adDefaultBase(conn); def != "" { + bases = append(bases, "CN=AIA,CN=Public Key Services,CN=Services,CN=Configuration,"+def, + "CN=Certification Authorities,CN=Public Key Services,CN=Services,CN=Configuration,"+def) + } + for _, base := range bases { + sr, err := conn.Search(ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 50, 10, false, + "(objectClass=certificationAuthority)", []string{"cACertificate"}, nil)) + if err != nil || sr == nil { + continue + } + for _, e := range sr.Entries { + for _, raw := range e.GetRawAttributeValues("cACertificate") { + ca, err := x509.ParseCertificate(raw) + if err != nil || !ca.IsCA || leaf.CheckSignatureFrom(ca) != nil { + continue + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Raw})), ca.Subject.CommonName, nil + } + } + } + return "", "", fmt.Errorf("центр сертификации %q в домене не найден", leaf.Issuer.CommonName) +} + +// adTrust — POST: «Доверять» сертификату, который показал контроллер (черновик неудачной попытки). Отпечаток из +// формы должен совпасть с полученным: человек подтверждает именно тот сертификат, который видел на экране. +func (h *setupHandlers) adTrust(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + adDraft.Lock() + a, cert, form := adDraft.a, adDraft.cert, adDraft.form + adDraft.Unlock() + if a == nil || cert == nil || cert.SHA256 != r.FormValue("fp") { + h.usersFlash(w, r, "Домен: сертификат на странице устарел — нажмите «Подключить» ещё раз") + return + } + b := *a + b.ADCAPEM, b.ADSkipVerify = cert.PEM, false + probe, c2, msg, _ := adAttempt(b) + if probe == "" { + adDraftSet(b, msg, c2, form) + h.usersFlash(w, r, "Домен: "+msg) + return + } + h.adSave(w, r, b, probe) +} + +// ---------- черновик подключения и сертификат контроллера ---------- + +// adCertInfo — сертификат, который предъявил контроллер домена (для решения «доверять»). +type adCertInfo struct { + Subject, Issuer, Until, SHA256 string + SHA1 string // «Отпечаток» в окне сертификата Windows — чтобы сверить на контроллере + IsCA bool // центр сертификации из цепочки (иначе — сертификат самого контроллера) + PEM string // что сохранится в настройках, если доверять +} + +// adForm — расширенные настройки, как их ввёл человек (пусто — bj ищет сам). +type adForm struct { + Server, Domain string + Adv bool // открыть расширенные настройки: домен или контроллер не нашлись сами +} + +// adDraftView — неудачная попытка подключить домен: что было введено и что не так (для формы). +type adDraftView struct { + Server string // контроллер, с которым не получилось (для карточки сертификата) + BindUser string + HasPassword bool + Form adForm + Err string + Cert *adCertInfo +} + +const adDraftTTL = 2 * time.Hour + +var adDraft struct { + sync.Mutex + a *AuthSettings + err string + cert *adCertInfo + form adForm + at time.Time +} + +func adDraftSet(a AuthSettings, msg string, cert *adCertInfo, form adForm) { + adDraft.Lock() + defer adDraft.Unlock() + adDraft.a, adDraft.err, adDraft.cert, adDraft.form, adDraft.at = &a, msg, cert, form, time.Now() +} + +func adDraftClear() { + adDraft.Lock() + defer adDraft.Unlock() + adDraft.a, adDraft.err, adDraft.cert = nil, "", nil +} + +// adDraftAuth — введённое в последней неудачной попытке (nil — попытки не было или она давно). +func adDraftAuth() *AuthSettings { + adDraft.Lock() + defer adDraft.Unlock() + if adDraft.a == nil || time.Since(adDraft.at) > adDraftTTL { + return nil + } + a := *adDraft.a + return &a +} + +// adDraftForView — для шаблона: черновик без пароля. +func adDraftForView() *adDraftView { + adDraft.Lock() + defer adDraft.Unlock() + if adDraft.a == nil || time.Since(adDraft.at) > adDraftTTL { + return nil + } + a := adDraft.a + host := a.ADServer + if u, err := url.Parse(a.ADServer); err == nil && u.Hostname() != "" { + host = u.Hostname() + } + return &adDraftView{Server: host, BindUser: a.ADBindUser, HasPassword: a.ADBindPassword != "", Form: adDraft.form, Err: adDraft.err, Cert: adDraft.cert} +} + +// adCertUntrusted — TLS не прошёл из-за того, что bj не знает, кто выдал сертификат контроллера. +func adCertUntrusted(err error) bool { + s := err.Error() + return strings.Contains(s, "x509: certificate signed by unknown authority") || strings.Contains(s, "certificate is not trusted") +} + +// adExplain — причина неудачи словами и что сделать. +func adExplain(err error, a AuthSettings, cert *adCertInfo) string { + s := err.Error() + host := a.ADServer + if u, e := url.Parse(a.ADServer); e == nil && u.Hostname() != "" { + host = u.Hostname() + } + switch { + case adCertUntrusted(err) && cert != nil: + return "контроллер " + host + " отвечает, но его сертификат выдан центром сертификации «" + cert.Issuer + "», которого bj пока не знает (обычно это центр сертификации самого домена). Проверьте сертификат ниже и нажмите «Доверять»." + case adCertUntrusted(err): + return "контроллер " + host + " отвечает, но его сертификат выдан неизвестным центром сертификации; вставьте сертификат корпоративного ЦС в Настройка → Пользователи → Active Directory" + case strings.Contains(s, "doesn't contain any IP SANs"): + return "контроллер указан IP-адресом, а сертификат выдан на имя — укажите имя контроллера, например dc1." + pickStr(a.ADDomain, "corp.local") + case strings.Contains(s, "x509: certificate is valid for"): + names := s[strings.Index(s, "x509: certificate is valid for")+len("x509: certificate is valid for "):] + if i := strings.Index(names, ", not "); i >= 0 { + names = names[:i] + } + return "сертификат контроллера выдан на другое имя (" + names + ") — укажите контроллер одним из этих имён" + case strings.Contains(s, "certificate has expired") || strings.Contains(s, "is not yet valid"): + return "срок сертификата контроллера " + host + " истёк или ещё не начался — обновите сертификат на контроллере или проверьте время на сервере bj" + case strings.Contains(s, "connection refused"): + port := "636" + if strings.HasPrefix(a.ADServer, "ldap://") { + port = "389" + } + return "контроллер " + host + " не принимает подключения на порту " + port + ": на нём не включён LDAPS или порт закрыт сетевым экраном" + case strings.Contains(s, "no such host"): + return "имя " + host + " не находится в DNS сервера bj — проверьте имя контроллера или укажите его полностью (dc1." + pickStr(a.ADDomain, "corp.local") + ")" + case strings.Contains(s, "i/o timeout") || strings.Contains(s, "deadline exceeded"): + return "контроллер " + host + " не ответил вовремя — проверьте, что сервер bj видит его по сети (порт 636)" + } + return "не удалось подключиться к контроллеру: " + s +} + +// adFetchCert — сертификат, который предъявляет контроллер: центр сертификации из цепочки (верхний, что прислал +// контроллер), а если цепочки нет — сертификат самого контроллера. Соединение — без проверки и только для чтения +// сертификата; пароль не отправляется. +func adFetchCert(a AuthSettings) (*adCertInfo, error) { + b := a + b.ADSkipVerify, b.ADCAPEM = true, "" + conn, err := adDial(b) + if err != nil { + return nil, err + } + defer conn.Close() + st, ok := conn.TLSConnectionState() + if !ok || len(st.PeerCertificates) == 0 { + return nil, fmt.Errorf("контроллер не предъявил сертификат") + } + pcs := st.PeerCertificates + c := pcs[0] + for i := len(pcs) - 1; i >= 1; i-- { + if pcs[i].IsCA { + c = pcs[i] + break + } + } + sum := sha256.Sum256(c.Raw) + hx := strings.ToUpper(hex.EncodeToString(sum[:])) + var fp []string + for i := 0; i < len(hx); i += 2 { + fp = append(fp, hx[i:i+2]) + } + s1 := sha1.Sum(c.Raw) + name := func(n pkix.Name) string { + if n.CommonName != "" { + return n.CommonName + } + return n.String() + } + return &adCertInfo{Subject: name(c.Subject), Issuer: name(c.Issuer), Until: c.NotAfter.In(time.Local).Format("02.01.2006"), + SHA256: strings.Join(fp, ":"), SHA1: strings.ToLower(hex.EncodeToString(s1[:])), IsCA: c.IsCA, PEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw}))}, nil } // adUserView — сотрудник домена в результатах поиска. diff --git a/internal/lkgateway/adwizard_test.go b/internal/lkgateway/adwizard_test.go new file mode 100644 index 0000000..e203604 --- /dev/null +++ b/internal/lkgateway/adwizard_test.go @@ -0,0 +1,89 @@ +package lkgateway + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestADDNToDomain(t *testing.T) { + if got := adDNToDomain("DC=corp,DC=fontvielle,DC=ru"); got != "corp.fontvielle.ru" { + t.Fatal(got) + } + if v := adVariants("dc1.corp.local"); len(v) != 2 || v[0].url != "ldaps://dc1.corp.local" || !v[1].startTLS { + t.Fatalf("%+v", v) + } + if v := adVariants("ldaps://dc1:636"); len(v) != 1 || v[0].startTLS { + t.Fatalf("%+v", v) + } +} + +func TestADExplain(t *testing.T) { + a := AuthSettings{ADServer: "ldaps://10.10.10.6", ADDomain: "corp.local"} + for _, c := range []struct{ err, want string }{ + {"x509: cannot validate certificate for 10.10.10.6 because it doesn't contain any IP SANs", "укажите имя контроллера"}, + {"x509: certificate is valid for dc1.corp.local, not dc9", "dc1.corp.local"}, + {"dial tcp 10.10.10.6:636: connect: connection refused", "порту 636"}, + {"dial tcp: lookup dc9.corp.local: no such host", "не находится в DNS"}, + } { + if got := adExplain(errors.New(c.err), a, nil); !strings.Contains(got, c.want) { + t.Errorf("%s → %s", c.err, got) + } + } +} + +// Контроллер с сертификатом неизвестного центра: введённое не теряется, «Доверять» запоминает сертификат, +// прежние настройки входа не портятся. +func TestADConnectKeepsDraftAndTrust(t *testing.T) { + adDraftClear() + defer adDraftClear() + dc := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer dc.Close() + srv := "ldaps://" + strings.TrimPrefix(dc.URL, "https://") + rc := testRC(t) + h := &setupHandlers{rc: rc} + post := func(path string, v url.Values) string { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(v.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + switch path { + case "/admin/setup/users/ad-connect": + h.adConnect(w, r) + default: + h.adTrust(w, r) + } + loc, _ := url.QueryUnescape(w.Header().Get("Location")) + return loc + } + loc := post("/admin/setup/users/ad-connect", url.Values{"ad_bind_user": {"svc-bj"}, "ad_bind_password": {"pw1"}, "ad_domain": {"corp.test"}, "ad_server": {srv}}) + if !strings.Contains(loc, "центром сертификации") { + t.Fatalf("нет понятной причины: %s", loc) + } + d := adDraftForView() + if d == nil || d.Cert == nil || d.BindUser != "svc-bj" || !d.HasPassword || d.Form.Server != srv || d.Form.Domain != "corp.test" { + t.Fatalf("черновик: %+v", d) + } + if rc.Snapshot().Auth.ADEnabled || rc.Snapshot().Auth.ADServer != "" { + t.Fatal("неудачная попытка изменила настройки входа") + } + // повтор без пароля — пароль берётся из черновика + post("/admin/setup/users/ad-connect", url.Values{"ad_bind_user": {"svc-bj"}, "ad_domain": {"corp.test"}, "ad_server": {srv}}) + if a := adDraftAuth(); a == nil || a.ADBindPassword != "pw1" { + t.Fatal("пароль из черновика потерян") + } + // чужой отпечаток не принимается + if loc := post("/admin/setup/users/ad-trust", url.Values{"fp": {"00:11"}}); !strings.Contains(loc, "устарел") { + t.Fatalf("чужой отпечаток: %s", loc) + } + fp := adDraftForView().Cert.SHA256 + loc = post("/admin/setup/users/ad-trust", url.Values{"fp": {fp}}) + if strings.Contains(loc, "центром сертификации") { + t.Fatalf("после «Доверять» TLS всё ещё не проходит: %s", loc) + } + if a := adDraftAuth(); a == nil || a.ADCAPEM == "" { + t.Fatal("сертификат не запомнен в черновике") + } +} diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go index 3b1cf1f..378c78c 100644 --- a/internal/lkgateway/auth.go +++ b/internal/lkgateway/auth.go @@ -909,11 +909,13 @@ func moduleForPath(p string) string { return "web-cabinet" case p == "/admin/setup/lk", has("/admin/setup/lk-check"), has("/admin/setup/lk-integration"), has("/admin/setup/lk-pull"): return "esia-finance" // интеграция с платформой ЕСИА Финанс — отдельный модуль лицензии + case has("/admin/setup/onec"): + return "onec-middle" } return "" } -var moduleTitles = map[string]string{"esia-finance": "ЕСИА Финанс", "quik-mnp": "QUIK: неторговые поручения и обмен с DEPO", "quik-export": "Экспорт биржевой информации QUIK", "web-cabinet": "Личный кабинет клиента", "npr": "Риск-монитор НПР / УДС"} +var moduleTitles = map[string]string{"esia-finance": "ЕСИА Финанс", "quik-mnp": "QUIK: неторговые поручения и обмен с DEPO", "quik-export": "Экспорт биржевой информации QUIK", "web-cabinet": "Личный кабинет клиента", "npr": "Риск-монитор НПР / УДС", "onec-middle": "1С: база мидл-офиса"} // moduleTitle — признак лицензии словами (в мастере и на странице лицензии — не коды вроде quik-mnp). func moduleTitle(f string) string { @@ -1430,7 +1432,12 @@ func (h *setupHandlers) testAD(w http.ResponseWriter, r *http.Request) { if login == "" || pw == "" { msg, err := adProbe(a) if err != nil { - h.usersFlash(w, r, "AD: недоступен — "+err.Error()) + var cert *adCertInfo + if adCertUntrusted(err) { + cert, _ = adFetchCert(a) + } + adDraftSet(a, adExplain(err, a, cert), cert, adForm{Server: a.ADServer, Domain: a.ADDomain}) + h.usersFlash(w, r, "AD: "+adExplain(err, a, cert)) return } h.usersFlash(w, r, "AD: доступен ("+msg+"). Для проверки ролей введите логин и пароль пользователя домена.") diff --git a/internal/lkgateway/checks.go b/internal/lkgateway/checks.go index 1ff4f0a..2e4cad7 100644 --- a/internal/lkgateway/checks.go +++ b/internal/lkgateway/checks.go @@ -58,7 +58,7 @@ func CheckAll(ctx context.Context, o CheckOptions) SystemStatus { out.Checks = append(out.Checks, checkCryptoSocket(o)) out.Checks = append(out.Checks, checkNSDAdapter(ctx, o)) out.Checks = append(out.Checks, ishVersionStatus(ctx)) - if globalRC == nil || moduleAllowed(globalRC, "web-cabinet") || devMode(globalRC) { // без модуля ЛК нечего проверять + if globalRC == nil || moduleAllowed(globalRC, "esia-finance") || devMode(globalRC) { // без модуля «ЕСИА Финанс» нечего проверять out.Checks = append(out.Checks, checkLKCallback(ctx, o)) } diff --git a/internal/lkgateway/depoevents.go b/internal/lkgateway/depoevents.go index c65b88f..0d42a79 100644 --- a/internal/lkgateway/depoevents.go +++ b/internal/lkgateway/depoevents.go @@ -555,7 +555,9 @@ func depoPollOrders(ctx context.Context, db *sql.DB, rc *RuntimeConfig) { var depoHandbookWritten time.Time -func depoWriteHandbook(rc *RuntimeConfig) error { return depoWriteHandbookCtx(context.Background(), rc) } +func depoWriteHandbook(rc *RuntimeConfig) error { + return depoWriteHandbookCtx(context.Background(), rc) +} // depoWriteHandbookCtx — то же с контекстом вызывающего: после подготовки DEPO (действие человека) вход идёт и во // время паузы после прежнего неудачного входа — иначе справочник ждал бы своего расписания («таблица не создана»). diff --git a/internal/lkgateway/licensecheck.go b/internal/lkgateway/licensecheck.go index d991934..ed0d51a 100644 --- a/internal/lkgateway/licensecheck.go +++ b/internal/lkgateway/licensecheck.go @@ -43,6 +43,7 @@ var knownFeatures = []struct{ Key, Title string }{ {"quik-export", "QUIK: экспорт биржевой информации"}, {"npr", "Риск-монитор НПР / УДС"}, {"esia-finance", "ЕСИА Финанс (платформа личного кабинета)"}, + {"onec-middle", "1С: база мидл-офиса (тест)"}, } func licenseFeatureViews(rc *RuntimeConfig) []LicenseFeatureView { diff --git a/internal/lkgateway/onec.go b/internal/lkgateway/onec.go new file mode 100644 index 0000000..9d1d537 --- /dev/null +++ b/internal/lkgateway/onec.go @@ -0,0 +1,128 @@ +package lkgateway + +// База 1С мидл-офиса — модуль лицензии onec-middle (07.10.2026, тестовый). Пока это подключение: адрес +// публикации базы на веб-сервере, пользователь и пароль 1С; bj проверяет их через стандартный интерфейс OData +// ($metadata). Обмен данными с базой — следующий шаг модуля. + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" +) + +// OneCSettings — подключение к базе 1С мидл-офиса. +type OneCSettings struct { + URL string `json:"url,omitempty"` // адрес публикации: http://1c-server/middle + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` + CheckedAt time.Time `json:"checked_at,omitempty"` + CheckOK bool `json:"check_ok,omitempty"` + CheckMsg string `json:"check_msg,omitempty"` +} + +func (r *RuntimeConfig) UpdateOneC(s OneCSettings) error { + r.mu.Lock() + r.data.OneC = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + +// oneCBase — адрес публикации без хвоста /odata/… и без завершающего «/». +func oneCBase(u string) string { + u = strings.TrimSpace(u) + if i := strings.Index(strings.ToLower(u), "/odata/"); i >= 0 { + u = u[:i] + } + u = strings.TrimRight(u, "/") + if u != "" && !strings.Contains(u, "://") { + u = "http://" + u + } + return u +} + +// saveOneC — POST /admin/setup/onec: сохранить подключение и сразу проверить его. +func (h *setupHandlers) saveOneC(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().OneC + s.URL = oneCBase(r.FormValue("onec_url")) + s.User = strings.TrimSpace(r.FormValue("onec_user")) + if pw := r.FormValue("onec_password"); pw != "" { + s.Password = pw + } + var miss []string + for _, f := range []struct{ v, n string }{{s.URL, "адрес публикации базы"}, {s.User, "пользователь 1С"}, {s.Password, "пароль"}} { + if f.v == "" { + miss = append(miss, f.n) + } + } + if len(miss) > 0 { + setupFlash(w, r, "1С: не заполнено: "+strings.Join(miss, ", ")) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + s.CheckOK, s.CheckMsg = oneCCheck(ctx, s) + cancel() + s.CheckedAt = time.Now() + if err := h.rc.UpdateOneC(s); err != nil { + setupFlash(w, r, "1С: настройки не сохранены — "+err.Error()) + return + } + if s.CheckOK { + setupFlash(w, r, "1С: подключение сохранено — "+s.CheckMsg) + return + } + setupFlash(w, r, "1С: подключение сохранено, но проверка не прошла — "+s.CheckMsg) +} + +// oneCCheck — база отвечает по стандартному интерфейсу OData и пускает пользователя. +func oneCCheck(ctx context.Context, s OneCSettings) (bool, string) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, oneCBase(s.URL)+"/odata/standard.odata/$metadata", nil) + if err != nil { + return false, "адрес не разобран: " + err.Error() + } + req.SetBasicAuth(s.User, s.Password) + cl := &http.Client{Transport: &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 8 * time.Second}).DialContext}} + resp, err := cl.Do(req) + if err != nil { + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return false, "сервер 1С не ответил вовремя — проверьте адрес и что веб-сервер с публикацией базы доступен с сервера bj" + } + if strings.Contains(err.Error(), "certificate") { + return false, "сертификат HTTPS веб-сервера 1С не доверенный — укажите адрес по http или выпустите доверенный сертификат (" + err.Error() + ")" + } + return false, "сервер 1С не отвечает: " + err.Error() + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + switch resp.StatusCode { + case http.StatusOK: + case http.StatusUnauthorized, http.StatusForbidden: + return false, "1С не пустила пользователя «" + s.User + "»: неверный логин или пароль, либо у пользователя нет права на внешнее подключение" + case http.StatusNotFound: + return false, "по адресу нет стандартного интерфейса OData: в конфигураторе 1С — Администрирование → Публикация на веб-сервере, отметьте «Публиковать стандартный интерфейс OData» и переопубликуйте базу" + default: + t := strings.TrimSpace(string(body)) + if len([]rune(t)) > 200 { + t = string([]rune(t)[:200]) + "…" + } + return false, fmt.Sprintf("веб-сервер 1С ответил %d %s", resp.StatusCode, t) + } + if !strings.Contains(string(body), "edmx") { + return false, "по адресу отвечает не 1С (нет описания OData) — проверьте адрес публикации базы" + } + n := strings.Count(string(body), "`)) + } + })) + defer srv.Close() + ctx := context.Background() + if ok, msg := oneCCheck(ctx, OneCSettings{URL: srv.URL + "/mid", User: "bj", Password: "секрет"}); !ok || !strings.Contains(msg, "объектов в OData — 2") { + t.Fatalf("рабочее подключение: %v %q", ok, msg) + } + if ok, msg := oneCCheck(ctx, OneCSettings{URL: srv.URL + "/mid", User: "bj", Password: "не тот"}); ok || !strings.Contains(msg, "не пустила") { + t.Fatalf("неверный пароль: %v %q", ok, msg) + } + if ok, msg := oneCCheck(ctx, OneCSettings{URL: srv.URL + "/other", User: "bj", Password: "секрет"}); ok || !strings.Contains(msg, "Публиковать стандартный интерфейс OData") { + t.Fatalf("без OData: %v %q", ok, msg) + } +} + +func TestOneCModuleGate(t *testing.T) { + if f := moduleForPath("/admin/setup/onec"); f != "onec-middle" { + t.Fatalf("раздел 1С без модуля лицензии: %q", f) + } + if moduleTitle("onec-middle") == "onec-middle" { + t.Fatal("нет названия модуля для лицензии") + } +} diff --git a/internal/lkgateway/prepare.go b/internal/lkgateway/prepare.go index ad5933d..2b14c6a 100644 --- a/internal/lkgateway/prepare.go +++ b/internal/lkgateway/prepare.go @@ -37,7 +37,7 @@ var prepareRun struct { var prepareBoot = time.Now().UnixNano() // prepareCoreFeatures — признаки лицензии, которые работают внутри ядра (отдельных файлов нет). -var prepareCoreFeatures = []string{"quik-mnp", "esia-finance"} +var prepareCoreFeatures = []string{"quik-mnp", "esia-finance", "onec-middle"} // prepareItems — состав лицензии этой установки и состояние каждого модуля. func prepareItems(rc *RuntimeConfig, running, finished bool, runErr string) []PrepareItem { diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index d54533f..b0afbc6 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -43,6 +43,7 @@ type Settings struct { HTTPS HTTPSSettings `json:"https,omitempty"` Time TimeSettings `json:"time,omitempty"` WorkHours WorkHoursSettings `json:"work_hours,omitempty"` // рабочее время: запросы M2M вне него — отказ M2M06 + OneC OneCSettings `json:"onec,omitempty"` // база 1С мидл-офиса (модуль onec-middle) LastTest *TestRunResult `json:"last_test,omitempty"` UpdatedAt time.Time `json:"updated_at"` } @@ -793,8 +794,9 @@ func (r *RuntimeConfig) save() error { // Readiness — сводная готовность подсистемы. type Readiness struct { Name string `json:"name"` - Ready bool `json:"ready"` // полностью настроена и проверена - Configured bool `json:"configured"` // есть пользовательский конфиг (не stub) + Title string `json:"title,omitempty"` // для «Обзора»: по-человечески + Ready bool `json:"ready"` // полностью настроена и проверена + Configured bool `json:"configured"` // есть пользовательский конфиг (не stub) Message string `json:"message"` } @@ -804,28 +806,29 @@ func (r *RuntimeConfig) ReadinessSummary() []Readiness { out := []Readiness{ { Name: "postgres", + Title: "База bj", Configured: s.Postgres.DSN != "", Ready: false, // настоящий ping будет в checks.go Message: posMsg(s.Postgres.DSN), }, { Name: "crypto-service", + Title: "СКЗИ «Валидата»", Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub", Ready: false, Message: cryptoMsg(s.Crypto), }, { Name: "nsd-adapter", + Title: "Шлюз НРД", Configured: s.NSD.IGWBaseURL != "" && s.NSD.Profile != "", Ready: false, Message: nsdMsg(s.NSD), }, - { - Name: "lk-callback", - Configured: s.LK.CallbackURL != "", - Ready: false, - Message: lkMsg(s.LK), - }, + } + // уведомления в личный кабинет — только при модуле ЛК в лицензии: без него нечего настраивать + if moduleAllowed(r, "esia-finance") || devMode(r) { + out = append(out, Readiness{Name: "lk-callback", Title: "Уведомления в ЕСИА Финанс", Configured: s.LK.CallbackURL != "", Message: lkMsg(s.LK)}) } return out } diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 5626e92..8c34079 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -98,6 +98,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate) mux.HandleFunc("/admin/setup/lk-pull", h.lkPull) mux.HandleFunc("/admin/setup/workhours", h.saveWorkHours) + mux.HandleFunc("/admin/setup/onec", h.saveOneC) mux.HandleFunc("/admin/setup/alerts-smtp", h.saveAlertsSMTP) mux.HandleFunc("/admin/setup/alerts-smtp-test", h.testAlertsSMTP) mux.HandleFunc("/admin/setup/alerts-zabbix", h.saveAlertsZabbix) diff --git a/internal/lkgateway/web/templates/admin_help.html b/internal/lkgateway/web/templates/admin_help.html index 1ae54d5..a449d6d 100644 --- a/internal/lkgateway/web/templates/admin_help.html +++ b/internal/lkgateway/web/templates/admin_help.html @@ -11,7 +11,7 @@

PostgreSQL: подключение, схемы fansy / fansy_staging / m2m_core, что подгружается извне (ETL Fansy) и что пишет сама система. Учётные записи, гранты, миграции.

{{end}} - {{if or (mod "web-cabinet") devOn}} + {{if or (mod "esia-finance") devOn}}

API личного кабинета →

REST-контракт ESIA Finance V1: POST /api/v1/back_office/claims/, GET/PATCH-операции, формат callback'ов, аутентификация Basic, примеры запросов curl.

diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 5651572..98aaf0f 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -15,6 +15,7 @@ {{if mod "web-cabinet"}}{{end}} {{if mod "esia-finance"}}{{end}} + {{if mod "onec-middle"}}{{end}} @@ -36,7 +37,7 @@
{{range .Readiness}}
-
{{.Name}}
+
{{if .Title}}{{.Title}}{{else}}{{.Name}}{{end}}
{{if .Configured}}настроено{{else}}не настроено{{end}}
{{end}} @@ -182,6 +183,7 @@
+ {{with adDraft}}{{template "adTrustCard" (withRet . "")}}{{end}}
@@ -783,6 +785,32 @@ {{end}} + {{/* ============ 1С: БАЗА МИДЛ-ОФИСА (модуль onec-middle, тестовый) ============ */}} + {{if mod "onec-middle"}} +
+

1С: база мидл-офиса

+

Связь bj с базой 1С мидл-офиса. Модуль в разработке: сейчас здесь задаётся и проверяется подключение к базе, обмен данными появится в следующих выпусках.

+
+

Подключение к базе 1С

+

bj обращается к базе через стандартный интерфейс OData. Его включают при публикации базы: конфигуратор 1С → Администрирование → Публикация на веб-сервере → «Публиковать стандартный интерфейс OData».

+ +
+ + + Адрес, по которому база открывается в браузере, без /odata — например http://1c-server/middle. +
+
+
+
+
+ Лучше завести в 1С отдельного пользователя для bj — с правом только на нужные данные. +
+ + {{with .Settings.OneC}}{{if not .CheckedAt.IsZero}}

{{if .CheckOK}}✓ Подключение работает{{else}}✗ Подключение не работает{{end}} — {{.CheckMsg}} (проверено {{.CheckedAt.Format "02.01.2006 15:04"}})

{{end}}{{end}} +
+
+ {{end}} + {{/* ============ ДИАГНОСТИКА (id sec-tests сохранён для ссылок) ============ */}}

Диагностика

diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index 2ed8f0b..5ea1cf5 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -52,40 +52,8 @@ .wiz-row-l { color:var(--muted); font-weight:400; font-size:14.5px; min-width:170px; margin:0; } .wiz-opt { display:flex; gap:8px; align-items:flex-start; font-weight:400; margin:0 0 6px; } .wiz-opt .muted { font-size:13px; } -/* Учёт DEPO: вопросы по порядку (номер — счётчиком), выбор — плитками, под ними — панель выбранного способа */ -.dq { counter-reset:dq; } -.dq-s { padding:18px 0 20px; border-top:1px solid var(--border); } -.dq-s:first-child { border-top:0; padding-top:2px; } -.dq-h { display:flex; align-items:center; gap:10px; font-size:15.5px; font-weight:650; margin:0 0 12px; } -.dq-h::before { counter-increment:dq; content:counter(dq); flex:none; width:22px; height:22px; border-radius:50%; background:var(--accent-weak); color:var(--accent); display:grid; place-items:center; font-size:12px; font-weight:700; } -.dq-tiles { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; } -.dq-tile { display:flex; gap:10px; align-items:flex-start; padding:12px 14px; border:1px solid var(--border); border-radius:12px; cursor:pointer; font-weight:400; margin:0; transition:border-color .15s var(--ease), background .15s var(--ease); } -.dq-tile:hover { border-color:var(--border-strong); } -.dq-tile:has(input:checked) { border-color:var(--accent); background:var(--accent-weak); } -.dq-tile b { display:block; font-weight:600; font-size:14.5px; } -.dq-tile small { display:block; color:var(--muted); font-size:13px; line-height:1.4; margin-top:3px; } -.dq-alt { display:inline-flex; gap:8px; align-items:flex-start; margin:12px 0 0; font-weight:400; font-size:13.5px; color:var(--text-2); cursor:pointer; } -.dq-grid { display:grid; gap:10px 12px; } -.dq-grid label { display:block; margin:0; font-size:13.5px; font-weight:500; color:var(--text-2); } -.dq-grid input, .dq-grid select { display:block; width:100%; box-sizing:border-box; margin-top:5px; } -.dq-srv { grid-template-columns:minmax(0,1.5fr) minmax(0,.55fr) minmax(0,1.25fr); } -.dq-2 { grid-template-columns:repeat(2,minmax(0,1fr)); } -.dq-opt { color:var(--muted); font-weight:400; } -.dq-note { color:var(--muted); font-size:13px; margin:8px 0 0; } -.dq-panel { margin-top:18px; } -.dq-panel + .dq-panel { margin-top:14px; } .wiz-card .dq-go { margin-top:16px; border-radius:999px; padding:10px 22px; font-size:14.5px; font-weight:600; } .wiz-card .dq-mono { font-family:var(--mono); } -.dq-sql { margin-top:14px; } -.dq-sql-head { display:flex; align-items:center; gap:8px; margin:0 0 6px; justify-content:flex-start; } -.dq-sql-st { flex:1; font-size:12.5px; color:var(--muted); } -.dq-sql pre:empty::before { content:attr(data-msg); color:var(--muted); font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; font-size:13px; } -.dq-sql pre { max-height:380px; min-height:140px; font-size:12px; margin:0; transition:opacity .2s; } -.dq-sql pre.dq-busy { opacity:.5; } -.dq.is-pg [data-ms], .dq:not(.is-pg) [data-pg] { display:none !important; } -.dq-jobname { margin-top:14px; max-width:520px; } -.dq-jobname label { display:block; margin:0; font-size:13.5px; font-weight:500; color:var(--text-2); } -.dq-jobname input { display:block; width:100%; box-sizing:border-box; margin-top:5px; } /* установка ключа (общий блок part_keywiz) — в виде мастера */ .keywiz ol { gap:4px !important; } .keywiz ol li > div > div:first-child { font-weight:500 !important; font-size:14.5px; } @@ -93,37 +61,8 @@ .keywiz .card h2 { font-size:16px; margin:0 0 6px; } .keywiz form .btn-ok, .keywiz form .btn-secondary { justify-self:start; border-radius:999px; padding:10px 22px; font-weight:600; } .keywiz form .btn-ok { background:var(--accent); border-color:var(--accent); } -.dq-box { border:1px solid var(--border); border-radius:12px; padding:14px 16px 16px; } -.dq-box-bj { background:color-mix(in srgb, var(--accent-weak) 45%, transparent); } -.dq-box-h { font-weight:600; font-size:14.5px; margin:0 0 10px; } -.dq-box-h span { color:var(--muted); font-weight:400; font-size:13px; } -.dq-lbl-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; } .wiz-card .dq-link { background:none; border:0; padding:0; color:var(--accent); font-size:12.5px; font-weight:500; cursor:pointer; border-radius:4px; } -.dq-link:hover { text-decoration:underline; } -.dq-warn { margin:10px 0 0; padding:8px 12px; border-radius:8px; background:var(--warn-weak); color:var(--text-2); font-size:13px; } /* ход подготовки: строки шагов со значком состояния */ -.dq-run { margin-top:16px; border:1px solid var(--border); border-radius:12px; padding:12px 16px; } -.dq-steps { list-style:none; margin:0; padding:0; display:grid; gap:2px; } -.dq-st { display:flex; gap:10px; align-items:flex-start; padding:6px 0; font-size:14px; } -.dq-st-t { min-width:0; } -.dq-st-d { color:var(--muted); font-size:12.5px; margin-top:2px; overflow-wrap:anywhere; } -.dq-st-ic { flex:none; position:relative; width:18px; height:18px; border-radius:50%; margin-top:1px; box-sizing:border-box; } -.dq-st.is-run .dq-st-ic { border:2px solid var(--accent-weak); border-top-color:var(--accent); animation:dq-spin .8s linear infinite; } -.dq-st.is-run { color:var(--text); font-weight:500; } -.dq-st.is-ok .dq-st-ic { background:var(--ok); } -.dq-st.is-ok .dq-st-ic::after { content:""; position:absolute; left:50%; top:45%; width:4px; height:8px; border:solid #fff; border-width:0 1.8px 1.8px 0; transform:translate(-50%,-50%) rotate(45deg); } -.dq-st.is-ok:not(.is-final) { color:var(--text-2); } -.dq-st.is-final { font-weight:600; color:var(--ok); } -.dq-st.is-warn .dq-st-ic, .dq-st.is-err .dq-st-ic { color:#fff; font-size:12px; font-weight:700; line-height:18px; text-align:center; } -.dq-st.is-warn .dq-st-ic { background:var(--warn); } -.dq-st.is-warn .dq-st-ic::after { content:"!"; } -.dq-st.is-err .dq-st-ic { background:var(--err); } -.dq-st.is-err .dq-st-ic::after { content:"×"; } -.dq-st.is-err { color:var(--err); } -.dq-run-msg { margin-top:8px; padding:10px 12px; border-radius:8px; background:var(--err-weak, rgba(200,40,40,.08)); font-size:13.5px; overflow-wrap:anywhere; white-space:pre-wrap; } -@keyframes dq-spin { to { transform:rotate(360deg); } } -@media (prefers-reduced-motion: reduce) { .dq-st.is-run .dq-st-ic { animation:none; border-color:var(--accent); } } -@media (max-width:600px) { .dq-tiles, .dq-srv, .dq-2 { grid-template-columns:minmax(0,1fr); } .dq-sql-head { flex-wrap:wrap; } .dq-sql-st { flex-basis:100%; } } .tooltip { display:inline-block; background:var(--border); color:var(--muted); border-radius:50%; width:16px; height:16px; line-height:16px; text-align:center; font-size:11px; cursor:help; margin-left:4px; } .help-block { background:var(--accent-weak); padding:10px 14px; margin:10px 0; font-size:13px; border-radius:10px; } .help-block strong { color:var(--accent); } @@ -748,8 +687,8 @@

Пароль передайте сотруднику лично; сменить его можно в разделе Настройка → Пользователи.

- {{$A := .Settings.Auth}} -
+ {{$A := .Settings.Auth}}{{$D := adDraft}} +
Добавить сотрудника из домена (Active Directory) — необязательно {{if and $A.ADEnabled $A.ADBindUser}}

Домен {{$A.ADDomain}} подключён. Найдите сотрудника — он будет входить в bj своим доменным логином и паролем; пароль проверяет домен.

@@ -777,16 +716,40 @@ },350); }); })(); {{end}} -
{{if and $A.ADEnabled $A.ADBindUser}}Другой домен или учётная запись{{else}}Подключить домен{{end}} -

Нужны адрес контроллера домена и учётная запись для поиска сотрудников — подойдёт обычная, без прав администратора домена. bj сразу проверит связь.

-
+
{{if and $A.ADEnabled $A.ADBindUser}}Другой домен или учётная запись{{else}}Подключить домен{{end}} + {{$bu := $A.ADBindUser}}{{$pw := $A.ADBindPassword}}{{$fs := ""}}{{$fd := ""}}{{$adv := false}} + {{if $D}}{{$bu = $D.BindUser}}{{$pw = ""}}{{if $D.HasPassword}}{{$pw = "введён"}}{{end}}{{$fs = $D.Form.Server}}{{$fd = $D.Form.Domain}}{{$adv = $D.Form.Adv}}{{end}} +

Введите учётную запись домена и пароль — домен и его контроллеры bj найдёт сам. Прав администратора домена не нужно; лучше отдельная служебная запись (например svc-bj): bj хранит её пароль, чтобы искать сотрудников.

+
+ - - - - -
+ + +
Расширенные настройки — если домен не найдётся сам +
+ + +
+

Пусто — bj ищет сам. Контроллер можно указать именем (bj попробует LDAPS 636, затем LDAP 389 со StartTLS) или адресом со схемой: ldaps://dc1.corp.example.ru:636.

+
+
+ {{if $D}}

Не подключено: {{$D.Err}}{{if not $D.Cert}} Поправьте и нажмите «Подключить» ещё раз — введённое сохранено.{{end}}

+ {{template "adTrustCard" (withRet $D "/admin/wizard?step=13")}}{{end}} +