Кабинет: веб-установщик и управление пользователями в ядре
- установщик в самом кабинете: до настройки любая страница ведёт в /install; три шага — проверка сервера (каталог данных, https), связь с ядром (режим, адрес, ключ; связь проверяется до сохранения, в режиме ожидания страница ждёт подключения ядра), администратор кабинета (заводится в ядре через /api/lk/admin/bootstrap, только пока администраторов нет); настройки в data/lk.json 0600, окружение перекрывает файл; после установки установщик закрыт - настройки кабинета можно менять на ходу: Server за мьютексом, один mux на всё время - ключ модуля больше не кладётся в архив: его вводят в установщике; на странице настроек ключ показан целиком 10 минут после создания, потом маска - ядро: карточка «Пользователи кабинета» в настройках — список, заведение, смена пароля (гасит сессии), блокировка, права сотрудника, разрешение поручений, удаление; письма не отправляются - инструкция /admin/help/lk дополнена шагами установщика и разделом о подходящем хостинге - тесты: TestInstaller, TestNotConfigured, TestSetupPageLKUsersAndLink; установщик проверен вживую вторым экземпляром против боевого ядра Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package lkgateway
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
sess := newLKSessions()
|
||||
sess.bindNetwork = func() bool { return rc.Snapshot().LK.BindNetwork }
|
||||
globalLKSessions = sess
|
||||
|
||||
// проверка ключа модуля
|
||||
auth := func(w http.ResponseWriter, r *http.Request) bool {
|
||||
@@ -117,6 +119,40 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
})
|
||||
})
|
||||
|
||||
// Первый администратор кабинета из веб-установщика. Только по ключу модуля и только
|
||||
// пока администраторов нет: потом учётные записи заводит администратор в настройках.
|
||||
mux.HandleFunc("/api/lk/admin/bootstrap", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
lkJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "нужен POST"})
|
||||
return
|
||||
}
|
||||
has, err := lkHasAdmin(r.Context(), rc)
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusBadGateway, map[string]string{"error": "база кабинета недоступна: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if has {
|
||||
lkJSON(w, http.StatusConflict, map[string]string{"error": "администратор кабинета уже создан — войдите под ним или пропустите шаг"})
|
||||
return
|
||||
}
|
||||
var in struct{ Login, Password, FullName, Email string }
|
||||
if json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&in) != nil {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": "некорректный JSON"})
|
||||
return
|
||||
}
|
||||
u := LKUser{Login: strings.TrimSpace(in.Login), Contract: "ADMIN", FullName: strings.TrimSpace(in.FullName),
|
||||
Email: strings.TrimSpace(in.Email), Active: true, IsAdmin: true}
|
||||
if err := lkCreateUser(r.Context(), rc, u, in.Password); err != nil {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
raiseSystemAlert(rc, "Кабинет: создан администратор", fmt.Sprintf("Из установщика кабинета создан администратор «%s».", u.Login))
|
||||
lkJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "администратор создан"})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/lk/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
|
||||
@@ -49,6 +49,18 @@ func lkNewAPIKey() (string, error) {
|
||||
return "lk-" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// lkKeyRevealUntil — до какого момента ключ на странице настроек показывается целиком:
|
||||
// сразу после создания администратору нужно переписать его в установщик кабинета.
|
||||
var lkKeyRevealUntil time.Time
|
||||
|
||||
// lkKeyForPage — ключ для страницы настроек: целиком сразу после создания, потом маска.
|
||||
func lkKeyForPage(key string) string {
|
||||
if key != "" && time.Now().Before(lkKeyRevealUntil) {
|
||||
return key
|
||||
}
|
||||
return maskSecret(key)
|
||||
}
|
||||
|
||||
// maskSecret — показать ключ так, чтобы его нельзя было списать со скриншота,
|
||||
// но можно было сверить с тем, что стоит у кабинета.
|
||||
func maskSecret(v string) string {
|
||||
@@ -84,18 +96,26 @@ func lkDistFiles(s Settings, coreURL string) ([]struct {
|
||||
// ядро подключается к кабинету само по имени этого сервера.
|
||||
mode, urlLine = "agent", "# BJ_URL не нужен: ядро подключается к кабинету само"
|
||||
}
|
||||
env := fmt.Sprintf(`# Настройки кабинета клиента (bj-lk). Файл читает служба, права 0600.
|
||||
// Ключ в архив НЕ кладём: его вводят в веб-установщике кабинета. Так архив можно
|
||||
// хранить и пересылать, а секрет живёт только в двух местах — в ядре и в файле
|
||||
// настроек кабинета с правами 0600.
|
||||
env := fmt.Sprintf(`# Настройки кабинета клиента (bj-lk) для установки службой. Файл читает служба, права 0600.
|
||||
# Проще — ничего здесь не менять: после запуска откройте адрес кабинета в браузере,
|
||||
# запустится установщик и сам спросит способ связи и ключ.
|
||||
# Режим связи с ядром: direct — кабинет обращается к ядру; agent — ядро подключается сюда.
|
||||
BJ_MODE=%s
|
||||
# Адрес ядра bj, к которому обращается кабинет (только для режима direct):
|
||||
%s
|
||||
# Ключ доступа модуля — тот же, что в настройках ядра, раздел «Личный кабинет».
|
||||
BJ_KEY=%s
|
||||
# Ключ доступа модуля — из настроек ядра, раздел «Личный кабинет». Оставьте пустым,
|
||||
# чтобы ввести его в установщике; заполните — если ставите без браузера.
|
||||
BJ_KEY=
|
||||
# Адрес и порт, на которых кабинет слушает (за ним обычно стоит nginx с TLS):
|
||||
LISTEN=127.0.0.1:8099
|
||||
# Заголовок кабинета:
|
||||
LK_TITLE=%s
|
||||
`, mode, urlLine, s.LK.APIKey, title)
|
||||
# Каталог данных (файл настроек установщика):
|
||||
LK_DATA=/var/lib/bj-lk
|
||||
`, mode, urlLine, title)
|
||||
|
||||
unit := `[Unit]
|
||||
Description=Личный кабинет клиента депозитария (bj-lk)
|
||||
@@ -116,7 +136,7 @@ NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/log/bj-lk
|
||||
ReadWritePaths=/var/log/bj-lk /var/lib/bj-lk
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -129,6 +149,7 @@ 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
|
||||
@@ -142,7 +163,8 @@ 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"
|
||||
echo "Дальше откройте адрес кабинета в браузере — запустится установщик."
|
||||
echo "Проверка службы: curl -s http://127.0.0.1:8099/health"
|
||||
`
|
||||
|
||||
readme := fmt.Sprintf(`Личный кабинет клиента депозитария (bj-lk)
|
||||
@@ -160,10 +182,19 @@ echo "Проверка: curl -s http://127.0.0.1:8099/health"
|
||||
install.sh — установка одной командой
|
||||
README.txt — этот файл
|
||||
|
||||
Установка:
|
||||
Установка — как у обычного веб-приложения:
|
||||
1. Распакуйте архив на сервере кабинета.
|
||||
2. sudo ./install.sh
|
||||
3. Проверьте: curl -s http://127.0.0.1:8099/health — должно вернуться "ok":true.
|
||||
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
|
||||
@@ -200,14 +231,13 @@ echo "Проверка: curl -s http://127.0.0.1:8099/health"
|
||||
• наружу открыт только 443 сервера кабинета;
|
||||
• в режиме direct кабинет обращается к ядру на %s — разрешите этот доступ
|
||||
в межсетевом экране; в режиме agent входящий доступ к ядру не нужен вовсе;
|
||||
• ключ доступа хранится только в /etc/bj-lk/lk.env (права 0600, владелец root);
|
||||
• ключ можно сменить в настройках ядра, после чего заменить его в lk.env и
|
||||
перезапустить службу;
|
||||
• ключ доступа хранится только в файле настроек кабинета (lk.json или lk.env,
|
||||
права 0600); в этом архиве его нет;
|
||||
• ключ можно сменить в настройках ядра, после чего удалить lk.json и пройти
|
||||
установщик заново (или вписать новый ключ в lk.env) и перезапустить службу;
|
||||
• кабинет не хранит данные клиентов: ни базы, ни файлов — всё берётся у ядра
|
||||
на время запроса. Шрифты и оформление вшиты в программу, сторонние сайты
|
||||
страница не запрашивает;
|
||||
• ВАЖНО: в этом архиве лежит боевой ключ доступа. После установки удалите
|
||||
архив с рабочего компьютера и не пересылайте его почтой или в мессенджере.
|
||||
|
||||
Обновления. Кабинет обновляется отдельно от ядра: новую версию скачивайте на
|
||||
странице настроек ядра, раздел «Личный кабинет», и ставьте тем же install.sh —
|
||||
@@ -290,7 +320,8 @@ func (h *setupHandlers) lkSaveDist(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.APIKey = key
|
||||
msg = "Создан новый ключ доступа кабинета. Скачайте дистрибутив заново или впишите ключ в /etc/bj-lk/lk.env и перезапустите службу кабинета."
|
||||
lkKeyRevealUntil = time.Now().Add(10 * time.Minute) // показать целиком один раз, чтобы переписать в установщик
|
||||
msg = "Создан новый ключ доступа кабинета. Он показан целиком ниже в течение 10 минут — введите его в установщике кабинета (или в lk.env) и перезапустите службу кабинета, если она уже работала."
|
||||
case "clearkey":
|
||||
s.APIKey = ""
|
||||
msg = "Ключ удалён: кабинет больше не сможет обращаться к ядру."
|
||||
|
||||
@@ -35,9 +35,12 @@ func TestLKDistFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
env := got["bj-lk/lk.env"]
|
||||
if !strings.Contains(env, "BJ_URL=https://bj.example.ru") || !strings.Contains(env, "BJ_KEY=lk-testkey") {
|
||||
if !strings.Contains(env, "BJ_URL=https://bj.example.ru") || !strings.Contains(env, "BJ_KEY=\n") {
|
||||
t.Errorf("настройки кабинета: %s", env)
|
||||
}
|
||||
if strings.Contains(env, "lk-testkey") || strings.Contains(got["bj-lk/README.txt"], "lk-testkey") {
|
||||
t.Error("ключ доступа попал в архив — он должен вводиться в установщике")
|
||||
}
|
||||
if !strings.Contains(env, "Депозитарий «Пример» · личный кабинет") {
|
||||
t.Errorf("заголовок кабинета не подставлен: %s", env)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package lkgateway
|
||||
|
||||
// lkusers_admin.go — учётные записи кабинета клиента: управление из настроек ядра и
|
||||
// первый администратор из веб-установщика кабинета.
|
||||
//
|
||||
// Пользователи кабинета живут только здесь, в lk.users: кабинет на хостинге своей
|
||||
// базы не имеет. Здесь заводят клиентов и сотрудников, меняют пароли, блокируют.
|
||||
// Письма никому не отправляются: почта хранится, но рассылка выключена — данные
|
||||
// пока тестовые, и случайное письмо реальному человеку недопустимо.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// globalLKSessions — сессии кабинета: нужны, чтобы гасить их при смене пароля и блокировке.
|
||||
var globalLKSessions *lkSessions
|
||||
|
||||
// lkHasAdmin — есть ли хотя бы один администратор кабинета.
|
||||
func lkHasAdmin(ctx context.Context, rc *RuntimeConfig) (bool, error) {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
var n int
|
||||
if err := db.QueryRowContext(cctx, `SELECT count(*) FROM lk.users WHERE is_admin AND active`).Scan(&n); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// lkUserSetFlags — включить/выключить учётную запись, права сотрудника, подачу поручений.
|
||||
func lkUserSetFlags(ctx context.Context, rc *RuntimeConfig, id int64, column string, value bool) error {
|
||||
switch column {
|
||||
case "active", "is_admin", "can_transfer":
|
||||
default:
|
||||
return errors.New("неизвестный признак")
|
||||
}
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
res, err := db.ExecContext(cctx, `UPDATE lk.users SET `+column+`=$1 WHERE id=$2`, value, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return errors.New("учётная запись не найдена")
|
||||
}
|
||||
if globalLKSessions != nil {
|
||||
globalLKSessions.forget(id)
|
||||
if !value && column == "active" {
|
||||
globalLKSessions.dropUser(id) // заблокированного выбрасываем сразу
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lkUserUpdate — контакты и договор.
|
||||
func lkUserUpdate(ctx context.Context, rc *RuntimeConfig, id int64, contract, fullName, email, phone string) error {
|
||||
if strings.TrimSpace(contract) == "" {
|
||||
return errors.New("номер договора обязателен")
|
||||
}
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
_, err = db.ExecContext(cctx, `UPDATE lk.users SET contract_number=$1, full_name=$2, email=$3, phone=$4 WHERE id=$5`,
|
||||
strings.TrimSpace(contract), strings.TrimSpace(fullName), strings.TrimSpace(email), strings.TrimSpace(phone), id)
|
||||
if err == nil && globalLKSessions != nil {
|
||||
globalLKSessions.forget(id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// lkUserDelete — удалить учётную запись. Поручения и журнал остаются: они привязаны к договору.
|
||||
func lkUserDelete(ctx context.Context, rc *RuntimeConfig, id int64) error {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := db.ExecContext(cctx, `DELETE FROM lk.users WHERE id=$1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if globalLKSessions != nil {
|
||||
globalLKSessions.dropUser(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lkUserByLogin — учётная запись по логину (для смены пароля из настроек).
|
||||
func lkUserIDByLogin(ctx context.Context, rc *RuntimeConfig, login string) (int64, error) {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
var id int64
|
||||
if err := db.QueryRowContext(cctx, `SELECT id FROM lk.users WHERE login=$1`, login).Scan(&id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, errors.New("учётная запись не найдена")
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// lkUsersSave — POST /admin/setup/lk/users: заведение, пароль, признаки, удаление.
|
||||
func (h *setupHandlers) lkUsersSave(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
var err error
|
||||
msg := "Пользователи кабинета: сохранено"
|
||||
switch op := r.FormValue("op"); op {
|
||||
case "create":
|
||||
u := LKUser{
|
||||
Login: strings.TrimSpace(r.FormValue("login")), Contract: strings.TrimSpace(r.FormValue("contract")),
|
||||
FullName: strings.TrimSpace(r.FormValue("full_name")), Email: strings.TrimSpace(r.FormValue("email")),
|
||||
Phone: strings.TrimSpace(r.FormValue("phone")), Active: true,
|
||||
IsAdmin: r.FormValue("is_admin") == "1", CanTransfer: r.FormValue("can_transfer") == "1",
|
||||
}
|
||||
if u.IsAdmin && u.Contract == "" {
|
||||
u.Contract = "ADMIN" // у сотрудника своего договора нет
|
||||
}
|
||||
err = lkCreateUser(ctx, h.rc, u, r.FormValue("password"))
|
||||
msg = fmt.Sprintf("Учётная запись «%s» создана. Пароль сообщите пользователю лично: письма кабинет не отправляет.", u.Login)
|
||||
case "password":
|
||||
login := strings.TrimSpace(r.FormValue("login"))
|
||||
if err = lkSetPassword(ctx, h.rc, login, r.FormValue("password")); err == nil {
|
||||
if uid, e := lkUserIDByLogin(ctx, h.rc, login); e == nil && globalLKSessions != nil {
|
||||
globalLKSessions.dropUser(uid) // старые сессии после смены пароля не живут
|
||||
}
|
||||
msg = fmt.Sprintf("Пароль «%s» изменён, его сессии завершены.", login)
|
||||
}
|
||||
case "update":
|
||||
err = lkUserUpdate(ctx, h.rc, id, r.FormValue("contract"), r.FormValue("full_name"), r.FormValue("email"), r.FormValue("phone"))
|
||||
case "block", "unblock":
|
||||
err = lkUserSetFlags(ctx, h.rc, id, "active", op == "unblock")
|
||||
msg = map[bool]string{true: "Учётная запись разблокирована", false: "Учётная запись заблокирована, сессии завершены"}[op == "unblock"]
|
||||
case "admin_on", "admin_off":
|
||||
err = lkUserSetFlags(ctx, h.rc, id, "is_admin", op == "admin_on")
|
||||
case "transfer_on", "transfer_off":
|
||||
err = lkUserSetFlags(ctx, h.rc, id, "can_transfer", op == "transfer_on")
|
||||
case "delete":
|
||||
err = lkUserDelete(ctx, h.rc, id)
|
||||
msg = "Учётная запись удалена"
|
||||
default:
|
||||
err = errors.New("неизвестное действие")
|
||||
}
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Пользователи кабинета: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, msg)
|
||||
}
|
||||
|
||||
// lkUsersForSetup — список для страницы настроек; ошибка базы — строкой, чтобы страница открылась.
|
||||
func lkUsersForSetup(rc *RuntimeConfig) ([]LKUser, string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
list, err := lkListUsers(ctx, rc)
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
return list, ""
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Страница настроек: пользователи кабинета, режим связи, замаскированный ключ.
|
||||
func TestSetupPageLKUsersAndLink(t *testing.T) {
|
||||
a, err := newAdmin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var s Settings
|
||||
s.LK.APIKey = "lk-0123456789abcdef0123456789abcdef"
|
||||
s.LK.AgentMode, s.LK.CabinetURL = true, "https://lk.example.ru"
|
||||
rec := httptest.NewRecorder()
|
||||
render(rec, a.setup, SetupData{
|
||||
page: nowPage("Настройка", "setup"), Settings: s,
|
||||
LKKeyMask: maskSecret(s.LK.APIKey), LKAgent: lkAgentStatus(s),
|
||||
LKUsers: []LKUser{
|
||||
{ID: 1, Login: "manager", Contract: "ADMIN", FullName: "Сотрудник", Active: true, IsAdmin: true},
|
||||
{ID: 2, Login: "br859", Contract: "БР-859", Active: false},
|
||||
},
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("код %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"Пользователи кабинета", "manager", "БР-859", "заблокирована", "Снять права сотрудника",
|
||||
"Разблокировать", "Письма не отправляются", "Ядро подключается к кабинету", "https://lk.example.ru", "ядро не дозвонилось"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("в странице нет %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, s.LK.APIKey) {
|
||||
t.Error("ключ кабинета показан целиком без создания")
|
||||
}
|
||||
if !strings.Contains(body, "lk-0123") {
|
||||
t.Error("маска ключа не показана")
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
||||
mux.HandleFunc("/admin/setup/lk", h.saveLK)
|
||||
mux.HandleFunc("/admin/setup/lk/dist", h.lkSaveDist)
|
||||
mux.HandleFunc("/admin/setup/lk/dist.tar.gz", h.lkDistDownload)
|
||||
mux.HandleFunc("/admin/setup/lk/users", h.lkUsersSave)
|
||||
mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration)
|
||||
mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn)
|
||||
mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate)
|
||||
@@ -311,10 +312,12 @@ type SetupData struct {
|
||||
NotifyGroups []struct{ Key, Title string }
|
||||
NotifyKinds []struct{ Key, Title string }
|
||||
DepoJob DepoJobView
|
||||
DepoTables DepoTablesView // трансферные таблицы FTV_M2M* в базе DEPO
|
||||
QuikExport QuikExportView // модуль «Экспорт биржевой информации»
|
||||
LKAgent LKAgentStatus // канал кабинета клиента (режим ожидания)
|
||||
LKKeyMask string // ключ кабинета в маскированном виде
|
||||
DepoTables DepoTablesView // трансферные таблицы FTV_M2M* в базе DEPO
|
||||
QuikExport QuikExportView // модуль «Экспорт биржевой информации»
|
||||
LKAgent LKAgentStatus // канал кабинета клиента (режим ожидания)
|
||||
LKKeyMask string // ключ кабинета в маскированном виде
|
||||
LKUsers []LKUser // учётные записи кабинета
|
||||
LKUsersError string
|
||||
Versions SystemVersions // версии компонентов bj и внешних систем
|
||||
Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак)
|
||||
Features []LicenseFeatureView // признаки лицензии для карточки
|
||||
@@ -380,13 +383,14 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl
|
||||
DepoTables: depoTablesView(s.Fancy),
|
||||
QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"),
|
||||
LKAgent: lkAgentStatus(s),
|
||||
LKKeyMask: maskSecret(s.LK.APIKey),
|
||||
LKKeyMask: lkKeyForPage(s.LK.APIKey),
|
||||
Versions: systemVersions(h.rc),
|
||||
Modules: modulesAllowed(h.rc),
|
||||
Features: licenseFeatureViews(h.rc),
|
||||
Users: usersView(w),
|
||||
Flash: flash,
|
||||
}
|
||||
data.LKUsers, data.LKUsersError = lkUsersForSetup(h.rc)
|
||||
if errVal := errMsgFromQuery(_q(w)); errVal != "" {
|
||||
data.Error = errVal
|
||||
}
|
||||
|
||||
@@ -6,19 +6,29 @@
|
||||
<p class="muted">Речь о нашем модуле <code>bj-lk</code> — кабинете, который клиенты открывают в браузере. Он ставится отдельно от ядра: на веб-сервер депозитария или на обычный хостинг с выделенным именем. Своей базы у кабинета нет, данные клиентов он не хранит — берёт у ядра на время запроса.</p>
|
||||
|
||||
<h3>Шаг 1. Ключ и архив</h3>
|
||||
<p>Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек, установщик и README. <b>Внутри архива боевой ключ</b>: после установки удалите архив с рабочего компьютера, не пересылайте его почтой.</p>
|
||||
<p>Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа (сразу после создания он показан целиком 10 минут — перепишите его для установщика) и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек службы, установщик и README. Ключа в архиве нет.</p>
|
||||
|
||||
<h3>Шаг 2. Установка на сервере кабинета</h3>
|
||||
<h3>Шаг 2. Файлы на сервер и запуск</h3>
|
||||
<pre>tar xzf bj-lk-ГГГГ-ММ-ДД.tar.gz
|
||||
cd bj-lk
|
||||
sudo ./install.sh
|
||||
curl -s http://127.0.0.1:8099/health # {"ok":true}</pre>
|
||||
<p>Установщик заводит системного пользователя <code>bjlk</code>, кладёт программу в <code>/opt/bj-lk</code>, настройки — в <code>/etc/bj-lk/lk.env</code> (права 0600) и включает службу <code>bj-lk</code>. Сам кабинет слушает только <code>127.0.0.1:8099</code>.</p>
|
||||
sudo ./install.sh # на своём сервере: заводит службу bj-lk
|
||||
# или на хостинге, где можно держать программу запущенной:
|
||||
LISTEN=127.0.0.1:8099 ./bj-lk</pre>
|
||||
<p><code>install.sh</code> заводит системного пользователя <code>bjlk</code>, кладёт программу в <code>/opt/bj-lk</code>, каталог данных — <code>/var/lib/bj-lk</code>, и включает службу. Сам кабинет слушает только <code>127.0.0.1:8099</code>.</p>
|
||||
|
||||
<h3>Шаг 3. Публикация наружу</h3>
|
||||
<h3>Шаг 3. Установщик в браузере</h3>
|
||||
<p>Откройте адрес кабинета — до настройки любая страница ведёт в установщик, как у обычных веб-приложений. Он делает три шага:</p>
|
||||
<ol>
|
||||
<li><b>Проверка сервера</b>: каталог данных доступен для записи, соединение защищено (https), адрес кабинета.</li>
|
||||
<li><b>Связь с ядром</b>: способ связи (кабинет обращается к ядру / ядро подключается к кабинету), адрес ядра и ключ доступа. В прямом режиме связь проверяется до сохранения; в режиме ожидания страница ждёт, пока ядро подключится.</li>
|
||||
<li><b>Администратор кабинета</b>: логин и пароль сотрудника, который будет видеть список клиентов. Учётная запись заводится в ядре; если администратор уже есть, шаг пропускается.</li>
|
||||
</ol>
|
||||
<p>Настройки сохраняются в <code>lk.json</code> (каталог данных, права 0600), после чего установщик закрывается. Чтобы пройти его заново — удалите этот файл. Установка без браузера: заполните <code>lk.env</code> — окружение перекрывает файл установщика.</p>
|
||||
|
||||
<h3>Шаг 4. Публикация наружу</h3>
|
||||
<p>Перед кабинетом ставится nginx с сертификатом TLS — пример конфигурации есть в README архива. Наружу открыт только 443 порт сервера кабинета.</p>
|
||||
|
||||
<h3>Шаг 4. Связь с ядром — два способа</h3>
|
||||
<h3>Связь с ядром — два способа</h3>
|
||||
<p><b>Кабинет обращается к ядру</b> (обычный случай). В настройках ядра укажите его адрес, кабинет ходит туда за данными. Требуется, чтобы сервер кабинета мог открыть соединение к ядру по https.</p>
|
||||
<p><b>Ядро подключается к кабинету.</b> Если у ядра нет адреса в интернете — а так чаще всего и бывает, ядро стоит во внутренней сети депозитария, — порядок разворачивается. Кабинет ничего никуда не звонит: он копит запросы клиентов и ждёт. Ядро само подключается к кабинету по его доменному имени, забирает запрос, выполняет его у себя и возвращает ответ. Из сети депозитария идёт только исходящее соединение: ни белого адреса, ни проброса портов, ни правил для входящего трафика не нужно.</p>
|
||||
<p>Включается в настройках ядра: «Личный кабинет» → «Как кабинет и ядро находят друг друга» → «Ядро подключается к кабинету», рядом — адрес кабинета. В файле кабинета при этом стоит <code>BJ_MODE=agent</code>, а <code>BJ_URL</code> не задан. Состояние связи видно там же: подключено ли ядро, когда был последний обмен, сколько запросов обслужено.</p>
|
||||
@@ -31,7 +41,11 @@ curl -s http://127.0.0.1:8099/health # {"ok":true}</pre>
|
||||
<li>Страница не обращается к сторонним сайтам — шрифты и оформление вшиты в программу.</li>
|
||||
<li>Вход сотрудника в кабинет клиента записывается в журнал доступа (<code>lk.audit</code>) и поднимает оповещение.</li>
|
||||
<li>Обновляется кабинет отдельно от ядра — через сервер обновлений, по лицензии модуля «Личный кабинет клиента».</li>
|
||||
<li>Учётные записи кабинета (клиенты и сотрудники) заводятся здесь же, в карточке «Пользователи кабинета». Письма не отправляются — пароль сообщается лично.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Какой хостинг подходит</h3>
|
||||
<p>Кабинет — одна программа (Linux, x86-64), которая должна быть постоянно запущена и слушать порт. Подходят: свой сервер или VPS, хостинг с поддержкой собственных приложений (Node/Go/Python через SSH или панель). Обычный PHP-хостинг «папка с файлами» программу запустить не сможет — там нужен либо тариф с приложениями, либо VPS у того же хостера.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
@@ -605,7 +605,7 @@ function hbBind(inp,box,list,onPick){
|
||||
|
||||
<div class="card" id="sec-lk-dist">
|
||||
<h2><span class="dot {{if .Settings.LK.APIKey}}ok{{else}}warn{{end}}"></span>Кабинет клиента на своём сервере</h2>
|
||||
<p class="muted">Кабинет (<code>bj-lk</code>) — отдельная программа для веб-сервера депозитария или хостинга. Своей базы у неё нет: данные она берёт у ядра по защищённому каналу и не хранит дольше запроса. Порядок: <b>1)</b> создайте ключ доступа, <b>2)</b> выберите способ связи, <b>3)</b> скачайте архив и распакуйте его на сервере кабинета, <b>4)</b> выполните там <code>sudo ./install.sh</code>, <b>5)</b> опубликуйте кабинет через nginx с сертификатом (пример в README архива).</p>
|
||||
<p class="muted">Кабинет (<code>bj-lk</code>) — отдельная программа для веб-сервера депозитария или хостинга. Своей базы у неё нет: данные она берёт у ядра по защищённому каналу и не хранит дольше запроса. Порядок: <b>1)</b> создайте ключ доступа, <b>2)</b> выберите способ связи, <b>3)</b> скачайте архив и распакуйте его на сервере кабинета, <b>4)</b> запустите программу (<code>sudo ./install.sh</code> заводит службу), <b>5)</b> откройте адрес кабинета в браузере — установщик проверит сервер, спросит ключ, проверит связь и заведёт администратора, <b>6)</b> опубликуйте кабинет через nginx с сертификатом (пример в README архива).</p>
|
||||
|
||||
<form method="post" action="/admin/setup/lk/dist" style="display:grid;gap:14px;max-width:780px;margin-top:12px">
|
||||
<input type="hidden" name="section" value="sec-lk-dist"><input type="hidden" name="core_url_set" value="1"><input type="hidden" name="mode_set" value="1">
|
||||
@@ -617,7 +617,7 @@ function hbBind(inp,box,list,onPick){
|
||||
<button type="submit" name="op" value="newkey" class="btn btn-secondary">{{if .Settings.LK.APIKey}}Сменить ключ{{else}}Создать ключ{{end}}</button>
|
||||
{{if .Settings.LK.APIKey}}<button type="submit" name="op" value="clearkey" class="btn btn-secondary" onclick="return confirm('Удалить ключ? Кабинет перестанет получать данные, пока не впишете новый.')">Удалить</button>{{end}}
|
||||
</div>
|
||||
<span class="muted" style="font-size:12px">Ключ показан не полностью — целиком он попадает только в архив, в файл <code>/etc/bj-lk/lk.env</code> на сервере кабинета. При смене ключа скачайте архив заново или впишите новый ключ в этот файл и перезапустите службу кабинета.</span>
|
||||
<span class="muted" style="font-size:12px">Ключ показан не полностью. В архив он не попадает: его вводят в установщике кабинета (или вписывают в <code>lk.env</code> при установке без браузера). Целиком ключ виден один раз — сразу после создания.</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -658,7 +658,56 @@ function hbBind(inp,box,list,onPick){
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="muted" style="font-size:12px;margin-top:10px">В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек с правами 0600, установщик и README. Внутри архива лежит боевой ключ — после установки удалите архив с рабочего компьютера. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.</p>
|
||||
<p class="muted" style="font-size:12px;margin-top:10px">В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек службы, установщик и README. Ключа в архиве нет — его спрашивает веб-установщик. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="sec-lk-users">
|
||||
<h2><span class="dot {{if .LKUsers}}ok{{else}}warn{{end}}"></span>Пользователи кабинета</h2>
|
||||
<p class="muted">Учётные записи кабинета живут здесь, в базе ядра: кабинет на хостинге своей базы не имеет. Клиент привязан к номеру договора депозитария — по нему кабинет показывает портфель. Сотрудник («администратор кабинета») видит список клиентов и открывает их кабинеты на просмотр. <b>Письма не отправляются</b>: почта только хранится, пароль сообщите лично.</p>
|
||||
{{if .LKUsersError}}<div class="alert alert-warn">База кабинета недоступна: {{.LKUsersError}}</div>{{end}}
|
||||
{{if .LKUsers}}
|
||||
<div style="overflow-x:auto"><table style="margin-top:10px">
|
||||
<tr><th>Логин</th><th>Договор</th><th>Имя</th><th>Почта</th><th>Роль</th><th>Поручения</th><th>Состояние</th><th></th></tr>
|
||||
{{range .LKUsers}}<tr>
|
||||
<td><code>{{.Login}}</code></td><td>{{.Contract}}</td><td>{{.FullName}}</td><td>{{.Email}}</td>
|
||||
<td>{{if .IsAdmin}}сотрудник{{else}}клиент{{end}}</td>
|
||||
<td>{{if .CanTransfer}}разрешены{{else}}<span class="muted">просмотр</span>{{end}}</td>
|
||||
<td>{{if .Active}}<span class="dot ok"></span>активна{{else}}<span class="dot err"></span>заблокирована{{end}}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<form method="post" action="/admin/setup/lk/users" style="display:inline"><input type="hidden" name="section" value="sec-lk-users"><input type="hidden" name="id" value="{{.ID}}">
|
||||
{{if .Active}}<button class="btn btn-secondary btn-sm" name="op" value="block">Заблокировать</button>{{else}}<button class="btn btn-secondary btn-sm" name="op" value="unblock">Разблокировать</button>{{end}}
|
||||
{{if .IsAdmin}}<button class="btn btn-secondary btn-sm" name="op" value="admin_off">Снять права сотрудника</button>{{else}}<button class="btn btn-secondary btn-sm" name="op" value="admin_on">Сделать сотрудником</button>
|
||||
{{if .CanTransfer}}<button class="btn btn-secondary btn-sm" name="op" value="transfer_off">Запретить поручения</button>{{else}}<button class="btn btn-secondary btn-sm" name="op" value="transfer_on">Разрешить поручения</button>{{end}}{{end}}
|
||||
<button class="btn btn-secondary btn-sm" name="op" value="delete" onclick="return confirm('Удалить учётную запись {{.Login}}? Поручения и журнал останутся.')">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>{{end}}
|
||||
</table></div>
|
||||
{{else}}{{if not .LKUsersError}}<p class="muted">Учётных записей пока нет. Первого администратора можно завести из установщика кабинета или здесь.</p>{{end}}{{end}}
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;margin-top:14px">
|
||||
<form method="post" action="/admin/setup/lk/users" style="display:grid;gap:8px">
|
||||
<input type="hidden" name="section" value="sec-lk-users"><input type="hidden" name="op" value="create">
|
||||
<b>Новая учётная запись</b>
|
||||
<input type="text" name="login" placeholder="Логин (латиницей или номер договора)" required autocomplete="off">
|
||||
<input type="text" name="contract" placeholder="Номер договора в депозитарии, например БР-859" autocomplete="off">
|
||||
<input type="text" name="full_name" placeholder="ФИО" autocomplete="off">
|
||||
<input type="email" name="email" placeholder="Электронная почта (не рассылается)" autocomplete="off">
|
||||
<input type="text" name="phone" placeholder="Телефон" autocomplete="off">
|
||||
<input type="password" name="password" placeholder="Пароль: от 10 символов, буквы и цифра" required autocomplete="new-password">
|
||||
<label style="font-weight:400"><input type="checkbox" name="is_admin" value="1"> сотрудник депозитария (видит клиентов, договор не нужен)</label>
|
||||
<label style="font-weight:400"><input type="checkbox" name="can_transfer" value="1"> разрешить подачу поручений (по умолчанию — только просмотр)</label>
|
||||
<div><button type="submit" class="btn btn-ok">Создать</button></div>
|
||||
</form>
|
||||
<form method="post" action="/admin/setup/lk/users" style="display:grid;gap:8px;align-content:start">
|
||||
<input type="hidden" name="section" value="sec-lk-users"><input type="hidden" name="op" value="password">
|
||||
<b>Сменить пароль</b>
|
||||
<input type="text" name="login" placeholder="Логин" required autocomplete="off">
|
||||
<input type="password" name="password" placeholder="Новый пароль" required autocomplete="new-password">
|
||||
<span class="muted" style="font-size:12px">Все сессии пользователя будут завершены.</span>
|
||||
<div><button type="submit" class="btn btn-secondary">Сменить</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
Reference in New Issue
Block a user