Кабинет: веб-установщик и управление пользователями в ядре
- установщик в самом кабинете: до настройки любая страница ведёт в /install; три шага — проверка сервера (каталог данных, https), связь с ядром (режим, адрес, ключ; связь проверяется до сохранения, в режиме ожидания страница ждёт подключения ядра), администратор кабинета (заводится в ядре через /api/lk/admin/bootstrap, только пока администраторов нет); настройки в data/lk.json 0600, окружение перекрывает файл; после установки установщик закрыт - настройки кабинета можно менять на ходу: Server за мьютексом, один mux на всё время - ключ модуля больше не кладётся в архив: его вводят в установщике; на странице настроек ключ показан целиком 10 минут после создания, потом маска - ядро: карточка «Пользователи кабинета» в настройках — список, заведение, смена пароля (гасит сессии), блокировка, права сотрудника, разрешение поручений, удаление; письма не отправляются - инструкция /admin/help/lk дополнена шагами установщика и разделом о подходящем хостинге - тесты: TestInstaller, TestNotConfigured, TestSetupPageLKUsersAndLink; установщик проверен вживую вторым экземпляром против боевого ядра Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
package lk
|
||||
|
||||
// install.go — веб-установщик кабинета.
|
||||
//
|
||||
// Порядок как у обычных веб-приложений: администратор копирует файлы на сервер,
|
||||
// открывает адрес кабинета — и попадает в установщик. Тот проверяет окружение,
|
||||
// спрашивает, как связаться с ядром, проверяет связь, помогает завести первого
|
||||
// администратора кабинета и сохраняет настройки в файл рядом с программой.
|
||||
// Служебные переменные окружения (BJ_URL, BJ_KEY…) при этом остаются рабочим способом
|
||||
// настройки для тех, кто ставит кабинет руками или скриптом: они перекрывают файл.
|
||||
//
|
||||
// Файл настроек: <каталог данных>/lk.json, права 0600. Каталог данных — LK_DATA, иначе
|
||||
// подкаталог data рядом с исполняемым файлом.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// configFileName — имя файла настроек в каталоге данных.
|
||||
const configFileName = "lk.json"
|
||||
|
||||
// fileConfig — что лежит в lk.json. Ключ — секрет, поэтому файл 0600.
|
||||
type fileConfig struct {
|
||||
Mode string `json:"mode"` // direct | agent
|
||||
BJURL string `json:"bj_url,omitempty"`
|
||||
BJKey string `json:"bj_key"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultDataDir — каталог данных по умолчанию: data рядом с программой.
|
||||
func DefaultDataDir() string {
|
||||
if v := strings.TrimSpace(os.Getenv("LK_DATA")); v != "" {
|
||||
return v
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "data"
|
||||
}
|
||||
return filepath.Join(filepath.Dir(exe), "data")
|
||||
}
|
||||
|
||||
// LoadConfigFile — настройки из файла; непустые значения из cfg (окружение, флаги)
|
||||
// имеют приоритет. Отсутствие файла — не ошибка: значит, установщик ещё не проходили.
|
||||
func LoadConfigFile(dataDir string, cfg Config) (Config, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(dataDir, configFileName))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return cfg, nil
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
var f fileConfig
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return cfg, fmt.Errorf("файл настроек %s повреждён: %v", configFileName, err)
|
||||
}
|
||||
if cfg.BJKey == "" {
|
||||
cfg.BJKey = f.BJKey
|
||||
}
|
||||
if cfg.BJURL == "" {
|
||||
cfg.BJURL = f.BJURL
|
||||
}
|
||||
if cfg.Title == "" {
|
||||
cfg.Title = f.Title
|
||||
}
|
||||
if !cfg.Agent && os.Getenv("BJ_MODE") == "" {
|
||||
cfg.Agent = f.Mode == "agent"
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// saveConfigFile — записать настройки; каталог создаётся при необходимости.
|
||||
func saveConfigFile(dataDir string, cfg Config) error {
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
return fmt.Errorf("каталог данных %s: %v", dataDir, err)
|
||||
}
|
||||
f := fileConfig{Mode: "direct", BJURL: cfg.BJURL, BJKey: cfg.BJKey, Title: cfg.Title}
|
||||
if cfg.Agent {
|
||||
f.Mode, f.BJURL = "agent", ""
|
||||
}
|
||||
raw, _ := json.MarshalIndent(f, "", " ")
|
||||
tmp := filepath.Join(dataDir, configFileName+".tmp")
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return fmt.Errorf("запись настроек: %v", err)
|
||||
}
|
||||
return os.Rename(tmp, filepath.Join(dataDir, configFileName))
|
||||
}
|
||||
|
||||
// SetDataDir — где хранить настройки (вызывается из main до Handler).
|
||||
func (s *Server) SetDataDir(dir string) {
|
||||
s.mu.Lock()
|
||||
s.dataDir = dir
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) dataDirPath() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.dataDir == "" {
|
||||
return DefaultDataDir()
|
||||
}
|
||||
return s.dataDir
|
||||
}
|
||||
|
||||
// installCheck — одна строка проверки окружения.
|
||||
type installCheck struct {
|
||||
Name, Note string
|
||||
OK bool
|
||||
Warn bool
|
||||
}
|
||||
|
||||
// installChecks — что проверяем перед установкой.
|
||||
func (s *Server) installChecks(r *http.Request) []installCheck {
|
||||
var out []installCheck
|
||||
dir := s.dataDirPath()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
out = append(out, installCheck{Name: "Каталог данных", Note: fmt.Sprintf("%s: нет прав на создание (%v). Дайте программе право писать в свой каталог.", dir, err)})
|
||||
} else if f, err := os.CreateTemp(dir, ".probe-*"); err != nil {
|
||||
out = append(out, installCheck{Name: "Каталог данных", Note: fmt.Sprintf("%s: нельзя записать файл (%v)", dir, err)})
|
||||
} else {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
out = append(out, installCheck{Name: "Каталог данных", Note: dir + " — доступен для записи", OK: true})
|
||||
}
|
||||
if httpsRequest(r) {
|
||||
out = append(out, installCheck{Name: "Защищённое соединение", Note: "кабинет открыт по https", OK: true})
|
||||
} else if localRequest(r) {
|
||||
out = append(out, installCheck{Name: "Защищённое соединение", Note: "открыто с этого же сервера — для проверки достаточно; клиентам кабинет публикуйте только по https", OK: true, Warn: true})
|
||||
} else {
|
||||
out = append(out, installCheck{Name: "Защищённое соединение", Note: "кабинет открыт по http без TLS. Так публиковать нельзя: пароли клиентов пойдут по сети открытым текстом. Включите сертификат на хостинге и откройте установщик по https.", Warn: true})
|
||||
}
|
||||
out = append(out, installCheck{Name: "Адрес кабинета", Note: r.Host, OK: true})
|
||||
return out
|
||||
}
|
||||
|
||||
// installData — данные для страницы установщика.
|
||||
type installData struct {
|
||||
Step int
|
||||
Checks []installCheck
|
||||
Cfg Config
|
||||
Error string
|
||||
Notice string
|
||||
Host string
|
||||
Waiting bool // режим ожидания: ждём подключения ядра
|
||||
AdminOK bool
|
||||
}
|
||||
|
||||
// registerInstall — страницы установщика.
|
||||
func (s *Server) registerInstall(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/install", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Уже настроено: повторный вход в установщик — только удалением файла настроек.
|
||||
// Исключение — шаг «администратор», он идёт сразу после сохранения связи.
|
||||
if s.Configured() && !(r.Method == http.MethodPost && r.FormValue("step") == "admin") {
|
||||
s.render(w, r, "installed", map[string]any{"File": filepath.Join(s.dataDirPath(), configFileName)})
|
||||
return
|
||||
}
|
||||
d := installData{Step: 1, Checks: s.installChecks(r), Cfg: s.conf(), Host: r.Host}
|
||||
if r.Method == http.MethodGet {
|
||||
s.renderInstall(w, r, d)
|
||||
return
|
||||
}
|
||||
if !csrfOK(r) {
|
||||
d.Error = "Форма устарела, обновите страницу."
|
||||
s.renderInstall(w, r, d)
|
||||
return
|
||||
}
|
||||
switch r.FormValue("step") {
|
||||
case "connect":
|
||||
s.installConnect(w, r, d)
|
||||
case "admin":
|
||||
s.installAdmin(w, r, d)
|
||||
default:
|
||||
s.renderInstall(w, r, d)
|
||||
}
|
||||
})
|
||||
// Состояние связи для страницы ожидания ядра (опрашивается страницей по meta refresh).
|
||||
mux.HandleFunc("/install/wait", func(w http.ResponseWriter, r *http.Request) {
|
||||
d := installData{Step: 2, Cfg: s.conf(), Host: r.Host, Waiting: true}
|
||||
if q := s.agentQueue(); q != nil && q.coreSeen() {
|
||||
d.Step, d.Waiting = 3, false
|
||||
d.Notice = "Ядро подключилось к кабинету. Осталось завести администратора."
|
||||
}
|
||||
s.renderInstall(w, r, d)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) renderInstall(w http.ResponseWriter, r *http.Request, d installData) {
|
||||
s.render(w, r, "install", map[string]any{"D": d})
|
||||
}
|
||||
|
||||
// installConnect — шаг «связь с ядром»: сохранить настройки и проверить связь.
|
||||
func (s *Server) installConnect(w http.ResponseWriter, r *http.Request, d installData) {
|
||||
cfg := d.Cfg
|
||||
cfg.Agent = r.FormValue("mode") == "agent"
|
||||
cfg.BJURL = strings.TrimRight(strings.TrimSpace(r.FormValue("bj_url")), "/")
|
||||
cfg.BJKey = strings.TrimSpace(r.FormValue("bj_key"))
|
||||
cfg.Title = strings.TrimSpace(r.FormValue("title"))
|
||||
d.Cfg = cfg
|
||||
switch {
|
||||
case cfg.BJKey == "":
|
||||
d.Error = "Укажите ключ доступа модуля — он создаётся в настройках ядра, раздел «Личный кабинет»."
|
||||
case !cfg.Agent && cfg.BJURL == "":
|
||||
d.Error = "Укажите адрес ядра или выберите режим «ядро подключается к кабинету»."
|
||||
case !cfg.Agent && !strings.HasPrefix(cfg.BJURL, "https://") && cfg.insecureCore():
|
||||
d.Error = "Адрес ядра должен начинаться с https:// — по этому каналу идут ключ и сессии клиентов."
|
||||
}
|
||||
if d.Error != "" {
|
||||
s.renderInstall(w, r, d)
|
||||
return
|
||||
}
|
||||
if !cfg.Agent {
|
||||
// Прямой режим: проверяем связь до сохранения, чтобы не записать нерабочие настройки.
|
||||
probe := &bjClient{cfg: cfg, hc: &http.Client{Timeout: 15 * time.Second}}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
var out map[string]any
|
||||
if err := probe.call(ctx, http.MethodGet, "/api/lk/ping", "", nil, &out); err != nil {
|
||||
d.Error = "Связь с ядром не установлена: " + err.Error()
|
||||
s.renderInstall(w, r, d)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := saveConfigFile(s.dataDirPath(), cfg); err != nil {
|
||||
d.Error = err.Error()
|
||||
s.renderInstall(w, r, d)
|
||||
return
|
||||
}
|
||||
s.apply(cfg)
|
||||
if cfg.Agent {
|
||||
// Проверить связь можно только когда ядро само подключится: показываем ожидание.
|
||||
http.Redirect(w, r, "/install/wait", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
d.Step, d.Notice = 3, "Связь с ядром установлена, настройки сохранены. Осталось завести администратора кабинета."
|
||||
s.renderInstall(w, r, d)
|
||||
}
|
||||
|
||||
// installAdmin — шаг «администратор кабинета»: учётная запись заводится в ядре.
|
||||
func (s *Server) installAdmin(w http.ResponseWriter, r *http.Request, d installData) {
|
||||
d.Step = 3
|
||||
login := strings.TrimSpace(r.FormValue("login"))
|
||||
pass, pass2 := r.FormValue("password"), r.FormValue("password2")
|
||||
switch {
|
||||
case r.FormValue("skip") != "":
|
||||
s.installDone(w, r)
|
||||
return
|
||||
case login == "" || pass == "":
|
||||
d.Error = "Укажите логин и пароль администратора."
|
||||
case pass != pass2:
|
||||
d.Error = "Пароли не совпадают."
|
||||
}
|
||||
if d.Error != "" {
|
||||
s.renderInstall(w, r, d)
|
||||
return
|
||||
}
|
||||
var out struct{ Message string }
|
||||
err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/admin/bootstrap", "", map[string]string{
|
||||
"login": login, "password": pass, "full_name": strings.TrimSpace(r.FormValue("full_name")),
|
||||
"email": strings.TrimSpace(r.FormValue("email")),
|
||||
}, &out)
|
||||
if err != nil {
|
||||
d.Error = err.Error()
|
||||
s.renderInstall(w, r, d)
|
||||
return
|
||||
}
|
||||
s.installDone(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) installDone(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, r, "install_done", map[string]any{"File": filepath.Join(s.dataDirPath(), configFileName)})
|
||||
}
|
||||
Reference in New Issue
Block a user