Files
Bridge-and-Join-s/internal/lkgateway/lkagent.go
T
zuevav 2a7442c935 Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные
- корпоративные действия по бумагам клиента (U_CA_ACTS), готовые выписки (U_OP_INF_EXTRACT),
  заявки клиента на документ (lk.doc_requests) с оповещением сотрудникам; страница «Документы»
- дистрибутив кабинета из настроек ядра: архив с программой, службой, настройками,
  установщиком и инструкцией; ключ доступа на странице показан замаскированным

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 23:47:10 +03:00

255 lines
9.5 KiB
Go

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
}
}