Канал ИШ: необязательные параметры — NULL (иначе ИШ молча выкидывал канал); мастер: шаг «Учётные записи», HTTPS сразу, DEPO без процедур — не блокирует
- bj-igate-admin: пустые строки в числовых/интервальных параметрах канала ИШ разбирал с ошибкой и не загружал канал («There are no active channels», API 500 «more than one Channel…»); теперь NULL, как пишет интерфейс ИШ; старые каналы чинятся при «Создать канал»; папка архивов 1042S — по имени - проверка «Шлюз НРД»: канал контура действительно загружен и включён (api/admin/channels/wsl) - «Учёт DEPO»: нет процедур bj2depo — жёлтое «не забудьте доустановить», не блокирует - HTTPS: перенаправление с http:// включено по умолчанию, только когда HTTPS реально слушает порт - мастер: шаг «Учётные записи» (роли, создание, «Придумать пароль») перед проверкой; письма с проверки убраны; форма «создать» не меняет существующую запись с тем же логином Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("один администратор — шаг не выполнен")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()+"…» в учёте нет — проверьте префикс счёта")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<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 .Settings.Update.Available}}<span class="ind warn"></span>{{end}}</button>
|
||||
<button data-sec="backup"><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="12" cy="6" rx="7" ry="2.5"/><path d="M5 6v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V6"/><path d="M12 17v4M9 19l3 2 3-2"/></svg></span>Резервное копирование{{$bs := backupStatus}}{{if and .Settings.Backup.Enabled $bs}}{{if not $bs.OK}}<span class="ind err"></span>{{end}}{{else if not .Settings.Backup.Enabled}}<span class="ind warn"></span>{{end}}</button>
|
||||
<button data-sec="server"><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="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/></svg></span>Сервер: HTTPS, время, сеть{{if or (not .Settings.HTTPS.Redirect) (not timeSynced)}}<span class="ind warn"></span>{{end}}</button>
|
||||
<button data-sec="server"><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="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/></svg></span>Сервер: HTTPS, время, сеть{{if or (not .Settings.HTTPS.RedirectOn) (not timeSynced)}}<span class="ind warn"></span>{{end}}</button>
|
||||
<button data-sec="license"><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="8" cy="12" r="4"/><path d="M12 12h9M18 12v3M15 12v2"/></svg></span>Лицензия{{if .License.Present}}<span class="ind {{if .License.Valid}}ok{{else}}err{{end}}"></span>{{end}}</button>
|
||||
<button data-sec="branding"><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="8.5" cy="10" r="1.5"/><path d="M21 16l-5-5L5 19"/></svg></span>Оформление{{if .Settings.Branding.LogoDataURI}}<span class="ind ok"></span>{{end}}</button>
|
||||
{{if devOn}} <button data-sec="advanced"><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="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>Перспективные{{if .AdvancedUnlocked}}<span class="ind ok"></span>{{end}}</button>{{end}}
|
||||
@@ -942,11 +942,11 @@
|
||||
<h1>Сервер: HTTPS, время, сеть</h1>
|
||||
{{$h := httpsCert}}
|
||||
<div class="card" id="sec-https">
|
||||
<h2><span class="dot {{if and .Settings.HTTPS.Redirect (not $h.Err)}}ok{{else}}warn{{end}}"></span>HTTPS — шифрование входа в bj</h2>
|
||||
<h2><span class="dot {{if and .Settings.HTTPS.RedirectOn (not $h.Err)}}ok{{else}}warn{{end}}"></span>HTTPS — шифрование входа в bj</h2>
|
||||
<p class="muted">Пароли администратора, баз данных, хранилища и ключ продукта не должны идти по сети открытым текстом. bj всегда слушает HTTPS: <a href="https://{{serverIP}}:{{if .Settings.HTTPS.Port}}{{.Settings.HTTPS.Port}}{{else}}8443{{end}}/admin/">https://{{serverIP}}:{{if .Settings.HTTPS.Port}}{{.Settings.HTTPS.Port}}{{else}}8443{{end}}/</a>. Модули bj и API на этом сервере работают по HTTP, как раньше.</p>
|
||||
<p style="margin:8px 0">{{if $h.Err}}<span style="color:var(--err)">✗ сертификат: {{$h.Err}}</span>{{else}}Сертификат: <b>{{$h.CN}}</b>, до {{$h.Until.Format "02.01.2006"}} — {{if .Settings.HTTPS.Custom}}сертификат организации{{else}}самоподписанный: браузер один раз попросит подтвердить исключение; чтобы не просил — загрузите сертификат организации{{end}}.{{end}}</p>
|
||||
<form method="post" action="/admin/setup/https" style="margin:8px 0">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="redirect" style="width:auto"{{if .Settings.HTTPS.Redirect}} checked{{end}}> <b>Открывать bj только по HTTPS</b> — браузер с http:// перенаправляется на https://</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="redirect" style="width:auto"{{if .Settings.HTTPS.RedirectOn}} checked{{end}}> <b>Открывать bj только по HTTPS</b> — браузер с http:// перенаправляется на https://</label>
|
||||
<button type="submit" class="btn" style="margin-top:8px">Сохранить</button>
|
||||
</form>
|
||||
<details style="margin-top:8px"><summary style="cursor:pointer;color:var(--accent)">Сертификат организации</summary>
|
||||
|
||||
@@ -682,6 +682,57 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ============= ШАГ 13: Учётные записи (глава «Запуск», перед проверкой) ============= */}}
|
||||
{{if eq .Step 13}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Done.Users}}ok{{else}}warn{{end}}"></span>Учётные записи</h2>
|
||||
<p>У каждого, кто работает с bj, — своя учётная запись: свой пароль и свои права, а в журнале видно, кто что сделал. Заведите сотрудников по ролям; права, пароли и вход через Active Directory меняются позже в разделе Настройка → Пользователи.</p>
|
||||
<style>
|
||||
.acc-roles{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:12px 0 4px}
|
||||
.acc-role{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}
|
||||
.acc-role:has(input:checked){border-color:var(--accent);background:var(--accent-weak)}
|
||||
.acc-role input{margin-top:3px;flex:none;width:auto}
|
||||
.acc-role b{display:block;font-size:14.5px}
|
||||
.acc-role small{display:block;color:var(--muted);font-size:13px;line-height:1.4;margin-top:3px}
|
||||
.acc-role .acc-who{display:block;color:var(--text);font-size:12.5px;margin-top:6px}
|
||||
.acc-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;max-width:640px;margin-top:12px}
|
||||
.acc-form label{display:block;margin:0}
|
||||
.acc-form label input{width:100%;box-sizing:border-box;margin-top:4px}
|
||||
.acc-pw{display:flex;gap:6px;align-items:flex-end}
|
||||
@media (max-width:600px){.acc-roles,.acc-form{grid-template-columns:minmax(0,1fr)}}
|
||||
</style>
|
||||
{{with .Fancy}}{{$U := .Users}}
|
||||
<form method="post" action="/admin/setup/users" id="acc-form">
|
||||
<input type="hidden" name="return_to" value="/admin/wizard?step=13"><input type="hidden" name="create" value="1">
|
||||
<input type="hidden" name="active" value="1"><input type="hidden" name="source" value="local">
|
||||
<h3 style="margin:16px 0 0">Кого добавить</h3>
|
||||
<div class="acc-roles">
|
||||
{{range $r := $U.Roles}}
|
||||
<label class="acc-role"><input type="radio" name="role" value="{{$r.Key}}" {{if eq $r.Key "operator"}}checked{{end}}>
|
||||
<span><b>{{$r.Title}}</b><small>{{$r.Desc}}</small>
|
||||
<span class="acc-who">Уже есть: {{$n := 0}}{{range $U.Users}}{{if eq .Role $r.Key}}{{if $n}}, {{end}}{{$n = 1}}{{.Login}}{{if .Name}} ({{.Name}}){{end}}{{if not .Active}} — заблокирован{{end}}{{end}}{{end}}{{if not $n}}<span class="muted">никого</span>{{end}}</span></span></label>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="acc-form">
|
||||
<label>Логин<input type="text" name="login" placeholder="ivanov" required autocomplete="off" pattern="[^ /\@"'<>]+"></label>
|
||||
<label>Имя<input type="text" name="name" placeholder="Иванов Иван Иванович" autocomplete="off"></label>
|
||||
<label>E-mail<input type="email" name="email" placeholder="ivanov@example.ru" autocomplete="off"></label>
|
||||
<div class="acc-pw"><label style="flex:1">Пароль (от 8 символов)<input type="text" name="password" id="acc-pw" minlength="8" required autocomplete="off" spellcheck="false"></label>
|
||||
<button type="button" class="btn btn-secondary" id="acc-gen" style="white-space:nowrap">Придумать</button></div>
|
||||
</div>
|
||||
<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>
|
||||
<script>(function(){
|
||||
var g=document.getElementById('acc-gen'), f=document.getElementById('acc-pw'); if(!g||!f||!window.crypto) return;
|
||||
g.addEventListener('click',function(){ var a='abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789', b=new Uint32Array(12), p='';
|
||||
crypto.getRandomValues(b); for(var i=0;i<b.length;i++){ p+=a[b[i]%a.length]; if(i===3||i===7) p+='-'; } f.value=p; f.select(); });
|
||||
})();</script>
|
||||
{{end}}
|
||||
{{template "wizNav" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ============= ШАГ 10: Проверка готовности (в режиме разработки — ещё тестовая заявка) ============= */}}
|
||||
{{if eq .Step 10}}
|
||||
<div class="card">
|
||||
@@ -730,13 +781,6 @@
|
||||
})();
|
||||
</script>
|
||||
{{template "chainTest" .}}
|
||||
<div style="margin-top:18px;padding-top:18px;border-top:1px solid var(--border)">
|
||||
<h2>Письма для подключения</h2>
|
||||
<p class="muted" style="margin:0 0 6px">Готовые письма с вашими реквизитами — тем, без кого подключение не закончить.</p>
|
||||
{{template "letterCard" (letter "nsd-test")}}
|
||||
{{template "letterCard" (letter "nsd-prod")}}
|
||||
{{if .DepoLicensed}}{{template "letterCard" (letter "arqa")}}{{template "letterCard" (letter "dba")}}{{end}}
|
||||
</div>
|
||||
{{template "wizNav" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -17,7 +17,7 @@ var wizChapters = []struct {
|
||||
{"Сервер", []int{1, 2, 6, 12}},
|
||||
{"Учёт и организация", []int{3, 4, 5}},
|
||||
{"НРД", []int{7, 8}},
|
||||
{"Запуск", []int{11, 9, 10}},
|
||||
{"Запуск", []int{11, 9, 13, 10}},
|
||||
}
|
||||
|
||||
// wizStepTitles — короткие названия шагов (подзаголовки внутри главы).
|
||||
@@ -25,7 +25,7 @@ var wizStepTitles = map[int]string{
|
||||
1: "Лицензия", 2: "База bj", 6: "Валидата", 12: "Время и сеть",
|
||||
3: "Учёт DEPO", 4: "Организация", 5: "QUIK МНП",
|
||||
7: "Шлюз НРД", 8: "Ключ подписи",
|
||||
11: "Модули", 9: "Оповещения и копии", 10: "Проверка",
|
||||
11: "Модули", 9: "Оповещения и копии", 13: "Учётные записи", 10: "Проверка",
|
||||
}
|
||||
|
||||
// wizRequired — шаги, без которых обмен не работает: на них мастер возвращает, пока они не сделаны.
|
||||
|
||||
Reference in New Issue
Block a user