054222d154
- modules/lk: конверт запроса/ответа (PageRequest/PageResponse), ServeEnvelope — тот же обработчик страниц внутри ядра; клиент к API внутри процесса (Config.Local/LocalKey); режим ретранслятора (Config.Relay, BJ_RENDER=core, выбор в установщике и lk.json) - ядро: встроенный кабинет (lkcabinet.go), POST /api/lk/render для прямого режима, цикл режима ожидания выполняет конверты страниц; API через ретранслятор недостижимо - PHP-ретранслятор для обычного хостинга (modules/lk/relay-php: index.php, .htaccess, README): установщик в браузере, прямой режим через curl, режим ожидания через очередь в SQLite (BEGIN IMMEDIATE, busy_timeout), конфиг в bj-lk-data рядом с сайтом или в data под .htaccess; архив со страницы настроек (/admin/setup/lk/relay-php.zip) - настройки: карточка НПР/УДС перенесена из раздела «Лицензия» в «Экспорт QUIK»; кнопка «Обновить с сервера лицензий» видна при любом ключе - инструкция /admin/help/lk: два варианта размещения, шаги для PHP-хостинга - тесты: TestLKRelayRendersInCore (оба режима), TestLKPHPRelayAgentMode (настоящий PHP против цикла ядра, PHP_BIN), проверка безопасности через PHP-ретранслятор 34/34 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
361 lines
18 KiB
Go
361 lines
18 KiB
Go
package lkgateway
|
|
|
|
// lkdist.go — дистрибутив кабинета клиента для отдельного веб-сервера.
|
|
//
|
|
// Кабинет (bj-lk) ставится не рядом с ядром, а на веб-сервер депозитария: своей базы
|
|
// у него нет, он обращается к ядру по адресу и ключу. Здесь bj собирает архив с
|
|
// исполняемым файлом, юнитом systemd, файлом настроек, установщиком и инструкцией —
|
|
// администратору остаётся распаковать архив и запустить установку.
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"compress/gzip"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/lk"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// lkKeyForPage — ключ для страницы настроек. Показывается целиком: настройки ядра —
|
|
// закрытый контур, а при переустановке кабинета ключ нужно переписать в установщик;
|
|
// маскированный пришлось бы пересоздавать и менять на всех кабинетах.
|
|
func lkKeyForPage(key string) string { return key }
|
|
|
|
// 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 не нужен: ядро подключается к кабинету само"
|
|
}
|
|
// Ключ в архив НЕ кладём: его вводят в веб-установщике кабинета. Так архив можно
|
|
// хранить и пересылать, а секрет живёт только в двух местах — в ядре и в файле
|
|
// настроек кабинета с правами 0600.
|
|
env := fmt.Sprintf(`# Настройки кабинета клиента (bj-lk) для установки службой. Файл читает служба, права 0600.
|
|
# Проще — ничего здесь не менять: после запуска откройте адрес кабинета в браузере,
|
|
# запустится установщик и сам спросит способ связи и ключ.
|
|
# Режим связи с ядром: direct — кабинет обращается к ядру; agent — ядро подключается сюда.
|
|
BJ_MODE=%s
|
|
# Адрес ядра bj, к которому обращается кабинет (только для режима direct):
|
|
%s
|
|
# Ключ доступа модуля — из настроек ядра, раздел «Личный кабинет». Оставьте пустым,
|
|
# чтобы ввести его в установщике; заполните — если ставите без браузера.
|
|
BJ_KEY=
|
|
# Адрес и порт, на которых кабинет слушает (за ним обычно стоит nginx с TLS):
|
|
LISTEN=127.0.0.1:8099
|
|
# Заголовок кабинета:
|
|
LK_TITLE=%s
|
|
# Каталог данных (файл настроек установщика):
|
|
LK_DATA=/var/lib/bj-lk
|
|
`, mode, urlLine, 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 /var/lib/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 700 -o bjlk -g bjlk /var/lib/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 "Дальше откройте адрес кабинета в браузере — запустится установщик."
|
|
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 — заводит службу; либо просто запустите ./bj-lk в своём
|
|
каталоге, если хостинг позволяет держать программу запущенной.
|
|
3. Откройте адрес кабинета в браузере. Запустится установщик: проверит сервер,
|
|
спросит способ связи с ядром и ключ доступа, проверит связь и предложит
|
|
завести администратора кабинета. Настройки он сохранит в файл lk.json
|
|
(каталог LK_DATA, права 0600). Ключа в этом архиве нет — берите его из
|
|
настроек ядра, раздел «Личный кабинет».
|
|
4. Проверка службы: curl -s http://127.0.0.1:8099/health — "ok":true.
|
|
|
|
Установка без браузера: заполните BJ_KEY (и BJ_URL для режима direct) в lk.env и
|
|
перезапустите службу — окружение перекрывает файл установщика.
|
|
|
|
Настройки (файл /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 входящий доступ к ядру не нужен вовсе;
|
|
• ключ доступа хранится только в файле настроек кабинета (lk.json или lk.env,
|
|
права 0600); в этом архиве его нет;
|
|
• ключ можно сменить в настройках ядра, после чего удалить lk.json и пройти
|
|
установщик заново (или вписать новый ключ в 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 = "Создан новый ключ доступа кабинета. Введите его в установщике кабинета (или в lk.env); если кабинет уже работал — удалите там lk.json и пройдите установщик заново либо впишите ключ в 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"
|
|
}
|
|
|
|
// lkPHPRelayDownload — GET /admin/setup/lk/relay-php.zip: ретранслятор для обычного
|
|
// PHP-хостинга. Ключа в архиве нет — его спрашивает установщик в браузере.
|
|
func (h *setupHandlers) lkPHPRelayDownload(w http.ResponseWriter, r *http.Request) {
|
|
files := lk.PHPRelayFiles()
|
|
if len(files) == 0 {
|
|
setupFlash(w, r, "Ретранслятор PHP не собран в эту версию ядра")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="bj-lk-php-`+time.Now().Format("2006-01-02")+`.zip"`)
|
|
zw := zip.NewWriter(w)
|
|
defer zw.Close()
|
|
for _, name := range []string{"index.php", ".htaccess", "README.txt"} {
|
|
fw, err := zw.CreateHeader(&zip.FileHeader{Name: "bj-lk-php/" + name, Method: zip.Deflate, Modified: time.Now()})
|
|
if err != nil {
|
|
return
|
|
}
|
|
if _, err := fw.Write(files[name]); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|