diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go index 06f0589..0627b6f 100644 --- a/internal/lkgateway/auth.go +++ b/internal/lkgateway/auth.go @@ -861,6 +861,8 @@ func authExempt(p string) bool { switch { case p == "/login" || p == "/logout" || p == "/healthz" || p == "/first-run": return true + case strings.HasPrefix(p, "/first-run/"): // восстановление из копии при переустановке — по одноразовому коду + return true case strings.HasPrefix(p, "/api/"): return true case strings.HasPrefix(p, "/admin/npr/static/"): diff --git a/internal/lkgateway/firstrun.go b/internal/lkgateway/firstrun.go index 3a6780d..619d4e9 100644 --- a/internal/lkgateway/firstrun.go +++ b/internal/lkgateway/firstrun.go @@ -44,7 +44,69 @@ type firstRunData struct { 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) { + 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) @@ -151,4 +213,51 @@ code{font-size:12px} {{if .Error}}
sudo bj-installer first-run-code.Вместо создания администратора можно вернуть сервер из резервной копии: настройки, пользователи, лицензия, заявки, база и папка шлюза НРД. Нужен одноразовый код (поле вверху) и доступ к хранилищу копий.
+ + + + + + + + +