From 2a7442c9354ad3c7fff89eac8458159479fe9e56 Mon Sep 17 00:00:00 2001 From: zuevav Date: Thu, 17 Sep 2026 23:47:10 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D0=BF=D0=BE=D1=80=D0=B0=D1=82=D0=B8=D0=B2=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D1=8F?= =?UTF-8?q?,=20=D0=B4=D0=B8=D1=81=D1=82=D1=80=D0=B8=D0=B1=D1=83=D1=82?= =?UTF-8?q?=D0=B8=D0=B2,=20=D0=BE=D0=B1=D1=80=D0=B0=D1=82=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB,=20=D0=B7=D0=B0=D1=89?= =?UTF-8?q?=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Данные - корпоративные действия по бумагам клиента (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 --- cmd/bj-lk/main.go | 34 +- internal/lkgateway/depodb.go | 12 +- internal/lkgateway/lkagent.go | 254 +++++++++++++ internal/lkgateway/lkagent_test.go | 90 +++++ internal/lkgateway/lkapi.go | 241 ++++++------ internal/lkgateway/lkaudit.go | 85 +++++ internal/lkgateway/lkcoverage_live_test.go | 120 ++++++ internal/lkgateway/lkdist.go | 311 ++++++++++++++++ internal/lkgateway/lkdist_test.go | 91 +++++ internal/lkgateway/lkdocs.go | 278 ++++++++++++++ internal/lkgateway/lkhistory.go | 2 +- internal/lkgateway/lksecurity_test.go | 187 ++++++++++ internal/lkgateway/lksession.go | 297 +++++++++++++++ internal/lkgateway/lkstore.go | 5 +- internal/lkgateway/lkunpriced_live_test.go | 98 +++++ internal/lkgateway/lkusers.go | 59 ++- internal/lkgateway/runtimeconfig.go | 11 + internal/lkgateway/server.go | 4 + internal/lkgateway/setup.go | 6 + .../web/templates/admin_help_lk.html | 33 ++ .../lkgateway/web/templates/admin_setup.html | 58 +++ modules/lk/agent.go | 199 ++++++++++ modules/lk/assets.go | 39 ++ modules/lk/assets/fonts.css | 4 + modules/lk/assets/golos-cyrillic.woff2 | Bin 0 -> 22032 bytes modules/lk/assets/golos-latin.woff2 | Bin 0 -> 37916 bytes modules/lk/assets/mono-cyrillic.woff2 | Bin 0 -> 8872 bytes modules/lk/assets/mono-latin.woff2 | Bin 0 -> 31432 bytes modules/lk/lk.go | 342 ++++++++++++++---- modules/lk/lk_test.go | 141 +++++++- modules/lk/security.go | 140 +++++++ modules/lk/templates.go | 85 ++++- scripts/lk-sec-smoke.sh | 45 +++ 33 files changed, 3062 insertions(+), 209 deletions(-) create mode 100644 internal/lkgateway/lkagent.go create mode 100644 internal/lkgateway/lkagent_test.go create mode 100644 internal/lkgateway/lkaudit.go create mode 100644 internal/lkgateway/lkcoverage_live_test.go create mode 100644 internal/lkgateway/lkdist.go create mode 100644 internal/lkgateway/lkdist_test.go create mode 100644 internal/lkgateway/lkdocs.go create mode 100644 internal/lkgateway/lksecurity_test.go create mode 100644 internal/lkgateway/lksession.go create mode 100644 internal/lkgateway/lkunpriced_live_test.go create mode 100644 modules/lk/agent.go create mode 100644 modules/lk/assets.go create mode 100644 modules/lk/assets/fonts.css create mode 100644 modules/lk/assets/golos-cyrillic.woff2 create mode 100644 modules/lk/assets/golos-latin.woff2 create mode 100644 modules/lk/assets/mono-cyrillic.woff2 create mode 100644 modules/lk/assets/mono-latin.woff2 create mode 100644 modules/lk/security.go create mode 100755 scripts/lk-sec-smoke.sh 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 — кабинете, который клиенты открывают в браузере. Он ставится отдельно от ядра: на веб-сервер депозитария или на обычный хостинг с выделенным именем. Своей базы у кабинета нет, данные клиентов он не хранит — берёт у ядра на время запроса.

+ +

Шаг 1. Ключ и архив

+

Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек, установщик и README. Внутри архива боевой ключ: после установки удалите архив с рабочего компьютера, не пересылайте его почтой.

+ +

Шаг 2. Установка на сервере кабинета

+
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.

+ +

Шаг 3. Публикация наружу

+

Перед кабинетом ставится nginx с сертификатом TLS — пример конфигурации есть в README архива. Наружу открыт только 443 порт сервера кабинета.

+ +

Шаг 4. Связь с ядром — два способа

+

Кабинет обращается к ядру (обычный случай). В настройках ядра укажите его адрес, кабинет ходит туда за данными. Требуется, чтобы сервер кабинета мог открыть соединение к ядру по https.

+

Ядро подключается к кабинету. Если у ядра нет адреса в интернете — а так чаще всего и бывает, ядро стоит во внутренней сети депозитария, — порядок разворачивается. Кабинет ничего никуда не звонит: он копит запросы клиентов и ждёт. Ядро само подключается к кабинету по его доменному имени, забирает запрос, выполняет его у себя и возвращает ответ. Из сети депозитария идёт только исходящее соединение: ни белого адреса, ни проброса портов, ни правил для входящего трафика не нужно.

+

Включается в настройках ядра: «Личный кабинет» → «Как кабинет и ядро находят друг друга» → «Ядро подключается к кабинету», рядом — адрес кабинета. В файле кабинета при этом стоит BJ_MODE=agent, а BJ_URL не задан. Состояние связи видно там же: подключено ли ядро, когда был последний обмен, сколько запросов обслужено.

+

В обе стороны канал защищён тем же ключом модуля: запрос без ключа кабинет не отдаёт, ответ без ключа не принимает.

+ +

Что с данными

+
    +
  • Кабинет не хранит ни базы, ни файлов: портфель, операции и документы приходят из ядра на время показа страницы.
  • +
  • Пароли клиентов лежат только в ядре и только в виде bcrypt-хеша; кабинет их не видит.
  • +
  • Страница не обращается к сторонним сайтам — шрифты и оформление вшиты в программу.
  • +
  • Вход сотрудника в кабинет клиента записывается в журнал доступа (lk.audit) и поднимает оповещение.
  • +
  • Обновляется кабинет отдельно от ядра — через сервер обновлений, по лицензии модуля «Личный кабинет клиента».
  • +
+
+

API личного кабинета (ESIA Finance V1)

Bridge-and-Join-s реализует контракт ESIA Finance V1 на стороне back-office. ЛК клиента отправляет нам заявку, мы возвращаем подтверждение и потом колбэк со статусом. Полная спецификация: docs/lk-contract/v1/openapi.yaml.

diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index c9b36fa..5195033 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -603,6 +603,64 @@ function hbBind(inp,box,list,onPick){

Интеграция с платформой ЛК ЕСИА Финанс: адрес для клиента, приём заявок M2M, возврат статуса и подключение к API (тест/бой). НРД настраивается отдельно — в разделе «НРД».

{{template "verline" (.Versions.Get "bj-lk")}} +
+

Кабинет клиента на своём сервере

+

Кабинет (bj-lk) — отдельная программа для веб-сервера депозитария или хостинга. Своей базы у неё нет: данные она берёт у ядра по защищённому каналу и не хранит дольше запроса. Порядок: 1) создайте ключ доступа, 2) выберите способ связи, 3) скачайте архив и распакуйте его на сервере кабинета, 4) выполните там sudo ./install.sh, 5) опубликуйте кабинет через nginx с сертификатом (пример в README архива).

+ +
+ + +
+ +
+ {{if .LKKeyMask}}{{.LKKeyMask}}{{else}}не создан — кабинет не сможет обращаться к ядру{{end}} + + {{if .Settings.LK.APIKey}}{{end}} +
+ Ключ показан не полностью — целиком он попадает только в архив, в файл /etc/bj-lk/lk.env на сервере кабинета. При смене ключа скачайте архив заново или впишите новый ключ в этот файл и перезапустите службу кабинета. +
+ +
+ + + +
+ +
+ + + Куда кабинет обращается за данными. Если кабинет и ядро в одной сети — внутренний адрес; если в разных — тот, что открыт межсетевым экраном. Только https: по этому каналу идут ключ и сессии клиентов. +
+ +
+ + + Имя, по которому ядро дозванивается до кабинета. Оно же — адрес, по которому входят клиенты. +
+ + + + +
+ + {{if .Settings.LK.AgentMode}} +
+ Связь с кабинетом + {{if .LKAgent.Connected}}ядро подключено к {{.LKAgent.Cabinet}}{{else}}ядро не дозвонилось до кабинета{{end}} +
+ {{if .LKAgent.LastOK}}Последний обмен: {{.LKAgent.LastOK}}. {{end}}Обслужено запросов: {{.LKAgent.Served}}. + {{if .LKAgent.LastErr}}
Ошибка: {{.LKAgent.LastErr}}{{end}} +
+
+ {{end}} + +

В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек с правами 0600, установщик и README. Внутри архива лежит боевой ключ — после установки удалите архив с рабочего компьютера. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.

+
+

Личный кабинет — интеграция

Адрес ЛК (стенд/бой), куда клиент подаёт заявку M2M. Заявка приходит к нам по контракту POST /api/v1/back_office/claims/, а мы возвращаем статус через Callback URL.

diff --git a/modules/lk/agent.go b/modules/lk/agent.go new file mode 100644 index 0000000..cfe6aa0 --- /dev/null +++ b/modules/lk/agent.go @@ -0,0 +1,199 @@ +package lk + +// agent.go — режим ожидания для случая, когда у ядра нет адреса, доступного кабинету. +// +// Обычно кабинет сам ходит в ядро: BJ_URL указывает на bj-server. Но ядро часто стоит во +// внутренней сети депозитария без публичного имени, а кабинет — на хостинге. Тогда связь +// разворачивается наоборот: кабинет держит очередь запросов, а ядро само подключается к +// кабинету, забирает запрос, выполняет его у себя и возвращает ответ. Наружу из сети +// депозитария идёт только исходящее соединение. +// +// клиент → страница кабинета → очередь → (ядро забирает) → ответ → страница +// +// Оба обращения ядра защищены тем же ключом модуля, что и прямой режим. + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "sync" + "time" +) + +// agentTask — один запрос кабинета к ядру, ожидающий ответа. +type agentTask struct { + ID string `json:"id"` + Method string `json:"method"` + Path string `json:"path"` + Body []byte `json:"body,omitempty"` + Session string `json:"session,omitempty"` + // Адрес и браузер посетителя: ядро привязывает к ним сессию и считает по адресу + // попытки входа. В прямом режиме они идут заголовками, здесь — полями запроса. + ClientIP string `json:"client_ip,omitempty"` + ClientAgent string `json:"client_agent,omitempty"` + + done chan agentResult +} + +// agentResult — ответ ядра. +type agentResult struct { + Status int `json:"status"` + Body []byte `json:"body,omitempty"` +} + +// agentQueue — очередь запросов к ядру и ожидающие ответа. +type agentQueue struct { + mu sync.Mutex + queue []*agentTask + waiting map[string]*agentTask + signal chan struct{} + seq uint64 +} + +func newAgentQueue() *agentQueue { + return &agentQueue{waiting: map[string]*agentTask{}, signal: make(chan struct{}, 1)} +} + +// push — поставить запрос в очередь и ждать ответа ядра. +func (q *agentQueue) push(ctx context.Context, method, path string, body []byte, session string, wait time.Duration) (agentResult, error) { + q.mu.Lock() + q.seq++ + t := &agentTask{ID: idFromSeq(q.seq), Method: method, Path: path, Body: body, Session: session, done: make(chan agentResult, 1)} + if v, ok := ctx.Value(visitorKey{}).(visitor); ok { + t.ClientIP, t.ClientAgent = v.ip, v.agent + } + q.queue = append(q.queue, t) + q.waiting[t.ID] = t + q.mu.Unlock() + select { + case q.signal <- struct{}{}: + default: + } + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case res := <-t.done: + return res, nil + case <-ctx.Done(): + q.forget(t.ID) + return agentResult{}, ctx.Err() + case <-timer.C: + q.forget(t.ID) + return agentResult{}, errors.New("ядро не ответило: проверьте, что bj-server подключён к кабинету") + } +} + +func (q *agentQueue) forget(id string) { + q.mu.Lock() + delete(q.waiting, id) + q.mu.Unlock() +} + +// next — выдать ядру очередной запрос; ждёт до wait, если очередь пуста. +func (q *agentQueue) next(ctx context.Context, wait time.Duration) *agentTask { + for { + q.mu.Lock() + if len(q.queue) > 0 { + t := q.queue[0] + q.queue = q.queue[1:] + q.mu.Unlock() + return t + } + q.mu.Unlock() + timer := time.NewTimer(wait) + select { + case <-q.signal: + timer.Stop() + case <-timer.C: + return nil + case <-ctx.Done(): + timer.Stop() + return nil + } + } +} + +// reply — вернуть ответ ядра ожидающему запросу. +func (q *agentQueue) reply(id string, res agentResult) bool { + q.mu.Lock() + t, ok := q.waiting[id] + delete(q.waiting, id) + q.mu.Unlock() + if !ok { + return false + } + select { + case t.done <- res: + default: + } + return true +} + +func idFromSeq(n uint64) string { + const alphabet = "0123456789abcdef" + buf := make([]byte, 0, 16) + for i := 0; i < 16; i++ { + buf = append(buf, alphabet[(n>>(uint(i)*4))&0xf]) + } + return string(buf) +} + +// registerAgent — точки, куда подключается ядро (только в режиме ожидания). +func (s *Server) registerAgent(mux *http.ServeMux) { + keyOK := func(w http.ResponseWriter, r *http.Request) bool { + if !secureEqual(r.Header.Get("X-BJ-Key"), s.cfg.BJKey) { + http.Error(w, "неверный ключ модуля", http.StatusForbidden) + return false + } + return true + } + // Ядро забирает очередной запрос; если запросов нет — ответ 204 через несколько секунд. + mux.HandleFunc("/agent/tasks", func(w http.ResponseWriter, r *http.Request) { + if !keyOK(w, r) { + return + } + wait := 25 * time.Second + t := s.agent.next(r.Context(), wait) + if t == nil { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": t.ID, "method": t.Method, "path": t.Path, "session": t.Session, + "client_ip": t.ClientIP, "client_agent": t.ClientAgent, + "body": base64.StdEncoding.EncodeToString(t.Body), + }) + }) + // Ядро возвращает ответ на запрос. + mux.HandleFunc("/agent/result", func(w http.ResponseWriter, r *http.Request) { + if !keyOK(w, r) { + return + } + var in struct { + ID string `json:"id"` + Status int `json:"status"` + Body string `json:"body"` + } + if json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&in) != nil || in.ID == "" { + http.Error(w, "не разобрали ответ", http.StatusBadRequest) + return + } + body, _ := base64.StdEncoding.DecodeString(in.Body) + if !s.agent.reply(in.ID, agentResult{Status: in.Status, Body: body}) { + w.WriteHeader(http.StatusGone) // запрос уже не ждут: клиент ушёл или истёк срок + return + } + w.WriteHeader(http.StatusNoContent) + }) + // Состояние связи для страницы «не настроено» и проверки администратором. + mux.HandleFunc("/agent/state", func(w http.ResponseWriter, r *http.Request) { + if !keyOK(w, r) { + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "mode": "agent"}) + }) +} diff --git a/modules/lk/assets.go b/modules/lk/assets.go new file mode 100644 index 0000000..c432abe --- /dev/null +++ b/modules/lk/assets.go @@ -0,0 +1,39 @@ +package lk + +// assets.go — шрифты кабинета внутри программы. +// +// Кабинет показывает персональные данные клиентов, поэтому страница не должна тянуть +// ничего со сторонних сайтов: иначе адреса клиентов и заголовок Referer уходят третьей +// стороне, строгие правила безопасности (CSP) становятся невозможны, а в закрытом +// сегменте страница ещё и ждёт недоступную сеть. Шрифты вшиты в файл программы. + +import ( + "embed" + "net/http" + "strings" + "time" +) + +//go:embed assets +var assetsFS embed.FS + +// assetsHandler — отдаёт /assets/*: только вшитые файлы, ничего с диска. +func assetsHandler() http.Handler { + start := time.Now() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/") + data, err := assetsFS.ReadFile(name) + if err != nil { + http.NotFound(w, r) + return + } + switch { + case strings.HasSuffix(name, ".css"): + w.Header().Set("Content-Type", "text/css; charset=utf-8") + case strings.HasSuffix(name, ".woff2"): + w.Header().Set("Content-Type", "font/woff2") + } + w.Header().Set("Cache-Control", "public, max-age=604800, immutable") + http.ServeContent(w, r, name, start, strings.NewReader(string(data))) + }) +} diff --git a/modules/lk/assets/fonts.css b/modules/lk/assets/fonts.css new file mode 100644 index 0000000..11a5b39 --- /dev/null +++ b/modules/lk/assets/fonts.css @@ -0,0 +1,4 @@ +@font-face{font-family:'Golos Text';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/golos-cyrillic.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116} +@font-face{font-family:'Golos Text';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/golos-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} +@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/mono-cyrillic.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116} +@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/mono-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} diff --git a/modules/lk/assets/golos-cyrillic.woff2 b/modules/lk/assets/golos-cyrillic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f8fef0ceeb28062a19b7466a4b31b026d2542560 GIT binary patch literal 22032 zcmV)4K+3;&Pew8T0RR9109FtH761SM0I!q)09BR%0RR9100000000000000000000 z0000Qhkkf0we>390VW*g(wG!CJYA`Bp8Gk8_T8_qI35~DcuS5()RMzlP1$`J_6@~!qg$& zGZG1c^&Ix-Li3xbD^_q}c$-q>8=Z;DnaG_eQ6&tqBwQrs?@#Jsjo5>JO2MV;$G%`k z_A`Cg*o*e)vBxAyA{B{NFlVquS0&mcDEJ3rlW5Tr6t|iu3IAYYy-|+nM-u)lsxwjl zg>QXf2Zf|#YGizy@5m7v`y|Qb`TzIt;hk%rbMf8>%nA8!pa zTe8Nh8JT8wyx=|LAp1ZWrT?-Ya|G0u_iY&7Wgjw34{69C=&tUrYT%#Zg1z0nsEL{h z0P?^OfJ6EAxL@`lF*8YPRfSssR23%Q{ zc2>lCdgz)-cb^K6KKWbo5!Z}W=ya(tRA|GHAW;%75lHOcTZtwRPbt6z;&FUD3N9Ug z{^?oooebvp+BDI(_Kks*93j-MU0;3m6-|{o?Ez1K#xT+@{8MW?N5rExre6s({UH%D zZ#Tq%`IXK3FEgt$tGc?Hq?SgSU7mkHXrW=Fb!VaVty8sTc8{zV-#b7X0Ty_6SJR`$ zn%ZB#TOaCyp3do1zZNgrAH)Ju&SthrdkKt>=YN^j(*N{JrJg;E`Ua!Lt_s!&e2_U4 z6J)k>JJVX%dfPWLxVb*(w0B;IaVCY32$F0UV{Ax~1q3-sM3AKXeX3U12Z+ytWcCcq zPS8YZHhoT{x3=yYdsDuhKK^I;04Vztl-dAkt3lG91u3-`q!XofO;MKR*kMu(pd5;{ zXRD-%4in{zXh^IZAZhMXhlhX&71x9TxflTA+>!t_)Spnj$ZFb^Kh{1nvOM7k= zg9nHoO|Y;*QLs>adjE&*!TU6(T+rkxHdEyFX`J#?(be z*>iK}9DB6%ND(5Y0bP$4By_ELgS{U>gcybd42;>9VcH5D%Okb*8f*)NRZ&?Dy+yTI zGqbg^TL+)*6t%csJ8amFny{0m{NiXymMsaP1T&uKo#o!v_H{J#pKkGCycrMS7C9oR zf8%>{@Dm(rGth5m-#hXM+55)2U`G0F$2yVydPhq6UAacYIF^_(LE@Hb8e9x%UT0QO z=)`3iZSm*$ySm(+YvV=34p;e44hj8wx%ZzL`(LeVA8P4zt##r153F-7XOUd+eHU6C zSJ`9U2dbpo?VUV-M|6CvO1^5J#I9rL0Avx2LeN?_0oE}fB(W9MWwe5V`dGyw z9KkVpU^-8d-i1|fRn%Uk{lz!3JC%X75K)~)$TX5z20(H$X?=qL(q5sR$ex`bX(1JA zV>$6__y&Ojl9GzCEtGU`O+2U0o|r-=rlhli%eX6#O!r1Iyo=}Z#vfbFg^%Xe{~)akTT^@u`h-i6^kT;9m{V znq}uRRXsP*oL;sKCAfNJ!X7YJ6oF$zM8ye&8VQkC2-UfXA>XAft{Os%b%9VLQLUE9 z%VoJM&l?|M^(D#4`bT8GbZE-{(xPxGJx|A`pvPzlCyZiCByQ+>6v{U^q1)l}{-oK& z9xUZzPRz!hj?(yj+r6OY}C8Ga#Y{AG}#{JD5&8OX^Sk?#_c+h$jO}HAzUqaN3?E0{ZxA10Ijxn zCoKI9zkTXtYVy(j2dzIqk0MjSP@#4Ee$Va8A~}fG%S5uXZ4<+l{#W7?m+C9&n~VDU zQ4l(k9!F^J%F%M>CU0W@yz*AifcW>iQkUuL@bHBisjl)A%0m?;sItlt zwvykG|Ky9jo%4A#cju<;%9bP(jy-z*p8BxDNI?Mp=o?pD@~ZRhcg9V2nMO*k=y3@< zkE_qG9b_kVfaTU_y^i71@J$71<-p?#(+f`j211NAkrx56R>JF#K@l3Yt8n@%m+|%6 zUIahv`f`}#FiwbeY0UMN;9Es{s?dm3S-dT@skKc_t!_QlwqbogS$wD0E2*5I*$^$I{g+`Vvk_B;N>y0cs@34&;S;DOA|@f7 za=1m-PH1T9+6$Ve8LK-uxwv)k@cJWz!XmmwmpK!o#*DWY4<`cK18XZ3@nB$c)09yX zTbP3Uix5!|*E-M;M}RZIYSe8&6lrhy<^MAU4xyXtvsH6C%8Ky|v(+D%mF}g`-byVQ z0Kl8DL~qukclkf0e9P%S)4rqj9JFsbOJ{63Hyi3V&VL)gSpcd55I{ej{ySjRunz%% z)yuIlA8t$hCtzkVbG}cD`?-bK3`hUSQ;Al8I4%8i{MVWI^W5M6rxwlc^VdvDSwLi* z4}UY>+P3`S&#t|7IbQs6TI$;tNHqK)SG6DLp*$^oKC_ZU`)1$Uk>*v=GI}16|6TYG ze8XDW%(cFBjWW;Xt;fST*HTzFpOwY;%UN~~-$~kUz-?ZFK3uqyTh2%pziG}@uBln) zZSf=|)jF!kd_~@^fF$wdP)mHFxoBs8{ukPflXJBwwAEUvcph2!x>dDHC*}Nfdw-MG zBq?jMs+E`7cd--3t5z%ib1JXm3eB75^B5H`WCZPth`%WxCi4vgkQ`W^PcA|p?}g1Z z&&}uU59ml+O9vFTIgzdrj)e$j4j57zP?8cb zna+yI2Bdb77kGon(SuE`Z8Wl||eB#{BK;viR&A>odrY@s~rg;v5C zCnUROXb#7fnzUz@4uzo!CBmByvOo{g)&k8_3G@AwqExh}0knf9z^u=h&d9i`;FZ^d zlP4tII5bseqL?RTtK?Y}qs#14%&Geq$_6422L)_MAQ+gBDzPk(>zQli27l9mw zxH*Cfxhaz*UNB&bxG@??4H+^LcnW((lpjYDOkvgNTS%E3tbf=B3K85(4_x~Bzx7Y+vumjwU%kBA*KAy$U2)J@ z;dJM3x`#{)lXg(_C&?pcT6$TI^=T)!wG%Do^r+@*dE(#C-^lKhWw>G6NSR9$koVpE zXQ!KU*8a43KF8*kvN_Dmu)t3#2t0Q#4`(m|7x<`{jf-SbTs8|*%7rOg#IRcZRwsUO zy&)3}CavAfm$xGsLsMxS4l$u4RXq3OI2zN;{@QsI%cQ8cY& z@mQk+OnE7!Q7Q$rO&uW$v-)KZb6#>HL9+yzIxY^mMnRp^0pgMIaN{@;w|W&r!Dn~4 zB!e5;nrc)QsoaQg*x9(4Dlh8SzKg@fQ8US$N=Zi>5&?yY2B-hYp@|H&kq$slM*g6!D(>D#LV+w7MRmnIV$3cLn=#o$p7$> z3QRZCLo_oxDrG?vS)6fJX&ZZv*T40^^_bh^w%Mp=gS~ZQhE%} z#Ig+L{iH`8ML8`{Xh&J;Obljdc}^WPsseca{23Ub0Cp{g&8k5Djg|5PZjt{HXo|3k z7-z1LV9uYIHdtq3y$2N*DsgUA&d1b?n)w5&l|JMWgWa*Tef)`0uVn7-n^|Js>@&9K zWj3XDZ3h-tIGn`!A>%Htkh9zac3{*~8+^Q)K0&_+1JF(eT{XloG_u(B-YR)ocgUln zbG%=ViFg3%JvDcVu}^5f4rp8lIYhxkNKKx5IOdcQj*1T-r}#jnaOM`10#ori(*w?g zj}&F+1`wvH5yvE^(pjA*)dfL16JgK}2x*hSOlSr>OiCpx*7?gi$!B$GcFMu@mpJKQ z@*}4m#&U6cUO=rmokQ{`NxaVB8QlWLLBG1*6LvboDOc_$zMRxa$~qJ4J$RypN{H&z zdJi=~nZ_%Je4()(?qQJKjc`=W9Ea>JQUkz9+|jh=lSF|s9)cL!G#UkAp6Dy4 z8A@dhG*cTj!Wtki4$5oxc0H(y*seCni%$W&A|Zs;w3+J=Hcbftma5yYiz4Ey+5@a^ z=5bJOsEfV<0d39UaTDY9R#S;rF%uF5W;>#GrW!9ry+Rw$ADJu72{88PaZCaf)oF5_ zU@n%+aYSp!K(MBYXFD3qALf)p4|9M>n#~BvmKNrCST|5gVNiqI*|63P>}{f=HP{Y!*>MUxRc%smUYgH2s1UX3N`j+yk`*8}Sy%^b^`m4Oq*RZ} zw)7x-PT4w;qRH6HZpkw2D5u;Bqqy^(=NiAQU%_2c!1hBDUK~>^7mX{<Ejc8ZurH4TpA~~BV(mPPBnl*{oW!63>*FXBq?teo!$5P8 zm98|9s)Hy$Ql=n0mIh0~`A^ANH3xtM64NA$*(~9P0Wv~mjO5JS|GGXb^_6_g9d{%~ zfCALlf+a>fGEXtnRF<^K7(uRzE8%p;nqatZA*Q`87NRvnou_3aLxqdBu1D=n z&G=%RScnIhT=p4WNEqY>kP8!9d+Ea`v){vSB`Knu z4u1@82{>`T762kF(G?Ida3m;6!`Qr+J(1>V4?2#Wd+-@RRFe z)(jBj@$2;gh~RMDVJm7raBABDwpP5O>vVEB5S;!U<Lm>z&io8d8L6}i)V~NZYdMk@wOkor+o>VfMO)2H8nxIse_ zl7@{KHD=s|lu1*j&6qW3vw2%=wP4YbW!r4O>irc6-za+NM|KO=CQh`wzAeX{)mys0J9@Yu>JMXU zPfG~)ESDEetA7#TzZ*}mbCP%X^Lr7rU;DOdfir=JZF7rbP5|uAh)GTL!`JI6wKCw4 zl3^(QAug2nFTRvI^%^y3(oCU+oaRTiAKSvp2IE+c?tzQx)u-Q(LGdbX(iEp@b|apb zy$MMvK2iUIZPLzU1_9U<5dk6-<(h;;Y{jpkB|%6^{c_5bze3};2CE0<%P>r|U7gXL z&(e!!6D4PHE*r16I^PL4*bd}!kP`t4anb8)2y-A71#4Ch>x-pOlk+T^#=_dwou;iO zc1T2=py*O0XN#X5Qc)K^ol&tzySFEmApE-T@}TGvHH<38vS$UZWi)kM!bNV#uIqYq z6nmDT>W&sPk;U~OM2KNbv1yowY#FKH8H0mi=n|t$N`xAQs#7g!ASoTSGp(~H*Ilj&wmeg zzf;QXIrs@Uij^+=Vib=lsawAZ~6i`IL_QYF2H>0Oo=Oxald*+QZ#n8U~#HX z%vL6E&fIGwK0+@Ao&8>L+3B_uou=8kEK^;oKSX%?+zo)z72`n*e%cebH(xA(_w#7* z*>mK0@;75MRk_N#03;)=?VOYR`bZ{sOu~^sZx3t#ybre*C_bElwPuiX<>yvD6HIZ_ zVf~Z6I1|-xB44`}-v7LHe#2_@7X1_V?``QTg?93M^SRvt6gf^d+gF_?6b2(4+cl)bd_%B zrMpZk(f#*sWcbLvkQ2{GmM4>8Z*3lzHm4KPNVetROS60yKShH5@(Oq8IF1pXDhcpg{+NZ z1eq#aR9w)wTXSeH{UEMkJk@~~Y{hi<)Sr|uyW-Z@^crli6`YI+nu-wy%Ir>gW3N|n2+5WZaGGvA9mqgMtt4aCu zXMFpS@h->HoD<@LYW}S-A0?k2PGw)vSKb zWjK~n&chkn4mx=0uuLlP3OWjcpdoAbFJB9A&({vwl{YJ^E-YvpgkzFEln85{z@JkN!H88PhxoZqe8Yo*`m08jrmKi2PTY_UFk zxy`MYNqmXAz(bCQ0~&Q>lSbY7ko}nZvRLww`xr#2_U$I;vCT+8wCHffIdWNeV_TEJ z;lguueqEcyFKHUx0FBr_Vd_4^SuiU)dL(6Khq~<(~>xbm6Q=N!MH=dBSsse&0>&d{6%d#3Bi)U9OdY((iu90X( z9|Rs#TKyd0yLA&XbSjAWbYsKKn$*^7^{Pys z+E4}ox{nTpBiRq1#zxC2mF}wzbRXTx>@-vnxpMTSd(iO-nNB}03!Vq*^daMNT|3vz z61xnDT5P`tT#kiho*FS2wcnx8zWU+g`0ZWIYJ6&G5eYO-MO(D`K+90g>1>UsJB)Ze zVU5^W!_o6GF~?}+0EOCME0$dg`-Wdvn@^OlU$}bX6NdHc?qG9-x=yZ6<>UzsGrqBw zdX0P&YuQ32Th=$;Rl^e*x_D4>JSUwI+k0-zil*28hx(7c{!N4HNAkbGzQ{+;Jk|JF zVf4NSZhauUT3Bk9cK!?S^r~ZE_1NB}@o>1y-g~R#MjX>Bm1X?K{rc0+K>SAga^T`H zv?zYj!Y)qcEs=Mq_N~S{(%(Z%KPf#KYA z!^}Zo3G`)&&r|8om+mV*a$@c|?AY(@H*VW<&%9$spZU@8p-98y@1Ju1^}JK_wD4;2 zPM7_4%uouwt(8ra~kU*Qfiy(7L(M~Agz~W>DaAN}N#AD&wmWxz1bjC-f@-%%kZJKfsckWu)J zr$*!@o4+43l?wE+7WuFpajK_Bv~BIEzbk~;{6Q4;2W?0T&utU}M!%wUv1-)(s@M71 zQmxR?>4=`TooRlLUHyLZ8RG%jYE{MGWd{JG_uC6rypGJfU+I1y7ya!O&6n%KIa>|; z##St^xaW7xqc4B=hZU!L{V^yxQSdxOPhVDk;`>B#`{l0Z|6DEVzw@f%n(vzOuB$+z zY9s81Hi^$vG|D#oucAhw?lg8Z{j-`m*Q|`We2O|lXH9FYyF*{?JGq6g5%49(ENlv7RoxA<7E8w6fiP-u}c27*@~jIk0yTnLDt1&ne%j zJ$G*oS@hw1&Ypc4=?waNcJu^0-S>uesyW+N(9^3l+hlUQP$bI&&p0}Evml=C;{0RqMSvhi~yh9zyqnc1? zCxdveXTLs$Tf0IYOUXZgMpw1^y`Mbzx@}fY<8o>8+1J)TVbx!JO~+gIw`2lTy{T#* zL!veXI11~9zz1oEq{U?-dkSJQ+a0K)>B76upZsZer=V3oC{w*g-iUH)P!7o#1LnZJ zzI(5kubuwr|F@U%x+3wuv+*U&3X4Jd`$k>$y(cniAzO9519k7Ek}^$1GnJIo!LRfU0rviPsGl5byZ&IS`Sq4pmRa6WxYlP9yc)^m;CYV-=-_wvq#m#7I|w*2V* zN9EZs&d2t1TgLlUdviNuw&>qhdQR*=us`zfO58Sg#dihpba1M?BB+L57)AX7Gfl!V z8M(EhHg4xhXy)W(gQap#NE6Ogez@BBMYF&RHh*zp@gL_tu8)IuFQbrw9w@paaWZ-R z$(`r?QBqlgb1%pnfFdq&{q*(M!}DCTrGl-M_-b$DaPO-1NU9gbTp@0;243oECB1;(4Fj%vGo4bS zsd{DRjkeMY_+~9MqqRZL7;FBE$tRX3p72Qn{0$dFxEW*Lx#tC2co1Q@bCw_E zoR(6Ovs#ICM*d%AutV~d`ic-!8$3wzo&CD0S)13pnBCi^@RzVk$z96%)jXuVohv%b zQl%udk?2n3>)~eLoAu^$1!V7fefg|n{rnHBpEd(zPdk+ZL(UfC|5e$P4=PWbuHJgJ zZD<#}9zVHN`3mxn@^Mx~`#F<#we^2A|$%RsE;qpY#LXMcp%h#8Y=5;M$6_HPy7!OX~&KXRO9JGgL;i4BPSZ^G;H+`C0S%h~hl}}x8;Y%uAHxdVn zsth$51tsi(^Z>0OswjrWcnwEJWPMF0&J?Ax{NqZO;&i&%5DFtiIuEzR%R)i)=b$>s ziYbB^NJoauL^M$9f^c31w_N-)TFfV@K1xW=)6|5++kn3dSt;gYFs$Cei7~6~3?(GV zSNs9|?r`@U^|YIlzYTAqoz#ArB>cXGVcRTB(FJ4={kWhSQ5^{+ygzQxVXWr{S2zi- z@GPFivv|2Z5Kfjc$^lDSl;jFILknLw;97*Tg1YA&7oP#J4A{0q5BK9|3A`5R% zT{p(g2|-K@IHDzt3aM&+#k||1&(S*3pHbu(((d^PV=e+u7QtT1eF4d-1kNf5>9D=G z6sfwR!ASB$JzWHx-zcjEzERUAQy({w%oV;~iD2ca;PH77yDH)(mW z?>I=oT(5}jTv}g5R=`+p$5A)-@G}fC@~{pT<~NdkV60^CZTsm*J(#ix4J3mNiPFZx zkg9!m&#$gXWc`?m`wK6P%^&MPQ6YuR0zrZVNrE&rK|)>HG4+AGQzg&x*}SThyb`hn z8t(W)D38#AvvAKRFP=pxEPZj7>Rk+iHri+>?4-(@<94!yFHJgN2CM=f&Tn{cG(}*6 zOuXrMH}q}?c)$_A-+M&y#Vkyf_Ryn&1{w)tG$<`jD5&8frpWUU@RA`29Vrmv;BUP* z0JR(m*0F|l9FAup&CGL;a5PF#ZRi*{q*f$fv-~1lV^3Dxhy`t{^oNQuH7^Ts-x>i0 z>J-t%mWP~rU|v8U>ot-1xd9)?ZVR*p3ig7ir+w3hR3gN|d|oORdhBj0Zb();tSEXo2U~~aN_8qlqJgoz%j{#jw`rKDCrM@o zXE6ZY1k7<0x>{guUnuaJAm;)GlX|p9J6Si>^HqjtW_;Vm_A*0?woGf8DFp|~OxJ=U zNuw{n2CE(@znznk8 z1+3&r=W9U}k5t$gBzpeyJT9D9M}NLN=;zTmUGeDr@+WNK2#zF1x*+p_nfi+4CBltQ zVY6`=Mxu`MU_bNS(_J9zI|i zDEix(9lN>I@L_xyAI67cM_xgWM)RqwGG7_dwk8CQC!hIM$TpnIcqPKaiA@W#vS1t< zF^$Y_6^}WRQBpIzh$1L5>zf;poU%=vGmJ6rM}&H$RpP4sUPPl^a1;+l^4e!hEyywv z5p$0^-a*SiNgjx}4G|R_cFh}2rK1u7k-oHzkaOqr1+`O+0Txi2=LT|myC1o@R+f#$ zyP>Ue#q=|Yie*hiHS#^52fhVU7c=S9+VnJH>KXSO^&UeCp`tO}H__HYRyyuAus8;b zE8@nUO{M;x(&T?~+K5M9elM3z#pjWHme$>I-(#QV>*hst8pM3LC6F_LX+6LHE_PWW zjWDn_|A)~F2h%USk*;7ONIx=H2NqxTwW}U}olL83{LB`i8ad*>g8y7jg7#iIz4nhXITb19Fo(Is;O7n2p!hE8mU+I8 z$H3?SBS1tmdTo>G9X(+mAJp<|-Xic9AV3C0SRA45)R*Y;rQ`MG;4l;_kjC=E%W&mo zcJ&)f%pKAGRO?nRxSz|X#^Oa-ONW3AgOE9Q@@1)G1aHIJ61N?IL*HfnghzBD5>AVJ zmVg=wRAu#~hN4A-IRdwW3%QDH9#(2Bz4L9;ZJZ z!?m@eF;R9VCJ6_1=SVl; zWIuX@PH<>Zv5d28E3v-jwBopjve++V%y>naSS$q_!Z}2Q7)b}cC#*3HG%}Lhl!NIp zls>gUdg`I|9lHP2iL^c~y&Y!p(R0qVV^SIijVT6AYtql{+_@*+EdE2rYwx;7RRyF{ zhCT*2iVpw!7BRsvV#tQV+b{Y`B~S6jk4()O_*jP;XZ0gFiU;v99!(s507@{Krnjbp z{K6q!)NFiZ0{6P=ZJDAP1n&Z)5wk^9Eh$Mj_Jy6iA*J=>N}}fxK9y{p@m4vCt9-i* zu`-jMX97GGtwHt_p_a1W8QB=&Z0R*9_6{|R!!f^9YAYV|yDAiZQhSBKwAgmbT-F@P&#pm6Crvf0~DTDQ(zcgc&g5jlC>I&MMV{zO~WPsMc^JD2yT{`PJg zRyXA&Gb7|GXOY7@BzfS+A-7G%-TB8uhf&4U04jAnfCnygh{MNsz@?K50%*4ErH>JO zyTb;+Fkg8$OD)&^#ChalWU7O-$mZ)a#kNIY2Hn=XyA#21z%t|{Llv+}<-#y3qvkEH z4(gOclufBA4?_9{H=JK1uZ9tzSCM1;}%9=N{oczQjO}uL#f-!9aedWb$z*q%XWC zU;R#~>qygq(ugRjMm~K~K@>72xxE^TOpVJJFJYcCMAyNQF&kN(?8wEq^r-rt(2P1Z z2ZFh#c5_&AU#D{h@FFp!315sQ;qgqF4=JXhzZnjl;B4RvV#8A4aXf~H_ncgFQulQ& zZ3)Cs%P;~H9x|W@a#et6uZAw9AZ9!yf)EsAG&e!CzO`s?1v&F#NI5N+b}=Y+N`?-l zrQBpF5zwz(jKyeEm_VsP)NqNVYhFmW=QxgsI==Ac<~LTOmIrODGK_}lKxGIGpem_3 z;=V4H_ae8m7c0(79@;@Fmkh%cDuR3`yj#JpPdW;<%q!N&D0<4}zZPX25i@=`-Y}K` zN*CA*SI}IDncoaqD!uHEANQkoWaBjdSZH0sLl5P!E{Y=DrLj35pcVSW`@!t=c%5@h z^1i`58mFj9te7hWRY?i1aZr3Ma3&>Hx2Py5cE%qXVSq8()ufpFYfUQwY_XGx^#bN6 zOa*5}x(=2VVsJirM!xJ81tCantGxo3v#tWhHpI`F7HL50PNMgX-KPl~hrrBo z*~7MF2M2EirR|hb-|PKlaDk?WYiHA5S6l1_1@kTr6bKyH z7><0A1`qD4gM4Q($L<`bh{~bJI*E_txmdpm*$f-xwQ5#7iA)F`* zB1O%-#O1spX;aIkg`L=spqxO+B1x%+q4j9(YiPYSXU!1nBnmZ!BP%*NGJ(MAQ8%@F zIg2Q2o2YecXhNUaWn61LlvP$l(f7Q7mp$CWNHoG=ZN8)j$*g5BRl}w(x6!n_YQ#`# zolBh+RY(bnp18lB7V%n8N*?VePfisp%{SBWEJ{9p#-Mrr0*&u_NQAyR6E*h+Vh)M* z8b)fZId7gI&GnU6v*dIp%cw(z=&l)Nbn9RAQ5K7me4!Bga>q5G#PM`+&r`LlIO%c+ zPz4$gP?i4RoqndZ^VC~)1b{0L0fVHhUH%(&WxQY3dC!#t=L3hrmU;?yCV z$tXOR-c?P{z~8c#ST4|-u%Lv&a1En>(vGVnmvtbW=1r>X~Q<#3LSdHO<+^=vHM{yKK?Tm?PmsBxs zQ?<%t4I5{%7!+Yy6>QY765X?`qr{s~P0lhAl)Gb*fFn_p6atnq_26;R3YrW(uTxGi|>2@%!K1 z?q$x;PI6S(#xdM&lg5LUlU0>Hd?$i8nNLu=2zn9EMY8to0bz|2y$Qn!gcDnIHDhNk z_Lt55TzZ>tm7Ue$7)MK#p>ih$F4ps#!5`C#n{~Ni1jf!|7YzF+Oh-ESL9R`H8MNc% zh1HU;Tpj2_p;P#u8NnVAh45l-dTLs+4_Fjazxu0x+q~uU>DS~Xp<$QofdyF5f3f6^ zlkrFQ{EllG&+#p7iAT?_6RM8NdRdH=1f1f4nqq){&stEhiOpTLhG)JqpK6o8ow0CR z;Ce1GvpRcdCIHfW!``e3~0)5%#Q-K^%?uTuN`SeK)-lFzJwgT-% zw!xMEuSmamC203h`!u}fi!Uw^3mgm**c0gY%9}TMp2Zuov{*7i6T3fFMST0hR1C=?qnUxYc>)wBBGkZS`$GB z&mioTly@Vl;GyZMXxJoH}NIrOrMKY9<{Yu_@P;;l`$gbd$wT^7(mK#GnkNIV84nSQwGE zIO+~g41KhBZfT;(2?7uUc|g{YA@!$Rv^_mJ_uGV-J8*M9StIh%w3^BsiItxiIng`t z-W$dfGh;HQre_nwLP|Id%P?lUuLfeVh|-NQdNKkR7@N71i-`pbSFFfmCl|4nQ5uMG zD|+amhdvnBp3okSE}OF3fSQGW-z2nu$4xJOS~%w`Ou}C<2B5tda^9T75wq~5eBE=ccSXMv5ziuQ2Oc(9oQ_>w(KoU zri040L={=Kb4N<9o*u6(1t7~cLRH+2RKrvJ;#I=G$gke3SS9Su@s(XQ9*I5!k*Foa zlm$2MnFV`sEIB3|OHOEo!1{b?HrUXH6}eJVU6N!@fP=T<967wLVRf}lm{N)573<=j z1`tuzIilR0M}st}VTtCqn|a37#k}?TZJ6}-BF;2ZF%i((^QCDRPb*yV8-@AWISCyT zZ1AS?)D5J8{5qR3latvT3pvzf01!2jB8_2O;gYxQRVLkaeO4QGnM6z-n0`0zez+0( z$c>wRWr&g($*9h@Hi{^sh~n(?$)f-!0c;&M*g;T)JH|_QlsC4A-fc_2-*tVq%#TK~ zL71H3*~iA&QZNqT5VmmFwO%UB;E5kc@2fCyZR4q=)wmAG|8J9R1JsZ`h9D+h3ZOG0 zEtQv%BpXrwYM_+oHr)81JcP1uZEQo+Oen%&5I4~E!93{RXaNzGcJrF#jAAr}dKkzEN4c|(!Uwth#tBUsol3Sw3^V!>3I*(s2{Z4PcrXIBev+(g2 z`yO$C+{64VspqC=UGMqei{7LmN0tWyU0<9%B?LIGs!Zidfw?#9@E~!d?B6wk45tmM zOoq`mPmP_U(!U$xnw_bqML$^ahAb_?D)7{)-PR)>s716Dy%SQd*^x=_OcXFUp=RK@ zAkpXDw%*J^H&1FilLrCm-H@jEfn8zjJv5xo&!;eXoOyeDT7MJ+cGT2wllL^gO|2MbZ~t#DPc%Sr<#gf6=1CbCJxnv`r7 zlCJ9Lj>&``daIF++%cKZL*64ga#xTEZG-;)b1xJpz78(#LO4ppz7q7K4S`WALfB7; zZ2nZZK-J!L`aIq9h2n5>NH{z@e4_Yk`|jsqqe-id&AdpciMc;g`NO&DWy^Dn2)Ikb zt^%j}m|kBsusRBDcIAdr04%XKY8jhPf~*ZKvR+Z|O_q$bkr6(-cJu?2qy;1rdk{j+ zC&Bg_?R@R&c?m+|mw^%CIPf(_ALK`0FhEuMrH=~#%Ms<0txV^->}OnWKghH6cmbTM zjXj#EYEQRIv(%xUt^jIVSDo~q*5|FDfsY-1aQ7cQ1d?w#zAQYoJ4b?#dw zUM@Sp@_V`LF%W<+Jie52hdeG8UyL!BOvaagny&iXK@qaj9ChfYR?r{$&L}^Pyc=C80*(V!HzL&Sa+{Ipv150m zKe;)s(W<8l{rJ?EuYG*n42%E$?%!%X8qwokW5psk_*-BW17$1!@n|G)@3sUl<0ZU| z7Z29M$lJ-Dhhs2hoSbh0h1UT!xOXPd*4#Z`Tw|6X4_UjS{yAg;iGzB5$cHeVPj2Bz&L)LwCj zAOgBRp^uj}r%CqqA5TcHX2g({*W^X*%IxGX>hI@bhL3v|Pm&-X=R(})ZTrfC<%^h$ zYkxh69!_@XKNHILa5T+ro%tpU9DA85hu&q%g87>|N6l$bk@sbi8r-*`20Pv>u@NoG zVeeL%zyYBc1KCih!Zel!YiO0rsH$uG>pRp}tPGOhOg2=PDB?nn>YigKrrd9l^*+Bn zC?L>ciKncgNJ@%40_LzLE8VmjG@9oI0wn#OX2R|#bS2eqm1}+kMX`Pn^|n6>X%YqH zD#tI&%e&isJxI6cvnbHeKvCL(fDp~$ThVM@empV#UazJEKRv$X7jC%W*MDWq&UalK zxcos*o$!G@Cp+-l)m$KrLODN(`sZ1mG!h2~FRp#E=(f0E&z=dmNZNa41(&X0bH-Jb zJVG_XG^K!~Wbi2g5d9RWojEUArK*UNcRJ%TLk@5p_XM;6qz57wu_>oly|dI?~6E3^R0P zwrFQVck2r_%YGkA8o@#+vZ?zXqCyq5AVg@)b}Z|gaak26igMRV4b5rYQdc`N)F9hb z8nKlS)?vXeJxfR=M64KR>16(55FKilEX%S|^95h3H)aC3M*~{nVBXczy3qZfJ)ATh zZXB7{!S{=J^>-6YvEb^_aBPNzNn|z{-dX$Z!QTQQqD3-qZJq`1S>)d`FQqY=@}jIy z%@l@EHlQ9Zc9^1+aq--&ba3R{;Q7;5>#eT2!gTNa0~K<$0!`HV-{d zH+f*Jsa?54HZp$8ZBKoahNMPyn`WvU`>MoE{T%aE&WUL0554$_oiEl>bTScA=h+3X z<7=j?uXs<<4xHN2D0|4+k^la^R1X?Y)ZJZ{TKkn{&7@=e#(6X?mHYqwIJaRZ)qo`| zVhKlYiMp61IIbM!eiDYc92Du;ibZP%@DKrgZlN#uy&lGAnD+7Ie7;hFde)#QGe9WK zz*hQ7lu9c0Z)Anacri1wQvA&DJ zJ4a)``Ur}KFNGI^4W;$b=p#6{5quP%#HaA-X6I8)BY%qXK2g#*MQt<0I%{}ZAqa17+aFnJ6BJ3c+afhSqtq9E(EtKCng9whc&%eeo8#dG%eQ62mk`Nu3kj zm&^+v`Wu^4!LFX00oxTM>hS*4ENgVo`R&2W?F%|ZM06IFH?8*&AW$_%Vvz<)6f%3+ z9mAkEmf;dfxka3Vl%z$T`JygkFItx2acldHjE2xRzy%lBS4|k4=uMh0qor7#lw&O=+8g?ykPx^^2J!n^=y#|C4Mthd6)Yx5c`<-PDeu}cMGR+ zKkoNfMKD&JEw}tqDE|&FS8&t0%sD?;DJ_?90Nx`Kg&Cw&g`>&Uu7R_g5-D#s!hV7e zioR*RtD}lcy8Q9J0ttH*A^NT=h@ojXLebY>%lA_U!zD#U*m=DsZb?C1us+;|)mVd$ z?ZD~{Ks9sy8ahqNzU*oiC_`07U5Rhovp{(r$dl*TLO1m!0?lB% z5Q{LbZYnJIgF~RM?=C|gbMZs zV*w(?&}`Ur;6QZ7ENkbY7c=a2AMn4UWI;7ii_~@8>ly|hsL-(97cYDR<3)^Afj$uP zfRv81>xx3QX)HPx9~GJhc~3J#=OiYS!Uk^O3pdHHscxWCAVD^pi&)_pu%Kv~-eHAh z;uIJKCUQ4=Y_DwALj97&$jm6ei69+7pKd#(q zAB@QGdORH?99o!U*f5Sq4x%=P3~3hLg{ZNf9o$H4-oE{ek^RBXhz8K?X2Uf^6U@ly zCGK_{iC&TG9dFws7*zjF57PC8C2B4}m|C1mv=&cAnwdJHosFz`+s7(xd2_`@DWv$0{_^&jt2T3Z@ zSXDgsuT2tupWO6wl~qCtU+GnPCLtxkSW=!XWI+nah8{{I;;&|Bu+5O==H)w~kac4X zuGWt&pRmL@$HKjgYjIoifFkE5E^5ua)!K=5c?S~QH>?HM#{ES?Se7X(6!#gs8gd=` zF1=t&;$kNU3+UiX<=msI*-|o}&(b1=JS|%OI@5+-Q@{S}-5&0!G9bv!R!*0s(zj?QdICNMiL`n(JO zb5<|bCiL7}wNh3!pZ4LoCkAh&%|G;Dl{<93GJ}P^?Wgv|`00Fz?oSsgUG~Pk z5_CmPS9i)g6(9vJ5sRTkV!&=cH%9#3GmkZGz4Spt3-~)>Yj!Ob<>^N<{4|cVUa5-O zol0f3MPhHh>9_xGD(LCQ_j}Jy6vH}rwF{WYt|) z1Zc0M;tw^C1eh6>sfrro^m=!ZkV2upR|URDf-g-TRPvbc1fHA^YC$zZg$cwGBBaID zhQZW~^_1&cM!1{m_BJuJ>RSo{Rg8f6wjz$u6>nkF5a!EXyK{z^3B_;n^7VX%H#~i2Q%)kG~X>xOly#{-_L*{SRp((glZb$oJpcg&o?{AqW}` zd4Atkb26|HW1!2#UacBs(uh+6Tj&_&6B{NZkq~`|*w=)JA-jDA&KtI#dLvl@J8Hl* z(i$OZW=Zd_;s{26DoMfHffiZr-`4L&ftz>wmorArW#dw8x#!hx4Zpaj6av*2C)W#w|@}by5V~EIE{p^H6$TGED$;SZ-{RGtBFf6QhfV5<|^>9V)gC zb8{iznA=A|KiWWzQWL^>0<)D&>Bc^;fE|NW3WNjH1962*DUMOBQS)DMb})n1g(HrD7+j@MKpMVBa}`}TO}@w zAu?`)W^6@w8(b_Ogb@lhVql@3(wR=b7-W{GXpAKHt@Q_Ojha7)nu!aIZ19X3?88P~ z*r#&UO16bR^$5%SdZb4Q*bZx?8?jFX;sC@4Ha+ zs(9gmOXxm2#bAy;Y^`nC8hRdn1L|L$;a|Iy?xVG1=2s2w6;WThl#$urbH;XwWcxCB zsd?o)aG|u7|AD|3j^j9vYa}lU4b}a53jlD4%_XF&)&O6as1r_azA~1LgI41Ui`SBO zX1=m~J`RKG4M{@$p^+A>iHOlx{QHGvtwW`Ift70ue(1E8FCN3wcn;6)3mf<{SLQs< zbd*#UHfon$F>~E9P73ZaKY?2Z=a z&U{ngQm!HkcmegtI;a}XnJ74_ZY|SAZ*PYTFk&sLZoVm8SF<#*ggQN6{jnIfbvKGc zZ4Or0A0EN7!NDH~Ht_cS%J(60YK!xBzA4bH_^}%e;TLA}gjyoT-MAZfSCa?ypTd#m ztTQi7-5M#e;jUxBW?Ce)Ew-swYid%}KxqcjlX2_YHA=f|c!!#orFNyU>v6ly0iEDT zY5qgQvgk>OcVeEIZplx}v4e(cOWUo~0xDi@wFFZNdTc;haB9qS%+@%E!y)Y3^{n&R zXS=%rok=oSX&kh^xA%u_Y0qpokT7ehUQW=CIsReL-w#e=)W`DEv&-+}Q9O<(@#MuS z!_lkR!sVAQXlFCykDuh4m-BrRx5U4U;gl02uyl;ty^5}B8yUHiNDHgWP#|jxN zpFQ_6cwp!6I#|e=AN94I>A=2GM@jQ^e?JZX=0TSz@m9nid;QHYDaUUq`-0tNg1uD^ z9vskkCWiUP&DQXg>I|*nN=NN8Ijc4`%K#{sd~m2KNGn`QHv) zD>`*;;a2Brivu-V_#0IX;uq{Z16DxQd9S)Q8r@K)UO={WQr#0GZ_O2DZ&oqYr-L|i zm-u?&W|K9jLxC22@TG!gvt2DBlCAdHum?rf1wrAD8q7l6ipypTF(#+1aWN zJp%r4EL>hN3d8?C{D~x0WxR-|E_9KDhryu4qaSOaIQJDy+l~^$C=WcKIMANIa++|q zTwi-7qyEldG6n|R&u^PhMF|_*e2@dX+QOz6*zh4_sxZt<6}->{@NcC~W{eIi~)E&B7 zPg6L7%wYJ{<#ooG3_#ToFc2p9UY;h2R%wlZFCM z7HEXzDdCmK3uQDwZgNsZdT>ZfIf}~b_Dj%7F;iX5^weu!)xe_4VCVhYW~=0>WmZW zrFnN4yv|k{kG%bdSrnbsQY+X{n-&^9@ieMWPaP?joB!fc7@H%IsP>V>Z~?Et~9Q4The=asyo?f0u0)If}Aa(Drni=}>j?}=6`{@eK7nE=FzOJ>B9 zF+d!k0ZmyeB0}`>&~v7BaHIVP?8gpX-HE8SLUh_AZg>})L_l%gyIE?Y;sXIofZd>@ z)`GhI7)vdX)>xsO$u)raMhN}OSjhRXv!K)d1W`Laqf8MybyL3ZlD0Q5g&dUG1@5yM z%K-Dtsb~@n+6rd@x=VP?6`^2!-Sgr@eXDIsX{TWRn!V!5R&M_7y$_8|}K zsupf(;^haT$U))!H1wy=H6$I}K;ciXMt(h*7fZvXxQfOf0Wdy4O;*X4dIhVRh0SIq zhUJYmjIUU>)6J+V1KZbQ7|i2RZJns#8b%lY;WKOuUuO&1 z*>0v1bUU$>cX#Med(7D8_cGu^KL~uirN>G$-I(O1p#?`eaowaG16opx|fF@ z$BkgFsFh9jDMSW#Uo69bwYH%mtql0Bbja|KD-Cfk3&_nvR_QF(Og5Q{7Sxuh2d1qW zUe0c{%CzQ?30GSqhiX#&(hI6XctRvt_oaEVnNOaZ(cZ*Pu}pSV+qP44I)5;&zLn0eFEH{k=l{4`xIIfs zdQ5KsOPI$pr4IrZ4alW3x+NVc3=XLxFu*^8X08(#&l~;p%JBBF+D4m-g^DpN^GzoI+YPA|5heuo| zu#zkatLIi{b*6tMP$M7f-1{gI`?w*_1EKP(0tMtu|4LvE$@eix1~CTPi>jbx@pJ1q zGGQo-h-zln#*ioqWURWx0e1ejWd++UXWZyRE@>q*L89SS&k^qxl7cZtEt%-(h}J0l z&$ET|OHpxdIxz_@>2d^>n9Mj+g_O;+;e#;vN{~!yD2sr7Jp%tkxMRL$pvHo)gBHG z7c}iq*~-^pZUgC75U$=ElLLXR{n_&Qun)F$-(Rg{#k9EDQNL8Y@*lcH9UgJ#Fn2*+ z$Tq6!@Ji_LOL+V*9E|ZLLviPMHpSylAb*;Bxqn}}JJ)>0>|;NLJe!m}juf1KK6Rui zW(<};&7lr*Pg?TC4qIR} z8LxRf&wx+wJ$n$Z8xD+^F_yfGEB7cNbI20ueJ=5pn6bgfW9^9=DfS#M&gqpoOvXL~ z)-om9`yOY)kOe>7E)VGy~VvT_o6se+=C zvWlt!HG}Gg3~Oi_F{-6)%(xvUOxo#lcDcut-S+srX&PEOdIqRAMkZz!RyKC+Fb+;G zZXGz(Y)N_`*|#L4JRRJ13B9KMa( z)|(pn+aR8vna3;C+w(j&Oq8IJb zFkd^vpX!@cVb;EM#k7fH?=HNUZQJwfAfAuUH(51IV(xR-V#mYG zd57lxy|>U7ZL7NCiix5=71iv&`|Dj5X*deu`+YBDyTRw5$CAo~Kb8(?p7!{dv+Z46 z^6mwDPxes?$s*-eGx|2??u5{ewM412p z0sccW4glJJ9U!ke06W;5}e;yJ-vC^}Cjy76g>li5P%*+gw z@`#EmE$N!C3g*g$S{Z4aML)tdZ!TML%;Y-j)@blB&-JdIB2L>Ofe9O=*5m!D3o?|A z@YRE;Yq~-Ll;E6M;F-=)w}m1?$_&_`YLWuW`|%qs~R%LQE$S)A2yxm>66!lg*8C_QVku6svMKc9wEbGK9%gk;42p zJb>oEx%+1L-gd^k;-C$AfK8?i4yFz2DS1YO8KG?Yg`FV|3x)pH-_oYue@% z_=+nQY-M6zo-8KSGfN(oSSv?g%+IDfVyQA1&pb1jnaM_%)lHk7VyB|F7|$NPGT4!g z#5kKWOe9*z`NL3*^&-jimV-rl5`l)*aYb6qS(_%J{s_1w6a9yXaL@t>2Ji+gh2i6} zUg`M-u_%738@9cLS_0YYkyp7IKm zmnfxpKHC8$RbPa(8hd}9Nkv6dp4SpMYpnxh#qxN2K$nf2Jaryh`IYh+3-XlT{&Esn z8fjog*Y6q9=1P+zZJp2+aSIXLqD-SKvkt=?g`&3s*49-sIhdVhgNc=H`YJDLFK;vP zz+w0UlPNAVc#fW9oE9{&b$!trATM-01za~w&`D<7&zeIWNf%fdBw z9e&73loz2R=J#lVr74sfW|;ZI&CPYwj?}xx!IxA404X|Y)4*{ABjILB+_1?_@{6Sf zV{Odx0H6TU{EW!A^Zz&%+~R(l0Px7r&Vo5hG%Iwa%%A{xD9Z|e{-jvHZfF72sN(Cv z^E+<}JLjYg3=6z~*qP1dQj<)5AskRw|GF!~h^P>Z3-~*$po*0NDr^CYw?X|I0ULqE zIFC>CXx1DWzI;3M zHjVebH}nYD2}rGsL-_W!J3)a&2m+ip(j_NkRn7MhR9HlT3 zYT`gg_+55B7Nzk=J|v$c9kIqdW{m?7gGC_TWQ)Us3~t z0O}wZy2kl%`rL*2a2^J9f*Q*IG(CLz?(67|s80O7YV}HSv31hnp9Y17k@;{qqemRT z=!;6|vcAif`UUOd&HS4h=uSh(#p#z?ZHrFW;MuyXIAFv)5NYwqis_D(_T6tDat&ra z0s}KmDMMd!VuZ8458&zI{q%qEZoU|Rv*6ra!l8`uNd%6fK~1n;Z$B)=7APztC2i#G z!xXq-{m_QLZxp4BX~@)g3{h6QGHW?87p^_z`gish{2)r(Rf^bVjvuKzJHa%YMe~en z_oIKOa68F#ZFOq8a9VSt?R7dTmG}5X^($Nn-FE>0)il>POjo_f11_Rp0c7{bE7`?=1iIQxovlk+*Bt_ z;X8ez++~ccw)M~*nJ26Lp^^7Bh=0^p`K`%nwiGJLX1!_X>bfCp)%$GJo=q_tCEbHJ zCe8Gpx!%a{MjwPQQj0K^|?OZ~%lm9Bs%Kw~33 z`w(U{Yj+^jEQcJmcVOFv8PEZ9L!l5Ic1Mbj5PM=VCsB$gDqm~0;}=!wJJ^e?I0*Mj zO8A4zNz|ZeW)%bz9dLF(_*E4>pWg`1=?+J~4-frEV?<8^f6gvbBM6|V7f&BlIup)h3BbBg6d=Ky+3&pH{FLUS~tR(hEN12&Xo%)MYt zpHZTYwo(*L%r%XwM^zY%6w45_F=f?Vx7KqazuFNvl@xC}S;H~xCj@B3L-h8deMTA^ z_^~IRI*dl)7S39+3fI&BLx!9?vP77U18M+%xd#1QaLep_FLjsP*quf$as8~0{j&4b z4}bvfug*6_L#}a-h-NnT+j2+I>iel)zo!1Lxdps5VmC!d5N!q?)_*?#ENKPX_Iq>% zH<#JKWgnK{JJ6XzrEpcZtq_g`{O-Ud2n3eG2~0f*esk<7v{eV)5RRif6KFRjN)cUn zN8nbGJ!lBR0?@Gex4!%o#&8%+*VhNOzJX~xkp#*0G1@$ZAUpz^-}NtJfr;(OAS(_K zkn>fWfZOD8Nw^R~?DR)tU?jI{?wGsnYqr*SK&ORlH!HI>sY`?$}Xy?kZ< zyucK^;bH5%w3gDMF>76PF&yM?+3s?z<>psJZf)!e_vO=5gQshCmDox}N40G2p`{zH zBIKDKRcohCWu<0P5x3@5Ijf4)&cb&^qXO|mWJjK|@|mhPQI#+S|ChRNa@Lz#>+!dn zwr)o1@q0UCMI*(^QDJtPy)EoG0JG3n$}W+5M`krUW0uT`sl;R=-ZbA_F?lPIYH<-? zP8>=K;gg&7{Zuyi1w z98=>H%q0@Wpa3bAB*~|EU7A4QMG7kTqm0y4K@P~#Cpo{CY_oxWaQ&O>%v!T6WWdk0fyv*KGDJqE zZqU7PgrLYyTTxQ{*qFk^#H&YG27${?d8xfXUDq|LnY33c-^Qk(FoEiCvJf3nk~73J%i zX}Sr>g?NQ1KDR)4j@j(VeJID0A9hV(hP1d+{7JrbE6I!@CcxXVprF7lP1Y$bmWjR>zn2FDv%<>{y03Vp zaE|o~m{;xy{q$z3$fso(YEw7!pyGSYRc<|xCh1O~B#-@`7$w6QR|^dOl>GczIPHUR zc5BkzbHT5fKj?@y_3O^cq!5|+mq^Odd-A!i`^6{fK5iwGDX6cP54l0*nsdvYE!oIQ zyJRc@AFnP}?1lCt5GMMq#jjCcrW)y)mOFJR*x?nrY^#|>|w6! zOYT|r4c&Lq`q#do=LI#u?+3GCC;4`abXwthV3H6E7?%;aEs|72&;0V}ehj>SWCwi< z#n?B!qtK%3!Vy!fXZ|LmsdaX2Y~SW`x?SO7!H0JTpQiPj1O4&64N zb?kKIDI?Z~%9Ji_Y{cw|DN9u16QXuq!&&F;XlUo@=AvQG^SdnA_NLy7yKz6@Mu@X% zacsd9uQKz6Ov~E7A%^*vTk4ikmUV-UQ;|9Cw(ZHz@sw+a`udM8CRAC-3b)y3QA6mg z8Aq>6Y)_Xb>iQ$5ZGK_%J@+@DkzxB4M)K*@i*M7+Mbv5gjPAhQYykL3d9fMR!s}vp z^97})_v2mawQpPKR83?{cLp`jjK!LndDlGZ*CICiqR3n?G`G(&YE5Ox(~Q!_Z|l3+ zZxu^rv#oF|HUDfkbo))^^E*A{kNH^&kxmG%gd3GfrGcqRp-ebP^dytD$$X(|(I1eO zd$c7JLEmi@(`S~WZqMZ(jOX}JC_)JOdHNN5bk0)s0o?YfosGlpJ1_L2sk$j*&1eCD zbi;P*t4;HxG+GMqGh|6*uyEu!iBCwQ$Q?^ZMRUQQU6M6~7{72d5+RTX65AO5L5=?NTh>gvf!lhzPh@83KgMB zbv+hqy`~Ehi#bAp0&%qjvL-WjwFtFl7o2#REe;pg@Ib)Paq#oGz|{)$)e`u{S{$1> z0^6{R`8rYpw`ha+Xg!%Ulv`F(AqYq?KJRzB+fwxWlzH-U=ncjo*OIt~G2(EIU3ZbZ3)dea>W`VU))d;)=~ zNkpY|gXH3gFi}H!FKo%aj?}%z(O&G~5jlfx2pa$%B{yOu2B2XdKJdp~P+*O$Hyagw zR5lN)Q3<|ytbzC~o8I4kF=>*i91*#0?zvmEL8l4Yo_!mu0Rb}!$NMpcI~SR)o!;C) z;R5n_93|&`jY;E^y4yaHQ{C&ft&P`U)|6&;$m8THk~g+Wl5SV!kcyY8x41Yb(U-y> zi5pXZ0!f{m3;+(?RhnI_$wCvXBnb%+jtxg(^!H7PJc%kJ^bg~BQ>j*iY0u=dlSC%{ zZM;qvs++4iZP~4-bish=k>Pl#-+ee~E}2))r(@o~Zj)&)zq*om7lUxn@k=Q7;0qvF zxo!67(ist5@;ynT{l6Q6!Ni8BVxJX-MnmOs68t6FGzUE2MW0k7ug&VSNDCAXj#s7D zq?j*U!fU2us3LIV`h;ESph3?!e|z}7`gou9S~k_Dn7n60oDj}~b3Uy0=I7S4ecCTne2CfUswheTLD%Rt*}_G9U)xFBkU)lHK+E5`SC z%soD)*G`=8C--V|g1ZuJ2o$6=AGkAUNW;XTo@o@M3w+mLI>-Wte$l?pG*Vi|eW={S zi?nG?Z+azqK;9V2-yL>}kcc0{ObLj41o*z>Qy}DDK%KQGnBD!oEPoW~DC+h5FnfV0 zI3BQQQ%533aORO@c}dI@Woa5AvZ=V(@bDd?A zsS{>J15)jdI$ArTivvGE!hPDp$%$^hlVHSc0LPqsd zAk#WdmXJGxj~Qj0#74d7;BU@+4H(I?0Q+ZesdGud-oJA;mC@ z2fC(E5~w$`T*oIePxaHM=w8+{ zNoL5tGUNKO5{~=6orxMyhqYD}Z(Bg?-iDQxf$^t14uC#R>W7q_Ov)j3vI~pw`m5TQ8kmx9f+?_M-WqBIFwjLPu<4X zT@csp(sY_n$a`zIw{``h{x6M*#+08#Hh@{{%+-wZw5!TaY7!wPXB^!UH8scRt%Uke z(O<|A2%n++JdptcTk&1_^(W=|Me_RQAPn{PWg#OV$f&Gnhg$L1q=68aQ&5G8f)DZh zL=s~YA%Xl6iUhRA)@GlfG+Am=_VDbwczn0DBH6Rx&@@%~?GqNZMm7}+5@C42q8W=< zg0;~7oYhmF)pKChM4R|A3Cd?u~Rk)CZdypT6J?MY*&Jy(OAevU~PTlO06fvPrSyEYse4vd`A-fEY zgLEx1*iI(@&@_j&@ruabsf4R;O1mu7dc}a_=<#f=fd72rSE(|&j^>JC3K-stdhrxWWh*%|?23^?1Y&*+l;-!eOB1j7OVfmObDJu353xXs;gh@(SjA~+`pz1m;kqaYBDD;*p zk!#9z6sX7&V_H<=vKa6`rcebFOnJLGl{c@sWC(y{BSejHEoq6jT{%>B z8823M<#e9H8zL-In`3(s`190v5|pgfW(~D<>vW413OtmXvWc{OC zX*WBB!haG`u?!nAbS}|7L;om58O*u$zG+QAmL(*Sj-Ped*$_Iu45rxI+NlZApd8Ei z%;`K!u7=mdn0E$FxXEvHdYZehOg^31mQg#PCOLL?YRD&m|}FhF1i zL9w6@L%Mo+#&`XJ-71E*Hlg7kc}-X5i${4M4}Rz+gzUwJFDb3010^xApD*%%omKh; z6%`x~hk@N5WVApqrQt}^ds`74)(X3=bY6=3zXb*ccKyI1QdpK+GaKW)AqHX^4)+HK zg|(d$1{dQv#0T(7KvrqrvK<$spb(0|j3V`< zuMsVwPl_CL9AceK-x@EII9DHVn$G8p`oknrfE^fK@#T!2ma(x9SV^;b^!y1bZ~;Hc znSQ*qu844NOiST`uiz{tJ4sgaZS#YTlIsogC#uG(Om@DVA}j5I1&ICqgQ$wC8vu@D zkcpluj(X>|xFcLh;v^Bq$;I(#OuKe<B3oOBbiPy4Qo0ktz^5Svz#VC`644Kytx0#6{5jyj?6IiCaLh9jVjA>_(ra zql5GWO{hc%=}~w$kpGE4wMM#|^xfy6)^S}YM}5(d&bzTay!dq9;}h>7G;kS8pv|<+ zan5HnD(;emSz8;Ld{-6guaCb-#soijI=TPF|6NZ8Q zY>%$a09g^}sc%3MF;O zx;d$RywPyDo*oti?Kpa)ZD~01sC#3HBiNH@9$`43%l|sqr7`qjK{O~AG9&$6Z#+Rl zk-HdB1W7`4BFf_B%FZQIUj8(>{ixay`a|9@`96*+$V1y4uxPK-|4V>8VFQBx2hiD0 z7JwfUQ{fI0sB~eWmG=J+0a@<@MtJ)k$()H_^)Yi+(wu&{i=Y|V?p2GRmsWn!yk+Ua zSC?C-6zf@sudyO0Xv>z}>cV-13uHBM8NxD#x0ju?t<%{i+CJ#Ynhpk`-$4l;k5=>j z!CLi=%qA#_Uz;Jch(B%#L{{^LVtkU!_;Exu0TTnTS3ZTs$4sx+)`qZ7&>KLWzQ_d4$Drk;nHI_J=(Hj>-{$k>Lj(EI(T zJ**?Pbzqq4U=v_%5n4fNs9e-DkiKOljwLY{kRJ1rta`S{eM^?bAyIv(y1%?w&p!iO zZ9f~VAXeAvDBxhaR+on6p8qyoPvMKgvTNFC(hhG1w??L2ckk+Kr?=JZjj+B?yQ12j zKHvUiUpI$NLK@`0<{{eu=6==k@2u8u18v2qVb%2%UT{Xz${L2@%MY8f3}%XO*++ z4$3^QIZ?oJLRwo+z0SB*TT0*COf*<~n+lo3+$?!X8LJtAF}&=~2ubjl{UI^Hj{*~B zsS1@$8pq$N${_jk+-msT0UYk;9+(ZHY4v*9TR~NfvvHqzWF9Nk4?6ix`~RVgbW>fl zGVRI`Fd6!}_xvGfOaKs~A|J8G$)n=8nL0v@SaLqW5-d^T#l&7Qkmp``;2{}`q-fG; zk%ZN}G}1sZb;6kjSY!8JeK}=yw>#vWR{c6HT(ik8j{uaeOgs1)Xos4N-43kP>Y;K@=mU&ZtB@3prPp@0*ADX`Jqwe49fxm~0f|OI!`Q{HVx+@5jc|wU z8RE0P`7UFXX3jy$e2*zM_{PSzsDQlpr_S-rSyGJZnGe#Mup=|El3lXct^lAdyX5sm z4|C6T@4jy^HhwYbE_OEFR`ut-bYzzv)=FgbWQPPnBN^(3p|<`xNuM-fe2AOYexu#! zpCHM2|4K}FPLX@i5kGG?@}HJHRkC|cM*V@pbpbpT_-?(!?l3Po+=@)bNRr{xCHk!Kl zel}ZJ7Y10EmkVYCeN^TJbK3Ut+L2d;MJvMf6tc8XB5VSWpWhQE%+q(Fn^jskxqOYV z#(F6P{p)VxM#W&tz=P9?{(%jFvNX>J)w$2i1%ix+DB6d`pv3`}NbG1(1S@y4SiBG< zGZuLL>mwGU%j9;3`G7((G&DHcKcrNQmg5%}mKKyO1re4NkPwj(s!E2Fo&zv24rCfq z--l3TbA^*N5vQ}oJL&fR2BHE9308>=uT-Qh6|LcX!D7KGlW1y3tJ!S6nz!hN?38Ai zFjkUYvB|tJO}9wJYnowP<9?#8pC^i*s>wJl47M*aL^-IxIJe$P7XhUaxyVV3K4P@<*_4g^qu6{d-=-=;Ojj5Lm9>M7BNz8 z_O?SUwN~btr%2ST3jn|m{O$|lFYqt(+Znd=p6PTm&+U)-JYdf6`87+9+vD$B3%~bd zV9WMzwyoFaP0L{RSW@S&f4_J_sZ=(rVZK`gO~?AdBvr@u12xa%d;_2R_bz2s(`)DD z--dJ3Oxu>hSdQzi%ia$>`~AL9EcfNPQLNu(QCXkcl1`rQ!$ul@-&ft6tozAVTsPn2 zfm>bgDIcHr*{w{4%7zUO-znw~cf z4M??YyDw1pi_rcG4g5p=q+8Om zx=fp2aDquLG1NRmf0EKHTZc}imPit|i>T>mAv7vNDW|BSiA^~_vj#Q?1Uxq0AU6pP zD?;aAo)zsE1?Zo-39)ZMGLS-X)B5}t_?GQ)oK9l-M z&dBB*VAaHg6>%=TV14+x%@VHH?s699EUDA}+cJs)Q&L}ynO-yIN<1UPD&WG&!6fBg zJL%l-oj6@Xqg|reE3>mJ?;h&fvN`P3 z2sGKp%Ya&&P##m6nON++i^DF|6a%TLSE02PuL|5C-<7cTp>6wYepc516b&l_^TR|3bj*qL9GDk#@R@A z@;Ys3(ooj|o@{&rBr`oOM@-|=WIP7~Beuu$CJ-j9lGyLt4?Wb&U&FDy&YxonrkH(f zivyW~yD*X#R9PGz=*+RHOZj}#NzlJKBBh#tmK(T8R>#Ds-$0U$U3h#KQ zDq`9CFhj^Iq~rH}@`lE)ws?3xx}hV22Z;eis^D{sPwPRg$~UlRzjfl?>S=V!h(U$E z9E5qDxOC!?xynqxIKEmmwEE1eaFOL14_B68eQpYzL7M_+aYI=p6P!oTjoRdCB+(U% z;vsLAO^(o9NYLXcYDL>ub&B6P=7F=z;NQMk53)kM48QZzh*P{b*1*_tC*3-j3;D3B|mq5v0hbDjYUE%L%;cT z>G{a$)6k8n8Z&JKC&S*aX%P&BY-h!9cN|uIdQ2<*ly>=SJ35(IJ}5n-3n@dY-J!UO zpX*blBaY^@m+=+Ee6wv=EWJVGH?J&ZjF?njheb6zYIY=-KT*}sDZ`q-RhQBKmt4$l zAN241qF99T+H4$tbyI@^hrj=7pmx!mpF?xx9Um_xGTk^H7HiUVc)zy>HK`s2Zv{UG z)9ks)#B-L?dIZQo30F7nh4!x@6&lkypiQsI52kzlO|sn|!N}`wRk3ROBX21NGriV~ z{H9?)jtd?;R?}^m3*_K~;FanF`}D(td-KkU@5 zQN=HjI&-N|uUEz%u;o0~N!UyEdSI&+Q}!vJm-1VJ)~HsgY{0!HM2<1$d6TCSrUOej zrl@B=M%IjZ9Kc1}vI!Nd(e{u{Aklvl`yxYGeNA>Nk%I!JHKzA0FT6S`mIfRjvl*s^ zC0;2t;}vAF8)fYuct==pZJZC&5*WwH*ski!u_OCNRiD~l{Q!}FDm5I!zwgy_YWwwp zC|QoZu2+wh+>E_X(L2PF1wN^6zqe9{$c=cJjc_e?|Dx`JHFWB+FP=K-myrtKXXQJY zZrBb9c>^kuEosxTn|!s*i+Gm`9&aOkDsS&;XAD z0!qncB(vB+*_c`+8*n8N1zH8UDn2OTY+_y`VhUlxlPxL3=qWz}<3Hgn6u%qN8DlxS zjN^=rsJ^x9J8`4k;!5+MYa3%UM20W8F2eIWF6aS0Rb&F3;436`$)gDew?8O7J>yUX zFBzC{!*E2uGn^{ACP9-64=~--gVhXg*5vFM8Zk27dk}!2kOVwR)JzgISj<$dihqI^ zFgbiIVCo3$uqz6AwE~H-%d4>`AgDJ4X`i4Z964!Z(iBb9+%Ga%}m>O?VT^z z!&E9DF=&j#$D&qw_b)MC;M?(RnG?}?#SKKtYgG(JLX3wQtBgKSl9?T%P|~z9)5;x0bU1LQFsS?3$CbUp{}@B}Q%Y zNk09exj$y-8i1?x?8FhS{8o2#N)c?4Td*KAl!nXh#X6|58I zLnZ<-^*jEiLUjilIAv{UwVGCUQK+lzn^VlW1eg6ecWFp1`NL)-IHVBpo~{V55!7$_ zG=i7^8xKkLZ=pCAjHEPOxpZZ6lqlN8RtOt&p?s}ckJAJT8j9ScLSOC*#@`ehrD_h!|)rx?J`{ai{s(CmB{E-0y7XuU8$&OtoUkh028n-yl+M z;@PNQy%Ty|TygqUrgQ!+M(n1eS6$xyLJ!`qJ1NPR#+E=2&~Xw&yn*uyFAQ_&#u>|| zW5rKDcOF^Cgd&S%qvnSqlSn}ITBdPp7v4rH23@+|OcqQcH3u%6^YdT(tR_;}Aj|ys zO^uN}`C!Vd*4BISx_;4}Qz7xC#PGnw2rOx)R5JD0yF?wKIpt*QJhMYJ%Wc>qGnZQI?tumS_Z+>zift0aKqYBm3Izo`{M%Uw}9JQ}j5BclSp8N?W^n8r zoywQz5V$qN1>G9tMhOWnu8~ybDYQ+!vNjc|*)>}&8^KNYj7+QYF4k{{H!XuZA@ z8d0#M&ru1%j(Z+U;v2RA@(3%asOefQY@JPA^@BT=)n<(%_V%VR21C-Pr5zD^Lgj(a zKHul3;Z*uo`fbki$@KVZ6nA%6M-b$D(YoKs3%Tf9TJk!qUwh`=rN-3o6>neWDI4_wAQNiIM5a01j=b+5}L3o^4UxYSbiN9%QzW5JXock6N4TIBd2H)Sw!$5_Wm-AEaeg%)Mdxw zA`Oj7Xq2g%EFgHVWb5L1v#<)$Txf&F5wL@p{x8j{JYZqTDot1y&~jT5u@tP(p+|_3 zHMvpY6|4f8UOk#iC5!r@-<`)P zsW5bbiqy`yV<~$2mj}zONVTvEL}$q`EC>4s!35mWN)dM5{u2aHB5j!v5!XTl<#O6P zC`BJ}uPDK~$~?h_MrFf~mD{etmU$4*3|n@1wiEx9rQjTKc?O1Ne%?`y)9(ykQ37OA zntFP6SPXRQ6l>l?cw4NvWJI=UWN4`aB(mr?MF|lr$%0%pnVqE4;~KrN839mPICLcst0|9-DYC9SNbR>8iht>tL)2=@n4;69E zkP3jae+e;fcY7~4Gh6akVsbI}4)wgsw$o(?na+BhG%XYlv-sEd1OVAa6Bq=nq-0T- zUnjC1iPhzn&hN``;&u6L%=*2XJA`Xfl!sj7aOVx)@+}!u%d0QuVr58-t{5T}5T%^T zn`}B{pvbg9Dm{i!=0_G(&x0mat+u5ftnP~oUCUkG$TP%aJ|sVjUv13Uoz)yWZY}qp z8Cw~MxO;EG1scEabztL;_!4c5bbi0rwjqRkX}LK@+h&}6_DRt;aTNRNX?=d{U`G5q z!@G&+FNQDg*8q@f((YN`^TVA_T)@|>RBLHDhY@zXDLuJZ2W~}iX?_ERuhk@SZ^aIH zOt9V%;mO3UFeiEjdQ)l)y$QK&M{{j(S&NEp3>@CYfl=q;czY}pYqhBtDjp#`ASUaE zSj|y0$eBdk_6KhcH%HViyuI^u36i*O>uF64XrQPemmtMXKc~$n@`b1(&M3H+^@Q=DUFK%-1ObnRCQf0S;0IWPBp$g| z#)7=yn{|5dx8DCcEcX^%#D;(;_RZ4B#Vl88)R)d`BWfT$eY~euX*7Y?Hxzz^tk8WN zNmX9@cqOmYd|sk}o^>&vqMp(imutid@)E0Fan;=jxZ|^9q^N!YaiSa>$9q*jF>m&V z*p~78k%tlCM^PbWv`r$A@AnVLi2R`V0{BS#+UVs46F#40OsYd(mMN@KCr-`8%|Zks znhKXcK_n6dgNmbh4g5rt&NN1>1Uk5Lizs>@|I;_TLAzQk-24kSb`x{F3 zwp?B0Yd4KoG_74}tQT2^SJ0fK39AA{q`L;3-?M&dNX84)u}vcViD*~{RI4TcDCLO- z*deb$NxCAk3}E@R6%Q*?)bBMTrb!o)6Pb6JJ;ISX>J-3uJIF&n_^SR`_`zO-a{H@8($(OHif3j)gGTI`5k%2tUtxSKWMSUDhNp4eH_i=FATyMj9B zyPKff`0+v)-+kW?D-9!bD9mxSewB~XLG-cds2@NSd&jeizf1ysT+Un315!)sd= zK)E&%ZVpqo=VHcCi=}H9B=t9krxkZ`^t}agF&(W{Lx4>q9*3rA{Fso$)E@@6?*v=f|bAak{bv1HDf9T22_$)mlR;*bXdr37~ ze`(SR@WID6A;78VeDG(d>BncsrS&5f7;j9mUetA8f07*&M)XG+)Tim%J)WR*mjeQ+ zm9)tZkm&{AIM~Nt7Cu8)_OmD{--0QdJvus`%qa);0G6k2W5$kFdiG=N{2qUlpScJU z@&W?XaA)JAwNvm2DIv^=>V+?fnHPH-G{O|LOI=8`?{; z(0DM`^YvXV1B{uoVf#t%WyM0@jP>ZSvy+$6pJCqU>+A?@GXKt;`5XDXaoc$5zk<6T z%YuN*>0P#LI(j~pc%E^QiWI^cJy;F9170R89y2utI52) zX5Ac9YQ-dE-jwLrbhZHtm^-T1P%<^GMKdRxk8iH3T58!qPgJJ3d)NCIr z%GOclX#{V0<(tb)5XqUD@72n0qn1r@*Oo@B0tOQxWI`5WCB;~FfyN6ljukWYMW)oI zU4`)~>rGJ_VozHqgd@Cg87PnyVs&=iMG2>0EE%iJU1e|dHuR)cw4xeu?D@Dicx|?6 zAVfxg{9CF$XR~uBw)4fafqf4OFxv|l-;fQ!c?2h*FlyCmRROz(j?WomeHxnPz_|rc zJyj+7n^17#FI!HVtCB&0Y){VtpRoDhMN>%M8Zo({oL;w_{`-1r2`rHeDw|4zZ5@@Y z5THdNC_yr`2o~G~_l6(ggpL#~&`=fRjk0EylErx)g8X8E<}Ip*$>W7>+Y0bkI0X^( zEm@}_H5ojF@(I*Mu#YrWWbvav%>KrU+B1~sKelJAGs;BXurm!*idt?FarIKD(UgHC ztWElyH_I#4tf{13sj%!{G_N#Njj3gk+gB50)ZO;4-e=ZzgvBlI>|p54nAl9n?F||M z*^>{J9ou<#ex7#xE9N8nQ|RsXc3yFqxcP@=t)Xgs=qMv165W4VxKrg0a^xl21SO51 zev`|SvbA$lYy9m3kVz40iiwD?V>{eToG7X&Miw(~Sh_&6Fi*2W?ZY9bF6(9;^eL6F zX(#LG7x&{k#%J;!>$6ASfbj?=<%@X|BXiiPui*jae%H<>XE=2|p=>q5-ZzWw8n(zl zxYxWd;_D|d8b2044=1SOb@N)qcXs;iIC5Jr5!+5cE{5S4zt!#-XW#dIP>=m8Z`h@+ z=@e7_4$k`*X80}!D1KvEQ^vwVhJfpu%+YN~!-URhR z1!!1s3&7^zQ9=}yIfu_wcnU11v{uY!2N9k(^6~VL5{R?8%{NNsOD=Z+%k%jnI8`Ep zk(>QqlRXJ4n&nWA$WGTyXd7bIp9!5%Je)ndP!!9MmM$nELe zE%4)c!R_3`Eoj5S$D%3EDBgYBtu#CHy)I+IbdYj0BxZ|<??4p%Va^r~NWlcgiT(eT_Ol{Wy4x!uRs$`o^3aA_5o40^UHwF-2r!BL=t2$Ixtc zpq33Erb`)zu0BgpJ9b&2@JpqAsd$OZsP6f}rZ?Q`>Y&`(^DH(QdsNe?B7T+|>!P<2 z+jzL^Z*snMHd-w!xO$JMdQY%>pN#swPMT!YpJkaV<$ONvr_V?Kyqq`HXHIrkCYJcH zjBENk(Dz;BT>+j0oDJA$a-0cs@7RJ$St6v@*kfx>je(Om`)!70gQt=;LOxVhw;D{? zZ;YyZUFfu!nno8T&__+{2ew<%mleneaH9$jCB5_1IoCY|;4Dpx)5v^DAo*A+xf_yP zCr$dZRuW-{EsdwLGA^czILnC2)|nu@nD{>Js`XhHNtSRBn$U8t4Wbp`0Ie85xZxj4 zB#=g5i={M|#fz#oYx5Y_Sx}n;vN0sO>+)$b*=wa9o%q5SN zq$rYp$|xR(8iN!N=bqLeBddt`7*l2u%9gzR=99$XvdYQk@A0W&MHmpWz<%{MJ3Xyk z+seFAd(C~PB@QlRJ@drQRMx3_v%hKAwrcB4^O{5+%RDkAC(o4nBzr`CGUB(zmi$J4 zm>c<_fAQzjvJwxT7VDT~p2xW1C;WKW(kZ9ZUR8SO+p4BWf6;z<{1Yk4Yt7C!e}oG~|NaAZI;y_x#v zc#uUU!B`J%g&u|K#jA}5m4=w3evV)NlViqb+Z%3Q=e_PE9XYJ0 z8Ax$Q?S=A_F&7kYMgkA#t8uaBvyNaJZ=H(Ww7S{j5Esib>L{AaIMh~ARXnBS z(t8Ex2rf?2Wa4*CsG4w_h$`yE0XfZJOy&%jqhtSJ=yw*tY!)SCOZN#IavHz$QwU(% zgzkoaO119DmksLJ4jOkWWK`0@cB`U3-E|lg1QGzSeYYmRpfXk2h}1%RTD$#*F|I$#mjU0*$pv&;{p>nyM*L>52* zt0wDg4X;wXjJb3|x7jqEXmSBXQGEeMU{n)(R-J_|cr3O#<u=LF!j^eW?ETRg-q-ya)VRMWN{FqW6|l{mSvNk0mHQ+tr@1j-WA-v`gty>Pc9Z&mu!&3<*uvtsW6}V$a#@iaW7b1Vj`5~87l=Ce1MgVeU zfS3smUJ@H_pryS=C4?HepfvihF<6TNrAL$8g5Ftkc(RbEliGvt_I{cpTrWAr@ks!& zV)cIjIY7q0z;zxsBpoAUJBs9{AnO&Z+M3P!HjX*q4;O74sWn%Pzm`1VH2)UY=`u}qyqPbWs95tFs zqc5*$(tmRz#x53%fI4d6CG3GW9@55{R|l5a@8p!ha%6_cID_Zdfo{eOAzFi2oca=( zWe+bK4c9BogT^pf^+-W+f0BSmiB)-C=h*@Xrs(dqt{ZV~#O{uUUXFi~JSA74Bu$a6 z{LR@1Rf!f4woS#DAs1!(*lx3nw&p_iUcso$hkCJ%ebU*XX`%9bvGXSA+>Z0uOKR&w z#=nLadjge#b2SXUUR|5|&gR{NzxaT-q7nPMJ??K!r9kT)r186mNl!sUgaU=>v@qm? z^mTJ}_?mLza$9JN4T{`D9&T)@HHRVsS-7=hgDWTk5@UTG%`E>y9FU<3_}DH$ZhYX7 z^l}>wDIp;QFVH8^ch&$ER8Sc-$q6>KUE9m%>bpuy)CSmHd7e6-qIq)NdPnCW)McHw z0mXk-BK}AI71b-6JneoOgpbeSv$&LbIfmtGJSeu%qL;p`M|>DCu_;zohB3;6){@s| z%IH~gBD>_(3?D_{EcPj}s5Y*5ZM#~c$I^x1Lpofg1oliU=D3vf~YurPUWZ$@sqGO*_Paa)vz_U-=DorDY7GzEr%&!x5aPg7X ztV@2zsjvJSpMc^3E2$M@+G1h_Hd|UsiP>u7HeUw2d*ad63hX$x=}`I&=bI@Si%l?b zArv^Y__1#e~+lGK+6EEwDO*8w2h5K52LK*TB# zhcMJp2nNqU;E?fhV?$b~5|S>^uIM}00m~R-8LR#@Xl7m;n6uS}Wlv?0$+8p0jtVUT zUD^^m`O_BF8~eF}r^(p@A1hiKXnT2VylE*!;+@%U+G+0rt zf}l`>=XgsjQu~HWv5)ybG+7Gd@5h0)eS#`z7{h$>*5cISx$e!x*A=pmS}q;k!*bhMA|B(xDjVz0J_~;Q z5?d|UO^hSfcq}#&@B4Li@TKC4_x+JLXc-oLZvaMvk%#MIgEGn}M>GP7+AoIKX|2)7 z#gXlCr6Yt^!fhw?xGP z;cM8Y-lBm98WA%=x}CGPB7^QO^lk`8bRqFzXlir4;8kwBoo6@}wsw)E)_>mLO5W`c zGPnxf^@+vS{LtdQLw|5pF#9MJ9k$bj_fk+qp|lEmJLET@1tf^4mXXIHeG2dqeAKN8%#nEG6o(%n|T5Af|J}mPs6sh^{n&TNGdNc5>DuoG*N} z(l${`t2N@}Izpm#sI4{d+Pl%5szEA*RH5E4@`I)pNu%~IkxqHNlK9Q*jPAT-$Mu;Q zclheCWsl#sq=#BZJ;WZ|7x%^kA6%l|&cYLwxY;u2Q!DdB)5tb45PF(`OtPAE2DUhd z*vOYXLrk(%^2mFAWwk}cE=rsP9+A9lh^t<8I}s2(X*34F)6n;ECW09KgvBd8pZnpoIy8Bib0 zXikV*PhzbwxmtpP0eAjqjs>T4h45dWl5Z%!j<4fI{J>dJj!ab)D)qv^lKdtdEEmAq z=X6;zq{OICuG888eESc9;G$!eltevGa-AuYWtLqCWo!6PGqnv-gBLnzl2fKUun3A0 z{3S}R%eex4Oy`cNtDAr;Nz@kS>MUmbImUBLs;a`{zC+be;jnI8Qh%Bn=yCk)5_d^3 z66#ftm(=vG1?~2Uxa}>XYAkgG;Vnn2Rfm<9D*wAcy3BS^|11WxxA|J5|g;{8X+yGOkJTXr5^;c$w_0dodptAjf z`pf>=g~`W$yBWIooUQ%et?*@h8DGYieTQTA$=2{CTQKiKFbp)iLJOo`nXayTlg5(ZyhVq+Wk; zQgu>13?R!y(OXU^c^*1ggzk#yxwc(^$FV;KZIS_p$XwVSTxzTq1QiyRdq8DB{}91N zuK?GM%M;(iw{e~CBUQ_z1$!sCw627(3T1}Rye}?1gPrkcDNGnMiY8lod;Ft=vZ0I- z$T`Zd&i2&XMB?A$^<)0$EeST9JdjBz{Bs2U+>G0^Z__>h?7uCjFo`OYi>`_TR|Cc< zHY|F9t^&OKr>$eV4~(;_w7~42;GVbsY(E1%m9dE#12mW!xrHX=rRvYC)qD=13q+rQ zQj<48JXh!kAU`I{(4Ld{{{VB%F%W*0OuwG38$AdWt60TqXvhPrejTCVoJ&IJp?194 zvW*(TMl=>Tx@l^tPBCqd8HNhD=F(uF!JAVCHij&;fk(P|?~&9t)zNh!v}+VX zMG~`u%ti(aNH4ct0VN;=-~||sa#DlSP<4l_l^`DYGJRYw6af$K9Vm@H6TNG1{41;O zM^^pUk@ZkNcz@MDb%0s%c<_DX*h}}o;A=G8W?1w)L)8ukDmx!|EuK#OxLoWy7ZcSo z3miMqrwi|I6L)K8lbk36%g_he?M?p`&erP8?k?EyekBIVIlwu4Z+gId_IU6Ocvhtd zz8g>Xb!~3*Jo3Mp24AP)Univo&55X3n$!W9#VlrVuy1D6{0benIkv}K0yR~d67;y< z9X-U&kv>yBd|H^>^?nA}3m~8i9Ki2_4(N-utwZbOt(GbT890Nz{qqk5ZidH$AI1}t zCxY*U&6SPG;~%NQhU@|o?gT3HMhwwL8*R*OgH8cHvwYat_6yXYW!u_=X%c*SrQhaq zCk7b$x5W=8vhZV@_xlG2PYDpLRM0t^PNRn3AZz~Y4|?$BSLr&s8WtOUw|sQ~iw|Ar zfj}TQyijnwxaHSO6L9I=Zq91w!^5(Z8$Pql_f4 z{sVBI+XndUuczR(VHo_eSPa7(-0FUKLG^_%;7j-tzT}J$WMCZia1;nG>S~MwyI30y znO9p&EOFehWNalEK&Bu^?-LgVJH$P66ulw(*P{nbsjdqSsov+V-d+yfvlhdHDoLs{ z^EoeCSGc-}ceEC%%(S7Etg(s~lB(<|phN)&Ruet=E|YmsS&cS@z)7wU!W<}y670%U zEq$2E!pF{1_WlLcVY`xP!>XDjUsCu~%`qV)aTLr^P^@L3T{Ec%9_@s25epCGdlo+0 z8rzNs*l1GuROucw@+dh~%`5e~)}-6CzJJ%YZ9A_A!-M43FzvEys!Y2P_I3uKK&FaaBImqaF!yNCG~_0sqDa4x#65ViK}2e)`?n z0Wv@Z=nKooeH4pdgki+*%*vEGy+x9bt*+Ps`C zY3+b~$;6{G*}jggcec(Ijpeu|dYVn;^Qq~eh9y$^q$G0@%~QXN4p`!nY_(adPD|=m?UWDM?)ZP%rky-F`tKYL;LOf;N-2Jp>*jBLSa z<+BdJKC3yccX(b}Vj8E!%QzRuncaSvv6cTv#gL;9J1!S3xwGwuMg*R~lXwD8?^Y<; zpxWaiRzkuNw`Q`3yG&n3O>Wl0tXuA>$Qw5-ye$!u+33R8h%8NLyHze*`P7|S)(x`b zf!e2OM_dcwYt!XJu!qt11tgldEnFH>GWCO3wN!>``NwjGhLP)1fLdB2`ncnv|Npp5 zdEFV<(r87VTGp&Y(+wveZkc*fp14*)vsG2JmDy7$5fV1rrAuC~-y{)m9A|N#e6cb9 z^%Gm~EgCWaVwpAYdQTQ7iw=Ez7$JOZCF}X~nJqf?=+$4SOmiil#0A6%i>;KgikiV?NuPsny(W}kc8GeYC~6GTxfXgCk^Jyn?7U0Jq+DzM&PlYD zu>oBbUL<2wnDW?Fy+O?{Rn$;=1~qu>$88L} zjhahthA557aA2X9#uRI(iM~W6FVFS(ES}RYnM(bwGX#1uRoj-&H+lyr=0_At72`|P}orU-r2O|D9F){2Ps0>FLhSO_@D{>&X_y;pg5M( zu#d-BHYe^3WNq^a7>qHEd39LMhId3M`Zn>O|M)o<_FcdPCG z&Tkw)pP7L0N9Ip+Ln=>8jzgTI? zuYCWw%VE&~`R2J@J&OMZe0il-)<2hZ?FmY!u2u#nKa-Si&m#mDxlPH%C zkg+BDOf@;nFJ0hj;bfk(5RW)s>_;fh^SL{s7C{rSVtUCOW>2=IaNccKlB~*~`dze| z>ECnkIlca3bX#vUVVUTYt?PUIx{4kzHZ|^Bc}#=ON{mA&wX9J6qNhy8^T+rnw;dJy z_>=zlTfyOux*w}y}?Rl4&gv?R#NR`MaID+FR)Dp7Ram_X%&9c|f(o9w5 zdSc{wO=V!(qKpm5%W6NJya4%BJ5maLDdi7a*YnpV5BR9>8$oM;zh0M+?R*c}SHAFU z)sdFuJ-Zrnxd_tc70)|a@!m)8x;)E5$l{x_KC4)*)vArDvq6)AG*Ux+IlewOhzB!< zP~e|sQ}@nOnh|&rFXF5Cy6#CslF693O{G`->}xDe*M;$7gIi=l^HYB5b(r z2jgD}W0{v{euU4{!Mlty9d}qek%>qxHDX^fv0v~c$nZ&@V(zz$Ds3&pFoY%}yjoQm ze-t&m<&9VrwF0ZfQ2P8n6(dR7!fHk1^wO@RdJLlBSQIUi%OBIXv)(M&mOIE*M$oVhpLpl(ae%+CX*py7jaN5A3fSm0=-U{L zyt@_=vb&ZwWWxap$%J_P0utmmJ{}=GSU6(|dA`pEeg#dV@p%*dWrP?>4VJ0M3c00q ziQsdw_#%hs83oxK^c6P>7g`p=+=3xR6qLBxs->f(Tw_X0{Y@dAxMikD z^$cKC#laJPx<3vVBq~kFYvJgRg+oQMgmvmfm*0X~8o8D^Ay zHF)nVo*yX@DS!i5o|P3+tya)n8y2}eRfUtXz{a|9j|a$pZ7TgVyT<4xYH!hWH zA8Xy4acX>ED}}3j1f-EwN%iZ^rB4wf{q1B5}6OuA7JY%Yn=L8Su^g?uXTn z9fe;i-X>_1t-@_Cc8Dd|k3*yNgiVLX-_h)@r_pVS$3_Og3RbZ4YD$5GRCqk8=(ukiL4&g5O-B?wd@&DNP;-+C`X2F^go;e|?e zc0SMI!2;D$yYM2uhOgl3r?xxb11)$apbBOW#&7XXCcRMOSD@ZwxKi~#nK|AgHNA$# zoYRfZ;+U_?+6Un_Z_`jNEeRPslh#t0B7kg&IaA~*WQXDdg>0&{AMD=ik;hJ3RB6M~;E0zree%tH zNTBi@3(t+zqU7o7!)^~o&HCQ=g)SuL`Sawyw-u=Cz=02cdfp3p8qS?Kz{bSTHeyYt%&`oQyuWj*X^yK@T`Ror`w^UG$t7$+Dh`BK*GtaIF+i3C_0mEs#eL zZ)J`Rn_+CLoMPge-!#E!9HFvz4Rv9U>OfSGPQmlm#sdr5c}KF)|9`x>&St}og0rnd z(@8Xlx3axqQH*VobR7O>8nc`Y`}AI^kP%fCAJy)9>=EwJM_e%V`vl3@Ez_sq%N@_^ z`hRWDt_yCc5j&x+i>{Z45N(Bf;ctv>k&_%|9e20G-}Ut3qc`FeJRh-+c`Br2*^sV^+)(eIq%nG!H5fyTqk~+>+mL%~I|Ud-Pq5eE zu$nd>a^ajMRy$nJa4;$zf&`}MCpeDa5D{ioZ{g~;wI~H8Bw!hn_zBbmj#RwQ8;hbE zvZ7P|i;P}uTunb1mJeDE;fLpu1xGsBc|HYcYtO}GAv7KG!h^`bCR19BQeU7EU95u8 z=?4pWai1`1O3=v3P*a%Am;y#uqYBLnkNZ+B4^{14UcPo!km0-xP!~_*A}-?Da~7pQ zYfqo7xgKOPMJjHrmWGgothS1twV|kmYWy+p9r)_TdTCYX+=V8XH96Stww2q8!B`=Qw5 zYom(Yy-?4PB}vgK`J5cN!}0m-ZMmwbF1?hzDm4aY=Us&iT!~PzePRele~^Pk(R`iC zdKFAP$SuQ$qT1!BD%XHHdoZiUS?ph(|HQkW+^6Y}uO5808<26lB8X4UuL?QMzGD#d zg7dP-i*gY{L6ME_0v)_yEOS_TT4WvmJag<07vH3H28}DnDqFG};6F;u7A;*GT52)~ zDGI_9c|oM^s4;W6Y+K!n87=B=!5IXL2pkq^VJ%A5PMR=fbRi0<^JfyRIEJF&k`G>x zM(e@O+1qT!%0tB_Yjb(!AEaM;9^2Go)%q3`OvA8PKSP0)9V{yWdtWWxfMhmmQaVW| zmnxa`G1vFcylSCd%pBWJ{^4EHg6o44{Qdt6k1rI}{(aJ)ci=D2nDieW$}^KMSzu(@ z?og|rhyk|)##y;$Z0M|ct|`1(6ej1=5EU#q?jW50=GiXuq|0+b;F(FiLwqLu6m**c zyl4c&_{l7A&^v?U@SqB=wNyDCu<@KAljb-@@fE9qlwMgrhY!Zi4rbi!X%H%YC|L%v zIYNW=@hP%Ob1|2;#^t6DV_8I2MfwXBewPE+wOlhZ@lPk=1aCw~5p!Q-jY07E2!iLx zgq1gBcR7pLm&~JeNvj${)T%Tz5n9t$AKE4jTqj;r^jZHKr2N)imU7cHo!Vi13J$@y z-gqfA7mY<~V|P}U$;0CRi&7D159T=QuKsKiSX8%zvJ}vW1BH7;$TOY;Kgp|X=l%q& z=91sr{?7DCsN)tH1V^wk8L4ko$OH6?0m&Y(q-5S915s+zv9=#V^S* z550#?2n!jDUwcx0QPo(jR+3?(u%i6zLTwB&8~g9QBAOi{C3IM8fwstWdPw5x?l&AElTN z>>=am=P-1Xw}s7N-Ldl#6b!-2w*u^MhXf6Y&&%zTkQUs$UcJf*O`mrp!_^XX7&Z_Z zspECxZdrqbpC>lLXK4o^PW$&J7sbExFJBM^(=$cir^)-$xX8C}#{xKCingN%%=#Eg z-~s{Qx3A%))UO^?m+^hRI-YU_a0I~JwCP5Ce|cEh80#(v(it2MM=7(*pQxC%6f;twG7a z0bX!2Xy^CnPP*Tg*(UuM$S?F3xF+pE6hD9Q$J{?T8wOv-&)yOI02u!U3XTWr(%lcH z@<%FTIVUn+aNZ{4y4Y2zrJ-ywwbjZu577=G@>^Pmc_yoG*g_o{Auax$d-;N32xq{wN>(nCb8?4lzmc$^ zCld(Vp#ukA#oX}i8yz&%2%`1di+CXi5bz(G@deA3z{PvmpMx;`k6C^u#nmlPHy|AT z1yx@bd2t0uuQZMS+7A0~f33mWp=t1a7((_pUWXrkKEqgCYa81+0Z<((l5Ib(cH?c_3b)L>8O%1{nCXxZ9H; zBDwJSup_*k9u`S|JnO@uTFkx?PKE}PSe?IO)as1VI44UH>>Q;Td;*tn37=i41WpY1 z@`6KEpHcaccv9HK6~Q&jT%euS_0qT#CV0gdg<|g7F%0WY zT#x(zSuTT!YQ1>d4=OX z7sRSdQC2Inf)LhfzAVD?_%!;h5GMrtqQxpALm$pQ#L3c?wN_@D?+Uy~!73O0u3|zN z7h=e)ci)P0O*Y6ma64kZt@Qc=*D z2*LY;VEQj7aFSF5>Whiht^3^mwH$@-+C5eDDX6 zI(r5{zJD4ULc*4_@e&~G2*j+9R;pw!f-jPuf`|wO3Jwf~$~u2(s z51c)U2kDCi$6TM8hKw34t6KX$FPU=`7~zaFhr@y(lg&yva}0Rktv7S@Taow#!rL1R z1?#&Is&_3t?b?|2EOcT{);v;EapKWR*|El!wvR&wEn6_YcxWjQZGh9}V*deFau4P= zOcN}hJ^Q2~pT$Fu%!xNb0}g3~W(S(9!jdDO)C|wnWww$A6GK)dj8~1}0378sium3QeZm7n zdm^OmQgF16?gEmzU2*4J+L*s+BXW+;8r&QiU=flfaS>qnfsRMAf>I3s;C2rWTTA5Y zvqfV+b5dY>Pfg1F`7~L9BW*D|tumdaIVoh7OjjW^eTtYKc6{H@8j~&9DRMqpMORo4GocsXfRmmetr``XHuU(cNHSN9;-7i z8u|ONZF3)QAd~A{Po~Fw7)elTQptv7FZi08E-U7R)&63!0ZW9c9MTkLVuP*FlCSEI2-MF9R4n< zi*bY$$aNcfk(D}>O?eR=0M|k~HwaM(D2c%G9Np`?UYi5ox?pcYoHglq-iH{yb=*+! z@I2g9=Lg3W?J%OE@SdrH+7(l7!JB{DQ7I+DwBfyRBhkYLx-=QZi#d7v>D7?)Z9sw_ zeKH!jzC^Lcn+EWe@HnjVZ*Om}4LDw|^>2Q;3FuNX`Mxp|Bp&QAJpS{5ZC6 z)eL^?kFbL@V{x=`;-9M15%TT^l*ip$fBdx`D?{k)FV-I;jL{B+q_I?rfb@VG3cJ`F zNIkRpJP54B5$pg&*!lT>nFok{`)j?}#?KEw31~@4W+s3a#D4rRei%QDAI>#m98;UD zYo}snd(K`MgnS`v;%^687CD<_Rolq!PBNNiy5+%`?(1nlPAvd5Xn^YF8+dS!B)(pW zK9edL9ZG(*mU{vbq1H|&L4|EwHl?h3nWw^Sl=YQ)7t7Rf^>V?-vGbT2tRYOzk%beQ z7Um^G_JFY*e6e(pn!|v8SGKu^OUrCEqlZa4XO*1;$8$at=*XYcWcz9zr7RUsBQ{qn zx>!K$W?2a(RcQ7=jvx30WQ|~sD(JFG$1XDA_+t&6{QiNTrs>3vYv!kOeq27EWm_wIjs&pdOI_mZP#ZG@Ktg(|&WMZ` zjA4`+Z(l+)WhIl5lgN9>`^3%6p{6MO$j6r(##|NKjubdc8(k{6Kgy$43OR)?3TH7D zGCq$UPJY219hC4Z6k=SK()q01T++9uC_vt|g>t%oc1cZH*Z9z2(0)!p5iQuTs3G%; zsm5jwwshQi7|l1K$^h2`t|hh$Phs6esaIvnK;D10hC=+vmU2|@bsJjB;jG&jO8#Jh zyQxA}6#m1PYqwq6y?ci9V&1wva)C40`Mck8{9WH872zzoKur76RZH)w6OWJhn}RM3 zzVEr8?*g)V}bV@!;sPYPtQ{z25e>NpB6oB z%uaNb8+wiI;C@RvS95RwE?E_-2ioQZ(uvvNKml*?g!PI(2ka@xyzPx&ZWE`q+Fzn; zIq`qzf$D4%oIcVwdz86Qt>EnLe|^bM)*TN%&0b$YgdzhMAJc8y{kip@*8jFi-=HN| z8GKI^%M5HLIC$!Y2MP|Yw_HKn70-6~FhjWUDW}w`$NqxzUmnBoU^g|a81Uq3Rc)4RamO`L&z+_N4sljo+lGi zlvc%&X?Su8-n@X<<3eH3rBxO*Kl3i5%JGmH7eCLkNGg^^5&F?_Q3{{LM@#xi&gB zbQ*k0cK~EAeJ!coRO?DCiAYdZq8RIj8PV|}-O;I@!k|<{`^$3Nywep+Z=D7!q(^i7 z#pQ_39-u4H43xF;0eoZaqEIauqr^CHwU$NVQ(>#=x_nu^2`%{Y$=-SAUyg@GJ0-H> zjRxO6x>&7V;%avfUBJEwXj^&z=%F4yyrjpucwCn{ow1ovmv`OVd=!<3 zFG%v@vR&52D4{8!9LoC<1Cd)BcxcdbsQdao+?2XYciaJs{Z|V(*`M43y4lvJ9}cLs z)(n~Rz^nH^8K68J(}NCX1PW8Yzy{HM(dG3yIx%Qns>`BU8Y;pPfkfSeHrQVbS=Bzg=*PPYDC^~hb$<=)L8w}waCbr zMf5}+^@yIi^hk>SP$!O|S%Y3H%_z--g?uJ3!FwPDQM$53I7NWq@(^|vj?$?p4MN@* zAh_ZMTx)ri-s#jZ#I|RgOoeG?^334#<^2#S-yG_IA9gZShnp`U{KCNt;FFh8pj%`- z2R5LP%eVGe7|v(Mt`#}E`g*9Z$SuAaP|2ol^VN+?<=VN`R|8+NeYe{*=m_a**8hh~ zW^b`7Lo>9(ebDWySg*_d3}J*fU0vYR0gocv%^U6G|0kVn5TgU!jXDTC=s#f5==Z0t z!1>Rxp3yt_-dl(=Iy`|>-9h|s;H|YU$Qz%7cNs6yEWu?vcM`KkK1&8rhKz~Wd)31U z2gj$AZO`UV*Bx`1MaX$sS1A7e$yowBpo;<_32TV2lJ@2OO-NxlmDwM}(#AmG zb45V zS}%VN48kV)Io)q5bBOGi8P$Z&>l7;LdQbUaeKn^2%E_qCs<}6VsGXw(4IJe%c!pb{ zweis)R2xB)(5Jjg4E3e4b91xJ27TsW|5Bw94HbQds7$VE9JQrzS#ct($M&M+UVNnq+*YTm7*2OtbCwHVZ}7 zW<*8f5oV(uL+Sgy1#n^iL}DJUdvkv+Ro0M=Q&M6uI+t0O?<3)REv{F^hNjA z==Rx+`vPRy_|CCIlaQpN&)Hwb7)gx3yI}2+M3MA&!^eqjpD(c0s+=~GYkbVurBOLfuNT&5{bSx$+ZI+JbLbh6%P6E#)3CfMY zM|$vT^tYW+t9+Z`XnO9ZOPh~);sRgJ-6nf(h z;!a&dQ8~HI@Sb}gx^TbA(k=g4Cd_7g;OebU8tW?Pd5{|@IEQmMhjUhLsGYJ#M;ENZ z(bk1($jV+1SUxQ`v#pjUl>*^}AX9U-L4#dwTdt`}Y3QNq;sF;`jK!o7dsRJE^n9!3 z<4z4Ie_= zl4SZ;3$R;%ESCzv!J;u5yfDG@j{i(m=Q~JYi@oW1Ozcy^_Dr=Rxlt5Eo@Y=jkRV<} z)wfwmf~UM=V>#JD;7vyyvlb5orH{XgZf8IJ=;#MDI+Mi}xEq(nq|=U`_~hi-uf)^I zo;oV?q{gb?tCKb%+c3k1q(!!=pDnFg9S*%8^g#Ni1dBCXl^SZ@QcOq~frE2eL!84{ zOYd0LOl=1an2d5aLmc6Jm+G3L8SMD4X4r<50R%#oT4^<_8~38yy4EVLHR45<5Tw=s z@i^aL=WL#Ga#5w7wJn6*ZE|YHV*O=te(u1OyB(ODU*yg}^`(>1dwg|J$UH_hD&A8T z-ejFVthKS4(3Ll`G5VM3J_X!0#xkTU*$33X}irz>{c_Q@BLNqlt=!;7|;3T>1 zLK#;mE?^P05^n?@`Iz`#R2hyRunog!uET!kM0IG7lEcWNCEd{Sw7bcb5B_8Ap2@;v z@qX5&YhR_C7~D?B;%WW!!%`*e$Ys$(XZ}R>H@OJevSnj{C$33@6WhS56Ssh96~r{L zKrm_{>Wvkt*8%gJz(yR;uv4fgOr)5&G9&7*7p$l}`4dnriX!Nasv1S#iz2N_i-BBY zKa$0_Xqs+YMNu~41j__XhzIDuu?q@|k&73kHo~5wYmUzhZaj!6f_^@Nem;cc2Z==c z+Lws7fV?p@-dJVHy=#?PRzBzcO?W7Jdu>?*p>f(FT36k;j z+bq;UlNFL#I6l#&1exCamc~CMP3RDf^9Bjlj0`o#0Sr@w!0Tr8h7OUyLln>u4K@~p zYiBs{X)Dqk5J$XcJwo3I-OY3dajQ&BS_5`-A<`%QJv16}+|4d{hftYL>rX(Z2Eh$q z193ja;@b}9zwqPuo(O~cugbV1EYzj(zLChy-lag6MvBCw{ z4paA1X)A~2=_OU*)k)AvRR;Ku`+biSfd>|gUh9K|vVtqLsQ9w3Tav`J#q~O7VIT{^ z$t8x&K+Ig!H~+#<&x*k?`_Ah6EYG*^nH@RNvG*aShCtR>ZE!_`A)_=T#h-LGz)|Ba znof3x`LMD2bJz{TE@Zwpfo%IYba%ubB-$|y&O%ByC4hNQvfTsdkElY&1QFSB9T*UlbgiGXmK zv*G?;;FhM?GB0YkftS=b*2reVX-o*25WD-|31OblRoRUA*C(}j=I=ApwQXU{Ctvz^ zKJ7%l!Va!t8#~HWXygQB{1y(~T_l{UT1el-fjn$83dlGcRYp7cb1fZZY~u$6`&N!q zkw%ydnqz?IP4*OGTGNHuuu09}(>r^*4~i0mwm_{5Mi?2(TUsrnAq#NA=3x+MD$hIz zRs17kMm(P5Z<(=cY?oEHRE-vlgPGzNqmW`zEK`~jAOtjV+zT98AU>!@6XvbNBD_uS zOqllr`E9ly{IamNR+C^HQRpk?v#m4664~w(`p^k*#usjpp`TXvO+evi?1oFfa*{Ac zj6B&BxsT^krmpSad~g)OSfUZZYUe&P5pK;{o0^@AH;ybnGMOP>{N%bo-hw?Kl=fRc zRr>%NpP8PTVFinN{iNRX&ft!VJ2vbdl%o&6|5lws{ptD@`CmU#r6lp>WZBqTn(M9C zk0+sS1;$apr838UbXk>k9T|^BnEOLm${1B#%BOh?iu(}!^!}t-|Hun}R#%~QD8k9_ zxcrNptNU=xkUgga-AohVY1!$Nw_a*w`(*Uzd@*EP+4cVdY`vSS9U|+K5V_YkfR9FD z;C;^Uui)3=*W&jrnbKiGR7?`{g=6fwe&-}^cSrg#WNyw;&$gl;wRhl#Lu+i za|gkOndX62q3Mu%sK+k1V8pQ!A^`+u^|D6}89OaK&SaftSe```%nF};1diKpW2VV- zJ9H53b5ITNK0&G7}k`07Kv)Acn^4rJDJdCTn*a zu9sRTEJOxFIfW+5@irh(;q-;_(cHgSO7s6&_KrrI#M*~XF1}&yom+OawM*{kIRohS zw((jfT}6wHIvET4CRqC5oO})5g*QKYqW0Bcwkz%?T5NI%u*?rVP+#%#G@qzYJRLLh zDFddwC~6u((u4O;aAu5s-$Qxp_XP-fMm5BA;;*i=*e83v=)4ZP@98@7DK7m2^cI1y zn}LCwI|_#lYf-aEqi-%Igs?&V3#QU;$SsqQy|5?l0wR2m0bJ?l>~@nBdAv^{XD4Fd zRAeu5f;K>$5Ea)wZ6jUqsy2VYF;v>MD}7{*$y>6gFo~Fcal7<9k(uJGs2j`{fmQiL zRkhG|fy;*L$#C{`j#MA*I-fc3EL1G&W{!~O-kb(b;!T5GKCDqVCm7(&5(E@;rxig+ zRdpr@Eq$jnh*ioGZ+BlfTXyEAqFHq=O;aN;mn#zslVye0^w=pcer7uQlx&%^x*8Yc2T$@5i5p9*1=j}3y+UZTB5Ws zvqYB0-b}4_@ra{{GkRyGA|IY=_bykY(gYTdsOJoL<8!s05@&xpqzTs2gM!arCD6Fb zv6_(SN5_2b8eW!}O(bSBWsMU%EL8iMKWFiJbH=t`&yA1I!FbC9OVYen%t;*tE#z_8 zAy{wljiJLLq=ygd;3nbV`3zdX+Ah{(>oPj>8Tb3hd*a$=0oAc-{96cgX-(kq0X4O2 zcW)eM6MH`ocVm&yHArNvC=v*n6?Z$mW~@w&$>^CKrxnRO&9`|M`b+_Wnk^Pd5P-S8 zeJ1_&y7bJ9GA!#am$&tAakqz>@2Q`o{QU8tma)5c69{mu^2}1^cbc$Rhzv1NS2zWE z3vCrItzw+7v%eowFUxFkGDWB#@*^$fcCtnuCa6j@+-6JP{alurGAnk{+80G(%RP(C zrp%`AO_I=KNG6A7o3CfG$W1H_>^91p>-aEP2Q)c5RWR*p5R3ij0?gkB6vqP;+y*l? zGv2KHA7?9x^Sjxm{~Jg8VZ#q*9)_#>q!_MDrzJNrarDS(JhUhzn~)AnLl_t(X($7u zBn@d`l%%1BM&T;1;wpYa<#FDv$af zHHcwlFwD`!VKGoDSg#X&ckr<4G#tko6C2+V95YoG5XPS0KyaKgIeOZ3qnihO5cXd= z@aR}JL1CV`!{k=~8F^EZarn0m0vhy{xPcsp`(-{H|1i>LRMob6+xql-ljOzIrUQRq8u2l+W4( zEc;rPJ)>pw;Bbc7HZNuip`id)nOm@;&d7?tjW8t%;lq$$mNcC;o(-W(#bBSSm>xAk zH-B^U7#rDK49{-Z)%ND|x4zicYrsB7=IcLBv(~zWx_166=LE+bdg@wB>DB+ehb`AN z6=nkC-^#ywlGA2`;Q;XWzX5U+jqF}zSsafX;3W`HzkS29v+v-RP=2Y%O2f;q7=wVZ zV-Hz8#cpwFh5o4$(1M%eX1K-NTos7s5}=ZZ7OZuA?Gg41OTkz&Zb5n$@zU!in*&?_ z06N~hDru$phhq)mvR)|V7p%l}`pmb0#H4WIn3Bcp(c1n2Bvf9lj}GS_7NT?4CM4fT zd6DXx(smw)eLA)K%{1oz0aQnvN-tfy!qwStFLWmeuir^r`}GwUx?8-`C7=!c`5I9k z9nMA~I>OB4?iTOvzk`>0_Un=N=eUW3*IZo2+Q*AVESA7wS9+fjoyPm5{qYOhXxPOD zfqX5lsI+Nk?4gFCT}PP{-b5T_p-w6a9>vXBmWESEv{j{RHE_DE3}AHotq2EIN+-pZpRJV{#A5EV8K8Itw~f3qpu4 z9~&bSFxrNjy+_$Y$@j?jm~Gh*J)?TMn=BHrcNud%pNX4xwJERk;Xx#W z!31Gyu&hBF8K20nHo~r1Ap#kLH95`Nn4Vm*HQ2aaL5Ake0P09D;l-?gvL%NfP-hocq#6nA*pq z%1wtFnJ}g2u87KpLvGRsiC=Z$J?@S%b2mN_a)a1az_|socx*Ip0Z7EQ?xhsVS>V_t{;Z7oP63^2gpm9-SiT8^L*%GNO@Q~fPJ{n^oCKNltU zCsvUlWvOR$8z)y*@=Up7Z^`8(ReAUFg>np!(vU65|A)R*{v_tgycgn~n-QHh~Wmg2$IMQyZ=B|szef@xE z-d^Utq=%K3NCIEjdJpj1Vl+PZkFmrcLw{nVGJ!#)HR^!jdp+n~sQ<<)fdWgN>YdJm zqg+JR^hV{)Y}51WSjun{+Q|F=&&XQ$Z#2aMAZF>Y_-V%> zD4DhA8ey=_ASWM^r2n<@0NB^V*Y^=ga`UZ!{j#HrUDhywCi?whI04PB4S0rFY)Ad! zdjh*<@CVSsisAomJLK760-lDjaQK+N{jQ6>cl{9_u*IUdtTz~himmEB6a=w0ONKKi z==Jh@5@ML=GErl)a{Af31nn}3s=(m;$gua``0m^>i(e9Tlh(9uK21ZNU}Bj=$(gPm zXq373NYK>-jY3R3Ho;^?R{0e&*Ma~dD5B-|LgL3&92s*?$;&lLv z=1gFD(ytdlV=S{FL{Rlk^lV!x#|&iHX^bH_hJ0cN=Vvh_6huwvGzWeTl{HS{$T6XJ zESO}WsZ|W|%&4?M1Bv)p+KI8y(W$Zu!1EM?kM*eMw3(HY-Y)JZXF^Qw%(kemkPxh_ z7EqKgm%a*cik8bLrB)9(a5x>J*)Cv8lcv<6pQX*Mp0Ql1Hau3}t{MtkTV<2uH#Zyo zk^QGzHf-DOWN2Dv2PP)wEbn6&*nPVL=uRXZd8uZ9vQK)TwPNm1?~`_ROA>F|*FWli z``NH0@poIl`BXE@LF~<%ZabSnU)@1@P{>}=P1h2XOcCx!>iX8ZMfU-Lbl`r%^ad4_Th4&Chn+!A57| zlTwi{sChgIj!ig@m?KpDQ&95afLSP)#M)xa@`(s^@o#iK(uKh8QNv@2EWBO~9)~{Z9!*^O&NBuOFR-h7 z$M6lsvO#l+mP&sn+4zuvT^Iw#mV3YJj4PruijADu50Q|NPZ^}W(1`sTA?+qGC(R=Q zbo@w^(rCeV>_QLgTS>WO2et@htnk=*=MDa{fslf@G!MWS^>mVIbd$%`CH?YKO~kyp zSr}ic`K{=zITu@+OuF{LGO&LN!)QM*w%VbAJ)A(*$-HFgzR<~U6s6oOq+EG$CRH34 zJ3mPFyY^laNbwYbL8E*-s(4ll`t0F6$>+_-tVZIx(G#oq=&ND5w(y7DcD2cKMEjEw z>rY0!pI?kA-^kE|M!YS0WMK8pGK|=QK||I!h?~St#<66PWxp&Sj{;vf=FXj*_-VJ3 zx@bOsg5+?|aJ$z~bl(LT_WM@fHmwg-Vn@4Q+74_OduEqT2g`%B{(vt{N^;mg>pXv5 zsuPL>(EwDsYqlPy_=bV9VeZGD1*o-|z4%{<)8rR2|C9eZam6qW1?yDFtK&2UU4($V zdP=t2U41!b%ri@KVh%YUl-#u605+KrqZMa;j8#L-gytWOUa2$o4yXrEQmR^_hGO{^ zTsisNT66N1IoXf{UZI9ipB zW=_J&pzaKVXo}*Uf_K7zXl(fA5k)Mk?K2*Mp|8g5Jj;Mo9B2`u=-^do^{0;iv&FOQn^gRE1>6OWu#I;G67pGzDTOnBN%RV5Upq0T*J(JiYB%Y za$hH1y?DHVBF1J;CLB2U6o*~N#BSdu=1w{F7G$uXOB_+7tT)AgVuYnQix-nLY{lzD63Yt z8bagRzprL?uFVsPdSe9g6oCOh=UXcDc;yUL0hhef!PqH2>F<|!R7M%r^`aGS7Toe& zE3k+;Ytcb9(yGtj5es0RVj!r`TeYsA|N0?pHG+$=7(skgUhFo2Z@E6hzgMqSPB$!b z|Fqo@E;za)(EXDtg#O~%A4nrtV8>{k(MAu%|FM?VT*4$4F&8c+g_aw)!t?3c!fGd9 zy6E-jImFKN3*eYaqy*eRwl|?iIRPqw}_m~vmozu@#LehRu{UC zti{>4!B6K>N&kGUQ^VG@Po8M^_uw9_P+j-;_ghHufRq-g{K?>C8v}!xOvKte5SY&i zEhf*ZK@2#>F*Kf#_{4JN%p{*hLziRv3)>_?U&L6L#IYQ051$uE!!EEBEp z$aQoI@1^k#sUssAOZckMbt?7fdKJanX9G|+z6uUG8Is%STGuif{KLcnTFb$RLvd;F zyda;=`u29_4k~IID=j0&fG^AHfQB057s8K4Lm~G}U6U1zqFyeGwXyN$0_Gsy{y;1m z@Rq?@hJ)nn5*Kt`-{)*l{Nr1Y=Q(~YpU-8vC~)?0v8Vi9?c!%zj6XDpUkZ-ALVe?C zM(F_?sjIV%3rgXfiJ_{UD;br%Q=W$}mHWVblFj;lh{!y7*a+Jtfj}S_=nsx;WS~IT zEBdB0AJa2$-K9Oe0{dSv=LN$Xrq>Tn20=5EgIpGraOd}>5I_nh#M$57g0ufF3h)&{}00Hv+vI44?OLrs`njjeBJ^hQsWWxJ}d>S#XFF4jr zgGy)Vw)mvh+QKxdSN8CIelocUmnrjGJyZ;7I*yVFdxpPYzyENtG;F0X(sCpys=5E+ zoJ*Zr@yW?1wBGV%^0b;mbCI7_P0~{e6`S{doivekqogwvRn@#@k#veNMSMymZXFSn zg>2#f-^L#``~QHzD65o8b-wzeW35x6wX6p=b7>1q%tlYw;j%I77`GmwTze7tjFTsn z)Q?dN4{UGQ(65_YY2ttCR5YvQimCCoCXIMhIAdyRO>Gf1Sr74K7KDjny0}s|hmmId zDeWNlm#ShCd2~|L`bM(>hve#=g#<__tJU_QCEk*lR= z9X@&-r8S6qUEUvgRD2KGB#kv`J`q$!O!;yF67Nr{Sx*^xMbnhtDYsI6_h6upqL}0e z^++A4l`3|~yZl`+H3TA{nm{{tKA0*zJ<2T@s)SkhGaG06h%1P`)8Vt(+VWh7buJ8aOhK0K$-uGeCefxpc7`RAii&M;DV} zE{^4REvF&y@dB zS%u<^>hLCC$64M^S0i%jzPfCWhC%mi>^96Tj!K5CdvnfTlW}r{xK?AifR%Bh8sF0_ zxH@QGL|xqM5lc7n?rB|>EDJ0$h!Z&1Q=e_+Y3jfjv^z$==1Fa#rq0T%oXq#?S8bo} zOSM}c66|5aQE_qV@rL?LC3)eU>AZ6(DA!M`TtMYFU<80!o(euZq1#z@=j@Rvi9@n4 zDLFmqY0pS8U#c|Edd~A+fVA6vfgUO^dP%QW>~;QlDBHJk@a?vH)oc2^Zjtvyb9^T?NZfT2IBMqPTdrmZ|7_nsGg&4#l4ha!KA|ysZNCGo{@x|8} zfI=cD;0TTo2#$yd6Drsoa;MR5r*O(6HVeWCqZbn4F1gG^M66vW{rmM%kGbNiYp%P; zwEI0kfQTow#U|Trv%`8Fz6h}m?!(&X&oUbS5jc;>KOu>KLmH8R zL{!LPaL8jwpb;Hd3=KSDA~xb8KK={BE{pB-ruV$%ZSO}yQ?RGUIp!2jUbOWvyQ{9I zoYfuLpOu)D=ADUsP4~V`+o8_e>^^SNxtwXGs%PJ}ek`!yPV~(8j z`$h<9p0&F0R2($!Zc;i`skhTaI$xURyH~8UoxfZ~T7EB|mt;AUNbjVe-F<+|F+$0+ ziD@DuX;W5{k|Xt`R_T%vf4FMgCpB20OV+H3X#*yw3Q|%B^tYE{tsC+8@Nei+Xf*V< z5wGBj>L}@d7b*)lvzq@3#C#A#+s)QTrek5Gnk^-Z(hGrlnKkWWmeWbCRyqHL zwX{B%US`8{y}9kIHyMkkJk8lDux;(zx$1ZctNnS1W7D2|SoZ_vz#?-wdA>4RPVT!W z8m9Ld>SfbOuP1HLE02?|dgQ8cvGzprzH7%j{Ro-%?Kwhk8fI5G`5W^zTog`g!QJ6F ziTPpXWL0+{GbxoBIu=HZVtvEysfB5vy4ar{<8Q6mm=ew(GP}{fmsC7UQa1kOTtM){ LhjMQSk$lv|YM&HU literal 0 HcmV?d00001 diff --git a/modules/lk/assets/mono-cyrillic.woff2 b/modules/lk/assets/mono-cyrillic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..de39acbc40690eba8a6e4beb11ed8aa26656b07f GIT binary patch literal 8872 zcmV;ZB3IpaPew8T0RR9103xUW6951J07iHK03tmA0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/modules/lk/lk.go b/modules/lk/lk.go index c2179bc..bb7e221 100644 --- a/modules/lk/lk.go +++ b/modules/lk/lk.go @@ -27,16 +27,20 @@ const cookieTTL = 12 * 3600 // Config — подключение к bj-server и оформление. type Config struct { - BJURL string // адрес bj-server (https://...) + BJURL string // адрес bj-server (https://...) — в прямом режиме BJKey string // ключ доступа модуля (выдаётся в настройках bj-server) Title string // название кабинета в шапке + // Agent — режим ожидания: у ядра нет адреса, доступного кабинету, поэтому + // не кабинет ходит в ядро, а ядро само подключается сюда за запросами. + Agent bool } // Server — кабинет. type Server struct { - cfg Config - bj *bjClient - tpl *template.Template + cfg Config + bj *bjClient + tpl *template.Template + agent *agentQueue } // New — кабинет с разобранными шаблонами. @@ -97,27 +101,52 @@ func New(cfg Config) (*Server, error) { if err != nil { return nil, err } - return &Server{cfg: cfg, bj: &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}}, tpl: tpl}, nil + srv := &Server{cfg: cfg, tpl: tpl} + if cfg.Agent { + srv.agent = newAgentQueue() + } + srv.bj = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: srv.agent} + return srv, nil } -// Configured — заданы адрес bj-server и ключ доступа. -func (s *Server) Configured() bool { return s.cfg.BJURL != "" && s.cfg.BJKey != "" } +// Configured — кабинету есть с чем работать: ключ модуля и, в прямом режиме, адрес ядра. +func (s *Server) Configured() bool { + if s.cfg.BJKey == "" { + return false + } + if s.cfg.Agent { + return true // адрес ядра не нужен: ядро подключается само + } + return s.cfg.BJURL != "" +} + +// InsecureCore — ядро задано по открытому http не на этой же машине: ключ модуля и +// токены сессий клиентов пойдут по сети в открытом виде. +func (s *Server) InsecureCore() bool { + if s.cfg.Agent || s.cfg.BJURL == "" || strings.HasPrefix(s.cfg.BJURL, "https://") { + return false + } + rest := strings.TrimPrefix(s.cfg.BJURL, "http://") + return !strings.HasPrefix(rest, "127.0.0.1") && !strings.HasPrefix(rest, "localhost") +} // ---- клиент к bj-server ---- type bjClient struct { - cfg Config - hc *http.Client + cfg Config + hc *http.Client + agent *agentQueue // задан в режиме ожидания } func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error { - var rdr *bytes.Reader + var raw []byte if body != nil { - raw, _ := json.Marshal(body) - rdr = bytes.NewReader(raw) - } else { - rdr = bytes.NewReader(nil) + raw, _ = json.Marshal(body) } + if b.agent != nil { + return b.callAgent(ctx, method, path, session, raw, out) + } + rdr := bytes.NewReader(raw) req, err := http.NewRequestWithContext(ctx, method, b.cfg.BJURL+path, rdr) if err != nil { return err @@ -126,6 +155,12 @@ func (b *bjClient) call(ctx context.Context, method, path, session string, body if session != "" { req.Header.Set("X-BJ-Session", session) } + if v, ok := ctx.Value(visitorKey{}).(visitor); ok { + // Ядро привязывает сессию к браузеру и сети клиента, а вход считает по адресу: + // кабинет — единственный, кто их знает, поэтому передаёт вместе с ключом. + req.Header.Set("X-BJ-Client-IP", v.ip) + req.Header.Set("X-BJ-Client-Agent", v.agent) + } if body != nil { req.Header.Set("Content-Type", "application/json") } @@ -148,6 +183,37 @@ func (b *bjClient) call(ctx context.Context, method, path, session string, body return nil } +// callAgent — тот же запрос, но через очередь: его заберёт ядро, когда подключится. +func (b *bjClient) callAgent(ctx context.Context, method, path, session string, raw []byte, out any) error { + res, err := b.agent.push(ctx, method, path, raw, session, 40*time.Second) + if err != nil { + return err + } + if res.Status >= 400 { + var e struct{ Error string } + _ = json.Unmarshal(res.Body, &e) + if e.Error == "" { + e.Error = fmt.Sprintf("ошибка сервера (%d)", res.Status) + } + return fmt.Errorf("%s", e.Error) + } + if out != nil && len(res.Body) > 0 { + return json.Unmarshal(res.Body, out) + } + return nil +} + +// visitor — кто пришёл в кабинет: адрес и браузер. Передаём ядру, чтобы сессия +// была привязана к устройству, а перебор пароля считался по адресу посетителя. +type visitor struct{ ip, agent string } + +type visitorKey struct{} + +// reqCtx — контекст запроса вместе с данными посетителя. +func reqCtx(r *http.Request) context.Context { + return context.WithValue(r.Context(), visitorKey{}, visitor{ip: clientIP(r), agent: r.UserAgent()}) +} + // ---- модели ответов ---- type position struct { @@ -249,6 +315,29 @@ type incomeRow struct { Account string `json:"account"` } +// corpAction — корпоративное действие по бумаге клиента. +type corpAction struct { + FixDate string `json:"fix_date"` + OperDate string `json:"oper_date"` + Kind string `json:"kind"` + ISIN string `json:"isin"` + Security string `json:"security"` + Note string `json:"note"` +} + +// documentRow — документ депозитария или заявка клиента. +type documentRow struct { + ID int64 `json:"id"` + Date string `json:"date"` + Title string `json:"title"` + Period string `json:"period"` + Account string `json:"account"` + State string `json:"state"` + Note string `json:"note"` + Ordered bool `json:"ordered"` + Kind string `json:"kind"` +} + // securityCard — карточка бумаги из ядра. type securityCard struct { ISIN string `json:"isin"` @@ -281,7 +370,7 @@ type securityCard struct { // portfolio — реестр клиента из ядра. func (s *Server) portfolio(r *http.Request, sess string) (portfolioResp, error) { var out portfolioResp - err := s.bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", sess, nil, &out) + err := s.bj.call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out) return out, err } @@ -304,23 +393,59 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat if c, err := r.Cookie("bj_lk_as"); err == nil && c.Value != "" { data["AsClient"] = c.Value } + data["CSRF"] = csrfToken(w, r) + if _, ok := data["Flash"]; !ok { + if f := takeFlash(w, r); f != "" { + data["Flash"] = f + } + } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tpl.ExecuteTemplate(w, name, data); err != nil { log.Printf("шаблон %s: %v", name, err) } } -func setFlag(w http.ResponseWriter, name, val string) { +// setFlag — служебная кука кабинета. HttpOnly обязателен: в bj_lk_admin_sess лежит +// живой токен сотрудника, и скрипт на странице не должен уметь его прочитать. +func setFlag(w http.ResponseWriter, r *http.Request, name, val string) { age := cookieTTL if val == "" { age = -1 } - http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age, SameSite: http.SameSiteLaxMode}) + http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age, + HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode}) } -func setSession(w http.ResponseWriter, val string) { +func setSession(w http.ResponseWriter, r *http.Request, val string) { http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: val, Path: "/", - HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL}) + HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode, MaxAge: cookieTTL}) +} + +// flash — сообщение посетителю до следующей страницы. Раньше текст ехал в адресе +// (?m=…), то есть попадал в журнал nginx и в историю браузера вместе с ФИО и +// текстами ошибок; теперь он живёт одну страницу в куке. +func setFlashCookie(w http.ResponseWriter, r *http.Request, msg string) { + http.SetCookie(w, &http.Cookie{Name: "bj_lk_flash", Value: url.QueryEscape(msg), Path: "/", MaxAge: 60, + HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode}) +} + +func takeFlash(w http.ResponseWriter, r *http.Request) string { + c, err := r.Cookie("bj_lk_flash") + if err != nil || c.Value == "" { + return "" + } + http.SetCookie(w, &http.Cookie{Name: "bj_lk_flash", Value: "", Path: "/", MaxAge: -1, + HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode}) + msg, _ := url.QueryUnescape(c.Value) + return msg +} + +// clearAll — погасить все куки кабинета (выход, сбой сессии). +func clearAll(w http.ResponseWriter, r *http.Request) { + for _, n := range []string{"bj_lk", "bj_lk_admin", "bj_lk_as", "bj_lk_admin_sess", "bj_lk_flash", csrfCookie} { + http.SetCookie(w, &http.Cookie{Name: n, Value: "", Path: "/", MaxAge: -1, + HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode}) + } } // sessionW — как session, но заодно продлевает куку, чтобы у активного @@ -330,7 +455,7 @@ func sessionW(w http.ResponseWriter, r *http.Request) string { if err != nil || c.Value == "" { return "" } - setSession(w, c.Value) + setSession(w, r, c.Value) return c.Value } @@ -345,7 +470,7 @@ func session(r *http.Request) string { // Handler — все страницы кабинета. Без адреса bj-server или ключа — страница с подсказкой настройки. func (s *Server) Handler() http.Handler { if !s.Configured() { - return http.HandlerFunc(s.notConfigured) + return harden(http.HandlerFunc(s.notConfigured)) } bj := s.bj mux := http.NewServeMux() @@ -353,36 +478,46 @@ func (s *Server) Handler() http.Handler { // Вход mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { - s.render(w, r, "login", map[string]any{"Flash": r.URL.Query().Get("e")}) + s.render(w, r, "login", nil) + return + } + if !csrfOK(r) { + s.render(w, r, "login", map[string]any{"Error": "Форма устарела, попробуйте войти ещё раз."}) return } - _ = r.ParseForm() var out struct { Session string `json:"session"` FullName string `json:"full_name"` Contract string `json:"contract"` IsAdmin bool `json:"is_admin"` } - err := bj.call(r.Context(), http.MethodPost, "/api/lk/login", "", map[string]string{ + err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/login", "", map[string]string{ "login": r.FormValue("login"), "password": r.FormValue("password"), }, &out) if err != nil { - s.render(w, r, "login", map[string]any{"Flash": err.Error()}) + // Текст ошибки приходит из ядра: «неверный логин или пароль» либо + // «слишком много попыток входа» — показываем его как есть. + s.render(w, r, "login", map[string]any{"Error": err.Error()}) return } - setSession(w, out.Session) + setSession(w, r, out.Session) if out.IsAdmin { - setFlag(w, "bj_lk_admin", "1") + setFlag(w, r, "bj_lk_admin", "1") http.Redirect(w, r, "/clients", http.StatusSeeOther) return } - setFlag(w, "bj_lk_admin", "") + setFlag(w, r, "bj_lk_admin", "") http.Redirect(w, r, "/", http.StatusSeeOther) }) + // Выход гасит и клиентскую сессию, и сохранённую админскую: иначе после + // «выхода» в браузере оставался действующий токен сотрудника. mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { - _ = bj.call(r.Context(), http.MethodPost, "/api/lk/logout", session(r), nil, nil) - http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: "", Path: "/", MaxAge: -1}) + _ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil) + if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" { + _ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", c.Value, nil, nil) + } + clearAll(w, r) http.Redirect(w, r, "/login", http.StatusSeeOther) }) @@ -406,7 +541,7 @@ func (s *Server) Handler() http.Handler { var cl struct { Claims []claimRow `json:"claims"` } - _ = bj.call(r.Context(), http.MethodGet, "/api/lk/claims", sess, nil, &cl) + _ = bj.call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &cl) active, _ := claimsView(cl.Claims) pos := pf.Positions if len(pos) > 5 { @@ -418,7 +553,7 @@ func (s *Server) Handler() http.Handler { } s.render(w, r, "board", map[string]any{ "Nav": "b", "Contract": pf.Contract, "FullName": pf.FullName, - "Positions": pos, "Claims": claims, "Flash": r.URL.Query().Get("m"), + "Positions": pos, "Claims": claims, "Total": total, "Free": free, "Blocked": blocked, "Active": active, "Value": pf.TotalValue, "Priced": pf.Priced, }) @@ -443,8 +578,8 @@ func (s *Server) Handler() http.Handler { } s.render(w, r, "portfolio", map[string]any{ "Nav": "p", "Contract": pf.Contract, "FullName": pf.FullName, - "Positions": pf.Positions, "Flash": r.URL.Query().Get("m"), - "Source": pf.Source, "ReadOnly": pf.ReadOnly, "UpdatedAt": updated, + "Positions": pf.Positions, + "Source": pf.Source, "ReadOnly": pf.ReadOnly, "UpdatedAt": updated, "Total": total, "Free": free, "Blocked": blocked, "Value": pf.TotalValue, "Priced": pf.Priced, }) @@ -465,7 +600,7 @@ func (s *Server) Handler() http.Handler { var out struct { Security securityCard `json:"security"` } - if err := bj.call(r.Context(), http.MethodGet, "/api/lk/security?isin="+url.QueryEscape(isin), sess, nil, &out); err != nil { + if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/security?isin="+url.QueryEscape(isin), sess, nil, &out); err != nil { s.render(w, r, "security", map[string]any{"Nav": "p", "Error": err.Error(), "ISIN": isin}) return } @@ -490,7 +625,7 @@ func (s *Server) Handler() http.Handler { Operations []operationRow `json:"operations"` } path := "/api/lk/operations?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to) - if err := bj.call(r.Context(), http.MethodGet, path, sess, nil, &out); err != nil { + if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil { s.render(w, r, "operations", map[string]any{"Nav": "o", "Error": err.Error(), "From": from, "To": to}) return } @@ -527,12 +662,62 @@ func (s *Server) Handler() http.Handler { Tax float64 `json:"tax"` } path := "/api/lk/income?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to) - if err := bj.call(r.Context(), http.MethodGet, path, sess, nil, &out); err != nil { + if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil { s.render(w, r, "income", map[string]any{"Nav": "i", "Error": err.Error(), "From": from, "To": to}) return } + var ca struct { + Actions []corpAction `json:"actions"` + } + _ = bj.call(reqCtx(r), http.MethodGet, "/api/lk/corpactions", sess, nil, &ca) + acts := ca.Actions + if len(acts) > 8 { + acts = acts[:8] + } s.render(w, r, "income", map[string]any{"Nav": "i", "Income": out.Income, "From": from, "To": to, - "Accrued": out.Accrued, "Paid": out.Paid, "Tax": out.Tax}) + "Accrued": out.Accrued, "Paid": out.Paid, "Tax": out.Tax, "Actions": acts}) + }) + + // Документы: готовые выписки, заявки клиента и заказ нового документа + mux.HandleFunc("/documents", func(w http.ResponseWriter, r *http.Request) { + sess := sessionW(w, r) + if sess == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + if r.Method == http.MethodPost { + _ = r.ParseForm() + var res struct { + Message string `json:"message"` + } + err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/documents", sess, map[string]string{ + "kind": r.FormValue("kind"), + "from": r.FormValue("from"), + "to": r.FormValue("to"), + "account": r.FormValue("account"), + "comment": strings.TrimSpace(r.FormValue("comment")), + }, &res) + if err != nil { + setFlashCookie(w, r, err.Error()) + http.Redirect(w, r, "/documents", http.StatusSeeOther) + return + } + setFlashCookie(w, r, res.Message) + http.Redirect(w, r, "/documents", http.StatusSeeOther) + return + } + var out struct { + Ready []documentRow `json:"ready"` + Requests []documentRow `json:"requests"` + Kinds []string `json:"kinds"` + } + if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/documents", sess, nil, &out); err != nil { + s.render(w, r, "documents", map[string]any{"Nav": "d", "Error": err.Error()}) + return + } + s.render(w, r, "documents", map[string]any{"Nav": "d", "Ready": out.Ready, "Requests": out.Requests, + "Kinds": out.Kinds, + "From": time.Now().AddDate(0, -1, 0).Format("2006-01-02"), "To": time.Now().Format("2006-01-02")}) }) // Профиль: договор, счета депо, доступ @@ -576,7 +761,7 @@ func (s *Server) Handler() http.Handler { Positions []position `json:"positions"` FullName string `json:"full_name"` } - if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil { + if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil { s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": err.Error(), "Today": time.Now().Format("02.01.2006")}) return } @@ -605,7 +790,7 @@ func (s *Server) Handler() http.Handler { Message string `json:"message"` GUID string `json:"guid"` } - err := bj.call(r.Context(), http.MethodPost, "/api/lk/transfer", sess, map[string]any{ + err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/transfer", sess, map[string]any{ "receiving_inn": strings.TrimSpace(r.FormValue("receiving_inn")), "receiving_name": strings.TrimSpace(r.FormValue("receiving_name")), "account_at_recv": strings.TrimSpace(r.FormValue("account_at_recv")), @@ -616,7 +801,8 @@ func (s *Server) Handler() http.Handler { s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": err.Error(), "Today": time.Now().Format("02.01.2006")}) return } - http.Redirect(w, r, "/orders?m="+url.QueryEscape(res.Message), http.StatusSeeOther) + setFlashCookie(w, r, res.Message) + http.Redirect(w, r, "/orders", http.StatusSeeOther) }) // Поручения — журнал всех видов @@ -632,12 +818,12 @@ func (s *Server) Handler() http.Handler { var out struct { Claims []claimRow `json:"claims"` } - if err := bj.call(r.Context(), http.MethodGet, "/api/lk/claims", sess, nil, &out); err != nil { + if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &out); err != nil { s.render(w, r, "claims", map[string]any{"Nav": "c", "Error": err.Error()}) return } active, done := claimsView(out.Claims) - s.render(w, r, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Flash": r.URL.Query().Get("m"), + s.render(w, r, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Total": len(out.Claims), "Active": active, "Done": done}) }) @@ -669,13 +855,12 @@ func (s *Server) Handler() http.Handler { if search != "" { path += "?search=" + url.QueryEscape(search) } - if err := bj.call(r.Context(), http.MethodGet, path, sess, nil, &out); err != nil { + if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil { s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Error": err.Error()}) return } s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Clients": out.Clients, - "Depo": out.Depo, "DepoError": out.DepoError, "Search": search, - "Flash": r.URL.Query().Get("m")}) + "Depo": out.Depo, "DepoError": out.DepoError, "Search": search}) }) // Администратор: войти в кабинет клиента @@ -685,53 +870,74 @@ func (s *Server) Handler() http.Handler { http.Redirect(w, r, "/login", http.StatusSeeOther) return } + // Только POST с формой кабинета: иначе достаточно было заманить сотрудника + // на страницу с картинкой /impersonate?contract=… , чтобы его сессия молча + // переключилась в кабинет чужого клиента. + if r.Method != http.MethodPost || !csrfOK(r) { + setFlashCookie(w, r, "Форма устарела, откройте список клиентов заново.") + http.Redirect(w, r, "/clients", http.StatusSeeOther) + return + } login := r.FormValue("login") - if login == "" { - login = r.URL.Query().Get("login") - } contract := r.FormValue("contract") - if contract == "" { - contract = r.URL.Query().Get("contract") - } var out struct { Session string `json:"session"` AsClient string `json:"as_client"` } - if err := bj.call(r.Context(), http.MethodPost, "/api/lk/admin/impersonate", sess, + if err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/admin/impersonate", sess, map[string]string{"login": login, "contract": contract}, &out); err != nil { - http.Redirect(w, r, "/clients?m="+url.QueryEscape(err.Error()), http.StatusSeeOther) + setFlashCookie(w, r, err.Error()) + http.Redirect(w, r, "/clients", http.StatusSeeOther) return } // сохраняем админскую сессию, чтобы можно было вернуться - setFlag(w, "bj_lk_admin_sess", sess) - setSession(w, out.Session) - setFlag(w, "bj_lk_as", out.AsClient) + setFlag(w, r, "bj_lk_admin_sess", sess) + setSession(w, r, out.Session) + setFlag(w, r, "bj_lk_as", out.AsClient) http.Redirect(w, r, "/", http.StatusSeeOther) }) // Администратор: вернуться из кабинета клиента mux.HandleFunc("/back-to-admin", func(w http.ResponseWriter, r *http.Request) { + // Сессию просмотра гасим в ядре, а не бросаем: иначе выданные сотруднику + // токены чужих кабинетов копились бы и жили ещё 12 часов. if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" { - setSession(w, c.Value) + _ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil) + setSession(w, r, c.Value) } - setFlag(w, "bj_lk_as", "") - setFlag(w, "bj_lk_admin_sess", "") + setFlag(w, r, "bj_lk_as", "") + setFlag(w, r, "bj_lk_admin_sess", "") http.Redirect(w, r, "/clients", http.StatusSeeOther) }) // Проверка связи с bj-server (для админа при установке) + // Проверка связи с ядром. Наружу отдаём только признак жизни: подробности + // (внутренний адрес ядра, состояние базы) — администратору по ключу модуля. mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + full := secureEqual(r.Header.Get("X-BJ-Key"), s.cfg.BJKey) var out map[string]any - err := bj.call(r.Context(), http.MethodGet, "/api/lk/ping", "", nil, &out) + err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/ping", "", nil, &out) w.Header().Set("Content-Type", "application/json; charset=utf-8") if err != nil { w.WriteHeader(http.StatusBadGateway) - _ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error(), "bj_url": s.cfg.BJURL}) + resp := map[string]any{"ok": false} + if full { + resp["error"], resp["bj_url"], resp["mode"] = err.Error(), s.cfg.BJURL, s.mode() + } + _ = json.NewEncoder(w).Encode(resp) return } - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "bj": out, "bj_url": s.cfg.BJURL}) + resp := map[string]any{"ok": true} + if full { + resp["bj"], resp["bj_url"], resp["mode"] = out, s.cfg.BJURL, s.mode() + } + _ = json.NewEncoder(w).Encode(resp) }) - return mux + if s.agent != nil { + s.registerAgent(mux) + } + mux.Handle("/assets/", assetsHandler()) + return harden(mux) } // notConfigured — кабинет запущен без адреса bj-server или ключа: подсказка вместо падения службы. @@ -749,3 +955,11 @@ func (s *Server) notConfigured(w http.ResponseWriter, r *http.Request) { `

%s

%s

`, template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(msg)) } + +// mode — режим связи с ядром, для страницы состояния и инструкций администратору. +func (s *Server) mode() string { + if s.cfg.Agent { + return "agent" + } + return "direct" +} diff --git a/modules/lk/lk_test.go b/modules/lk/lk_test.go index 6e3d9fe..40a33ac 100644 --- a/modules/lk/lk_test.go +++ b/modules/lk/lk_test.go @@ -1,12 +1,15 @@ package lk import ( + "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" + "time" ) // fakeBJ — API bj-server для кабинета: проверка ключа, вход и портфель. @@ -91,9 +94,12 @@ func TestLoginPortfolioHealth(t *testing.T) { } h := s.Handler() - form := url.Values{"login": {"client"}, "password": {"secret"}} + // Форма входа подписана токеном CSRF: без него запрос не выполняется. + csrf := csrfFromPage(t, h) + form := url.Values{"login": {"client"}, "password": {"secret"}, "csrf": {csrf.Value}} req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(csrf) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { @@ -242,3 +248,136 @@ func TestBoardPages(t *testing.T) { } } } + +// csrfFromPage — открыть страницу входа и взять с неё токен формы. +func csrfFromPage(t *testing.T, h http.Handler) *http.Cookie { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + for _, c := range rec.Result().Cookies() { + if c.Name == csrfCookie { + return c + } + } + t.Fatal("страница входа не выдала токен формы") + return nil +} + +// TestSecurityHeaders — кабинет отдаёт заголовки защиты и не разрешает кэшировать страницы с данными. +func TestSecurityHeaders(t *testing.T) { + bj := fakeBJ(t) + s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key"}) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + for h, want := range map[string]string{ + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "same-origin", + } { + if got := rec.Header().Get(h); got != want { + t.Errorf("%s = %q, ожидали %q", h, got, want) + } + } + if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") { + t.Errorf("CSP без запрета встраивания: %q", csp) + } + if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { + t.Errorf("Cache-Control = %q", cc) + } + if body := rec.Body.String(); strings.Contains(body, "fonts.googleapis.com") || strings.Contains(body, "fonts.gstatic.com") { + t.Error("страница тянет шрифты со стороннего сайта") + } +} + +// TestImpersonateNeedsPost — вход сотрудника в кабинет клиента не выполняется по ссылке. +func TestImpersonateNeedsPost(t *testing.T) { + bj := fakeBJ(t) + s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key"}) + if err != nil { + t.Fatal(err) + } + h := s.Handler() + req := httptest.NewRequest(http.MethodGet, "/impersonate?contract=BR-1", nil) + req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if loc := rec.Header().Get("Location"); loc != "/clients" { + t.Fatalf("GET /impersonate: код %d, Location %q — должен отправлять к списку клиентов без переключения", rec.Code, loc) + } + for _, c := range rec.Result().Cookies() { + if c.Name == "bj_lk" && c.Value != "" && c.Value != "sess-1" { + t.Fatal("GET /impersonate переключил сессию") + } + } +} + +// TestAgentMode — режим ожидания: запрос страницы попадает в очередь, ядро забирает +// его своим подключением и возвращает ответ. +func TestAgentMode(t *testing.T) { + s, err := New(Config{BJKey: "lk-key", Agent: true}) + if err != nil { + t.Fatal(err) + } + if !s.Configured() { + t.Fatal("в режиме ожидания адрес ядра не нужен") + } + h := s.Handler() + + // «ядро»: подключается к кабинету, забирает запрос и отвечает + done := make(chan string, 1) + go func() { + for i := 0; i < 50; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/agent/tasks", nil) + req.Header.Set("X-BJ-Key", "lk-key") + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + time.Sleep(10 * time.Millisecond) + continue + } + var task struct{ ID, Method, Path string } + if json.Unmarshal(rec.Body.Bytes(), &task) != nil { + continue + } + done <- task.Path + body := base64.StdEncoding.EncodeToString([]byte(`{"session":"sess-1","full_name":"Клиент","contract":"BR-1"}`)) + payload := fmt.Sprintf(`{"id":%q,"status":200,"body":%q}`, task.ID, body) + rr := httptest.NewRecorder() + rq := httptest.NewRequest(http.MethodPost, "/agent/result", strings.NewReader(payload)) + rq.Header.Set("X-BJ-Key", "lk-key") + h.ServeHTTP(rr, rq) + return + } + }() + + csrf := csrfFromPage(t, h) + form := url.Values{"login": {"client"}, "password": {"secret"}, "csrf": {csrf.Value}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(csrf) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("вход через очередь: код %d, тело %s", rec.Code, rec.Body.String()) + } + select { + case path := <-done: + if path != "/api/lk/login" { + t.Fatalf("ядро получило %q", path) + } + default: + t.Fatal("ядро не получило запрос") + } + + // чужой ключ к каналу не подпускаем + rec = httptest.NewRecorder() + bad := httptest.NewRequest(http.MethodGet, "/agent/tasks", nil) + bad.Header.Set("X-BJ-Key", "не тот ключ") + h.ServeHTTP(rec, bad) + if rec.Code != http.StatusForbidden { + t.Fatalf("чужой ключ принят: код %d", rec.Code) + } +} diff --git a/modules/lk/security.go b/modules/lk/security.go new file mode 100644 index 0000000..10f59a2 --- /dev/null +++ b/modules/lk/security.go @@ -0,0 +1,140 @@ +package lk + +// security.go — защита кабинета: заголовки, куки, CSRF, ограничение тела запроса и +// подстраховка от паники. Кабинет стоит в интернете и показывает персональные данные, +// поэтому всё это включено всегда, отдельной настройки «выключить» нет. + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "log" + "net" + "net/http" + "strings" +) + +// secureEqual — сравнение секретов за постоянное время. +func secureEqual(a, b string) bool { + if a == "" || b == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +// httpsRequest — запрос пришёл по TLS (напрямую или через nginx). +func httpsRequest(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]), "https") +} + +// localRequest — обращение на сам сервер (проверка при установке, автотесты). +// Только для таких запросов куки выставляются без признака Secure, иначе браузер +// их не вернёт по http и войти в кабинет на стенде будет нельзя. +func localRequest(r *http.Request) bool { + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.ToLower(strings.TrimSpace(host)) + if host == "localhost" || strings.HasSuffix(host, ".local") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// secureCookies — ставить ли признак Secure на куки этого запроса. +func secureCookies(r *http.Request) bool { return httpsRequest(r) || !localRequest(r) } + +// clientIP — адрес посетителя кабинета (за nginx — из X-Real-IP / X-Forwarded-For). +func clientIP(r *http.Request) string { + if v := strings.TrimSpace(r.Header.Get("X-Real-IP")); v != "" { + return v + } + if v := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); v != "" { + return strings.TrimSpace(strings.Split(v, ",")[0]) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// ---- CSRF ---- + +const csrfCookie = "bj_lk_csrf" + +// csrfToken — токен этого посетителя: лежит в куке, недоступной скриптам, и +// подставляется в каждую форму. Запрос без совпадающего поля мы не выполняем, +// поэтому чужая страница не сможет отправить форму от имени клиента. +func csrfToken(w http.ResponseWriter, r *http.Request) string { + if c, err := r.Cookie(csrfCookie); err == nil && len(c.Value) >= 32 { + return c.Value + } + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "" // без источника случайности форму не подписываем: пусть лучше не отправится + } + tok := hex.EncodeToString(b) + http.SetCookie(w, &http.Cookie{Name: csrfCookie, Value: tok, Path: "/", MaxAge: cookieTTL, + HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode}) + return tok +} + +// csrfOK — проверка формы: метод POST и поле совпадает с кукой. +func csrfOK(r *http.Request) bool { + c, err := r.Cookie(csrfCookie) + if err != nil || c.Value == "" { + return false + } + _ = r.ParseForm() + return secureEqual(r.FormValue("csrf"), c.Value) +} + +// ---- обёртка над всеми страницами ---- + +// maxBody — сколько принимаем от посетителя. Формы кабинета маленькие; исключение — +// канал ядра (/agent/), где ответ ядра может быть большим. +const maxBody = 1 << 20 + +func securityHeaders(w http.ResponseWriter, r *http.Request) { + h := w.Header() + // Своих скриптов у кабинета нет, шрифты вшиты в программу — поэтому + // правила строгие: ничего стороннего страница загрузить не может. + h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'") + h.Set("X-Frame-Options", "DENY") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "same-origin") + h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=()") + if httpsRequest(r) { + h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } +} + +// harden — заголовки защиты, запрет кэширования страниц с данными клиента, +// ограничение тела запроса и подстраховка от паники в обработчике. +func harden(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Printf("кабинет: сбой при обработке %s %s: %v", r.Method, r.URL.Path, rec) + http.Error(w, "Внутренняя ошибка кабинета", http.StatusInternalServerError) + } + }() + securityHeaders(w, r) + if !strings.HasPrefix(r.URL.Path, "/agent/") { + r.Body = http.MaxBytesReader(w, r.Body, maxBody) + if !strings.HasPrefix(r.URL.Path, "/assets/") { + // Портфель, доходы, паспортные данные не должны оседать в кэше + // браузера на общем компьютере и в промежуточных прокси. + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private") + w.Header().Set("Pragma", "no-cache") + } + } + next.ServeHTTP(w, r) + }) +} diff --git a/modules/lk/templates.go b/modules/lk/templates.go index e9bb38e..1a7e3a1 100644 --- a/modules/lk/templates.go +++ b/modules/lk/templates.go @@ -11,8 +11,7 @@ const pageTemplates = ` {{define "head"}} {{.Title}} - - +