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>
312 lines
14 KiB
Go
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"
|
|
}
|