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