diff --git a/deploy/linux/sbin/bj-backup b/deploy/linux/sbin/bj-backup index 5ccd3fb..7ff6a51 100755 --- a/deploy/linux/sbin/bj-backup +++ b/deploy/linux/sbin/bj-backup @@ -47,7 +47,33 @@ tarok() { tar "$@"; local rc=$?; [ $rc -le 1 ]; } [ -n "$SHARE" ] || fail "не задано хранилище (Настройка → Резервное копирование)" [ -r "$CRED" ] || fail "нет логина хранилища (Настройка → Резервное копирование)" -runuser -u postgres -- pg_dump -Fc bj > "$W/bj.pgdump" || fail "дамп базы bj" +# база bj: на этом сервере — от имени postgres; на сервере PostgreSQL организации (перенесена в мастере) — по её +# подключению из настроек bj. Сервер организации новее нашего pg_dump — копия без базы, с пометкой: базу на сервере +# организации резервирует её администратор баз. +DBNOTE="" +mapfile -t BJDB < <(python3 - <<'PY' +import json,urllib.parse +try: + d=json.load(open("/var/lib/bj/.bj/setup.json")).get("postgres",{}).get("dsn","") + u=urllib.parse.urlparse(d); h=(u.hostname or "").lower() + if d and h not in ("","localhost","127.0.0.1","::1") and not h.startswith("/"): + host="["+u.hostname+"]" if ":" in u.hostname else u.hostname + netloc=(urllib.parse.quote(u.username or "")+"@" if u.username else "")+host+(":"+str(u.port) if u.port else "") + print("ext"); print(urllib.parse.urlunparse((u.scheme,netloc,u.path,"",u.query,""))); print(urllib.parse.unquote(u.password or "")); print(u.hostname) +except Exception: pass +PY +) +if [ "${BJDB[0]:-}" = ext ]; then + if ! PGPASSWORD="${BJDB[2]:-}" pg_dump -Fc --no-owner --no-privileges --dbname="${BJDB[1]}" > "$W/bj.pgdump" 2>"$W/pgdump.err"; then + rm -f "$W/bj.pgdump" + DBNOTE="база bj на сервере организации ${BJDB[3]} в копию не вошла ($(grep -v '^\s*$' "$W/pgdump.err" | tail -1 | head -c 160)) — её резервирует администратор баз" + else + DBNOTE="база bj — с сервера организации ${BJDB[3]}" + fi + rm -f "$W/pgdump.err" +else + runuser -u postgres -- pg_dump -Fc bj > "$W/bj.pgdump" || fail "дамп базы bj" +fi if runuser -u postgres -- psql -Atc "select 1 from pg_database where datname='igate'" 2>/dev/null | grep -q 1; then runuser -u postgres -- pg_dump -Fc igate > "$W/igate.pgdump" || fail "дамп базы igate" fi @@ -83,5 +109,5 @@ smb "cd \"$DIR\"; ls" 2>/dev/null | awk '{print $1}' | grep -E '^[0-9]{4}-[0-9]{ smb "cd \"$DIR/$d\"; prompt; mask *; mdel *; cd ..; rmdir \"$d\"" >/dev/null 2>&1 && echo "удалена старая копия $d" fi done -status true "размер $SIZE" -echo "копия $DIR/$TS готова ($SIZE)" +status true "размер $SIZE${DBNOTE:+; $DBNOTE}" +echo "копия $DIR/$TS готова ($SIZE)${DBNOTE:+; $DBNOTE}" diff --git a/deploy/linux/sbin/bj-backup-admin b/deploy/linux/sbin/bj-backup-admin index 09876d4..9d8fdf8 100755 --- a/deploy/linux/sbin/bj-backup-admin +++ b/deploy/linux/sbin/bj-backup-admin @@ -121,8 +121,17 @@ case "$cmd" in printf '%s' "$out" | grep -q "NT_STATUS" && err "не удалось скачать копию $name: $(printf '%s' "$out" | grep -o 'NT_STATUS[A-Z_]*' | head -1)" [ -s "$W/SHA256SUMS" ] || err "в копии $name нет SHA256SUMS — копия неполная" (cd "$W" && sha256sum -c SHA256SUMS >/dev/null 2>&1) || err "контрольные суммы копии $name не сходятся — копия повреждена" - [ -s "$W/bj.pgdump" ] && [ -s "$W/bj-data.tar.gz" ] || err "в копии $name нет базы или данных bj" - pg_restore -l < "$W/bj.pgdump" >/dev/null 2>&1 || err "база bj в копии $name не читается" + [ -s "$W/bj-data.tar.gz" ] || err "в копии $name нет данных bj" + if [ -s "$W/bj.pgdump" ]; then + pg_restore -l < "$W/bj.pgdump" >/dev/null 2>&1 || err "база bj в копии $name не читается" + elif [ -z "$(tar -xzOf "$W/bj-data.tar.gz" var/lib/bj/.bj/setup.json 2>/dev/null | python3 -c ' +import json,sys,urllib.parse +try: + h=(urllib.parse.urlparse(json.load(sys.stdin).get("postgres",{}).get("dsn","")).hostname or "").lower() + print(h if h not in ("","localhost","127.0.0.1","::1") else "") +except Exception: pass')" ]; then + err "в копии $name нет базы bj" + fi # база на сервере организации, не вошедшая в копию, — её резервирует администратор баз tar -tzf "$W/bj-data.tar.gz" >/dev/null 2>&1 || err "архив данных bj в копии $name не читается" ok "копия $name цела: $(cd "$W" && ls | grep -vc SHA256SUMS) файлов, $(du -sh "$W" | cut -f1), контрольные суммы сходятся, база и архивы читаются" ;; restore) @@ -148,14 +157,27 @@ PY st download "скачивание копии $NAME" smbclient "$SHARE" -A /run/bj-restore.cred -m SMB3 -c "cd \"$DIR/$NAME\"; lcd $W; prompt; mget *" >/dev/null 2>&1 || die "не удалось скачать копию $NAME" (cd "$W" && sha256sum -c SHA256SUMS >/dev/null 2>&1) || die "контрольные суммы копии не сходятся — копия повреждена" - [ -s "$W/bj.pgdump" ] && [ -s "$W/bj-data.tar.gz" ] || die "в копии нет базы или данных bj" + [ -s "$W/bj-data.tar.gz" ] || die "в копии нет данных bj" + # база bj на сервере PostgreSQL организации (перенесена в мастере): её не восстанавливаем поверх — это база + # организации, у неё свежие данные и свой администратор; bj после восстановления настроек подключится к ней + EXTDB=$(tar -xzOf "$W/bj-data.tar.gz" var/lib/bj/.bj/setup.json 2>/dev/null | python3 -c ' +import json,sys,urllib.parse +try: + h=(urllib.parse.urlparse(json.load(sys.stdin).get("postgres",{}).get("dsn","")).hostname or "").lower() + print(h if h not in ("","localhost","127.0.0.1","::1") else "") +except Exception: pass') + [ -n "$EXTDB" ] || [ -s "$W/bj.pgdump" ] || die "в копии нет базы bj" st stop "остановка bj и шлюза НРД" systemctl stop bj-lk bj-npr bj-server igate >/dev/null 2>&1 + if [ -n "$EXTDB" ]; then + st db "база bj — на сервере организации $EXTDB: не восстанавливается, bj подключится к ней" + else st db "восстановление базы bj" # каталог копии доступен только root — дамп подаём postgres через stdin; сообщения — по-английски для разбора ошибок LC_ALL=C PGOPTIONS='-c lc_messages=C' runuser -u postgres -- pg_restore --clean --if-exists -d bj < "$W/bj.pgdump" >/dev/null 2>"$W/pg.err" # «does not exist» при --clean на пустой базе — не ошибка; остальное — ошибка if grep -i "error" "$W/pg.err" | grep -vqi "does not exist"; then die "база bj: $(grep -i error "$W/pg.err" | grep -vi 'does not exist' | head -1)"; fi + fi if [ -s "$W/igate.pgdump" ]; then st db "восстановление базы шлюза НРД" # новый сервер: ИШ ещё не ставили — готовим пользователя, роль, базу и доступ без пароля (как ставит diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 7cd20c3..23bf602 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -60,6 +60,7 @@ var templateFuncs = template.FuncMap{ "backupStatus": backupStatus, "backupWhen": backupWhen, "smbWin": smbWin, + "pgLocal": pgDSNLocal, "whForm": func(w WorkHoursSettings, ret string) map[string]any { return map[string]any{"W": w, "Ret": ret} }, "weekdayNames": func() []string { return weekdayShort }, "depoAccPrefix": depoAccPrefix, diff --git a/internal/lkgateway/igate.go b/internal/lkgateway/igate.go index 29c4dea..6267c93 100644 --- a/internal/lkgateway/igate.go +++ b/internal/lkgateway/igate.go @@ -139,6 +139,15 @@ var igateVersionRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`) func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { // состояние ИШ и каналов + что выбрано в bj mux.HandleFunc("/admin/setup/igate/status", func(w http.ResponseWriter, r *http.Request) { + // пока записывается канал, root-обработчик занят им (до двух минут) и запрос состояния ждал бы в очереди — + // страница после перезагрузки висела на «Проверяю…»; отдаём последнее известное состояние + if v := igateContourJob.view(); v.Running { + if last := igateLastStatus.get(); last != nil { + last["busy"] = "идёт запись канала " + strings.ToUpper(v.Contour) + jsonOut(w, last) + return + } + } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() res, err := runKeymediaOp(ctx, "igate-status", nil) @@ -146,6 +155,7 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { jsonOut(w, map[string]any{"ok": false, "error": err.Error()}) return } + defer igateLastStatus.set(res) s := rc.Snapshot() res["active_contour"] = "" if s.NSD.Profile != "" { @@ -240,24 +250,16 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { jsonOut(w, map[string]any{"ok": false, "error": "сначала укажите депозитарный код организации (мастер, раздел «Организация»)"}) return } - extra, _ := json.Marshal(map[string]string{"contour": contour, "crypto": crypto, "deponent": dep}) - ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) - defer cancel() - res, err := runKeymediaOp(ctx, "igate-contour", map[string]string{"extra": string(extra)}) - if err == nil && res["ok"] == true && rc.Snapshot().NSD.Profile == "" { - // контур в bj ещё не выбран (новая установка) — первый настроенный канал и есть рабочий - n := rc.Snapshot().NSD - n.Profile, n.IGWBaseURL = contour+"-"+crypto, pickStr(n.IGWBaseURL, igateDefaultURL) - if rc.UpdateNSD(n) == nil { - res["output"] = asString(res["output"]) + ". bj работает с этим контуром и перезапускается — обновите страницу через 15 секунд" - go func() { - time.Sleep(2 * time.Second) - _, _ = restartUnit(context.Background(), "bj-server") - }() - } + if !igateContourJob.start(contour) { + jsonOut(w, map[string]any{"ok": true, "running": true}) // уже идёт — страница покажет ход + return } - refreshCertStore(rc) - jsonOut(w, opResult(res, err)) + go igateContourRun(rc, contour, crypto, dep) + jsonOut(w, map[string]any{"ok": true, "started": true}) + }) + // ход записи канала: страница показывает его и после перезагрузки + mux.HandleFunc("/admin/setup/igate/contour-status", func(w http.ResponseWriter, r *http.Request) { + jsonOut(w, igateContourJob.view()) }) // переключить bj на контур: профиль отправки, база DEPO контура; bj перезапускается mux.HandleFunc("/admin/setup/igate/switch", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/lkgateway/igatecontour.go b/internal/lkgateway/igatecontour.go new file mode 100644 index 0000000..fe6dd49 --- /dev/null +++ b/internal/lkgateway/igatecontour.go @@ -0,0 +1,153 @@ +package lkgateway + +// Запись канала контура НРД в ИШ (06.10.2026). Root-обработчик пишет канал в базу ИШ и перезапускает шлюз — до +// двух минут. Раньше это делал сам запрос страницы: перезагрузка страницы прерывала ожидание, а запрос +// состояния ИШ вставал в очередь за записью канала — страница висела на «Проверяю…» без объяснений, и человек +// мог уйти «Далее» посреди перезапуска. Теперь запись идёт в фоне bj, страница показывает её ход (и после +// перезагрузки), а в конце bj проверяет, что шлюз действительно загрузил канал. + +import ( + "context" + "strings" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter" +) + +// igateContourView — ход записи канала для страницы. +type igateContourView struct { + Running bool `json:"running"` + Contour string `json:"contour,omitempty"` + Stage string `json:"stage,omitempty"` // write | engine | channel | done + Started int64 `json:"started,omitempty"` + Done bool `json:"done,omitempty"` + OK bool `json:"ok"` + Msg string `json:"msg,omitempty"` + At int64 `json:"at,omitempty"` +} + +type igateContourState struct { + sync.Mutex + v igateContourView +} + +var igateContourJob igateContourState + +func (j *igateContourState) start(contour string) bool { + j.Lock() + defer j.Unlock() + if j.v.Running { + return false + } + j.v = igateContourView{Running: true, Contour: contour, Stage: "write", Started: time.Now().Unix(), OK: true} + return true +} + +func (j *igateContourState) stage(st string) { + j.Lock() + j.v.Stage = st + j.Unlock() +} + +func (j *igateContourState) finish(ok bool, msg string) { + j.Lock() + j.v.Running, j.v.Done, j.v.OK, j.v.Msg, j.v.Stage, j.v.At = false, true, ok, msg, "done", time.Now().Unix() + j.Unlock() +} + +func (j *igateContourState) view() igateContourView { + j.Lock() + defer j.Unlock() + v := j.v + if v.Done && time.Since(time.Unix(v.At, 0)) > 15*time.Minute { + return igateContourView{OK: true} // итог давний — не показываем + } + return v +} + +// igateLastStatus — последнее состояние ИШ от root-обработчика (отдаётся, пока он занят записью канала). +var igateLastStatus lastStatus + +type lastStatus struct { + sync.Mutex + m map[string]any +} + +func (l *lastStatus) set(m map[string]any) { + l.Lock() + l.m = m + l.Unlock() +} + +func (l *lastStatus) get() map[string]any { + l.Lock() + defer l.Unlock() + if l.m == nil { + return nil + } + out := map[string]any{} + for k, v := range l.m { + out[k] = v + } + return out +} + +// igateContourRun — запись канала контура в ИШ и проверка, что шлюз его загрузил (в фоне, без запроса страницы). +func igateContourRun(rc *RuntimeConfig, contour, crypto, dep string) { + extra := `{"contour":"` + contour + `","crypto":"` + crypto + `","deponent":"` + dep + `"}` + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + go func() { // этап «перезапуск ИШ» — root-обработчик пишет канал за секунды и дальше ждёт движок + time.Sleep(4 * time.Second) + if v := igateContourJob.view(); v.Running && v.Stage == "write" { + igateContourJob.stage("engine") + } + }() + res, err := runKeymediaOp(ctx, "igate-contour", map[string]string{"extra": extra}) + if err != nil || res["ok"] != true { + msg := errStr(err) + if e, ok := res["error"].(string); ok && e != "" { + msg = e + } + igateContourJob.finish(false, msg) + return + } + out := asString(res["output"]) + code := strings.ToUpper(contour) + dep + if prof, err := nsdadapter.LookupProfile(contour + "-" + crypto); err == nil { + code = prof.Channel + dep + } + enabled := strings.Contains(out, "включён") + if enabled { // канал с ключом: шлюз должен его загрузить — иначе любой обмен даёт HTTP 500 + igateContourJob.stage("channel") + base := pickStr(rc.Snapshot().NSD.IGWBaseURL, igateDefaultURL) + loaded := false + for i := 0; i < 20 && !loaded; i++ { + if found, en, err := igateChannelState(ctx, base, code); err == nil && found && en { + loaded = true + break + } + time.Sleep(3 * time.Second) + } + if !loaded { + igateContourJob.finish(false, "канал "+code+" записан, но шлюз его не загрузил — журнал ИШ: /var/lib/igate/.local/share/NSD Integration Gateway/logs-svc/log.txt") + return + } + out += "; шлюз загрузил канал " + code + } + if rc.Snapshot().NSD.Profile == "" { + // контур в bj ещё не выбран (новая установка) — первый настроенный канал и есть рабочий + n := rc.Snapshot().NSD + n.Profile, n.IGWBaseURL = contour+"-"+crypto, pickStr(n.IGWBaseURL, igateDefaultURL) + if rc.UpdateNSD(n) == nil { + out += ". bj работает с этим контуром и перезапускается — страница обновится сама" + go func() { + time.Sleep(3 * time.Second) + _, _ = restartUnit(context.Background(), "bj-server") + }() + } + } + refreshCertStore(rc) + igateContourJob.finish(true, out) +} diff --git a/internal/lkgateway/pgmove.go b/internal/lkgateway/pgmove.go new file mode 100644 index 0000000..ef86aa8 --- /dev/null +++ b/internal/lkgateway/pgmove.go @@ -0,0 +1,353 @@ +package lkgateway + +// База bj на своём сервере PostgreSQL организации (06.10.2026). Установщик кладёт базу bj на этот же сервер — +// так и остаётся по умолчанию. Если у организации свой сервер PostgreSQL (с резервным копированием и +// администратором баз), базу можно держать там: шаг «База bj» проверяет подключение и права, переносит данные +// (pg_dump этой базы → psql в пустую базу на том сервере), сверяет число строк в каждой таблице, переключает +// bj и перезапускает его. Прежняя база на этом сервере не удаляется — остаётся запасной копией. +// Только PostgreSQL: другие СУБД bj для своей базы не поддерживает. + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "sort" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// pgTarget — сервер PostgreSQL организации для базы bj (из формы). +type pgTarget struct{ Host, Port, DB, User, Password, SSL string } + +func pgTargetFromForm(r *http.Request) (pgTarget, string) { + t := pgTarget{ + Host: strings.TrimSpace(r.FormValue("pg_host")), Port: strings.TrimSpace(r.FormValue("pg_port")), + DB: strings.TrimSpace(r.FormValue("pg_db")), User: strings.TrimSpace(r.FormValue("pg_user")), + Password: r.FormValue("pg_password"), SSL: r.FormValue("pg_ssl"), + } + if t.Port == "" { + t.Port = "5432" + } + switch t.SSL { + case "disable", "require", "verify-full": + default: + t.SSL = "prefer" + } + switch { + case t.Host == "" || strings.ContainsAny(t.Host, "/ @"): + return t, "укажите адрес сервера PostgreSQL" + case pgLocalHost(t.Host): + return t, "это адрес этого же сервера — база bj и так здесь; укажите сервер PostgreSQL организации" + case t.DB == "" || t.User == "": + return t, "укажите базу и пользователя" + } + if p, err := strconv.Atoi(t.Port); err != nil || p < 1 || p > 65535 { + return t, "порт — число от 1 до 65535" + } + return t, "" +} + +// dsn — строка подключения; withPass=false — без пароля (для pg_dump/psql пароль идёт через PGPASSWORD, +// чтобы не светиться в списке процессов). +func (t pgTarget) dsn(withPass bool) string { + u := url.URL{Scheme: "postgres", Host: net.JoinHostPort(t.Host, t.Port), Path: "/" + t.DB} + if withPass && t.Password != "" { + u.User = url.UserPassword(t.User, t.Password) + } else { + u.User = url.User(t.User) + } + u.RawQuery = "sslmode=" + t.SSL + return u.String() +} + +func pgLocalHost(h string) bool { + h = strings.Trim(strings.ToLower(h), "[]") + return h == "" || h == "localhost" || h == "127.0.0.1" || h == "::1" || strings.HasPrefix(h, "/") +} + +// pgDSNLocal — база по этой строке подключения на этом же сервере. +func pgDSNLocal(dsn string) bool { + u, err := url.Parse(strings.TrimSpace(dsn)) + return err != nil || pgLocalHost(u.Hostname()) +} + +// pgSplitDSN — строка подключения без пароля и пароль отдельно (для внешних программ). +func pgSplitDSN(dsn string) (string, string) { + u, err := url.Parse(strings.TrimSpace(dsn)) + if err != nil || u.User == nil { + return dsn, "" + } + pw, _ := u.User.Password() + u.User = url.User(u.User.Username()) + return u.String(), pw +} + +// pgInfo — что известно о базе: версия сервера, права, таблицы. +type pgInfo struct { + Version string + Major int + CanCreate bool // CREATE в базе: создать схемы bj + PublicOK bool // CREATE в схеме public (с PostgreSQL 15 по умолчанию есть только у владельца базы) + Tables []string + Rows map[string]int64 // для сверки после переноса +} + +func pgInspect(ctx context.Context, dsn string, counts bool) (pgInfo, error) { + info := pgInfo{Rows: map[string]int64{}} + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + conn, err := pgx.Connect(cctx, dsn) + if err != nil { + return info, err + } + defer conn.Close(context.Background()) + var num int + if err := conn.QueryRow(cctx, "SELECT current_setting('server_version'), current_setting('server_version_num')::int, has_database_privilege(current_user, current_database(), 'CREATE'), COALESCE(has_schema_privilege(current_user, 'public', 'CREATE'), false)"). + Scan(&info.Version, &num, &info.CanCreate, &info.PublicOK); err != nil { + return info, err + } + info.Major = num / 10000 + rows, err := conn.Query(cctx, "SELECT quote_ident(table_schema)||'.'||quote_ident(table_name) FROM information_schema.tables WHERE table_type='BASE TABLE' AND table_schema NOT IN ('pg_catalog','information_schema') AND table_schema NOT LIKE 'pg_toast%' ORDER BY 1") + if err != nil { + return info, err + } + for rows.Next() { + var n string + if rows.Scan(&n) == nil { + info.Tables = append(info.Tables, n) + } + } + rows.Close() + if counts { + for _, t := range info.Tables { + var c int64 + if err := conn.QueryRow(ctx, "SELECT count(*) FROM "+t).Scan(&c); err != nil { + return info, fmt.Errorf("подсчёт строк %s: %w", t, err) + } + info.Rows[t] = c + } + } + return info, nil +} + +// pgPublicUsed — в базе bj есть таблицы в схеме public (тогда на новом сервере нужно право создавать в ней). +func pgPublicUsed(tables []string) bool { + for _, t := range tables { + if strings.HasPrefix(t, "public.") { + return true + } + } + return false +} + +// pgCheckTarget — годится ли сервер организации: подключение, версия не старше нашей, права, пустая база. +func pgCheckTarget(ctx context.Context, src pgInfo, t pgTarget) (pgInfo, string) { + dst, err := pgInspect(ctx, t.dsn(true), false) + if err != nil { + return dst, "не удалось подключиться к " + t.Host + ":" + t.Port + ", база " + t.DB + ": " + pgErrRu(err) + } + switch { + case src.Major > 0 && dst.Major < src.Major: + return dst, fmt.Sprintf("на сервере организации PostgreSQL %s, а база bj сейчас на %s — нужен PostgreSQL %d или новее", dst.Version, src.Version, src.Major) + case !dst.CanCreate: + return dst, "у пользователя " + t.User + " нет права создавать объекты в базе " + t.DB + " (GRANT CREATE ON DATABASE " + t.DB + " TO " + t.User + ") — проще всего сделать его владельцем базы" + case pgPublicUsed(src.Tables) && !dst.PublicOK: + return dst, "у пользователя " + t.User + " нет права создавать таблицы в схеме public базы " + t.DB + " (с PostgreSQL 15 оно есть только у владельца базы) — сделайте его владельцем базы: ALTER DATABASE " + t.DB + " OWNER TO " + t.User + case len(dst.Tables) > 0: + return dst, fmt.Sprintf("в базе %s уже есть таблицы (%d, например %s) — для bj нужна пустая база", t.DB, len(dst.Tables), dst.Tables[0]) + } + return dst, "" +} + +func pgErrRu(err error) string { + s := err.Error() + switch { + case strings.Contains(s, "password authentication failed"): + return "неверный пользователь или пароль" + case strings.Contains(s, "does not exist") && strings.Contains(s, "database"): + return "такой базы нет — создайте пустую базу" + case strings.Contains(s, "no pg_hba.conf entry"): + return "сервер не пускает с адреса bj — администратору баз нужно разрешить подключение в pg_hba.conf" + case strings.Contains(s, "connection refused"), strings.Contains(s, "i/o timeout"), strings.Contains(s, "no route to host"): + return "сервер не отвечает на этом адресе и порту (" + s + ")" + } + return s +} + +// pgCopy — данные базы bj на сервер организации: pg_dump (простой SQL без владельцев и прав) → psql. +func pgCopy(ctx context.Context, srcDSN string, t pgTarget) error { + src, srcPass := pgSplitDSN(srcDSN) + dump := exec.CommandContext(ctx, "pg_dump", "--no-owner", "--no-privileges", "--format=plain", "--dbname="+src) + dump.Env = append(os.Environ(), "PGPASSWORD="+srcPass) + load := exec.CommandContext(ctx, "psql", "--quiet", "--no-psqlrc", "-v", "ON_ERROR_STOP=1", "--dbname="+t.dsn(false)) + load.Env = append(os.Environ(), "PGPASSWORD="+t.Password) // без PGOPTIONS: lc_messages — только суперпользователю + pipe, err := dump.StdoutPipe() + if err != nil { + return err + } + load.Stdin = pipe + var dumpErr, loadErr strings.Builder + dump.Stderr, load.Stderr, load.Stdout = &dumpErr, &loadErr, io.Discard + if err := load.Start(); err != nil { + return fmt.Errorf("psql: %w", err) + } + if err := dump.Start(); err != nil { + _ = load.Process.Kill() + return fmt.Errorf("pg_dump: %w", err) + } + de := dump.Wait() + le := load.Wait() + if de != nil { + return fmt.Errorf("pg_dump: %v %s", de, lastLine(dumpErr.String())) + } + if le != nil { + return fmt.Errorf("загрузка на сервер организации: %v %s", le, lastLine(loadErr.String())) + } + return nil +} + +func lastLine(s string) string { + sc := bufio.NewScanner(strings.NewReader(s)) + last := "" + for sc.Scan() { + if l := strings.TrimSpace(sc.Text()); l != "" { + last = l + } + } + return last +} + +// pgCompare — расхождения числа строк (пусто — всё сошлось). +func pgCompare(src, dst pgInfo) []string { + var bad []string + for _, t := range src.Tables { + if d, ok := dst.Rows[t]; !ok { + bad = append(bad, t+": нет на новом сервере") + } else if d != src.Rows[t] { + bad = append(bad, fmt.Sprintf("%s: было %d строк, стало %d", t, src.Rows[t], d)) + } + } + sort.Strings(bad) + return bad +} + +func (h *setupHandlers) registerPGMove(mux *http.ServeMux) { + mux.HandleFunc("/admin/setup/postgres/move", h.pgMove) +} + +// pgMove — POST /admin/setup/postgres/move: check=1 — только проверить сервер организации; иначе перенести +// и переключиться. Ход — строками JSON (как подготовка DEPO): этапы и итог. +func (h *setupHandlers) pgMove(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Accel-Buffering", "no") + fl, _ := w.(http.Flusher) + enc := json.NewEncoder(w) + send := func(v any) { + _ = enc.Encode(v) + if fl != nil { + fl.Flush() + } + } + step := func(key, state, text string) { send(map[string]string{"step": key, "state": state, "text": text}) } + done := func(ok bool, msg string) { + send(map[string]any{"done": true, "ok": ok, "msg": msg, "go": "/admin/wizard?step=2&flash=" + url.QueryEscape(msg)}) + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) + defer cancel() + + cur := h.rc.Snapshot().Postgres.DSN + t, bad := pgTargetFromForm(r) + if bad != "" { + done(false, bad) + return + } + if !pgDSNLocal(cur) { + done(false, "база bj уже не на этом сервере — перенос делается с базы на этом сервере") + return + } + step("src", "run", "База bj на этом сервере") + src, err := pgInspect(ctx, cur, false) + if err != nil { + step("src", "err", pgErrRu(err)) + done(false, "база bj на этом сервере недоступна: "+pgErrRu(err)) + return + } + step("src", "ok", fmt.Sprintf("PostgreSQL %s, таблиц: %d", src.Version, len(src.Tables))) + step("dst", "run", "Подключение к серверу организации "+t.Host+":"+t.Port+", база "+t.DB) + dst, why := pgCheckTarget(ctx, src, t) + if why != "" { + step("dst", "err", why) + done(false, why) + return + } + step("dst", "ok", "PostgreSQL "+dst.Version+", база пустая, права есть") + if r.FormValue("check") != "" { + done(true, "Сервер организации подходит: PostgreSQL "+dst.Version+", база "+t.DB+" пустая, права есть. Можно переносить.") + return + } + + step("copy", "run", "Копирование данных bj (pg_dump → psql)") + start := time.Now() + if err := pgCopy(ctx, cur, t); err != nil { + step("copy", "err", err.Error()) + done(false, "данные не перенесены: "+err.Error()+". bj продолжает работать с базой на этом сервере; на сервере организации очистите базу "+t.DB+" и повторите") + return + } + step("copy", "ok", "за "+humanDur(time.Since(start))) + step("verify", "run", "Сверка: число строк в каждой таблице") + srcN, err1 := pgInspect(ctx, cur, true) + dstN, err2 := pgInspect(ctx, t.dsn(true), true) + if err1 != nil || err2 != nil { + e := err1 + if e == nil { + e = err2 + } + step("verify", "err", e.Error()) + done(false, "сверка не выполнена: "+e.Error()+". bj продолжает работать с базой на этом сервере") + return + } + if diff := pgCompare(srcN, dstN); len(diff) > 0 { + // пока шло копирование, bj мог дописать строки — это не повод переключаться на неполную базу + step("verify", "err", strings.Join(diff, "; ")) + done(false, "после копирования данные не совпали ("+strings.Join(diff, "; ")+"). bj продолжает работать с базой на этом сервере; очистите базу "+t.DB+" на сервере организации и повторите в спокойное время") + return + } + var total int64 + for _, n := range srcN.Rows { + total += n + } + step("verify", "ok", fmt.Sprintf("таблиц %d, строк %d — совпадает", len(srcN.Tables), total)) + step("switch", "run", "Переключение bj на сервер организации и перезапуск") + if err := h.rc.UpdatePostgres(PostgresSettings{DSN: t.dsn(true), Local: cur}); err != nil { + step("switch", "err", err.Error()) + done(false, "настройки не сохранены: "+err.Error()) + return + } + step("switch", "ok", "bj перезапускается — через полминуты он работает с базой на "+t.Host) + log.Printf("postgres: база bj перенесена на %s:%s/%s (%d таблиц, %d строк); прежняя база на этом сервере сохранена", t.Host, t.Port, t.DB, len(srcN.Tables), total) + done(true, "База bj перенесена на сервер организации "+t.Host+", база "+t.DB+". Прежняя база на этом сервере сохранена как запасная копия.") + go func() { // перезапуск после ответа: страница успевает показать итог + time.Sleep(2 * time.Second) + rctx, rcancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer rcancel() + if _, err := restartUnit(rctx, "bj-server"); err != nil { + log.Printf("postgres: перезапуск после переноса: %v", err) + } + }() +} diff --git a/internal/lkgateway/pgmove_live_test.go b/internal/lkgateway/pgmove_live_test.go new file mode 100644 index 0000000..3a0adc6 --- /dev/null +++ b/internal/lkgateway/pgmove_live_test.go @@ -0,0 +1,63 @@ +package lkgateway + +import ( + "context" + "net/url" + "os" + "testing" +) + +// Перенос базы bj на живом PostgreSQL: BJ_PGMOVE_SRC — строка подключения исходной базы, BJ_PGMOVE_DST — пустой +// целевой (для проверки допускается этот же сервер). Без переменных — пропуск. +func TestPGMoveLive(t *testing.T) { + srcDSN, dstDSN := os.Getenv("BJ_PGMOVE_SRC"), os.Getenv("BJ_PGMOVE_DST") + if srcDSN == "" || dstDSN == "" { + t.Skip("нет BJ_PGMOVE_SRC/BJ_PGMOVE_DST") + } + u, _ := url.Parse(dstDSN) + pw, _ := u.User.Password() + tg := pgTarget{Host: u.Hostname(), Port: u.Port(), DB: u.Path[1:], User: u.User.Username(), Password: pw, SSL: u.Query().Get("sslmode")} + ctx := context.Background() + src, err := pgInspect(ctx, srcDSN, false) + if err != nil { + t.Fatal(err) + } + if _, why := pgCheckTarget(ctx, src, tg); why != "" { + t.Fatal(why) + } + if err := pgCopy(ctx, srcDSN, tg); err != nil { + t.Fatal(err) + } + s, _ := pgInspect(ctx, srcDSN, true) + d, err := pgInspect(ctx, tg.dsn(true), true) + if err != nil { + t.Fatal(err) + } + if diff := pgCompare(s, d); len(diff) > 0 { + t.Fatalf("расхождения: %v", diff) + } + var n int64 + for _, c := range s.Rows { + n += c + } + t.Logf("перенесено: таблиц %d, строк %d (PostgreSQL %s → %s)", len(s.Tables), n, s.Version, d.Version) + if _, why := pgCheckTarget(ctx, src, tg); why == "" { + t.Fatal("после переноса база не пустая — повторная проверка должна отказать") + } +} + +func TestPGTargetForm(t *testing.T) { + tg := pgTarget{Host: "db.local", Port: "5432", DB: "bj", User: "bj", Password: "p@ss:w/rd", SSL: "require"} + if got := tg.dsn(true); got != "postgres://bj:p%40ss%3Aw%2Frd@db.local:5432/bj?sslmode=require" { + t.Errorf("dsn: %s", got) + } + if s, p := pgSplitDSN(tg.dsn(true)); p != "p@ss:w/rd" || s != "postgres://bj@db.local:5432/bj?sslmode=require" { + t.Errorf("split: %s / %s", s, p) + } + if !pgDSNLocal("postgres://bj:x@127.0.0.1:5432/bj") || pgDSNLocal("postgres://bj:x@10.0.0.5:5432/bj") { + t.Error("pgDSNLocal") + } + if d := pgCompare(pgInfo{Tables: []string{"a.t"}, Rows: map[string]int64{"a.t": 3}}, pgInfo{Rows: map[string]int64{"a.t": 2}}); len(d) != 1 { + t.Errorf("сверка: %v", d) + } +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index e53b319..d54533f 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -175,6 +175,9 @@ type FetchedCACert struct { // PostgresSettings — DSN для подключения к БД (M2-шаг-3). type PostgresSettings struct { DSN string `json:"dsn"` + // Local — прежняя база bj на этом сервере после переноса на сервер организации (pgmove.go): не удаляется, + // остаётся запасной копией. + Local string `json:"local,omitempty"` } // FancyMSSQLSettings — подключение к MSSQL-серверу клиента: канал обмена с diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 2daf669..a4510cb 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -57,6 +57,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service }) mux.HandleFunc("/admin/setup/postgres", h.savePostgres) mux.HandleFunc("/admin/setup/postgres/quick-start", h.quickStartPostgres) + h.registerPGMove(mux) h.registerFancy(mux) // вкладка «Обмен Fancy (MSSQL)» h.registerQuik(mux) // вкладка «QUIK МНП» (блокировка бумаг) mux.HandleFunc("/admin/setup/crypto", h.saveCrypto) diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index 64141a5..f8b2183 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -218,9 +218,9 @@

Где база DEPO

- + - +

Точное имя базы учёта DEPO — bj будет работать только с ней. Перед подготовкой bj проверит, что в ней есть учётные таблицы DEPO, и в чужую базу ничего не запишет.

@@ -249,8 +249,8 @@
Администратор SQL — нужен один раз, чтобы подготовить базу; bj его не сохраняет
- - + +
@@ -258,8 +258,8 @@
Логин bj — под ним bj будет работать с базой DEPO
- - + +

bj придумал их сам. Можно заменить своими — bj сохранит и будет использовать ровно то, что здесь написано: подготовка создаст на сервере этот логин с этим паролем (в скрипте — они же).

Если логин с таким именем на сервере уже есть, ему будет поставлен этот пароль — программа, которая им пользуется, перестанет входить. Тогда впишите другое имя.

@@ -286,8 +286,8 @@
- - + +
@@ -408,7 +408,52 @@

База данных bj (PostgreSQL)

База самого bj: журнал заявок и поручений, история обмена с НРД и DEPO, пользователи.

- {{if .Done.Postgres}}
База готова{{if .PGSummary}}: {{.PGSummary}}{{end}}. Её создал установщик — ничего делать не нужно.
{{end}} + {{if and .Done.Postgres (pgLocal .Settings.Postgres.DSN)}}
База готова{{if .PGSummary}}: {{.PGSummary}}{{end}}. Её создал установщик — ничего делать не нужно.
+
Хранить базу bj на своём сервере PostgreSQL (необязательно) +

Если у организации есть свой сервер PostgreSQL — с резервным копированием и администратором баз, — базу bj можно держать на нём. bj перенесёт данные, сверит число строк в каждой таблице и переключится сам; база на этом сервере останется запасной копией. Нужно: пустая база на сервере организации, пользователь — её владелец, доступ с адреса bj ({{serverIP}}) к порту PostgreSQL. Только PostgreSQL той же версии, что здесь, или новее.

+
+ + + + + + +
+ + +
+ +
+ + +
+ {{else if .Done.Postgres}}
База bj — на сервере организации{{if .PGSummary}}: {{.PGSummary}}{{end}}.{{if .Settings.Postgres.Local}} Прежняя база на этом сервере сохранена как запасная копия.{{end}}
{{end}} {{if not .Done.Postgres}}
Обычно базу создаёт установщик — если вы ставили bj с образа или командой установки, этот шаг уже выполнен. Если база bj на отдельном сервере PostgreSQL — введите строку подключения ниже{{if devOn}}, или поднимите локальную базу автоматически (режим разработки){{end}}. @@ -425,15 +470,15 @@
{{end}} -
- {{if .Settings.Postgres.DSN}}Подключить другую базу (обычно не нужно){{else}}Вариант Б — у меня уже есть PostgreSQL, введу DSN сам{{end}} + {{if or devOn (not .Done.Postgres)}}
+ {{if .Settings.Postgres.DSN}}Подключить другую базу строкой подключения (без переноса данных){{else}}Вариант Б — у меня уже есть PostgreSQL, введу DSN сам{{end}}

При сохранении выполняется проверка подключения (Ping). Если БД недоступна — будет ошибка.

-
+
{{end}} {{template "wizNav" .}} {{if and devOn (not .Done.Postgres)}}

Пропустить (режим разработки: in-memory — без сохранения сделок)

{{end}} @@ -634,7 +679,7 @@ https://{{serverIP}}:8443/

Чтобы адрес не менялся, задайте постоянный: на консоли сервера — sudo bj-network.

- {{if .AutoDone}}

Уже сделано установщиком: {{range $i, $a := .AutoDone}}{{if $i}} · {{end}}✓ {{$a}}{{end}}

{{end}} + {{if .AutoDone}}

Уже сделано установщиком: {{range $i, $a := .AutoDone}}{{if $i}} · {{end}}✓ {{$a}}{{end}}{{if and .Done.Postgres (pgLocal .Settings.Postgres.DSN)}}. Базу bj можно держать и на своём сервере PostgreSQL →{{end}}

{{end}} + + {{end}} diff --git a/internal/lkgateway/web/templates/part_alerts.html b/internal/lkgateway/web/templates/part_alerts.html index 8c9d8f9..5a78432 100644 --- a/internal/lkgateway/web/templates/part_alerts.html +++ b/internal/lkgateway/web/templates/part_alerts.html @@ -6,7 +6,7 @@
-
+
-
+
По умолчанию — администратор системы из блока «Кому писать».
diff --git a/internal/lkgateway/web/templates/part_backup.html b/internal/lkgateway/web/templates/part_backup.html index d66dc13..2a9363c 100644 --- a/internal/lkgateway/web/templates/part_backup.html +++ b/internal/lkgateway/web/templates/part_backup.html @@ -9,13 +9,13 @@
- +

Например \\10.10.10.11\backup — та же папка, что открывается в проводнике Windows с этим логином. Копии ложатся в подпапку внутри неё.

- - + +
@@ -39,7 +39,7 @@ if(withForm){ ['share','dir','user','password'].forEach(function(n){ var v=f[n].value.trim(); if(v) p.set(n,v); }); } return fetch(url,{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()}); } - document.getElementById('bk-test').onclick=function(){ out.textContent='Проверяю доступ…'; + document.getElementById('bk-test').onclick=function(){ if(window.bjNeed&&!window.bjNeed(this.form,this)) return; out.textContent='Проверяю доступ…'; post('/admin/setup/backup/test',true).then(function(d){ out.innerHTML=d.ok?'✓ '+esc(d.output)+'':'✗ '+esc(d.error)+''; }); }; document.getElementById('bk-run').onclick=function(){ out.textContent='Запускаю копию…'; post('/admin/setup/backup/run',false).then(function(d){ if(!d.ok){out.innerHTML='✗ '+esc(d.error)+'';return} @@ -47,7 +47,7 @@ var iv=setInterval(function(){ fetch('/admin/setup/backup/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){ var b=s.backup; if(b && Date.parse(b.at)>=t0-60000){ clearInterval(iv); out.innerHTML=b.ok?'✓ копия готова: '+esc(b.dir)+', '+esc(b.msg)+'':'✗ '+esc(b.msg)+''; } if(Date.now()-t0>600000) clearInterval(iv); }); },3000); }); }; - document.getElementById('bk-list').onclick=function(){ out.textContent='Читаю список…'; + document.getElementById('bk-list').onclick=function(){ if(window.bjNeed&&!window.bjNeed(this.form,this)) return; out.textContent='Читаю список…'; post('/admin/setup/backup/list',true).then(function(d){ if(!d.ok){out.innerHTML='✗ '+esc(d.error)+'';return} if(!d.list||!d.list.length){ out.textContent='Копий на хранилище нет.'; return; } out.innerHTML='Копий: '+d.list.length+'. «Проверить» скачивает копию и сверяет контрольные суммы — ничего не восстанавливает.'+ diff --git a/internal/lkgateway/web/templates/part_igate.html b/internal/lkgateway/web/templates/part_igate.html index 18b4993..4968a59 100644 --- a/internal/lkgateway/web/templates/part_igate.html +++ b/internal/lkgateway/web/templates/part_igate.html @@ -58,6 +58,23 @@
+ +
@@ -67,6 +84,50 @@ function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c]})} function ok(t){return '✓ '+esc(t)+''} function bad(t){return '✗ '+esc(t)+''} function post(u,p){return fetch(u,{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()})} + // Запись канала идёт в фоне bj: показываем её ход (и после перезагрузки страницы), пока идёт — не даём уйти. + var job=(function(){ + var box=document.getElementById('ig-job'), list=document.getElementById('ig-job-steps'), tt=document.getElementById('ig-job-t'), + tm=document.getElementById('ig-job-time'), note=document.getElementById('ig-job-note'), busy=false, t0=0, timer=0, fails=0; + var ST=[['write','Запись канала в базу шлюза'],['engine','Перезапуск шлюза и запуск движка'],['channel','Проверка: шлюз загрузил канал']]; + function draw(v){ + var idx=-1; ST.forEach(function(x,i){ if(x[0]===v.stage) idx=i; }); if(v.done) idx=ST.length; + list.innerHTML=ST.map(function(x,i){ var cls=i
'+x[1]+'
'; }).join(''); + } + function tick(){ if(!t0) return; var n=Math.max(0,Math.round(Date.now()/1000-t0)); tm.textContent=Math.floor(n/60)+':'+('0'+n%60).slice(-2); } + function finish(v){ + busy=false; clearInterval(timer); box.classList.remove('shake'); + box.classList.add(v.ok?'is-ok':'is-err'); tt.textContent=v.ok?'Канал '+String(v.contour||'').toUpperCase()+' записан, шлюз работает':'Канал не записан'; + note.textContent=v.msg||''; draw(v); load(); + if(v.ok && /перезапускается/.test(v.msg||'')) setTimeout(function(){ location.reload(); },20000); + } + function poll(){ + fetch('/admin/setup/igate/contour-status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(v){ + fails=0; if(v.started) t0=v.started; + if(v.running){ draw(v); setTimeout(poll,2000); return; } + if(v.done) finish(v); else { busy=false; box.hidden=true; } + }).catch(function(){ if(++fails<30) setTimeout(poll,3000); }); // bj мог перезапуститься — ждём + } + function watch(c){ + busy=true; box.hidden=false; box.classList.remove('is-ok','is-err'); tt.textContent='Записываю канал '+String(c||'').toUpperCase()+' в шлюз НРД'; + note.textContent='Обычно до двух минут: шлюз перезапускается. Не уходите со страницы — после перезагрузки ход покажется снова.'; + t0=t0||Date.now()/1000; clearInterval(timer); timer=setInterval(tick,1000); tick(); + box.scrollIntoView({block:'center',behavior:'smooth'}); poll(); + } + // пока идёт запись — переходы и кнопки вне карточки не срабатывают + document.addEventListener('click',function(e){ + if(!busy) return; var a=e.target.closest('a[href],button,input[type=submit]'); if(!a||box.contains(a)) return; + e.preventDefault(); e.stopPropagation(); box.classList.remove('shake'); void box.offsetWidth; box.classList.add('shake'); + note.textContent='Подождите: идёт запись канала, шлюз перезапускается. Переход станет доступен, когда она закончится.'; + box.scrollIntoView({block:'center',behavior:'smooth'}); + },true); + window.addEventListener('beforeunload',function(e){ if(busy){ e.preventDefault(); e.returnValue=''; } }); + // при открытии страницы: запись уже идёт или только что закончилась — показать + fetch('/admin/setup/igate/contour-status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(v){ + if(v.running){ t0=v.started||0; watch(v.contour); } else if(v.done){ box.hidden=false; t0=0; finish(v); } + }).catch(function(){}); + return {watch:watch}; + })(); var st=null; function load(){ fetch('/admin/setup/igate/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){ @@ -76,6 +137,7 @@ var running=d.installed&&d.engine==='Running'; if(!d.installed) head.innerHTML=bad('ИШ на этом сервере не установлен'); else head.innerHTML=(running?ok('ИШ '+d.version+' установлен и работает'):bad('ИШ '+d.version+' установлен, но не работает: движок '+(d.engine||'не отвечает')+', служба '+d.service)); + if(d.busy) head.innerHTML='⟳ Шлюз перезапускается — '+esc(d.busy)+'; состояние — последнее известное'; // не стоит или не работает — одна кнопка: поставить заново проверенную версию с сервера (систему переставлять не нужно) ri.style.display=running?'none':'block'; if(!running){ @@ -106,8 +168,9 @@ function act(a,c,b){ if(a==='showprod'){ document.getElementById('ig-prod').style.display='block'; return; } var p=new URLSearchParams(); p.set('contour',c); p.set('crypto',document.getElementById('ig-crypto').value); - if(a==='contour'){ b.disabled=true; cout.textContent='Записываю канал и перезапускаю ИШ (до 2 минут)…'; - post('/admin/setup/igate/contour',p).then(function(d){ b.disabled=false; cout.innerHTML=d.ok?ok(d.output):bad(d.error); load(); }); } + if(a==='contour'){ b.disabled=true; cout.textContent=''; + post('/admin/setup/igate/contour',p).then(function(d){ b.disabled=false; if(!d.ok){ cout.innerHTML=bad(d.error); return; } job.watch(c); }) + .catch(function(){ b.disabled=false; job.watch(c); }); } if(a==='switch'){ if(!confirm('Переключить bj на тестовый контур TEST3?')) return; cout.textContent='Переключаю…'; post('/admin/setup/igate/switch',p).then(function(d){ cout.innerHTML=d.ok?ok(d.output):bad(d.error); if(d.ok) setTimeout(function(){location.reload()},15000); }); } } diff --git a/internal/lkgateway/web/templates/part_quik.html b/internal/lkgateway/web/templates/part_quik.html index ca534d6..5e0373f 100644 --- a/internal/lkgateway/web/templates/part_quik.html +++ b/internal/lkgateway/web/templates/part_quik.html @@ -9,17 +9,17 @@
+ +
+ +
Дополнительно: шифрование
diff --git a/internal/lkgateway/wizard_depo.go b/internal/lkgateway/wizard_depo.go index a3cd052..f103ec4 100644 --- a/internal/lkgateway/wizard_depo.go +++ b/internal/lkgateway/wizard_depo.go @@ -393,8 +393,15 @@ func (h *setupHandlers) depoSetupDo(ctx context.Context, r *http.Request, step d switch mode { case "auto": au := strings.TrimSpace(r.FormValue("admin_user")) + var miss []string if au == "" { - return fail("введите логин и пароль администратора SQL (они используются один раз и не сохраняются)") + miss = append(miss, "логин администратора SQL") + } + if r.FormValue("admin_password") == "" { + miss = append(miss, "пароль администратора SQL") + } + if len(miss) > 0 { + return fail("не заполнено: " + strings.Join(miss, ", ") + " (они используются один раз и не сохраняются)") } if bad := depoBjCreds(r, &s); bad != "" { return fail(bad)