Первичная настройка, часть 2: шаг 9 «Оповещения и резервные копии», шаг 10 — итоговая проверка готовности (живые проверки со ссылкой «Исправить»); проверка модуля неторговых поручений QUIK (объекты M2M, инструменты, справочник получателей, цикл, ошибки операций); восстановление из копии на /first-run по одноразовому коду; возврат на тот же шаг мастера после «Сохранить» (return_to с ?step); пароль SMTP не выводится в страницу; карточки почты, адресатов и копий — общие для настроек и мастера
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -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}}<div class="err">{{.Error}}</div>{{end}}
|
||||
<div class="hint">Код потерян или истёк: на сервере выполните <code>sudo bj-installer first-run-code</code>.</div>
|
||||
</form>
|
||||
<details style="margin-top:16px;border-top:1px solid var(--border);padding-top:12px"><summary style="cursor:pointer;font-size:13px;color:var(--accent)">Переустановка сервера? Восстановить из резервной копии</summary>
|
||||
<p class="hint">Вместо создания администратора можно вернуть сервер из резервной копии: настройки, пользователи, лицензия, заявки, база и папка шлюза НРД. Нужен одноразовый код (поле вверху) и доступ к хранилищу копий.</p>
|
||||
<label>Хранилище (SMB)</label><input id="rs-share" placeholder="//nas/backup" autocomplete="off">
|
||||
<label>Папка</label><input id="rs-dir" placeholder="bj-backup" autocomplete="off">
|
||||
<label>Логин хранилища</label><input id="rs-user" autocomplete="off">
|
||||
<label>Пароль хранилища</label><input id="rs-pass" type="password" autocomplete="new-password">
|
||||
<button type="button" id="rs-list">Показать копии</button>
|
||||
<label id="rs-lbl" style="display:none">Копия</label><select id="rs-name" style="display:none;width:100%;padding:9px;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--text)"></select>
|
||||
<button type="button" id="rs-go" style="display:none;background:var(--err)">Восстановить</button>
|
||||
<div id="rs-out" class="hint"></div>
|
||||
</details>
|
||||
<script>
|
||||
(function(){
|
||||
var out=document.getElementById('rs-out'), sel=document.getElementById('rs-name');
|
||||
function v(id){return document.getElementById(id).value.trim()}
|
||||
function params(){ var p=new URLSearchParams(); p.set('code', document.querySelector('input[name=code]').value);
|
||||
p.set('share',v('rs-share')); p.set('dir',v('rs-dir')); p.set('user',v('rs-user')); p.set('password',document.getElementById('rs-pass').value); return p; }
|
||||
function nice(n){ var m=n.match(/^(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})$/); return m? m[3]+'.'+m[2]+'.'+m[1]+' '+m[4]+':'+m[5] : n; }
|
||||
function post(u,p){ return fetch(u,{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()}); }
|
||||
document.getElementById('rs-list').onclick=function(){
|
||||
if(!document.querySelector('input[name=code]').value.trim()){ out.textContent='Сначала введите одноразовый код (вверху).'; return; }
|
||||
out.textContent='Читаю список копий…';
|
||||
post('/first-run/backup/list',params()).then(function(d){
|
||||
if(!d.ok){ out.textContent='✗ '+d.error; return; }
|
||||
if(!d.list||!d.list.length){ out.textContent='На хранилище копий нет.'; return; }
|
||||
sel.innerHTML=d.list.map(function(n){return '<option value="'+n+'">'+nice(n)+'</option>'}).join('');
|
||||
sel.style.display='block'; document.getElementById('rs-lbl').style.display='block'; document.getElementById('rs-go').style.display='block';
|
||||
out.textContent='Найдено копий: '+d.list.length+'. Обычно выбирают самую свежую.';
|
||||
}).catch(function(){ out.textContent='Не удалось получить список.'; });
|
||||
};
|
||||
document.getElementById('rs-go').onclick=function(){
|
||||
if(!confirm('Восстановить копию от '+nice(sel.value)+'? Данные этого сервера будут заменены, bj и шлюз НРД перезапустятся.')) return;
|
||||
var p=params(); p.set('name',sel.value); out.textContent='Запускаю восстановление…';
|
||||
post('/first-run/backup/restore',p).then(function(d){
|
||||
if(!d.ok){ out.textContent='✗ '+d.error; return; }
|
||||
var t0=Date.now(), iv=setInterval(function(){
|
||||
fetch('/first-run/backup/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
|
||||
var r=s.restore; if(!r) return; out.textContent='Восстановление: '+r.msg;
|
||||
if(r.stage==='done'){ clearInterval(iv); out.textContent='✓ '+r.msg+'. Сейчас откроется вход — войдите учётной записью из копии.'; setTimeout(function(){location.href='/login'},4000); }
|
||||
if(r.stage==='error'){ clearInterval(iv); out.textContent='✗ '+r.msg; }
|
||||
}).catch(function(){ out.textContent='bj перезапускается, жду…'; });
|
||||
if(Date.now()-t0>900000) clearInterval(iv);
|
||||
},3000);
|
||||
});
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</div></body></html>`
|
||||
|
||||
Reference in New Issue
Block a user