diff --git a/deploy/linux/sbin/bj-igate-admin b/deploy/linux/sbin/bj-igate-admin index 94b3f24..b45fbed 100755 --- a/deploy/linux/sbin/bj-igate-admin +++ b/deploy/linux/sbin/bj-igate-admin @@ -270,19 +270,27 @@ INSERT INTO channels(name, code, type, enable) SELECT :'name', :'code', 'WSL', true WHERE NOT EXISTS (SELECT 1 FROM channels WHERE code = :'code'); CREATE TEMP TABLE want(n text, v text) ON COMMIT DROP; INSERT INTO want VALUES - ('enable','True'),('wslEndpoint',:'ep'),('cryptography.type',:'typ'),('cryptography.pincode',''), + ('enable','True'),('wslEndpoint',:'ep'),('cryptography.type',:'typ'),('cryptography.pincode',NULL), ('cryptography.clientCertificateSerialNumber',''),('repositoryCode',:'dep'),('fetchInterval','00:00:10'),('attemptInterval','30000'), ('sendAttempts','3'),('maxPartSize','500'),('loadOldMessagesDepth','3'),('isIncomingEnabled','True'),('isOutgoingEnabled','True'), ('isTransitTerminalChannel','False'),('useDirectories','True'),('dir',:'dir'),('inboxDirName','INBOX'),('outboxDirName','OUTBOX'), - ('sentDirName','SENT'),('errorDirName','ERRORS'),('archive1042sDirName',:'dir' || '/Archives1042S'),('enableLockFile','True'), + ('sentDirName','SENT'),('errorDirName','ERRORS'),('archive1042sDirName','Archives1042S'),('enableLockFile','True'), ('enableAutoResponse','True'),('enable1042ReportProcessing','True'),('RenameOutgoingFiles','True'),('generateReceivedPackageInfo','True'), ('generateSentPackageInfo','False'),('moveReceiptsToSentFolder','False'),('applyAddHashOfPackageToFolder','False'), ('ignorePackageDirectoryStructure','True'),('checkReceivedPackageNsdSign','False'),('checkReceivedPackageSenderSign','False'), ('autoUpdateTransitMember','False'),('automaticcalyLoadCrls','False'),('autoInPkgReportOffload','False'),('monitoringThreshold','00:00:01'), - ('autoLoadCrlsTime',''),('fetchThreadCount',''),('forceCryPackageEncryption',''),('inPkgReportDirectory',''), - ('inPkgReportOffloadInterval',''),('maxPackagesPerJob',''),('nsdCertificateSerialNumbers',''),('pkiDecryptMode',''),('pkiEncryptMode',''), - ('pkiSignMode',''),('pkiVerifyMode',''),('receiveProcThreadCount',''),('sendProcThreadCount',''),('updateTransitMemberListTime',''), + ('autoLoadCrlsTime',NULL),('fetchThreadCount',NULL),('forceCryPackageEncryption',NULL),('inPkgReportDirectory',NULL), + ('inPkgReportOffloadInterval',NULL),('maxPackagesPerJob',NULL),('nsdCertificateSerialNumbers',''),('pkiDecryptMode',NULL),('pkiEncryptMode',NULL), + ('pkiSignMode',NULL),('pkiVerifyMode',NULL),('receiveProcThreadCount',NULL),('sendProcThreadCount',NULL),('updateTransitMemberListTime',NULL), ('wslTlsCACertFingerprint',:'fp'),('wslTlsCertDN','*.nsd.ru'),('wslTlsCertChainLen','2'),('isM2M','True'); +-- Необязательные параметры канала — NULL, как их пишет интерфейс ИШ. Пустую строку ИШ разбирает как число или +-- интервал, падает и молча выкидывает канал («There are no active channels», API: «more than one Channel with same +-- code») — так было на чистом сервере .29 (06.10.2026). Каналы, записанные прежними версиями bj, чинятся здесь же. +UPDATE parameters SET value = NULL + WHERE chanel_id IS NOT NULL AND value = '' AND name IN ('autoLoadCrlsTime','cryptography.pincode','fetchThreadCount', + 'forceCryPackageEncryption','inPkgReportDirectory','inPkgReportOffloadInterval','maxPackagesPerJob','pkiDecryptMode','pkiEncryptMode', + 'pkiSignMode','pkiVerifyMode','receiveProcThreadCount','sendProcThreadCount','updateTransitMemberListTime'); +UPDATE parameters SET value = 'Archives1042S' WHERE chanel_id IS NOT NULL AND name = 'archive1042sDirName' AND value LIKE '/%/Archives1042S'; -- существующие значения канала не трогаем, кроме контурных (адрес, отпечаток, криптография, депозитарный код) UPDATE parameters p SET value = w.v FROM want w, channels c WHERE c.code = :'code' AND p.chanel_id = c.id AND p.name = w.n diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go index 63d3b1c..db5f319 100644 --- a/internal/lkgateway/auth.go +++ b/internal/lkgateway/auth.go @@ -1209,9 +1209,25 @@ func (v UsersView) RoleNote(k string) string { } func (h *setupHandlers) usersFlash(w http.ResponseWriter, r *http.Request, msg string) { + if safeReturnPath(r.FormValue("return_to")) != "" { // форма из мастера (шаг «Учётные записи») — туда же + setupFlash(w, r, msg) + return + } http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg)+"#users", http.StatusSeeOther) } +// doneUsers — кроме администратора заведена хотя бы одна действующая учётная запись: у каждого сотрудника свой +// вход, и в журнале видно, кто что сделал. +func doneUsers() bool { + n := 0 + for _, u := range globalUsers.List() { + if u.Active { + n++ + } + } + return n > 1 +} + func (h *setupHandlers) saveUser(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) @@ -1230,6 +1246,10 @@ func (h *setupHandlers) saveUser(w http.ResponseWriter, r *http.Request) { } u := globalUsers.Get(login) isNew := u == nil + if !isNew && r.FormValue("create") != "" { // форма «создать» не должна молча менять существующую запись + h.usersFlash(w, r, "Пользователи: логин «"+login+"» уже занят — выберите другой.") + return + } if isNew { u = &User{Login: login, Source: "local", Active: true, CreatedAt: time.Now()} } diff --git a/internal/lkgateway/depoevents.go b/internal/lkgateway/depoevents.go index a4ec7d3..f00e667 100644 --- a/internal/lkgateway/depoevents.go +++ b/internal/lkgateway/depoevents.go @@ -1439,28 +1439,51 @@ var ( ) func depoObjectsMissing(ctx context.Context, db *sql.DB, f FancyMSSQLSettings) string { + if msg := depoTablesMissing(ctx, db, f); msg != "" { + return msg + } + return depoProcsMissing(ctx, db, f) +} + +// depoMissing — какие объекты из списка (тип U — таблица, P — процедура) отсутствуют в базе DEPO. +func depoMissing(ctx context.Context, db *sql.DB, names []string, typ string) []string { + var out []string + for _, n := range names { + var ok int + if err := db.QueryRowContext(ctx, "SELECT CASE WHEN OBJECT_ID(@n, @t) IS NULL THEN 0 ELSE 1 END", sql.Named("n", "dbo."+n), sql.Named("t", typ)).Scan(&ok); err == nil && ok == 0 { + out = append(out, n) + } + } + return out +} + +// depoTablesMissing — нет таблиц Fancy DEPO с поддержкой M2M: без них обмен невозможен. +func depoTablesMissing(ctx context.Context, db *sql.DB, f FancyMSSQLSettings) string { if f.IsPostgres() { return "" } - missing := func(names []string, typ string) []string { - var out []string - for _, n := range names { - var ok int - if err := db.QueryRowContext(ctx, "SELECT CASE WHEN OBJECT_ID(@n, @t) IS NULL THEN 0 ELSE 1 END", sql.Named("n", "dbo."+n), sql.Named("t", typ)).Scan(&ok); err == nil && ok == 0 { - out = append(out, n) - } - } - return out - } - if m := missing(depoFancyM2MTables, "U"); len(m) > 0 { + if m := depoMissing(ctx, db, depoFancyM2MTables, "U"); len(m) > 0 { return "в базе " + f.DepoDB() + " нет таблиц Fancy DEPO с поддержкой M2M (" + strings.Join(m, ", ") + "): нужна версия Fancy DEPO с M2M — обратитесь к разработчику Fancy" } + return "" +} + +// depoProcsMissingText — начало сообщения «нет процедур bj2depo» (по нему отличается от других отказов обработки). +const depoProcsMissingText = "нет процедур обработки bj2depo" + +// depoProcsMissing — нет процедур обработки bj2depo. С 06.10.2026 это не блокирует запуск: формат обмена с учётной +// системой ещё готовится, процедуры могут поставляться отдельно (в том числе сообщениями в саму учётную систему) — +// проверка готовности напоминает «не забудьте доустановить». +func depoProcsMissing(ctx context.Context, db *sql.DB, f FancyMSSQLSettings) string { + if f.IsPostgres() { + return "" + } procs := depoBj2depoProcs if p := strings.TrimSpace(f.DepoJobProc); p != "" && !strings.EqualFold(strings.TrimPrefix(p, "dbo."), "FTV_M2M_JOB") { procs = []string{strings.TrimPrefix(p, "dbo.")} // своя процедура обработки — проверяем только её } - if m := missing(procs, "P"); len(m) > 0 { - return "в базе " + f.DepoDB() + " нет процедур обработки bj2depo: " + strings.Join(m, ", ") + ". Их нужно установить в базу DEPO (порядок: WIRING → OPERATION → EVENTS → CREATE → JOB), затем — «Перепроверить всё»" + if m := depoMissing(ctx, db, procs, "P"); len(m) > 0 { + return "в базе " + f.DepoDB() + " " + depoProcsMissingText + ": " + strings.Join(m, ", ") + ". Их нужно установить в базу DEPO (порядок: WIRING → OPERATION → EVENTS → CREATE → JOB), затем — «Перепроверить всё»" } return "" } diff --git a/internal/lkgateway/igate.go b/internal/lkgateway/igate.go index 4e5c6e6..29c4dea 100644 --- a/internal/lkgateway/igate.go +++ b/internal/lkgateway/igate.go @@ -54,6 +54,37 @@ func igateState(ctx context.Context, base string) (bool, string) { return true, "шлюз отвечает по адресу " + base + ", движок работает" } +// igateChannelState — загрузил ли ИШ канал с этим составным кодом (<канал контура><код депонента>) и включён ли он. +// Движок может быть Running и без единого канала: ИШ молча выкидывает канал, который не смог прочитать из базы +// (06.10.2026 на .29: пустые строки вместо NULL в необязательных параметрах), — и тогда любой обмен даёт HTTP 500. +func igateChannelState(ctx context.Context, base, code string) (found, enabled bool, err error) { + base = strings.TrimRight(pickStr(strings.TrimSpace(base), igateDefaultURL), "/") + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(cctx, http.MethodGet, base+"/api/admin/channels/wsl", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, false, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, false, fmt.Errorf("HTTP %s", resp.Status) + } + var list []struct { + Code string `json:"code"` + IsEnabled bool `json:"isEnabled"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&list); err != nil { + return false, false, err + } + for _, c := range list { + if strings.EqualFold(c.Code, code) { + return true, c.IsEnabled, nil + } + } + return false, false, nil +} + func registerIgate(mux *http.ServeMux, rc *RuntimeConfig) { registerIgateAdmin(mux, rc) mux.HandleFunc("/admin/setup/igate/state", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/lkgateway/notifyadmin_test.go b/internal/lkgateway/notifyadmin_test.go index 1df3673..575e4f8 100644 --- a/internal/lkgateway/notifyadmin_test.go +++ b/internal/lkgateway/notifyadmin_test.go @@ -157,3 +157,30 @@ func TestSMTPTestTo(t *testing.T) { t.Errorf("без поля — прежний: %q", got) } } + +// Форма «Создать учётную запись» из мастера не меняет существующего пользователя с тем же логином. +func TestCreateUserLoginTaken(t *testing.T) { + old := usersFile + usersFile = filepath.Join(t.TempDir(), "users.json") + defer func() { usersFile = old }() + prev := globalUsers + globalUsers = &userStore{users: map[string]*User{}} + defer func() { globalUsers = prev }() + if err := globalUsers.Put(&User{Login: "admin", Role: "admin", Source: "local", Active: true}); err != nil { + t.Fatal(err) + } + h := &setupHandlers{} + r := httptest.NewRequest(http.MethodPost, "/admin/setup/users", strings.NewReader("login=admin&role=viewer&create=1&password=12345678&return_to=%2Fadmin%2Fwizard%3Fstep%3D13")) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.saveUser(w, r) + if u := globalUsers.Get("admin"); u == nil || u.Role != "admin" { + t.Fatalf("существующий администратор изменён: %+v", u) + } + if loc := w.Header().Get("Location"); !strings.HasPrefix(loc, "/admin/wizard?step=13") || !strings.Contains(loc, "flash=") { + t.Fatalf("возврат в мастер: %q", loc) + } + if doneUsers() { + t.Fatal("один администратор — шаг не выполнен") + } +} diff --git a/internal/lkgateway/onboarding_test.go b/internal/lkgateway/onboarding_test.go index 6ad7b08..6c9ec7a 100644 --- a/internal/lkgateway/onboarding_test.go +++ b/internal/lkgateway/onboarding_test.go @@ -126,9 +126,19 @@ func TestHTTPSSelfSignedAndRedirect(t *testing.T) { if _, err := loadTLSCert(nil); err != nil { t.Fatal(err) } - rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json") - _ = rc.UpdateHTTPS(HTTPSSettings{Redirect: true}) + rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json") // перенаправление — по умолчанию, без настройки h := httpsRedirect(rc, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) })) + httpsUp.Store(false) // HTTPS не поднялся — не перенаправляем, иначе в bj не войти + if rec := httptest.NewRecorder(); func() int { + req := httptest.NewRequest(http.MethodGet, "http://10.10.10.6:8080/admin/setup", nil) + req.RemoteAddr = "10.10.10.5:5000" + h.ServeHTTP(rec, req) + return rec.Code + }() != http.StatusTeapot { + t.Fatal("без работающего HTTPS перенаправлять нельзя") + } + httpsUp.Store(true) + defer httpsUp.Store(false) cases := []struct { path, remote string want int @@ -150,6 +160,14 @@ func TestHTTPSSelfSignedAndRedirect(t *testing.T) { t.Errorf("перенаправление: %s", rec.Header().Get("Location")) } } + _ = rc.UpdateHTTPS(HTTPSSettings{RedirectOff: true}) // выключено в настройках + req := httptest.NewRequest(http.MethodGet, "http://10.10.10.6:8080/admin/setup", nil) + req.RemoteAddr = "10.10.10.5:5000" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusTeapot { + t.Errorf("выключенное перенаправление: %d", rec.Code) + } } func TestReceiverUnknownRejected(t *testing.T) { @@ -192,7 +210,7 @@ func TestWizardChaptersWithoutLicense(t *testing.T) { rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json") done := map[int]bool{2: true, 6: true} path, chapters := wizPath(rc, done, 4) - want := []int{1, 12, 4, 7, 8, 9, 10} // без лицензии нет учёта DEPO, QUIK и модулей; база и Валидата — сделаны + want := []int{1, 12, 4, 7, 8, 9, 13, 10} // без лицензии нет учёта DEPO, QUIK и модулей; база и Валидата — сделаны; учётные записи — перед проверкой if fmt.Sprint(path) != fmt.Sprint(want) { t.Fatalf("путь %v, ожидался %v", path, want) } diff --git a/internal/lkgateway/readiness.go b/internal/lkgateway/readiness.go index c365740..8b2a58f 100644 --- a/internal/lkgateway/readiness.go +++ b/internal/lkgateway/readiness.go @@ -14,11 +14,14 @@ import ( "os" "os/exec" "sort" + "strconv" "strings" "sync" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter" ) // cryptoJarPath — модуль проверки ключа подписи bj-crypto (компонент crypto-service.jar). @@ -177,6 +180,20 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck { c.State, c.Detail = "err", "шлюз отвечает, но контур НРД в bj не выбран" default: c.State, c.Detail = "ok", msg+"; контур "+s.NSD.Profile + if prof, err := nsdadapter.LookupProfile(s.NSD.Profile); err == nil && orgCode(s) != "" { + code := prof.Channel + orgCode(s) + switch found, enabled, err := igateChannelState(ctx, s.NSD.IGWBaseURL, code); { + case err != nil: + case !found: + c.State, c.Detail = "err", "движок шлюза работает, но канала "+code+" в нём нет — шлюз не загрузил канал: пересоздайте его на шаге «Шлюз НРД» (кнопка «Создать канал»)" + return c + case !enabled: + c.State, c.Detail = "err", "канал "+code+" в шлюзе выключен — он включается, когда установлен ключ подписи этого контура (шаг «Ключ подписи»)" + return c + default: + c.Detail += "; канал " + code + " работает" + } + } nsdWatch.Lock() okAt := nsdWatch.LastPollOK nsdWatch.Unlock() @@ -239,8 +256,10 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck { c.State, c.Detail = "warn", "сертификат HTTPS: "+err.Error() case certDaysLeft(until) < 14: c.State, c.Detail = "warn", fmt.Sprintf("сертификат «%s» истекает %s — замените", cn, until.Format("02.01.2006")) - case !s.HTTPS.Redirect: - c.State, c.Detail = "warn", "вход в bj по http:// не перенаправляется на https:// — пароли могут идти по сети открытым текстом" + case !httpsUp.Load(): + c.State, c.Detail = "warn", "HTTPS не запущен (порт "+strconv.Itoa(s.HTTPS.port())+" занят или нет сертификата) — вход только по http://, пароли идут по сети открытым текстом; см. журнал bj-server" + case !s.HTTPS.RedirectOn(): + c.State, c.Detail = "warn", "перенаправление с http:// на https:// выключено в настройках — пароли могут идти по сети открытым текстом" default: c.State, c.Detail = "ok", fmt.Sprintf("вход только по HTTPS, сертификат «%s» до %s", cn, until.Format("02.01.2006")) } @@ -386,17 +405,26 @@ func readyDepo(ctx context.Context, rc *RuntimeConfig) ReadyCheck { c.State = "err" notes = append(notes, "таблицы обмена FTV_M2M: "+pickStr(f.DepoTablesCheck, "не проверены")) } - if msg := depoObjectsMissing(cctx, db, f); msg != "" { + procsPending := false + if msg := depoTablesMissing(cctx, db, f); msg != "" { c.State = "err" notes = append(notes, msg) + } else if msg := depoProcsMissing(cctx, db, f); msg != "" { + // процедуры bj2depo ещё не установлены — не блокирует: формат обмена с учётной системой готовится, их + // доустанавливают отдельно; пока их нет, события bj в DEPO копятся необработанными + procsPending = true + notes = append(notes, "процедуры обработки bj2depo ещё не установлены — не забудьте их доустановить. Пока их нет, события bj в базе DEPO ждут обработки") } else if !f.DepoProcCheckOK && f.DepoProcCheck != "" { c.State = "err" notes = append(notes, "обработка событий: "+f.DepoProcCheck) } - if jmsg, jok := depoJobCheck(cctx, rc); !jok { + if jmsg, jok := depoJobCheck(cctx, rc); !jok && !procsPending { c.State = "err" notes = append(notes, "запуск обработки: "+jmsg) } + if procsPending && c.State == "ok" { + c.State = "warn" + } if n, _, err := depoAccPrefixInfo(cctx, f); err == nil && n == 0 && !f.IsPostgres() { c.State = "err" notes = append(notes, "открытых торговых счетов «"+depoAccPrefix()+"…» в учёте нет — проверьте префикс счёта") diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 4da27a5..73723b1 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -211,6 +211,7 @@ type WizardData struct { Alerts bool // оповещения настроены и резервные копии включены Modules bool // модули по лицензии настроены Server bool // время сервера синхронизируется + Users bool // кроме администратора заведены учётные записи сотрудников } } @@ -281,8 +282,9 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { } // Главы (wizchapters.go): в пути только шаги, нужные этой установке — по лицензии и без сделанного // установщиком. Без ?step — первый несделанный обязательный шаг, а если всё сделано — проверка. + d.Done.Users = doneUsers() done := map[int]bool{1: d.Done.License, 2: d.Done.Postgres, 3: d.Done.Depo, 4: d.Done.Org, 5: d.Done.Quik, 6: d.Done.Crypto, - 7: d.Done.NSD, 8: d.Done.Certs, 9: d.Done.Alerts, 10: d.Done.TestRun, 11: d.Done.Modules, 12: d.Done.Server} + 7: d.Done.NSD, 8: d.Done.Certs, 9: d.Done.Alerts, 10: d.Done.TestRun, 11: d.Done.Modules, 12: d.Done.Server, 13: d.Done.Users} path, _ := wizPath(h.rc, done, 0) step := wizDefaultStep(path, done) if v := r.URL.Query().Get("step"); v != "" { @@ -320,7 +322,7 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { } cancel() } - if (step >= 3 && step <= 5) || step == 9 || step == 11 { // DEPO, организация, QUIK МНП, оповещения и копии, модули — общие блоки из «Настройки» + if (step >= 3 && step <= 5) || step == 9 || step == 11 || step == 13 { // DEPO, организация, QUIK МНП, оповещения и копии, модули, учётные записи — общие блоки из «Настройки» fd := h.setupData(w, r, "") fd.InWizard = true d.Fancy = &fd @@ -391,7 +393,7 @@ func depoServerEdition(ctx context.Context, f FancyMSSQLSettings) (string, bool) // wizardSteps — шагов в мастере: 1 лицензия, 2 база bj, 3 учёт DEPO, 4 наша организация, 5 QUIK МНП, // 6 Валидата, 7 шлюз НРД, 8 ключ и сертификаты (ключ ставится в профиль ИШ — после шлюза), // 9 оповещения и резервные копии, 10 проверка готовности. -const wizardSteps = 12 // 11 — модули по лицензии (глава «Запуск»), 12 — время и сеть (глава «Сервер») +const wizardSteps = 13 // 11 — модули по лицензии (глава «Запуск»), 12 — время и сеть (глава «Сервер»), 13 — учётные записи // dsnSummary — «host:port, база name» из строки подключения PostgreSQL, без логина и пароля. func dsnSummary(dsn string) string { diff --git a/internal/lkgateway/tlsserve.go b/internal/lkgateway/tlsserve.go index 04de118..afbeb5b 100644 --- a/internal/lkgateway/tlsserve.go +++ b/internal/lkgateway/tlsserve.go @@ -25,16 +25,25 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" ) // HTTPSSettings — HTTPS веб-интерфейса. type HTTPSSettings struct { - Port int `json:"port,omitempty"` // 0 — 8443 - Redirect bool `json:"redirect,omitempty"` // браузер с HTTP — на HTTPS - Custom bool `json:"custom,omitempty"` // загружен сертификат организации (не самоподписанный) + Port int `json:"port,omitempty"` // 0 — 8443 + // RedirectOff — не перенаправлять браузер с HTTP на HTTPS. С 06.10.2026 перенаправление включено сразу + // (самоподписанный сертификат bj создаёт сам): пароли не должны идти по сети открытым текстом. + RedirectOff bool `json:"redirect_off,omitempty"` + Custom bool `json:"custom,omitempty"` // загружен сертификат организации (не самоподписанный) } +// RedirectOn — браузер с HTTP перенаправляется на HTTPS. +func (h HTTPSSettings) RedirectOn() bool { return !h.RedirectOff } + +// httpsUp — HTTPS действительно слушает порт: иначе перенаправление оставило бы без входа в bj. +var httpsUp atomic.Bool + func (h HTTPSSettings) port() int { if h.Port > 0 && h.Port < 65536 { return h.Port @@ -148,7 +157,7 @@ func tlsCertInfo() (subject string, notAfter time.Time, err error) { func httpsRedirect(rc *RuntimeConfig, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := rc.Snapshot().HTTPS - if h.Redirect && r.TLS == nil && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/healthz" { + if h.RedirectOn() && httpsUp.Load() && r.TLS == nil && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/healthz" { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr @@ -216,9 +225,16 @@ func startHTTPS(rc *RuntimeConfig, handler http.Handler) *http.Server { ReadHeaderTimeout: 5 * time.Second, TLSConfig: &tls.Config{GetCertificate: loadTLSCert, MinVersion: tls.VersionTLS12}, } + ln, err := net.Listen("tcp", srv.Addr) + if err != nil { + log.Printf("https: %v — HTTPS выключен, вход по HTTP без перенаправления", err) + return nil + } + httpsUp.Store(true) go func() { log.Printf("lk-gateway: listen https %s", srv.Addr) - if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { + if err := srv.ServeTLS(ln, "", ""); err != nil && err != http.ErrServerClosed { + httpsUp.Store(false) log.Printf("https: %v", err) } }() @@ -232,13 +248,13 @@ func registerHTTPS(mux *http.ServeMux, rc *RuntimeConfig) { return } h := rc.Snapshot().HTTPS - h.Redirect = r.FormValue("redirect") != "" + h.RedirectOff = r.FormValue("redirect") == "" if err := rc.UpdateHTTPS(h); err != nil { setupFlash(w, r, "HTTPS: "+err.Error()) return } msg := "HTTPS: перенаправление с HTTP выключено" - if h.Redirect { + if h.RedirectOn() { msg = "HTTPS: браузеры теперь перенаправляются на https://…:" + strconv.Itoa(h.port()) } setupFlash(w, r, msg) diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index ae7b1e1..df2cf40 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -18,7 +18,7 @@ - + {{if devOn}} {{end}} @@ -942,11 +942,11 @@
Пароли администратора, баз данных, хранилища и ключ продукта не должны идти по сети открытым текстом. bj всегда слушает HTTPS: https://{{serverIP}}:{{if .Settings.HTTPS.Port}}{{.Settings.HTTPS.Port}}{{else}}8443{{end}}/. Модули bj и API на этом сервере работают по HTTP, как раньше.
{{if $h.Err}}✗ сертификат: {{$h.Err}}{{else}}Сертификат: {{$h.CN}}, до {{$h.Until.Format "02.01.2006"}} — {{if .Settings.HTTPS.Custom}}сертификат организации{{else}}самоподписанный: браузер один раз попросит подтвердить исключение; чтобы не просил — загрузите сертификат организации{{end}}.{{end}}
У каждого, кто работает с bj, — своя учётная запись: свой пароль и свои права, а в журнале видно, кто что сделал. Заведите сотрудников по ролям; права, пароли и вход через Active Directory меняются позже в разделе Настройка → Пользователи.
+ + {{with .Fancy}}{{$U := .Users}} + + + {{end}} + {{template "wizNav" .}} +Готовые письма с вашими реквизитами — тем, без кого подключение не закончить.
- {{template "letterCard" (letter "nsd-test")}} - {{template "letterCard" (letter "nsd-prod")}} - {{if .DepoLicensed}}{{template "letterCard" (letter "arqa")}}{{template "letterCard" (letter "dba")}}{{end}} -