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

- установщик в самом кабинете: до настройки любая страница ведёт в /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
+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"