Кабинет: веб-установщик и управление пользователями в ядре

- установщик в самом кабинете: до настройки любая страница ведёт в /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:
zuevav
2026-09-18 09:30:47 +03:00
parent e1bf7c440f
commit 222d48e740
14 changed files with 977 additions and 105 deletions
+20 -3
View File
@@ -19,6 +19,7 @@ import (
"errors"
"net/http"
"sync"
"sync/atomic"
"time"
)
@@ -50,6 +51,15 @@ type agentQueue struct {
waiting map[string]*agentTask
signal chan struct{}
seq uint64
// lastFetch — когда ядро в последний раз забирало запросы: по этому установщик
// понимает, что ядро дозвонилось до кабинета.
lastFetch atomic.Int64
}
// coreSeen — ядро подключалось за последнюю минуту.
func (q *agentQueue) coreSeen() bool {
t := q.lastFetch.Load()
return t > 0 && time.Since(time.Unix(t, 0)) < time.Minute
}
func newAgentQueue() *agentQueue {
@@ -142,8 +152,13 @@ func idFromSeq(n uint64) string {
// registerAgent — точки, куда подключается ядро (только в режиме ожидания).
func (s *Server) registerAgent(mux *http.ServeMux) {
// В прямом режиме этих точек как бы нет: очередь не создана — отвечаем 404.
keyOK := func(w http.ResponseWriter, r *http.Request) bool {
if !secureEqual(r.Header.Get("X-BJ-Key"), s.cfg.BJKey) {
if s.agentQueue() == nil {
http.NotFound(w, r)
return false
}
if !secureEqual(r.Header.Get("X-BJ-Key"), s.conf().BJKey) {
http.Error(w, "неверный ключ модуля", http.StatusForbidden)
return false
}
@@ -155,7 +170,9 @@ func (s *Server) registerAgent(mux *http.ServeMux) {
return
}
wait := 25 * time.Second
t := s.agent.next(r.Context(), wait)
q := s.agentQueue()
q.lastFetch.Store(time.Now().Unix())
t := q.next(r.Context(), wait)
if t == nil {
w.WriteHeader(http.StatusNoContent)
return
@@ -182,7 +199,7 @@ func (s *Server) registerAgent(mux *http.ServeMux) {
return
}
body, _ := base64.StdEncoding.DecodeString(in.Body)
if !s.agent.reply(in.ID, agentResult{Status: in.Status, Body: body}) {
if !s.agentQueue().reply(in.ID, agentResult{Status: in.Status, Body: body}) {
w.WriteHeader(http.StatusGone) // запрос уже не ждут: клиент ушёл или истёк срок
return
}
+278
View File
@@ -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)})
}
+94 -65
View File
@@ -17,6 +17,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
)
@@ -35,12 +36,15 @@ type Config struct {
Agent bool
}
// Server — кабинет.
// Server — кабинет. Настройки живут за мьютексом: веб-установщик меняет их на ходу,
// без перезапуска службы.
type Server struct {
cfg Config
bj *bjClient
tpl *template.Template
agent *agentQueue
mu sync.RWMutex
cfg Config
bjc *bjClient
agent *agentQueue
dataDir string // где лежит lk.json (веб-установщик)
tpl *template.Template
}
// New — кабинет с разобранными шаблонами.
@@ -101,32 +105,70 @@ func New(cfg Config) (*Server, error) {
if err != nil {
return nil, err
}
srv := &Server{cfg: cfg, tpl: tpl}
if cfg.Agent {
srv.agent = newAgentQueue()
}
srv.bj = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: srv.agent}
srv := &Server{tpl: tpl}
srv.apply(cfg)
return srv, nil
}
// apply — применить настройки: пересобрать клиента к ядру и очередь режима ожидания.
func (s *Server) apply(cfg Config) {
cfg.BJURL = strings.TrimRight(strings.TrimSpace(cfg.BJURL), "/")
cfg.BJKey = strings.TrimSpace(cfg.BJKey)
if cfg.Title == "" {
cfg.Title = "Личный кабинет"
}
s.mu.Lock()
defer s.mu.Unlock()
s.cfg = cfg
s.agent = nil
if cfg.Agent {
s.agent = newAgentQueue()
}
s.bjc = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: s.agent}
}
// conf — текущие настройки (копия).
func (s *Server) conf() Config {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg
}
// bj — клиент к ядру по текущим настройкам.
func (s *Server) bj() *bjClient {
s.mu.RLock()
defer s.mu.RUnlock()
return s.bjc
}
func (s *Server) agentQueue() *agentQueue {
s.mu.RLock()
defer s.mu.RUnlock()
return s.agent
}
// Configured — кабинету есть с чем работать: ключ модуля и, в прямом режиме, адрес ядра.
func (s *Server) Configured() bool {
if s.cfg.BJKey == "" {
func (s *Server) Configured() bool { return s.conf().configured() }
func (c Config) configured() bool {
if c.BJKey == "" {
return false
}
if s.cfg.Agent {
if c.Agent {
return true // адрес ядра не нужен: ядро подключается само
}
return s.cfg.BJURL != ""
return c.BJURL != ""
}
// InsecureCore — ядро задано по открытому http не на этой же машине: ключ модуля и
// токены сессий клиентов пойдут по сети в открытом виде.
func (s *Server) InsecureCore() bool {
if s.cfg.Agent || s.cfg.BJURL == "" || strings.HasPrefix(s.cfg.BJURL, "https://") {
func (s *Server) InsecureCore() bool { return s.conf().insecureCore() }
func (c Config) insecureCore() bool {
if c.Agent || c.BJURL == "" || strings.HasPrefix(c.BJURL, "https://") {
return false
}
rest := strings.TrimPrefix(s.cfg.BJURL, "http://")
rest := strings.TrimPrefix(c.BJURL, "http://")
return !strings.HasPrefix(rest, "127.0.0.1") && !strings.HasPrefix(rest, "localhost")
}
@@ -370,7 +412,7 @@ type securityCard struct {
// portfolio — реестр клиента из ядра.
func (s *Server) portfolio(r *http.Request, sess string) (portfolioResp, error) {
var out portfolioResp
err := s.bj.call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out)
err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out)
return out, err
}
@@ -381,7 +423,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat
if data == nil {
data = map[string]any{}
}
data["Title"] = s.cfg.Title
data["Title"] = s.conf().Title
if _, ok := data["Nav"]; !ok {
data["Nav"] = ""
}
@@ -467,13 +509,11 @@ func session(r *http.Request) string {
return c.Value
}
// Handler — все страницы кабинета. Без адреса bj-server или ключа — страница с подсказкой настройки.
// Handler — все страницы кабинета. Пока кабинет не настроен, любая страница ведёт
// в веб-установщик (/install), как у обычных веб-приложений после копирования файлов.
func (s *Server) Handler() http.Handler {
if !s.Configured() {
return harden(http.HandlerFunc(s.notConfigured))
}
bj := s.bj
mux := http.NewServeMux()
s.registerInstall(mux)
// Вход
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
@@ -491,7 +531,7 @@ func (s *Server) Handler() http.Handler {
Contract string `json:"contract"`
IsAdmin bool `json:"is_admin"`
}
err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/login", "", map[string]string{
err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/login", "", map[string]string{
"login": r.FormValue("login"), "password": r.FormValue("password"),
}, &out)
if err != nil {
@@ -513,9 +553,9 @@ func (s *Server) Handler() http.Handler {
// Выход гасит и клиентскую сессию, и сохранённую админскую: иначе после
// «выхода» в браузере оставался действующий токен сотрудника.
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
_ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
_ = s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
_ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", c.Value, nil, nil)
_ = s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/logout", c.Value, nil, nil)
}
clearAll(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
@@ -541,7 +581,7 @@ func (s *Server) Handler() http.Handler {
var cl struct {
Claims []claimRow `json:"claims"`
}
_ = bj.call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &cl)
_ = s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &cl)
active, _ := claimsView(cl.Claims)
pos := pf.Positions
if len(pos) > 5 {
@@ -600,7 +640,7 @@ func (s *Server) Handler() http.Handler {
var out struct {
Security securityCard `json:"security"`
}
if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/security?isin="+url.QueryEscape(isin), sess, nil, &out); err != nil {
if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/security?isin="+url.QueryEscape(isin), sess, nil, &out); err != nil {
s.render(w, r, "security", map[string]any{"Nav": "p", "Error": err.Error(), "ISIN": isin})
return
}
@@ -625,7 +665,7 @@ func (s *Server) Handler() http.Handler {
Operations []operationRow `json:"operations"`
}
path := "/api/lk/operations?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
s.render(w, r, "operations", map[string]any{"Nav": "o", "Error": err.Error(), "From": from, "To": to})
return
}
@@ -662,14 +702,14 @@ func (s *Server) Handler() http.Handler {
Tax float64 `json:"tax"`
}
path := "/api/lk/income?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
s.render(w, r, "income", map[string]any{"Nav": "i", "Error": err.Error(), "From": from, "To": to})
return
}
var ca struct {
Actions []corpAction `json:"actions"`
}
_ = bj.call(reqCtx(r), http.MethodGet, "/api/lk/corpactions", sess, nil, &ca)
_ = s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/corpactions", sess, nil, &ca)
acts := ca.Actions
if len(acts) > 8 {
acts = acts[:8]
@@ -690,7 +730,7 @@ func (s *Server) Handler() http.Handler {
var res struct {
Message string `json:"message"`
}
err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/documents", sess, map[string]string{
err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/documents", sess, map[string]string{
"kind": r.FormValue("kind"),
"from": r.FormValue("from"),
"to": r.FormValue("to"),
@@ -711,7 +751,7 @@ func (s *Server) Handler() http.Handler {
Requests []documentRow `json:"requests"`
Kinds []string `json:"kinds"`
}
if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/documents", sess, nil, &out); err != nil {
if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/documents", sess, nil, &out); err != nil {
s.render(w, r, "documents", map[string]any{"Nav": "d", "Error": err.Error()})
return
}
@@ -761,7 +801,7 @@ func (s *Server) Handler() http.Handler {
Positions []position `json:"positions"`
FullName string `json:"full_name"`
}
if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil {
if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil {
s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": err.Error(), "Today": time.Now().Format("02.01.2006")})
return
}
@@ -790,7 +830,7 @@ func (s *Server) Handler() http.Handler {
Message string `json:"message"`
GUID string `json:"guid"`
}
err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/transfer", sess, map[string]any{
err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/transfer", sess, map[string]any{
"receiving_inn": strings.TrimSpace(r.FormValue("receiving_inn")),
"receiving_name": strings.TrimSpace(r.FormValue("receiving_name")),
"account_at_recv": strings.TrimSpace(r.FormValue("account_at_recv")),
@@ -818,7 +858,7 @@ func (s *Server) Handler() http.Handler {
var out struct {
Claims []claimRow `json:"claims"`
}
if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &out); err != nil {
if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &out); err != nil {
s.render(w, r, "claims", map[string]any{"Nav": "c", "Error": err.Error()})
return
}
@@ -855,7 +895,7 @@ func (s *Server) Handler() http.Handler {
if search != "" {
path += "?search=" + url.QueryEscape(search)
}
if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Error": err.Error()})
return
}
@@ -884,7 +924,7 @@ func (s *Server) Handler() http.Handler {
Session string `json:"session"`
AsClient string `json:"as_client"`
}
if err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/admin/impersonate", sess,
if err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/admin/impersonate", sess,
map[string]string{"login": login, "contract": contract}, &out); err != nil {
setFlashCookie(w, r, err.Error())
http.Redirect(w, r, "/clients", http.StatusSeeOther)
@@ -902,7 +942,7 @@ func (s *Server) Handler() http.Handler {
// Сессию просмотра гасим в ядре, а не бросаем: иначе выданные сотруднику
// токены чужих кабинетов копились бы и жили ещё 12 часов.
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
_ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
_ = s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
setSession(w, r, c.Value)
}
setFlag(w, r, "bj_lk_as", "")
@@ -914,51 +954,40 @@ func (s *Server) Handler() http.Handler {
// Проверка связи с ядром. Наружу отдаём только признак жизни: подробности
// (внутренний адрес ядра, состояние базы) — администратору по ключу модуля.
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
full := secureEqual(r.Header.Get("X-BJ-Key"), s.cfg.BJKey)
cfg := s.conf()
full := secureEqual(r.Header.Get("X-BJ-Key"), cfg.BJKey)
var out map[string]any
err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/ping", "", nil, &out)
err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/ping", "", nil, &out)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err != nil {
w.WriteHeader(http.StatusBadGateway)
resp := map[string]any{"ok": false}
if full {
resp["error"], resp["bj_url"], resp["mode"] = err.Error(), s.cfg.BJURL, s.mode()
resp["error"], resp["bj_url"], resp["mode"] = err.Error(), cfg.BJURL, s.mode()
}
_ = json.NewEncoder(w).Encode(resp)
return
}
resp := map[string]any{"ok": true}
if full {
resp["bj"], resp["bj_url"], resp["mode"] = out, s.cfg.BJURL, s.mode()
resp["bj"], resp["bj_url"], resp["mode"] = out, cfg.BJURL, s.mode()
}
_ = json.NewEncoder(w).Encode(resp)
})
if s.agent != nil {
s.registerAgent(mux)
}
s.registerAgent(mux)
mux.Handle("/assets/", assetsHandler())
return harden(mux)
}
// notConfigured — кабинет запущен без адреса bj-server или ключа: подсказка вместо падения службы.
func (s *Server) notConfigured(w http.ResponseWriter, r *http.Request) {
const msg = "Личный кабинет не настроен: укажите адрес bj-server (BJ_URL) и ключ доступа модуля (BJ_KEY) в /etc/bj/lk.env, затем выполните systemctl restart bj-lk. Ключ задаётся в настройках bj-server."
if r.URL.Path == "/health" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": msg})
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head>`+
`<body style="font-family:system-ui,sans-serif;max-width:640px;margin:48px auto;padding:0 16px;line-height:1.5"><h1>%s</h1><p>%s</p></body></html>`,
template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(msg))
return harden(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.Configured() && !strings.HasPrefix(r.URL.Path, "/install") && !strings.HasPrefix(r.URL.Path, "/assets/") && r.URL.Path != "/health" {
http.Redirect(w, r, "/install", http.StatusSeeOther)
return
}
mux.ServeHTTP(w, r)
}))
}
// mode — режим связи с ядром, для страницы состояния и инструкций администратору.
func (s *Server) mode() string {
if s.cfg.Agent {
if s.conf().Agent {
return "agent"
}
return "direct"
+98 -5
View File
@@ -7,6 +7,8 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -81,6 +83,19 @@ func fakeBJ(t *testing.T) *httptest.Server {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
})
mux.HandleFunc("/api/lk/admin/bootstrap", func(w http.ResponseWriter, r *http.Request) {
if !keyOK(w, r) {
return
}
var in struct{ Login, Password string }
_ = json.NewDecoder(r.Body).Decode(&in)
if in.Login == "" || len(in.Password) < 10 {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "пароль минимум 10 символов"})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "message": "администратор создан"})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
@@ -155,20 +170,94 @@ func TestAdminFlagPerRequest(t *testing.T) {
}
}
// Кабинет без настроек ведёт в веб-установщик, а /health честно отвечает, что связи нет.
func TestNotConfigured(t *testing.T) {
s, err := New(Config{BJURL: "http://127.0.0.1:8080"})
if err != nil {
t.Fatal(err)
}
s.SetDataDir(t.TempDir())
if s.Configured() {
t.Fatal("без ключа кабинет не настроен")
}
for _, p := range []string{"/", "/health"} {
h := s.Handler()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/install" {
t.Fatalf("/: код %d, Location %q — ожидали установщик", rec.Code, rec.Header().Get("Location"))
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/install", nil))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Проверить связь и сохранить") {
t.Fatalf("/install: код %d", rec.Code)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
if rec.Code != http.StatusBadGateway {
t.Fatalf("/health без настроек: код %d", rec.Code)
}
}
// Веб-установщик целиком: проверка сервера → связь с ядром → администратор → файл настроек.
func TestInstaller(t *testing.T) {
bj := fakeBJ(t)
dir := t.TempDir()
s, err := New(Config{})
if err != nil {
t.Fatal(err)
}
s.SetDataDir(dir)
h := s.Handler()
csrf := csrfFromPage(t, h)
post := func(form url.Values) *httptest.ResponseRecorder {
form.Set("csrf", csrf.Value)
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(csrf)
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil))
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "BJ_KEY") {
t.Fatalf("%s: код %d: %s", p, rec.Code, rec.Body.String())
}
h.ServeHTTP(rec, req)
return rec
}
// неверный ключ — настройки не сохраняются
rec := post(url.Values{"step": {"connect"}, "mode": {"direct"}, "bj_url": {bj.URL}, "bj_key": {"чужой"}})
if !strings.Contains(rec.Body.String(), "Связь с ядром не установлена") || s.Configured() {
t.Fatalf("неверный ключ принят: %d", rec.Code)
}
// верный ключ — сохранено, шаг администратора
rec = post(url.Values{"step": {"connect"}, "mode": {"direct"}, "bj_url": {bj.URL}, "bj_key": {"lk-key"}, "title": {"Тест"}})
if !strings.Contains(rec.Body.String(), "Администратор кабинета") || !s.Configured() {
t.Fatalf("связь не сохранена: %d %s", rec.Code, rec.Body.String()[:200])
}
raw, err := os.ReadFile(filepath.Join(dir, configFileName))
if err != nil || !strings.Contains(string(raw), `"bj_key": "lk-key"`) || !strings.Contains(string(raw), `"mode": "direct"`) {
t.Fatalf("файл настроек: %v %s", err, raw)
}
if st, _ := os.Stat(filepath.Join(dir, configFileName)); st.Mode().Perm() != 0o600 {
t.Fatalf("права файла настроек %o", st.Mode().Perm())
}
// администратор: пароли не совпали → ошибка; совпали → ядро получило bootstrap
rec = post(url.Values{"step": {"admin"}, "login": {"admin"}, "password": {"Надёжный-пароль-1"}, "password2": {"другой"}})
if !strings.Contains(rec.Body.String(), "Пароли не совпадают") {
t.Fatal("несовпадение паролей пропущено")
}
rec = post(url.Values{"step": {"admin"}, "login": {"admin"}, "password": {"Надёжный-пароль-1"}, "password2": {"Надёжный-пароль-1"}})
if !strings.Contains(rec.Body.String(), "Кабинет установлен") {
t.Fatalf("завершение: %d %s", rec.Code, rec.Body.String()[:200])
}
// установщик закрыт, настройки поднимаются из файла при новом запуске
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/install", nil))
if !strings.Contains(rec.Body.String(), "уже настроен") {
t.Fatal("установщик остался открытым после установки")
}
cfg, err := LoadConfigFile(dir, Config{})
if err != nil || cfg.BJKey != "lk-key" || cfg.BJURL != bj.URL || cfg.Agent || cfg.Title != "Тест" {
t.Fatalf("настройки из файла: %+v %v", cfg, err)
}
// окружение перекрывает файл
cfg, _ = LoadConfigFile(dir, Config{BJKey: "из-окружения"})
if cfg.BJKey != "из-окружения" {
t.Fatal("окружение должно перекрывать файл")
}
}
@@ -254,6 +343,10 @@ func csrfFromPage(t *testing.T, h http.Handler) *http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
if loc := rec.Header().Get("Location"); rec.Code == http.StatusSeeOther && loc != "" {
rec = httptest.NewRecorder() // кабинет не настроен: токен выдаёт установщик
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, loc, nil))
}
for _, c := range rec.Result().Cookies() {
if c.Name == csrfCookie {
return c
+82
View File
@@ -303,6 +303,88 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
{{else}}<p class="empty">Поручений пока нет.</p>{{end}}
</main></body></html>{{end}}
{{define "install"}}{{template "head" .}}{{$d := .D}}
<div class="login">
<section class="side">
<span class="brand">{{template "mark" .}}<span class="lbl" style="font-size:12px;letter-spacing:.14em;color:#f3f0e7">Установка кабинета</span></span>
<div>
<h1>Три шага — и кабинет работает</h1>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:22px;font-size:14.5px">
<span style="display:flex;gap:12px"><span class="n" style="color:{{if ge $d.Step 1}}#e8a33d{{else}}#8e8879{{end}}">01</span><span>Проверка сервера{{if gt $d.Step 1}} — готово{{end}}</span></span>
<span style="display:flex;gap:12px"><span class="n" style="color:{{if ge $d.Step 2}}#e8a33d{{else}}#8e8879{{end}}">02</span><span>Связь с ядром депозитария{{if gt $d.Step 2}} — готово{{end}}</span></span>
<span style="display:flex;gap:12px"><span class="n" style="color:{{if ge $d.Step 3}}#e8a33d{{else}}#8e8879{{end}}">03</span><span>Администратор кабинета</span></span>
</div>
</div>
<p class="mut" style="color:#8e8879;margin:0">Кабинет ничего не хранит у себя: пользователи, пароли и данные клиентов живут в ядре. Здесь сохраняется только способ связи с ним.</p>
</section>
<section class="form">
<form method="post" action="/install" style="max-width:520px"><input type="hidden" name="csrf" value="{{$.CSRF}}">
{{if $d.Error}}<div class="msg err">{{$d.Error}}</div>{{end}}
{{if $d.Notice}}<div class="msg ok">{{$d.Notice}}</div>{{end}}
{{if lt $d.Step 3}}
<h1 style="margin:0">Сервер и связь с ядром</h1>
<div style="display:grid;gap:8px">
{{range $d.Checks}}<div style="display:flex;gap:10px;align-items:flex-start;font-size:14px"><span style="color:{{if .OK}}{{if .Warn}}#e8a33d{{else}}#3f8f5a{{end}}{{else}}#c0392b{{end}};font-weight:700">{{if .OK}}✓{{else}}✗{{end}}</span><span><b>{{.Name}}.</b> {{.Note}}</span></div>{{end}}
</div>
{{if $d.Waiting}}
<meta http-equiv="refresh" content="5">
<div class="msg ok">Настройки сохранены. Ждём, когда ядро подключится к кабинету по адресу <b>{{$d.Host}}</b>… Страница обновляется сама.</div>
<p class="mut" style="margin:0">В настройках ядра (раздел «Личный кабинет») выберите «Ядро подключается к кабинету» и укажите этот адрес. Ключ должен совпадать.</p>
<input type="hidden" name="step" value="wait">
{{else}}
<input type="hidden" name="step" value="connect">
<div>
<label>Заголовок кабинета</label>
<input name="title" value="{{$d.Cfg.Title}}" placeholder="Депозитарий · личный кабинет">
</div>
<div>
<label>Как связаться с ядром</label>
<label style="display:flex;gap:8px;align-items:flex-start;text-transform:none;letter-spacing:0;font-family:inherit;font-size:14px;color:var(--ink);margin-top:6px"><input type="radio" name="mode" value="direct" {{if not $d.Cfg.Agent}}checked{{end}}> <span><b>Кабинет обращается к ядру</b> — у ядра есть адрес, доступный с этого сервера.</span></label>
<label style="display:flex;gap:8px;align-items:flex-start;text-transform:none;letter-spacing:0;font-family:inherit;font-size:14px;color:var(--ink);margin-top:6px"><input type="radio" name="mode" value="agent" {{if $d.Cfg.Agent}}checked{{end}}> <span><b>Ядро подключается к кабинету</b> — у ядра нет адреса в интернете; оно само дозвонится сюда.</span></label>
</div>
<div>
<label for="bj_url">Адрес ядра <span style="text-transform:none;letter-spacing:0">(только для первого способа)</span></label>
<input id="bj_url" name="bj_url" value="{{$d.Cfg.BJURL}}" placeholder="https://bj.депозитарий.ru" autocomplete="off">
</div>
<div>
<label for="bj_key">Ключ доступа модуля</label>
<input id="bj_key" name="bj_key" type="password" value="{{$d.Cfg.BJKey}}" autocomplete="off" placeholder="lk-…">
<p class="mut" style="margin:6px 0 0">Создаётся в настройках ядра: «Личный кабинет» → «Создать ключ».</p>
</div>
<button class="btn main" type="submit">Проверить связь и сохранить</button>
{{end}}
{{else}}
<h1 style="margin:0">Администратор кабинета</h1>
<p class="mut" style="margin:0">Сотрудник депозитария, который входит в кабинет, видит список клиентов и может открыть кабинет клиента на просмотр. Учётная запись заводится в ядре. Если администратор уже есть — шаг можно пропустить.</p>
<input type="hidden" name="step" value="admin">
<div><label for="login">Логин</label><input id="login" name="login" autocomplete="off"></div>
<div><label for="full_name">Имя сотрудника</label><input id="full_name" name="full_name" autocomplete="off"></div>
<div><label for="email">Электронная почта <span style="text-transform:none;letter-spacing:0">(письма пока не отправляются)</span></label><input id="email" name="email" type="email" autocomplete="off"></div>
<div><label for="password">Пароль <span style="text-transform:none;letter-spacing:0">(от 10 символов, буквы и цифра)</span></label><input id="password" name="password" type="password" autocomplete="new-password"></div>
<div><label for="password2">Пароль ещё раз</label><input id="password2" name="password2" type="password" autocomplete="new-password"></div>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<button class="btn main" type="submit">Создать и завершить</button>
<button class="btn" type="submit" name="skip" value="1">Пропустить</button>
</div>
{{end}}
</form>
</section>
</div>
</body></html>{{end}}
{{define "install_done"}}{{template "head" .}}
<div class="login"><section class="side"><span class="brand">{{template "mark" .}}<span class="lbl" style="font-size:12px;letter-spacing:.14em;color:#f3f0e7">Установка кабинета</span></span><div><h1>Кабинет установлен</h1></div><p class="mut" style="color:#8e8879;margin:0">Настройки — в файле <span class="n">{{.File}}</span>. Чтобы пройти установку заново, удалите его.</p></section>
<section class="form"><form action="/login" method="get" style="max-width:420px"><h1 style="margin:0">Готово</h1><p class="mut" style="margin:0">Клиенты входят по логину и паролю, которые выдаёт депозитарий. Учётные записи заводятся в настройках ядра, раздел «Личный кабинет».</p><button class="btn main" type="submit">Перейти ко входу</button></form></section></div>
</body></html>{{end}}
{{define "installed"}}{{template "head" .}}
<div class="login"><section class="side"><span class="brand">{{template "mark" .}}<span class="lbl" style="font-size:12px;letter-spacing:.14em;color:#f3f0e7">Установка кабинета</span></span><div><h1>Кабинет уже настроен</h1></div></section>
<section class="form"><form action="/login" method="get" style="max-width:420px"><p class="mut" style="margin:0">Установщик закрыт: настройки сохранены в <span class="n">{{.File}}</span>. Чтобы изменить их, отредактируйте файл или удалите его и откройте эту страницу снова.</p><button class="btn main" type="submit">Перейти ко входу</button></form></section></div>
</body></html>{{end}}
{{define "profile"}}{{template "head" .}}{{template "nav" .}}
<section class="board"><div class="board-row">
<div><span class="lbl" style="color:var(--dim)">Профиль</span>