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