bdaef4dc39
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
291 lines
12 KiB
Go
291 lines
12 KiB
Go
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 — как связываемся с ядром
|
|
Render string `json:"render,omitempty"` // core | local — где собираются страницы
|
|
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"
|
|
}
|
|
if !cfg.Relay && os.Getenv("BJ_RENDER") == "" {
|
|
cfg.Relay = f.Render == "core"
|
|
}
|
|
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", Render: "local", BJURL: cfg.BJURL, BJKey: cfg.BJKey, Title: cfg.Title}
|
|
if cfg.Agent {
|
|
f.Mode, f.BJURL = "agent", ""
|
|
}
|
|
if cfg.Relay {
|
|
f.Render = "core"
|
|
}
|
|
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.conf().Local != nil {
|
|
http.NotFound(w, r) // внутри ядра установщика нет
|
|
return
|
|
}
|
|
// Уже настроено: повторный вход в установщик — только удалением файла настроек.
|
|
// Исключение — шаг «администратор», он идёт сразу после сохранения связи.
|
|
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.Relay = r.FormValue("render") == "core"
|
|
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)})
|
|
}
|