Files
Bridge-and-Join-s/internal/lkgateway/lkdist.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

312 lines
14 KiB
Go

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