diff --git a/cmd/bj-lk/main.go b/cmd/bj-lk/main.go index 948f20b..552ab98 100644 --- a/cmd/bj-lk/main.go +++ b/cmd/bj-lk/main.go @@ -8,8 +8,12 @@ // // BJ_URL адрес bj-server (напр. https://bj.example.ru) // BJ_KEY ключ доступа модуля (выдаётся в админке bj-server) +// BJ_MODE direct | agent (agent — ядро подключается к кабинету само) // LISTEN адрес прослушивания (по умолчанию :8081) // LK_TITLE название кабинета в шапке (по умолчанию «Личный кабинет») +// +// Режим agent нужен, когда у ядра нет адреса, доступного из интернета: тогда адрес +// ядра не задаётся, а кабинет ждёт, пока ядро подключится к нему само. package main import ( @@ -18,6 +22,7 @@ import ( "log" "net/http" "os" + "strings" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/modules/lk" @@ -27,8 +32,10 @@ import ( var version = "0.1.0" func main() { - cfg := lk.Config{BJURL: os.Getenv("BJ_URL"), BJKey: os.Getenv("BJ_KEY"), Title: os.Getenv("LK_TITLE")} + cfg := lk.Config{BJURL: os.Getenv("BJ_URL"), BJKey: os.Getenv("BJ_KEY"), Title: os.Getenv("LK_TITLE"), + Agent: strings.EqualFold(strings.TrimSpace(os.Getenv("BJ_MODE")), "agent")} listen := os.Getenv("LISTEN") + flag.BoolVar(&cfg.Agent, "agent", cfg.Agent, "режим ожидания: ядро подключается к кабинету само") flag.StringVar(&cfg.BJURL, "bj-url", cfg.BJURL, "адрес bj-server (https://...)") flag.StringVar(&cfg.BJKey, "bj-key", cfg.BJKey, "ключ доступа модуля") flag.StringVar(&listen, "listen", listen, "адрес прослушивания (:8081)") @@ -47,10 +54,29 @@ func main() { log.Fatalf("bj-lk: %v", err) } if !srv.Configured() { - log.Printf("bj-lk: не заданы BJ_URL и BJ_KEY — кабинет показывает страницу настройки (см. /etc/bj/lk.env)") + log.Printf("bj-lk: не заданы BJ_KEY и адрес ядра — кабинет показывает страницу настройки (см. /etc/bj-lk/lk.env)") + } + if srv.InsecureCore() { + // Не отказываем в запуске (бывают закрытые сети со своим шифрованием), + // но это надо видеть в журнале: по такому каналу идут ключ и токены сессий. + log.Printf("bj-lk: ВНИМАНИЕ — адрес ядра задан по открытому http (%s): ключ модуля и сессии клиентов пойдут по сети незашифрованными. Используйте https.", cfg.BJURL) + } + if cfg.Agent { + log.Printf("bj-lk %s (Личный кабинет) слушает %s, режим ожидания: ядро подключается само", version, listen) + } else { + log.Printf("bj-lk %s (Личный кабинет) слушает %s, ядро: %s", version, listen, cfg.BJURL) + } + // Таймауты обязательны: кабинет стоит в интернете, и без них медленный клиент + // может держать соединения сколько угодно. Ответ живёт дольше запроса — в режиме + // ожидания страница ждёт, пока ядро заберёт запрос и ответит. + hs := &http.Server{ + Addr: listen, Handler: srv.Handler(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 20 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 32 << 10, } - log.Printf("bj-lk %s (Личный кабинет) слушает %s, bj-server: %s", version, listen, cfg.BJURL) - hs := &http.Server{Addr: listen, Handler: srv.Handler(), ReadHeaderTimeout: 5 * time.Second} if err := hs.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(err) } diff --git a/internal/lkgateway/depodb.go b/internal/lkgateway/depodb.go index fc20226..1c300cc 100644 --- a/internal/lkgateway/depodb.go +++ b/internal/lkgateway/depodb.go @@ -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) +} diff --git a/internal/lkgateway/lkagent.go b/internal/lkgateway/lkagent.go new file mode 100644 index 0000000..f0fac20 --- /dev/null +++ b/internal/lkgateway/lkagent.go @@ -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 + } +} diff --git a/internal/lkgateway/lkagent_test.go b/internal/lkgateway/lkagent_test.go new file mode 100644 index 0000000..6f93343 --- /dev/null +++ b/internal/lkgateway/lkagent_test.go @@ -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) + } +} diff --git a/internal/lkgateway/lkapi.go b/internal/lkgateway/lkapi.go index 8615782..07cbce7 100644 --- a/internal/lkgateway/lkapi.go +++ b/internal/lkgateway/lkapi.go @@ -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 diff --git a/internal/lkgateway/lkaudit.go b/internal/lkgateway/lkaudit.go new file mode 100644 index 0000000..d11d240 --- /dev/null +++ b/internal/lkgateway/lkaudit.go @@ -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() +} diff --git a/internal/lkgateway/lkcoverage_live_test.go b/internal/lkgateway/lkcoverage_live_test.go new file mode 100644 index 0000000..c7880c8 --- /dev/null +++ b/internal/lkgateway/lkcoverage_live_test.go @@ -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) +} diff --git a/internal/lkgateway/lkdist.go b/internal/lkgateway/lkdist.go new file mode 100644 index 0000000..9937fa1 --- /dev/null +++ b/internal/lkgateway/lkdist.go @@ -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" +} diff --git a/internal/lkgateway/lkdist_test.go b/internal/lkgateway/lkdist_test.go new file mode 100644 index 0000000..91e3f86 --- /dev/null +++ b/internal/lkgateway/lkdist_test.go @@ -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 +} diff --git a/internal/lkgateway/lkdocs.go b/internal/lkgateway/lkdocs.go new file mode 100644 index 0000000..af0c548 --- /dev/null +++ b/internal/lkgateway/lkdocs.go @@ -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)) +} diff --git a/internal/lkgateway/lkhistory.go b/internal/lkgateway/lkhistory.go index cc0ea39..612b0d4 100644 --- a/internal/lkgateway/lkhistory.go +++ b/internal/lkgateway/lkhistory.go @@ -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 } diff --git a/internal/lkgateway/lksecurity_test.go b/internal/lkgateway/lksecurity_test.go new file mode 100644 index 0000000..d63b655 --- /dev/null +++ b/internal/lkgateway/lksecurity_test.go @@ -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) + } +} diff --git a/internal/lkgateway/lksession.go b/internal/lkgateway/lksession.go new file mode 100644 index 0000000..343f4a1 --- /dev/null +++ b/internal/lkgateway/lksession.go @@ -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) +} diff --git a/internal/lkgateway/lkstore.go b/internal/lkgateway/lkstore.go index ed6f16a..4b6b33a 100644 --- a/internal/lkgateway/lkstore.go +++ b/internal/lkgateway/lkstore.go @@ -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 } diff --git a/internal/lkgateway/lkunpriced_live_test.go b/internal/lkgateway/lkunpriced_live_test.go new file mode 100644 index 0000000..56ec854 --- /dev/null +++ b/internal/lkgateway/lkunpriced_live_test.go @@ -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) +} diff --git a/internal/lkgateway/lkusers.go b/internal/lkgateway/lkusers.go index 8be276c..520df5f 100644 --- a/internal/lkgateway/lkusers.go +++ b/internal/lkgateway/lkusers.go @@ -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 +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 8689814..bb8a67c 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -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" diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 29192ff..777b135 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -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) diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 5b8a763..2d5b2d2 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -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), diff --git a/internal/lkgateway/web/templates/admin_help_lk.html b/internal/lkgateway/web/templates/admin_help_lk.html index 3dd2d8d..5dacac9 100644 --- a/internal/lkgateway/web/templates/admin_help_lk.html +++ b/internal/lkgateway/web/templates/admin_help_lk.html @@ -1,6 +1,39 @@ {{define "content"}}
+Речь о нашем модуле bj-lk — кабинете, который клиенты открывают в браузере. Он ставится отдельно от ядра: на веб-сервер депозитария или на обычный хостинг с выделенным именем. Своей базы у кабинета нет, данные клиентов он не хранит — берёт у ядра на время запроса.
Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек, установщик и README. Внутри архива боевой ключ: после установки удалите архив с рабочего компьютера, не пересылайте его почтой.
+ +tar xzf bj-lk-ГГГГ-ММ-ДД.tar.gz
+cd bj-lk
+sudo ./install.sh
+curl -s http://127.0.0.1:8099/health # {"ok":true}
+ Установщик заводит системного пользователя bjlk, кладёт программу в /opt/bj-lk, настройки — в /etc/bj-lk/lk.env (права 0600) и включает службу bj-lk. Сам кабинет слушает только 127.0.0.1:8099.
Перед кабинетом ставится nginx с сертификатом TLS — пример конфигурации есть в README архива. Наружу открыт только 443 порт сервера кабинета.
+ +Кабинет обращается к ядру (обычный случай). В настройках ядра укажите его адрес, кабинет ходит туда за данными. Требуется, чтобы сервер кабинета мог открыть соединение к ядру по https.
+Ядро подключается к кабинету. Если у ядра нет адреса в интернете — а так чаще всего и бывает, ядро стоит во внутренней сети депозитария, — порядок разворачивается. Кабинет ничего никуда не звонит: он копит запросы клиентов и ждёт. Ядро само подключается к кабинету по его доменному имени, забирает запрос, выполняет его у себя и возвращает ответ. Из сети депозитария идёт только исходящее соединение: ни белого адреса, ни проброса портов, ни правил для входящего трафика не нужно.
+Включается в настройках ядра: «Личный кабинет» → «Как кабинет и ядро находят друг друга» → «Ядро подключается к кабинету», рядом — адрес кабинета. В файле кабинета при этом стоит BJ_MODE=agent, а BJ_URL не задан. Состояние связи видно там же: подключено ли ядро, когда был последний обмен, сколько запросов обслужено.
В обе стороны канал защищён тем же ключом модуля: запрос без ключа кабинет не отдаёт, ответ без ключа не принимает.
+ +lk.audit) и поднимает оповещение.Bridge-and-Join-s реализует контракт ESIA Finance V1 на стороне back-office. ЛК клиента отправляет нам заявку, мы возвращаем подтверждение и потом колбэк со статусом. Полная спецификация: docs/lk-contract/v1/openapi.yaml.
Интеграция с платформой ЛК ЕСИА Финанс: адрес для клиента, приём заявок M2M, возврат статуса и подключение к API (тест/бой). НРД настраивается отдельно — в разделе «НРД».
{{template "verline" (.Versions.Get "bj-lk")}} +Кабинет (bj-lk) — отдельная программа для веб-сервера депозитария или хостинга. Своей базы у неё нет: данные она берёт у ядра по защищённому каналу и не хранит дольше запроса. Порядок: 1) создайте ключ доступа, 2) выберите способ связи, 3) скачайте архив и распакуйте его на сервере кабинета, 4) выполните там sudo ./install.sh, 5) опубликуйте кабинет через nginx с сертификатом (пример в README архива).
В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек с правами 0600, установщик и README. Внутри архива лежит боевой ключ — после установки удалите архив с рабочего компьютера. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.
+Адрес ЛК (стенд/бой), куда клиент подаёт заявку M2M. Заявка приходит к нам по контракту POST /api/v1/back_office/claims/, а мы возвращаем статус через Callback URL.
%s