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

Данные
- корпоративные действия по бумагам клиента (U_CA_ACTS), готовые выписки (U_OP_INF_EXTRACT),
  заявки клиента на документ (lk.doc_requests) с оповещением сотрудникам; страница «Документы»
- дистрибутив кабинета из настроек ядра: архив с программой, службой, настройками,
  установщиком и инструкцией; ключ доступа на странице показан замаскированным

Связь без публичного адреса у ядра
- режим ожидания (BJ_MODE=agent): кабинет копит запросы, ядро само подключается к нему,
  выполняет их обработчиком /api/lk/* и возвращает ответы; 4 параллельных подключения,
  тот же ключ модуля в обе стороны, состояние канала в настройках
- сквозной тест: настоящий кабинет + настоящий цикл ядра

Защита (по итогам аудита)
- вход: лимит попыток по адресу и логину, одинаковое время ответа для несуществующего логина,
  пароль от 10 символов
- сессии ядра: на диске отпечаток токена и ссылка на учётную запись, без персональных данных;
  карточка клиента читается из БД на каждый запрос (блокировка действует сразу); абсолютный
  срок 24 ч; привязка к браузеру, к сети — по настройке
- вход сотрудника в кабинет клиента пишется в lk.audit и поднимает оповещение
- кабинет: CSRF на всех формах, /impersonate только POST, куки HttpOnly+Secure+SameSite=Strict,
  выход гасит все сессии, заголовки CSP/X-Frame-Options/nosniff/HSTS, no-store, таймауты сервера,
  ограничение тела, recover; сообщения через куку вместо адреса; /health наружу без подробностей
- шрифты вшиты в программу: страница не обращается к сторонним сайтам
- экранирование LIKE в выборках по номеру договора; ключ и токен без суррогата при отказе rand
- исправлено: ошибка входа не показывалась клиенту (шаблон ждал Error, приходил Flash)

Тесты: lksecurity_test, lkagent_test, lkdist_test, modules/lk (CSRF, заголовки, режим ожидания),
живые lkcoverage/lkunpriced, scripts/lk-sec-smoke.sh — проверка работающего кабинета снаружи.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-17 23:47:10 +03:00
parent 4b18579956
commit 2a7442c935
33 changed files with 3062 additions and 209 deletions
+10 -2
View File
@@ -48,7 +48,7 @@ func depoContractExists(ctx context.Context, s FancyMSSQLSettings, contract stri
var full string
err = conn.QueryRowContext(cctx,
"SELECT TOP 1 PORT_NUM FROM OD_CLIENTS_PORTS WHERE PORT_NUM=@p1 OR PORT_NUM LIKE @p2",
contract, contract+" %").Scan(&full)
contract, likeEscape(contract)+" %").Scan(&full)
if err != nil {
return "", false
}
@@ -75,7 +75,7 @@ func depoClientPositions(ctx context.Context, s FancyMSSQLSettings, contract str
WHERE (p.PORT_NUM = @p1 OR p.PORT_NUM LIKE @p2)
AND CAST(rst.K AS float) <> 0
ORDER BY v.NAME`
rows, err := conn.QueryContext(cctx, q, contract, contract+" %")
rows, err := conn.QueryContext(cctx, q, contract, likeEscape(contract)+" %")
if err != nil {
return nil, err
}
@@ -218,3 +218,11 @@ func depoFindContracts(ctx context.Context, s FancyMSSQLSettings, like string, l
}
return out, rows.Err()
}
// likeEscape — экранирование шаблона LIKE. Номер договора приходит от клиента, и
// знаки % _ [ в нём должны означать сами себя, а не подстановку: иначе договор
// вида «БР-1%» совпал бы с чужими счетами.
func likeEscape(v string) string {
r := strings.NewReplacer("[", "[[]", "%", "[%]", "_", "[_]")
return r.Replace(v)
}
+254
View File
@@ -0,0 +1,254 @@
package lkgateway
// lkagent.go — подключение ядра к кабинету, когда у ядра нет адреса в интернете.
//
// Обычный порядок: кабинет на хостинге сам обращается к bj-server. Но ядро депозитария
// часто стоит во внутренней сети без публичного имени, и «достучаться» до него снаружи
// нельзя. Тогда связь разворачивается: ядро само подключается к кабинету по его
// доменному имени, забирает накопившиеся запросы клиентов, выполняет их у себя и
// возвращает ответы. Наружу из сети депозитария идёт только исходящее соединение по
// TLS — открывать порты и пробрасывать адрес не нужно.
//
// Запрос выполняется тем же обработчиком /api/lk/*, что и в прямом режиме: ядро просто
// прогоняет его через свой mux, как если бы кабинет пришёл по сети.
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
)
// lkAgentState — состояние канала для страницы настроек.
type lkAgentStateT struct {
Connected atomic.Bool
LastOK atomic.Int64 // unix-время последнего успешного обращения
LastErr atomic.Value // string
Served atomic.Uint64 // сколько запросов кабинета выполнено
}
var lkAgentState lkAgentStateT
func (a *lkAgentStateT) fail(err error) {
a.Connected.Store(false)
a.LastErr.Store(err.Error())
}
func (a *lkAgentStateT) ok() {
a.Connected.Store(true)
a.LastOK.Store(time.Now().Unix())
a.LastErr.Store("")
}
// LKAgentStatus — что показать администратору в настройках.
type LKAgentStatus struct {
Enabled bool `json:"enabled"`
Cabinet string `json:"cabinet"`
Connected bool `json:"connected"`
LastOK string `json:"last_ok,omitempty"`
LastErr string `json:"last_error,omitempty"`
Served uint64 `json:"served"`
}
func lkAgentStatus(s Settings) LKAgentStatus {
st := LKAgentStatus{
Enabled: s.LK.AgentMode, Cabinet: strings.TrimSpace(s.LK.CabinetURL),
Connected: lkAgentState.Connected.Load(), Served: lkAgentState.Served.Load(),
}
if v := lkAgentState.LastOK.Load(); v > 0 {
st.LastOK = time.Unix(v, 0).Format("02.01.2006 15:04:05")
}
if v, _ := lkAgentState.LastErr.Load().(string); v != "" {
st.LastErr = v
}
return st
}
// lkAgentLoop — вечный цикл: подключиться к кабинету, забрать запрос, выполнить, ответить.
// Запускается всегда, но работает только когда режим ожидания включён в настройках:
// так администратор может включить и выключить его без перезапуска службы.
func (s *Server) lkAgentLoop(ctx context.Context) {
// Несколько подключений сразу: запрос к учётной системе может идти десятки секунд,
// и один медленный клиент не должен задерживать остальных.
var wg sync.WaitGroup
for i := 0; i < lkAgentWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s.lkAgentWorker(ctx)
}()
}
wg.Wait()
}
// lkAgentWorkers — сколько запросов кабинета ядро обслуживает одновременно.
const lkAgentWorkers = 4
func (s *Server) lkAgentWorker(ctx context.Context) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("кабинет: сбой при обслуживании запроса: %v", rec)
}
}()
client := &http.Client{Timeout: 40 * time.Second}
for {
if ctx.Err() != nil {
return
}
cfg := s.rc.Snapshot().LK
cabinet := strings.TrimRight(strings.TrimSpace(cfg.CabinetURL), "/")
if !cfg.AgentMode || cabinet == "" || strings.TrimSpace(cfg.APIKey) == "" {
lkAgentState.Connected.Store(false)
if !sleepCtx(ctx, 15*time.Second) {
return
}
continue
}
task, err := lkAgentFetch(ctx, client, cabinet, cfg.APIKey)
if err != nil {
lkAgentState.fail(err)
log.Printf("кабинет: нет связи с %s: %v", cabinet, err)
if !sleepCtx(ctx, 20*time.Second) { // не долбим хостинг при аварии
return
}
continue
}
lkAgentState.ok()
if task == nil {
continue // запросов не было, кабинет отпустил соединение — подключаемся заново
}
status, body := s.lkAgentServe(ctx, task)
if err := lkAgentReply(ctx, client, cabinet, cfg.APIKey, task.ID, status, body); err != nil {
log.Printf("кабинет: ответ на запрос %s не доставлен: %v", task.ID, err)
continue
}
lkAgentState.Served.Add(1)
}
}
// lkAgentTask — запрос, ожидающий в кабинете.
type lkAgentTask struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Session string `json:"session"`
Body string `json:"body"` // base64
// Адрес и браузер посетителя кабинета — для привязки сессии и лимита попыток входа.
ClientIP string `json:"client_ip"`
ClientAgent string `json:"client_agent"`
}
// lkAgentFetch — забрать очередной запрос. nil без ошибки — запросов не было.
func lkAgentFetch(ctx context.Context, c *http.Client, cabinet, key string) (*lkAgentTask, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cabinet+"/agent/tasks", nil)
if err != nil {
return nil, err
}
req.Header.Set("X-BJ-Key", key)
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusNoContent:
return nil, nil
case http.StatusOK:
var t lkAgentTask
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&t); err != nil {
return nil, err
}
if t.ID == "" || !strings.HasPrefix(t.Path, "/api/lk/") {
return nil, fmt.Errorf("кабинет прислал неизвестный запрос")
}
return &t, nil
case http.StatusForbidden:
return nil, fmt.Errorf("кабинет не принял ключ модуля: сверьте BJ_KEY в /etc/bj-lk/lk.env")
default:
return nil, fmt.Errorf("кабинет ответил %d", resp.StatusCode)
}
}
// lkAgentServe — выполнить запрос кабинета у себя: тот же обработчик /api/lk/*.
func (s *Server) lkAgentServe(ctx context.Context, t *lkAgentTask) (int, []byte) {
body, _ := base64.StdEncoding.DecodeString(t.Body)
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(cctx, t.Method, t.Path, bytes.NewReader(body))
if err != nil {
return http.StatusBadRequest, []byte(`{"error":"некорректный запрос кабинета"}`)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-BJ-Key", s.rc.Snapshot().LK.APIKey) // запрос пришёл по ключу, он же идёт дальше
if t.Session != "" {
req.Header.Set("X-BJ-Session", t.Session)
}
if t.ClientIP != "" {
req.Header.Set("X-BJ-Client-IP", t.ClientIP)
}
if t.ClientAgent != "" {
req.Header.Set("X-BJ-Client-Agent", t.ClientAgent)
}
req.RemoteAddr = "127.0.0.1:0" // обращение пришло по нашему исходящему каналу
rec := &lkAgentRecorder{status: http.StatusOK}
s.mux.ServeHTTP(rec, req)
return rec.status, rec.buf.Bytes()
}
// lkAgentRecorder — приёмник ответа обработчика: пишем в память, а не в сеть.
type lkAgentRecorder struct {
status int
buf bytes.Buffer
hdr http.Header
}
func (w *lkAgentRecorder) Header() http.Header {
if w.hdr == nil {
w.hdr = http.Header{}
}
return w.hdr
}
func (w *lkAgentRecorder) Write(b []byte) (int, error) { return w.buf.Write(b) }
func (w *lkAgentRecorder) WriteHeader(code int) { w.status = code }
// lkAgentReply — вернуть кабинету ответ на его запрос.
func lkAgentReply(ctx context.Context, c *http.Client, cabinet, key, id string, status int, body []byte) error {
payload, _ := json.Marshal(map[string]any{"id": id, "status": status,
"body": base64.StdEncoding.EncodeToString(body)})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cabinet+"/agent/result", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("X-BJ-Key", key)
req.Header.Set("Content-Type", "application/json")
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusGone {
return fmt.Errorf("кабинет ответил %d", resp.StatusCode)
}
return nil
}
// sleepCtx — пауза, прерываемая остановкой службы.
func sleepCtx(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
+90
View File
@@ -0,0 +1,90 @@
package lkgateway
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/lk"
)
// Режим ожидания целиком: настоящий кабинет (modules/lk) в режиме agent и настоящий цикл
// ядра. Кабинет ставит запрос в очередь, ядро своим исходящим подключением забирает его,
// выполняет обработчиком /api/lk/* и возвращает ответ.
func TestLKAgentEndToEnd(t *testing.T) {
const key = "lk-agent-test-key"
cabinet, err := lk.New(lk.Config{BJKey: key, Agent: true})
if err != nil {
t.Fatal(err)
}
cab := httptest.NewServer(cabinet.Handler())
defer cab.Close()
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
if err != nil {
t.Fatal(err)
}
lkCfg := rc.Snapshot().LK
lkCfg.APIKey, lkCfg.AgentMode, lkCfg.CabinetURL = key, true, cab.URL
if err := rc.UpdateLK(lkCfg); err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
registerLKAPI(mux, rc, nil)
core := &Server{rc: rc, mux: mux}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go core.lkAgentLoop(ctx)
// Администратор проверяет связь: кабинет спрашивает ядро через очередь.
req, _ := http.NewRequest(http.MethodGet, cab.URL+"/health", nil)
req.Header.Set("X-BJ-Key", key)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out struct {
OK bool `json:"ok"`
Mode string `json:"mode"`
BJ map[string]any `json:"bj"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK || !out.OK || out.Mode != "agent" || out.BJ["service"] != "bj-server" {
t.Fatalf("ответ ядра через очередь: код %d, %+v", resp.StatusCode, out)
}
if st := lkAgentStatus(rc.Snapshot()); !st.Connected || st.Served == 0 {
t.Fatalf("состояние канала: %+v", st)
}
// Без ключа наружу — только признак жизни, без адресов и подробностей.
resp2, err := client.Get(cab.URL + "/health")
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
var pub map[string]any
_ = json.NewDecoder(resp2.Body).Decode(&pub)
if len(pub) != 1 || pub["ok"] != true {
t.Fatalf("/health без ключа раскрывает лишнее: %+v", pub)
}
}
// Ядро выполняет через канал только запросы к API кабинета — ничего другого.
func TestLKAgentRejectsForeignPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{"id": "1", "method": "GET", "path": "/admin/setup"})
}))
defer srv.Close()
if task, err := lkAgentFetch(context.Background(), srv.Client(), srv.URL, "k"); err == nil || task != nil {
t.Fatalf("чужой путь принят: %+v", task)
}
}
+118 -123
View File
@@ -1,14 +1,12 @@
package lkgateway
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
)
@@ -22,115 +20,10 @@ import (
// GET /api/lk/claims — статусы заявок клиента
// GET /api/lk/ping — проверка связи модуля с bj-server
// Сессии ЛК живут дольше рабочего дня и ПРОДЛЕВАЮТСЯ при каждом обращении
// (скользящий срок), поэтому активного клиента из кабинета не выкидывает.
const lkSessionTTL = 12 * time.Hour
// lkSessionsFile — сессии переживают перезапуск bj-server. Раньше они были
// только в памяти, и любой рестарт (обновление, деплой) выбрасывал всех
// клиентов с «сессия истекла, войдите заново».
const lkSessionsFile = "/var/lib/bj/.bj/lk-sessions.json"
type lkSession struct {
user LKUser
expiresAt time.Time
}
type lkSessions struct {
mu sync.Mutex
data map[string]lkSession
}
func newLKSessions() *lkSessions {
s := &lkSessions{data: map[string]lkSession{}}
s.load()
return s
}
// persisted — формат хранения сессии на диске.
type lkSessionDisk struct {
User LKUser `json:"user"`
ExpiresAt time.Time `json:"expires_at"`
}
// load поднимает непротухшие сессии с диска при старте сервера.
func (s *lkSessions) load() {
raw, err := os.ReadFile(lkSessionsFile)
if err != nil {
return
}
var m map[string]lkSessionDisk
if json.Unmarshal(raw, &m) != nil {
return
}
now := time.Now()
for tok, v := range m {
if now.Before(v.ExpiresAt) {
s.data[tok] = lkSession{user: v.User, expiresAt: v.ExpiresAt}
}
}
}
// save сбрасывает сессии на диск (вызывается под удержанным mu).
func (s *lkSessions) saveLocked() {
m := make(map[string]lkSessionDisk, len(s.data))
for tok, v := range s.data {
m[tok] = lkSessionDisk{User: v.user, ExpiresAt: v.expiresAt}
}
raw, err := json.Marshal(m)
if err != nil {
return
}
tmp := lkSessionsFile + ".tmp"
if os.WriteFile(tmp, raw, 0o600) == nil {
_ = os.Rename(tmp, lkSessionsFile)
}
}
func (s *lkSessions) create(u LKUser) string {
b := make([]byte, 24)
_, _ = rand.Read(b)
tok := hex.EncodeToString(b)
s.mu.Lock()
s.data[tok] = lkSession{user: u, expiresAt: time.Now().Add(lkSessionTTL)}
// подчистим протухшие
for k, v := range s.data {
if time.Now().After(v.expiresAt) {
delete(s.data, k)
}
}
s.saveLocked()
s.mu.Unlock()
return tok
}
func (s *lkSessions) get(tok string) (LKUser, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.data[tok]
if !ok || time.Now().After(v.expiresAt) {
return LKUser{}, false
}
// Скользящий срок: пока клиент работает — сессия не истекает.
// Пишем на диск не чаще раза в 10 минут, чтобы не дёргать файл на каждый запрос.
if until := time.Until(v.expiresAt); until < lkSessionTTL-10*time.Minute {
v.expiresAt = time.Now().Add(lkSessionTTL)
s.data[tok] = v
s.saveLocked()
}
return v.user, true
}
func (s *lkSessions) drop(tok string) {
s.mu.Lock()
delete(s.data, tok)
s.saveLocked()
s.mu.Unlock()
}
// registerLKAPI вешает /api/lk/* — интерфейс для модуля личного кабинета.
func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
sess := newLKSessions()
sess.bindNetwork = func() bool { return rc.Snapshot().LK.BindNetwork }
// проверка ключа модуля
auth := func(w http.ResponseWriter, r *http.Request) bool {
@@ -146,14 +39,21 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
}
return true
}
// текущий клиент по session-токену
// текущий клиент по session-токену. Карточка читается из lk.users на каждом
// запросе (с коротким кэшем), поэтому блокировка учётной записи и снятие прав
// администратора действуют сразу, а не после истечения сессии.
current := func(w http.ResponseWriter, r *http.Request) (LKUser, bool) {
tok := r.Header.Get("X-BJ-Session")
u, ok := sess.get(tok)
v, ok := sess.get(r.Header.Get("X-BJ-Session"), r)
if !ok {
lkJSON(w, http.StatusUnauthorized, map[string]string{"error": "сессия истекла, войдите заново"})
return LKUser{}, false
}
u, err := sess.user(r.Context(), rc, v)
if err != nil {
sess.drop(r.Header.Get("X-BJ-Session"))
lkJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
return LKUser{}, false
}
return u, true
}
@@ -178,17 +78,38 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
return
}
var in struct{ Login, Password string }
if json.NewDecoder(r.Body).Decode(&in) != nil {
if json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&in) != nil {
lkJSON(w, http.StatusBadRequest, map[string]string{"error": "некорректный JSON"})
return
}
u, err := lkAuth(r.Context(), rc, strings.TrimSpace(in.Login), in.Password)
login := strings.TrimSpace(in.Login)
// Защита от перебора: логин клиента — это обычно номер договора, то есть
// пространство перебора маленькое. Считаем неудачи и по адресу, и по логину.
byIP := "lk-ip:" + lkClientAddr(r)
byLogin := "lk-login:" + strings.ToLower(login)
if loginFailCount(byIP) >= 10 || loginFailCount(byLogin) >= 8 {
time.Sleep(700 * time.Millisecond)
lkJSON(w, http.StatusTooManyRequests, map[string]string{"error": "слишком много попыток входа, повторите через 15 минут"})
return
}
u, err := lkAuth(r.Context(), rc, login, in.Password)
if err != nil {
loginFailAdd(byIP)
loginFailAdd(byLogin)
time.Sleep(300 * time.Millisecond) // и неудача, и перебор одинаково медленные
lkJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
return
}
loginFailReset(byIP)
loginFailReset(byLogin)
tok, err := sess.create(lkSession{UserID: u.ID, Login: u.Login, Contract: u.Contract,
IsAdmin: u.IsAdmin, CanTransfer: u.CanTransfer}, r)
if err != nil {
lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
return
}
lkJSON(w, http.StatusOK, map[string]any{
"session": sess.create(*u),
"session": tok,
"full_name": u.FullName,
"contract": u.Contract,
"is_admin": u.IsAdmin,
@@ -338,6 +259,72 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
lkJSON(w, http.StatusOK, map[string]any{"income": rows, "accrued": accrued, "paid": paid, "tax": tax})
})
// Корпоративные действия по бумагам клиента.
mux.HandleFunc("/api/lk/corpactions", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
return
}
u, ok := current(w, r)
if !ok {
return
}
fancy := rc.Snapshot().Fancy
var isins []string
if dp, err := depoClientPositions(r.Context(), fancy, u.Contract); err == nil {
for _, p := range depoPositionsAsClient(dp) {
if p.ISIN != "" {
isins = append(isins, p.ISIN)
}
}
}
acts, err := lkCorpActions(r.Context(), fancy, isins)
if err != nil {
lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить корпоративные действия: " + err.Error()})
return
}
lkJSON(w, http.StatusOK, map[string]any{"actions": acts})
})
// Документы: готовые выписки депозитария и заявки клиента.
mux.HandleFunc("/api/lk/documents", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
return
}
u, ok := current(w, r)
if !ok {
return
}
if r.Method == http.MethodPost {
var in struct {
Kind string `json:"kind"`
From string `json:"from"`
To string `json:"to"`
Account string `json:"account"`
Comment string `json:"comment"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
lkJSON(w, http.StatusBadRequest, map[string]string{"error": "не разобрали заявку"})
return
}
if err := lkDocRequestAdd(r.Context(), rc, u.Contract, u.FullName, in.Kind, in.From, in.To, in.Account, in.Comment); err != nil {
lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
notifyDocRequest(rc, u, in.Kind, in.From, in.To)
lkJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "Заявка принята: депозитарий подготовит документ и он появится в этом разделе."})
return
}
ready, err := lkReadyDocuments(r.Context(), rc.Snapshot().Fancy, u.Contract)
if err != nil {
log.Printf("lk: документы депозитария: %v", err)
}
reqs, err2 := lkDocRequests(r.Context(), rc, u.Contract)
if err2 != nil {
log.Printf("lk: заявки на документы: %v", err2)
}
lkJSON(w, http.StatusOK, map[string]any{"ready": ready, "requests": reqs, "kinds": lkDocKinds})
})
mux.HandleFunc("/api/lk/claims", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
return
@@ -414,12 +401,15 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
if i := strings.Index(full, " "); i > 0 {
name = strings.TrimSpace(full[i:])
}
target := LKUser{
Login: "depo:" + full, Contract: full, FullName: name,
Active: true, ImpersonatedBy: u.Login,
tok, err := sess.create(lkSession{Login: "depo:" + full, Contract: full,
Virtual: true, VirtualName: name, ImpersonatedBy: u.Login}, r)
if err != nil {
lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
return
}
lkAuditImpersonate(r.Context(), rc, u.Login, "depo:"+full, full, lkClientAddr(r))
lkJSON(w, http.StatusOK, map[string]any{
"session": sess.create(target), "full_name": name,
"session": tok, "full_name": name,
"contract": full, "as_client": full, "by_admin": u.Login,
})
return
@@ -431,10 +421,15 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
}
for _, c := range list {
if c.Login == in.Login {
target := c
target.ImpersonatedBy = u.Login // помечаем: вошёл админ
tok, err := sess.create(lkSession{UserID: c.ID, Login: c.Login, Contract: c.Contract,
ImpersonatedBy: u.Login}, r) // помечаем: вошёл сотрудник, только просмотр
if err != nil {
lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
return
}
lkAuditImpersonate(r.Context(), rc, u.Login, c.Login, c.Contract, lkClientAddr(r))
lkJSON(w, http.StatusOK, map[string]any{
"session": sess.create(target), "full_name": c.FullName,
"session": tok, "full_name": c.FullName,
"contract": c.Contract, "as_client": c.Login, "by_admin": u.Login,
})
return
+85
View File
@@ -0,0 +1,85 @@
package lkgateway
// lkaudit.go — журнал входов сотрудника в кабинет клиента.
//
// Сотрудник может открыть кабинет клиента, чтобы помочь ему или подготовить поручение.
// Это доступ к персональным данным, поэтому каждый такой вход записывается: кто, к кому,
// когда и с какого адреса. Запись нужна и для внутреннего контроля, и чтобы ответить на
// вопрос клиента «кто смотрел мои данные».
import (
"context"
"fmt"
"log"
"time"
)
// lkAuditSchema — журнал доступа (создаётся вместе со схемой lk).
const lkAuditSchema = `
CREATE TABLE IF NOT EXISTS lk.audit (
id bigserial PRIMARY KEY,
at timestamptz NOT NULL DEFAULT now(),
event text NOT NULL,
actor text NOT NULL,
target_login text,
target_contract text,
ip text
);
CREATE INDEX IF NOT EXISTS ix_lk_audit_at ON lk.audit (at DESC);
CREATE INDEX IF NOT EXISTS ix_lk_audit_target ON lk.audit (target_contract, at DESC);
`
// lkAuditImpersonate — записать вход сотрудника в кабинет клиента.
func lkAuditImpersonate(ctx context.Context, rc *RuntimeConfig, actor, targetLogin, contract, ip string) {
go func() {
bg, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
db, err := lkDB(rc)
if err != nil {
log.Printf("кабинет: журнал доступа недоступен: %v", err)
return
}
if _, err := db.ExecContext(bg, `INSERT INTO lk.audit (event, actor, target_login, target_contract, ip)
VALUES ('impersonate', $1, $2, $3, $4)`, actor, targetLogin, contract, ip); err != nil {
log.Printf("кабинет: не записан вход в кабинет клиента: %v", err)
}
}()
raiseSystemAlert(rc, "Кабинет: вход сотрудника в кабинет клиента",
fmt.Sprintf("Сотрудник %s открыл кабинет клиента %s (договор %s) с адреса %s. Доступ только на просмотр, запись в журнале lk.audit.",
actor, targetLogin, contract, ip))
}
// lkAuditRecord — строка журнала для страницы настроек.
type lkAuditRecord struct {
At time.Time `json:"at"`
Event string `json:"event"`
Actor string `json:"actor"`
Target string `json:"target"`
Contract string `json:"contract"`
IP string `json:"ip"`
}
// lkAuditList — последние записи журнала доступа.
func lkAuditList(ctx context.Context, rc *RuntimeConfig, limit int) ([]lkAuditRecord, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
db, err := lkDB(rc)
if err != nil {
return nil, err
}
rows, err := db.QueryContext(ctx, `SELECT at, event, actor, COALESCE(target_login,''), COALESCE(target_contract,''), COALESCE(ip,'')
FROM lk.audit ORDER BY at DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []lkAuditRecord
for rows.Next() {
var a lkAuditRecord
if rows.Scan(&a.At, &a.Event, &a.Actor, &a.Target, &a.Contract, &a.IP) == nil {
out = append(out, a)
}
}
return out, rows.Err()
}
+120
View File
@@ -0,0 +1,120 @@
//go:build live
package lkgateway
import (
"context"
"strings"
"testing"
"time"
)
// Покрытие данных кабинета на реальных договорах: что клиент увидит на каждой странице.
// Только чтение. В вывод идут номера договоров без ФИО.
func TestLiveCabinetCoverage(t *testing.T) {
rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json")
if err != nil {
t.Fatal(err)
}
globalRC = rc
fs := rc.Snapshot().Fancy
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
defer cancel()
contracts, err := depoListContracts(ctx, fs, "", 60)
if err != nil {
t.Fatalf("список договоров: %v", err)
}
if len(contracts) == 0 {
t.Fatal("в депозитарии нет договоров с остатками")
}
type agg struct{ clients, withData, rows int }
var positions, priced, ops, income, corp, docs agg
var posTotal, posPriced int
from := time.Now().AddDate(-2, 0, 0).Format("2006-01-02")
for _, c := range contracts {
num := c.Contract
if i := strings.Index(num, " "); i > 0 {
num = num[:i] // без ФИО
}
pos, err := depoClientPositions(ctx, fs, c.Contract)
if err != nil {
t.Errorf("%s: портфель: %v", num, err)
continue
}
positions.clients++
if len(pos) > 0 {
positions.withData++
positions.rows += len(pos)
}
isins := make([]string, 0, len(pos))
for _, p := range pos {
if p.ISIN != "" {
isins = append(isins, p.ISIN)
}
}
px := lkPrices(ctx, rc, isins)
got := 0
for _, i := range isins {
if p, ok := px[strings.ToUpper(i)]; ok && p.Price > 0 {
got++
}
}
posTotal += len(isins)
posPriced += got
priced.clients++
if got > 0 {
priced.withData++
}
o, err := lkOperations(ctx, fs, c.Contract, from, "", 200)
if err != nil {
t.Errorf("%s: операции: %v", num, err)
}
ops.clients++
if len(o) > 0 {
ops.withData++
ops.rows += len(o)
}
inc, _, err := lkIncome(ctx, fs, c.Contract, from, "")
if err != nil {
t.Errorf("%s: доходы: %v", num, err)
}
income.clients++
if len(inc) > 0 {
income.withData++
income.rows += len(inc)
}
ca, err := lkCorpActions(ctx, fs, isins)
if err != nil {
t.Errorf("%s: корпоративные действия: %v", num, err)
}
corp.clients++
if len(ca) > 0 {
corp.withData++
corp.rows += len(ca)
}
d, err := lkReadyDocuments(ctx, fs, c.Contract)
if err != nil {
t.Errorf("%s: документы: %v", num, err)
}
docs.clients++
if len(d) > 0 {
docs.withData++
docs.rows += len(d)
}
}
line := func(name string, a agg) {
t.Logf("%-28s данные есть у %3d из %3d клиентов, строк всего %d", name, a.withData, a.clients, a.rows)
}
t.Logf("Выборка: %d договоров, период операций и доходов — с %s", len(contracts), from)
line("Портфель (остатки)", positions)
line("Оценка по ценам QUIK", priced)
if posTotal > 0 {
t.Logf("%-28s оценено %d из %d позиций (%.0f%%)", " доля позиций с ценой", posPriced, posTotal, 100*float64(posPriced)/float64(posTotal))
}
line("Операции по счёту депо", ops)
line("Доходы (купоны, дивиденды)", income)
line("Корпоративные действия", corp)
line("Готовые выписки", docs)
}
+311
View File
@@ -0,0 +1,311 @@
package lkgateway
// lkdist.go — дистрибутив кабинета клиента для отдельного веб-сервера.
//
// Кабинет (bj-lk) ставится не рядом с ядром, а на веб-сервер депозитария: своей базы
// у него нет, он обращается к ядру по адресу и ключу. Здесь bj собирает архив с
// исполняемым файлом, юнитом systemd, файлом настроек, установщиком и инструкцией —
// администратору остаётся распаковать архив и запустить установку.
import (
"archive/tar"
"compress/gzip"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// lkDistBinary — где лежит исполняемый файл кабинета на этом сервере (переменная — для тестов).
var lkDistBinary = "/opt/bj/bj-lk"
// lkCoreURL — адрес ядра, по которому кабинет будет за ним ходить. Если в настройках
// не задан — подставляем адрес этого сервера, чтобы инструкция не была пустой.
func lkCoreURL(s Settings, r *http.Request) string {
if u := strings.TrimSpace(s.LK.CoreURL); u != "" {
return strings.TrimRight(u, "/")
}
host := strings.TrimSpace(r.Host)
if host == "" {
host = "bj.example.ru"
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + host
}
// lkNewAPIKey — новый ключ доступа модуля. Если источник случайных чисел отказал,
// возвращаем ошибку: предсказуемый ключ хуже, чем его отсутствие.
func lkNewAPIKey() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("нет источника случайных чисел, ключ не создан: %v", err)
}
return "lk-" + hex.EncodeToString(b), nil
}
// maskSecret — показать ключ так, чтобы его нельзя было списать со скриншота,
// но можно было сверить с тем, что стоит у кабинета.
func maskSecret(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if len(v) <= 12 {
return strings.Repeat("•", len(v))
}
return v[:7] + strings.Repeat("•", 12) + v[len(v)-4:]
}
// lkDistFiles — содержимое архива: имя файла → содержимое и права.
func lkDistFiles(s Settings, coreURL string) ([]struct {
Name string
Mode int64
Data []byte
}, error) {
bin, err := os.ReadFile(lkDistBinary)
if err != nil {
return nil, fmt.Errorf("исполняемый файл кабинета не найден (%s): %v", lkDistBinary, err)
}
title := strings.TrimSpace(orgName(s))
if title == "" {
title = "Личный кабинет"
} else {
title += " · личный кабинет"
}
mode, urlLine := "direct", "BJ_URL="+coreURL
if s.LK.AgentMode {
// Режим ожидания: адрес ядра кабинету не нужен и не должен быть задан —
// ядро подключается к кабинету само по имени этого сервера.
mode, urlLine = "agent", "# BJ_URL не нужен: ядро подключается к кабинету само"
}
env := fmt.Sprintf(`# Настройки кабинета клиента (bj-lk). Файл читает служба, права 0600.
# Режим связи с ядром: direct — кабинет обращается к ядру; agent — ядро подключается сюда.
BJ_MODE=%s
# Адрес ядра bj, к которому обращается кабинет (только для режима direct):
%s
# Ключ доступа модуля — тот же, что в настройках ядра, раздел «Личный кабинет».
BJ_KEY=%s
# Адрес и порт, на которых кабинет слушает (за ним обычно стоит nginx с TLS):
LISTEN=127.0.0.1:8099
# Заголовок кабинета:
LK_TITLE=%s
`, mode, urlLine, s.LK.APIKey, title)
unit := `[Unit]
Description=Личный кабинет клиента депозитария (bj-lk)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=bjlk
Group=bjlk
EnvironmentFile=/etc/bj-lk/lk.env
ExecStart=/opt/bj-lk/bj-lk
Restart=on-failure
RestartSec=5
StandardOutput=append:/var/log/bj-lk/bj-lk.log
StandardError=append:/var/log/bj-lk/bj-lk.log
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/log/bj-lk
[Install]
WantedBy=multi-user.target
`
install := `#!/bin/sh
# Установка кабинета клиента на этот сервер. Запускать от root: sudo ./install.sh
set -eu
DIR=$(cd "$(dirname "$0")" && pwd)
id bjlk >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin bjlk
install -d -m 755 /opt/bj-lk
install -d -m 750 -o bjlk -g bjlk /var/log/bj-lk
install -d -m 750 /etc/bj-lk
install -m 755 "$DIR/bj-lk" /opt/bj-lk/bj-lk
if [ -f /etc/bj-lk/lk.env ]; then
echo "Файл /etc/bj-lk/lk.env уже есть — оставляю как есть."
else
install -m 600 "$DIR/lk.env" /etc/bj-lk/lk.env
fi
install -m 644 "$DIR/bj-lk.service" /etc/systemd/system/bj-lk.service
systemctl daemon-reload
systemctl enable --now bj-lk
sleep 2
systemctl --no-pager --full status bj-lk | head -n 12
echo
echo "Проверка: curl -s http://127.0.0.1:8099/health"
`
readme := fmt.Sprintf(`Личный кабинет клиента депозитария (bj-lk)
==========================================
Что это. Кабинет — отдельная программа для веб-сервера депозитария. Своей базы у неё
нет: все данные она запрашивает у ядра bj по защищённому каналу и не хранит дольше
сессии пользователя. Поэтому кабинет можно держать в защищённом сегменте (DMZ), а
учётные данные клиентов оставить внутри.
Что в архиве:
bj-lk — исполняемый файл кабинета (Linux, x86-64)
bj-lk.service — служба systemd
lk.env — настройки: адрес ядра, ключ доступа, порт, заголовок
install.sh — установка одной командой
README.txt — этот файл
Установка:
1. Распакуйте архив на сервере кабинета.
2. sudo ./install.sh
3. Проверьте: curl -s http://127.0.0.1:8099/health — должно вернуться "ok":true.
Настройки (файл /etc/bj-lk/lk.env):
BJ_URL — адрес ядра bj: %s
BJ_KEY — ключ доступа модуля, он же в настройках ядра, раздел «Личный кабинет»
LISTEN — адрес и порт кабинета (по умолчанию 127.0.0.1:8099)
LK_TITLE — заголовок кабинета: %s
После изменения файла: sudo systemctl restart bj-lk
Публикация в интернет. Поставьте перед кабинетом nginx с сертификатом TLS:
server {
listen 443 ssl;
server_name lk.example.ru;
ssl_certificate /etc/ssl/certs/lk.crt;
ssl_certificate_key /etc/ssl/private/lk.key;
location / {
proxy_pass http://127.0.0.1:8099;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Режим связи (BJ_MODE в lk.env). Сейчас в архиве: %s.
direct — кабинет сам обращается к ядру по адресу BJ_URL. Подходит, когда у ядра
есть адрес, доступный серверу кабинета.
agent — у ядра нет адреса в интернете. Тогда кабинет ничего никуда не звонит: он
ждёт, пока ядро само подключится к нему по имени %s, заберёт запросы
клиентов и вернёт ответы. Наружу из сети депозитария идёт только
исходящее соединение, порты открывать не нужно. В настройках ядра
(«Личный кабинет» → связь) выберите «Ядро подключается к кабинету» и
укажите адрес этого сервера.
Сеть и безопасность:
• наружу открыт только 443 сервера кабинета;
• в режиме direct кабинет обращается к ядру на %s — разрешите этот доступ
в межсетевом экране; в режиме agent входящий доступ к ядру не нужен вовсе;
• ключ доступа хранится только в /etc/bj-lk/lk.env (права 0600, владелец root);
• ключ можно сменить в настройках ядра, после чего заменить его в lk.env и
перезапустить службу;
• кабинет не хранит данные клиентов: ни базы, ни файлов — всё берётся у ядра
на время запроса. Шрифты и оформление вшиты в программу, сторонние сайты
страница не запрашивает;
• ВАЖНО: в этом архиве лежит боевой ключ доступа. После установки удалите
архив с рабочего компьютера и не пересылайте его почтой или в мессенджере.
Обновления. Кабинет обновляется отдельно от ядра: новую версию скачивайте на
странице настроек ядра, раздел «Личный кабинет», и ставьте тем же install.sh —
настройки при этом сохраняются. Версии кабинета и ядра должны быть совместимы;
если ядро старше, кабинет покажет предупреждение администратору.
Лицензия. Кабинет входит в модуль «Личный кабинет клиента» и работает, пока он есть
в лицензии депозитария.
`, coreURL, title, mode, cabinetHint(s), coreURL)
return []struct {
Name string
Mode int64
Data []byte
}{
{"bj-lk/bj-lk", 0o755, bin},
{"bj-lk/bj-lk.service", 0o644, []byte(unit)},
{"bj-lk/lk.env", 0o600, []byte(env)},
{"bj-lk/install.sh", 0o755, []byte(install)},
{"bj-lk/README.txt", 0o644, []byte(readme)},
}, nil
}
// lkDistDownload — GET /admin/setup/lk/dist.tar.gz: архив для установки кабинета.
func (h *setupHandlers) lkDistDownload(w http.ResponseWriter, r *http.Request) {
s := h.rc.Snapshot()
if strings.TrimSpace(s.LK.APIKey) == "" {
setupFlash(w, r, "Сначала создайте ключ доступа кабинета — кнопка «Создать ключ».")
return
}
files, err := lkDistFiles(s, lkCoreURL(s, r))
if err != nil {
setupFlash(w, r, "Дистрибутив кабинета: "+err.Error())
return
}
name := fmt.Sprintf("bj-lk-%s.tar.gz", time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
gz := gzip.NewWriter(w)
defer gz.Close()
tw := tar.NewWriter(gz)
defer tw.Close()
now := time.Now()
for _, f := range files {
if err := tw.WriteHeader(&tar.Header{Name: f.Name, Mode: f.Mode, Size: int64(len(f.Data)), ModTime: now, Typeflag: tar.TypeReg}); err != nil {
return
}
if _, err := tw.Write(f.Data); err != nil {
return
}
}
}
// lkSaveDist — POST /admin/setup/lk/dist: адрес ядра для кабинета и ключ доступа.
func (h *setupHandlers) lkSaveDist(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot().LK
if u := strings.TrimSpace(r.FormValue("core_url")); u != "" || r.FormValue("core_url_set") != "" {
s.CoreURL = strings.TrimRight(u, "/")
}
// Режим связи: кабинет ходит к ядру или ядро подключается к кабинету само.
if r.FormValue("mode_set") != "" {
s.BindNetwork = r.FormValue("bind_network") == "1"
s.AgentMode = r.FormValue("mode") == "agent"
s.CabinetURL = strings.TrimRight(strings.TrimSpace(r.FormValue("cabinet_url")), "/")
if s.AgentMode && s.CabinetURL == "" {
setupFlash(w, r, "Кабинет: для режима ожидания укажите адрес кабинета на хостинге (https://lk.example.ru).")
return
}
}
msg := "Кабинет: настройки сохранены"
switch r.FormValue("op") {
case "newkey":
key, err := lkNewAPIKey()
if err != nil {
setupFlash(w, r, "Кабинет: "+err.Error())
return
}
s.APIKey = key
msg = "Создан новый ключ доступа кабинета. Скачайте дистрибутив заново или впишите ключ в /etc/bj-lk/lk.env и перезапустите службу кабинета."
case "clearkey":
s.APIKey = ""
msg = "Ключ удалён: кабинет больше не сможет обращаться к ядру."
}
if err := h.rc.UpdateLK(s); err != nil {
setupFlash(w, r, "Кабинет: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, msg)
}
// cabinetHint — как в инструкции назвать адрес самого кабинета.
func cabinetHint(s Settings) string {
if u := strings.TrimSpace(s.LK.CabinetURL); u != "" {
return u
}
return "https://lk.example.ru"
}
+91
View File
@@ -0,0 +1,91 @@
package lkgateway
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Дистрибутив кабинета: файлы, подстановка адреса ядра и ключа, ключ не пустой.
func TestLKDistFiles(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "bj-lk")
if err := os.WriteFile(bin, []byte("двоичный файл"), 0o755); err != nil {
t.Fatal(err)
}
saved := lkDistBinary
lkDistBinary = bin
defer func() { lkDistBinary = saved }()
var s Settings
s.LK.APIKey = "lk-testkey"
s.NSD.OrgName = "Депозитарий «Пример»"
files, err := lkDistFiles(s, "https://bj.example.ru")
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, f := range files {
got[f.Name] = string(f.Data)
}
for _, want := range []string{"bj-lk/bj-lk", "bj-lk/bj-lk.service", "bj-lk/lk.env", "bj-lk/install.sh", "bj-lk/README.txt"} {
if _, ok := got[want]; !ok {
t.Errorf("в архиве нет %s", want)
}
}
env := got["bj-lk/lk.env"]
if !strings.Contains(env, "BJ_URL=https://bj.example.ru") || !strings.Contains(env, "BJ_KEY=lk-testkey") {
t.Errorf("настройки кабинета: %s", env)
}
if !strings.Contains(env, "Депозитарий «Пример» · личный кабинет") {
t.Errorf("заголовок кабинета не подставлен: %s", env)
}
if !strings.Contains(got["bj-lk/README.txt"], "https://bj.example.ru") || !strings.Contains(got["bj-lk/install.sh"], "systemctl enable --now bj-lk") {
t.Error("инструкция или установщик собраны неправильно")
}
k1, err1 := lkNewAPIKey()
k2, err2 := lkNewAPIKey()
if err1 != nil || err2 != nil || k1 == k2 || !strings.HasPrefix(k1, "lk-") || len(k1) < 20 {
t.Errorf("ключи доступа: %q %q (%v, %v)", k1, k2, err1, err2)
}
if m := maskSecret(k1); strings.Contains(m, k1[8:20]) || !strings.HasPrefix(m, k1[:7]) {
t.Errorf("ключ показан не замаскированным: %q", m)
}
}
// TestLKDistAgentMode — в режиме ожидания адрес ядра в настройках кабинета не задаётся.
func TestLKDistAgentMode(t *testing.T) {
lkDistBinary = writeFakeBinary(t)
s := Settings{}
s.LK.APIKey = "lk-test-key"
s.LK.AgentMode = true
s.LK.CabinetURL = "https://lk.example.ru"
files, err := lkDistFiles(s, "https://bj.example.ru")
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, f := range files {
got[f.Name] = string(f.Data)
}
env := got["bj-lk/lk.env"]
if !strings.Contains(env, "BJ_MODE=agent") || strings.Contains(env, "BJ_URL=https://bj.example.ru") {
t.Errorf("настройки для режима ожидания собраны неправильно:\n%s", env)
}
if !strings.Contains(got["bj-lk/README.txt"], "https://lk.example.ru") {
t.Error("в инструкции нет адреса кабинета, к которому подключается ядро")
}
}
// writeFakeBinary — подложный файл кабинета для сборки архива в тестах.
func writeFakeBinary(t *testing.T) string {
t.Helper()
bin := filepath.Join(t.TempDir(), "bj-lk")
if err := os.WriteFile(bin, []byte("двоичный файл"), 0o755); err != nil {
t.Fatal(err)
}
saved := lkDistBinary
t.Cleanup(func() { lkDistBinary = saved })
return bin
}
+278
View File
@@ -0,0 +1,278 @@
package lkgateway
// lkdocs.go — корпоративные действия по бумагам клиента и документы депозитария:
// готовые выписки из учётной системы (U_OP_INF_EXTRACT) плюс заявки клиента на
// документ, которые кабинет кладёт в схему lk и показывает сотрудникам.
//
// Кабинет не создаёт документы в учётной системе: заявка — это поручение сотруднику,
// он готовит документ у себя и выкладывает клиенту.
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
// LKCorpAction — корпоративное действие по бумаге клиента.
type LKCorpAction struct {
FixDate string `json:"fix_date,omitempty"` // дата фиксации
OperDate string `json:"oper_date,omitempty"` // дата операции
Kind string `json:"kind"` // расшифровка вида
Code string `json:"code,omitempty"` // код вида в учёте
ISIN string `json:"isin,omitempty"`
Security string `json:"security,omitempty"`
Note string `json:"note,omitempty"`
}
// caKindRu — вид корпоративного действия по коду учёта (коды ISO 20022).
func caKindRu(code string) string {
switch strings.ToUpper(strings.TrimSpace(code)) {
case "DVCA":
return "дивиденды"
case "INTR":
return "выплата купона"
case "REDM":
return "погашение"
case "MCAL":
return "досрочное погашение"
case "PCAL":
return "частичное погашение"
case "BPUT":
return "оферта: предъявление к выкупу"
case "TEND":
return "добровольное предложение о выкупе"
case "MEET", "XMET", "OMET":
return "собрание владельцев"
case "CONV":
return "конвертация"
case "SPLF", "SPLR":
return "дробление или консолидация"
case "MRGR":
return "реорганизация"
case "EXOF":
return "обмен бумаг"
case "DRCA":
return "выплата по депозитарной расписке"
}
return "корпоративное действие"
}
// lkCorpActions — ближайшие корпоративные действия по бумагам клиента.
func lkCorpActions(ctx context.Context, s FancyMSSQLSettings, isins []string) ([]LKCorpAction, error) {
if strings.TrimSpace(s.Server) == "" || len(isins) == 0 {
return nil, nil
}
conn, err := fancyOpen(s, s.DepoDB())
if err != nil {
return nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
// Список ISIN подставляем параметрами: значения приходят из портфеля клиента.
names := make([]string, 0, len(isins))
args := make([]any, 0, len(isins))
for i, isin := range isins {
names = append(names, fmt.Sprintf("@p%d", i+1))
args = append(args, sql.Named(fmt.Sprintf("p%d", i+1), strings.ToUpper(strings.TrimSpace(isin))))
}
q := fmt.Sprintf(`SELECT TOP 40 ISNULL(a.KD_TYPE,''), ISNULL(v.ISIN,''), ISNULL(v.NAME,''), a.FIX_DATE, a.OPER_DATE, ISNULL(a.OFFER_NAME,'')
FROM U_CA_ACTS a JOIN OD_VALUES v ON v.ID = a.SHARE
WHERE UPPER(v.ISIN) IN (%s)
AND ISNULL(a.FIX_DATE, a.OPER_DATE) >= DATEADD(month, -1, GETDATE())
ORDER BY ISNULL(a.FIX_DATE, a.OPER_DATE)`, strings.Join(names, ","))
rows, err := conn.QueryContext(cctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LKCorpAction
for rows.Next() {
var code, isin, name, offer string
var fix, oper sql.NullTime
if rows.Scan(&code, &isin, &name, &fix, &oper, &offer) != nil {
continue
}
a := LKCorpAction{Code: strings.TrimSpace(code), Kind: caKindRu(code), ISIN: isin, Security: name, Note: strings.TrimSpace(offer)}
if fix.Valid && fix.Time.Year() < 9000 {
a.FixDate = fix.Time.Format("02.01.2006")
}
if oper.Valid && oper.Time.Year() > 2000 && oper.Time.Year() < 9000 {
a.OperDate = oper.Time.Format("02.01.2006")
}
out = append(out, a)
}
return out, rows.Err()
}
// LKDocument — документ депозитария, готовый или заказанный клиентом.
type LKDocument struct {
ID int64 `json:"id,omitempty"`
Date string `json:"date"` // дата готовности или заказа
Title string `json:"title"` // что за документ
Period string `json:"period,omitempty"` // за какой период
Account string `json:"account,omitempty"`
State string `json:"state"` // готов | в работе | отклонён
Note string `json:"note,omitempty"` // комментарий депозитария
Ordered bool `json:"ordered"` // заявка клиента (а не готовый документ учёта)
Kind string `json:"kind,omitempty"` // вид заявки
}
// lkDocSchema — таблица заявок на документы (создаётся вместе со схемой lk).
const lkDocSchema = `
CREATE TABLE IF NOT EXISTS lk.doc_requests (
id bigserial PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
contract_number text NOT NULL,
client_name text,
kind text NOT NULL,
period_from date,
period_to date,
account text,
comment text,
status text NOT NULL DEFAULT 'new',
staff_note text,
done_at timestamptz
);
CREATE INDEX IF NOT EXISTS ix_lk_doc_requests_contract ON lk.doc_requests (contract_number, created_at DESC);
`
// lkDocKinds — виды документов, которые клиент может заказать в кабинете.
var lkDocKinds = []string{
"Выписка по счёту депо",
"Справка об остатках на дату",
"Отчёт об операциях за период",
"Справка о доходах",
}
// lkDocKindAllowed — вид заявки из списка (чужие значения не принимаем).
func lkDocKindAllowed(kind string) bool {
for _, k := range lkDocKinds {
if k == kind {
return true
}
}
return false
}
// lkDocRequestAdd — заявка клиента на документ.
func lkDocRequestAdd(ctx context.Context, rc *RuntimeConfig, contract, clientName, kind, from, to, account, comment string) error {
if !lkDocKindAllowed(kind) {
return fmt.Errorf("неизвестный вид документа")
}
db, err := lkDB(rc)
if err != nil {
return err
}
var fromP, toP any
if t, err := time.Parse("2006-01-02", strings.TrimSpace(from)); err == nil {
fromP = t
}
if t, err := time.Parse("2006-01-02", strings.TrimSpace(to)); err == nil {
toP = t
}
_, err = db.ExecContext(ctx, `INSERT INTO lk.doc_requests (contract_number, client_name, kind, period_from, period_to, account, comment)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, contract, clientName, kind, fromP, toP, strings.TrimSpace(account), strings.TrimSpace(comment))
return err
}
// lkDocRequests — заявки клиента на документы.
func lkDocRequests(ctx context.Context, rc *RuntimeConfig, contract string) ([]LKDocument, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
rows, err := db.QueryContext(ctx, `SELECT id, created_at, kind, period_from, period_to, COALESCE(account,''), status, COALESCE(staff_note,'')
FROM lk.doc_requests WHERE contract_number=$1 ORDER BY created_at DESC LIMIT 50`, contract)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LKDocument
for rows.Next() {
var d LKDocument
var created time.Time
var from, to *time.Time
var status string
if err := rows.Scan(&d.ID, &created, &d.Kind, &from, &to, &d.Account, &status, &d.Note); err != nil {
continue
}
d.Ordered, d.Title, d.Date = true, d.Kind, created.Format("02.01.2006")
switch status {
case "done":
d.State = "готов"
case "rejected":
d.State = "отклонён"
default:
d.State = "в работе"
}
if from != nil && to != nil {
d.Period = from.Format("02.01.2006") + " — " + to.Format("02.01.2006")
}
out = append(out, d)
}
return out, rows.Err()
}
// lkReadyDocuments — готовые выписки по счетам клиента из учётной системы.
func lkReadyDocuments(ctx context.Context, s FancyMSSQLSettings, contract string) ([]LKDocument, error) {
if strings.TrimSpace(s.Server) == "" {
return nil, nil
}
ids, _, accounts, err := depoClientIDs(ctx, s, contract)
if err != nil || len(ids) == 0 {
return nil, err
}
conn, err := fancyOpen(s, s.DepoDB())
if err != nil {
return nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
q := fmt.Sprintf(`SELECT TOP 30 d.R_DATE, ISNULL(d.NUM,''), e.B_DATE, e.E_DATE, e.DEPO, d.STATE, ISNULL(e.IS_TURNS,0)
FROM U_OP_INF_EXTRACT e JOIN OD_DOCS d ON d.ID = e.DOC
WHERE e.DEPO IN (%s) ORDER BY d.R_DATE DESC`, inClause(ids))
rows, err := conn.QueryContext(cctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LKDocument
for rows.Next() {
var rd, bd, ed sql.NullTime
var num string
var acc int64
var state, turns sql.NullInt64
if rows.Scan(&rd, &num, &bd, &ed, &acc, &state, &turns) != nil {
continue
}
d := LKDocument{Title: "Выписка по счёту депо", Account: accounts[acc], State: docStateRu(state, sql.NullTime{}), Note: num}
if turns.Valid && turns.Int64 == 1 {
d.Title = "Выписка по счёту депо с оборотами"
}
if rd.Valid {
d.Date = rd.Time.Format("02.01.2006")
}
if bd.Valid && ed.Valid {
d.Period = bd.Time.Format("02.01.2006") + " — " + ed.Time.Format("02.01.2006")
} else if ed.Valid {
d.Period = "на " + ed.Time.Format("02.01.2006")
}
out = append(out, d)
}
return out, rows.Err()
}
// notifyDocRequest — сообщить сотрудникам депозитария, что клиент заказал документ.
func notifyDocRequest(rc *RuntimeConfig, u LKUser, kind, from, to string) {
period := ""
if from != "" || to != "" {
period = fmt.Sprintf(" за период %s — %s", pick(from, "…"), pick(to, "…"))
}
go raiseSystemAlert(rc, "Кабинет: заказан документ",
fmt.Sprintf("Клиент %s (договор %s) заказал документ «%s»%s. Заявка видна в кабинете клиента и в журнале lk.doc_requests.",
pick(u.FullName, u.Login), u.Contract, kind, period))
}
+1 -1
View File
@@ -56,7 +56,7 @@ func depoClientIDs(ctx context.Context, s FancyMSSQLSettings, contract string) (
defer cancel()
rows, err := conn.QueryContext(cctx, `SELECT DISTINCT c.ID, ISNULL(c.NUMBER,''), ISNULL(c.FACE,0)
FROM OD_CLIENTS c JOIN OD_CLIENTS_PORTS p ON p.CLIENT = c.ID
WHERE p.PORT_NUM = @p1 OR p.PORT_NUM LIKE @p2`, contract, contract+" %")
WHERE p.PORT_NUM = @p1 OR p.PORT_NUM LIKE @p2`, contract, likeEscape(contract)+" %")
if err != nil {
return nil, 0, nil, err
}
+187
View File
@@ -0,0 +1,187 @@
package lkgateway
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Тесты безопасности кабинета на стороне ядра: что лежит на диске, как живут сессии,
// как ядро отвечает на перебор и на запросы без ключа.
func sessionsInTemp(t *testing.T) *lkSessions {
t.Helper()
saved := lkSessionsFile
lkSessionsFile = filepath.Join(t.TempDir(), "lk-sessions.json")
t.Cleanup(func() { lkSessionsFile = saved })
return newLKSessions()
}
func reqFrom(ip, agent string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/api/lk/portfolio", nil)
r.Header.Set("X-BJ-Client-IP", ip)
r.Header.Set("X-BJ-Client-Agent", agent)
return r
}
// На диске нет ни токена, ни персональных данных клиента.
func TestLKSessionFileHasNoTokenOrPII(t *testing.T) {
s := sessionsInTemp(t)
tok, err := s.create(lkSession{UserID: 7, Login: "ivanov", Contract: "БР-100", CanTransfer: true}, reqFrom("203.0.113.5", "Firefox"))
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(lkSessionsFile)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(raw, []byte(tok)) {
t.Fatal("в файле сессий лежит живой токен")
}
for _, leak := range []string{"full_name", "doc_series", "doc_number", "email", "phone", "last_name"} {
if bytes.Contains(raw, []byte(leak)) {
t.Errorf("в файле сессий есть поле с персональными данными: %s", leak)
}
}
if st, _ := os.Stat(lkSessionsFile); st.Mode().Perm() != 0o600 {
t.Errorf("права файла сессий %o, нужны 0600", st.Mode().Perm())
}
// после перезапуска сессия поднимается по отпечатку
if _, ok := newLKSessions().get(tok, reqFrom("203.0.113.5", "Firefox")); !ok {
t.Fatal("сессия не пережила перезапуск")
}
}
// Украденный токен с другого браузера или из другой сети не подходит и гасит сессию.
func TestLKSessionBoundToDevice(t *testing.T) {
s := sessionsInTemp(t)
s.bindNetwork = func() bool { return true }
tok, _ := s.create(lkSession{UserID: 1, Contract: "БР-1"}, reqFrom("203.0.113.5", "Firefox"))
if _, ok := s.get(tok, reqFrom("203.0.113.77", "Firefox")); !ok {
t.Fatal("тот же клиент в той же сети /24 должен проходить")
}
if _, ok := s.get(tok, reqFrom("203.0.113.5", "curl/8")); ok {
t.Fatal("токен принят с другого браузера")
}
if _, ok := s.get(tok, reqFrom("203.0.113.5", "Firefox")); ok {
t.Fatal("после попытки с чужого устройства сессия должна быть погашена")
}
tok2, _ := s.create(lkSession{UserID: 1, Contract: "БР-1"}, reqFrom("203.0.113.5", "Firefox"))
if _, ok := s.get(tok2, reqFrom("198.51.100.9", "Firefox")); ok {
t.Fatal("токен принят из другой сети при включённой привязке к сети")
}
// без привязки к сети мобильный клиент со сменившимся адресом остаётся в кабинете
s.bindNetwork = nil
tok3, _ := s.create(lkSession{UserID: 1, Contract: "БР-1"}, reqFrom("203.0.113.5", "Firefox"))
if _, ok := s.get(tok3, reqFrom("198.51.100.9", "Firefox")); !ok {
t.Fatal("по умолчанию смена адреса не должна выбрасывать клиента")
}
}
// Сессия не живёт дольше абсолютного срока, даже если ей всё время пользуются.
func TestLKSessionAbsoluteLifetime(t *testing.T) {
s := sessionsInTemp(t)
r := reqFrom("203.0.113.5", "Firefox")
tok, _ := s.create(lkSession{UserID: 1, Contract: "БР-1"}, r)
h := lkTokenHash(tok)
s.mu.Lock()
v := s.data[h]
v.CreatedAt = time.Now().Add(-lkSessionMaxAge - time.Minute)
v.ExpiresAt = time.Now().Add(time.Hour) // скользящий срок ещё не вышел
s.data[h] = v
s.mu.Unlock()
if _, ok := s.get(tok, r); ok {
t.Fatal("сессия пережила абсолютный срок")
}
if _, ok := s.get("", r); ok {
t.Fatal("пустой токен принят")
}
}
// Сотрудник в кабинете клиента — только просмотр, что бы ни стояло в учётной записи.
func TestLKVirtualSessionIsReadOnly(t *testing.T) {
s := sessionsInTemp(t)
u, err := s.user(nil, nil, lkSession{Virtual: true, Contract: "БР-5", VirtualName: "Клиент", ImpersonatedBy: "manager", CanTransfer: true})
if err != nil {
t.Fatal(err)
}
if u.CanTransfer || u.ImpersonatedBy != "manager" || u.Contract != "БР-5" {
t.Fatalf("сессия сотрудника: %+v", u)
}
}
// API кабинета: без ключа, с чужим ключом, перебор пароля.
func TestLKAPIKeyAndBruteForce(t *testing.T) {
sessionsInTemp(t)
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
registerLKAPI(mux, rc, nil)
call := func(method, path, key, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, strings.NewReader(body))
if key != "" {
r.Header.Set("X-BJ-Key", key)
}
r.Header.Set("X-BJ-Client-IP", "203.0.113.200")
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
return w
}
// ключа нет в настройках — API выключен целиком
if w := call("GET", "/api/lk/ping", "что-угодно", ""); w.Code != http.StatusServiceUnavailable {
t.Fatalf("API без ключа в настройках: %d", w.Code)
}
cfg := rc.Snapshot().LK
cfg.APIKey = "lk-правильный-ключ"
_ = rc.UpdateLK(cfg)
for _, path := range []string{"/api/lk/ping", "/api/lk/portfolio", "/api/lk/operations", "/api/lk/income",
"/api/lk/documents", "/api/lk/corpactions", "/api/lk/claims", "/api/lk/admin/clients"} {
if w := call("GET", path, "", ""); w.Code != http.StatusUnauthorized {
t.Errorf("%s без ключа: код %d", path, w.Code)
}
if w := call("GET", path, "lk-чужой-ключ", ""); w.Code != http.StatusUnauthorized {
t.Errorf("%s с чужим ключом: код %d", path, w.Code)
}
}
// с ключом, но без сессии клиента — данных нет
for _, path := range []string{"/api/lk/portfolio", "/api/lk/operations", "/api/lk/income", "/api/lk/documents", "/api/lk/admin/clients"} {
if w := call("GET", path, cfg.APIKey, ""); w.Code != http.StatusUnauthorized {
t.Errorf("%s без сессии: код %d", path, w.Code)
}
}
// перебор: после серии неудач ядро перестаёт проверять пароль
body, _ := json.Marshal(map[string]string{"login": "brute-test-" + t.Name(), "password": "неверный-пароль"})
var blocked bool
for i := 0; i < 12; i++ {
if w := call("POST", "/api/lk/login", cfg.APIKey, string(body)); w.Code == http.StatusTooManyRequests {
blocked = true
break
}
}
if !blocked {
t.Fatal("перебор пароля не ограничен")
}
}
// Требования к паролю и экранирование шаблона LIKE.
func TestLKPasswordPolicyAndLikeEscape(t *testing.T) {
for _, bad := range []string{"", "123456", "короткий1", "толькобуквыбезцифр", "1234567890123"} {
if lkCheckPassword(bad) == nil {
t.Errorf("слабый пароль принят: %q", bad)
}
}
if err := lkCheckPassword("Надёжный-пароль-2026"); err != nil {
t.Errorf("хороший пароль отклонён: %v", err)
}
if got := likeEscape("БР-1%_[x"); got != "БР-1[%][_][[]x" {
t.Errorf("likeEscape: %q", got)
}
}
+297
View File
@@ -0,0 +1,297 @@
package lkgateway
// lksession.go — сессии кабинета клиента.
//
// На диске лежит не токен, а его отпечаток (sha256), и не карточка клиента, а только
// ссылка на неё: идентификатор учётной записи, номер договора и признаки. Всё остальное —
// ФИО, паспорт, телефон — дочитывается из lk.users на каждый запрос. Поэтому файл сессий
// в резервной копии или в снимке диска не даёт ни персональных данных, ни живого токена, а
// блокировка клиента и снятие прав администратора действуют сразу, а не через 12 часов.
//
// Срок жизни двойной: скользящий (клиент работает — сессия не истекает) и абсолютный,
// после которого нужно войти заново, сколько бы запросов ни было.
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"sync"
"time"
)
const (
lkSessionTTL = 12 * time.Hour // скользящий срок: столько сессия живёт без обращений
lkSessionMaxAge = 24 * time.Hour // абсолютный срок: дольше не живёт даже активная
lkUserCacheTTL = 30 * time.Second
)
// lkSessionsFile — сессии переживают перезапуск bj-server. Иначе любое обновление
// выбрасывало бы всех клиентов с «сессия истекла, войдите заново».
var lkSessionsFile = "/var/lib/bj/.bj/lk-sessions.json" // переменная — для тестов
// lkSession — что храним о сессии. Персональных данных здесь нет.
type lkSession struct {
UserID int64 `json:"user_id,omitempty"`
Login string `json:"login,omitempty"`
Contract string `json:"contract,omitempty"`
// Virtual — сессия без учётной записи: сотрудник смотрит кабинет клиента
// депозитария. Читать из lk.users нечего, признаки берём отсюда.
Virtual bool `json:"virtual,omitempty"`
VirtualName string `json:"virtual_name,omitempty"`
IsAdmin bool `json:"is_admin,omitempty"`
CanTransfer bool `json:"can_transfer,omitempty"`
ImpersonatedBy string `json:"impersonated_by,omitempty"`
Device string `json:"device,omitempty"` // отпечаток браузера клиента
IP string `json:"ip,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type lkSessions struct {
mu sync.Mutex
data map[string]lkSession // отпечаток токена → сессия
cacheMu sync.Mutex
cache map[int64]lkUserCached
// bindNetwork — сверять ли сеть клиента. По умолчанию нет: у мобильных операторов
// адрес меняется прямо во время работы, и клиента выбрасывало бы из кабинета.
// Включается в настройках для депозитариев, чьи клиенты работают из офиса.
bindNetwork func() bool
}
type lkUserCached struct {
user LKUser
at time.Time
}
func newLKSessions() *lkSessions {
s := &lkSessions{data: map[string]lkSession{}, cache: map[int64]lkUserCached{}}
s.load()
return s
}
// lkTokenHash — отпечаток токена: в файле и в памяти держим только его.
func lkTokenHash(tok string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(tok)))
return hex.EncodeToString(sum[:])
}
// lkDeviceFingerprint — грубый отпечаток устройства: браузер целиком и сеть /24.
// Точный адрес меняется у мобильного клиента, поэтому сверяем только подсеть.
func lkDeviceFingerprint(agent string) string {
if strings.TrimSpace(agent) == "" {
return ""
}
sum := sha256.Sum256([]byte(agent))
return hex.EncodeToString(sum[:8])
}
func lkNetwork(ip string) string {
ip = strings.TrimSpace(ip)
if i := strings.LastIndex(ip, "."); i > 0 && strings.Count(ip, ".") == 3 {
return ip[:i] // IPv4 → сеть /24
}
return ip
}
func (s *lkSessions) load() {
raw, err := os.ReadFile(lkSessionsFile)
if err != nil {
return
}
var m map[string]lkSession
if json.Unmarshal(raw, &m) != nil {
return // в том числе старый формат с карточками клиентов: такие сессии просто не поднимаем
}
now := time.Now()
for h, v := range m {
if now.Before(v.ExpiresAt) && now.Before(v.CreatedAt.Add(lkSessionMaxAge)) {
s.data[h] = v
}
}
}
// saveLocked сбрасывает сессии на диск (вызывается под удержанным mu).
func (s *lkSessions) saveLocked() {
raw, err := json.Marshal(s.data)
if err != nil {
return
}
tmp := lkSessionsFile + ".tmp"
if os.WriteFile(tmp, raw, 0o600) == nil {
_ = os.Rename(tmp, lkSessionsFile)
}
}
// newToken — 24 случайных байта. Если системный источник случайности отказал,
// суррогат не выдаём: предсказуемый токен хуже отказа во входе.
func newToken() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", errors.New("нет источника случайных чисел, вход временно невозможен")
}
return hex.EncodeToString(b), nil
}
// create — новая сессия; возвращает токен, который больше нигде не сохраняется.
func (s *lkSessions) create(v lkSession, r *http.Request) (string, error) {
tok, err := newToken()
if err != nil {
return "", err
}
now := time.Now()
v.CreatedAt, v.ExpiresAt = now, now.Add(lkSessionTTL)
if r != nil {
v.Device = lkDeviceFingerprint(lkClientAgent(r))
v.IP = lkNetwork(lkClientAddr(r))
}
s.mu.Lock()
s.data[lkTokenHash(tok)] = v
for k, old := range s.data { // заодно подчистим протухшие
if now.After(old.ExpiresAt) || now.After(old.CreatedAt.Add(lkSessionMaxAge)) {
delete(s.data, k)
}
}
s.saveLocked()
s.mu.Unlock()
return tok, nil
}
// get — сессия по токену с проверкой сроков и привязки к устройству.
func (s *lkSessions) get(tok string, r *http.Request) (lkSession, bool) {
if strings.TrimSpace(tok) == "" {
return lkSession{}, false
}
h := lkTokenHash(tok)
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.data[h]
now := time.Now()
if !ok || now.After(v.ExpiresAt) || now.After(v.CreatedAt.Add(lkSessionMaxAge)) {
if ok {
delete(s.data, h)
s.saveLocked()
}
return lkSession{}, false
}
// Сессия привязана к браузеру и сети клиента: украденный токен с другой
// машины не подойдёт. Отпечаток пустой — кабинет старой версии, не придираемся.
if r != nil && v.Device != "" {
if d := lkDeviceFingerprint(lkClientAgent(r)); d != "" && d != v.Device {
delete(s.data, h)
s.saveLocked()
return lkSession{}, false
}
}
if r != nil && v.IP != "" && s.bindNetwork != nil && s.bindNetwork() {
if ip := lkNetwork(lkClientAddr(r)); ip != "" && ip != v.IP {
delete(s.data, h)
s.saveLocked()
return lkSession{}, false
}
}
// Скользящий срок: пока клиент работает — сессия не истекает. На диск пишем
// не чаще раза в 10 минут, чтобы не дёргать файл на каждый запрос.
if time.Until(v.ExpiresAt) < lkSessionTTL-10*time.Minute {
v.ExpiresAt = now.Add(lkSessionTTL)
if v.ExpiresAt.After(v.CreatedAt.Add(lkSessionMaxAge)) {
v.ExpiresAt = v.CreatedAt.Add(lkSessionMaxAge)
}
s.data[h] = v
s.saveLocked()
}
return v, true
}
func (s *lkSessions) drop(tok string) {
if strings.TrimSpace(tok) == "" {
return
}
s.mu.Lock()
delete(s.data, lkTokenHash(tok))
s.saveLocked()
s.mu.Unlock()
}
// dropUser — погасить все сессии учётной записи (смена пароля, блокировка).
func (s *lkSessions) dropUser(userID int64) {
if userID == 0 {
return
}
s.mu.Lock()
for h, v := range s.data {
if v.UserID == userID {
delete(s.data, h)
}
}
s.saveLocked()
s.mu.Unlock()
}
// user — карточка клиента по сессии. Для обычной сессии читается из lk.users
// (с коротким кэшем), поэтому блокировка учётной записи и снятие прав
// администратора действуют сразу.
func (s *lkSessions) user(ctx context.Context, rc *RuntimeConfig, v lkSession) (LKUser, error) {
if v.Virtual {
return LKUser{
Login: v.Login, Contract: v.Contract, FullName: v.VirtualName,
Active: true, CanTransfer: false, ImpersonatedBy: v.ImpersonatedBy,
}, nil
}
s.cacheMu.Lock()
c, ok := s.cache[v.UserID]
s.cacheMu.Unlock()
var u LKUser
if ok && time.Since(c.at) < lkUserCacheTTL {
u = c.user
} else {
loaded, err := lkUserByID(ctx, rc, v.UserID)
if err != nil {
return LKUser{}, err
}
u = *loaded
s.cacheMu.Lock()
s.cache[v.UserID] = lkUserCached{user: u, at: time.Now()}
s.cacheMu.Unlock()
}
if !u.Active {
return LKUser{}, errors.New("учётная запись заблокирована")
}
// Сотрудник в кабинете клиента — только просмотр, что бы ни стояло в учётке.
if v.ImpersonatedBy != "" {
u.ImpersonatedBy = v.ImpersonatedBy
u.CanTransfer = false
}
return u, nil
}
// forget — сбросить кэш карточки клиента (после правки учётной записи).
func (s *lkSessions) forget(userID int64) {
s.cacheMu.Lock()
delete(s.cache, userID)
s.cacheMu.Unlock()
}
// lkClientAgent / lkClientAddr — браузер и адрес клиента кабинета. Кабинет стоит на
// отдельном сервере, поэтому передаёт их заголовками: сами заголовки приходят вместе
// с ключом модуля, то есть от доверенной стороны.
func lkClientAgent(r *http.Request) string {
if v := strings.TrimSpace(r.Header.Get("X-BJ-Client-Agent")); v != "" {
return v
}
return strings.TrimSpace(r.UserAgent())
}
func lkClientAddr(r *http.Request) string {
if v := strings.TrimSpace(r.Header.Get("X-BJ-Client-IP")); v != "" {
return v
}
return clientIP(r)
}
+4 -1
View File
@@ -99,6 +99,9 @@ var lkSchemaDDL = []string{
}
// ensureLKSchema — схема lk и таблицы модуля ЛК (идемпотентно), при старте bj.
// lkSchemaExtra — таблицы, добавленные позже основной схемы.
func lkSchemaExtra() []string { return []string{lkDocSchema, lkAuditSchema} }
func ensureLKSchema(ctx context.Context, rc *RuntimeConfig) error {
db, err := lkDB(rc)
if err != nil {
@@ -106,7 +109,7 @@ func ensureLKSchema(ctx context.Context, rc *RuntimeConfig) error {
}
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
for _, q := range lkSchemaDDL {
for _, q := range append(append([]string{}, lkSchemaDDL...), lkSchemaExtra()...) {
if _, err := db.ExecContext(cctx, q); err != nil {
return err
}
@@ -0,0 +1,98 @@
//go:build live
package lkgateway
import (
"context"
"sort"
"strings"
"testing"
"time"
)
// Почему у части позиций нет цены: какие это бумаги и есть ли они вообще в экспорте QUIK.
func TestLiveUnpricedBreakdown(t *testing.T) {
rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json")
if err != nil {
t.Fatal(err)
}
globalRC = rc
fs := rc.Snapshot().Fancy
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
defer cancel()
contracts, err := depoListContracts(ctx, fs, "", 60)
if err != nil {
t.Fatal(err)
}
type info struct {
name string
balAcc int
n int
}
un := map[string]*info{}
for _, c := range contracts {
pos, err := depoClientPositions(ctx, fs, c.Contract)
if err != nil {
continue
}
var isins []string
for _, p := range pos {
if p.ISIN != "" {
isins = append(isins, p.ISIN)
}
}
px := lkPrices(ctx, rc, isins)
for _, p := range pos {
if p.ISIN == "" {
k := "(без ISIN) " + p.SecurityName
if un[k] == nil {
un[k] = &info{name: p.SecurityName, balAcc: p.BalAcc}
}
un[k].n++
continue
}
if v, ok := px[strings.ToUpper(p.ISIN)]; ok && v.Price > 0 {
continue
}
if un[p.ISIN] == nil {
un[p.ISIN] = &info{name: p.SecurityName, balAcc: p.BalAcc}
}
un[p.ISIN].n++
}
}
// есть ли такие бумаги в экспорте вообще (любой класс, даже без торгов)
db, err := nprDB(rc)
if err != nil {
t.Fatalf("база экспорта: %v", err)
}
var keys []string
for k := range un {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return un[keys[i]].n > un[keys[j]].n })
var foreign, noISIN, inQuikNoTrade, absent int
for _, k := range keys {
i := un[k]
switch {
case strings.HasPrefix(k, "(без ISIN)"):
noISIN += i.n
t.Logf("без ISIN ×%d счёт %d %s", i.n, i.balAcc, i.name)
continue
case !strings.HasPrefix(strings.ToUpper(k), "RU"):
foreign += i.n
t.Logf("иностранная ×%d счёт %d %s %s", i.n, i.balAcc, k, i.name)
continue
}
var classes string
_ = db.QueryRow(ctx, `SELECT COALESCE(string_agg(DISTINCT "ClassCode", ','), '') FROM "Securities" WHERE upper("ISINCode") = $1`, strings.ToUpper(k)).Scan(&classes)
if classes == "" {
absent += i.n
t.Logf("нет в экспорте ×%d счёт %d %s %s", i.n, i.balAcc, k, i.name)
} else {
inQuikNoTrade += i.n
t.Logf("есть, без цены ×%d счёт %d %s %s классы: %s", i.n, i.balAcc, k, i.name, classes)
}
}
t.Logf("ИТОГО без цены: иностранные %d · без ISIN %d · российские вне экспорта QUIK %d · есть в экспорте, но без цены %d",
foreign, noISIN, absent, inQuikNoTrade)
}
+55 -4
View File
@@ -45,8 +45,8 @@ func lkCreateUser(ctx context.Context, rc *RuntimeConfig, u LKUser, password str
if strings.TrimSpace(u.Login) == "" || strings.TrimSpace(u.Contract) == "" {
return errors.New("логин и номер договора обязательны")
}
if len(password) < 6 {
return errors.New("пароль минимум 6 символов")
if err := lkCheckPassword(password); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -68,8 +68,8 @@ func lkCreateUser(ctx context.Context, rc *RuntimeConfig, u LKUser, password str
// lkSetPassword меняет пароль пользователя ЛК.
func lkSetPassword(ctx context.Context, rc *RuntimeConfig, login, password string) error {
if len(password) < 6 {
return errors.New("пароль минимум 6 символов")
if err := lkCheckPassword(password); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -109,6 +109,9 @@ func lkAuth(ctx context.Context, rc *RuntimeConfig, login, password string) (*LK
&u.FullName, &u.Email, &u.Phone, &u.Active,
&u.LastName, &u.FirstName, &u.MiddleName,
&u.DocType, &u.DocSeries, &u.DocNumber, &u.IsAdmin, &u.CanTransfer); err != nil {
// Логина нет. Всё равно считаем bcrypt по фиктивному хешу: иначе ответ
// приходит заметно быстрее и по времени можно перебрать существующие логины.
_ = bcrypt.CompareHashAndPassword([]byte(lkDummyHash), []byte(password))
return nil, errLKAuth // не раскрываем, что логина нет
}
if !u.Active {
@@ -146,3 +149,51 @@ func lkListUsers(ctx context.Context, rc *RuntimeConfig) ([]LKUser, error) {
}
return out, rows.Err()
}
// lkDummyHash — заведомо не подходящий bcrypt-хеш: по нему считаем проверку, когда
// логина нет, чтобы ответ занимал столько же времени, сколько при существующем логине.
const lkDummyHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
// lkCheckPassword — требования к паролю кабинета: в кабинете видны паспортные данные
// и остатки на счетах депо, поэтому шести символов мало.
func lkCheckPassword(password string) error {
if len([]rune(password)) < 10 {
return errors.New("пароль минимум 10 символов")
}
var hasLetter, hasOther bool
for _, r := range password {
switch {
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= 'а' && r <= 'я') || (r >= 'А' && r <= 'Я'):
hasLetter = true
default:
hasOther = true
}
}
if !hasLetter || !hasOther {
return errors.New("пароль должен содержать буквы и хотя бы одну цифру или знак")
}
return nil
}
// lkUserByID — карточка клиента по идентификатору. Нужна сессиям: на диске хранится
// только ссылка на учётную запись, а сами данные читаются отсюда на каждый запрос.
func lkUserByID(ctx context.Context, rc *RuntimeConfig, id int64) (*LKUser, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
var u LKUser
if err := db.QueryRowContext(cctx, `SELECT id, login, contract_number,
COALESCE(full_name,''), COALESCE(email,''), COALESCE(phone,''), active,
COALESCE(last_name,''), COALESCE(first_name,''), COALESCE(middle_name,''),
COALESCE(doc_type,''), COALESCE(doc_series,''), COALESCE(doc_number,''), is_admin, can_transfer
FROM lk.users WHERE id=$1`, id).Scan(&u.ID, &u.Login, &u.Contract,
&u.FullName, &u.Email, &u.Phone, &u.Active,
&u.LastName, &u.FirstName, &u.MiddleName,
&u.DocType, &u.DocSeries, &u.DocNumber, &u.IsAdmin, &u.CanTransfer); err != nil {
return nil, errors.New("учётная запись не найдена")
}
return &u, nil
}
+11
View File
@@ -247,6 +247,17 @@ type LKSettings struct {
// Модуль ставится на веб-сервер клиента и обращается к bj-server по этому
// ключу (заголовок X-BJ-Key). Пустой ключ = модульный API выключен.
APIKey string `json:"lk_api_key"`
// CoreURL — адрес ядра bj, по которому кабинет к нему обращается (кабинет стоит
// на отдельном веб-сервере). Пусто — подставляем адрес текущего сервера.
CoreURL string `json:"lk_core_url,omitempty"`
// AgentMode — режим ожидания: у ядра нет адреса, доступного из интернета,
// поэтому не кабинет обращается к ядру, а ядро само подключается к кабинету.
AgentMode bool `json:"lk_agent_mode,omitempty"`
// CabinetURL — адрес кабинета на хостинге, куда подключается ядро в режиме ожидания.
CabinetURL string `json:"lk_cabinet_url,omitempty"`
// BindNetwork — привязывать сессию клиента не только к браузеру, но и к сети /24.
// Строже, но мобильных клиентов будет выбрасывать при смене адреса оператором.
BindNetwork bool `json:"lk_bind_network,omitempty"`
// Интеграция с ЛК (потребление back_office API платформы): два окружения.
ActiveEnv string `json:"lk_active_env"` // "test" | "prod"
+4
View File
@@ -292,6 +292,10 @@ func (s *Server) Run(ctx context.Context) error {
// Отказы шлюза по заявкам ЛК: ждём ответ Fancy (DEPO) и только тогда ставим «Отклонена» в ЛК.
go superviseLoop(ctx, "lk-flow", s.rc, s.lkFlowLoop)
// Кабинет клиента в режиме ожидания: ядро само подключается к кабинету на
// хостинге и обслуживает запросы клиентов через исходящее соединение.
go superviseLoop(ctx, "lk-agent", s.rc, s.lkAgentLoop)
// Поллер заявок ЛК ЕСИА Финанс: раз в минуту забирает новые depo_m2m и
// отправляет в НРД (или имитатору). Интервал 1 мин — время ответов регламентировано.
go superviseLoop(ctx, "poll-lk", s.rc, s.pollLKClaims)
+6
View File
@@ -86,6 +86,8 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
mux.HandleFunc("/admin/setup/org", h.saveOrg)
mux.HandleFunc("/admin/setup/lk", h.saveLK)
mux.HandleFunc("/admin/setup/lk/dist", h.lkSaveDist)
mux.HandleFunc("/admin/setup/lk/dist.tar.gz", h.lkDistDownload)
mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration)
mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn)
mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate)
@@ -311,6 +313,8 @@ type SetupData struct {
DepoJob DepoJobView
DepoTables DepoTablesView // трансферные таблицы FTV_M2M* в базе DEPO
QuikExport QuikExportView // модуль «Экспорт биржевой информации»
LKAgent LKAgentStatus // канал кабинета клиента (режим ожидания)
LKKeyMask string // ключ кабинета в маскированном виде
Versions SystemVersions // версии компонентов bj и внешних систем
Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак)
Features []LicenseFeatureView // признаки лицензии для карточки
@@ -375,6 +379,8 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl
DepoJob: depoJobView(s.Fancy),
DepoTables: depoTablesView(s.Fancy),
QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"),
LKAgent: lkAgentStatus(s),
LKKeyMask: maskSecret(s.LK.APIKey),
Versions: systemVersions(h.rc),
Modules: modulesAllowed(h.rc),
Features: licenseFeatureViews(h.rc),
@@ -1,6 +1,39 @@
{{define "content"}}
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
<div class="card">
<h2>Как разместить кабинет клиента на хостинге</h2>
<p class="muted">Речь о нашем модуле <code>bj-lk</code> — кабинете, который клиенты открывают в браузере. Он ставится отдельно от ядра: на веб-сервер депозитария или на обычный хостинг с выделенным именем. Своей базы у кабинета нет, данные клиентов он не хранит — берёт у ядра на время запроса.</p>
<h3>Шаг 1. Ключ и архив</h3>
<p>Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек, установщик и README. <b>Внутри архива боевой ключ</b>: после установки удалите архив с рабочего компьютера, не пересылайте его почтой.</p>
<h3>Шаг 2. Установка на сервере кабинета</h3>
<pre>tar xzf bj-lk-ГГГГ-ММ-ДД.tar.gz
cd bj-lk
sudo ./install.sh
curl -s http://127.0.0.1:8099/health # {"ok":true}</pre>
<p>Установщик заводит системного пользователя <code>bjlk</code>, кладёт программу в <code>/opt/bj-lk</code>, настройки — в <code>/etc/bj-lk/lk.env</code> (права 0600) и включает службу <code>bj-lk</code>. Сам кабинет слушает только <code>127.0.0.1:8099</code>.</p>
<h3>Шаг 3. Публикация наружу</h3>
<p>Перед кабинетом ставится nginx с сертификатом TLS — пример конфигурации есть в README архива. Наружу открыт только 443 порт сервера кабинета.</p>
<h3>Шаг 4. Связь с ядром — два способа</h3>
<p><b>Кабинет обращается к ядру</b> (обычный случай). В настройках ядра укажите его адрес, кабинет ходит туда за данными. Требуется, чтобы сервер кабинета мог открыть соединение к ядру по https.</p>
<p><b>Ядро подключается к кабинету.</b> Если у ядра нет адреса в интернете — а так чаще всего и бывает, ядро стоит во внутренней сети депозитария, — порядок разворачивается. Кабинет ничего никуда не звонит: он копит запросы клиентов и ждёт. Ядро само подключается к кабинету по его доменному имени, забирает запрос, выполняет его у себя и возвращает ответ. Из сети депозитария идёт только исходящее соединение: ни белого адреса, ни проброса портов, ни правил для входящего трафика не нужно.</p>
<p>Включается в настройках ядра: «Личный кабинет» → «Как кабинет и ядро находят друг друга» → «Ядро подключается к кабинету», рядом — адрес кабинета. В файле кабинета при этом стоит <code>BJ_MODE=agent</code>, а <code>BJ_URL</code> не задан. Состояние связи видно там же: подключено ли ядро, когда был последний обмен, сколько запросов обслужено.</p>
<p>В обе стороны канал защищён тем же ключом модуля: запрос без ключа кабинет не отдаёт, ответ без ключа не принимает.</p>
<h3>Что с данными</h3>
<ul>
<li>Кабинет не хранит ни базы, ни файлов: портфель, операции и документы приходят из ядра на время показа страницы.</li>
<li>Пароли клиентов лежат только в ядре и только в виде bcrypt-хеша; кабинет их не видит.</li>
<li>Страница не обращается к сторонним сайтам — шрифты и оформление вшиты в программу.</li>
<li>Вход сотрудника в кабинет клиента записывается в журнал доступа (<code>lk.audit</code>) и поднимает оповещение.</li>
<li>Обновляется кабинет отдельно от ядра — через сервер обновлений, по лицензии модуля «Личный кабинет клиента».</li>
</ul>
</div>
<div class="card">
<h2>API личного кабинета (ESIA Finance V1)</h2>
<p class="muted">Bridge-and-Join-s реализует контракт ESIA Finance V1 на стороне back-office. ЛК клиента отправляет нам заявку, мы возвращаем подтверждение и потом колбэк со статусом. Полная спецификация: <code>docs/lk-contract/v1/openapi.yaml</code>.</p>
@@ -603,6 +603,64 @@ function hbBind(inp,box,list,onPick){
<p class="muted" style="margin:-8px 0 16px">Интеграция с платформой ЛК <strong>ЕСИА Финанс</strong>: адрес для клиента, приём заявок M2M, возврат статуса и подключение к API (тест/бой). НРД настраивается отдельно — в разделе «НРД».</p>
{{template "verline" (.Versions.Get "bj-lk")}}
<div class="card" id="sec-lk-dist">
<h2><span class="dot {{if .Settings.LK.APIKey}}ok{{else}}warn{{end}}"></span>Кабинет клиента на своём сервере</h2>
<p class="muted">Кабинет (<code>bj-lk</code>) — отдельная программа для веб-сервера депозитария или хостинга. Своей базы у неё нет: данные она берёт у ядра по защищённому каналу и не хранит дольше запроса. Порядок: <b>1)</b> создайте ключ доступа, <b>2)</b> выберите способ связи, <b>3)</b> скачайте архив и распакуйте его на сервере кабинета, <b>4)</b> выполните там <code>sudo ./install.sh</code>, <b>5)</b> опубликуйте кабинет через nginx с сертификатом (пример в README архива).</p>
<form method="post" action="/admin/setup/lk/dist" style="display:grid;gap:14px;max-width:780px;margin-top:12px">
<input type="hidden" name="section" value="sec-lk-dist"><input type="hidden" name="core_url_set" value="1"><input type="hidden" name="mode_set" value="1">
<div>
<label>Ключ доступа модуля</label>
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;flex-wrap:wrap">
<code style="flex:1;min-width:260px;overflow-wrap:anywhere">{{if .LKKeyMask}}{{.LKKeyMask}}{{else}}не создан — кабинет не сможет обращаться к ядру{{end}}</code>
<button type="submit" name="op" value="newkey" class="btn btn-secondary">{{if .Settings.LK.APIKey}}Сменить ключ{{else}}Создать ключ{{end}}</button>
{{if .Settings.LK.APIKey}}<button type="submit" name="op" value="clearkey" class="btn btn-secondary" onclick="return confirm('Удалить ключ? Кабинет перестанет получать данные, пока не впишете новый.')">Удалить</button>{{end}}
</div>
<span class="muted" style="font-size:12px">Ключ показан не полностью — целиком он попадает только в архив, в файл <code>/etc/bj-lk/lk.env</code> на сервере кабинета. При смене ключа скачайте архив заново или впишите новый ключ в этот файл и перезапустите службу кабинета.</span>
</div>
<div>
<label style="margin-bottom:8px">Как кабинет и ядро находят друг друга</label>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:600"><input type="radio" name="mode" value="direct" {{if not .Settings.LK.AgentMode}}checked{{end}}> <span>Кабинет обращается к ядру <span class="muted" style="font-weight:400">— обычный случай: у ядра есть адрес, доступный серверу кабинета.</span></span></label>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:600;margin-top:6px"><input type="radio" name="mode" value="agent" {{if .Settings.LK.AgentMode}}checked{{end}}> <span>Ядро подключается к кабинету <span class="muted" style="font-weight:400">— если у ядра нет адреса в интернете. Ядро само звонит кабинету по его имени, забирает запросы клиентов и возвращает ответы; входящих соединений и открытых портов не требуется.</span></span></label>
</div>
<div>
<label>Адрес ядра для кабинета <span class="muted" style="font-weight:400">— для режима «кабинет обращается к ядру»</span></label>
<input type="text" name="core_url" value="{{.Settings.LK.CoreURL}}" placeholder="https://bj.example.ru" style="margin-top:4px">
<span class="muted" style="font-size:12px">Куда кабинет обращается за данными. Если кабинет и ядро в одной сети — внутренний адрес; если в разных — тот, что открыт межсетевым экраном. Только <code>https</code>: по этому каналу идут ключ и сессии клиентов.</span>
</div>
<div>
<label>Адрес кабинета на хостинге <span class="muted" style="font-weight:400">— для режима «ядро подключается к кабинету»</span></label>
<input type="text" name="cabinet_url" value="{{.Settings.LK.CabinetURL}}" placeholder="https://lk.example.ru" style="margin-top:4px">
<span class="muted" style="font-size:12px">Имя, по которому ядро дозванивается до кабинета. Оно же — адрес, по которому входят клиенты.</span>
</div>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:600"><input type="checkbox" name="bind_network" value="1" {{if .Settings.LK.BindNetwork}}checked{{end}}> <span>Привязывать сессию клиента к его сети <span class="muted" style="font-weight:400">— к браузеру сессия привязана всегда; эта отметка добавляет сверку сети. Строже, но клиентов с мобильным интернетом будет выбрасывать при смене адреса оператором.</span></span></label>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button type="submit" class="btn btn-ok">Сохранить</button>
{{if .Settings.LK.APIKey}}<a class="btn" href="/admin/setup/lk/dist.tar.gz">Скачать дистрибутив кабинета</a>{{end}}
<a class="btn btn-secondary" href="/admin/help/lk">Инструкция по размещению</a>
</div>
</form>
{{if .Settings.LK.AgentMode}}
<div style="margin-top:14px;padding:12px 14px;border-radius:8px;background:var(--bg-soft,#f6f7f9)">
<b>Связь с кабинетом</b> <span class="dot {{if .LKAgent.Connected}}ok{{else}}err{{end}}"></span>
{{if .LKAgent.Connected}}ядро подключено к {{.LKAgent.Cabinet}}{{else}}ядро не дозвонилось до кабинета{{end}}
<div class="muted" style="font-size:12px;margin-top:4px">
{{if .LKAgent.LastOK}}Последний обмен: {{.LKAgent.LastOK}}. {{end}}Обслужено запросов: {{.LKAgent.Served}}.
{{if .LKAgent.LastErr}}<br>Ошибка: {{.LKAgent.LastErr}}{{end}}
</div>
</div>
{{end}}
<p class="muted" style="font-size:12px;margin-top:10px">В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек с правами 0600, установщик и README. Внутри архива лежит боевой ключ — после установки удалите архив с рабочего компьютера. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.</p>
</div>
<div class="card">
<h2><span class="dot {{if .Settings.LK.PublicURL}}ok{{else}}warn{{end}}"></span>Личный кабинет — интеграция</h2>
<p class="muted">Адрес ЛК (стенд/бой), куда клиент подаёт заявку M2M. Заявка приходит к нам по контракту <code>POST /api/v1/back_office/claims/</code>, а мы возвращаем статус через <strong>Callback URL</strong>.</p>