diff --git a/internal/lkgateway/depo_setup.go b/internal/lkgateway/depo_setup.go index 4d37dd1..c732b8a 100644 --- a/internal/lkgateway/depo_setup.go +++ b/internal/lkgateway/depo_setup.go @@ -192,19 +192,38 @@ func depoSetupFileName(f FancyMSSQLSettings) string { return "bj-depo-setup-mssql.sql" } +// depoStep — ход подготовки для экрана: key — шаг, state — run (начат) | ok | warn, text — что делается или итог. +type depoStep func(key, state, text string) + // depoSetupRun — «bj сделает сам»: тот же скрипт выполняется логином администратора (не сохраняется) в базе -// db, которую назвал клиент; сначала — проверка, что это база DEPO. -func depoSetupRun(ctx context.Context, f FancyMSSQLSettings, db, adminUser, adminPass, login, pass string, agentJob bool) error { +// db, которую назвал клиент; сначала — подключение и проверка, что это база DEPO. step — ход для экрана. +func depoSetupRun(ctx context.Context, f FancyMSSQLSettings, db, adminUser, adminPass, login, pass string, agentJob bool, step depoStep) error { adm := f adm.User, adm.Password = strings.TrimSpace(adminUser), adminPass if !validDBName(db) { return fmt.Errorf("укажите имя базы DEPO") } + step("connect", "run", "Подключаюсь к серверу "+f.Server+" логином администратора «"+adm.User+"»") + if _, err := fancyPing(ctx, adm, ""); err != nil { + return fmt.Errorf("к серверу %s логином %s подключиться не удалось — %s", f.Server, adm.User, quikErrorDetail(err)) + } + step("connect", "ok", "") + step("isdepo", "run", "Проверяю, что «"+db+"» — база учёта DEPO") if err := depoCheckIsDepo(ctx, adm, db); err != nil { return err } + step("isdepo", "ok", "") + what := "логин bj «" + login + "», таблицы обмена, права" + if agentJob && !f.IsPostgres() { + what += ", задание «" + depoJobName + "»" + } + step("apply", "run", "Применяю скрипт подготовки: "+what) if f.IsPostgres() { - return depoSetupRunPG(ctx, adm, db, login, pass) + if err := depoSetupRunPG(ctx, adm, db, login, pass); err != nil { + return err + } + step("apply", "ok", "") + return nil } conn, err := fancyOpen(adm, "") if err != nil { @@ -216,6 +235,7 @@ func depoSetupRun(ctx context.Context, f FancyMSSQLSettings, db, adminUser, admi if _, err := conn.ExecContext(cctx, depoSetupScript(f, db, login, pass, agentJob)); err != nil { return fmt.Errorf("подготовка базы %s логином %s: %s%s", db, adm.User, quikErrorDetail(err), depoTablesRightsHint(f, adm.User, err)) } + step("apply", "ok", "") return nil } diff --git a/internal/lkgateway/fancydb.go b/internal/lkgateway/fancydb.go index 71a5fdb..f7daee0 100644 --- a/internal/lkgateway/fancydb.go +++ b/internal/lkgateway/fancydb.go @@ -95,7 +95,7 @@ func fancyOpen(s FancyMSSQLSettings, db string) (*sql.DB, error) { if s.IsPostgres() && db == "" { db = fancyAdminDB(s) } - conn, err := sql.Open(fancyDriver(s), fancyDSN(s, db)) + conn, err := fancyOpenGated(s, fancyDSN(s, db)) // после неудачного входа — пауза, чтобы не заблокировать логин if err != nil { return nil, err } diff --git a/internal/lkgateway/fancydb_handlers.go b/internal/lkgateway/fancydb_handlers.go index 1e9bf76..3266332 100644 --- a/internal/lkgateway/fancydb_handlers.go +++ b/internal/lkgateway/fancydb_handlers.go @@ -126,7 +126,7 @@ func (h *setupHandlers) createFancyDepoTables(w http.ResponseWriter, r *http.Req setupFlash(w, r, "Таблицы DEPO: сначала сохраните подключение к серверу БД") return } - ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 2*time.Minute) defer cancel() msg, err := depoTablesCreate(ctx, s, r.FormValue("admin_user"), r.FormValue("admin_password")) if err != nil { @@ -158,7 +158,7 @@ func (h *setupHandlers) checkFancyDepoTables(w http.ResponseWriter, r *http.Requ setupFlash(w, r, "Таблицы DEPO: сначала сохраните подключение к серверу БД") return } - ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), time.Minute) defer cancel() msg, ok := depoTablesCheck(ctx, s) h.saveDepoTablesCheck(msg, ok) @@ -235,7 +235,7 @@ func (h *setupHandlers) checkFancyDepoJob(w http.ResponseWriter, r *http.Request http.Error(w, "method", http.StatusMethodNotAllowed) return } - ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) + ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 90*time.Second) defer cancel() msg, ok := depoJobCheck(ctx, h.rc) pmsg, pok := depoProcCheck(ctx, h.rc) @@ -263,7 +263,7 @@ func (h *setupHandlers) createFancyDepoJob(w http.ResponseWriter, r *http.Reques http.Error(w, "method", http.StatusMethodNotAllowed) return } - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + ctx, cancel := context.WithTimeout(depoLoginForce(r.Context()), 60*time.Second) defer cancel() if err := depoJobCreateAgent(ctx, h.rc, r.FormValue("admin_user"), r.FormValue("admin_password")); err != nil { setupFlash(w, r, "Создание джобы: "+err.Error()) @@ -313,7 +313,7 @@ func (h *setupHandlers) testFancy(w http.ResponseWriter, r *http.Request) { return } // Проверяем соединение к серверу (master) — БД может ещё не существовать. - ver, err := fancyPing(r.Context(), s, "master") + ver, err := fancyPing(depoLoginForce(r.Context()), s, "master") if err != nil { setupFlash(w, r, fancyDBLabel(s)+": соединение не удалось — "+err.Error()) return diff --git a/internal/lkgateway/fancygate.go b/internal/lkgateway/fancygate.go new file mode 100644 index 0000000..7a03401 --- /dev/null +++ b/internal/lkgateway/fancygate.go @@ -0,0 +1,125 @@ +package lkgateway + +// Защита логина DEPO от блокировки (06.10.2026). На SQL Server логины с CHECK_POLICY подчиняются политике +// блокировки Windows: несколько неудачных входов подряд — и логин заблокирован для всех, кто им пользуется. +// Службы bj ходят в DEPO каждые несколько секунд; с неверным паролем (сменили на сервере, опечатка в +// настройке, второй bj с тем же логином) они за минуты блокировали логин — так 06.10 тестовая ВМ с логином +// bj_user остановила обмен боевого bj. Поэтому после неудачного входа bj с этими логином и паролем больше +// не пробует depoLoginPause — кроме входа, который человек запустил сам (проверка в мастере, кнопки +// настройки): для него depoLoginForce. Новый пароль — новая попытка сразу. + +import ( + "context" + "crypto/sha256" + "database/sql" + "database/sql/driver" + "encoding/hex" + "errors" + "fmt" + "strconv" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgconn" + mssql "github.com/microsoft/go-mssqldb" +) + +// depoLoginPause — сколько не повторять вход после неудачи (политика блокировки считает попытки за окно; +// раз в 15 минут — заведомо реже порога). +const depoLoginPause = 15 * time.Minute + +type depoLoginFail struct { + at time.Time + err string +} + +var depoLoginFails = struct { + sync.Mutex + m map[string]depoLoginFail +}{m: map[string]depoLoginFail{}} + +type depoLoginForceKey struct{} + +// depoLoginForce — вход по действию человека: выполняется и во время паузы после неудачи. +func depoLoginForce(ctx context.Context) context.Context { + return context.WithValue(ctx, depoLoginForceKey{}, true) +} + +// depoLoginKey — сервер, логин и отпечаток пароля (сам пароль в памяти ключом не держим). +func depoLoginKey(s FancyMSSQLSettings) string { + h := sha256.Sum256([]byte(s.Password)) + return fancyDriver(s) + "|" + s.Server + "|" + strconv.Itoa(s.Port) + "|" + s.User + "|" + hex.EncodeToString(h[:8]) +} + +// depoIsLoginFailure — сервер отверг логин или пароль (то, что считает политика блокировки). +func depoIsLoginFailure(err error) bool { + var me mssql.Error + if errors.As(err, &me) { + switch me.Number { + case 18456, 18486, 18487, 18488: // неверный логин/пароль, заблокирован, пароль истёк, нужно сменить + return true + } + } + var pe *pgconn.PgError + if errors.As(err, &pe) { + return pe.Code == "28P01" || pe.Code == "28000" + } + return false +} + +// fancyConnector — подключение к DEPO через паузу после неудачного входа. +type fancyConnector struct { + base driver.Connector + key string + user string + srv string +} + +func (c *fancyConnector) Driver() driver.Driver { return c.base.Driver() } + +func (c *fancyConnector) Connect(ctx context.Context) (driver.Conn, error) { + force, _ := ctx.Value(depoLoginForceKey{}).(bool) + depoLoginFails.Lock() + f, failed := depoLoginFails.m[c.key] + depoLoginFails.Unlock() + if failed && !force && time.Since(f.at) < depoLoginPause { + return nil, fmt.Errorf("вход в DEPO логином %s не удался в %s (%s) — bj не повторяет его до %s, чтобы неудачные попытки не заблокировали логин на сервере. Проверьте логин и пароль: Настройка → «Учёт DEPO»", + c.user, f.at.Format("15:04"), f.err, f.at.Add(depoLoginPause).Format("15:04")) + } + conn, err := c.base.Connect(ctx) + switch { + case err != nil && depoIsLoginFailure(err): + depoLoginFails.Lock() + depoLoginFails.m[c.key] = depoLoginFail{at: time.Now(), err: quikErrorDetail(err)} + depoLoginFails.Unlock() + if !failed && !force { + raiseSystemAlert(globalRC, "DEPO: вход логином "+c.user+" не удался", + "Сервер "+c.srv+" отверг логин или пароль bj. Чтобы неудачные попытки не заблокировали логин, bj не повторяет вход "+ + strconv.Itoa(int(depoLoginPause/time.Minute))+" минут. Проверьте логин и пароль в настройке «Учёт DEPO».\n\n"+quikErrorDetail(err)) + } + case err == nil && failed: + depoLoginFails.Lock() + delete(depoLoginFails.m, c.key) + depoLoginFails.Unlock() + } + return conn, err +} + +// fancyOpenGated — пул подключений к DEPO с паузой после неудачного входа. +func fancyOpenGated(s FancyMSSQLSettings, dsn string) (*sql.DB, error) { + probe, err := sql.Open(fancyDriver(s), dsn) // без подключения: только чтобы взять драйвер + if err != nil { + return nil, err + } + drv := probe.Driver() + _ = probe.Close() + dc, ok := drv.(driver.DriverContext) + if !ok { + return sql.Open(fancyDriver(s), dsn) + } + base, err := dc.OpenConnector(dsn) + if err != nil { + return nil, err + } + return sql.OpenDB(&fancyConnector{base: base, key: depoLoginKey(s), user: s.User, srv: s.Server}), nil +} diff --git a/internal/lkgateway/fancygate_test.go b/internal/lkgateway/fancygate_test.go new file mode 100644 index 0000000..c60d05b --- /dev/null +++ b/internal/lkgateway/fancygate_test.go @@ -0,0 +1,56 @@ +package lkgateway + +import ( + "context" + "database/sql/driver" + "errors" + "strings" + "testing" + + mssql "github.com/microsoft/go-mssqldb" +) + +type fakeDepoConnector struct { + calls int + err error +} + +func (f *fakeDepoConnector) Connect(context.Context) (driver.Conn, error) { + f.calls++ + return nil, f.err +} +func (f *fakeDepoConnector) Driver() driver.Driver { return nil } + +// После неудачного входа службы bj с тем же паролем к серверу не ходят (иначе блокировка логина у всех, +// кто им пользуется); вход по действию человека — идёт; сетевые ошибки паузы не дают; удачный вход её снимает. +func TestDepoLoginPause(t *testing.T) { + old := globalRC + globalRC = nil + defer func() { globalRC = old }() + ctx := context.Background() + base := &fakeDepoConnector{err: mssql.Error{Number: 18456, Message: "Login failed for user 'bj_user'."}} + c := &fancyConnector{base: base, key: "test|pause", user: "bj_user", srv: "sql"} + if _, err := c.Connect(ctx); err == nil || base.calls != 1 { + t.Fatalf("первый вход: %v, обращений %d", err, base.calls) + } + if _, err := c.Connect(ctx); err == nil || base.calls != 1 || !strings.Contains(err.Error(), "не повторяет") { + t.Fatalf("служба во время паузы не должна ходить на сервер: %v, обращений %d", err, base.calls) + } + if _, _ = c.Connect(depoLoginForce(ctx)); base.calls != 2 { + t.Fatalf("вход по действию человека должен идти: обращений %d", base.calls) + } + base.err = nil + if _, err := c.Connect(depoLoginForce(ctx)); err != nil { + t.Fatal(err) + } + if _, _ = c.Connect(ctx); base.calls != 4 { + t.Fatalf("удачный вход снимает паузу: обращений %d", base.calls) + } + net := &fakeDepoConnector{err: errors.New("dial tcp 10.0.0.1:1433: i/o timeout")} + c2 := &fancyConnector{base: net, key: "test|net", user: "bj_user"} + _, _ = c2.Connect(ctx) + _, _ = c2.Connect(ctx) + if net.calls != 2 { + t.Fatalf("сетевая ошибка — не повод для паузы: обращений %d", net.calls) + } +} diff --git a/internal/lkgateway/onboarding_test.go b/internal/lkgateway/onboarding_test.go index c13bd17..64a3579 100644 --- a/internal/lkgateway/onboarding_test.go +++ b/internal/lkgateway/onboarding_test.go @@ -532,6 +532,18 @@ func TestDepoScriptView(t *testing.T) { if f := rc.Snapshot().Fancy; f.User != "bj_user" || f.Password != "Working1x" || f.DepoNewUser != "bj_new" || f.DepoNewPass != "Newpass12" { t.Fatalf("вход не удался — рабочие логин/пароль не должны меняться: %q/%q, черновик %q", f.User, f.Password, f.DepoNewUser) } + // с экрана (stream=1) — ход по шагам строками JSON, последняя — итог; адрес ошибки — на тот же экран + chk.Set("stream", "1") + r = httptest.NewRequest(http.MethodPost, "/admin/setup/wizard-depo/setup", strings.NewReader(chk.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + cw = httptest.NewRecorder() + h.wizardDepoSetup(cw, r) + lines := strings.Split(strings.TrimSpace(cw.Body.String()), "\n") + if !strings.Contains(cw.Header().Get("Content-Type"), "ndjson") || len(lines) < 2 || + !strings.Contains(lines[0], `"step":"login"`) || !strings.Contains(lines[0], `"state":"run"`) || + !strings.Contains(lines[len(lines)-1], `"done":true`) || !strings.Contains(lines[len(lines)-1], `"ok":false`) { + t.Fatalf("ход проверки: %s", cw.Body.String()) + } // экран пересобирает скрипт на ходу — и до того, как вписан адрес сервера form.Set("bj_password", "Other123x") form.Set("server", "") diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index 0a850be..79c3210 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -83,6 +83,36 @@ .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-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; } @@ -202,20 +232,23 @@ -
С этим логином bj будет входить в базу DEPO — его создаст подготовка. Пароль придумал bj, можно вписать свой. Если такой логин на сервере уже есть, его пароль будет заменён — когда этим логином пользуется другая программа, впишите другое имя.
+bj придумал их сам. Можно заменить своими — bj сохранит и будет использовать ровно то, что здесь написано: подготовка создаст на сервере этот логин с этим паролем (в скрипте — они же).
+Если логин с таким именем на сервере уже есть, ему будет поставлен этот пароль — программа, которая им пользуется, перестанет входить. Тогда впишите другое имя.
| Подключение к базе DEPO | {{if .Settings.Fancy.Server}}✓ {{.Settings.Fancy.Server}}, база {{.DepoTables.DB}}{{else}}не настроено{{end}} |
| Подключение к базе DEPO | {{if .Settings.Fancy.Server}}✓ {{.Settings.Fancy.Server}}, база {{.DepoTables.DB}}, логин bj «{{.Settings.Fancy.User}}»{{else}}не настроено{{end}} |
| Таблицы обмена FTV_M2M | {{if .Settings.Fancy.DepoTablesCheckOK}}✓ на месте{{else}}✗ {{.Settings.Fancy.DepoTablesCheck}}{{end}} |
| Запуск обработки | {{if .Settings.Fancy.DepoJobCheckOK}}✓ {{.Settings.Fancy.DepoJobCheck}}{{else}}✗ {{.Settings.Fancy.DepoJobCheck}}{{end}} |
| Обработка событий (процедуры) | {{if .Settings.Fancy.DepoProcCheckOK}}✓ {{.Settings.Fancy.DepoProcCheck}}{{else if .Settings.Fancy.DepoProcCheck}}⚠ {{.Settings.Fancy.DepoProcCheck}}{{else}}не проверялась — «Перепроверить всё»{{end}} |