Files
Bridge-and-Join-s/internal/lkgateway/firstrun.go
T
zuevav 10305a095e Первичная настройка, повторная проверка: копии без ключевых носителей СКЗИ (--one-file-system, без keymedia), без отпечатка железа и списка компонентов; восстановление на новый сервер — пароль базы bj из копии, база и пользователь ИШ; ключ для контура без канала запоминается и подключается при настройке канала; первый настроенный канал становится рабочим контуром; справочник сертификатов перечитывается после ключа и смены контура; шлюз НРД — шаг 7, ключ — шаг 8 (ключ ставится в профиль ИШ); подсказка контура ключа по сертификату; ИШ: параметры работающего шлюза не перезаписываются; без чужого кода отправителя и получателя по умолчанию; прокси для выхода в интернет; установка с образа без интернета; HTTPS — новый сертификат при смене адреса; bj-network — без cloud-init; проверка готовности: справочник участников, сетевые доступы, учётные записи, последний обмен с НРД
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
2026-10-05 17:33:10 +03:00

269 lines
17 KiB
Go

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)
// ключ продукта, если его не вводили на сервере: активируем сразу, чтобы модули и обновления заработали
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/wizard?flash="+url.QueryEscape("Администратор создан, лицензия активирована. Мастер проведёт по остальным шагам — любой можно пропустить."), http.StatusSeeOther)
return
}
if d.NeedKey {
http.Redirect(w, r, "/admin/wizard?step=1&flash="+url.QueryEscape("Администратор создан. Начнём с лицензии: без ключа продукта модули и обновления недоступны (шаг можно пропустить)."), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/wizard", http.StatusSeeOther)
return
}
w.WriteHeader(http.StatusUnauthorized)
}
_ = firstRunTpl.Execute(w, d)
})
}
const firstRunHTML = `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Первичная настройка · Bridge&amp;Join</title>
<style>
:root{--bg:#f4f5f9;--card:#fff;--text:#1a1d26;--muted:#6b7280;--accent:#3b4fd8;--err:#c62828;--border:#e3e5ee}
@media (prefers-color-scheme:dark){:root{--bg:#141721;--card:#1d2130;--text:#e6e8f0;--muted:#9aa0b3;--accent:#7c8cff;--border:#2b3044}}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px}
.box{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:28px 30px;width:100%;max-width:420px;box-shadow:0 12px 40px rgba(0,0,0,.08)}
.brand{display:flex;align-items:center;gap:10px;font-size:20px;font-weight:700;margin-bottom:4px}.brand em{font-style:normal;color:var(--accent)}
.sub{color:var(--muted);font-size:12.5px;margin-bottom:14px}
label{display:block;font-size:12px;color:var(--muted);margin:10px 0 4px}
input{width:100%;box-sizing:border-box;padding:9px 11px;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--text);font-size:14px}
input:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent}
input.code{font-size:20px;letter-spacing:.12em;text-align:center}
button{margin-top:16px;width:100%;padding:10px;border:0;border-radius:8px;background:var(--accent);color:#fff;font-weight:600;font-size:14px;cursor:pointer}
.err{background:rgba(198,40,40,.1);color:var(--err);padding:8px 10px;border-radius:8px;font-size:13px;margin-top:12px}
.hint{color:var(--muted);font-size:12px;margin-top:12px}
code{font-size:12px}
</style></head><body><div class="box">
<div class="brand"><svg width="26" height="26" viewBox="0 0 26 26" fill="none"><path d="M5.5 16 C 9 8.5, 17 8.5, 20.5 16" stroke="#3b4fd8" stroke-width="1.9" stroke-linecap="round"/><circle cx="5.5" cy="16" r="3.1" fill="#3b4fd8"/><circle cx="20.5" cy="16" r="3.1" fill="#e8562a"/></svg><span>Bridge<em>&amp;Join</em></span></div>
<div class="sub">Первичная настройка</div>
<p class="hint" style="margin-top:0">Введите одноразовый код, который установщик показал на экране сервера, и создайте учётную запись администратора. Остальные настройки откроются сразу после этого.</p>
<form method="post" action="/first-run">
<label>Одноразовый код</label><input class="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="0000-0000" autofocus required>
<label>Логин администратора</label><input type="text" name="login" value="{{.Login}}" autocomplete="username" required>
<label>Имя</label><input type="text" name="name" value="{{.Name}}" autocomplete="name">
<label>Пароль, не короче 10 символов</label><input type="password" name="password" minlength="10" autocomplete="new-password" required>
<label>Повтор пароля</label><input type="password" name="password2" minlength="10" autocomplete="new-password" required>
{{if .NeedKey}}<label>Ключ продукта (BJ-XXXX-XXXX-XXXX) — если не вводили на сервере; можно позже в «Настройка → Лицензия»</label><input type="text" name="product_key" value="{{.ProductKey}}" placeholder="BJ-XXXX-XXXX-XXXX" autocomplete="off" style="text-transform:uppercase">{{end}}
<button type="submit">Создать администратора</button>
{{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>`