diff --git a/cmd/bj-lk/main.go b/cmd/bj-lk/main.go index 552ab98..38570f3 100644 --- a/cmd/bj-lk/main.go +++ b/cmd/bj-lk/main.go @@ -49,12 +49,19 @@ func main() { if listen == "" { listen = ":8081" } + // Настройки из файла веб-установщика (data/lk.json); окружение и флаги их перекрывают. + dataDir := lk.DefaultDataDir() + cfg, err := lk.LoadConfigFile(dataDir, cfg) + if err != nil { + log.Printf("bj-lk: %v", err) + } srv, err := lk.New(cfg) if err != nil { log.Fatalf("bj-lk: %v", err) } + srv.SetDataDir(dataDir) if !srv.Configured() { - log.Printf("bj-lk: не заданы BJ_KEY и адрес ядра — кабинет показывает страницу настройки (см. /etc/bj-lk/lk.env)") + log.Printf("bj-lk: кабинет не настроен — откройте его адрес в браузере, запустится установщик (или задайте BJ_KEY и адрес ядра в окружении)") } if srv.InsecureCore() { // Не отказываем в запуске (бывают закрытые сети со своим шифрованием), diff --git a/internal/lkgateway/lkapi.go b/internal/lkgateway/lkapi.go index 07cbce7..6dd817d 100644 --- a/internal/lkgateway/lkapi.go +++ b/internal/lkgateway/lkapi.go @@ -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 diff --git a/internal/lkgateway/lkdist.go b/internal/lkgateway/lkdist.go index 9937fa1..bc5c3c2 100644 --- a/internal/lkgateway/lkdist.go +++ b/internal/lkgateway/lkdist.go @@ -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 = "Ключ удалён: кабинет больше не сможет обращаться к ядру." diff --git a/internal/lkgateway/lkdist_test.go b/internal/lkgateway/lkdist_test.go index 91e3f86..5746713 100644 --- a/internal/lkgateway/lkdist_test.go +++ b/internal/lkgateway/lkdist_test.go @@ -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) } diff --git a/internal/lkgateway/lkusers_admin.go b/internal/lkgateway/lkusers_admin.go new file mode 100644 index 0000000..1d2c0a2 --- /dev/null +++ b/internal/lkgateway/lkusers_admin.go @@ -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, "" +} diff --git a/internal/lkgateway/lkusers_admin_test.go b/internal/lkgateway/lkusers_admin_test.go new file mode 100644 index 0000000..6cf8b0b --- /dev/null +++ b/internal/lkgateway/lkusers_admin_test.go @@ -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("маска ключа не показана") + } +} diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 2d5b2d2..fa906fb 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -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 } diff --git a/internal/lkgateway/web/templates/admin_help_lk.html b/internal/lkgateway/web/templates/admin_help_lk.html index 5dacac9..bd9f90b 100644 --- a/internal/lkgateway/web/templates/admin_help_lk.html +++ b/internal/lkgateway/web/templates/admin_help_lk.html @@ -6,19 +6,29 @@

Речь о нашем модуле bj-lk — кабинете, который клиенты открывают в браузере. Он ставится отдельно от ядра: на веб-сервер депозитария или на обычный хостинг с выделенным именем. Своей базы у кабинета нет, данные клиентов он не хранит — берёт у ядра на время запроса.

Шаг 1. Ключ и архив

-

Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек, установщик и README. Внутри архива боевой ключ: после установки удалите архив с рабочего компьютера, не пересылайте его почтой.

+

Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа (сразу после создания он показан целиком 10 минут — перепишите его для установщика) и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек службы, установщик и README. Ключа в архиве нет.

-

Шаг 2. Установка на сервере кабинета

+

Шаг 2. Файлы на сервер и запуск

tar xzf bj-lk-ГГГГ-ММ-ДД.tar.gz
 cd bj-lk
-sudo ./install.sh
-curl -s http://127.0.0.1:8099/health   # {"ok":true}
-

Установщик заводит системного пользователя bjlk, кладёт программу в /opt/bj-lk, настройки — в /etc/bj-lk/lk.env (права 0600) и включает службу bj-lk. Сам кабинет слушает только 127.0.0.1:8099.

+sudo ./install.sh # на своём сервере: заводит службу bj-lk +# или на хостинге, где можно держать программу запущенной: +LISTEN=127.0.0.1:8099 ./bj-lk +

install.sh заводит системного пользователя bjlk, кладёт программу в /opt/bj-lk, каталог данных — /var/lib/bj-lk, и включает службу. Сам кабинет слушает только 127.0.0.1:8099.

-

Шаг 3. Публикация наружу

+

Шаг 3. Установщик в браузере

+

Откройте адрес кабинета — до настройки любая страница ведёт в установщик, как у обычных веб-приложений. Он делает три шага:

+
    +
  1. Проверка сервера: каталог данных доступен для записи, соединение защищено (https), адрес кабинета.
  2. +
  3. Связь с ядром: способ связи (кабинет обращается к ядру / ядро подключается к кабинету), адрес ядра и ключ доступа. В прямом режиме связь проверяется до сохранения; в режиме ожидания страница ждёт, пока ядро подключится.
  4. +
  5. Администратор кабинета: логин и пароль сотрудника, который будет видеть список клиентов. Учётная запись заводится в ядре; если администратор уже есть, шаг пропускается.
  6. +
+

Настройки сохраняются в lk.json (каталог данных, права 0600), после чего установщик закрывается. Чтобы пройти его заново — удалите этот файл. Установка без браузера: заполните lk.env — окружение перекрывает файл установщика.

+ +

Шаг 4. Публикация наружу

Перед кабинетом ставится nginx с сертификатом TLS — пример конфигурации есть в README архива. Наружу открыт только 443 порт сервера кабинета.

-

Шаг 4. Связь с ядром — два способа

+

Связь с ядром — два способа

Кабинет обращается к ядру (обычный случай). В настройках ядра укажите его адрес, кабинет ходит туда за данными. Требуется, чтобы сервер кабинета мог открыть соединение к ядру по https.

Ядро подключается к кабинету. Если у ядра нет адреса в интернете — а так чаще всего и бывает, ядро стоит во внутренней сети депозитария, — порядок разворачивается. Кабинет ничего никуда не звонит: он копит запросы клиентов и ждёт. Ядро само подключается к кабинету по его доменному имени, забирает запрос, выполняет его у себя и возвращает ответ. Из сети депозитария идёт только исходящее соединение: ни белого адреса, ни проброса портов, ни правил для входящего трафика не нужно.

Включается в настройках ядра: «Личный кабинет» → «Как кабинет и ядро находят друг друга» → «Ядро подключается к кабинету», рядом — адрес кабинета. В файле кабинета при этом стоит BJ_MODE=agent, а BJ_URL не задан. Состояние связи видно там же: подключено ли ядро, когда был последний обмен, сколько запросов обслужено.

@@ -31,7 +41,11 @@ curl -s http://127.0.0.1:8099/health # {"ok":true}
  • Страница не обращается к сторонним сайтам — шрифты и оформление вшиты в программу.
  • Вход сотрудника в кабинет клиента записывается в журнал доступа (lk.audit) и поднимает оповещение.
  • Обновляется кабинет отдельно от ядра — через сервер обновлений, по лицензии модуля «Личный кабинет клиента».
  • +
  • Учётные записи кабинета (клиенты и сотрудники) заводятся здесь же, в карточке «Пользователи кабинета». Письма не отправляются — пароль сообщается лично.
  • + +

    Какой хостинг подходит

    +

    Кабинет — одна программа (Linux, x86-64), которая должна быть постоянно запущена и слушать порт. Подходят: свой сервер или VPS, хостинг с поддержкой собственных приложений (Node/Go/Python через SSH или панель). Обычный PHP-хостинг «папка с файлами» программу запустить не сможет — там нужен либо тариф с приложениями, либо VPS у того же хостера.

    diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 5195033..359fe6b 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -605,7 +605,7 @@ function hbBind(inp,box,list,onPick){

    Кабинет клиента на своём сервере

    -

    Кабинет (bj-lk) — отдельная программа для веб-сервера депозитария или хостинга. Своей базы у неё нет: данные она берёт у ядра по защищённому каналу и не хранит дольше запроса. Порядок: 1) создайте ключ доступа, 2) выберите способ связи, 3) скачайте архив и распакуйте его на сервере кабинета, 4) выполните там sudo ./install.sh, 5) опубликуйте кабинет через nginx с сертификатом (пример в README архива).

    +

    Кабинет (bj-lk) — отдельная программа для веб-сервера депозитария или хостинга. Своей базы у неё нет: данные она берёт у ядра по защищённому каналу и не хранит дольше запроса. Порядок: 1) создайте ключ доступа, 2) выберите способ связи, 3) скачайте архив и распакуйте его на сервере кабинета, 4) запустите программу (sudo ./install.sh заводит службу), 5) откройте адрес кабинета в браузере — установщик проверит сервер, спросит ключ, проверит связь и заведёт администратора, 6) опубликуйте кабинет через nginx с сертификатом (пример в README архива).

    @@ -617,7 +617,7 @@ function hbBind(inp,box,list,onPick){ {{if .Settings.LK.APIKey}}{{end}}
    - Ключ показан не полностью — целиком он попадает только в архив, в файл /etc/bj-lk/lk.env на сервере кабинета. При смене ключа скачайте архив заново или впишите новый ключ в этот файл и перезапустите службу кабинета. + Ключ показан не полностью. В архив он не попадает: его вводят в установщике кабинета (или вписывают в lk.env при установке без браузера). Целиком ключ виден один раз — сразу после создания.
    @@ -658,7 +658,56 @@ function hbBind(inp,box,list,onPick){
    {{end}} -

    В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек с правами 0600, установщик и README. Внутри архива лежит боевой ключ — после установки удалите архив с рабочего компьютера. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.

    +

    В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек службы, установщик и README. Ключа в архиве нет — его спрашивает веб-установщик. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.

    + + +
    +

    Пользователи кабинета

    +

    Учётные записи кабинета живут здесь, в базе ядра: кабинет на хостинге своей базы не имеет. Клиент привязан к номеру договора депозитария — по нему кабинет показывает портфель. Сотрудник («администратор кабинета») видит список клиентов и открывает их кабинеты на просмотр. Письма не отправляются: почта только хранится, пароль сообщите лично.

    + {{if .LKUsersError}}
    База кабинета недоступна: {{.LKUsersError}}
    {{end}} + {{if .LKUsers}} +
    + + {{range .LKUsers}} + + + + + + {{end}} +
    ЛогинДоговорИмяПочтаРольПорученияСостояние
    {{.Login}}{{.Contract}}{{.FullName}}{{.Email}}{{if .IsAdmin}}сотрудник{{else}}клиент{{end}}{{if .CanTransfer}}разрешены{{else}}просмотр{{end}}{{if .Active}}активна{{else}}заблокирована{{end}} + + {{if .Active}}{{else}}{{end}} + {{if .IsAdmin}}{{else}} + {{if .CanTransfer}}{{else}}{{end}}{{end}} + + +
    + {{else}}{{if not .LKUsersError}}

    Учётных записей пока нет. Первого администратора можно завести из установщика кабинета или здесь.

    {{end}}{{end}} + +
    +
    + + Новая учётная запись + + + + + + + + +
    +
    +
    + + Сменить пароль + + + Все сессии пользователя будут завершены. +
    +
    +
    diff --git a/modules/lk/agent.go b/modules/lk/agent.go index cfe6aa0..c433a37 100644 --- a/modules/lk/agent.go +++ b/modules/lk/agent.go @@ -19,6 +19,7 @@ import ( "errors" "net/http" "sync" + "sync/atomic" "time" ) @@ -50,6 +51,15 @@ type agentQueue struct { waiting map[string]*agentTask signal chan struct{} seq uint64 + // lastFetch — когда ядро в последний раз забирало запросы: по этому установщик + // понимает, что ядро дозвонилось до кабинета. + lastFetch atomic.Int64 +} + +// coreSeen — ядро подключалось за последнюю минуту. +func (q *agentQueue) coreSeen() bool { + t := q.lastFetch.Load() + return t > 0 && time.Since(time.Unix(t, 0)) < time.Minute } func newAgentQueue() *agentQueue { @@ -142,8 +152,13 @@ func idFromSeq(n uint64) string { // registerAgent — точки, куда подключается ядро (только в режиме ожидания). func (s *Server) registerAgent(mux *http.ServeMux) { + // В прямом режиме этих точек как бы нет: очередь не создана — отвечаем 404. keyOK := func(w http.ResponseWriter, r *http.Request) bool { - if !secureEqual(r.Header.Get("X-BJ-Key"), s.cfg.BJKey) { + if s.agentQueue() == nil { + http.NotFound(w, r) + return false + } + if !secureEqual(r.Header.Get("X-BJ-Key"), s.conf().BJKey) { http.Error(w, "неверный ключ модуля", http.StatusForbidden) return false } @@ -155,7 +170,9 @@ func (s *Server) registerAgent(mux *http.ServeMux) { return } wait := 25 * time.Second - t := s.agent.next(r.Context(), wait) + q := s.agentQueue() + q.lastFetch.Store(time.Now().Unix()) + t := q.next(r.Context(), wait) if t == nil { w.WriteHeader(http.StatusNoContent) return @@ -182,7 +199,7 @@ func (s *Server) registerAgent(mux *http.ServeMux) { return } body, _ := base64.StdEncoding.DecodeString(in.Body) - if !s.agent.reply(in.ID, agentResult{Status: in.Status, Body: body}) { + if !s.agentQueue().reply(in.ID, agentResult{Status: in.Status, Body: body}) { w.WriteHeader(http.StatusGone) // запрос уже не ждут: клиент ушёл или истёк срок return } diff --git a/modules/lk/install.go b/modules/lk/install.go new file mode 100644 index 0000000..c37bd9d --- /dev/null +++ b/modules/lk/install.go @@ -0,0 +1,278 @@ +package lk + +// install.go — веб-установщик кабинета. +// +// Порядок как у обычных веб-приложений: администратор копирует файлы на сервер, +// открывает адрес кабинета — и попадает в установщик. Тот проверяет окружение, +// спрашивает, как связаться с ядром, проверяет связь, помогает завести первого +// администратора кабинета и сохраняет настройки в файл рядом с программой. +// Служебные переменные окружения (BJ_URL, BJ_KEY…) при этом остаются рабочим способом +// настройки для тех, кто ставит кабинет руками или скриптом: они перекрывают файл. +// +// Файл настроек: <каталог данных>/lk.json, права 0600. Каталог данных — LK_DATA, иначе +// подкаталог data рядом с исполняемым файлом. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// configFileName — имя файла настроек в каталоге данных. +const configFileName = "lk.json" + +// fileConfig — что лежит в lk.json. Ключ — секрет, поэтому файл 0600. +type fileConfig struct { + Mode string `json:"mode"` // direct | agent + BJURL string `json:"bj_url,omitempty"` + BJKey string `json:"bj_key"` + Title string `json:"title,omitempty"` +} + +// DefaultDataDir — каталог данных по умолчанию: data рядом с программой. +func DefaultDataDir() string { + if v := strings.TrimSpace(os.Getenv("LK_DATA")); v != "" { + return v + } + exe, err := os.Executable() + if err != nil { + return "data" + } + return filepath.Join(filepath.Dir(exe), "data") +} + +// LoadConfigFile — настройки из файла; непустые значения из cfg (окружение, флаги) +// имеют приоритет. Отсутствие файла — не ошибка: значит, установщик ещё не проходили. +func LoadConfigFile(dataDir string, cfg Config) (Config, error) { + raw, err := os.ReadFile(filepath.Join(dataDir, configFileName)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return cfg, nil + } + return cfg, err + } + var f fileConfig + if err := json.Unmarshal(raw, &f); err != nil { + return cfg, fmt.Errorf("файл настроек %s повреждён: %v", configFileName, err) + } + if cfg.BJKey == "" { + cfg.BJKey = f.BJKey + } + if cfg.BJURL == "" { + cfg.BJURL = f.BJURL + } + if cfg.Title == "" { + cfg.Title = f.Title + } + if !cfg.Agent && os.Getenv("BJ_MODE") == "" { + cfg.Agent = f.Mode == "agent" + } + return cfg, nil +} + +// saveConfigFile — записать настройки; каталог создаётся при необходимости. +func saveConfigFile(dataDir string, cfg Config) error { + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return fmt.Errorf("каталог данных %s: %v", dataDir, err) + } + f := fileConfig{Mode: "direct", BJURL: cfg.BJURL, BJKey: cfg.BJKey, Title: cfg.Title} + if cfg.Agent { + f.Mode, f.BJURL = "agent", "" + } + raw, _ := json.MarshalIndent(f, "", " ") + tmp := filepath.Join(dataDir, configFileName+".tmp") + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return fmt.Errorf("запись настроек: %v", err) + } + return os.Rename(tmp, filepath.Join(dataDir, configFileName)) +} + +// SetDataDir — где хранить настройки (вызывается из main до Handler). +func (s *Server) SetDataDir(dir string) { + s.mu.Lock() + s.dataDir = dir + s.mu.Unlock() +} + +func (s *Server) dataDirPath() string { + s.mu.RLock() + defer s.mu.RUnlock() + if s.dataDir == "" { + return DefaultDataDir() + } + return s.dataDir +} + +// installCheck — одна строка проверки окружения. +type installCheck struct { + Name, Note string + OK bool + Warn bool +} + +// installChecks — что проверяем перед установкой. +func (s *Server) installChecks(r *http.Request) []installCheck { + var out []installCheck + dir := s.dataDirPath() + if err := os.MkdirAll(dir, 0o700); err != nil { + out = append(out, installCheck{Name: "Каталог данных", Note: fmt.Sprintf("%s: нет прав на создание (%v). Дайте программе право писать в свой каталог.", dir, err)}) + } else if f, err := os.CreateTemp(dir, ".probe-*"); err != nil { + out = append(out, installCheck{Name: "Каталог данных", Note: fmt.Sprintf("%s: нельзя записать файл (%v)", dir, err)}) + } else { + f.Close() + os.Remove(f.Name()) + out = append(out, installCheck{Name: "Каталог данных", Note: dir + " — доступен для записи", OK: true}) + } + if httpsRequest(r) { + out = append(out, installCheck{Name: "Защищённое соединение", Note: "кабинет открыт по https", OK: true}) + } else if localRequest(r) { + out = append(out, installCheck{Name: "Защищённое соединение", Note: "открыто с этого же сервера — для проверки достаточно; клиентам кабинет публикуйте только по https", OK: true, Warn: true}) + } else { + out = append(out, installCheck{Name: "Защищённое соединение", Note: "кабинет открыт по http без TLS. Так публиковать нельзя: пароли клиентов пойдут по сети открытым текстом. Включите сертификат на хостинге и откройте установщик по https.", Warn: true}) + } + out = append(out, installCheck{Name: "Адрес кабинета", Note: r.Host, OK: true}) + return out +} + +// installData — данные для страницы установщика. +type installData struct { + Step int + Checks []installCheck + Cfg Config + Error string + Notice string + Host string + Waiting bool // режим ожидания: ждём подключения ядра + AdminOK bool +} + +// registerInstall — страницы установщика. +func (s *Server) registerInstall(mux *http.ServeMux) { + mux.HandleFunc("/install", func(w http.ResponseWriter, r *http.Request) { + // Уже настроено: повторный вход в установщик — только удалением файла настроек. + // Исключение — шаг «администратор», он идёт сразу после сохранения связи. + if s.Configured() && !(r.Method == http.MethodPost && r.FormValue("step") == "admin") { + s.render(w, r, "installed", map[string]any{"File": filepath.Join(s.dataDirPath(), configFileName)}) + return + } + d := installData{Step: 1, Checks: s.installChecks(r), Cfg: s.conf(), Host: r.Host} + if r.Method == http.MethodGet { + s.renderInstall(w, r, d) + return + } + if !csrfOK(r) { + d.Error = "Форма устарела, обновите страницу." + s.renderInstall(w, r, d) + return + } + switch r.FormValue("step") { + case "connect": + s.installConnect(w, r, d) + case "admin": + s.installAdmin(w, r, d) + default: + s.renderInstall(w, r, d) + } + }) + // Состояние связи для страницы ожидания ядра (опрашивается страницей по meta refresh). + mux.HandleFunc("/install/wait", func(w http.ResponseWriter, r *http.Request) { + d := installData{Step: 2, Cfg: s.conf(), Host: r.Host, Waiting: true} + if q := s.agentQueue(); q != nil && q.coreSeen() { + d.Step, d.Waiting = 3, false + d.Notice = "Ядро подключилось к кабинету. Осталось завести администратора." + } + s.renderInstall(w, r, d) + }) +} + +func (s *Server) renderInstall(w http.ResponseWriter, r *http.Request, d installData) { + s.render(w, r, "install", map[string]any{"D": d}) +} + +// installConnect — шаг «связь с ядром»: сохранить настройки и проверить связь. +func (s *Server) installConnect(w http.ResponseWriter, r *http.Request, d installData) { + cfg := d.Cfg + cfg.Agent = r.FormValue("mode") == "agent" + cfg.BJURL = strings.TrimRight(strings.TrimSpace(r.FormValue("bj_url")), "/") + cfg.BJKey = strings.TrimSpace(r.FormValue("bj_key")) + cfg.Title = strings.TrimSpace(r.FormValue("title")) + d.Cfg = cfg + switch { + case cfg.BJKey == "": + d.Error = "Укажите ключ доступа модуля — он создаётся в настройках ядра, раздел «Личный кабинет»." + case !cfg.Agent && cfg.BJURL == "": + d.Error = "Укажите адрес ядра или выберите режим «ядро подключается к кабинету»." + case !cfg.Agent && !strings.HasPrefix(cfg.BJURL, "https://") && cfg.insecureCore(): + d.Error = "Адрес ядра должен начинаться с https:// — по этому каналу идут ключ и сессии клиентов." + } + if d.Error != "" { + s.renderInstall(w, r, d) + return + } + if !cfg.Agent { + // Прямой режим: проверяем связь до сохранения, чтобы не записать нерабочие настройки. + probe := &bjClient{cfg: cfg, hc: &http.Client{Timeout: 15 * time.Second}} + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + var out map[string]any + if err := probe.call(ctx, http.MethodGet, "/api/lk/ping", "", nil, &out); err != nil { + d.Error = "Связь с ядром не установлена: " + err.Error() + s.renderInstall(w, r, d) + return + } + } + if err := saveConfigFile(s.dataDirPath(), cfg); err != nil { + d.Error = err.Error() + s.renderInstall(w, r, d) + return + } + s.apply(cfg) + if cfg.Agent { + // Проверить связь можно только когда ядро само подключится: показываем ожидание. + http.Redirect(w, r, "/install/wait", http.StatusSeeOther) + return + } + d.Step, d.Notice = 3, "Связь с ядром установлена, настройки сохранены. Осталось завести администратора кабинета." + s.renderInstall(w, r, d) +} + +// installAdmin — шаг «администратор кабинета»: учётная запись заводится в ядре. +func (s *Server) installAdmin(w http.ResponseWriter, r *http.Request, d installData) { + d.Step = 3 + login := strings.TrimSpace(r.FormValue("login")) + pass, pass2 := r.FormValue("password"), r.FormValue("password2") + switch { + case r.FormValue("skip") != "": + s.installDone(w, r) + return + case login == "" || pass == "": + d.Error = "Укажите логин и пароль администратора." + case pass != pass2: + d.Error = "Пароли не совпадают." + } + if d.Error != "" { + s.renderInstall(w, r, d) + return + } + var out struct{ Message string } + err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/admin/bootstrap", "", map[string]string{ + "login": login, "password": pass, "full_name": strings.TrimSpace(r.FormValue("full_name")), + "email": strings.TrimSpace(r.FormValue("email")), + }, &out) + if err != nil { + d.Error = err.Error() + s.renderInstall(w, r, d) + return + } + s.installDone(w, r) +} + +func (s *Server) installDone(w http.ResponseWriter, r *http.Request) { + s.render(w, r, "install_done", map[string]any{"File": filepath.Join(s.dataDirPath(), configFileName)}) +} diff --git a/modules/lk/lk.go b/modules/lk/lk.go index bb7e221..58af8c6 100644 --- a/modules/lk/lk.go +++ b/modules/lk/lk.go @@ -17,6 +17,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" ) @@ -35,12 +36,15 @@ type Config struct { Agent bool } -// Server — кабинет. +// Server — кабинет. Настройки живут за мьютексом: веб-установщик меняет их на ходу, +// без перезапуска службы. type Server struct { - cfg Config - bj *bjClient - tpl *template.Template - agent *agentQueue + mu sync.RWMutex + cfg Config + bjc *bjClient + agent *agentQueue + dataDir string // где лежит lk.json (веб-установщик) + tpl *template.Template } // New — кабинет с разобранными шаблонами. @@ -101,32 +105,70 @@ func New(cfg Config) (*Server, error) { if err != nil { return nil, err } - srv := &Server{cfg: cfg, tpl: tpl} - if cfg.Agent { - srv.agent = newAgentQueue() - } - srv.bj = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: srv.agent} + srv := &Server{tpl: tpl} + srv.apply(cfg) return srv, nil } +// apply — применить настройки: пересобрать клиента к ядру и очередь режима ожидания. +func (s *Server) apply(cfg Config) { + cfg.BJURL = strings.TrimRight(strings.TrimSpace(cfg.BJURL), "/") + cfg.BJKey = strings.TrimSpace(cfg.BJKey) + if cfg.Title == "" { + cfg.Title = "Личный кабинет" + } + s.mu.Lock() + defer s.mu.Unlock() + s.cfg = cfg + s.agent = nil + if cfg.Agent { + s.agent = newAgentQueue() + } + s.bjc = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: s.agent} +} + +// conf — текущие настройки (копия). +func (s *Server) conf() Config { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg +} + +// bj — клиент к ядру по текущим настройкам. +func (s *Server) bj() *bjClient { + s.mu.RLock() + defer s.mu.RUnlock() + return s.bjc +} + +func (s *Server) agentQueue() *agentQueue { + s.mu.RLock() + defer s.mu.RUnlock() + return s.agent +} + // Configured — кабинету есть с чем работать: ключ модуля и, в прямом режиме, адрес ядра. -func (s *Server) Configured() bool { - if s.cfg.BJKey == "" { +func (s *Server) Configured() bool { return s.conf().configured() } + +func (c Config) configured() bool { + if c.BJKey == "" { return false } - if s.cfg.Agent { + if c.Agent { return true // адрес ядра не нужен: ядро подключается само } - return s.cfg.BJURL != "" + return c.BJURL != "" } // InsecureCore — ядро задано по открытому http не на этой же машине: ключ модуля и // токены сессий клиентов пойдут по сети в открытом виде. -func (s *Server) InsecureCore() bool { - if s.cfg.Agent || s.cfg.BJURL == "" || strings.HasPrefix(s.cfg.BJURL, "https://") { +func (s *Server) InsecureCore() bool { return s.conf().insecureCore() } + +func (c Config) insecureCore() bool { + if c.Agent || c.BJURL == "" || strings.HasPrefix(c.BJURL, "https://") { return false } - rest := strings.TrimPrefix(s.cfg.BJURL, "http://") + rest := strings.TrimPrefix(c.BJURL, "http://") return !strings.HasPrefix(rest, "127.0.0.1") && !strings.HasPrefix(rest, "localhost") } @@ -370,7 +412,7 @@ type securityCard struct { // portfolio — реестр клиента из ядра. func (s *Server) portfolio(r *http.Request, sess string) (portfolioResp, error) { var out portfolioResp - err := s.bj.call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out) + err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out) return out, err } @@ -381,7 +423,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat if data == nil { data = map[string]any{} } - data["Title"] = s.cfg.Title + data["Title"] = s.conf().Title if _, ok := data["Nav"]; !ok { data["Nav"] = "" } @@ -467,13 +509,11 @@ func session(r *http.Request) string { return c.Value } -// Handler — все страницы кабинета. Без адреса bj-server или ключа — страница с подсказкой настройки. +// Handler — все страницы кабинета. Пока кабинет не настроен, любая страница ведёт +// в веб-установщик (/install), как у обычных веб-приложений после копирования файлов. func (s *Server) Handler() http.Handler { - if !s.Configured() { - return harden(http.HandlerFunc(s.notConfigured)) - } - bj := s.bj mux := http.NewServeMux() + s.registerInstall(mux) // Вход mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { @@ -491,7 +531,7 @@ func (s *Server) Handler() http.Handler { Contract string `json:"contract"` IsAdmin bool `json:"is_admin"` } - err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/login", "", map[string]string{ + err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/login", "", map[string]string{ "login": r.FormValue("login"), "password": r.FormValue("password"), }, &out) if err != nil { @@ -513,9 +553,9 @@ func (s *Server) Handler() http.Handler { // Выход гасит и клиентскую сессию, и сохранённую админскую: иначе после // «выхода» в браузере оставался действующий токен сотрудника. mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { - _ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil) + _ = s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil) if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" { - _ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", c.Value, nil, nil) + _ = s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/logout", c.Value, nil, nil) } clearAll(w, r) http.Redirect(w, r, "/login", http.StatusSeeOther) @@ -541,7 +581,7 @@ func (s *Server) Handler() http.Handler { var cl struct { Claims []claimRow `json:"claims"` } - _ = bj.call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &cl) + _ = s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &cl) active, _ := claimsView(cl.Claims) pos := pf.Positions if len(pos) > 5 { @@ -600,7 +640,7 @@ func (s *Server) Handler() http.Handler { var out struct { Security securityCard `json:"security"` } - if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/security?isin="+url.QueryEscape(isin), sess, nil, &out); err != nil { + if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/security?isin="+url.QueryEscape(isin), sess, nil, &out); err != nil { s.render(w, r, "security", map[string]any{"Nav": "p", "Error": err.Error(), "ISIN": isin}) return } @@ -625,7 +665,7 @@ func (s *Server) Handler() http.Handler { Operations []operationRow `json:"operations"` } path := "/api/lk/operations?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to) - if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil { + if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil { s.render(w, r, "operations", map[string]any{"Nav": "o", "Error": err.Error(), "From": from, "To": to}) return } @@ -662,14 +702,14 @@ func (s *Server) Handler() http.Handler { Tax float64 `json:"tax"` } path := "/api/lk/income?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to) - if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil { + if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil { s.render(w, r, "income", map[string]any{"Nav": "i", "Error": err.Error(), "From": from, "To": to}) return } var ca struct { Actions []corpAction `json:"actions"` } - _ = bj.call(reqCtx(r), http.MethodGet, "/api/lk/corpactions", sess, nil, &ca) + _ = s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/corpactions", sess, nil, &ca) acts := ca.Actions if len(acts) > 8 { acts = acts[:8] @@ -690,7 +730,7 @@ func (s *Server) Handler() http.Handler { var res struct { Message string `json:"message"` } - err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/documents", sess, map[string]string{ + err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/documents", sess, map[string]string{ "kind": r.FormValue("kind"), "from": r.FormValue("from"), "to": r.FormValue("to"), @@ -711,7 +751,7 @@ func (s *Server) Handler() http.Handler { Requests []documentRow `json:"requests"` Kinds []string `json:"kinds"` } - if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/documents", sess, nil, &out); err != nil { + if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/documents", sess, nil, &out); err != nil { s.render(w, r, "documents", map[string]any{"Nav": "d", "Error": err.Error()}) return } @@ -761,7 +801,7 @@ func (s *Server) Handler() http.Handler { Positions []position `json:"positions"` FullName string `json:"full_name"` } - if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil { + if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil { s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": err.Error(), "Today": time.Now().Format("02.01.2006")}) return } @@ -790,7 +830,7 @@ func (s *Server) Handler() http.Handler { Message string `json:"message"` GUID string `json:"guid"` } - err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/transfer", sess, map[string]any{ + err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/transfer", sess, map[string]any{ "receiving_inn": strings.TrimSpace(r.FormValue("receiving_inn")), "receiving_name": strings.TrimSpace(r.FormValue("receiving_name")), "account_at_recv": strings.TrimSpace(r.FormValue("account_at_recv")), @@ -818,7 +858,7 @@ func (s *Server) Handler() http.Handler { var out struct { Claims []claimRow `json:"claims"` } - if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &out); err != nil { + if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &out); err != nil { s.render(w, r, "claims", map[string]any{"Nav": "c", "Error": err.Error()}) return } @@ -855,7 +895,7 @@ func (s *Server) Handler() http.Handler { if search != "" { path += "?search=" + url.QueryEscape(search) } - if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil { + if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil { s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Error": err.Error()}) return } @@ -884,7 +924,7 @@ func (s *Server) Handler() http.Handler { Session string `json:"session"` AsClient string `json:"as_client"` } - if err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/admin/impersonate", sess, + if err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/admin/impersonate", sess, map[string]string{"login": login, "contract": contract}, &out); err != nil { setFlashCookie(w, r, err.Error()) http.Redirect(w, r, "/clients", http.StatusSeeOther) @@ -902,7 +942,7 @@ func (s *Server) Handler() http.Handler { // Сессию просмотра гасим в ядре, а не бросаем: иначе выданные сотруднику // токены чужих кабинетов копились бы и жили ещё 12 часов. if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" { - _ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil) + _ = s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil) setSession(w, r, c.Value) } setFlag(w, r, "bj_lk_as", "") @@ -914,51 +954,40 @@ func (s *Server) Handler() http.Handler { // Проверка связи с ядром. Наружу отдаём только признак жизни: подробности // (внутренний адрес ядра, состояние базы) — администратору по ключу модуля. mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - full := secureEqual(r.Header.Get("X-BJ-Key"), s.cfg.BJKey) + cfg := s.conf() + full := secureEqual(r.Header.Get("X-BJ-Key"), cfg.BJKey) var out map[string]any - err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/ping", "", nil, &out) + err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/ping", "", nil, &out) w.Header().Set("Content-Type", "application/json; charset=utf-8") if err != nil { w.WriteHeader(http.StatusBadGateway) resp := map[string]any{"ok": false} if full { - resp["error"], resp["bj_url"], resp["mode"] = err.Error(), s.cfg.BJURL, s.mode() + resp["error"], resp["bj_url"], resp["mode"] = err.Error(), cfg.BJURL, s.mode() } _ = json.NewEncoder(w).Encode(resp) return } resp := map[string]any{"ok": true} if full { - resp["bj"], resp["bj_url"], resp["mode"] = out, s.cfg.BJURL, s.mode() + resp["bj"], resp["bj_url"], resp["mode"] = out, cfg.BJURL, s.mode() } _ = json.NewEncoder(w).Encode(resp) }) - if s.agent != nil { - s.registerAgent(mux) - } + s.registerAgent(mux) mux.Handle("/assets/", assetsHandler()) - return harden(mux) -} - -// notConfigured — кабинет запущен без адреса bj-server или ключа: подсказка вместо падения службы. -func (s *Server) notConfigured(w http.ResponseWriter, r *http.Request) { - const msg = "Личный кабинет не настроен: укажите адрес bj-server (BJ_URL) и ключ доступа модуля (BJ_KEY) в /etc/bj/lk.env, затем выполните systemctl restart bj-lk. Ключ задаётся в настройках bj-server." - if r.URL.Path == "/health" { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusServiceUnavailable) - _ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": msg}) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusServiceUnavailable) - fmt.Fprintf(w, `%s`+ - `

    %s

    %s

    `, - template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(msg)) + return harden(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.Configured() && !strings.HasPrefix(r.URL.Path, "/install") && !strings.HasPrefix(r.URL.Path, "/assets/") && r.URL.Path != "/health" { + http.Redirect(w, r, "/install", http.StatusSeeOther) + return + } + mux.ServeHTTP(w, r) + })) } // mode — режим связи с ядром, для страницы состояния и инструкций администратору. func (s *Server) mode() string { - if s.cfg.Agent { + if s.conf().Agent { return "agent" } return "direct" diff --git a/modules/lk/lk_test.go b/modules/lk/lk_test.go index 40a33ac..1422094 100644 --- a/modules/lk/lk_test.go +++ b/modules/lk/lk_test.go @@ -7,6 +7,8 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "strings" "testing" "time" @@ -81,6 +83,19 @@ func fakeBJ(t *testing.T) *httptest.Server { _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) } }) + mux.HandleFunc("/api/lk/admin/bootstrap", func(w http.ResponseWriter, r *http.Request) { + if !keyOK(w, r) { + return + } + var in struct{ Login, Password string } + _ = json.NewDecoder(r.Body).Decode(&in) + if in.Login == "" || len(in.Password) < 10 { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "пароль минимум 10 символов"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "message": "администратор создан"}) + }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv @@ -155,20 +170,94 @@ func TestAdminFlagPerRequest(t *testing.T) { } } +// Кабинет без настроек ведёт в веб-установщик, а /health честно отвечает, что связи нет. func TestNotConfigured(t *testing.T) { s, err := New(Config{BJURL: "http://127.0.0.1:8080"}) if err != nil { t.Fatal(err) } + s.SetDataDir(t.TempDir()) if s.Configured() { t.Fatal("без ключа кабинет не настроен") } - for _, p := range []string{"/", "/health"} { + h := s.Handler() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/install" { + t.Fatalf("/: код %d, Location %q — ожидали установщик", rec.Code, rec.Header().Get("Location")) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/install", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Проверить связь и сохранить") { + t.Fatalf("/install: код %d", rec.Code) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + if rec.Code != http.StatusBadGateway { + t.Fatalf("/health без настроек: код %d", rec.Code) + } +} + +// Веб-установщик целиком: проверка сервера → связь с ядром → администратор → файл настроек. +func TestInstaller(t *testing.T) { + bj := fakeBJ(t) + dir := t.TempDir() + s, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + s.SetDataDir(dir) + h := s.Handler() + csrf := csrfFromPage(t, h) + post := func(form url.Values) *httptest.ResponseRecorder { + form.Set("csrf", csrf.Value) + req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(csrf) rec := httptest.NewRecorder() - s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil)) - if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "BJ_KEY") { - t.Fatalf("%s: код %d: %s", p, rec.Code, rec.Body.String()) - } + h.ServeHTTP(rec, req) + return rec + } + // неверный ключ — настройки не сохраняются + rec := post(url.Values{"step": {"connect"}, "mode": {"direct"}, "bj_url": {bj.URL}, "bj_key": {"чужой"}}) + if !strings.Contains(rec.Body.String(), "Связь с ядром не установлена") || s.Configured() { + t.Fatalf("неверный ключ принят: %d", rec.Code) + } + // верный ключ — сохранено, шаг администратора + rec = post(url.Values{"step": {"connect"}, "mode": {"direct"}, "bj_url": {bj.URL}, "bj_key": {"lk-key"}, "title": {"Тест"}}) + if !strings.Contains(rec.Body.String(), "Администратор кабинета") || !s.Configured() { + t.Fatalf("связь не сохранена: %d %s", rec.Code, rec.Body.String()[:200]) + } + raw, err := os.ReadFile(filepath.Join(dir, configFileName)) + if err != nil || !strings.Contains(string(raw), `"bj_key": "lk-key"`) || !strings.Contains(string(raw), `"mode": "direct"`) { + t.Fatalf("файл настроек: %v %s", err, raw) + } + if st, _ := os.Stat(filepath.Join(dir, configFileName)); st.Mode().Perm() != 0o600 { + t.Fatalf("права файла настроек %o", st.Mode().Perm()) + } + // администратор: пароли не совпали → ошибка; совпали → ядро получило bootstrap + rec = post(url.Values{"step": {"admin"}, "login": {"admin"}, "password": {"Надёжный-пароль-1"}, "password2": {"другой"}}) + if !strings.Contains(rec.Body.String(), "Пароли не совпадают") { + t.Fatal("несовпадение паролей пропущено") + } + rec = post(url.Values{"step": {"admin"}, "login": {"admin"}, "password": {"Надёжный-пароль-1"}, "password2": {"Надёжный-пароль-1"}}) + if !strings.Contains(rec.Body.String(), "Кабинет установлен") { + t.Fatalf("завершение: %d %s", rec.Code, rec.Body.String()[:200]) + } + // установщик закрыт, настройки поднимаются из файла при новом запуске + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/install", nil)) + if !strings.Contains(rec.Body.String(), "уже настроен") { + t.Fatal("установщик остался открытым после установки") + } + cfg, err := LoadConfigFile(dir, Config{}) + if err != nil || cfg.BJKey != "lk-key" || cfg.BJURL != bj.URL || cfg.Agent || cfg.Title != "Тест" { + t.Fatalf("настройки из файла: %+v %v", cfg, err) + } + // окружение перекрывает файл + cfg, _ = LoadConfigFile(dir, Config{BJKey: "из-окружения"}) + if cfg.BJKey != "из-окружения" { + t.Fatal("окружение должно перекрывать файл") } } @@ -254,6 +343,10 @@ func csrfFromPage(t *testing.T, h http.Handler) *http.Cookie { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + if loc := rec.Header().Get("Location"); rec.Code == http.StatusSeeOther && loc != "" { + rec = httptest.NewRecorder() // кабинет не настроен: токен выдаёт установщик + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, loc, nil)) + } for _, c := range rec.Result().Cookies() { if c.Name == csrfCookie { return c diff --git a/modules/lk/templates.go b/modules/lk/templates.go index 1a7e3a1..b0ced13 100644 --- a/modules/lk/templates.go +++ b/modules/lk/templates.go @@ -303,6 +303,88 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px} {{else}}

    Поручений пока нет.

    {{end}} {{end}} +{{define "install"}}{{template "head" .}}{{$d := .D}} +
    +
    + {{template "mark" .}}Установка кабинета +
    +

    Три шага — и кабинет работает

    +
    + 01Проверка сервера{{if gt $d.Step 1}} — готово{{end}} + 02Связь с ядром депозитария{{if gt $d.Step 2}} — готово{{end}} + 03Администратор кабинета +
    +
    +

    Кабинет ничего не хранит у себя: пользователи, пароли и данные клиентов живут в ядре. Здесь сохраняется только способ связи с ним.

    +
    +
    +
    + {{if $d.Error}}
    {{$d.Error}}
    {{end}} + {{if $d.Notice}}
    {{$d.Notice}}
    {{end}} + + {{if lt $d.Step 3}} +

    Сервер и связь с ядром

    +
    + {{range $d.Checks}}
    {{if .OK}}✓{{else}}✗{{end}}{{.Name}}. {{.Note}}
    {{end}} +
    + + {{if $d.Waiting}} + +
    Настройки сохранены. Ждём, когда ядро подключится к кабинету по адресу {{$d.Host}}… Страница обновляется сама.
    +

    В настройках ядра (раздел «Личный кабинет») выберите «Ядро подключается к кабинету» и укажите этот адрес. Ключ должен совпадать.

    + + {{else}} + +
    + + +
    +
    + + + +
    +
    + + +
    +
    + + +

    Создаётся в настройках ядра: «Личный кабинет» → «Создать ключ».

    +
    + + {{end}} + + {{else}} +

    Администратор кабинета

    +

    Сотрудник депозитария, который входит в кабинет, видит список клиентов и может открыть кабинет клиента на просмотр. Учётная запись заводится в ядре. Если администратор уже есть — шаг можно пропустить.

    + +
    +
    +
    +
    +
    +
    + + +
    + {{end}} +
    +
    +
    +{{end}} + +{{define "install_done"}}{{template "head" .}} +
    {{template "mark" .}}Установка кабинета

    Кабинет установлен

    Настройки — в файле {{.File}}. Чтобы пройти установку заново, удалите его.

    +

    Готово

    Клиенты входят по логину и паролю, которые выдаёт депозитарий. Учётные записи заводятся в настройках ядра, раздел «Личный кабинет».

    +{{end}} + +{{define "installed"}}{{template "head" .}} +
    {{template "mark" .}}Установка кабинета

    Кабинет уже настроен

    +

    Установщик закрыт: настройки сохранены в {{.File}}. Чтобы изменить их, отредактируйте файл или удалите его и откройте эту страницу снова.

    +{{end}} + {{define "profile"}}{{template "head" .}}{{template "nav" .}}
    Профиль