diff --git a/.gitignore b/.gitignore index 39a1e8e..b24a58e 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ DOC/*.bak.pdf # Собранный бинарь в корне /bj-server +__pycache__/ diff --git a/deploy/iso/bj-firstboot b/deploy/iso/bj-firstboot index 6d6f947..905f6f5 100755 --- a/deploy/iso/bj-firstboot +++ b/deploy/iso/bj-firstboot @@ -1,8 +1,9 @@ #!/bin/bash -# Первая загрузка сервера, установленного с образа Bridge&Join. Спрашивает ключ продукта на консоли; -# без ключа ставит ядро bj с образа (/var/lib/bj/bundle), а ключ вводится в браузере на первой -# странице настройки. Пока bj не установлен, вопрос повторяется при каждой загрузке (вопросы ждут ответа без таймера), -# адрес и подсказки всегда есть на экране входа (bj-welcome). +# Первая загрузка сервера, установленного с образа Bridge&Join: пароли учётных записей, постоянный адрес и +# установка ядра bj с образа (/var/lib/bj/bundle). Ключ продукта на консоли не спрашивается (06.10.2026): +# его вводят один раз — в браузере, после создания администратора. Пока bj не установлен, установка +# повторяется при каждой загрузке; адрес и подсказки всегда есть на экране входа (bj-welcome). +# Ключ на консоли спрашивается только у образа без комплекта bj (тогда bj качается с сервера по ключу). set -u MARK=/var/lib/bj/.bj/installed-from-iso SERVER=https://lic.zetit.ru @@ -107,11 +108,21 @@ clear printf '\n Bridge&Join: установка на сервер\n ────────────────────────────────────────────────────────────\n' printf ' Шаг 2 из 2 — установка bj. Система установлена, осталось поставить bj.\n\n' if [ -f "$BUNDLE/manifest.json" ]; then - printf ' Ключ продукта (BJ-XXXX-XXXX-XXXX) есть в портале клиента %s/portal/.\n' "$SERVER" - printf ' Вводить его здесь необязательно: нажмите Enter — ядро bj установится с образа,\n а ключ вы введёте в браузере на первой странице настройки (там его удобно вставить).\n\n' -else - printf ' Нужен ключ продукта (BJ-XXXX-XXXX-XXXX): он есть в портале клиента %s/portal/\n\n' "$SERVER" + printf ' Ключ продукта вводить здесь не нужно: его спросит браузер после создания администратора.\n\n' + if install_bj --bundle "$BUNDLE" --server "$SERVER" --channel "$CHANNEL"; then + finish_quiet # адрес и код первичной настройки — на экране установки выше + show_accounts + read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true + finish_screen + exit 0 + fi + show_accounts + printf ' Установка bj не завершилась. Она повторится при следующей загрузке, или сейчас вручную:\n sudo bj-installer install --bundle %s\n\n' "$BUNDLE" + read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true + exit 0 fi +# образ без комплекта bj — ядро качается с сервера лицензий, для этого нужен ключ +printf ' Нужен ключ продукта (BJ-XXXX-XXXX-XXXX): он есть в портале клиента %s/portal/\n\n' "$SERVER" for i in $(seq 1 30); do curl -fsS -m 5 -o /dev/null "$SERVER/healthz" && break [ "$i" = 1 ] && printf ' Жду сеть и связь с %s…\n' "$SERVER" @@ -119,26 +130,9 @@ for i in $(seq 1 30); do done while true; do CODE="" - if [ -f "$BUNDLE/manifest.json" ]; then - read -r -p " Ключ продукта (Enter — установить с образа, ключ ввести в браузере): " CODE < /dev/tty || true - else - read -r -p " Ключ продукта (Enter — установить позже): " CODE < /dev/tty || true - fi + read -r -p " Ключ продукта (Enter — установить позже): " CODE < /dev/tty || true CODE=$(printf '%s' "${CODE:-}" | tr -d ' ' | tr '[:lower:]' '[:upper:]') if [ -z "$CODE" ]; then - if [ -f "$BUNDLE/manifest.json" ]; then - if install_bj --bundle "$BUNDLE" --server "$SERVER" --channel "$CHANNEL"; then - finish_quiet # адрес и код первичной настройки — на экране установки выше - show_accounts - read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true - finish_screen - exit 0 - fi - show_accounts - printf ' Установка bj не завершилась. Она повторится при следующей загрузке, или сейчас вручную:\n sudo bj-installer install --bundle %s\n\n' "$BUNDLE" - read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true - exit 0 - fi show_accounts printf '\n Установка bj отложена. Вопрос повторится при следующей загрузке; вручную:\n sudo bj-installer install --code КЛЮЧ-ПРОДУКТА\n\n' read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true diff --git a/internal/lkgateway/depo_setup.go b/internal/lkgateway/depo_setup.go new file mode 100644 index 0000000..20106b9 --- /dev/null +++ b/internal/lkgateway/depo_setup.go @@ -0,0 +1,223 @@ +package lkgateway + +// Подготовка базы учёта DEPO одним действием (06.10.2026). Раньше это были три экрана мастера — логин bj, +// таблицы обмена, задание SQL Agent — и три скрипта. Теперь один скрипт делает всё: +// - находит базу DEPO на сервере сам (по учётным таблицам OD_FACES и OD_RESTS) — имя базы клиенту знать +// не нужно (SQL Server; для PostgreSQL базу назвать придётся: psql не умеет искать по чужим базам); +// - создаёт логин bj (пароль придумывает bj и запоминает) или обновляет его пароль; +// - создаёт таблицы обмена FTV_M2M* и выдаёт логину bj права: чтение учёта, запись в таблицы обмена; +// - по галочке «обработку запускает SQL Server Agent» — создаёт задание bj2depo, иначе обработку +// вызывает сам bj (тогда логину bj — право запускать процедуру обработки). +// Вариант «bj сделает сам» выполняет тот же скрипт логином администратора SQL (он не сохраняется), вариант +// «скрипт для администратора» отдаёт его файлом, вариант «логин уже есть» только проверяет. + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + "strings" + "time" +) + +// depoSetupLogin — логин bj в DEPO (если в настройках уже есть допустимый — он). +func depoSetupLogin(f FancyMSSQLSettings) string { + if u := strings.TrimSpace(f.User); u != "" && depoLoginRe.MatchString(u) { + return u + } + return "bj_user" +} + +// depoGenPassword — пароль логина bj: 24 знака, есть заглавные, строчные и цифры (политика паролей SQL Server). +func depoGenPassword() string { + const up, low, dig = "ABCDEFGHJKLMNPQRSTUVWXYZ", "abcdefghijkmnpqrstuvwxyz", "23456789" + all := up + low + dig + pick := func(set string) byte { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(set)))) + return set[n.Int64()] + } + b := []byte{pick(up), pick(low), pick(dig)} + for len(b) < 24 { + b = append(b, pick(all)) + } + for i := len(b) - 1; i > 0; i-- { // перемешать, чтобы обязательные знаки не стояли в начале + j, _ := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + b[i], b[j.Int64()] = b[j.Int64()], b[i] + } + return string(b) +} + +// sqlStr — строковый литерал T-SQL / PostgreSQL (одинарная кавычка удваивается). +func sqlStr(s string) string { return strings.ReplaceAll(s, "'", "''") } + +// depoSetupScript — один скрипт подготовки базы DEPO. db — имя базы (пусто — найти на сервере; только SQL +// Server), agentJob — создать задание SQL Server Agent (иначе обработку вызывает bj). +func depoSetupScript(f FancyMSSQLSettings, db, login, pass string, agentJob bool) string { + if f.IsPostgres() { + return depoSetupScriptPG(f, db, login, pass) + } + var b strings.Builder + job := "обработку вызывает сам bj" + if agentJob { + job = "задание SQL Server Agent «" + depoJobName + "» для обработки обмена" + } + fmt.Fprintf(&b, `-- Bridge&Join: подготовка базы учёта DEPO для bj. Microsoft SQL Server. +-- Один скрипт: находит базу DEPO на сервере, создаёт логин bj (%[1]s), таблицы обмена FTV_M2M*, выдаёт +-- права (чтение учёта, запись только в таблицы обмена) и %[2]s. +-- Учётные данные DEPO скрипт не меняет. Запускать в SSMS или sqlcmd под логином с правами sysadmin. +-- Можно запускать повторно: уже сделанное не повторяется. Скрипт содержит пароль логина bj — после +-- выполнения файл удалите. + +SET NOCOUNT ON; +DECLARE @db sysname = N'%[3]s'; -- база DEPO; пусто — найти на сервере по таблицам OD_FACES и OD_RESTS +DECLARE @found int; +IF @db = N'' +BEGIN + SELECT @found = COUNT(*) FROM sys.databases WHERE state = 0 AND database_id > 4 + AND OBJECT_ID(QUOTENAME(name) + N'.dbo.OD_FACES') IS NOT NULL AND OBJECT_ID(QUOTENAME(name) + N'.dbo.OD_RESTS') IS NOT NULL; + IF @found = 1 + SELECT @db = name FROM sys.databases WHERE state = 0 AND database_id > 4 + AND OBJECT_ID(QUOTENAME(name) + N'.dbo.OD_FACES') IS NOT NULL AND OBJECT_ID(QUOTENAME(name) + N'.dbo.OD_RESTS') IS NOT NULL; +END +IF @db = N'' OR DB_ID(@db) IS NULL +BEGIN + RAISERROR(N'База DEPO не найдена или на сервере их несколько. Впишите имя базы DEPO в @db в начале скрипта и запустите снова.', 16, 1); + RETURN; +END +PRINT N'База DEPO: ' + @db; +DECLARE @in nvarchar(300) = N'USE ' + QUOTENAME(@db) + N'; '; + +-- 1. Логин bj +IF SUSER_ID(N'%[1]s') IS NULL + EXEC(N'CREATE LOGIN [%[1]s] WITH PASSWORD = N''%[4]s'', CHECK_POLICY = ON, DEFAULT_DATABASE = ' + QUOTENAME(@db)) +ELSE + EXEC(N'ALTER LOGIN [%[1]s] WITH PASSWORD = N''%[4]s'''); + +-- 2. Таблицы обмена FTV_M2M* (только недостающие таблицы, колонки и индексы — данные не меняются) +`, login, job, sqlStr(db), sqlStr(sqlStr(pass))) + for _, s := range depoTablesDDL(false) { + fmt.Fprintf(&b, "EXEC(@in + N'%s');\n", sqlStr(s)) + } + b.WriteString("\n-- 3. Права логину bj в базе DEPO: чтение учёта (сверка переводов), запись в таблицы обмена\n") + for _, s := range depoTablesGrantSQL(false, login) { + fmt.Fprintf(&b, "EXEC(@in + N'%s');\n", sqlStr(s)) + } + proc := depoJobProcName(f) + if agentJob { + fmt.Fprintf(&b, ` +-- 4. Задание SQL Server Agent «%[1]s»: каждые %[3]d с вызывает %[2]s — записи bj становятся документами учёта +IF NOT EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'%[1]s') +BEGIN + EXEC msdb.dbo.sp_add_job @job_name = N'%[1]s', @enabled = 1, @description = N'bj2depo: обработка таблиц обмена M2M (Bridge&Join)'; + EXEC msdb.dbo.sp_add_jobstep @job_name = N'%[1]s', @step_name = N'%[1]s', @subsystem = N'TSQL', @database_name = @db, @command = N'exec %[2]s;'; + EXEC msdb.dbo.sp_add_jobschedule @job_name = N'%[1]s', @name = N'%[1]s', @enabled = 1, @freq_type = 4, @freq_interval = 1, @freq_subday_type = 2, @freq_subday_interval = %[3]d, @active_start_time = 0; + EXEC msdb.dbo.sp_add_jobserver @job_name = N'%[1]s'; + PRINT N'Задание %[1]s создано.'; +END +ELSE + PRINT N'Задание %[1]s уже есть — не изменялось.'; +-- логину bj — чтение списка заданий и их истории: bj проверяет, что задание работает +EXEC(N'USE msdb; IF USER_ID(N''%[4]s'') IS NULL CREATE USER [%[4]s] FOR LOGIN [%[4]s]; ELSE IF EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N''%[4]s'' AND sid <> SUSER_SID(N''%[4]s'')) ALTER USER [%[4]s] WITH LOGIN = [%[4]s]; IF IS_ROLEMEMBER(N''SQLAgentReaderRole'', N''%[4]s'') = 0 ALTER ROLE SQLAgentReaderRole ADD MEMBER [%[4]s];'); +`, depoJobName, proc, depoJobEvery(f), login) + } else { + fmt.Fprintf(&b, ` +-- 4. Обработку вызывает bj: логину bj — право запускать процедуру %[1]s +EXEC(@in + N'IF OBJECT_ID(N''%[1]s'', N''P'') IS NOT NULL GRANT EXECUTE ON %[1]s TO [%[2]s] ELSE PRINT N''Процедуры обработки ещё не установлены: после их установки выполните GRANT EXECUTE ON %[1]s TO [%[2]s]'''); +`, proc, login) + } + b.WriteString("\nPRINT N'Готово: база DEPO подготовлена для bj.';\n") + return b.String() +} + +// depoSetupScriptPG — то же для PostgreSQL (psql: роль, подключение к базе DEPO, таблицы, права). +func depoSetupScriptPG(f FancyMSSQLSettings, db, login, pass string) string { + var b strings.Builder + fmt.Fprintf(&b, `-- Bridge&Join: подготовка базы учёта DEPO для bj. PostgreSQL 10 и новее. +-- Запуск суперпользователем или владельцем базы: psql -h <сервер> -U postgres -f bj-depo-setup-postgres.sql +-- Создаёт роль bj (%[2]s), таблицы обмена FTV_M2M* и права (чтение учёта, запись только в таблицы обмена). +-- Обработку обмена вызывает сам bj. Учётные данные скрипт не меняет. Можно запускать повторно. +-- Скрипт содержит пароль роли bj — после выполнения файл удалите. +\set ON_ERROR_STOP on + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '%[2]s') THEN + CREATE ROLE "%[2]s" LOGIN PASSWORD '%[3]s'; + ELSE + ALTER ROLE "%[2]s" LOGIN PASSWORD '%[3]s'; + END IF; +END $$; +GRANT CONNECT ON DATABASE "%[1]s" TO "%[2]s"; + +\c "%[1]s" +`, db, login, sqlStr(pass)) + for _, s := range depoTablesDDL(true) { + b.WriteString(s + ";\n") + } + for _, s := range depoTablesGrantSQL(true, login) { + b.WriteString(s + ";\n") + } + proc := depoJobProcName(f) + fmt.Fprintf(&b, "DO $$ BEGIN IF to_regproc('%[1]s') IS NOT NULL THEN EXECUTE 'GRANT EXECUTE ON FUNCTION %[1]s TO \"%[2]s\"'; END IF; END $$;\n", strings.ToLower(proc), login) + return b.String() +} + +// depoSetupFileName — имя файла скрипта. +func depoSetupFileName(f FancyMSSQLSettings) string { + if f.IsPostgres() { + return "bj-depo-setup-postgres.sql" + } + return "bj-depo-setup-mssql.sql" +} + +// depoSetupRun — «bj сделает сам»: тот же скрипт выполняется логином администратора (не сохраняется). +// Возвращает найденную базу DEPO. +func depoSetupRun(ctx context.Context, f FancyMSSQLSettings, db, adminUser, adminPass, login, pass string, agentJob bool) (string, error) { + adm := f + adm.User, adm.Password = strings.TrimSpace(adminUser), adminPass + if db == "" { + found, err := depoDiscover(ctx, adm) + switch { + case err != nil: + return "", fmt.Errorf("список баз сервера логином %s: %s", adm.User, quikErrorDetail(err)) + case len(found) == 0: + return "", fmt.Errorf("на сервере нет базы учёта DEPO (с таблицами %s), доступной логину %s", strings.Join(depoMarkerTables, " и "), adm.User) + case len(found) > 1: + return "", fmt.Errorf("на сервере несколько баз DEPO (%s) — укажите нужную в поле «База DEPO»", strings.Join(found, ", ")) + } + db = found[0] + } + if f.IsPostgres() { + return db, depoSetupRunPG(ctx, adm, db, login, pass) + } + conn, err := fancyOpen(adm, "") + if err != nil { + return db, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + if _, err := conn.ExecContext(cctx, depoSetupScript(f, db, login, pass, agentJob)); err != nil { + return db, fmt.Errorf("подготовка базы %s логином %s: %s%s", db, adm.User, quikErrorDetail(err), depoTablesRightsHint(f, adm.User, err)) + } + return db, nil +} + +func depoSetupRunPG(ctx context.Context, adm FancyMSSQLSettings, db, login, pass string) error { + conn, err := fancyOpen(adm, db) + if err != nil { + return err + } + defer conn.Close() + stmts := []string{ + fmt.Sprintf(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '%[1]s') THEN CREATE ROLE "%[1]s" LOGIN PASSWORD '%[2]s'; ELSE ALTER ROLE "%[1]s" LOGIN PASSWORD '%[2]s'; END IF; END $$`, login, sqlStr(pass)), + fmt.Sprintf(`GRANT CONNECT ON DATABASE "%s" TO "%s"`, db, login), + } + stmts = append(stmts, depoTablesDDL(true)...) + stmts = append(stmts, depoTablesGrantSQL(true, login)...) + for _, s := range stmts { + if _, err := conn.ExecContext(ctx, s); err != nil { + return fmt.Errorf("%s → %s", depoStmtHead(s), quikErrorDetail(err)) + } + } + return nil +} diff --git a/internal/lkgateway/depodiscover.go b/internal/lkgateway/depodiscover.go new file mode 100644 index 0000000..3521e0a --- /dev/null +++ b/internal/lkgateway/depodiscover.go @@ -0,0 +1,98 @@ +package lkgateway + +// Поиск базы DEPO на сервере (05.10.2026). Клиент не знает и не должен знать, как называется база учёта у +// него на сервере: на шаге «Подключение» он вводит только адрес, логин и пароль, а bj сам находит базы с +// учётными таблицами Fancy DEPO (лица OD_FACES и остатки OD_RESTS), к которым у этого логина есть доступ. +// Одна база — выбирается сразу, несколько — мастер предлагает выбрать, ни одной — понятная ошибка. + +import ( + "context" + "fmt" + "strings" + "time" +) + +// depoMarkerTables — по каким таблицам база опознаётся как DEPO (Fancy). +var depoMarkerTables = []string{"OD_FACES", "OD_RESTS"} + +// depoDiscoverMax — сколько баз сервера просматривать (на сервере могут быть десятки баз). +const depoDiscoverMax = 60 + +// depoDiscover — базы DEPO на сервере, доступные этому логину. +func depoDiscover(ctx context.Context, s FancyMSSQLSettings) ([]string, error) { + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if s.IsPostgres() { + return depoDiscoverPG(cctx, s) + } + conn, err := fancyOpen(s, "") + if err != nil { + return nil, err + } + defer conn.Close() + rows, err := conn.QueryContext(cctx, `SELECT name FROM sys.databases + WHERE state = 0 AND database_id > 4 AND HAS_DBACCESS(name) = 1 ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("список баз сервера: %w", err) + } + var names []string + for rows.Next() { + var n string + if rows.Scan(&n) == nil && validDBName(n) && len(names) < depoDiscoverMax { + names = append(names, n) + } + } + rows.Close() + var found []string + for _, n := range names { + // имя проверено validDBName — подставлять в [ ] безопасно + q := "SELECT CASE WHEN " + strings.Join(func() []string { + var c []string + for _, t := range depoMarkerTables { + c = append(c, "OBJECT_ID(N'["+n+"].dbo."+t+"') IS NOT NULL") + } + return c + }(), " AND ") + " THEN 1 ELSE 0 END" + var ok int + if conn.QueryRowContext(cctx, q).Scan(&ok) == nil && ok == 1 { + found = append(found, n) + } + } + return found, nil +} + +func depoDiscoverPG(ctx context.Context, s FancyMSSQLSettings) ([]string, error) { + conn, err := fancyOpen(s, "") + if err != nil { + return nil, err + } + defer conn.Close() + rows, err := conn.QueryContext(ctx, `SELECT datname FROM pg_database + WHERE datallowconn AND NOT datistemplate AND has_database_privilege(datname, 'CONNECT') ORDER BY datname`) + if err != nil { + return nil, fmt.Errorf("список баз сервера: %w", err) + } + var names []string + for rows.Next() { + var n string + if rows.Scan(&n) == nil && validDBName(n) && n != "postgres" && len(names) < depoDiscoverMax { + names = append(names, n) + } + } + rows.Close() + var found []string + for _, n := range names { + db, err := fancyOpen(s, n) + if err != nil { + continue + } + var cnt int + err = db.QueryRowContext(ctx, `SELECT count(DISTINCT lower(table_name)) FROM information_schema.tables + WHERE lower(table_name) = ANY($1::text[])`, "{"+strings.ToLower(strings.Join(depoMarkerTables, ","))+"}").Scan(&cnt) + db.Close() + if err == nil && cnt == len(depoMarkerTables) { + found = append(found, n) + } + } + return found, nil +} diff --git a/internal/lkgateway/firstrun.go b/internal/lkgateway/firstrun.go index 3fecc28..ab37367 100644 --- a/internal/lkgateway/firstrun.go +++ b/internal/lkgateway/firstrun.go @@ -161,7 +161,8 @@ func registerFirstRun(mux *http.ServeMux) { s := globalSessions.New(u.Login, ip, r.UserAgent()) http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: s.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, MaxAge: int(sessionMaxLife.Seconds())}) log.Printf("auth: первичная настройка: создан администратор %s с %s", u.Login, ip) - // ключ продукта, если его не вводили на сервере: активируем сразу, чтобы модули и обновления заработали + // ключ продукта — следующим экраном (06.10.2026: ключ спрашивается один раз, после администратора); + // поле сохранено для старых страниц, открытых до обновления if d.NeedKey && d.ProductKey != "" && globalRC != nil { actx, acancel := context.WithTimeout(r.Context(), 30*time.Second) err := LicenseActivate(actx, globalRC, d.ProductKey) @@ -171,14 +172,15 @@ func registerFirstRun(mux *http.ServeMux) { http.Redirect(w, r, "/admin/wizard?step=1&flash="+url.QueryEscape("Администратор создан. Ключ продукта не принят: "+err.Error()+". Проверьте его и введите ещё раз."), http.StatusSeeOther) return } - http.Redirect(w, r, "/admin/setup/prepare?next=/admin/wizard&flash="+url.QueryEscape("Администратор создан, ключ продукта принят."), http.StatusSeeOther) + http.Redirect(w, r, "/admin/setup/prepare?next="+url.QueryEscape("/admin/wizard?step=12")+"&flash="+url.QueryEscape("Администратор создан, ключ продукта принят."), http.StatusSeeOther) return } if d.NeedKey { - http.Redirect(w, r, "/admin/wizard?step=1&flash="+url.QueryEscape("Администратор создан. Начнём с лицензии: без ключа продукта модули и обновления недоступны (шаг можно пропустить)."), http.StatusSeeOther) + http.Redirect(w, r, "/admin/wizard?step=1&flash="+url.QueryEscape("Администратор создан. Теперь ключ продукта — он есть в портале клиента lic.zetit.ru/portal: после него bj проверит обновления и поставит модули вашей лицензии."), http.StatusSeeOther) return } - http.Redirect(w, r, "/admin/wizard", http.StatusSeeOther) + // ключ уже есть (введён при установке) — сразу обновления и модули, потом «Время и сеть» + http.Redirect(w, r, "/admin/setup/prepare?next="+url.QueryEscape("/admin/wizard?step=12")+"&flash="+url.QueryEscape("Администратор создан."), http.StatusSeeOther) return } w.WriteHeader(http.StatusUnauthorized) @@ -213,7 +215,7 @@ code{font-size:12px} -{{if .NeedKey}}{{end}} +{{/* ключ продукта здесь не спрашивается: его спрашивает следующий экран — один раз, после создания администратора */}} {{if .Error}}
sudo bj-installer first-run-code.Ключ принят: {{.Tenant}}. bj ставит и запускает модули, которые входят в вашу лицензию, — затем откроется мастер настройки.
+Ключ принят: {{.Tenant}}. bj проверяет обновления, ставит и запускает модули, которые входят в вашу лицензию, — затем откроется мастер настройки.
✓ {{.Flash}}
{{end}}Лицензия: план {{.Plan}}, {{.Until}}
-Проверяю состав лицензии…
Проверяю обновления и состав лицензии…
Если у вас есть резервная копия bj на сетевом хранилище (Настройка → Резервное копирование), можно восстановить её вместо настройки с нуля: вернутся настройки, пользователи, лицензия, журнал заявок, база и папка шлюза НРД. Текущие данные этого сервера будут заменены. bj и шлюз НРД на время восстановления остановятся — обычно 1–3 минуты.
- - - -Модуль обмена с DEPO и QUIK не входит в текущую лицензию — шаг пропускается.
{{else}} {{$sub := .Sub}} -