Files
Bridge-and-Join-s/modules/lk/install.go
T

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)})
}