package lkgateway // Первичная настройка после установки. Пока в bj нет ни одного пользователя, установщик // (cmd/bj-installer) оставляет файл с хешем одноразового кода, который показан на экране сервера. // Все страницы, кроме этой, перенаправляются сюда: по коду создаётся первый администратор, файл // удаляется, дальше обычный вход и раздел «Настройка». Установки без файла работают как раньше // (открытый режим до создания первого пользователя). import ( "context" "encoding/json" "html/template" "log" "net/http" "net/url" "os" "strings" "time" "golang.org/x/crypto/bcrypt" ) const firstRunFile = "/var/lib/bj/.bj/first-run.json" type firstRunState struct { CodeHash string `json:"code_hash"` CreatedAt time.Time `json:"created_at"` } // firstRunPending — ждём первичной настройки: пользователей нет, установщик оставил код. func firstRunPending() bool { if globalUsers.Count() > 0 { return false } _, err := os.Stat(firstRunFile) return err == nil } type firstRunData struct { Error, Login, Name string NeedKey bool // лицензия ещё не активирована — предложить ввести ключ продукта здесь ProductKey string } var firstRunTpl = template.Must(template.New("first-run").Parse(firstRunHTML)) // firstRunCodeOK — одноразовый код первичной настройки верен (с ограничением попыток по адресу). func firstRunCodeOK(r *http.Request) (bool, string) { key := "first-run|" + clientIP(r) if loginFailCount(key) >= 8 { return false, "Слишком много неудачных попыток. Подождите 15 минут." } code := strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(r.FormValue("code"))) var st firstRunState raw, err := os.ReadFile(firstRunFile) if err == nil { _ = json.Unmarshal(raw, &st) } if err != nil || st.CodeHash == "" || bcrypt.CompareHashAndPassword([]byte(st.CodeHash), []byte(code)) != nil { loginFailAdd(key) time.Sleep(700 * time.Millisecond) log.Printf("auth: первичная настройка: неверный код с %s", clientIP(r)) return false, "Неверный одноразовый код" } return true, "" } // registerFirstRunRestore — восстановление из резервной копии прямо со страницы первичной настройки: при // переустановке сервера не нужно заводить администратора, которого восстановление тут же заменит. func registerFirstRunRestore(mux *http.ServeMux) { op := func(name string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } if !firstRunPending() { jsonOut(w, map[string]any{"ok": false, "error": "первичная настройка уже выполнена — восстановление доступно администратору в мастере настройки"}) return } if ok, msg := firstRunCodeOK(r); !ok { jsonOut(w, map[string]any{"ok": false, "error": msg}) return } ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() if name == "restore" { log.Printf("auth: первичная настройка: восстановление копии %s с %s", r.FormValue("name"), clientIP(r)) } jsonOut(w, opResult(backupOp(ctx, name, backupParams(r)))) } } mux.HandleFunc("/first-run/backup/list", op("list")) mux.HandleFunc("/first-run/backup/restore", op("restore")) mux.HandleFunc("/first-run/backup/status", func(w http.ResponseWriter, r *http.Request) { // без входа — только пока сервер ждёт первичной настройки или восстановление шло в последние полчаса if st, err := os.Stat(restoreStatusFile); !firstRunPending() && (err != nil || time.Since(st.ModTime()) > 30*time.Minute) { http.NotFound(w, r) return } out := map[string]any{} if raw, err := os.ReadFile(restoreStatusFile); err == nil { var rs map[string]any if json.Unmarshal(raw, &rs) == nil { out["restore"] = rs } } out["pending"] = firstRunPending() jsonOut(w, out) }) } func registerFirstRun(mux *http.ServeMux) { registerFirstRunRestore(mux) mux.HandleFunc("/first-run", func(w http.ResponseWriter, r *http.Request) { if !firstRunPending() { http.Redirect(w, r, "/login", http.StatusSeeOther) return } var d firstRunData d.NeedKey = globalRC != nil && !licenseStatus(globalRC).Valid w.Header().Set("Cache-Control", "no-store") if r.Method == http.MethodPost { ip := clientIP(r) key := "first-run|" + ip d.Login, d.Name = strings.TrimSpace(r.FormValue("login")), strings.TrimSpace(r.FormValue("name")) d.ProductKey = strings.ToUpper(strings.NewReplacer(" ", "").Replace(strings.TrimSpace(r.FormValue("product_key")))) pw, pw2 := r.FormValue("password"), r.FormValue("password2") code := strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(r.FormValue("code"))) var st firstRunState raw, readErr := os.ReadFile(firstRunFile) if readErr == nil { _ = json.Unmarshal(raw, &st) } switch { case loginFailCount(key) >= 8: d.Error = "Слишком много неудачных попыток. Подождите 15 минут." case readErr != nil || st.CodeHash == "" || bcrypt.CompareHashAndPassword([]byte(st.CodeHash), []byte(code)) != nil: loginFailAdd(key) time.Sleep(700 * time.Millisecond) log.Printf("auth: первичная настройка: неверный код с %s", ip) d.Error = "Неверный одноразовый код" case d.Login == "" || strings.ContainsAny(d.Login, " /\\@\"'<>"): d.Error = "Логин пустой или содержит недопустимые символы" case len(pw) < 10 || pw != pw2: d.Error = "Пароль не короче 10 символов и должен совпадать с повтором" default: hash, err := hashPassword(pw) if err != nil { d.Error = err.Error() break } now := time.Now() u := &User{Login: d.Login, Name: d.Name, Role: "admin", Source: "local", PassHash: hash, Active: true, CreatedAt: now, LastLogin: now} if err := globalUsers.Put(u); err != nil { d.Error = "Не удалось сохранить пользователя: " + err.Error() break } _ = os.Remove(firstRunFile) loginFailReset(key) 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) acancel() if err != nil { log.Printf("auth: первичная настройка: ключ продукта не принят: %v", err) http.Redirect(w, r, "/admin/wizard?step=1&flash="+url.QueryEscape("Администратор создан. Ключ продукта не принят: "+err.Error()+". Проверьте его и введите ещё раз."), http.StatusSeeOther) return } 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("Администратор создан. Теперь ключ продукта — он есть в портале клиента lic.zetit.ru/portal: после него bj проверит обновления и поставит модули вашей лицензии."), http.StatusSeeOther) return } // ключ уже есть (введён при установке) — сразу обновления и модули, потом «Время и сеть» http.Redirect(w, r, "/admin/setup/prepare?next="+url.QueryEscape("/admin/wizard?step=12")+"&flash="+url.QueryEscape("Администратор создан."), http.StatusSeeOther) return } w.WriteHeader(http.StatusUnauthorized) } _ = firstRunTpl.Execute(w, d) }) } const firstRunHTML = `Первичная настройка · Bridge&Join
Bridge&Join
Первичная настройка

Введите одноразовый код, который установщик показал на экране сервера, и создайте учётную запись администратора. Остальные настройки откроются сразу после этого.

{{/* ключ продукта здесь не спрашивается: его спрашивает следующий экран — один раз, после создания администратора */}} {{if .Error}}
{{.Error}}
{{end}}
Код потерян или истёк: на сервере выполните sudo bj-installer first-run-code.
Переустановка сервера? Восстановить из резервной копии

Вместо создания администратора можно вернуть сервер из резервной копии: настройки, пользователи, лицензия, заявки, база и папка шлюза НРД. Нужен одноразовый код (поле вверху) и доступ к хранилищу копий.

`