Мастер: шапка на месте; домен — только логин и пароль; модуль «1С: база мидл-офиса» (тест)

- Шапка мастера: правый блок (шлюз НРД, пользователь, тема) снова справа — после правила [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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-07 01:03:16 +03:00
parent 005053294e
commit 773fa8e0bf
21 changed files with 1064 additions and 118 deletions
+175
View File
@@ -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})
}
@@ -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)
}
}
}
+8 -7
View File
@@ -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
}
+366 -21
View File
@@ -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 — сотрудник домена в результатах поиска.
+89
View File
@@ -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("сертификат не запомнен в черновике")
}
}
+9 -2
View File
@@ -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+"). Для проверки ролей введите логин и пароль пользователя домена.")
+1 -1
View File
@@ -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))
}
+3 -1
View File
@@ -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 (действие человека) вход идёт и во
// время паузы после прежнего неудачного входа — иначе справочник ждал бы своего расписания («таблица не создана»).
+1
View File
@@ -43,6 +43,7 @@ var knownFeatures = []struct{ Key, Title string }{
{"quik-export", "QUIK: экспорт биржевой информации"},
{"npr", "Риск-монитор НПР / УДС"},
{"esia-finance", "ЕСИА Финанс (платформа личного кабинета)"},
{"onec-middle", "1С: база мидл-офиса (тест)"},
}
func licenseFeatureViews(rc *RuntimeConfig) []LicenseFeatureView {
+128
View File
@@ -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), "<EntityType ")
if n == 0 {
return true, "база отвечает, пользователь входит; объекты для OData ещё не открыты — их состав задаётся в базе 1С (УстановитьСоставСтандартногоИнтерфейсаOData)"
}
return true, fmt.Sprintf("база отвечает, пользователь входит; объектов в OData — %d", n)
}
+56
View File
@@ -0,0 +1,56 @@
package lkgateway
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestOneCBase(t *testing.T) {
for in, want := range map[string]string{
"http://1c/middle/": "http://1c/middle",
"1c-server/middle": "http://1c-server/middle",
"https://1c/mid/odata/standard.odata/$metadata": "https://1c/mid",
" http://1c/mid/OData/standard.odata ": "http://1c/mid",
} {
if got := oneCBase(in); got != want {
t.Errorf("oneCBase(%q) = %q, want %q", in, got, want)
}
}
}
func TestOneCCheck(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, _ := r.BasicAuth()
switch {
case !strings.HasPrefix(r.URL.Path, "/mid/odata/standard.odata/$metadata"):
http.NotFound(w, r)
case u != "bj" || p != "секрет":
w.WriteHeader(http.StatusUnauthorized)
default:
w.Write([]byte(`<edmx:Edmx><edmx:DataServices><Schema><EntityType Name="Catalog_Клиенты"/><EntityType Name="Document_Поручение"/></Schema></edmx:DataServices></edmx:Edmx>`))
}
}))
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("нет названия модуля для лицензии")
}
}
+1 -1
View File
@@ -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 {
+11 -8
View File
@@ -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
}
+1
View File
@@ -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)
@@ -11,7 +11,7 @@
<p class="muted">PostgreSQL: подключение, схемы <code>fansy</code> / <code>fansy_staging</code> / <code>m2m_core</code>, что подгружается извне (ETL Fansy) и что пишет сама система. Учётные записи, гранты, миграции.</p>
</div>
</a>{{end}}
{{if or (mod "web-cabinet") devOn}}<a href="/admin/help/lk-api" style="text-decoration:none">
{{if or (mod "esia-finance") devOn}}<a href="/admin/help/lk-api" style="text-decoration:none">
<div class="card" style="height:100%">
<h2 style="color:var(--accent)">API личного кабинета →</h2>
<p class="muted">REST-контракт ESIA Finance V1: <code>POST /api/v1/back_office/claims/</code>, GET/PATCH-операции, формат callback'ов, аутентификация Basic, примеры запросов curl.</p>
@@ -15,6 +15,7 @@
<button data-sec="nsd"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18M4 21V10M20 21V10M3 10l9-6 9 6M8 21v-7M12 21v-7M16 21v-7"/></svg></span>НРД<span class="ind {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span></button>
{{if mod "web-cabinet"}}<button data-sec="cabinet"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3.2"/><path d="M6 19c.6-3 3-4.6 6-4.6s5.4 1.6 6 4.6"/><rect x="3" y="4" width="18" height="16" rx="2"/></svg></span>Кабинет клиента<span class="ind {{if .Settings.LK.APIKey}}ok{{else}}warn{{end}}"></span></button>{{end}}
{{if mod "esia-finance"}}<button data-sec="lk"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="M6 16c.4-1.4 1.6-2.2 3-2.2s2.6.8 3 2.2"/><path d="M15 10h4M15 13h3"/></svg></span>ЕСИА Финанс<span class="ind {{if .Settings.LK.PublicURL}}ok{{else}}warn{{end}}"></span></button>{{end}}
{{if mod "onec-middle"}}<button data-sec="onec"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="10" cy="6" rx="6" ry="2.5"/><path d="M4 6v10c0 1.4 2.7 2.5 6 2.5"/><path d="M16 6v4"/><path d="M4 11c0 1.4 2.7 2.5 6 2.5"/><path d="M14 16h7M18 13l3 3-3 3"/></svg></span>1С: мидл-офис<span class="ind {{if .Settings.OneC.CheckOK}}ok{{else}}warn{{end}}"></span></button>{{end}}
<button data-sec="tests"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5 8.5A2 2 0 0 0 6.7 21h10.6a2 2 0 0 0 1.7-3.5L14 9V3"/><path d="M8 15h8"/></svg></span>Диагностика</button>
<button data-sec="alerts"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></span>Оповещения<span class="ind {{if or .Settings.Alerts.SMTP.Enabled .Settings.Alerts.Zabbix.Enabled}}ok{{end}}"></span></button>
<button data-sec="update"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V8M6 12l6-6 6 6"/><path d="M5 4h14"/></svg></span>Обновления{{if .UpdatesAvailable}}<span class="ind warn"></span>{{end}}</button>
@@ -36,7 +37,7 @@
<div class="grid" style="margin-top:12px">
{{range .Readiness}}
<div class="stat">
<div><span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span><strong>{{.Name}}</strong></div>
<div><span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span><strong>{{if .Title}}{{.Title}}{{else}}{{.Name}}{{end}}</strong></div>
<div class="muted" style="font-size:12px;margin-top:4px">{{if .Configured}}настроено{{else}}не настроено{{end}}</div>
</div>
{{end}}
@@ -182,6 +183,7 @@
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap"><button class="btn btn-ok" type="submit">Сохранить</button></div>
</form>
{{with adDraft}}{{template "adTrustCard" (withRet . "")}}{{end}}
<form method="post" action="/admin/setup/auth/test" style="display:grid;grid-template-columns:1fr 1fr auto;gap:10px;max-width:820px;margin-top:12px;align-items:end">
<label>Проверка: логин домена <input type="text" name="test_login" placeholder="ivanov" style="width:100%" autocomplete="off"></label>
<label>Пароль <input type="password" name="test_password" style="width:100%" autocomplete="off"></label>
@@ -783,6 +785,32 @@
</section>
{{end}}
{{/* ============ 1С: БАЗА МИДЛ-ОФИСА (модуль onec-middle, тестовый) ============ */}}
{{if mod "onec-middle"}}
<section class="settings-section" id="sec-onec">
<h1>1С: база мидл-офиса</h1>
<p class="muted" style="margin:-8px 0 16px">Связь bj с базой 1С мидл-офиса. Модуль в разработке: сейчас здесь задаётся и проверяется подключение к базе, обмен данными появится в следующих выпусках.</p>
<div class="card">
<h2><span class="dot {{if .Settings.OneC.CheckOK}}ok{{else}}warn{{end}}"></span>Подключение к базе 1С</h2>
<p class="muted">bj обращается к базе через стандартный интерфейс OData. Его включают при публикации базы: конфигуратор 1С → Администрирование → Публикация на веб-сервере → «Публиковать стандартный интерфейс OData».</p>
<form method="post" action="/admin/setup/onec" style="margin-top:12px;display:grid;gap:12px;max-width:640px">
<div class="fld">
<label>Адрес публикации базы</label>
<input type="text" name="onec_url" value="{{.Settings.OneC.URL}}" placeholder="http://1c-server/middle" data-need="адрес публикации базы" style="margin-top:4px">
<span class="muted" style="font-size:12px">Адрес, по которому база открывается в браузере, без /odata — например http://1c-server/middle.</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px">
<div class="fld"><label>Пользователь 1С</label><input type="text" name="onec_user" value="{{.Settings.OneC.User}}" data-need="пользователь 1С" autocomplete="off" style="margin-top:4px"></div>
<div class="fld"><label>Пароль</label><input type="password" name="onec_password" {{if .Settings.OneC.Password}}placeholder="сохранён — не меняйте, если тот же"{{else}}data-need="пароль"{{end}} autocomplete="new-password" style="margin-top:4px"></div>
</div>
<span class="muted" style="font-size:12px">Лучше завести в 1С отдельного пользователя для bj — с правом только на нужные данные.</span>
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
</form>
{{with .Settings.OneC}}{{if not .CheckedAt.IsZero}}<p style="margin:14px 0 0;font-size:13.5px"><b style="color:var(--{{if .CheckOK}}ok{{else}}err{{end}})">{{if .CheckOK}}✓ Подключение работает{{else}}✗ Подключение не работает{{end}}</b> — {{.CheckMsg}} <span class="muted">(проверено {{.CheckedAt.Format "02.01.2006 15:04"}})</span></p>{{end}}{{end}}
</div>
</section>
{{end}}
{{/* ============ ДИАГНОСТИКА (id sec-tests сохранён для ссылок) ============ */}}
<section class="settings-section" id="sec-tests">
<h1>Диагностика</h1>
@@ -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 @@
<p class="muted" style="font-size:12.5px;margin:8px 0 0">Пароль передайте сотруднику лично; сменить его можно в разделе Настройка → Пользователи.</p>
<div style="margin-top:12px"><button type="submit" class="btn btn-ok">Создать учётную запись</button></div>
</form>
{{$A := .Settings.Auth}}
<details class="acc-ad" style="margin-top:20px;padding-top:14px;border-top:1px solid var(--border)">
{{$A := .Settings.Auth}}{{$D := adDraft}}
<details class="acc-ad" {{if $D}}open{{end}} style="margin-top:20px;padding-top:14px;border-top:1px solid var(--border)">
<summary style="cursor:pointer;color:var(--accent);font-size:14px">Добавить сотрудника из домена (Active Directory) — необязательно</summary>
{{if and $A.ADEnabled $A.ADBindUser}}
<p class="muted" style="font-size:13.5px;margin:10px 0 0">Домен <b>{{$A.ADDomain}}</b> подключён. Найдите сотрудника — он будет входить в bj своим доменным логином и паролем; пароль проверяет домен.</p>
@@ -777,16 +716,40 @@
},350); });
})();</script>
{{end}}
<details {{if not (and $A.ADEnabled $A.ADBindUser)}}open{{end}} style="margin-top:12px"><summary style="cursor:pointer;font-size:13px;color:var(--accent)">{{if and $A.ADEnabled $A.ADBindUser}}Другой домен или учётная запись{{else}}Подключить домен{{end}}</summary>
<p class="muted" style="font-size:13px;margin:8px 0 0">Нужны адрес контроллера домена и учётная запись для поиска сотрудников — подойдёт обычная, без прав администратора домена. bj сразу проверит связь.</p>
<form method="post" action="/admin/setup/users/ad-connect" class="acc-form">
<details id="ad-connect" {{if or $D (not (and $A.ADEnabled $A.ADBindUser))}}open{{end}} style="margin-top:12px"><summary style="cursor:pointer;font-size:13px;color:var(--accent)">{{if and $A.ADEnabled $A.ADBindUser}}Другой домен или учётная запись{{else}}Подключить домен{{end}}</summary>
{{$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}}
<p class="muted" style="font-size:13px;margin:8px 0 0">Введите учётную запись домена и пароль — домен и его контроллеры bj найдёт сам. Прав администратора домена не нужно; лучше отдельная служебная запись (например svc-bj): bj хранит её пароль, чтобы искать сотрудников.</p>
<div id="ad-found" class="muted" style="font-size:13px;margin-top:8px" aria-live="polite"></div>
<form method="post" action="/admin/setup/users/ad-connect" class="acc-form" id="ad-form">
<input type="hidden" name="return_to" value="/admin/wizard?step=13">
<label>Контроллер домена<input type="text" name="ad_server" data-need="контроллер домена" value="{{$A.ADServer}}" placeholder="dc1.corp.local или ldaps://dc1.corp.local:636" autocomplete="off"></label>
<label>Домен<input type="text" name="ad_domain" data-need="домен" value="{{$A.ADDomain}}" placeholder="corp.local" autocomplete="off"></label>
<label>Учётная запись для поиска<input type="text" name="ad_bind_user" data-need="учётная запись для поиска" value="{{$A.ADBindUser}}" placeholder="svc-bj" autocomplete="off"></label>
<label>Пароль{{if $A.ADBindPassword}} <span class="muted">(задан; пусто — оставить)</span>{{end}}<input type="password" name="ad_bind_password" {{if not $A.ADBindPassword}}data-need="пароль учётной записи"{{end}} autocomplete="new-password"></label>
<div><button type="submit" class="btn">Подключить и проверить связь</button></div>
<label>Учётная запись домена<input type="text" name="ad_bind_user" data-need="учётная запись домена" value="{{$bu}}" placeholder="svc-bj или svc-bj@corp.example.ru" autocomplete="off"></label>
<label>Пароль{{if $pw}} <span class="muted">({{$pw}}; пусто — оставить)</span>{{end}}<input type="password" name="ad_bind_password" {{if not $pw}}data-need="пароль"{{end}} autocomplete="new-password"></label>
<details id="ad-adv" {{if $adv}}open{{end}} style="grid-column:1/-1;margin-top:2px"><summary style="cursor:pointer;font-size:13px;color:var(--muted)">Расширенные настройки — если домен не найдётся сам</summary>
<div class="acc-form" style="margin-top:8px">
<label>Домен<input type="text" name="ad_domain" value="{{$fd}}" placeholder="найдётся сам, например corp.example.ru" autocomplete="off"></label>
<label>Контроллер домена<input type="text" name="ad_server" value="{{$fs}}" placeholder="найдётся сам, например dc1.corp.example.ru" autocomplete="off"></label>
</div>
<p class="muted" style="font-size:12.5px;margin:6px 0 0">Пусто — bj ищет сам. Контроллер можно указать именем (bj попробует LDAPS 636, затем LDAP 389 со StartTLS) или адресом со схемой: ldaps://dc1.corp.example.ru:636.</p>
</details>
<div><button type="submit" class="btn">Подключить</button></div>
</form>
{{if $D}}<p style="margin:12px 0 0;padding:10px 12px;border-radius:10px;background:var(--card-2);font-size:13.5px;line-height:1.45"><b style="color:var(--err)">Не подключено:</b> {{$D.Err}}{{if not $D.Cert}} Поправьте и нажмите «Подключить» ещё раз — введённое сохранено.{{end}}</p>
{{template "adTrustCard" (withRet $D "/admin/wizard?step=13")}}{{end}}
<script>(function(){
var box=document.getElementById('ad-connect'), outer=box.closest('details.acc-ad'), out=document.getElementById('ad-found'), adv=document.getElementById('ad-adv'), f=document.getElementById('ad-form'), done=false;
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function look(){ if(done||!box.open||(outer&&!outer.open)) return; done=true; out.textContent='Ищу домен…';
fetch('/admin/setup/users/ad-discover',{cache:'no-store',headers:{'Accept':'application/json'}}).then(function(r){return r.json()}).then(function(d){
var x=d.found;
if(d.ok){ out.innerHTML='<span style="color:var(--ok)">✓</span> Найден домен <b>'+esc(x.domain)+'</b>, контроллеры: '+x.dcs.map(esc).join(', ')+' <span class="muted">('+esc(x.from)+')</span>';
f.ad_domain.placeholder=x.domain+' — найден сам'; f.ad_server.placeholder=x.dcs[0]+' — найден сам'; return; }
out.textContent=x&&x.domain?'Домен '+x.domain+' найден, но его контроллеры не нашлись в DNS — укажите контроллер в расширенных настройках.':'Домен сам не нашёлся — укажите его в расширенных настройках или впишите учётную запись с доменом: svc-bj@corp.example.ru.';
adv.open=true;
}).catch(function(){ out.textContent=''; done=false; });
}
box.addEventListener('toggle',look); if(outer) outer.addEventListener('toggle',look); look();
})();</script>
</details>
</details>
<script>(function(){
+73 -2
View File
@@ -89,7 +89,7 @@ body {
.nav .ddmenu { display:none; position:absolute; top:100%; left:0; margin-top:8px; background:var(--card); border:1px solid var(--border);
border-radius:10px; box-shadow:0 8px 30px rgba(0,0,0,.14); min-width:210px; z-index:60; padding:6px; }
.nav .ddmenu a { display:block; padding:8px 12px; border-radius:7px; }
.topbar-right { display:flex; align-items:center; gap:12px; flex:none; padding-left:10px; }
.topbar-right { display:flex; align-items:center; gap:12px; flex:none; padding-left:10px; margin-left:auto; }
@media (max-width:1100px){ .sys-status span:not(.pulse){ display:none } .sys-status{ padding:5px 8px } .topbar .clock{ display:none } .user-dd .user-role{ display:none } }
/* Строка системного статуса — состояние читается всегда */
.sys-status { display:inline-flex; align-items:center; gap:7px; font-size:12px; font-weight:600; color:var(--text-2); padding:5px 11px 5px 9px; border:1px solid var(--border); border-radius:20px; background:var(--card-2); white-space:nowrap; }
@@ -276,7 +276,15 @@ label { font-size:13px; font-weight:500; }
.settings-nav button .ind.err { background:var(--err); }
.settings-section { display:none; }
.settings-section.active { display:block; }
.settings-section > h1 { margin-bottom:18px; }
.settings-section > h1 { margin-bottom:18px; font-size:24px; font-weight:700; letter-spacing:-0.015em; }
/* «Настройка» в стиле мастера (07.10.2026): скруглённые карточки, спокойные поля, крупные заголовки */
.settings-section .card { padding:24px 28px 20px; border-radius:16px; }
.settings-section .card > h2:first-child { font-size:17px; font-weight:660; letter-spacing:-0.01em; margin:0 0 6px; }
.settings-section .card > h2:first-child + p.muted { font-size:14px; margin:0 0 14px; }
.settings-section input:not([type=radio]):not([type=checkbox]):not([type=hidden]):not([type=file]):not([type=submit]), .settings-section select, .settings-section textarea { background:var(--card-2) !important; border:1px solid var(--border) !important; border-radius:10px !important; padding:10px 13px !important; font-size:14.5px; color:var(--text); box-sizing:border-box; }
.settings-section input:focus, .settings-section select:focus, .settings-section textarea:focus { background:var(--card) !important; border-color:var(--accent) !important; outline:3px solid var(--accent-weak); }
.settings-section .btn, .settings-section button { border-radius:10px; }
.settings-section input[type=radio], .settings-section input[type=checkbox] { width:auto; }
@media (max-width:820px) {
.settings { grid-template-columns:1fr; }
.settings-nav { flex-direction:row; overflow-x:auto; position:static; padding-bottom:8px; }
@@ -327,6 +335,69 @@ details:has(> pre.copyable) { min-width:0; max-width:100%; }
.banner-dev.on{background:color-mix(in srgb,var(--warn) 12%,var(--card))}
.banner-dev.off{background:color-mix(in srgb,var(--ok) 10%,var(--card))}
.banner-dev button:hover{filter:brightness(.97)}
/* Компоненты мастера настройки (07.10.2026 — общие: те же плитки, панели и ход по этапам в «Настройке») */
/* Учёт 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; }
.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; }
.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; }
.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%; } }
</style>
</head>
<body>
@@ -0,0 +1,19 @@
{{/* Сертификат контроллера домена, которого bj не знает: кто выдал, отпечаток и «Доверять». Вход: withRet <adDraftView> <return_to>. */}}
{{define "adTrustCard"}}{{with .D.Cert}}
<div class="ad-trust" style="margin-top:14px;padding:14px 16px;border:1px solid color-mix(in srgb,var(--warn) 55%,var(--border));border-radius:12px;background:var(--warn-weak)">
<div style="font-weight:600;font-size:14.5px">Сертификат контроллера {{$.D.Server}}</div>
<div style="display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 14px;margin-top:8px;font-size:13.5px">
<span class="muted">{{if .IsCA}}Центр сертификации{{else}}Выдан на{{end}}</span><span>{{.Subject}}</span>
<span class="muted">Кем выдан</span><span>{{.Issuer}}</span>
<span class="muted">Действует до</span><span>{{.Until}}</span>
{{if .SHA1}}<span class="muted">Отпечаток (как в Windows)</span><code style="font-size:12px;overflow-wrap:anywhere">{{.SHA1}}</code>{{end}}
<span class="muted">Отпечаток SHA-256</span><code style="font-size:12px;overflow-wrap:anywhere">{{.SHA256}}</code>
</div>
{{if .IsCA}}<p class="muted" style="font-size:12.5px;margin:10px 0 0">Если это центр сертификации вашего домена — доверьтесь ему: bj запомнит его и будет проверять по нему все контроллеры. Сверить отпечаток можно на контроллере: certlm.msc → Доверенные корневые центры сертификации → сертификат → Состав → Отпечаток.</p>
{{else}}<p class="muted" style="font-size:12.5px;margin:10px 0 0">Контроллер не прислал сертификат центра, поэтому показан его собственный. После «Доверять» bj войдёт в домен, возьмёт оттуда сертификат центра «{{.Issuer}}» (только если именно он подписал этот сертификат) — и дальше подойдут все контроллеры домена и их новые сертификаты. Сверить отпечаток можно на контроллере: certlm.msc → Личное → Сертификаты → сертификат {{.Subject}} → Состав → Отпечаток.</p>{{end}}
<form method="post" action="/admin/setup/users/ad-trust" style="margin-top:10px">
{{if $.Ret}}<input type="hidden" name="return_to" value="{{$.Ret}}">{{end}}<input type="hidden" name="fp" value="{{.SHA256}}">
<button type="submit" class="btn btn-ok">Доверять и подключить</button>
</form>
</div>
{{end}}{{end}}
@@ -141,7 +141,7 @@
<div class="dq-jobname" id="quik-acc">
<label>Торговый счёт для поручений<input type="text" name="account" value="{{.D.Settings.Quik.Account}}" placeholder="торговый счёт фирмы в QUIK" spellcheck="false" class="dq-mono"></label>
</div>
<p class="dq-note" id="quik-mode-st" aria-live="polite">Выбор сохраняется сразу. Тонкая настройка — инструменты МНП, коды клиентов, поручения из терминала — в «Настройка → QUIK».</p>
<p class="dq-note" id="quik-mode-st" aria-live="polite">Выбор сохраняется сразу.{{if .Ret}} Тонкая настройка — инструменты МНП, коды клиентов, поручения из терминала — в «Настройка → QUIK».{{end}}</p>
<noscript><button type="submit" class="btn">Сохранить</button></noscript>
</form>
<p class="dq-note">{{quikRecipientsInfo}} После первого заполнения справочника депозитариев-получателей попросите администратора QUIK перезапустить модуль неторговых поручений — QUIK читает справочник только при запуске.</p>
+14 -1
View File
@@ -32,7 +32,7 @@ var wizStepTitles = map[int]string{
var wizRequired = map[int]bool{1: true, 2: true, 3: true, 4: true, 5: true, 6: true, 7: true, 8: true}
// wizModules — лицензируемые модули, которые настраиваются в главе «Запуск».
var wizModules = []string{"web-cabinet", "esia-finance", "quik-export", "npr"}
var wizModules = []string{"web-cabinet", "esia-finance", "quik-export", "npr", "onec-middle"}
// WizStep — шаг в пути мастера.
type WizStep struct {
@@ -202,6 +202,19 @@ func wizModuleViews(rc *RuntimeConfig, s Settings) []WizModule {
m.Link = "/admin/setup?from=wizard#quikexport"
m.Ready = s.NPR.Enabled && s.NPR.DSN != ""
m.Status = map[bool]string{true: "включён, база экспорта QUIK подключена", false: "не включён"}[m.Ready]
case "onec-middle":
m.Title = "1С: база мидл-офиса"
m.What = "Связь с базой 1С мидл-офиса (модуль в разработке): пока — подключение к опубликованной базе и его проверка."
m.Link = "/admin/setup?from=wizard#onec"
m.Ready = s.OneC.CheckOK
switch {
case m.Ready:
m.Status = "подключение к базе 1С проверено"
case s.OneC.URL != "":
m.Status = "адрес базы задан, проверка не прошла"
default:
m.Status = "не настроено: адрес публикации базы, пользователь и пароль 1С"
}
}
out = append(out, m)
}