Compare commits
35 Commits
a6f11d6689
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b18579956 | |||
| 26785ff3ba | |||
| f057b89d2e | |||
| ea869665df | |||
| 1f12167946 | |||
| 10e765b5b1 | |||
| 893e1222ab | |||
| 1fbc5d2375 | |||
| a9dc14969b | |||
| 634244ee28 | |||
| 1d0f904010 | |||
| e4de1ab4d8 | |||
| 627dbca6af | |||
| 698956092b | |||
| 059206348f | |||
| 28c0ed7349 | |||
| 6a2e9b7122 | |||
| 2601d29018 | |||
| e4e262de9e | |||
| f35c72c50a | |||
| cfd6dfa1e8 | |||
| 7288c86fba | |||
| d47229c9a5 | |||
| 4b62d00ac2 | |||
| be0081c139 | |||
| 42f5883b4a | |||
| 7c653a2490 | |||
| 6fee3c5ff7 | |||
| 97e99b72c7 | |||
| 5527a464ca | |||
| d8dee07213 | |||
| 4135435b51 | |||
| 8099e50ab2 | |||
| 2908697363 | |||
| 5115ed7223 |
@@ -75,3 +75,36 @@ near-black в тёмной теме (настоящее рабочее мест
|
||||
доступность). Постранично ещё не подняты: `admin_setup`, `admin_help_*`,
|
||||
`admin_wizard`, `admin_claim` — они наследуют систему, но их частная композиция
|
||||
(плотные формы, help-простыни) ждёт отдельного прохода.
|
||||
|
||||
## Личный кабинет клиента — мир «Табло» (17.09.2026)
|
||||
|
||||
Клиентский ЛК (`modules/lk`, служба `bj-lk`) получает собственную поверхность
|
||||
внутри той же системы. Дизайн-холст: https://claude.ai/artifact/NAW1ak8cGgZDyXjy35p9Av
|
||||
|
||||
**THESIS.** Кабинет — табло отправлений: бумаги куда-то едут, клиент читает
|
||||
состояние и расписание. Отказ от ряда метрик-плиток, одинаковых карточек как
|
||||
структуры страницы и серифного display-шрифта.
|
||||
|
||||
**OWN-WORLD.** Графитовая доска сверху (`#191A1C` шапка, `#1F2124` полоса
|
||||
состояния, отбивка `#E8A33D` 3px), светлая бумага реестра `#EDEBE4`, лист формы
|
||||
`#F7F5EF`. Янтарь `#E8A33D` — «в пути» и первичное действие; зелёный `#1F6B3E`
|
||||
— свободно/исполнено; красный `#A32D22` — отказ; интерактив — индиго `#2C46C0`.
|
||||
Волосяные линейки `#DED8CA` вместо рамок, радиус 2–3px (не 12).
|
||||
|
||||
**Типографика.** Две гарнитуры: Golos Text (интерфейс, кириллица) и JetBrains
|
||||
Mono (все величины, коды, подписи-заголовки `.lbl` — заглавные с трекингом
|
||||
0.1em). Величины всегда `font-variant-numeric: tabular-nums`. Крупная величина
|
||||
на доске 38–62px, шкала интерфейса 13–26px.
|
||||
|
||||
**Композиция.** Шапка 58px: знак (два узла + мост) · разделы · янтарная кнопка
|
||||
«Поручение M2M» (точка входа с каждого экрана) · договор · выход. Ниже — полоса
|
||||
состояния. Рабочая часть — таблицы с группировкой по счетам депо, состояние в
|
||||
правом столбце чипом.
|
||||
|
||||
**Движение.** Один авторский момент — «перекидывание шторки» (`.live`,
|
||||
`@keyframes flip`, 420ms) на чипе состояния, который только что изменился;
|
||||
отключается при `prefers-reduced-motion`.
|
||||
|
||||
**Роли.** Вход клиента и вход сотрудника — один экран с переключателем.
|
||||
Сотрудник в кабинете клиента только готовит поручение, подпись кодом из СМС —
|
||||
за клиентом; вход сотрудника виден клиенту в «Безопасности».
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Command bj-component-install — root-установщик компонентов bj (независимые обновления).
|
||||
//
|
||||
// bj-server работает без прав root: он скачивает подписанный манифест релиза и выбранные файлы в
|
||||
// каталог подготовки и просит root-воркер (bj-keymedia-worker, действие component-install) вызвать
|
||||
// этот инструмент. Проверка и установка — release.InstallStaged: подпись манифеста ключом из
|
||||
// /etc/bj/update.pub (принадлежит root), sha256 файлов, только известные компоненты, без отката,
|
||||
// копия прежнего файла, перезапуск служб (ядро последним), записи в /var/lib/bj/.bj/components.json.
|
||||
// Выделенным из ядра модулям, которые ставятся впервые, ставит и включает unit-файл службы из комплекта.
|
||||
// Вывод — JSON для bj-server.
|
||||
//
|
||||
// bj-component-install <каталог подготовки>
|
||||
// bj-component-install -root /tmp/песочница -pub test.pub -no-restart <каталог> (проверка на копии)
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/deploy"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
||||
)
|
||||
|
||||
// version — версия установщика (go build -ldflags "-X main.version=…").
|
||||
var version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
fs := flag.NewFlagSet("bj-component-install", flag.ExitOnError)
|
||||
root := fs.String("root", "", "корень установки (для проверки в песочнице; по умолчанию /)")
|
||||
pubPath := fs.String("pub", "/etc/bj/update.pub", "открытый ключ подписи релизов (base64 Ed25519), файл root")
|
||||
noRestart := fs.Bool("no-restart", false, "не перезапускать службы")
|
||||
showVersion := fs.Bool("version", false, "показать версию")
|
||||
_ = fs.Parse(os.Args[1:])
|
||||
if *showVersion {
|
||||
fmt.Println("bj-component-install", version)
|
||||
return
|
||||
}
|
||||
var res release.InstallResult
|
||||
if pub, err := release.LoadPublicKey(*pubPath); err != nil {
|
||||
res.Error = fmt.Sprintf("открытый ключ обновлений (%s): %v", *pubPath, err)
|
||||
} else {
|
||||
res = release.InstallStaged(fs.Arg(0), release.InstallOptions{Root: *root, PubKey: pub, Restart: !*noRestart, Units: deploy.Files})
|
||||
}
|
||||
b, _ := json.Marshal(res)
|
||||
fmt.Println(string(b))
|
||||
if !res.OK {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
+839
-82
@@ -1,111 +1,868 @@
|
||||
// Package main — bj-installer.
|
||||
// Command bj-installer — установка bj на Debian 13 или Astra Linux.
|
||||
//
|
||||
// Web-инсталлятор для bj-server: на машине клиента после установки
|
||||
// Debian/Astra поднимает локальный HTTP на 127.0.0.1:8181, проводит
|
||||
// через 5-страничный wizard (welcome → precheck → config → install → done)
|
||||
// и за кадром выполняет 20+ шагов установки Валидаты + bj-server + ИШ.
|
||||
// curl -fsSL https://lic.zetit.ru/install.sh | sudo bash -s -- КЛЮЧ-ПРОДУКТА
|
||||
// sudo bj-installer install --code BJ-XXXX-XXXX-XXXX [--server https://lic.zetit.ru] [--channel stable]
|
||||
// sudo bj-installer install --dry-run только проверка системы, без изменений
|
||||
// sudo bj-installer first-run-code новый одноразовый код первичной настройки
|
||||
// bj-installer version
|
||||
//
|
||||
// Прогресс шагов прилетает в UI через Server-Sent Events. Каждый шаг
|
||||
// идемпотентен — можно повторно запускать инсталлятор на уже настроенной
|
||||
// машине, он пропустит то, что сделано.
|
||||
// Установка идёт в консоли, без графической среды: проверка системы, пакеты, пользователь и каталоги,
|
||||
// активация лицензии, база PostgreSQL, компоненты bj по лицензии (подпись релиза и контрольные суммы
|
||||
// проверяются), root-скрипты и службы. В конце на экране — адрес первичной настройки и одноразовый код:
|
||||
// дальше настройка в браузере, в самом bj.
|
||||
//
|
||||
// Запуск: sudo ./bj-installer [--addr 127.0.0.1:8181] [--no-browser]
|
||||
// Артефакты ожидаются рядом с бинарём в каталоге ./artifacts/:
|
||||
//
|
||||
// artifacts/ClientL_Other/zpki-*.deb
|
||||
// artifacts/ClientL_Other/zsdk-*.deb
|
||||
// artifacts/bj-server (Go-бинарь)
|
||||
// artifacts/crypto-service.jar (Java-сайдкар)
|
||||
// artifacts/ish/igate_*.deb (ИШ НРД)
|
||||
// Проверка без чистого сервера: --steps license,components --root /tmp/песочница --server http://127.0.0.1:18091
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/deploy"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/migrations"
|
||||
)
|
||||
|
||||
const banner = `
|
||||
======================================================================
|
||||
bj-installer — мастер установки Bridge-and-Join-s
|
||||
======================================================================
|
||||
`
|
||||
// installerVersion — версия установщика (go build -ldflags "-X main.installerVersion=…").
|
||||
var installerVersion = "0.1.0"
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "127.0.0.1:8181", "адрес web-инсталлятора")
|
||||
noBrowser := flag.Bool("no-browser", false, "не пытаться открыть браузер автоматически")
|
||||
artifactsDir := flag.String("artifacts", "./artifacts", "каталог с дистрибутивами (Validata deb, bj-server, ish)")
|
||||
flag.Parse()
|
||||
const (
|
||||
setupFile = "/var/lib/bj/.bj/setup.json"
|
||||
usersFile = "/var/lib/bj/.bj/users.json"
|
||||
firstRunFile = "/var/lib/bj/.bj/first-run.json"
|
||||
bjPort = 8080
|
||||
)
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Fprintln(os.Stderr, "Установщик должен быть запущен от root (sudo).")
|
||||
os.Exit(1)
|
||||
}
|
||||
type installer struct {
|
||||
server, channel, code string
|
||||
root string // префикс файловой системы (песочница)
|
||||
dryRun bool
|
||||
steps map[string]bool
|
||||
out *console
|
||||
|
||||
fmt.Print(banner)
|
||||
fmt.Printf(" адрес: http://%s\n", *addr)
|
||||
fmt.Printf(" артефакты: %s\n", *artifactsDir)
|
||||
fmt.Println("======================================================================")
|
||||
|
||||
st := newState(*artifactsDir)
|
||||
|
||||
srv := newServer(st)
|
||||
httpSrv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: srv,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// SIGINT/SIGTERM → корректный shutdown
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("HTTP-сервер упал: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
url := "http://" + *addr
|
||||
log.Printf("Откройте в браузере: %s", url)
|
||||
if !*noBrowser {
|
||||
tryOpenBrowser(url)
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("Завершаем работу...")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = httpSrv.Shutdown(shutdownCtx)
|
||||
token string
|
||||
lic *license.License
|
||||
setupCode string // одноразовый код первичной настройки (итоговый экран)
|
||||
shown bool
|
||||
}
|
||||
|
||||
// tryOpenBrowser — без фанатизма. Если xdg-open/sensible-browser есть и
|
||||
// $DISPLAY поднят (xrdp, Fly DE) — откроем. Иначе пользователь увидит URL
|
||||
// в выводе и перейдёт сам с другого компа (типичный сценарий headless).
|
||||
func tryOpenBrowser(url string) {
|
||||
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||
return
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
}
|
||||
var bin string
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
for _, cand := range []string{"xdg-open", "sensible-browser", "x-www-browser"} {
|
||||
if p, err := exec.LookPath(cand); err == nil {
|
||||
bin = p
|
||||
break
|
||||
switch os.Args[1] {
|
||||
case "install":
|
||||
cmdInstall(os.Args[2:])
|
||||
case "first-run-code":
|
||||
cmdFirstRunCode(os.Args[2:])
|
||||
case "version":
|
||||
fmt.Println("bj-installer", installerVersion)
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "bj-installer install --code BJ-XXXX-XXXX-XXXX [--server https://lic.zetit.ru] [--channel stable] [--dry-run]")
|
||||
fmt.Fprintln(os.Stderr, "bj-installer first-run-code")
|
||||
fmt.Fprintln(os.Stderr, "bj-installer version")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func cmdInstall(args []string) {
|
||||
fl := flag.NewFlagSet("install", flag.ExitOnError)
|
||||
in := &installer{out: newConsole()}
|
||||
fl.StringVar(&in.code, "code", "", "ключ продукта (код активации)")
|
||||
fl.StringVar(&in.server, "server", "https://lic.zetit.ru", "сервер лицензий и обновлений")
|
||||
fl.StringVar(&in.channel, "channel", "stable", "канал обновлений")
|
||||
fl.BoolVar(&in.dryRun, "dry-run", false, "только проверить систему")
|
||||
stepsFlag := fl.String("steps", "", "только эти шаги через запятую (для проверки): precheck,packages,system,license,database,components,services,firstrun")
|
||||
fl.StringVar(&in.root, "root", "", "корень файловой системы для проверки в песочнице")
|
||||
_ = fl.Parse(args)
|
||||
in.server = strings.TrimRight(in.server, "/")
|
||||
if *stepsFlag != "" {
|
||||
in.steps = map[string]bool{}
|
||||
for _, s := range strings.Split(*stepsFlag, ",") {
|
||||
in.steps[strings.TrimSpace(s)] = true
|
||||
}
|
||||
}
|
||||
in.out.banner()
|
||||
if err := in.run(); err != nil {
|
||||
in.out.failure(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// P — путь с учётом песочницы.
|
||||
func (in *installer) P(path string) string {
|
||||
if in.root == "" {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(in.root, path)
|
||||
}
|
||||
|
||||
type step struct {
|
||||
id, title string
|
||||
run func() (string, error)
|
||||
}
|
||||
|
||||
func (in *installer) run() error {
|
||||
if in.root == "" && !in.dryRun && os.Geteuid() != 0 {
|
||||
return errors.New("установщик запускается от root: sudo bj-installer install …")
|
||||
}
|
||||
if !in.dryRun && in.steps == nil && strings.TrimSpace(in.code) == "" {
|
||||
return errors.New("не указан ключ продукта: bj-installer install --code BJ-XXXX-XXXX-XXXX (он есть в портале клиента https://lic.zetit.ru/portal/)")
|
||||
}
|
||||
all := []step{
|
||||
{"precheck", "Проверка системы", in.stepPrecheck},
|
||||
{"packages", "Системные пакеты", in.stepPackages},
|
||||
{"system", "Пользователь bj и каталоги", in.stepSystem},
|
||||
{"license", "Активация лицензии", in.stepLicense},
|
||||
{"database", "База данных PostgreSQL", in.stepDatabase},
|
||||
{"components", "Компоненты bj по лицензии", in.stepComponents},
|
||||
{"services", "Скрипты и службы", in.stepServices},
|
||||
{"firstrun", "Запуск и код первичной настройки", in.stepFirstRun},
|
||||
}
|
||||
var todo []step
|
||||
for _, s := range all {
|
||||
switch {
|
||||
case in.dryRun && s.id != "precheck":
|
||||
case in.steps != nil && !in.steps[s.id]:
|
||||
default:
|
||||
todo = append(todo, s)
|
||||
}
|
||||
}
|
||||
for i, s := range todo {
|
||||
in.out.step(i+1, len(todo), s.title)
|
||||
detail, err := s.run()
|
||||
if err != nil {
|
||||
in.out.stepFail()
|
||||
return fmt.Errorf("%s: %w", s.title, err)
|
||||
}
|
||||
in.out.stepOK(detail)
|
||||
}
|
||||
if in.dryRun {
|
||||
in.out.note("Проверка пройдена. Для установки запустите без --dry-run и с ключом продукта.")
|
||||
}
|
||||
if in.shown {
|
||||
in.out.done(in.setupCode, in.lic)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- шаги ----
|
||||
|
||||
func osRelease() map[string]string {
|
||||
m := map[string]string{}
|
||||
b, err := os.ReadFile("/etc/os-release")
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if ok {
|
||||
m[k] = strings.Trim(v, `"`)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (in *installer) stepPrecheck() (string, error) {
|
||||
osr := osRelease()
|
||||
id, ver, like := osr["ID"], osr["VERSION_ID"], osr["ID_LIKE"]
|
||||
name := pick(osr["PRETTY_NAME"], id+" "+ver)
|
||||
switch {
|
||||
case id == "debian" && strings.HasPrefix(ver, "13"):
|
||||
case id == "astra" || strings.Contains(like, "astra") || strings.Contains(strings.ToLower(name), "astra"):
|
||||
default:
|
||||
return "", fmt.Errorf("поддерживаются Debian 13 и Astra Linux, а на сервере %s", name)
|
||||
}
|
||||
if runtime.GOARCH != "amd64" {
|
||||
return "", fmt.Errorf("нужна архитектура x86_64, а на сервере %s", runtime.GOARCH)
|
||||
}
|
||||
if _, err := os.Stat("/run/systemd/system"); err != nil {
|
||||
return "", errors.New("systemd не запущен")
|
||||
}
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs("/", &st); err == nil {
|
||||
if free := st.Bavail * uint64(st.Bsize); free < 10<<30 {
|
||||
return "", fmt.Errorf("на диске свободно %.1f ГБ, нужно не меньше 10 ГБ", float64(free)/(1<<30))
|
||||
}
|
||||
}
|
||||
mem := memTotal()
|
||||
if mem > 0 && mem < 1800<<20 {
|
||||
return "", fmt.Errorf("памяти %.1f ГБ, нужно не меньше 2 ГБ", float64(mem)/(1<<30))
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, in.server+"/healthz", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("нет связи с %s: %v", in.server, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return fmt.Sprintf("%s, %.1f ГБ памяти, связь с сервером лицензий есть", name, float64(mem)/(1<<30)), nil
|
||||
}
|
||||
|
||||
func memTotal() uint64 {
|
||||
b, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 2 {
|
||||
kb, _ := strconv.ParseUint(f[1], 10, 64)
|
||||
return kb << 10
|
||||
}
|
||||
}
|
||||
}
|
||||
if bin == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (in *installer) stepPackages() (string, error) {
|
||||
pkgs := []string{"ca-certificates", "curl", "postgresql", "postgresql-client", "default-jre-headless", "pcscd", "python3"}
|
||||
env := append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
|
||||
if out, err := runEnv(env, "apt-get", "update", "-qq"); err != nil {
|
||||
return "", fmt.Errorf("apt-get update: %v\n%s", err, tail(out, 8))
|
||||
}
|
||||
args := append([]string{"install", "-y", "-qq", "--no-install-recommends"}, pkgs...)
|
||||
if out, err := runEnv(env, "apt-get", args...); err != nil {
|
||||
return "", fmt.Errorf("apt-get install: %v\n%s", err, tail(out, 12))
|
||||
}
|
||||
return strings.Join(pkgs, ", "), nil
|
||||
}
|
||||
|
||||
func (in *installer) stepSystem() (string, error) {
|
||||
if in.root == "" {
|
||||
if _, err := user.Lookup("bj"); err != nil {
|
||||
if out, err := run("useradd", "--system", "--home-dir", "/var/lib/bj", "--no-create-home", "--shell", "/usr/sbin/nologin", "bj"); err != nil {
|
||||
return "", fmt.Errorf("useradd: %v %s", err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs := []struct {
|
||||
path string
|
||||
mode os.FileMode
|
||||
bjOwn bool
|
||||
}{
|
||||
{"/opt/bj", 0o755, false}, {"/etc/bj", 0o755, false},
|
||||
{"/var/lib/bj", 0o750, true}, {"/var/lib/bj/.bj", 0o700, true}, {"/var/log/bj", 0o750, true},
|
||||
{"/var/lib/bj/keymedia-requests", 0o770, true}, {"/var/lib/bj/update-staging", 0o750, true},
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(in.P(d.path), d.mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = os.Chmod(in.P(d.path), d.mode)
|
||||
if d.bjOwn {
|
||||
in.chownBJ(in.P(d.path))
|
||||
}
|
||||
}
|
||||
return "пользователь bj, /opt/bj, /var/lib/bj, /var/log/bj", nil
|
||||
}
|
||||
|
||||
func (in *installer) chownBJ(path string) {
|
||||
if in.root != "" {
|
||||
return
|
||||
}
|
||||
_ = exec.Command(bin, url).Start()
|
||||
u, err := user.Lookup("bj")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
uid, _ := strconv.Atoi(u.Uid)
|
||||
gid, _ := strconv.Atoi(u.Gid)
|
||||
_ = os.Chown(path, uid, gid)
|
||||
}
|
||||
|
||||
// ---- настройки bj (setup.json) ----
|
||||
|
||||
func (in *installer) loadSetup() map[string]any {
|
||||
m := map[string]any{}
|
||||
if b, err := os.ReadFile(in.P(setupFile)); err == nil {
|
||||
_ = json.Unmarshal(b, &m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (in *installer) saveSetup(m map[string]any) error {
|
||||
b, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := in.P(setupFile)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(p+".tmp", b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
in.chownBJ(p + ".tmp")
|
||||
return os.Rename(p+".tmp", p)
|
||||
}
|
||||
|
||||
func section(m map[string]any, key string) map[string]any {
|
||||
if s, ok := m[key].(map[string]any); ok {
|
||||
return s
|
||||
}
|
||||
s := map[string]any{}
|
||||
m[key] = s
|
||||
return s
|
||||
}
|
||||
|
||||
func newUUID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
b[6] = b[6]&0x0f | 0x40
|
||||
b[8] = b[8]&0x3f | 0x80
|
||||
h := hex.EncodeToString(b)
|
||||
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
|
||||
}
|
||||
|
||||
func machineHash() string {
|
||||
b, err := os.ReadFile("/etc/machine-id")
|
||||
if err != nil || strings.TrimSpace(string(b)) == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(string(b))))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (in *installer) stepLicense() (string, error) {
|
||||
setup := in.loadSetup()
|
||||
lic := section(setup, "license")
|
||||
installID, _ := lic["install_id"].(string)
|
||||
if len(installID) != 36 {
|
||||
installID = newUUID()
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
body, _ := json.Marshal(map[string]string{"code": strings.TrimSpace(in.code), "install_id": installID,
|
||||
"hostname": host, "version": "bj-installer " + installerVersion, "machine": machineHash()})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, in.server+"/v1/activate", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("сервер лицензий недоступен: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var reply struct {
|
||||
Token string `json:"token"`
|
||||
Tenant string `json:"tenant"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &reply)
|
||||
if resp.StatusCode != http.StatusOK || reply.Token == "" {
|
||||
return "", fmt.Errorf("ключ продукта не принят: %s", pick(reply.Message, fmt.Sprintf("HTTP %d", resp.StatusCode)))
|
||||
}
|
||||
pub, err := license.ParsePublicKey(license.DefaultPublicKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tok, err := license.DecodeToken(reply.Token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
l, err := license.Verify(tok, pub)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("сервер вернул недействительный ключ: %v", err)
|
||||
}
|
||||
if !strings.EqualFold(l.InstallID, installID) {
|
||||
return "", errors.New("сервер вернул ключ для другой установки")
|
||||
}
|
||||
in.token, in.lic = reply.Token, l
|
||||
now := time.Now().UTC()
|
||||
lic["key"], lic["install_id"] = reply.Token, installID
|
||||
lic["last_sync"], lic["last_attempt"], lic["last_ok"] = now, now, true
|
||||
lic["last_msg"] = "активирована установщиком: ключ до " + l.ExpiresAt.Local().Format("02.01.2006")
|
||||
if err := in.saveSetup(setup); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s, план %s, модули: %s", pick(reply.Tenant, l.Tenant), l.Plan, featuresText(l)), nil
|
||||
}
|
||||
|
||||
func featuresText(l *license.License) string {
|
||||
if l.Plan == license.PlanEnterprise {
|
||||
return "все"
|
||||
}
|
||||
if len(l.Features) == 0 {
|
||||
return "только ядро"
|
||||
}
|
||||
return strings.Join(l.Features, ", ")
|
||||
}
|
||||
|
||||
// ensureToken — ключ из настроек, если шаг активации в этом запуске не выполнялся.
|
||||
func (in *installer) ensureToken() error {
|
||||
if in.token != "" {
|
||||
return nil
|
||||
}
|
||||
key, _ := section(in.loadSetup(), "license")["key"].(string)
|
||||
if key == "" {
|
||||
return errors.New("лицензия не активирована: запустите установку с ключом продукта")
|
||||
}
|
||||
pub, _ := license.ParsePublicKey(license.DefaultPublicKey)
|
||||
tok, err := license.DecodeToken(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := license.Verify(tok, pub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.token, in.lic = key, l
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *installer) stepDatabase() (string, error) {
|
||||
setup := in.loadSetup()
|
||||
pg := section(setup, "postgres")
|
||||
if dsn, _ := pg["dsn"].(string); dsn != "" {
|
||||
if err := applyMigrations(dsn); err == nil {
|
||||
return "база уже настроена, схема обновлена", nil
|
||||
}
|
||||
}
|
||||
if out, err := run("systemctl", "enable", "--now", "postgresql"); err != nil {
|
||||
return "", fmt.Errorf("запуск PostgreSQL: %v %s", err, out)
|
||||
}
|
||||
if err := waitFor(60*time.Second, func() bool { _, err := psqlAdmin("SELECT 1"); return err == nil }); err != nil {
|
||||
return "", errors.New("PostgreSQL не отвечает")
|
||||
}
|
||||
pw := randomString(28)
|
||||
exists, _ := psqlAdmin("SELECT 1 FROM pg_roles WHERE rolname='bj'")
|
||||
verb := "CREATE ROLE"
|
||||
if strings.TrimSpace(exists) == "1" {
|
||||
verb = "ALTER ROLE"
|
||||
}
|
||||
if _, err := psqlAdmin(verb + " bj WITH LOGIN PASSWORD '" + pw + "'"); err != nil {
|
||||
return "", fmt.Errorf("роль bj: %v", err)
|
||||
}
|
||||
if db, _ := psqlAdmin("SELECT 1 FROM pg_database WHERE datname='bj'"); strings.TrimSpace(db) != "1" {
|
||||
if _, err := psqlAdmin("CREATE DATABASE bj OWNER bj"); err != nil {
|
||||
return "", fmt.Errorf("база bj: %v", err)
|
||||
}
|
||||
}
|
||||
dsn := "postgres://bj:" + pw + "@127.0.0.1:5432/bj?sslmode=disable"
|
||||
if err := applyMigrations(dsn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
pg["dsn"] = dsn
|
||||
if err := in.saveSetup(setup); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "база bj, пользователь bj, схема m2m_core", nil
|
||||
}
|
||||
|
||||
func psqlAdmin(sql string) (string, error) {
|
||||
return run("runuser", "-u", "postgres", "--", "psql", "-X", "-tAq", "-v", "ON_ERROR_STOP=1", "-c", sql)
|
||||
}
|
||||
|
||||
func applyMigrations(dsn string) error {
|
||||
names, err := fs.Glob(migrations.M2MCore, "m2m-core/*.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
sqlText, _ := migrations.M2MCore.ReadFile(n)
|
||||
cmd := exec.Command("psql", "-X", "-q", "-v", "ON_ERROR_STOP=1", dsn)
|
||||
cmd.Stdin = bytes.NewReader(sqlText)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("миграция %s: %v\n%s", filepath.Base(n), err, tail(string(out), 6))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *installer) licensed(feature string) bool {
|
||||
return feature == "" || feature == "updates" || in.lic.HasFeature(feature)
|
||||
}
|
||||
|
||||
// unitTemplate — есть ли в установщике unit-файл для службы компонента.
|
||||
func unitTemplate(unit string) bool {
|
||||
if unit == "" {
|
||||
return true
|
||||
}
|
||||
_, err := fs.Stat(deploy.Files, "systemd/"+unit+".service")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (in *installer) stepComponents() (string, error) {
|
||||
if err := in.ensureToken(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
pub, err := release.ParsePublicKey(release.DefaultPublicKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cl, err := release.NewClient(in.server+"/updates", in.channel, release.DefaultPublicKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cl.Token = in.token
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
sm, m, err := cl.FetchSigned(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("релиз %s: %v", in.channel, err)
|
||||
}
|
||||
staging := filepath.Join(in.P(release.StagingBase), fmt.Sprintf("install-%d", time.Now().UnixNano()))
|
||||
if err := os.MkdirAll(staging, 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
var names, skipped []string
|
||||
for _, c := range release.Components {
|
||||
var art *release.Artifact
|
||||
for i := range m.Artifacts {
|
||||
if m.Artifacts[i].Name == c.Name {
|
||||
art = &m.Artifacts[i]
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case art == nil:
|
||||
case !c.Core && !in.licensed(c.Feature):
|
||||
case !unitTemplate(c.Unit):
|
||||
skipped = append(skipped, c.Title)
|
||||
default:
|
||||
if _, err := cl.DownloadArtifact(ctx, *art, staging); err != nil {
|
||||
return "", fmt.Errorf("скачивание %s: %v", c.Name, err)
|
||||
}
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "", fmt.Errorf("в релизе %s нет компонентов для этой лицензии", m.Version)
|
||||
}
|
||||
b, _ := json.Marshal(sm)
|
||||
_ = os.WriteFile(filepath.Join(staging, "manifest.json"), b, 0o640)
|
||||
rb, _ := json.Marshal(map[string][]string{"names": names})
|
||||
_ = os.WriteFile(filepath.Join(staging, "request.json"), rb, 0o640)
|
||||
res := release.InstallStaged(staging, release.InstallOptions{Root: in.root, PubKey: pub, AllowNew: true})
|
||||
if !res.OK {
|
||||
return "", errors.New(res.Error)
|
||||
}
|
||||
if err := os.WriteFile(in.P("/etc/bj/update.pub"), []byte(release.DefaultPublicKey+"\n"), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var list []string
|
||||
for _, x := range res.Installed {
|
||||
list = append(list, x["name"]+" "+x["version"])
|
||||
}
|
||||
detail := "релиз " + m.Version + ": " + strings.Join(list, ", ")
|
||||
if len(skipped) > 0 {
|
||||
detail += "; модули " + strings.Join(skipped, ", ") + " установятся позже, из раздела «Обновления»"
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (in *installer) stepServices() (string, error) {
|
||||
var files []string
|
||||
err := fs.WalkDir(deploy.Files, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
body, _ := deploy.Files.ReadFile(p)
|
||||
switch {
|
||||
case strings.HasPrefix(p, "linux/sbin/"):
|
||||
dst := in.P("/usr/local/sbin/" + filepath.Base(p))
|
||||
if err := writeFile(dst, body, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, filepath.Base(p))
|
||||
case strings.HasPrefix(p, "systemd/"):
|
||||
if err := writeFile(in.P("/etc/systemd/system/"+filepath.Base(p)), body, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, filepath.Base(p))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
enable := []string{"enable", "bj-keymedia.service", "bj-server.service"}
|
||||
// службы установленных модулей (bj-npr и т. п.), для которых есть unit-файл
|
||||
for name := range release.LoadInstalled(in.P(release.InstalledFile)) {
|
||||
if c, ok := release.ComponentByName(name); ok && !c.Core && c.Unit != "" && unitTemplate(c.Unit) {
|
||||
enable = append(enable, c.Unit+".service")
|
||||
}
|
||||
}
|
||||
if in.root == "" {
|
||||
for _, c := range [][]string{{"daemon-reload"}, enable} {
|
||||
if out, err := run("systemctl", c...); err != nil {
|
||||
return "", fmt.Errorf("systemctl %s: %v %s", strings.Join(c, " "), err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(files, ", ") + "; криптосервис включается после настройки ключей в bj", nil
|
||||
}
|
||||
|
||||
func (in *installer) stepFirstRun() (string, error) {
|
||||
code, err := in.newFirstRunCode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if in.root == "" {
|
||||
units := []string{"bj-keymedia", "bj-server"}
|
||||
for name := range release.LoadInstalled(in.P(release.InstalledFile)) {
|
||||
if c, ok := release.ComponentByName(name); ok && !c.Core && c.Unit != "" && unitTemplate(c.Unit) {
|
||||
units = append(units, c.Unit)
|
||||
}
|
||||
}
|
||||
for _, unit := range units {
|
||||
if out, err := run("systemctl", "restart", unit); err != nil {
|
||||
return "", fmt.Errorf("запуск %s: %v %s", unit, err, out)
|
||||
}
|
||||
}
|
||||
if err := waitFor(90*time.Second, func() bool {
|
||||
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/healthz", bjPort))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}); err != nil {
|
||||
return "", errors.New("bj-server не ответил за 90 секунд: journalctl -u bj-server")
|
||||
}
|
||||
}
|
||||
in.setupCode, in.shown = code, true
|
||||
if in.root != "" {
|
||||
return "проверка в песочнице: службы не запускались", nil
|
||||
}
|
||||
return "bj-server работает", nil
|
||||
}
|
||||
|
||||
// newFirstRunCode — одноразовый код первичной настройки (если пользователей ещё нет).
|
||||
func (in *installer) newFirstRunCode() (string, error) {
|
||||
if b, err := os.ReadFile(in.P(usersFile)); err == nil && bytes.Contains(b, []byte(`"login"`)) {
|
||||
return "", nil // bj уже настроен: вход по учётным записям
|
||||
}
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(100000000))
|
||||
digits := fmt.Sprintf("%08d", n.Int64())
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(digits), 12)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{"code_hash": string(hash), "created_at": time.Now().UTC()})
|
||||
if err := writeFile(in.P(firstRunFile), b, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.chownBJ(in.P(firstRunFile))
|
||||
code := digits[:4] + "-" + digits[4:]
|
||||
url := setupURLs()
|
||||
issue := fmt.Sprintf("Bridge&Join, первичная настройка: откройте в браузере %s/first-run и введите код %s\n\n", firstOr(url, "http://<адрес сервера>:8080"), code)
|
||||
_ = writeFile(in.P("/etc/issue.d/bj-setup.issue"), []byte(issue), 0o644)
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func cmdFirstRunCode(args []string) {
|
||||
in := &installer{out: newConsole()}
|
||||
if os.Geteuid() != 0 {
|
||||
in.out.failure(errors.New("запустите от root: sudo bj-installer first-run-code"))
|
||||
os.Exit(1)
|
||||
}
|
||||
code, err := in.newFirstRunCode()
|
||||
if err != nil {
|
||||
in.out.failure(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if code == "" {
|
||||
in.out.note("В bj уже есть пользователи: первичная настройка выполнена, входите своей учётной записью.")
|
||||
return
|
||||
}
|
||||
_ = in.ensureToken()
|
||||
in.out.done(code, in.lic)
|
||||
}
|
||||
|
||||
// ---- вспомогательное ----
|
||||
|
||||
func setupURLs() []string {
|
||||
var out []string
|
||||
ifaces, _ := net.Interfaces()
|
||||
for _, ifc := range ifaces {
|
||||
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, _ := ifc.Addrs()
|
||||
for _, a := range addrs {
|
||||
if ipn, ok := a.(*net.IPNet); ok && ipn.IP.To4() != nil {
|
||||
out = append(out, fmt.Sprintf("http://%s:%d", ipn.IP.String(), bjPort))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstOr(list []string, def string) string {
|
||||
if len(list) > 0 {
|
||||
return list[0]
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func run(name string, args ...string) (string, error) {
|
||||
return runEnv(nil, name, args...)
|
||||
}
|
||||
|
||||
func runEnv(env []string, name string, args ...string) (string, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
if env != nil {
|
||||
cmd.Env = env
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
lines := strings.Split(strings.TrimSpace(s), "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func waitFor(d time.Duration, ok func() bool) error {
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if ok() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return errors.New("время ожидания истекло")
|
||||
}
|
||||
|
||||
func writeFile(path string, body []byte, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path+".tmp", body, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(path+".tmp", path)
|
||||
}
|
||||
|
||||
func randomString(n int) string {
|
||||
const alphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
x, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
b[i] = alphabet[x.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func pick(v, def string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ---- вывод в консоль ----
|
||||
|
||||
type console struct {
|
||||
color bool
|
||||
w *bufio.Writer
|
||||
}
|
||||
|
||||
func newConsole() *console {
|
||||
fi, err := os.Stdout.Stat()
|
||||
return &console{color: err == nil && fi.Mode()&os.ModeCharDevice != 0 && os.Getenv("NO_COLOR") == "", w: bufio.NewWriter(os.Stdout)}
|
||||
}
|
||||
|
||||
func (c *console) paint(code, s string) string {
|
||||
if !c.color {
|
||||
return s
|
||||
}
|
||||
return "\033[" + code + "m" + s + "\033[0m"
|
||||
}
|
||||
|
||||
func (c *console) flush() { _ = c.w.Flush() }
|
||||
|
||||
func (c *console) banner() {
|
||||
fmt.Fprintf(c.w, "\n %s установка на сервер\n %s\n\n", c.paint("1", "Bridge&Join"), c.paint("2", "bj-installer "+installerVersion))
|
||||
c.flush()
|
||||
}
|
||||
|
||||
func (c *console) step(i, n int, title string) {
|
||||
fmt.Fprintf(c.w, " %s %s ", c.paint("36", fmt.Sprintf("[%d/%d]", i, n)), title)
|
||||
c.flush()
|
||||
}
|
||||
|
||||
func (c *console) stepOK(detail string) {
|
||||
fmt.Fprintf(c.w, "%s\n", c.paint("32", "готово"))
|
||||
if detail != "" {
|
||||
fmt.Fprintf(c.w, " %s\n", c.paint("2", detail))
|
||||
}
|
||||
c.flush()
|
||||
}
|
||||
|
||||
func (c *console) stepFail() {
|
||||
fmt.Fprintf(c.w, "%s\n", c.paint("31", "ошибка"))
|
||||
c.flush()
|
||||
}
|
||||
|
||||
func (c *console) failure(err error) {
|
||||
fmt.Fprintf(c.w, "\n %s %v\n\n Исправьте причину и запустите установку ещё раз: пройденные шаги повторяются безопасно.\n\n", c.paint("31;1", "Установка не завершена."), err)
|
||||
c.flush()
|
||||
}
|
||||
|
||||
func (c *console) note(s string) {
|
||||
fmt.Fprintf(c.w, "\n %s\n\n", s)
|
||||
c.flush()
|
||||
}
|
||||
|
||||
// done — итоговый экран: адрес первичной настройки и одноразовый код.
|
||||
func (c *console) done(code string, l *license.License) {
|
||||
urls := setupURLs()
|
||||
line := c.paint("2", strings.Repeat("─", 64))
|
||||
fmt.Fprintf(c.w, "\n %s\n %s\n\n", line, c.paint("32;1", "bj установлен"))
|
||||
if code != "" {
|
||||
fmt.Fprintf(c.w, " Откройте в браузере на своём компьютере:\n")
|
||||
if len(urls) == 0 {
|
||||
urls = []string{fmt.Sprintf("http://<адрес сервера>:%d", bjPort)}
|
||||
}
|
||||
for _, u := range urls {
|
||||
fmt.Fprintf(c.w, " %s\n", c.paint("1", u+"/first-run"))
|
||||
}
|
||||
fmt.Fprintf(c.w, "\n Одноразовый код первичной настройки: %s\n\n", c.paint("1;33", code))
|
||||
fmt.Fprintf(c.w, " По коду создаётся администратор, дальше в разделе «Настройка»:\n организация и код участника НРД, ключи и интеграционный шлюз, учётные системы, пользователи.\n")
|
||||
} else {
|
||||
fmt.Fprintf(c.w, " Адрес bj: %s\n", c.paint("1", firstOr(urls, fmt.Sprintf("http://<адрес сервера>:%d", bjPort))))
|
||||
}
|
||||
if l != nil {
|
||||
fmt.Fprintf(c.w, "\n Лицензия: %s, план %s, ключ до %s, модули: %s\n", l.Tenant, l.Plan, l.ExpiresAt.Local().Format("02.01.2006"), featuresText(l))
|
||||
}
|
||||
fmt.Fprintf(c.w, " Код потерян: sudo bj-installer first-run-code\n %s\n\n", line)
|
||||
c.flush()
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// runPrechecks — все системные проверки на стадии "Проверка системы".
|
||||
// Возвращает срез результатов, по каждому видно ✓/✗ + объяснение.
|
||||
//
|
||||
// Ничего не модифицирует — просто читает /etc/os-release, проверяет
|
||||
// наличие нужных бинарей, права root, свободное место, артефакты в
|
||||
// artifactsDir и т.п. UI отрисовывает таблицей.
|
||||
func runPrechecks(artifactsDir string) []PrecheckResult {
|
||||
var out []PrecheckResult
|
||||
|
||||
out = append(out, checkRoot())
|
||||
out = append(out, checkArch())
|
||||
out = append(out, checkDistro())
|
||||
out = append(out, checkAptAvailable())
|
||||
out = append(out, checkSystemd())
|
||||
out = append(out, checkDiskSpace())
|
||||
out = append(out, checkArtifacts(artifactsDir))
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func checkRoot() PrecheckResult {
|
||||
if os.Geteuid() == 0 {
|
||||
return PrecheckResult{ID: "root", Title: "Запуск от root", OK: true}
|
||||
}
|
||||
return PrecheckResult{ID: "root", Title: "Запуск от root", OK: false, Message: "Требуется sudo"}
|
||||
}
|
||||
|
||||
func checkArch() PrecheckResult {
|
||||
if runtime.GOARCH == "amd64" {
|
||||
return PrecheckResult{ID: "arch", Title: "Архитектура amd64", OK: true, Message: runtime.GOARCH}
|
||||
}
|
||||
return PrecheckResult{ID: "arch", Title: "Архитектура amd64", OK: false, Message: "Валидата собрана только под amd64, у вас " + runtime.GOARCH}
|
||||
}
|
||||
|
||||
func checkDistro() PrecheckResult {
|
||||
id, pretty := readOSRelease()
|
||||
switch id {
|
||||
case "debian", "astra":
|
||||
return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: true, Message: pretty}
|
||||
case "ubuntu":
|
||||
return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: true, Message: pretty + " (поддерживается на свой страх)"}
|
||||
default:
|
||||
return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: false, Message: "ОС не в списке поддерживаемых: " + pretty}
|
||||
}
|
||||
}
|
||||
|
||||
func checkAptAvailable() PrecheckResult {
|
||||
if _, err := exec.LookPath("apt-get"); err != nil {
|
||||
return PrecheckResult{ID: "apt", Title: "Доступен apt-get", OK: false, Message: "apt-get не найден — это не Debian-семейство"}
|
||||
}
|
||||
return PrecheckResult{ID: "apt", Title: "Доступен apt-get", OK: true}
|
||||
}
|
||||
|
||||
func checkSystemd() PrecheckResult {
|
||||
if _, err := os.Stat("/run/systemd/system"); err != nil {
|
||||
return PrecheckResult{ID: "systemd", Title: "systemd работает", OK: false, Message: "/run/systemd/system нет"}
|
||||
}
|
||||
return PrecheckResult{ID: "systemd", Title: "systemd работает", OK: true}
|
||||
}
|
||||
|
||||
func checkDiskSpace() PrecheckResult {
|
||||
var fs syscall.Statfs_t
|
||||
if err := syscall.Statfs("/var", &fs); err != nil {
|
||||
return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: false, Message: err.Error()}
|
||||
}
|
||||
freeBytes := fs.Bavail * uint64(fs.Bsize)
|
||||
freeGiB := freeBytes / (1 << 30)
|
||||
if freeGiB < 2 {
|
||||
return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: false, Message: fmt.Sprintf("Свободно %d GiB, нужно ≥ 2", freeGiB)}
|
||||
}
|
||||
return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: true, Message: fmt.Sprintf("%d GiB свободно", freeGiB)}
|
||||
}
|
||||
|
||||
func checkArtifacts(dir string) PrecheckResult {
|
||||
required := []struct {
|
||||
Glob string
|
||||
Name string
|
||||
}{
|
||||
{filepath.Join(dir, "ClientL_Other", "zpki-*.deb"), "zpki (Валидата)"},
|
||||
{filepath.Join(dir, "bj-server"), "bj-server (Go-бинарь)"},
|
||||
{filepath.Join(dir, "crypto-service.jar"), "crypto-service.jar"},
|
||||
}
|
||||
var missing []string
|
||||
for _, r := range required {
|
||||
matches, _ := filepath.Glob(r.Glob)
|
||||
if len(matches) == 0 {
|
||||
missing = append(missing, r.Name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return PrecheckResult{
|
||||
ID: "artifacts",
|
||||
Title: "Артефакты дистрибутива",
|
||||
OK: false,
|
||||
Message: "Отсутствуют: " + strings.Join(missing, ", ") + " (положите в " + dir + ")",
|
||||
}
|
||||
}
|
||||
return PrecheckResult{ID: "artifacts", Title: "Артефакты дистрибутива", OK: true, Message: "Все на месте в " + dir}
|
||||
}
|
||||
|
||||
func readOSRelease() (id, pretty string) {
|
||||
b, err := os.ReadFile("/etc/os-release")
|
||||
if err != nil {
|
||||
return "", "неизвестно"
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v = strings.Trim(v, `"`)
|
||||
switch k {
|
||||
case "ID":
|
||||
id = v
|
||||
case "PRETTY_NAME":
|
||||
pretty = v
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed web
|
||||
var webFS embed.FS
|
||||
|
||||
type server struct {
|
||||
state *State
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func newServer(st *State) *server {
|
||||
s := &server{state: st, mux: http.NewServeMux()}
|
||||
|
||||
// Статика (HTML/CSS/JS из embed)
|
||||
sub, _ := fs.Sub(webFS, "web")
|
||||
s.mux.Handle("/", http.FileServer(http.FS(sub)))
|
||||
|
||||
// API
|
||||
s.mux.HandleFunc("/api/state", s.handleState)
|
||||
s.mux.HandleFunc("/api/precheck", s.handlePrecheck)
|
||||
s.mux.HandleFunc("/api/config", s.handleConfig)
|
||||
s.mux.HandleFunc("/api/install", s.handleInstall)
|
||||
s.mux.HandleFunc("/api/events", s.handleSSE)
|
||||
s.mux.HandleFunc("/api/reset", s.handleReset)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Защита: только localhost (даже если addr 0.0.0.0 поставят)
|
||||
host := r.RemoteAddr
|
||||
if i := strings.LastIndex(host, ":"); i != -1 {
|
||||
host = host[:i]
|
||||
}
|
||||
switch host {
|
||||
case "127.0.0.1", "::1", "[::1]", "localhost":
|
||||
// ok
|
||||
default:
|
||||
http.Error(w, "installer is local-only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GET /api/state — полный snapshot для холодного открытия страницы.
|
||||
func (s *server) handleState(w http.ResponseWriter, r *http.Request) {
|
||||
snap := s.state.Snapshot()
|
||||
writeJSON(w, snap)
|
||||
}
|
||||
|
||||
// POST /api/precheck — запускает все pre-check проверки и возвращает результат.
|
||||
// Wizard переходит на стадию precheck.
|
||||
func (s *server) handlePrecheck(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s.state.setStage(StagePrecheck)
|
||||
results := runPrechecks(s.state.artifactsDir)
|
||||
s.state.setPrecheck(results)
|
||||
writeJSON(w, results)
|
||||
}
|
||||
|
||||
// POST /api/config — сохраняет org INN, email, license. Переход на стадию config.
|
||||
func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var c Config
|
||||
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.state.setConfig(c)
|
||||
s.state.setStage(StageConfig)
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/install — стартует установку (в горутине), переход на стадию installing.
|
||||
// UI слушает /api/events для прогресса.
|
||||
func (s *server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s.state.setStage(StageInstalling)
|
||||
go func() {
|
||||
if err := runInstallation(s.state); err != nil {
|
||||
log.Printf("install error: %v", err)
|
||||
s.state.setError(err.Error())
|
||||
return
|
||||
}
|
||||
s.state.setStage(StageDone)
|
||||
}()
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/reset — сброс wizard'а на welcome (после ошибки).
|
||||
func (s *server) handleReset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s.state.mu.Lock()
|
||||
s.state.Stage = StageWelcome
|
||||
s.state.ErrorMsg = ""
|
||||
s.state.Precheck = nil
|
||||
s.state.Steps = buildStepList()
|
||||
s.state.mu.Unlock()
|
||||
s.state.bus.publish(event{Type: "reset", Data: "{}"})
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// event — одно событие, отдаваемое подписчикам через SSE.
|
||||
// Type становится `event:` строкой, Data — `data:`.
|
||||
type event struct {
|
||||
Type string
|
||||
Data string
|
||||
}
|
||||
|
||||
// eventBus — простой fan-out для SSE. Подписчик создаётся в момент
|
||||
// открытия GET /api/events и живёт до закрытия соединения.
|
||||
type eventBus struct {
|
||||
mu sync.Mutex
|
||||
subscribers map[chan event]struct{}
|
||||
}
|
||||
|
||||
func newEventBus() *eventBus {
|
||||
return &eventBus{subscribers: make(map[chan event]struct{})}
|
||||
}
|
||||
|
||||
func (b *eventBus) subscribe() chan event {
|
||||
ch := make(chan event, 64)
|
||||
b.mu.Lock()
|
||||
b.subscribers[ch] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (b *eventBus) unsubscribe(ch chan event) {
|
||||
b.mu.Lock()
|
||||
delete(b.subscribers, ch)
|
||||
close(ch)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *eventBus) publish(e event) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for ch := range b.subscribers {
|
||||
select {
|
||||
case ch <- e:
|
||||
default:
|
||||
// Подписчик отстаёт — пропускаем (UI догонится снапшотом по GET /api/state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleSSE — GET /api/events. Держит соединение, в каждом событии
|
||||
// отдаёт event: <Type>\ndata: <Data>\n\n.
|
||||
func (s *server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
ch := s.state.bus.subscribe()
|
||||
defer s.state.bus.unsubscribe(ch)
|
||||
|
||||
// сразу шлём snapshot, чтобы UI догнал состояние
|
||||
snap := s.state.Snapshot()
|
||||
fmt.Fprintf(w, "event: snapshot\ndata: %s\n\n", mustJSON(snap))
|
||||
flusher.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case e := <-ch:
|
||||
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", e.Type, e.Data); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WizardStage — какой странице wizard'а соответствует текущее состояние.
|
||||
// Переходы: welcome → precheck → config → installing → done.
|
||||
// Из любого можно вернуться в welcome (полный reset).
|
||||
type WizardStage string
|
||||
|
||||
const (
|
||||
StageWelcome WizardStage = "welcome"
|
||||
StagePrecheck WizardStage = "precheck"
|
||||
StageConfig WizardStage = "config"
|
||||
StageInstalling WizardStage = "installing"
|
||||
StageDone WizardStage = "done"
|
||||
StageError WizardStage = "error"
|
||||
)
|
||||
|
||||
// Config — данные, которые wizard собирает на стадии config.
|
||||
type Config struct {
|
||||
OrgINN string `json:"orgInn"` // ИНН организации
|
||||
OrgName string `json:"orgName"` // отображаемое имя
|
||||
AdminEmail string `json:"adminEmail"` // куда писать алерты
|
||||
LicenseKey string `json:"licenseKey"` // годовой ключ (опционально, можно пропустить)
|
||||
}
|
||||
|
||||
// StepStatus — текущее состояние конкретного шага установки.
|
||||
type StepStatus string
|
||||
|
||||
const (
|
||||
StepPending StepStatus = "pending"
|
||||
StepRunning StepStatus = "running"
|
||||
StepDone StepStatus = "done"
|
||||
StepSkipped StepStatus = "skipped"
|
||||
StepFailed StepStatus = "failed"
|
||||
)
|
||||
|
||||
// StepState — снимок одного шага для отдачи в UI.
|
||||
type StepState struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status StepStatus `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Started *time.Time `json:"started,omitempty"`
|
||||
Finished *time.Time `json:"finished,omitempty"`
|
||||
}
|
||||
|
||||
// PrecheckResult — результат одной системной проверки на стадии precheck.
|
||||
type PrecheckResult struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// State — потокобезопасное состояние wizard'а. Хранит всё что нужно
|
||||
// отрисовать на любой из страниц + текущий прогресс установки.
|
||||
type State struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
artifactsDir string
|
||||
|
||||
Stage WizardStage `json:"stage"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Precheck []PrecheckResult `json:"precheck"`
|
||||
Config Config `json:"config"`
|
||||
Steps []StepState `json:"steps"`
|
||||
|
||||
bus *eventBus
|
||||
}
|
||||
|
||||
func newState(artifactsDir string) *State {
|
||||
return &State{
|
||||
artifactsDir: artifactsDir,
|
||||
Stage: StageWelcome,
|
||||
Steps: buildStepList(),
|
||||
bus: newEventBus(),
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot — потокобезопасная копия для GET /api/state.
|
||||
func (s *State) Snapshot() State {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
cp := *s
|
||||
cp.Precheck = append([]PrecheckResult(nil), s.Precheck...)
|
||||
cp.Steps = append([]StepState(nil), s.Steps...)
|
||||
return cp
|
||||
}
|
||||
|
||||
func (s *State) setStage(st WizardStage) {
|
||||
s.mu.Lock()
|
||||
s.Stage = st
|
||||
s.mu.Unlock()
|
||||
s.bus.publish(event{Type: "stage", Data: mustJSON(map[string]string{"stage": string(st)})})
|
||||
}
|
||||
|
||||
func (s *State) setError(msg string) {
|
||||
s.mu.Lock()
|
||||
s.Stage = StageError
|
||||
s.ErrorMsg = msg
|
||||
s.mu.Unlock()
|
||||
s.bus.publish(event{Type: "error", Data: mustJSON(map[string]string{"message": msg})})
|
||||
}
|
||||
|
||||
func (s *State) setPrecheck(items []PrecheckResult) {
|
||||
s.mu.Lock()
|
||||
s.Precheck = items
|
||||
s.mu.Unlock()
|
||||
s.bus.publish(event{Type: "precheck", Data: mustJSON(items)})
|
||||
}
|
||||
|
||||
func (s *State) setConfig(c Config) {
|
||||
s.mu.Lock()
|
||||
s.Config = c
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *State) updateStep(id string, fn func(*StepState)) {
|
||||
s.mu.Lock()
|
||||
var snap StepState
|
||||
for i := range s.Steps {
|
||||
if s.Steps[i].ID == id {
|
||||
fn(&s.Steps[i])
|
||||
snap = s.Steps[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.bus.publish(event{Type: "step", Data: mustJSON(snap)})
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Step — описание одного шага установки. Run выполняет шаг, может
|
||||
// проверить idempotency и вернуть Skipped. Логи прокидываются через
|
||||
// log-функцию, которая публикует event в SSE.
|
||||
type Step struct {
|
||||
ID string
|
||||
Title string
|
||||
Run func(s *State, log func(string)) (StepStatus, error)
|
||||
}
|
||||
|
||||
// buildStepList — фиксированный порядок шагов установки. Соответствует
|
||||
// install-validata.sh + установка bj-server/crypto-service/ИШ. Меняется
|
||||
// атомарно (если что-то добавляется — добавляем сюда).
|
||||
func buildStepList() []StepState {
|
||||
steps := allSteps()
|
||||
out := make([]StepState, len(steps))
|
||||
for i, s := range steps {
|
||||
out[i] = StepState{ID: s.ID, Title: s.Title, Status: StepPending}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func allSteps() []Step {
|
||||
return []Step{
|
||||
{ID: "deps", Title: "Установка системных зависимостей", Run: stepInstallDeps},
|
||||
{ID: "validata-deb", Title: "Установка пакетов Валидаты (zpki + zsdk)", Run: stepInstallValidataDebs},
|
||||
{ID: "execstack", Title: "execstack -c libvdcsp.so", Run: stepExecstack},
|
||||
{ID: "bj-user", Title: "Создание пользователя bj и каталогов", Run: stepCreateBJUser},
|
||||
{ID: "pcscd-dropin", Title: "Настройка pcscd (always-on)", Run: stepPcscdDropin},
|
||||
{ID: "bj-crypto-dropins", Title: "Drop-ins для bj-crypto sandbox", Run: stepBJCryptoDropins},
|
||||
{ID: "bj-server-dropin", Title: "Drop-in для bj-server", Run: stepBJServerDropin},
|
||||
{ID: "spki-ini", Title: "Создание spki.ini", Run: stepSPKIIni},
|
||||
{ID: "pki1-prep", Title: "Подготовка pki1.conf для bj", Run: stepPKI1Prep},
|
||||
{ID: "usb-mount", Title: "Авто-mount USB через udev + systemd", Run: stepUSBMount},
|
||||
{ID: "bj-server-binary", Title: "Установка bj-server бинаря в /opt/bj/", Run: stepInstallBJServer},
|
||||
{ID: "crypto-jar", Title: "Установка crypto-service.jar", Run: stepInstallCryptoJar},
|
||||
{ID: "systemd-units", Title: "systemd unit bj-crypto.service + bj-server.service", Run: stepSystemdUnits},
|
||||
{ID: "ish-install", Title: "Установка ИШ НРД (если есть .deb)", Run: stepInstallISH},
|
||||
{ID: "save-config", Title: "Сохранение setup.json", Run: stepSaveConfig},
|
||||
{ID: "systemd-start", Title: "Запуск сервисов (pcscd, bj-crypto, bj-server)", Run: stepStartServices},
|
||||
{ID: "health", Title: "Финальный health-check", Run: stepHealthCheck},
|
||||
}
|
||||
}
|
||||
|
||||
// runInstallation — основной цикл установки. Перебирает шаги, обновляет
|
||||
// статусы через State, прокидывает логи в SSE. Останавливается при первой
|
||||
// ошибке (UI покажет какой шаг + сообщение).
|
||||
func runInstallation(s *State) error {
|
||||
steps := allSteps()
|
||||
for _, step := range steps {
|
||||
now := time.Now()
|
||||
s.updateStep(step.ID, func(ss *StepState) {
|
||||
ss.Status = StepRunning
|
||||
ss.Started = &now
|
||||
ss.Message = ""
|
||||
})
|
||||
|
||||
logFn := func(line string) {
|
||||
s.updateStep(step.ID, func(ss *StepState) {
|
||||
ss.Message = line
|
||||
})
|
||||
}
|
||||
|
||||
status, err := step.Run(s, logFn)
|
||||
finished := time.Now()
|
||||
s.updateStep(step.ID, func(ss *StepState) {
|
||||
ss.Status = status
|
||||
ss.Finished = &finished
|
||||
if err != nil {
|
||||
ss.Message = err.Error()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("шаг %q: %w", step.Title, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------- //
|
||||
// Хелперы
|
||||
// --------------------------------------------------------------------- //
|
||||
|
||||
// runCmd — запускает команду, прокидывает stdout/stderr построчно в log.
|
||||
// Возвращает ошибку с последними строками stderr для удобства отображения.
|
||||
func runCmd(logFn func(string), name string, args ...string) error {
|
||||
logFn(fmt.Sprintf("$ %s %s", name, strings.Join(args, " ")))
|
||||
cmd := exec.Command(name, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
|
||||
if line != "" {
|
||||
logFn(line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeFileIfChanged — пишет файл только если содержимое отличается. Возвращает
|
||||
// true если файл был создан/изменён (для решения «нужен ли daemon-reload»).
|
||||
func writeFileIfChanged(path string, content string, mode os.FileMode) (bool, error) {
|
||||
existing, err := os.ReadFile(path)
|
||||
if err == nil && string(existing) == content {
|
||||
return false, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- //
|
||||
// Шаги
|
||||
// --------------------------------------------------------------------- //
|
||||
|
||||
func stepInstallDeps(s *State, log func(string)) (StepStatus, error) {
|
||||
log("Обновляю apt-кеш...")
|
||||
if err := runCmd(log, "apt-get", "update", "-qq"); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
|
||||
deps := []string{
|
||||
"libgtk-3-0", "libpcsclite1", "libccid", "pcscd",
|
||||
"libcurl4", "libkrb5-3", "libgssapi-krb5-2",
|
||||
"libsasl2-modules", "libsasl2-modules-gssapi-mit",
|
||||
"execstack", "p7zip-full",
|
||||
}
|
||||
if hasAPTPackage("libldap-2.4-2") {
|
||||
deps = append(deps, "libldap-2.4-2")
|
||||
} else {
|
||||
deps = append(deps, "libldap-2.5-0")
|
||||
log("libldap-2.4-2 не найден → ставлю 2.5-0, для zpki будет --force-depends")
|
||||
}
|
||||
|
||||
args := append([]string{"install", "-y", "--no-install-recommends"}, deps...)
|
||||
if err := runCmd(log, "apt-get", args...); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepInstallValidataDebs(s *State, log func(string)) (StepStatus, error) {
|
||||
zpki, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ClientL_Other", "zpki-*.amd64.deb"))
|
||||
zsdk, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ClientL_Other", "zsdk-*.amd64.deb"))
|
||||
if len(zpki) == 0 {
|
||||
return StepFailed, fmt.Errorf("zpki-*.amd64.deb не найден в %s/ClientL_Other/", s.artifactsDir)
|
||||
}
|
||||
useForce := !hasAPTPackage("libldap-2.4-2")
|
||||
for _, deb := range append(zpki, zsdk...) {
|
||||
args := []string{"-i", deb}
|
||||
if useForce {
|
||||
args = append([]string{"--force-depends"}, args...)
|
||||
}
|
||||
if err := runCmd(log, "dpkg", args...); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("/opt/Validata/VDCSP/lib/amd64"); err != nil {
|
||||
return StepFailed, fmt.Errorf("/opt/Validata не появился после установки")
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepExecstack(s *State, log func(string)) (StepStatus, error) {
|
||||
target := "/opt/Validata/VDCSP/lib/amd64/libvdcsp.so"
|
||||
// Проверка состояния
|
||||
out, err := exec.Command("execstack", "-q", target).Output()
|
||||
if err == nil && strings.HasPrefix(strings.TrimSpace(string(out)), "-") {
|
||||
log("executable-stack уже снят")
|
||||
return StepSkipped, nil
|
||||
}
|
||||
return StepDone, runCmd(log, "execstack", "-c", target)
|
||||
}
|
||||
|
||||
func stepCreateBJUser(s *State, log func(string)) (StepStatus, error) {
|
||||
if _, err := exec.LookPath("id"); err == nil {
|
||||
if exec.Command("id", "bj").Run() == nil {
|
||||
log("Пользователь bj уже существует")
|
||||
} else {
|
||||
if err := runCmd(log, "useradd", "--system", "--create-home",
|
||||
"--home-dir", "/var/lib/bj", "--shell", "/bin/bash", "bj"); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs := []struct {
|
||||
Path string
|
||||
Mode os.FileMode
|
||||
}{
|
||||
{"/var/lib/bj/usb", 0o755},
|
||||
{"/var/lib/bj/.Validata", 0o700},
|
||||
{"/var/lib/bj/.Validata/vdkeys", 0o700},
|
||||
{"/var/lib/bj/profiles", 0o755},
|
||||
{"/var/log/bj", 0o755},
|
||||
{"/var/lib/bj/.bj", 0o700},
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d.Path, d.Mode); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
return StepDone, runCmd(log, "chown", "-R", "bj:bj", "/var/lib/bj", "/var/log/bj")
|
||||
}
|
||||
|
||||
func stepPcscdDropin(s *State, log func(string)) (StepStatus, error) {
|
||||
const dropin = `[Unit]
|
||||
Requires=
|
||||
After=
|
||||
Sockets=
|
||||
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/sbin/pcscd --foreground
|
||||
`
|
||||
changed, err := writeFileIfChanged("/etc/systemd/system/pcscd.service.d/no-autoexit.conf", dropin, 0o644)
|
||||
if err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if !changed {
|
||||
log("Drop-in уже актуален")
|
||||
return StepSkipped, nil
|
||||
}
|
||||
log("Создан /etc/systemd/system/pcscd.service.d/no-autoexit.conf")
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepBJCryptoDropins(s *State, log func(string)) (StepStatus, error) {
|
||||
files := map[string]string{
|
||||
"/etc/systemd/system/bj-crypto.service.d/validata-paths.conf": `[Service]
|
||||
WorkingDirectory=/opt/Validata/VDCSP/etc
|
||||
ReadWritePaths=/opt/Validata/VDCSP/etc
|
||||
ReadWritePaths=/var/lib/bj
|
||||
`,
|
||||
"/etc/systemd/system/bj-crypto.service.d/usb-access.conf": `[Service]
|
||||
ReadOnlyPaths=/media
|
||||
ReadOnlyPaths=/var/lib/bj/usb
|
||||
`,
|
||||
"/etc/systemd/system/bj-crypto.service.d/share-crysvc.conf": `[Service]
|
||||
PrivateTmp=true
|
||||
BindPaths=/tmp/.crysvc.sock:/tmp/.crysvc.sock
|
||||
`,
|
||||
}
|
||||
for path, content := range files {
|
||||
if _, err := writeFileIfChanged(path, content, 0o644); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepBJServerDropin(s *State, log func(string)) (StepStatus, error) {
|
||||
const dropin = `[Service]
|
||||
ReadWritePaths=/opt/Validata/VDCSP/etc
|
||||
`
|
||||
_, err := writeFileIfChanged("/etc/systemd/system/bj-server.service.d/pki1conf.conf", dropin, 0o644)
|
||||
if err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepSPKIIni(s *State, log func(string)) (StepStatus, error) {
|
||||
const path = "/opt/Validata/VDCSP/etc/spki.ini"
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
log("Файл уже существует")
|
||||
return StepSkipped, nil
|
||||
}
|
||||
const content = `[store]
|
||||
count = 0
|
||||
|
||||
[Parameters]
|
||||
PkiLdapTimeout = 10
|
||||
PkiHttpTimeout = 60
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepPKI1Prep(s *State, log func(string)) (StepStatus, error) {
|
||||
const path = "/opt/Validata/VDCSP/etc/pki1.conf"
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
log("Файл pki1.conf отсутствует — Валидата создаст при первом запуске")
|
||||
return StepSkipped, nil
|
||||
}
|
||||
if err := runCmd(log, "chgrp", "bj", path); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if err := runCmd(log, "chmod", "g+w", path); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
|
||||
existing, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(existing), "# --- bj-server: BEGIN ---") {
|
||||
appended := string(existing) + "\n# --- bj-server: BEGIN ---\n# Секции профилей дописываются bj-server при импорте через /admin/setup.\n# --- bj-server: END ---\n"
|
||||
if err := os.WriteFile(path, []byte(appended), 0o664); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepUSBMount(s *State, log func(string)) (StepStatus, error) {
|
||||
files := map[string]string{
|
||||
"/etc/udev/rules.d/99-bj-usb.rules": `# Авто-mount USB-флешек в /var/lib/bj/usb/<UUID> с владельцем bj.
|
||||
ACTION=="add", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
|
||||
ENV{ID_FS_TYPE}!="", \
|
||||
ENV{SYSTEMD_WANTS}="bj-usb-mount@$env{ID_FS_UUID}.service"
|
||||
ACTION=="remove", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
|
||||
ENV{ID_FS_TYPE}!="", \
|
||||
ENV{SYSTEMD_WANTS}="bj-usb-umount@$env{ID_FS_UUID}.service"
|
||||
`,
|
||||
"/etc/systemd/system/bj-usb-mount@.service": `[Unit]
|
||||
Description=Mount USB %i to /var/lib/bj/usb/%i for bj
|
||||
DefaultDependencies=no
|
||||
After=local-fs.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/bin/bash -c 'mkdir -p /var/lib/bj/usb/%i && /usr/bin/mount -o uid=$(id -u bj),gid=$(id -g bj),fmask=0133,dmask=0022 UUID=%i /var/lib/bj/usb/%i'
|
||||
ExecStop=/usr/bin/umount /var/lib/bj/usb/%i || true
|
||||
`,
|
||||
"/etc/systemd/system/bj-usb-umount@.service": `[Unit]
|
||||
Description=Umount USB %i from /var/lib/bj/usb/%i
|
||||
DefaultDependencies=no
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/bash -c '/usr/bin/umount /var/lib/bj/usb/%i 2>/dev/null; /usr/bin/rmdir /var/lib/bj/usb/%i 2>/dev/null; true'
|
||||
`,
|
||||
}
|
||||
anyChanged := false
|
||||
for path, content := range files {
|
||||
ch, err := writeFileIfChanged(path, content, 0o644)
|
||||
if err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
anyChanged = anyChanged || ch
|
||||
}
|
||||
if anyChanged {
|
||||
_ = runCmd(log, "udevadm", "control", "--reload-rules")
|
||||
_ = runCmd(log, "udevadm", "trigger")
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepInstallBJServer(s *State, log func(string)) (StepStatus, error) {
|
||||
src := filepath.Join(s.artifactsDir, "bj-server")
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
return StepSkipped, nil // нет бинаря — может ставится через rpm/deb
|
||||
}
|
||||
if err := os.MkdirAll("/opt/bj", 0o755); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if err := runCmd(log, "install", "-o", "bj", "-g", "bj", "-m", "0755", src, "/opt/bj/bj-server"); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepInstallCryptoJar(s *State, log func(string)) (StepStatus, error) {
|
||||
src := filepath.Join(s.artifactsDir, "crypto-service.jar")
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
return StepSkipped, nil
|
||||
}
|
||||
if err := os.MkdirAll("/opt/bj", 0o755); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if err := runCmd(log, "install", "-o", "bj", "-g", "bj", "-m", "0644", src, "/opt/bj/crypto-service.jar"); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepSystemdUnits(s *State, log func(string)) (StepStatus, error) {
|
||||
units := map[string]string{
|
||||
"/etc/systemd/system/bj-crypto.service": `[Unit]
|
||||
Description=Bridge-and-Join-s — Crypto sidecar (Java + Валидата Клиент L)
|
||||
Before=bj-server.service
|
||||
After=network-online.target pcscd.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj
|
||||
Group=bj
|
||||
RuntimeDirectory=bj
|
||||
RuntimeDirectoryMode=0750
|
||||
Environment=BJ_CRYPTO_SOCKET=/run/bj/crypto.sock
|
||||
Environment=BJ_CRYPTO_PROVIDER=validata
|
||||
Environment=LD_LIBRARY_PATH=/opt/Validata/VDCSP/lib/amd64
|
||||
ExecStart=/usr/bin/java -Djava.library.path=/opt/Validata/VDCSP/lib/amd64 -jar /opt/bj/crypto-service.jar
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=append:/var/log/bj/crypto-service.log
|
||||
StandardError=append:/var/log/bj/crypto-service.err
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/run/bj /var/log/bj
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`,
|
||||
"/etc/systemd/system/bj-server.service": `[Unit]
|
||||
Description=Bridge-and-Join-s — единый сервис M2M-переводов
|
||||
After=network-online.target bj-crypto.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj
|
||||
Group=bj
|
||||
WorkingDirectory=/var/lib/bj
|
||||
ExecStart=/opt/bj/bj-server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=BJ_HTTP_ADDR=:8080
|
||||
Environment=BJ_SETUP_PATH=/var/lib/bj/.bj/setup.json
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/bj /var/log/bj
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`,
|
||||
}
|
||||
for path, content := range units {
|
||||
if _, err := writeFileIfChanged(path, content, 0o644); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
return StepDone, runCmd(log, "systemctl", "daemon-reload")
|
||||
}
|
||||
|
||||
func stepInstallISH(s *State, log func(string)) (StepStatus, error) {
|
||||
matches, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ish", "igate_*.deb"))
|
||||
if len(matches) == 0 {
|
||||
log("Дистрибутив ИШ не найден — пропускаю (можно установить позже)")
|
||||
return StepSkipped, nil
|
||||
}
|
||||
if err := runCmd(log, "dpkg", "-i", matches[0]); err != nil {
|
||||
// допустим, что зависимости подтянутся
|
||||
_ = runCmd(log, "apt-get", "-f", "install", "-y")
|
||||
if err := runCmd(log, "dpkg", "-i", matches[0]); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepSaveConfig(s *State, log func(string)) (StepStatus, error) {
|
||||
cfg := s.Snapshot().Config
|
||||
if cfg.OrgINN == "" && cfg.AdminEmail == "" && cfg.LicenseKey == "" {
|
||||
return StepSkipped, nil
|
||||
}
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if err := os.MkdirAll("/var/lib/bj/.bj", 0o700); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if err := os.WriteFile("/var/lib/bj/.bj/setup.json", b, 0o600); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
return StepDone, runCmd(log, "chown", "-R", "bj:bj", "/var/lib/bj/.bj")
|
||||
}
|
||||
|
||||
func stepStartServices(s *State, log func(string)) (StepStatus, error) {
|
||||
// disable+stop socket activation для pcscd
|
||||
_ = runCmd(log, "systemctl", "stop", "pcscd.socket")
|
||||
_ = runCmd(log, "systemctl", "disable", "pcscd.socket")
|
||||
|
||||
for _, svc := range []string{"pcscd", "bj-crypto", "bj-server"} {
|
||||
if err := runCmd(log, "systemctl", "enable", svc); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if err := runCmd(log, "systemctl", "restart", svc); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepHealthCheck(s *State, log func(string)) (StepStatus, error) {
|
||||
var bad []string
|
||||
for _, svc := range []string{"pcscd", "vdcrysvc", "bj-crypto", "bj-server"} {
|
||||
if err := exec.Command("systemctl", "is-active", "--quiet", svc).Run(); err != nil {
|
||||
bad = append(bad, svc)
|
||||
} else {
|
||||
log(svc + ": active")
|
||||
}
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
return StepFailed, fmt.Errorf("сервисы не запустились: %s", strings.Join(bad, ", "))
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
// hasAPTPackage — проверяет наличие пакета в apt-cache (доступен ли для установки).
|
||||
func hasAPTPackage(name string) bool {
|
||||
out, err := exec.Command("apt-cache", "show", name).CombinedOutput()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(out), "Package: "+name)
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
// Минимальный клиент wizard'а: рендерит страницы, ловит события из SSE,
|
||||
// отправляет POST'ы на backend для перехода между стадиями.
|
||||
|
||||
let state = {
|
||||
stage: "welcome",
|
||||
precheck: [],
|
||||
config: {},
|
||||
steps: [],
|
||||
errorMsg: "",
|
||||
};
|
||||
|
||||
const STAGE_ORDER = ["welcome", "precheck", "config", "installing", "done"];
|
||||
const STEP_ICONS = {
|
||||
pending: "○",
|
||||
running: "◐",
|
||||
done: "✓",
|
||||
skipped: "—",
|
||||
failed: "✗",
|
||||
};
|
||||
|
||||
function $(sel) { return document.querySelector(sel); }
|
||||
function $$(sel) { return [...document.querySelectorAll(sel)]; }
|
||||
|
||||
function render() {
|
||||
// stepper
|
||||
$$("#stepper span").forEach(el => {
|
||||
el.classList.remove("active", "done");
|
||||
const stage = el.dataset.stage;
|
||||
if (stage === state.stage) el.classList.add("active");
|
||||
if (STAGE_ORDER.indexOf(stage) < STAGE_ORDER.indexOf(state.stage)) el.classList.add("done");
|
||||
});
|
||||
// pages
|
||||
$$(".page").forEach(p => p.classList.toggle("active", p.dataset.stage === state.stage));
|
||||
|
||||
if (state.stage === "precheck") renderPrecheck();
|
||||
if (state.stage === "installing" || state.stage === "done") renderSteps();
|
||||
if (state.stage === "error") $("#error-message").textContent = state.errorMsg || "(нет деталей)";
|
||||
if (state.stage === "done") {
|
||||
// подставляем хост машины в админскую ссылку
|
||||
const adminURL = window.location.protocol + "//" + window.location.hostname + ":8080/admin/setup";
|
||||
$("#adminLink").href = adminURL;
|
||||
$("#adminLink").textContent = "Перейти в " + adminURL + " →";
|
||||
}
|
||||
}
|
||||
|
||||
function renderPrecheck() {
|
||||
const root = $("#precheck-results");
|
||||
root.innerHTML = "";
|
||||
let allOK = true;
|
||||
for (const r of state.precheck || []) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "check " + (r.ok ? "ok" : "bad");
|
||||
div.innerHTML = `
|
||||
<span class="check-icon">${r.ok ? "✓" : "✗"}</span>
|
||||
<div>
|
||||
<div class="check-title">${escapeHTML(r.title)}</div>
|
||||
${r.message ? `<div class="check-msg">${escapeHTML(r.message)}</div>` : ""}
|
||||
</div>`;
|
||||
root.appendChild(div);
|
||||
if (!r.ok) allOK = false;
|
||||
}
|
||||
$("#goConfigBtn").disabled = !allOK;
|
||||
}
|
||||
|
||||
function renderSteps() {
|
||||
const root = $("#step-list");
|
||||
root.innerHTML = "";
|
||||
let done = 0;
|
||||
for (const s of state.steps || []) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "step-" + s.status;
|
||||
li.innerHTML = `
|
||||
<span class="step-icon">${STEP_ICONS[s.status] || "○"}</span>
|
||||
<div>
|
||||
<div class="step-title">${escapeHTML(s.title)}</div>
|
||||
${s.message ? `<div class="step-msg">${escapeHTML(s.message)}</div>` : ""}
|
||||
</div>`;
|
||||
root.appendChild(li);
|
||||
if (s.status === "done" || s.status === "skipped") done++;
|
||||
}
|
||||
const total = state.steps.length;
|
||||
const pct = total ? Math.round(100 * done / total) : 0;
|
||||
$("#progress-bar").style.width = pct + "%";
|
||||
}
|
||||
|
||||
function escapeHTML(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({
|
||||
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'"
|
||||
}[c]));
|
||||
}
|
||||
|
||||
// ------------- transitions -------------
|
||||
|
||||
async function startPrecheck() {
|
||||
await fetch("/api/precheck", { method: "POST" });
|
||||
}
|
||||
|
||||
function goWelcome() {
|
||||
state.stage = "welcome";
|
||||
render();
|
||||
}
|
||||
|
||||
function goPrecheck() {
|
||||
state.stage = "precheck";
|
||||
render();
|
||||
}
|
||||
|
||||
async function goConfig() {
|
||||
state.stage = "config";
|
||||
render();
|
||||
}
|
||||
|
||||
async function startInstall() {
|
||||
const form = $("#config-form");
|
||||
const data = Object.fromEntries(new FormData(form).entries());
|
||||
await fetch("/api/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
await fetch("/api/install", { method: "POST" });
|
||||
}
|
||||
|
||||
async function resetWizard() {
|
||||
await fetch("/api/reset", { method: "POST" });
|
||||
}
|
||||
|
||||
// ------------- SSE -------------
|
||||
|
||||
function connectSSE() {
|
||||
const es = new EventSource("/api/events");
|
||||
es.addEventListener("snapshot", e => {
|
||||
const snap = JSON.parse(e.data);
|
||||
state.stage = snap.stage;
|
||||
state.precheck = snap.precheck || [];
|
||||
state.config = snap.config || {};
|
||||
state.steps = snap.steps || [];
|
||||
state.errorMsg = snap.errorMsg || "";
|
||||
render();
|
||||
});
|
||||
es.addEventListener("stage", e => {
|
||||
state.stage = JSON.parse(e.data).stage;
|
||||
render();
|
||||
});
|
||||
es.addEventListener("precheck", e => {
|
||||
state.precheck = JSON.parse(e.data);
|
||||
render();
|
||||
});
|
||||
es.addEventListener("step", e => {
|
||||
const s = JSON.parse(e.data);
|
||||
const idx = state.steps.findIndex(x => x.id === s.id);
|
||||
if (idx >= 0) state.steps[idx] = s;
|
||||
render();
|
||||
});
|
||||
es.addEventListener("error", e => {
|
||||
state.errorMsg = JSON.parse(e.data).message;
|
||||
state.stage = "error";
|
||||
render();
|
||||
});
|
||||
es.addEventListener("reset", () => {
|
||||
location.reload();
|
||||
});
|
||||
es.onerror = () => {
|
||||
// авто-реконнект делает EventSource сам, ничего не делаем
|
||||
};
|
||||
}
|
||||
|
||||
connectSSE();
|
||||
@@ -1,110 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>bj-installer — мастер установки Bridge-and-Join-s</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="logo">Bridge-and-Join-s</div>
|
||||
<div class="subtitle">мастер установки</div>
|
||||
</header>
|
||||
|
||||
<main id="app">
|
||||
<!-- ===== Stepper ===== -->
|
||||
<nav class="stepper" id="stepper">
|
||||
<span data-stage="welcome">1. Старт</span>
|
||||
<span data-stage="precheck">2. Проверка</span>
|
||||
<span data-stage="config">3. Настройка</span>
|
||||
<span data-stage="installing">4. Установка</span>
|
||||
<span data-stage="done">5. Готово</span>
|
||||
</nav>
|
||||
|
||||
<!-- ===== Welcome ===== -->
|
||||
<section class="page" data-stage="welcome">
|
||||
<h1>Добро пожаловать</h1>
|
||||
<p>Этот мастер установит на сервер <b>СКЗИ «Валидата Клиент L»</b>,
|
||||
<b>bj-server</b>, <b>bj-crypto</b> и <b>ИШ НРД</b>, настроит
|
||||
systemd-сервисы и подготовит окружение для подписи документов
|
||||
по ГОСТ 34.10-2012.</p>
|
||||
<p class="muted">После установки откроется <code>/admin/setup</code> в bj-server, где можно
|
||||
загрузить тестовый профиль от MOEX (.7z) и активировать подпись.</p>
|
||||
<div class="buttons">
|
||||
<button class="primary" onclick="startPrecheck()">Начать →</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Precheck ===== -->
|
||||
<section class="page" data-stage="precheck">
|
||||
<h1>Проверка системы</h1>
|
||||
<div id="precheck-results" class="checks"></div>
|
||||
<div class="buttons">
|
||||
<button onclick="goWelcome()">← Назад</button>
|
||||
<button class="primary" id="goConfigBtn" onclick="goConfig()">Дальше →</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Config ===== -->
|
||||
<section class="page" data-stage="config">
|
||||
<h1>Настройка</h1>
|
||||
<form id="config-form" onsubmit="event.preventDefault(); startInstall();">
|
||||
<label>ИНН организации
|
||||
<input type="text" name="orgInn" placeholder="7702077840" pattern="\d{10}|\d{12}">
|
||||
</label>
|
||||
<label>Название организации (для отображения)
|
||||
<input type="text" name="orgName" placeholder="ПАО Московская Биржа">
|
||||
</label>
|
||||
<label>Email администратора
|
||||
<input type="email" name="adminEmail" placeholder="admin@example.com">
|
||||
</label>
|
||||
<label>Лицензионный ключ (опционально)
|
||||
<input type="text" name="licenseKey" placeholder="BJ-XXXX-XXXX-XXXX">
|
||||
<span class="muted">Без ключа сервис работает, но обновления заблокированы. Получить можно в личном кабинете.</span>
|
||||
</label>
|
||||
<div class="buttons">
|
||||
<button type="button" onclick="goPrecheck()">← Назад</button>
|
||||
<button class="primary" type="submit">Установить →</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- ===== Installing ===== -->
|
||||
<section class="page" data-stage="installing">
|
||||
<h1>Установка</h1>
|
||||
<ol id="step-list" class="steps"></ol>
|
||||
<div class="progress"><div id="progress-bar" class="progress-bar"></div></div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Done ===== -->
|
||||
<section class="page" data-stage="done">
|
||||
<h1>✓ Готово</h1>
|
||||
<p>bj-server и все сервисы запущены. Откройте панель администратора и
|
||||
импортируйте профиль:</p>
|
||||
<div class="next-link">
|
||||
<a href="" id="adminLink" class="primary-link">Перейти в /admin/setup →</a>
|
||||
</div>
|
||||
<p class="muted">Что дальше:</p>
|
||||
<ol>
|
||||
<li>Подключите USB с .vdk → он автоматически смонтируется в <code>/var/lib/bj/usb/</code></li>
|
||||
<li>На <code>/admin/setup</code> загрузите .7z с профилем от MOEX и введите пароль</li>
|
||||
<li>Нажмите «Активировать» — bj-crypto подтянет ключ и подтвердит готовность</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- ===== Error ===== -->
|
||||
<section class="page" data-stage="error">
|
||||
<h1>✗ Установка прервана</h1>
|
||||
<p>Произошла ошибка:</p>
|
||||
<pre id="error-message" class="error"></pre>
|
||||
<p class="muted">Логи: <code>journalctl -u bj-installer</code> и <code>journalctl -u bj-crypto</code></p>
|
||||
<div class="buttons">
|
||||
<button onclick="resetWizard()">Начать заново</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,179 +0,0 @@
|
||||
:root {
|
||||
--bg: #f6f7fb;
|
||||
--card: #ffffff;
|
||||
--text: #1d2330;
|
||||
--muted: #6b7280;
|
||||
--accent: #2563eb;
|
||||
--accent-dark: #1d4ed8;
|
||||
--ok: #16a34a;
|
||||
--err: #dc2626;
|
||||
--border: #e5e7eb;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.topbar {
|
||||
display: flex; align-items: baseline; gap: 16px;
|
||||
padding: 18px 32px;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
}
|
||||
.logo { font-weight: 700; font-size: 18px; letter-spacing: 0.3px; }
|
||||
.subtitle { color: #94a3b8; font-size: 14px; }
|
||||
main {
|
||||
max-width: 760px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.stepper {
|
||||
display: flex; gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
padding: 12px 14px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.stepper span {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stepper span.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stepper span.done {
|
||||
color: var(--ok);
|
||||
}
|
||||
.page {
|
||||
display: none;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
.page.active { display: block; }
|
||||
h1 { margin: 0 0 16px; font-size: 22px; }
|
||||
p, label { font-size: 15px; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
code {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.buttons {
|
||||
display: flex; gap: 12px;
|
||||
margin-top: 24px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
button, .primary-link {
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
button.primary, .primary-link {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
button.primary:hover, .primary-link:hover { background: var(--accent-dark); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Precheck */
|
||||
.checks { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
|
||||
.check {
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.check.ok { border-color: var(--ok); }
|
||||
.check.bad { border-color: var(--err); }
|
||||
.check-icon { font-size: 18px; }
|
||||
.check.ok .check-icon { color: var(--ok); }
|
||||
.check.bad .check-icon { color: var(--err); }
|
||||
.check-title { font-weight: 500; }
|
||||
.check-msg { font-size: 13px; color: var(--muted); }
|
||||
|
||||
/* Config form */
|
||||
#config-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
#config-form label {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
#config-form input {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
#config-form input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
|
||||
|
||||
/* Installing steps */
|
||||
.steps { list-style: none; padding: 0; margin: 16px 0; }
|
||||
.steps li {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.steps li:last-child { border-bottom: none; }
|
||||
.step-icon { font-size: 16px; min-width: 24px; line-height: 1.5; }
|
||||
.step-title { font-weight: 500; }
|
||||
.step-msg { font-size: 12px; color: var(--muted); margin-top: 2px; word-break: break-word; }
|
||||
.step-pending .step-icon { color: var(--muted); }
|
||||
.step-running .step-icon { color: var(--accent); animation: spin 1.2s linear infinite; }
|
||||
.step-done .step-icon { color: var(--ok); }
|
||||
.step-skipped .step-icon { color: var(--muted); }
|
||||
.step-failed .step-icon { color: var(--err); }
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.progress {
|
||||
margin-top: 16px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Done */
|
||||
.next-link { margin: 20px 0; }
|
||||
|
||||
/* Error */
|
||||
.error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid var(--err);
|
||||
color: var(--err);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -1,512 +0,0 @@
|
||||
package main
|
||||
|
||||
// Доступ к кабинету: свой адрес вместо /admin, вход только из белого списка администраторов и
|
||||
// второй фактор (TOTP, RFC 6238).
|
||||
//
|
||||
// Маршруты кабинета внутри остаются /admin/…: adminGate переписывает внешний адрес (State.AdminPath)
|
||||
// во внутренний, на прямые обращения к /admin при своём адресе отвечает 404 и подменяет Location в
|
||||
// ответах. Ссылки в шаблонах строятся функцией {{adm}}. При AdminAllowOnly кабинет и страница входа
|
||||
// видны только адресам белого списка администраторов, остальным — 404. API /v1 не затрагивается.
|
||||
//
|
||||
// По SSH: bj-license-server admin-info — адрес и настройки доступа; fw-apply -off — аварийно открыть
|
||||
// кабинет всем и выключить оборону; admin-config — изменить доступ при остановленном сервере.
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsc.io/qr"
|
||||
)
|
||||
|
||||
const (
|
||||
adminDefault = "/admin"
|
||||
adminOpenFile = "admin-open" // создаёт fw-apply -off: кабинет открыт всем до повторного включения ограничения
|
||||
totpIssuer = "BJ License (lic.zetit.ru)"
|
||||
publicHost = "lic.zetit.ru"
|
||||
)
|
||||
|
||||
var adminSlugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{7,63}$`)
|
||||
|
||||
// adminBase — внешний адрес кабинета: "/admin" или "/<AdminPath>".
|
||||
func (srv *server) adminBase() string {
|
||||
base := adminDefault
|
||||
srv.st.read(func(s *State) {
|
||||
if s.AdminPath != "" {
|
||||
base = "/" + s.AdminPath
|
||||
}
|
||||
})
|
||||
return base
|
||||
}
|
||||
|
||||
func (srv *server) adminOpen() bool {
|
||||
_, err := os.Stat(filepath.Join(srv.dataDir(), adminOpenFile))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// adminIPBlocked — кабинет ограничен белым списком, а адрес в него не входит.
|
||||
func (srv *server) adminIPBlocked(ip string) bool {
|
||||
blocked := false
|
||||
srv.st.read(func(s *State) { blocked = s.AdminAllowOnly && !s.allowCovers(ip, false) })
|
||||
return blocked && !srv.adminOpen()
|
||||
}
|
||||
|
||||
// locRewriter — Location: /admin/… в ответах кабинета заменяется текущим адресом кабинета.
|
||||
type locRewriter struct {
|
||||
http.ResponseWriter
|
||||
srv *server
|
||||
done bool
|
||||
}
|
||||
|
||||
func (lw *locRewriter) fix() {
|
||||
if lw.done {
|
||||
return
|
||||
}
|
||||
lw.done = true
|
||||
h := lw.Header()
|
||||
if l := h.Get("Location"); l == adminDefault || strings.HasPrefix(l, adminDefault+"/") || strings.HasPrefix(l, adminDefault+"?") {
|
||||
h.Set("Location", lw.srv.adminBase()+strings.TrimPrefix(l, adminDefault))
|
||||
}
|
||||
}
|
||||
|
||||
func (lw *locRewriter) WriteHeader(code int) {
|
||||
lw.fix()
|
||||
lw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (lw *locRewriter) Write(b []byte) (int, error) {
|
||||
lw.fix()
|
||||
return lw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// adminGate — внешний адрес кабинета, 404 на /admin при своём адресе, ограничение белым списком.
|
||||
func (srv *server) adminGate(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
base, p := srv.adminBase(), r.URL.Path
|
||||
under := func(prefix string) bool { return p == prefix || strings.HasPrefix(p, prefix+"/") }
|
||||
switch {
|
||||
case base != adminDefault && under(adminDefault):
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
case base != adminDefault && under(base):
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = adminDefault + strings.TrimPrefix(p, base)
|
||||
r2.URL.RawPath = ""
|
||||
r = r2
|
||||
case !under(adminDefault):
|
||||
h.ServeHTTP(w, r) // API, healthz, главная
|
||||
return
|
||||
}
|
||||
if srv.adminIPBlocked(clientIP(r)) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(&locRewriter{ResponseWriter: w, srv: srv}, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (srv *server) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: srv.adminBase(), HttpOnly: true, Secure: isHTTPS(r),
|
||||
SameSite: http.SameSiteStrictMode, MaxAge: int(sessionMax.Seconds())})
|
||||
}
|
||||
|
||||
// ---- TOTP ----
|
||||
|
||||
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
func newTOTPSecret() string {
|
||||
b := make([]byte, 20)
|
||||
_, _ = rand.Read(b)
|
||||
return b32.EncodeToString(b)
|
||||
}
|
||||
|
||||
func totpAt(secret string, counter int64) (string, bool) {
|
||||
key, err := b32.DecodeString(strings.ToUpper(secret))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var msg [8]byte
|
||||
binary.BigEndian.PutUint64(msg[:], uint64(counter))
|
||||
m := hmac.New(sha1.New, key)
|
||||
m.Write(msg[:])
|
||||
sum := m.Sum(nil)
|
||||
off := sum[len(sum)-1] & 0x0f
|
||||
v := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1000000
|
||||
return fmt.Sprintf("%06d", v), true
|
||||
}
|
||||
|
||||
// totpCheck — код текущего, предыдущего или следующего 30-секундного шага, ещё не использованный
|
||||
// (шаг больше last). Возвращает шаг, чтобы запомнить его и не принять тот же код повторно.
|
||||
func totpCheck(secret, code string, last int64, now time.Time) (int64, bool) {
|
||||
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
|
||||
if len(code) != 6 {
|
||||
return 0, false
|
||||
}
|
||||
step := now.Unix() / 30
|
||||
for d := int64(-1); d <= 1; d++ {
|
||||
c := step + d
|
||||
if c <= last {
|
||||
continue
|
||||
}
|
||||
if want, ok := totpAt(secret, c); ok && hmac.Equal([]byte(want), []byte(code)) {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func totpURI(login, secret string) string {
|
||||
return "otpauth://totp/" + url.PathEscape(totpIssuer+":"+login) + "?secret=" + secret +
|
||||
"&issuer=" + url.QueryEscape(totpIssuer) + "&algorithm=SHA1&digits=6&period=30"
|
||||
}
|
||||
|
||||
// qrSVG — QR-код строкой SVG (с полем в 4 модуля).
|
||||
func qrSVG(text string) template.HTML {
|
||||
c, err := qr.Encode(text, qr.M)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
n := c.Size + 8
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="220" height="220" shape-rendering="crispEdges" role="img" aria-label="QR-код для приложения"><rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, n, n, n, n)
|
||||
for y := 0; y < c.Size; y++ {
|
||||
for x := 0; x < c.Size; x++ {
|
||||
if c.Black(x, y) {
|
||||
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+4, y+4)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString(`"/></svg>`)
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
func groupBy4(s string) string {
|
||||
var parts []string
|
||||
for i := 0; i < len(s); i += 4 {
|
||||
parts = append(parts, s[i:min(i+4, len(s))])
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// ---- страницы ----
|
||||
|
||||
type totpPageData struct {
|
||||
Enabled bool
|
||||
SecretGrouped string
|
||||
URI template.URL
|
||||
QR template.HTML
|
||||
}
|
||||
|
||||
// meTOTPPage — GET /admin/me/2fa: подключение или отключение второго фактора.
|
||||
func (srv *server) meTOTPPage(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
var d totpPageData
|
||||
srv.st.read(func(s *State) {
|
||||
if u := s.user(sess.Login); u != nil {
|
||||
d.Enabled = u.TOTPSecret != ""
|
||||
}
|
||||
})
|
||||
if !d.Enabled {
|
||||
srv.sessions.mu.Lock()
|
||||
if sess.PendingTOTP == "" {
|
||||
sess.PendingTOTP = newTOTPSecret()
|
||||
}
|
||||
secret := sess.PendingTOTP
|
||||
srv.sessions.mu.Unlock()
|
||||
uri := totpURI(sess.Login, secret)
|
||||
d.SecretGrouped, d.URI, d.QR = groupBy4(secret), template.URL(uri), qrSVG(uri)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
srv.render(w, r, sess, "totp", "Второй фактор", "users", d)
|
||||
}
|
||||
|
||||
func (srv *server) meTOTPEnable(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
srv.sessions.mu.Lock()
|
||||
secret := sess.PendingTOTP
|
||||
srv.sessions.mu.Unlock()
|
||||
if secret == "" {
|
||||
back(w, r, "/admin/me/2fa", "", "обновите страницу и добавьте ключ в приложение заново")
|
||||
return
|
||||
}
|
||||
step, ok := totpCheck(secret, r.PostFormValue("code"), 0, time.Now())
|
||||
if !ok {
|
||||
back(w, r, "/admin/me/2fa", "", "код не подошёл: проверьте, что время на телефоне точное, и введите текущий код")
|
||||
return
|
||||
}
|
||||
err := srv.st.write(func(s *State) error {
|
||||
u := s.user(sess.Login)
|
||||
if u == nil {
|
||||
return fmt.Errorf("администратор не найден")
|
||||
}
|
||||
u.TOTPSecret, u.TOTPLast = secret, step
|
||||
s.addEvent("totp_on", "", "", sess.Login, clientIP(r), "подключён второй фактор входа")
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
back(w, r, "/admin/me/2fa", "", err.Error())
|
||||
return
|
||||
}
|
||||
srv.sessions.mu.Lock()
|
||||
sess.PendingTOTP = ""
|
||||
srv.sessions.mu.Unlock()
|
||||
back(w, r, "/admin/me/2fa", "Второй фактор подключён: при входе теперь нужен код из приложения.", "")
|
||||
}
|
||||
|
||||
func (srv *server) meTOTPDisable(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
var bad string
|
||||
err := srv.st.write(func(s *State) error {
|
||||
u := s.user(sess.Login)
|
||||
if u == nil || u.TOTPSecret == "" {
|
||||
bad = "второй фактор не подключён"
|
||||
return nil
|
||||
}
|
||||
if _, ok := totpCheck(u.TOTPSecret, r.PostFormValue("code"), 0, time.Now()); !ok {
|
||||
bad = "код не подошёл"
|
||||
return nil
|
||||
}
|
||||
u.TOTPSecret, u.TOTPLast = "", 0
|
||||
s.addEvent("totp_off", "", "", sess.Login, clientIP(r), "второй фактор входа отключён")
|
||||
return nil
|
||||
})
|
||||
if err != nil || bad != "" {
|
||||
back(w, r, "/admin/me/2fa", "", pickStr(bad, fmt.Sprint(err)))
|
||||
return
|
||||
}
|
||||
back(w, r, "/admin/me/2fa", "Второй фактор отключён.", "")
|
||||
}
|
||||
|
||||
// userTOTPReset — POST /admin/users/{login}/2fa-reset: сброс второго фактора другого администратора.
|
||||
func (srv *server) userTOTPReset(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
login := r.PathValue("login")
|
||||
if strings.EqualFold(login, sess.Login) {
|
||||
back(w, r, "/admin/users", "", "свой второй фактор отключается на странице «Мой второй фактор» с кодом из приложения")
|
||||
return
|
||||
}
|
||||
err := srv.st.write(func(s *State) error {
|
||||
u := s.user(login)
|
||||
if u == nil {
|
||||
return fmt.Errorf("администратор не найден")
|
||||
}
|
||||
u.TOTPSecret, u.TOTPLast = "", 0
|
||||
s.addEvent("totp_reset", "", "", sess.Login, clientIP(r), "сброшен второй фактор администратора "+u.Login)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
back(w, r, "/admin/users", "", err.Error())
|
||||
return
|
||||
}
|
||||
srv.sessions.dropUser(login, "")
|
||||
back(w, r, "/admin/users", "Второй фактор "+login+" сброшен, его сессии завершены. При следующем входе хватит пароля.", "")
|
||||
}
|
||||
|
||||
// securityAccess — POST /admin/security/access: адрес кабинета и ограничение белым списком.
|
||||
func (srv *server) securityAccess(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
op, myIP := r.PostFormValue("op"), clientIP(r)
|
||||
oldBase := srv.adminBase()
|
||||
var bad, okMsg string
|
||||
err := srv.st.write(func(s *State) error {
|
||||
switch op {
|
||||
case "path", "path-random", "path-default":
|
||||
slug := strings.ToLower(strings.Trim(strings.TrimSpace(r.PostFormValue("path")), "/"))
|
||||
switch {
|
||||
case op == "path-random":
|
||||
slug = "k" + randHex(12)
|
||||
case op == "path-default" || slug == "admin":
|
||||
slug = ""
|
||||
case !adminSlugRe.MatchString(slug):
|
||||
bad = "адрес: от 8 до 64 символов — латинские строчные буквы, цифры и дефис"
|
||||
return nil
|
||||
}
|
||||
if slug == s.AdminPath {
|
||||
bad = "адрес не изменился"
|
||||
return nil
|
||||
}
|
||||
s.AdminPath = slug
|
||||
shown := adminDefault
|
||||
if slug != "" {
|
||||
shown = "/" + slug
|
||||
}
|
||||
s.addEvent("admin_path", "", "", sess.Login, myIP, "адрес кабинета изменён на "+shown)
|
||||
okMsg = "Новый адрес кабинета: https://" + publicHost + shown + "/ — сохраните его в закладки. Прежний адрес больше не открывается."
|
||||
case "allow-only-on":
|
||||
if n, ok := normalizeNet(myIP); ok && !s.allowCovers(myIP, false) {
|
||||
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: "добавлен автоматически при ограничении кабинета", AddedBy: sess.Login, AddedAt: time.Now()})
|
||||
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список (ограничение кабинета)")
|
||||
}
|
||||
s.AdminAllowOnly = true
|
||||
s.addEvent("admin_allow_only_on", "", "", sess.Login, myIP, "кабинет доступен только из белого списка администраторов")
|
||||
_ = os.Remove(filepath.Join(srv.dataDir(), adminOpenFile))
|
||||
okMsg = "Кабинет открывается только с адресов белого списка, остальным — «не найдено»."
|
||||
case "allow-only-off":
|
||||
s.AdminAllowOnly = false
|
||||
s.addEvent("admin_allow_only_off", "", "", sess.Login, myIP, "ограничение кабинета белым списком снято")
|
||||
okMsg = "Ограничение снято: страница входа доступна с любого адреса."
|
||||
default:
|
||||
bad = "неизвестная команда"
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || bad != "" {
|
||||
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
|
||||
return
|
||||
}
|
||||
if newBase := srv.adminBase(); newBase != oldBase {
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: oldBase, HttpOnly: true, Secure: isHTTPS(r), MaxAge: -1})
|
||||
srv.setSessionCookie(w, r, sess.Token)
|
||||
}
|
||||
back(w, r, "/admin/security", okMsg, "")
|
||||
}
|
||||
|
||||
// ---- консоль ----
|
||||
|
||||
type multiFlag []string
|
||||
|
||||
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
|
||||
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }
|
||||
|
||||
// adminConfig — `bj-license-server admin-config`: доступ к кабинету из консоли (сервер остановлен).
|
||||
func adminConfig(args []string) {
|
||||
fs := flag.NewFlagSet("admin-config", flag.ExitOnError)
|
||||
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
|
||||
var allow multiFlag
|
||||
fs.Var(&allow, "allow", "добавить в белый список администраторов: IP[=комментарий] (флаг можно повторять)")
|
||||
path := fs.String("path", "", "адрес кабинета: свой (8–64 символа a-z, 0-9, дефис), random — сгенерировать, admin — стандартный")
|
||||
only := fs.String("allow-only", "", "кабинет только из белого списка: on | off")
|
||||
reset := fs.String("reset-2fa", "", "сбросить второй фактор администратора (логин)")
|
||||
zkFlag := fs.String("zetit-key", "", "вход через ЗЕТИТ Ключ: off — выключить (аварийно)")
|
||||
_ = fs.Parse(args)
|
||||
st, err := openStore(*data)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
err = st.write(func(s *State) error {
|
||||
now := time.Now()
|
||||
for _, a := range allow {
|
||||
ipPart, comment, _ := strings.Cut(a, "=")
|
||||
n, ok := normalizeNet(ipPart)
|
||||
if !ok {
|
||||
return fmt.Errorf("неверный адрес %q", ipPart)
|
||||
}
|
||||
exists := false
|
||||
for _, x := range s.AllowIPs {
|
||||
exists = exists || x.Net == n
|
||||
}
|
||||
if !exists {
|
||||
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: strings.TrimSpace(comment), AddedBy: "консоль", AddedAt: now})
|
||||
s.addEvent("fw_allow_add", "", "", "консоль", "", "IP "+n+" добавлен в белый список")
|
||||
}
|
||||
}
|
||||
switch p := strings.ToLower(strings.Trim(*path, "/")); {
|
||||
case p == "":
|
||||
case p == "admin":
|
||||
s.AdminPath = ""
|
||||
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета: стандартный /admin")
|
||||
case p == "random":
|
||||
s.AdminPath = "k" + randHex(12)
|
||||
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета изменён")
|
||||
case adminSlugRe.MatchString(p):
|
||||
s.AdminPath = p
|
||||
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета изменён")
|
||||
default:
|
||||
return fmt.Errorf("адрес: 8–64 символа, латинские строчные буквы, цифры и дефис")
|
||||
}
|
||||
switch *only {
|
||||
case "":
|
||||
case "on":
|
||||
if len(s.AllowIPs) == 0 {
|
||||
return fmt.Errorf("белый список пуст — ограничение закроет кабинет всем")
|
||||
}
|
||||
s.AdminAllowOnly = true
|
||||
s.addEvent("admin_allow_only_on", "", "", "консоль", "", "кабинет доступен только из белого списка администраторов")
|
||||
case "off":
|
||||
s.AdminAllowOnly = false
|
||||
s.addEvent("admin_allow_only_off", "", "", "консоль", "", "ограничение кабинета белым списком снято")
|
||||
default:
|
||||
return fmt.Errorf("-allow-only: on или off")
|
||||
}
|
||||
switch *zkFlag {
|
||||
case "":
|
||||
case "off":
|
||||
s.ZetitKey.Enabled = false
|
||||
s.addEvent("zetit_key", "", "", "консоль", "", "вход через ЗЕТИТ Ключ выключен")
|
||||
default:
|
||||
return fmt.Errorf("-zetit-key: только off")
|
||||
}
|
||||
if *reset != "" {
|
||||
u := s.user(*reset)
|
||||
if u == nil {
|
||||
return fmt.Errorf("администратор %s не найден", *reset)
|
||||
}
|
||||
u.TOTPSecret, u.TOTPLast = "", 0
|
||||
s.addEvent("totp_reset", "", "", "консоль", "", "сброшен второй фактор администратора "+u.Login)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if *only == "on" {
|
||||
_ = os.Remove(filepath.Join(filepath.Dir(*data), adminOpenFile))
|
||||
}
|
||||
printAccess(st)
|
||||
}
|
||||
|
||||
// adminInfo — `bj-license-server admin-info`: адрес и настройки доступа (только чтение).
|
||||
func adminInfo(args []string) {
|
||||
fs := flag.NewFlagSet("admin-info", flag.ExitOnError)
|
||||
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
|
||||
_ = fs.Parse(args)
|
||||
st, err := openStore(*data)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
printAccess(st)
|
||||
}
|
||||
|
||||
func printAccess(st *store) {
|
||||
_, openErr := os.Stat(filepath.Join(filepath.Dir(st.path), adminOpenFile))
|
||||
st.read(func(s *State) {
|
||||
path := adminDefault
|
||||
if s.AdminPath != "" {
|
||||
path = "/" + s.AdminPath
|
||||
}
|
||||
fmt.Printf("Адрес кабинета: https://%s%s/\n", publicHost, path)
|
||||
switch {
|
||||
case s.AdminAllowOnly && openErr == nil:
|
||||
fmt.Println("Только из белого списка: включено, но кабинет открыт аварийно (fw-apply -off)")
|
||||
case s.AdminAllowOnly:
|
||||
fmt.Println("Только из белого списка: включено")
|
||||
default:
|
||||
fmt.Println("Только из белого списка: выключено")
|
||||
}
|
||||
fmt.Println("Белый список администраторов:")
|
||||
for _, a := range s.AllowIPs {
|
||||
fmt.Printf(" %-20s %s\n", a.Net, a.Comment)
|
||||
}
|
||||
if s.ZetitKey.Enabled {
|
||||
fmt.Println("Вход через ЗЕТИТ Ключ: включён (" + pickStr(s.ZetitKey.URL, zetitKeyDefaultURL) + ")")
|
||||
} else {
|
||||
fmt.Println("Вход через ЗЕТИТ Ключ: выключен")
|
||||
}
|
||||
fmt.Println("Администраторы (второй фактор):")
|
||||
for _, u := range s.Users {
|
||||
state := "не подключён"
|
||||
if u.TOTPSecret != "" {
|
||||
state = "подключён"
|
||||
}
|
||||
fmt.Printf(" %-24s TOTP %s, e-mail %s\n", u.Login, state, pickStr(u.Email, "не указан"))
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,291 +0,0 @@
|
||||
package main
|
||||
|
||||
// API для установок bj: активация по коду и продление токена (docs/license-server-api.md).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenTTL = 60 * 24 * time.Hour
|
||||
renewAfterSec = 86400
|
||||
)
|
||||
|
||||
type apiRequest struct {
|
||||
Code string `json:"code"`
|
||||
Token string `json:"token"`
|
||||
InstallID string `json:"install_id"`
|
||||
Machine string `json:"machine"`
|
||||
Hostname string `json:"hostname"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
Token string `json:"token"`
|
||||
LicenseID string `json:"license_id"`
|
||||
Tenant string `json:"tenant"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ContractUntil time.Time `json:"contract_until"`
|
||||
RenewAfterSec int `json:"renew_after_sec"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Status int `json:"-"`
|
||||
Code string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func apiErr(status int, code, msg string) *apiError {
|
||||
return &apiError{Status: status, Code: code, Message: msg}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if r := []rune(s); len(r) > n {
|
||||
return string(r[:n])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (srv *server) decodeAPI(w http.ResponseWriter, r *http.Request) (*apiRequest, bool) {
|
||||
if !srv.apiRate.allow(clientIP(r)) {
|
||||
writeJSON(w, http.StatusTooManyRequests, apiErr(429, "rate_limited", "слишком много запросов, повторите позже"))
|
||||
return nil, false
|
||||
}
|
||||
var in apiRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiErr(400, "bad_request", "тело запроса — JSON по docs/license-server-api.md"))
|
||||
return nil, false
|
||||
}
|
||||
in.InstallID = strings.ToLower(strings.TrimSpace(in.InstallID))
|
||||
if !uuidRe.MatchString(in.InstallID) {
|
||||
writeJSON(w, http.StatusBadRequest, apiErr(400, "bad_request", "install_id: ожидается UUID"))
|
||||
return nil, false
|
||||
}
|
||||
in.Machine, in.Hostname, in.Version = clip(in.Machine, 128), clip(in.Hostname, 128), clip(in.Version, 40)
|
||||
return &in, true
|
||||
}
|
||||
|
||||
// issueToken — подписанная лицензия под установку: срок токена 60 дней, не дольше договора.
|
||||
func (srv *server) issueToken(s *State, lic *License, installID string, now time.Time) (string, time.Time, error) {
|
||||
exp := now.Add(tokenTTL)
|
||||
if !lic.ContractUntil.IsZero() && lic.ContractUntil.Before(exp) {
|
||||
exp = lic.ContractUntil
|
||||
}
|
||||
tenant := ""
|
||||
if t := s.tenant(lic.TenantID); t != nil {
|
||||
tenant = t.Name
|
||||
}
|
||||
l := &license.License{
|
||||
Schema: license.CurrentSchema, ID: lic.ID, Tenant: tenant, Product: "bj-server",
|
||||
Plan: license.Plan(lic.Plan), IssuedAt: now.UTC(), ExpiresAt: exp.UTC(),
|
||||
Features: lic.Features, MaxNodes: lic.MaxNodes, InstallID: installID, ContractUntil: lic.ContractUntil.UTC(),
|
||||
}
|
||||
tok, err := license.Sign(l, srv.priv, srv.keyID)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
return tok.Encode(), exp, nil
|
||||
}
|
||||
|
||||
func (srv *server) answer(s *State, lic *License, token string, exp time.Time) *apiResponse {
|
||||
tenant := ""
|
||||
if t := s.tenant(lic.TenantID); t != nil {
|
||||
tenant = t.Name
|
||||
}
|
||||
return &apiResponse{Token: token, LicenseID: lic.ID, Tenant: tenant, ExpiresAt: exp.UTC(), ContractUntil: lic.ContractUntil.UTC(), RenewAfterSec: renewAfterSec}
|
||||
}
|
||||
|
||||
// touchInstall — сведения об установке из запроса; смена machine-id — предупреждение в журнал.
|
||||
func touchInstall(s *State, inst *Install, in *apiRequest, ip string, now time.Time) {
|
||||
if in.Machine != "" && inst.Machine != "" && in.Machine != inst.Machine {
|
||||
s.addEvent("machine_changed", inst.LicenseID, inst.InstallID, "api", ip,
|
||||
fmt.Sprintf("сменился machine-id установки (%s → %s): возможно, копия сервера", short(inst.Machine), short(in.Machine)))
|
||||
}
|
||||
if in.Machine != "" {
|
||||
inst.Machine = in.Machine
|
||||
}
|
||||
if in.Hostname != "" {
|
||||
inst.Hostname = in.Hostname
|
||||
}
|
||||
if in.Version != "" {
|
||||
inst.Version = in.Version
|
||||
}
|
||||
inst.IP, inst.LastSeen = ip, now
|
||||
}
|
||||
|
||||
// trustInstallIP — после успешной активации или продления адрес установки попадает в доверенные fail2ban.
|
||||
func trustInstallIP(s *State, inst *Install, ip string, now time.Time) {
|
||||
if s.trustIP(inst, ip, now) {
|
||||
s.addEvent("trust_ip", inst.LicenseID, inst.InstallID, "api", ip, "IP "+ip+" добавлен в доверенные fail2ban")
|
||||
}
|
||||
}
|
||||
|
||||
func hostNote(in *apiRequest) string {
|
||||
if in.Hostname == "" {
|
||||
return ""
|
||||
}
|
||||
return " (" + in.Hostname + ")"
|
||||
}
|
||||
|
||||
// POST /v1/activate
|
||||
func (srv *server) apiActivate(w http.ResponseWriter, r *http.Request) {
|
||||
in, ok := srv.decodeAPI(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ip := clientIP(r)
|
||||
code := normalizeCode(in.Code)
|
||||
var resp *apiResponse
|
||||
var fail *apiError
|
||||
err := srv.st.write(func(s *State) error {
|
||||
now := time.Now()
|
||||
var lic *License
|
||||
if code != "" {
|
||||
lic = s.licenseByCode(code)
|
||||
}
|
||||
if lic == nil {
|
||||
fail = apiErr(http.StatusNotFound, "unknown_code", "код активации не найден")
|
||||
log.Printf("api denied: unknown_code ip=%s", ip) // строку ловит fail2ban
|
||||
s.addEvent("activate_denied", "", in.InstallID, "api", ip, "неизвестный код активации"+hostNote(in))
|
||||
return nil
|
||||
}
|
||||
inst := s.install(in.InstallID)
|
||||
switch {
|
||||
case lic.Revoked():
|
||||
fail = apiErr(http.StatusForbidden, "revoked", "лицензия отозвана")
|
||||
case lic.ContractExpired(now):
|
||||
fail = apiErr(http.StatusForbidden, "contract_expired", "срок договора истёк "+lic.ContractUntil.In(msk).Format("02.01.2006"))
|
||||
case inst != nil && inst.Blocked:
|
||||
fail = apiErr(http.StatusForbidden, "install_blocked", "установка заблокирована: "+inst.BlockReason)
|
||||
case (inst == nil || inst.LicenseID != lic.ID) && lic.MaxNodes > 0 && s.activeInstalls(lic.ID) >= lic.MaxNodes:
|
||||
fail = apiErr(http.StatusConflict, "max_nodes", fmt.Sprintf("исчерпан лимит установок по лицензии (%d)", lic.MaxNodes))
|
||||
}
|
||||
if fail != nil {
|
||||
s.addEvent("activate_denied", lic.ID, in.InstallID, "api", ip, fail.Message+hostNote(in))
|
||||
return nil
|
||||
}
|
||||
text := "активирована новая установка" + hostNote(in)
|
||||
switch {
|
||||
case inst == nil:
|
||||
inst = &Install{InstallID: in.InstallID, FirstSeen: now}
|
||||
s.Installs = append(s.Installs, inst)
|
||||
case inst.LicenseID != lic.ID:
|
||||
text = "установка переведена на эту лицензию с " + short(inst.LicenseID) + hostNote(in)
|
||||
default:
|
||||
text = "повторная активация установки" + hostNote(in)
|
||||
}
|
||||
inst.LicenseID = lic.ID
|
||||
touchInstall(s, inst, in, ip, now)
|
||||
trustInstallIP(s, inst, ip, now)
|
||||
token, exp, err := srv.issueToken(s, lic, inst.InstallID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inst.TokenExpires = exp
|
||||
s.addEvent("activate", lic.ID, inst.InstallID, "api", ip, text+", токен до "+exp.In(msk).Format("02.01.2006"))
|
||||
resp = srv.answer(s, lic, token, exp)
|
||||
return nil
|
||||
})
|
||||
srv.finishAPI(w, resp, fail, err)
|
||||
}
|
||||
|
||||
// POST /v1/renew
|
||||
func (srv *server) apiRenew(w http.ResponseWriter, r *http.Request) {
|
||||
in, ok := srv.decodeAPI(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ip := clientIP(r)
|
||||
tok, err := license.DecodeToken(in.Token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, apiErr(401, "bad_token", "токен не разобран"))
|
||||
return
|
||||
}
|
||||
l, err := license.Verify(tok, srv.pub)
|
||||
if err != nil {
|
||||
log.Printf("api denied: bad_token ip=%s", ip) // строку ловит fail2ban
|
||||
writeJSON(w, http.StatusUnauthorized, apiErr(401, "bad_token", "подпись токена недействительна"))
|
||||
return
|
||||
}
|
||||
if l.InstallID == "" || !strings.EqualFold(l.InstallID, in.InstallID) {
|
||||
writeJSON(w, http.StatusForbidden, apiErr(403, "install_mismatch", "токен выдан другой установке"))
|
||||
return
|
||||
}
|
||||
var resp *apiResponse
|
||||
var fail *apiError
|
||||
err = srv.st.write(func(s *State) error {
|
||||
now := time.Now()
|
||||
lic := s.license(l.ID)
|
||||
if lic == nil {
|
||||
fail = apiErr(http.StatusUnauthorized, "bad_token", "лицензия из токена не найдена")
|
||||
return nil
|
||||
}
|
||||
inst := s.install(in.InstallID)
|
||||
switch {
|
||||
case inst == nil || inst.LicenseID != lic.ID:
|
||||
fail = apiErr(http.StatusNotFound, "unknown_install", "установка не зарегистрирована по этой лицензии — активируйте кодом")
|
||||
case lic.Revoked():
|
||||
fail = apiErr(http.StatusForbidden, "revoked", "лицензия отозвана")
|
||||
case lic.ContractExpired(now):
|
||||
fail = apiErr(http.StatusForbidden, "contract_expired", "срок договора истёк "+lic.ContractUntil.In(msk).Format("02.01.2006"))
|
||||
case inst.Blocked:
|
||||
fail = apiErr(http.StatusForbidden, "install_blocked", "установка заблокирована: "+inst.BlockReason)
|
||||
}
|
||||
if fail != nil {
|
||||
s.addEvent("renew_denied", lic.ID, in.InstallID, "api", ip, fail.Message+hostNote(in))
|
||||
return nil
|
||||
}
|
||||
touchInstall(s, inst, in, ip, now)
|
||||
trustInstallIP(s, inst, ip, now)
|
||||
token, exp, err := srv.issueToken(s, lic, inst.InstallID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inst.TokenExpires = exp
|
||||
s.addEvent("renew", lic.ID, inst.InstallID, "api", ip, fmt.Sprintf("продление%s, версия %s, токен до %s", hostNote(in), pickStr(in.Version, "—"), exp.In(msk).Format("02.01.2006")))
|
||||
resp = srv.answer(s, lic, token, exp)
|
||||
return nil
|
||||
})
|
||||
srv.finishAPI(w, resp, fail, err)
|
||||
}
|
||||
|
||||
func (srv *server) finishAPI(w http.ResponseWriter, resp *apiResponse, fail *apiError, err error) {
|
||||
switch {
|
||||
case err != nil:
|
||||
writeJSON(w, http.StatusInternalServerError, apiErr(500, "internal", "внутренняя ошибка сервера лицензий"))
|
||||
case fail != nil:
|
||||
writeJSON(w, fail.Status, fail)
|
||||
default:
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func pickStr(v, def string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func short(id string) string {
|
||||
if len(id) > 8 {
|
||||
return id[:8]
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -1,917 +0,0 @@
|
||||
package main
|
||||
|
||||
// Защита сервера лицензий из кабинета: fail2ban и «режим обороны».
|
||||
//
|
||||
// Кабинет работает непривилегированно (пользователь bj-license) и только пишет желаемое
|
||||
// состояние рядом с state.json: белый список администраторов (allow-ips.txt) и режим с
|
||||
// разовыми командами fail2ban (fw-control.json). Применяет их root-помощник
|
||||
// `bj-license-server fw-apply` — systemd запускает его при изменении fw-control.json
|
||||
// (bj-license-fw.path) и раз в 30 секунд (bj-license-fw.timer); фактическое состояние
|
||||
// он пишет в fw-status.json, его и показывает кабинет.
|
||||
//
|
||||
// Режим обороны: на порты 80/443 пускаются только адреса из белого списка (ipset
|
||||
// bj-allow / bj-allow6) — доверенные IP установок (trusted-ips.txt) и адреса
|
||||
// администраторов (allow-ips.txt). SSH не затрагивается. Аварийное выключение по SSH:
|
||||
//
|
||||
// /opt/bj-license/bj-license-server fw-apply -off
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
fwControlFile = "fw-control.json"
|
||||
fwStatusFile = "fw-status.json"
|
||||
fwAllowFile = "allow-ips.txt"
|
||||
fwOffFile = "fw-off" // создаёт fw-apply -off; удаляет кабинет при повторном включении обороны
|
||||
fwSetV4 = "bj-allow"
|
||||
fwSetV6 = "bj-allow6"
|
||||
fwRuleComment = "bj-license-lockdown"
|
||||
fwStatusStale = 2 * time.Minute
|
||||
)
|
||||
|
||||
// fwJails — jail fail2ban, которыми управляет кабинет.
|
||||
var fwJails = []string{"bj-license-api", "bj-license-admin", "sshd"}
|
||||
|
||||
// AllowIP — адрес или сеть администратора в белом списке.
|
||||
type AllowIP struct {
|
||||
Net string `json:"net"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
AddedBy string `json:"added_by,omitempty"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
}
|
||||
|
||||
// FirewallState — желаемое состояние защиты: пишет кабинет, применяет fw-apply.
|
||||
type FirewallState struct {
|
||||
Mode string `json:"mode,omitempty"` // "" — обычный, lockdown — оборона
|
||||
Until time.Time `json:"until,omitempty"`
|
||||
UpdatedBy string `json:"updated_by,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Actions []fwAction `json:"actions,omitempty"`
|
||||
// Параметры блокировок jail (секунды; bantime -1 — навсегда). Пусто — значения из jail.d/bj-license.conf.
|
||||
Jails map[string]fwJailConf `json:"jails,omitempty"`
|
||||
// Блокировка входа в кабинет самим приложением (до fail2ban): попыток и срок.
|
||||
LoginAttempts int `json:"login_attempts,omitempty"`
|
||||
LoginLockSec int64 `json:"login_lock_sec,omitempty"`
|
||||
}
|
||||
|
||||
// fwJailConf — параметры блокировки jail.
|
||||
type fwJailConf struct {
|
||||
BanTime int64 `json:"bantime"`
|
||||
FindTime int64 `json:"findtime"`
|
||||
MaxRetry int `json:"maxretry"`
|
||||
}
|
||||
|
||||
// fwDefaultJail — значения из deploy/license/fail2ban/jail.d/bj-license.conf.
|
||||
func fwDefaultJail(name string) fwJailConf {
|
||||
if name == "sshd" {
|
||||
return fwJailConf{BanTime: 3600, FindTime: 600, MaxRetry: 5}
|
||||
}
|
||||
return fwJailConf{BanTime: 3600, FindTime: 600, MaxRetry: 10}
|
||||
}
|
||||
|
||||
type fwPreset struct {
|
||||
Sec int64
|
||||
Label string
|
||||
}
|
||||
|
||||
var (
|
||||
fwBanPresets = []fwPreset{{900, "15 минут"}, {3600, "1 час"}, {7200, "2 часа"}, {21600, "6 часов"}, {43200, "12 часов"},
|
||||
{86400, "1 день"}, {259200, "3 дня"}, {604800, "1 неделя"}, {2592000, "1 месяц"}, {7776000, "3 месяца"},
|
||||
{31536000, "1 год"}, {-1, "навсегда"}}
|
||||
fwFindPresets = []fwPreset{{600, "10 минут"}, {3600, "1 час"}, {21600, "6 часов"}, {86400, "1 день"}, {604800, "1 неделя"}}
|
||||
fwRetryPresets = []int{3, 5, 10, 20}
|
||||
)
|
||||
|
||||
// durRu — срок по-русски: из пресетов или в секундах.
|
||||
func durRu(sec int64) string {
|
||||
for _, p := range fwBanPresets {
|
||||
if p.Sec == sec {
|
||||
return p.Label
|
||||
}
|
||||
}
|
||||
for _, p := range fwFindPresets {
|
||||
if p.Sec == sec {
|
||||
return p.Label
|
||||
}
|
||||
}
|
||||
return strconv.FormatInt(sec, 10) + " с"
|
||||
}
|
||||
|
||||
func fwPresetOK(list []fwPreset, sec int64) bool {
|
||||
for _, p := range list {
|
||||
if p.Sec == sec {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// loginLimitOf — ограничение входа в кабинет: попыток и срок (по умолчанию 8 за 15 минут).
|
||||
func loginLimitOf(s *State) (int, time.Duration) {
|
||||
n, sec := s.Firewall.LoginAttempts, s.Firewall.LoginLockSec
|
||||
if n <= 0 {
|
||||
n = 8
|
||||
}
|
||||
if sec <= 0 {
|
||||
sec = 900
|
||||
}
|
||||
return n, time.Duration(sec) * time.Second
|
||||
}
|
||||
|
||||
// fwAction — разовая команда fail2ban из кабинета.
|
||||
type fwAction struct {
|
||||
ID string `json:"id"`
|
||||
Op string `json:"op"` // ban | unban
|
||||
Jail string `json:"jail"`
|
||||
IP string `json:"ip"`
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
type fwJailStatus struct {
|
||||
Name string `json:"name"`
|
||||
BanTime int64 `json:"bantime"`
|
||||
FindTime int64 `json:"findtime"`
|
||||
MaxRetry int `json:"maxretry"`
|
||||
CurrentlyFailed int `json:"currently_failed"`
|
||||
TotalFailed int `json:"total_failed"`
|
||||
CurrentlyBanned int `json:"currently_banned"`
|
||||
TotalBanned int `json:"total_banned"`
|
||||
Banned []string `json:"banned"`
|
||||
}
|
||||
|
||||
// fwStatus — фактическое состояние, пишет fw-apply.
|
||||
type fwStatus struct {
|
||||
At time.Time `json:"at"`
|
||||
Lockdown bool `json:"lockdown"` // правило обороны действует
|
||||
Emergency bool `json:"emergency"` // оборона выключена аварийно (fw-apply -off)
|
||||
Until time.Time `json:"until,omitempty"`
|
||||
AllowCount int `json:"allow_count"`
|
||||
Jails []fwJailStatus `json:"jails"`
|
||||
Done []string `json:"done,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// normalizeNet — IP или сеть в каноническом виде; слишком широкие сети не принимаются.
|
||||
func normalizeNet(s string) (string, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if ip := net.ParseIP(s); ip != nil {
|
||||
return ip.String(), true
|
||||
}
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
ones, bits := n.Mask.Size()
|
||||
if (bits == 32 && ones < 16) || (bits == 128 && ones < 32) {
|
||||
return "", false
|
||||
}
|
||||
return n.String(), true
|
||||
}
|
||||
|
||||
// netContains — адрес ip попадает в IP или сеть entry.
|
||||
func netContains(entry, ip string) bool {
|
||||
p := net.ParseIP(strings.TrimSpace(ip))
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
if e := net.ParseIP(entry); e != nil {
|
||||
return e.Equal(p)
|
||||
}
|
||||
_, n, err := net.ParseCIDR(entry)
|
||||
return err == nil && n.Contains(p)
|
||||
}
|
||||
|
||||
// allowCovers — адрес в белом списке администраторов (includeTrusted — или среди доверенных установок).
|
||||
func (s *State) allowCovers(ip string, includeTrusted bool) bool {
|
||||
for _, a := range s.AllowIPs {
|
||||
if netContains(a.Net, ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if includeTrusted {
|
||||
for _, t := range s.trustedIPs() {
|
||||
if netContains(t, ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// writeIfChanged — атомарная запись файла, только если содержимое изменилось.
|
||||
func writeIfChanged(path string, data []byte, perm os.FileMode) error {
|
||||
if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, data) {
|
||||
return nil
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// writeFirewallLocked — allow-ips.txt и fw-control.json для fw-apply; вызывать под блокировкой.
|
||||
func (st *store) writeFirewallLocked() error {
|
||||
dir := filepath.Dir(st.path)
|
||||
body := "# Белый список администраторов (IP или сеть). Файл пишет bj-license-server.\n"
|
||||
for _, a := range st.s.AllowIPs {
|
||||
body += a.Net + "\n"
|
||||
}
|
||||
if err := writeIfChanged(filepath.Join(dir, fwAllowFile), []byte(body), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(st.s.Firewall, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeIfChanged(filepath.Join(dir, fwControlFile), b, 0o600)
|
||||
}
|
||||
|
||||
func fwReadStatus(dir string) fwStatus {
|
||||
var st fwStatus
|
||||
if b, err := os.ReadFile(filepath.Join(dir, fwStatusFile)); err == nil {
|
||||
_ = json.Unmarshal(b, &st)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// ---- root-помощник ----
|
||||
|
||||
// fwApply — `bj-license-server fw-apply [-dir …] [-off]`: применить желаемое состояние (от root).
|
||||
func fwApply(args []string) {
|
||||
fs := flag.NewFlagSet("fw-apply", flag.ExitOnError)
|
||||
dir := fs.String("dir", "/var/lib/bj-license", "каталог данных сервера лицензий")
|
||||
off := fs.Bool("off", false, "аварийно выключить режим обороны (до повторного включения в кабинете)")
|
||||
_ = fs.Parse(args)
|
||||
if os.Geteuid() != 0 {
|
||||
log.Fatal("fw-apply запускается от root")
|
||||
}
|
||||
if *off {
|
||||
if err := os.WriteFile(filepath.Join(*dir, fwOffFile), []byte(time.Now().Format(time.RFC3339)+"\n"), 0o644); err != nil {
|
||||
log.Fatalf("аварийное выключение: %v", err)
|
||||
}
|
||||
chownLikeDir(*dir, filepath.Join(*dir, fwOffFile))
|
||||
// Заодно открыть кабинет всем адресам, если он ограничен белым списком (access.go).
|
||||
if err := os.WriteFile(filepath.Join(*dir, adminOpenFile), []byte(time.Now().Format(time.RFC3339)+"\n"), 0o644); err == nil {
|
||||
chownLikeDir(*dir, filepath.Join(*dir, adminOpenFile))
|
||||
}
|
||||
}
|
||||
st := fwRun(*dir)
|
||||
if err := fwWriteStatus(*dir, st); err != nil {
|
||||
log.Printf("fw-status.json: %v", err)
|
||||
}
|
||||
for _, e := range st.Errors {
|
||||
log.Print(e)
|
||||
}
|
||||
if *off {
|
||||
fmt.Println("Режим обороны выключен аварийно, кабинет открыт для всех адресов. Включить снова — в кабинете, раздел «Защита».")
|
||||
fmt.Println("Адрес кабинета: bj-license-server admin-info")
|
||||
}
|
||||
}
|
||||
|
||||
func fwRun(dir string) fwStatus {
|
||||
now := time.Now()
|
||||
st := fwStatus{At: now}
|
||||
prev := fwReadStatus(dir)
|
||||
var ctl FirewallState
|
||||
if b, err := os.ReadFile(filepath.Join(dir, fwControlFile)); err == nil {
|
||||
if err := json.Unmarshal(b, &ctl); err != nil {
|
||||
st.Errors = append(st.Errors, "fw-control.json: "+err.Error())
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, fwOffFile)); err == nil {
|
||||
st.Emergency = true
|
||||
}
|
||||
want := ctl.Mode == "lockdown" && !st.Emergency && (ctl.Until.IsZero() || now.Before(ctl.Until))
|
||||
|
||||
v4, v6 := fwReadAllow(dir)
|
||||
st.AllowCount = len(v4) + len(v6)
|
||||
if err := fwSyncSet(fwSetV4, "inet", v4); err != nil {
|
||||
st.Errors = append(st.Errors, err.Error())
|
||||
want = false
|
||||
}
|
||||
if err := fwSyncSet(fwSetV6, "inet6", v6); err != nil {
|
||||
st.Errors = append(st.Errors, err.Error())
|
||||
want = false
|
||||
}
|
||||
if want && st.AllowCount == 0 {
|
||||
st.Errors = append(st.Errors, "белый список пуст — режим обороны не включён, иначе доступ закроется всем")
|
||||
want = false
|
||||
}
|
||||
ok4 := fwRule("iptables", fwSetV4, want, &st.Errors)
|
||||
ok6 := fwRule("ip6tables", fwSetV6, want, &st.Errors)
|
||||
st.Lockdown = want && ok4 && ok6
|
||||
if st.Lockdown {
|
||||
st.Until = ctl.Until
|
||||
}
|
||||
|
||||
if err := fwApplyJailConf(ctl.Jails); err != nil {
|
||||
st.Errors = append(st.Errors, err.Error())
|
||||
}
|
||||
|
||||
done := map[string]bool{}
|
||||
for _, id := range prev.Done {
|
||||
done[id] = true
|
||||
}
|
||||
for _, a := range ctl.Actions {
|
||||
if !done[a.ID] {
|
||||
if err := fwRunAction(a); err != nil {
|
||||
st.Errors = append(st.Errors, err.Error())
|
||||
}
|
||||
done[a.ID] = true
|
||||
}
|
||||
st.Done = append(st.Done, a.ID) // только команды, ещё лежащие в fw-control.json
|
||||
}
|
||||
|
||||
for _, j := range fwJails {
|
||||
js, err := fwJail(j)
|
||||
if err != nil {
|
||||
st.Errors = append(st.Errors, err.Error())
|
||||
continue
|
||||
}
|
||||
st.Jails = append(st.Jails, js)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// fwReadAllow — белый список для ipset: доверенные установки и администраторы.
|
||||
func fwReadAllow(dir string) (v4, v6 []string) {
|
||||
seen := map[string]bool{}
|
||||
for _, name := range []string{trustedFile, fwAllowFile} {
|
||||
b, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
n, ok := normalizeNet(line)
|
||||
if !ok || seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
if strings.Contains(n, ":") {
|
||||
v6 = append(v6, n)
|
||||
} else {
|
||||
v4 = append(v4, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return v4, v6
|
||||
}
|
||||
|
||||
// fwSyncSet — содержимое ipset заменяется атомарно (временный набор + swap).
|
||||
func fwSyncSet(name, family string, nets []string) error {
|
||||
tmp := name + "-tmp"
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "create %s hash:net family %s -exist\n", name, family)
|
||||
fmt.Fprintf(&b, "create %s hash:net family %s -exist\nflush %s\n", tmp, family, tmp)
|
||||
for _, n := range nets {
|
||||
fmt.Fprintf(&b, "add %s %s -exist\n", tmp, n)
|
||||
}
|
||||
fmt.Fprintf(&b, "swap %s %s\ndestroy %s\n", tmp, name, tmp)
|
||||
cmd := exec.Command("ipset", "restore")
|
||||
cmd.Stdin = strings.NewReader(b.String())
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("ipset %s: %v: %s", name, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fwRuleSpec — правило обороны: 80/443 только из белого списка.
|
||||
func fwRuleSpec(set string) []string {
|
||||
return []string{"-p", "tcp", "-m", "multiport", "--dports", "80,443", "-m", "set", "!", "--match-set", set, "src",
|
||||
"-m", "comment", "--comment", fwRuleComment, "-j", "DROP"}
|
||||
}
|
||||
|
||||
// fwRule — привести наличие правила к want; true — правило в нужном состоянии.
|
||||
func fwRule(bin, set string, want bool, errs *[]string) bool {
|
||||
has := func() bool {
|
||||
return exec.Command(bin, append([]string{"-w", "-C", "INPUT"}, fwRuleSpec(set)...)...).Run() == nil
|
||||
}
|
||||
if want && !has() {
|
||||
if out, err := exec.Command(bin, append([]string{"-w", "-I", "INPUT", "1"}, fwRuleSpec(set)...)...).CombinedOutput(); err != nil {
|
||||
*errs = append(*errs, fmt.Sprintf("%s: включение обороны: %v: %s", bin, err, strings.TrimSpace(string(out))))
|
||||
return false
|
||||
}
|
||||
}
|
||||
for i := 0; !want && has() && i < 10; i++ {
|
||||
if out, err := exec.Command(bin, append([]string{"-w", "-D", "INPUT"}, fwRuleSpec(set)...)...).CombinedOutput(); err != nil {
|
||||
*errs = append(*errs, fmt.Sprintf("%s: выключение обороны: %v: %s", bin, err, strings.TrimSpace(string(out))))
|
||||
return false
|
||||
}
|
||||
}
|
||||
return want == has()
|
||||
}
|
||||
|
||||
func fwRunAction(a fwAction) error {
|
||||
ip := net.ParseIP(a.IP)
|
||||
known := false
|
||||
for _, j := range fwJails {
|
||||
known = known || j == a.Jail
|
||||
}
|
||||
if ip == nil || !known || (a.Op != "ban" && a.Op != "unban") {
|
||||
return fmt.Errorf("команда %s %s %s отклонена: неверные параметры", a.Op, a.Jail, a.IP)
|
||||
}
|
||||
out, err := exec.Command("fail2ban-client", "set", a.Jail, a.Op+"ip", ip.String()).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fail2ban %s %s %s: %v: %s", a.Jail, a.Op, ip, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
fwJailOverride = "/etc/fail2ban/jail.d/zz-bj-license-ui.local"
|
||||
fwServerDefault = "/etc/fail2ban/fail2ban.d/zz-bj-license-ui.local"
|
||||
)
|
||||
|
||||
// fwApplyJailConf — параметры блокировок из кабинета в override fail2ban; при изменении — reload.
|
||||
func fwApplyJailConf(conf map[string]fwJailConf) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Параметры блокировок из кабинета сервера лицензий (раздел «Защита»). Файл пишет bj-license-server fw-apply.\n")
|
||||
var maxBan int64 = 86400
|
||||
forever := false
|
||||
for _, j := range fwJails {
|
||||
c, ok := conf[j]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if c.BanTime == -1 {
|
||||
forever = true
|
||||
} else if c.BanTime > maxBan {
|
||||
maxBan = c.BanTime
|
||||
}
|
||||
fmt.Fprintf(&b, "\n[%s]\nbantime = %d\nfindtime = %d\nmaxretry = %d\n", j, c.BanTime, c.FindTime, c.MaxRetry)
|
||||
}
|
||||
old, _ := os.ReadFile(fwJailOverride)
|
||||
if string(old) == b.String() {
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(fwJailOverride, []byte(b.String()), 0o644); err != nil {
|
||||
return fmt.Errorf("параметры блокировок: %w", err)
|
||||
}
|
||||
// Долгие баны должны пережить перезапуск fail2ban: база банов хранится дольше самого долгого срока.
|
||||
purge := maxBan + 86400
|
||||
if forever && purge < 5*31536000 {
|
||||
purge = 5 * 31536000
|
||||
}
|
||||
_ = os.WriteFile(fwServerDefault, []byte(fmt.Sprintf("# Файл пишет bj-license-server fw-apply.\n[Definition]\ndbpurgeage = %d\n", purge)), 0o644)
|
||||
if out, err := exec.Command("fail2ban-client", "reload").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("fail2ban reload: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
_ = exec.Command("fail2ban-client", "set", "dbpurgeage", strconv.FormatInt(purge, 10)).Run()
|
||||
return nil
|
||||
}
|
||||
|
||||
var fwNumRe = regexp.MustCompile(`(Currently failed|Total failed|Currently banned|Total banned):\s*(\d+)`)
|
||||
|
||||
func fwJail(name string) (fwJailStatus, error) {
|
||||
js := fwJailStatus{Name: name}
|
||||
out, err := exec.Command("fail2ban-client", "status", name).CombinedOutput()
|
||||
if err != nil {
|
||||
return js, fmt.Errorf("fail2ban status %s: %v: %s", name, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
text := string(out)
|
||||
for _, m := range fwNumRe.FindAllStringSubmatch(text, -1) {
|
||||
n, _ := strconv.Atoi(m[2])
|
||||
switch m[1] {
|
||||
case "Currently failed":
|
||||
js.CurrentlyFailed = n
|
||||
case "Total failed":
|
||||
js.TotalFailed = n
|
||||
case "Currently banned":
|
||||
js.CurrentlyBanned = n
|
||||
case "Total banned":
|
||||
js.TotalBanned = n
|
||||
}
|
||||
}
|
||||
if i := strings.Index(text, "Banned IP list:"); i >= 0 {
|
||||
line, _, _ := strings.Cut(text[i+len("Banned IP list:"):], "\n")
|
||||
js.Banned = strings.Fields(line)
|
||||
}
|
||||
get := func(opt string) int64 {
|
||||
out, err := exec.Command("fail2ban-client", "get", name, opt).Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
|
||||
return n
|
||||
}
|
||||
js.BanTime, js.FindTime, js.MaxRetry = get("bantime"), get("findtime"), int(get("maxretry"))
|
||||
return js, nil
|
||||
}
|
||||
|
||||
func chownLikeDir(dir, path string) {
|
||||
if fi, err := os.Stat(dir); err == nil {
|
||||
if s, ok := fi.Sys().(*syscall.Stat_t); ok {
|
||||
_ = os.Chown(path, int(s.Uid), int(s.Gid))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fwWriteStatus(dir string, st fwStatus) error {
|
||||
b, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(dir, fwStatusFile+".tmp")
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
chownLikeDir(dir, tmp)
|
||||
return os.Rename(tmp, filepath.Join(dir, fwStatusFile))
|
||||
}
|
||||
|
||||
// fwIgnore — `bj-license-server fw-ignore [-dir …] <ip> api|admin` для ignorecommand fail2ban:
|
||||
// код 0 — адрес не блокировать. api — доверенные установки и белый список, admin — только белый список.
|
||||
func fwIgnore(args []string) {
|
||||
fs := flag.NewFlagSet("fw-ignore", flag.ExitOnError)
|
||||
dir := fs.String("dir", "/var/lib/bj-license", "каталог данных сервера лицензий")
|
||||
_ = fs.Parse(args)
|
||||
ip, kind := fs.Arg(0), fs.Arg(1)
|
||||
if net.ParseIP(ip) == nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
files := []string{fwAllowFile}
|
||||
if kind != "admin" {
|
||||
files = append(files, trustedFile)
|
||||
}
|
||||
for _, name := range files {
|
||||
b, err := os.ReadFile(filepath.Join(*dir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "#") && netContains(line, ip) {
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// ---- кабинет: раздел «Защита» ----
|
||||
|
||||
type securityData struct {
|
||||
Status fwStatus
|
||||
StatusFresh bool
|
||||
Wanted FirewallState
|
||||
WantedActive bool
|
||||
Allow []AllowIP
|
||||
TrustedCount int
|
||||
Pending []fwAction
|
||||
MyIP string
|
||||
MyIPCovered bool
|
||||
Jails []string
|
||||
// Доступ к кабинету.
|
||||
Host string
|
||||
AdminBase string
|
||||
AdminDefault bool
|
||||
AdminAllowOnly bool
|
||||
AdminOpen bool
|
||||
TwoFA, Admins int
|
||||
ZKEnabled bool
|
||||
ZKHasKey bool
|
||||
ZKURL string
|
||||
ZKWithEmail int
|
||||
Limits []fwLimitRow
|
||||
BanPresets []fwPreset
|
||||
FindPresets []fwPreset
|
||||
RetryPresets []int
|
||||
LoginTries int
|
||||
LoginLockSec int64
|
||||
}
|
||||
|
||||
// fwLimitRow — строка карточки «Параметры блокировок».
|
||||
type fwLimitRow struct {
|
||||
Jail, Title string
|
||||
Conf fwJailConf
|
||||
Effective string // действующие значения в fail2ban
|
||||
HasEffective bool
|
||||
BanOpts []fwPreset
|
||||
FindOpts []fwPreset
|
||||
RetryOpts []int
|
||||
}
|
||||
|
||||
// withPreset — варианты выбора с текущим значением, даже если его нет среди стандартных.
|
||||
func withPreset(list []fwPreset, sec int64) []fwPreset {
|
||||
if fwPresetOK(list, sec) {
|
||||
return list
|
||||
}
|
||||
return append(append([]fwPreset{}, list...), fwPreset{sec, durRu(sec)})
|
||||
}
|
||||
|
||||
func withRetry(list []int, n int) []int {
|
||||
for _, v := range list {
|
||||
if v == n {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append(append([]int{}, list...), n)
|
||||
}
|
||||
|
||||
func (srv *server) dataDir() string { return filepath.Dir(srv.st.path) }
|
||||
|
||||
// fwPrune — убрать из желаемого состояния команды, которые помощник уже выполнил.
|
||||
func fwPrune(s *State, st fwStatus) {
|
||||
done := map[string]bool{}
|
||||
for _, id := range st.Done {
|
||||
done[id] = true
|
||||
}
|
||||
var keep []fwAction
|
||||
for _, a := range s.Firewall.Actions {
|
||||
if !done[a.ID] {
|
||||
keep = append(keep, a)
|
||||
}
|
||||
}
|
||||
s.Firewall.Actions = keep
|
||||
}
|
||||
|
||||
// fwWait — подождать, пока помощник применит изменения (до 5 секунд).
|
||||
func (srv *server) fwWait(since time.Time) {
|
||||
for i := 0; i < 20; i++ {
|
||||
if fwReadStatus(srv.dataDir()).At.After(since) {
|
||||
return
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *server) securityPage(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
st := fwReadStatus(srv.dataDir())
|
||||
d := securityData{Status: st, StatusFresh: !st.At.IsZero() && time.Since(st.At) < fwStatusStale, MyIP: clientIP(r), Jails: fwJails}
|
||||
srv.st.read(func(s *State) {
|
||||
d.Wanted = s.Firewall
|
||||
d.WantedActive = s.Firewall.Mode == "lockdown" && (s.Firewall.Until.IsZero() || time.Now().Before(s.Firewall.Until))
|
||||
for _, a := range s.AllowIPs {
|
||||
d.Allow = append(d.Allow, *a)
|
||||
}
|
||||
d.TrustedCount = len(s.trustedIPs())
|
||||
done := map[string]bool{}
|
||||
for _, id := range st.Done {
|
||||
done[id] = true
|
||||
}
|
||||
for _, a := range s.Firewall.Actions {
|
||||
if !done[a.ID] {
|
||||
d.Pending = append(d.Pending, a)
|
||||
}
|
||||
}
|
||||
d.MyIPCovered = s.allowCovers(d.MyIP, false)
|
||||
d.AdminAllowOnly, d.Admins = s.AdminAllowOnly, len(s.Users)
|
||||
d.ZKEnabled, d.ZKHasKey, d.ZKURL = s.ZetitKey.Enabled, s.ZetitKey.AppKey != "", pickStr(s.ZetitKey.URL, zetitKeyDefaultURL)
|
||||
for _, u := range s.Users {
|
||||
if u.Email != "" {
|
||||
d.ZKWithEmail++
|
||||
}
|
||||
}
|
||||
for _, u := range s.Users {
|
||||
if u.TOTPSecret != "" {
|
||||
d.TwoFA++
|
||||
}
|
||||
}
|
||||
n, lock := loginLimitOf(s)
|
||||
d.LoginTries, d.LoginLockSec = n, int64(lock/time.Second)
|
||||
for _, j := range fwJails {
|
||||
c, configured := s.Firewall.Jails[j]
|
||||
if !configured {
|
||||
c = fwDefaultJail(j)
|
||||
}
|
||||
row := fwLimitRow{Jail: j, Title: fwJailTitle(j)}
|
||||
for _, js := range st.Jails {
|
||||
if js.Name == j && js.MaxRetry > 0 {
|
||||
row.HasEffective = true
|
||||
row.Effective = fmt.Sprintf("сейчас: бан %s, %d попыток за %s", durRu(js.BanTime), js.MaxRetry, durRu(js.FindTime))
|
||||
if !configured { // ещё не настраивали в кабинете — показываем то, что действует
|
||||
c = fwJailConf{BanTime: js.BanTime, FindTime: js.FindTime, MaxRetry: js.MaxRetry}
|
||||
}
|
||||
}
|
||||
}
|
||||
row.Conf = c
|
||||
row.BanOpts, row.FindOpts, row.RetryOpts = withPreset(fwBanPresets, c.BanTime), withPreset(fwFindPresets, c.FindTime), withRetry(fwRetryPresets, c.MaxRetry)
|
||||
d.Limits = append(d.Limits, row)
|
||||
}
|
||||
})
|
||||
d.BanPresets, d.FindPresets, d.RetryPresets = fwBanPresets, fwFindPresets, fwRetryPresets
|
||||
d.Host, d.AdminBase, d.AdminOpen = publicHost, srv.adminBase(), srv.adminOpen()
|
||||
d.AdminDefault = d.AdminBase == adminDefault
|
||||
srv.render(w, r, sess, "security", "Защита", "security", d)
|
||||
}
|
||||
|
||||
func fwJailTitle(j string) string {
|
||||
switch j {
|
||||
case "bj-license-api":
|
||||
return "Активация и продление (API)"
|
||||
case "bj-license-admin":
|
||||
return "Вход в кабинет (fail2ban)"
|
||||
case "sshd":
|
||||
return "SSH"
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
// securityLimits — POST /admin/security/limits: сроки и пороги блокировок.
|
||||
func (srv *server) securityLimits(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
conf := map[string]fwJailConf{}
|
||||
var changes []string
|
||||
for _, j := range fwJails {
|
||||
ban, e1 := strconv.ParseInt(r.PostFormValue("ban_"+j), 10, 64)
|
||||
find, e2 := strconv.ParseInt(r.PostFormValue("find_"+j), 10, 64)
|
||||
retry, e3 := strconv.Atoi(r.PostFormValue("retry_" + j))
|
||||
if e1 != nil || e2 != nil || e3 != nil || (ban != -1 && (ban < 60 || ban > 5*31536000)) || find < 60 || find > 31536000 || retry < 1 || retry > 100 {
|
||||
back(w, r, "/admin/security", "", "неверные параметры для "+fwJailTitle(j))
|
||||
return
|
||||
}
|
||||
conf[j] = fwJailConf{BanTime: ban, FindTime: find, MaxRetry: retry}
|
||||
changes = append(changes, fmt.Sprintf("%s: бан %s, %d попыток за %s", fwJailTitle(j), durRu(ban), retry, durRu(find)))
|
||||
}
|
||||
tries, e1 := strconv.Atoi(r.PostFormValue("login_tries"))
|
||||
lock, e2 := strconv.ParseInt(r.PostFormValue("login_lock"), 10, 64)
|
||||
if e1 != nil || e2 != nil || tries < 3 || tries > 100 || lock <= 0 || !fwPresetOK(fwBanPresets, lock) {
|
||||
back(w, r, "/admin/security", "", "неверные параметры блокировки входа в кабинет")
|
||||
return
|
||||
}
|
||||
changes = append(changes, fmt.Sprintf("вход в кабинет (приложение): %d попыток, блокировка %s", tries, durRu(lock)))
|
||||
start := time.Now()
|
||||
err := srv.st.write(func(s *State) error {
|
||||
s.Firewall.Jails, s.Firewall.LoginAttempts, s.Firewall.LoginLockSec = conf, tries, lock
|
||||
s.Firewall.UpdatedBy, s.Firewall.UpdatedAt = sess.Login, start
|
||||
s.addEvent("fw_limits", "", "", sess.Login, clientIP(r), "параметры блокировок: "+strings.Join(changes, "; "))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
back(w, r, "/admin/security", "", err.Error())
|
||||
return
|
||||
}
|
||||
srv.fails.set(tries, time.Duration(lock)*time.Second)
|
||||
srv.fwWait(start)
|
||||
back(w, r, "/admin/security", "Параметры блокировок сохранены. fail2ban перечитывает настройки, действующие значения обновятся в течение 30 секунд.", "")
|
||||
}
|
||||
|
||||
// securityMode — POST /admin/security/mode: включить (op=on, hours) или выключить (op=off) оборону.
|
||||
func (srv *server) securityMode(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
op, myIP := r.PostFormValue("op"), clientIP(r)
|
||||
start := time.Now()
|
||||
status := fwReadStatus(srv.dataDir())
|
||||
var bad string
|
||||
err := srv.st.write(func(s *State) error {
|
||||
fwPrune(s, status)
|
||||
switch op {
|
||||
case "on":
|
||||
hours, _ := strconv.Atoi(r.PostFormValue("hours"))
|
||||
if hours < 0 || hours > 24*30 {
|
||||
bad = "неверный срок"
|
||||
return nil
|
||||
}
|
||||
if n, ok := normalizeNet(myIP); ok && !s.allowCovers(myIP, false) {
|
||||
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: "добавлен автоматически при включении обороны", AddedBy: sess.Login, AddedAt: start})
|
||||
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список (включение обороны)")
|
||||
}
|
||||
s.Firewall.Mode, s.Firewall.Until = "lockdown", time.Time{}
|
||||
text := "включён режим обороны: на 80/443 только белый список"
|
||||
if hours > 0 {
|
||||
s.Firewall.Until = start.Add(time.Duration(hours) * time.Hour)
|
||||
text += ", до " + s.Firewall.Until.In(msk).Format("02.01.2006 15:04")
|
||||
}
|
||||
s.addEvent("fw_lockdown_on", "", "", sess.Login, myIP, text)
|
||||
_ = os.Remove(filepath.Join(srv.dataDir(), fwOffFile))
|
||||
case "off":
|
||||
s.Firewall.Mode, s.Firewall.Until = "", time.Time{}
|
||||
s.addEvent("fw_lockdown_off", "", "", sess.Login, myIP, "режим обороны выключен")
|
||||
default:
|
||||
bad = "неизвестная команда"
|
||||
return nil
|
||||
}
|
||||
s.Firewall.UpdatedBy, s.Firewall.UpdatedAt = sess.Login, start
|
||||
return nil
|
||||
})
|
||||
if err != nil || bad != "" {
|
||||
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
|
||||
return
|
||||
}
|
||||
srv.fwWait(start)
|
||||
if op == "on" {
|
||||
back(w, r, "/admin/security", "Режим обороны включается: доступ к сайту и API — только из белого списка.", "")
|
||||
return
|
||||
}
|
||||
back(w, r, "/admin/security", "Режим обороны выключен: доступ открыт, fail2ban работает как обычно.", "")
|
||||
}
|
||||
|
||||
// securityAllow — POST /admin/security/allow: добавить (op=add) или убрать (op=remove) адрес белого списка.
|
||||
func (srv *server) securityAllow(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
op, myIP := r.PostFormValue("op"), clientIP(r)
|
||||
start := time.Now()
|
||||
var bad, okMsg string
|
||||
err := srv.st.write(func(s *State) error {
|
||||
switch op {
|
||||
case "add":
|
||||
n, ok := normalizeNet(r.PostFormValue("net"))
|
||||
if !ok {
|
||||
bad = "укажите IP или сеть (IPv4 не шире /16, IPv6 не шире /32)"
|
||||
return nil
|
||||
}
|
||||
for _, a := range s.AllowIPs {
|
||||
if a.Net == n {
|
||||
bad = n + " уже в белом списке"
|
||||
return nil
|
||||
}
|
||||
}
|
||||
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: clip(r.PostFormValue("comment"), 120), AddedBy: sess.Login, AddedAt: start})
|
||||
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список")
|
||||
okMsg = n + " добавлен в белый список"
|
||||
case "remove":
|
||||
n := strings.TrimSpace(r.PostFormValue("net"))
|
||||
var keep []*AllowIP
|
||||
for _, a := range s.AllowIPs {
|
||||
if a.Net != n {
|
||||
keep = append(keep, a)
|
||||
}
|
||||
}
|
||||
if len(keep) == len(s.AllowIPs) {
|
||||
bad = "адрес не найден в белом списке"
|
||||
return nil
|
||||
}
|
||||
lockdown := s.Firewall.Mode == "lockdown" && (s.Firewall.Until.IsZero() || start.Before(s.Firewall.Until))
|
||||
if (lockdown || s.AdminAllowOnly) && netContains(n, myIP) {
|
||||
rest := &State{AllowIPs: keep}
|
||||
if !rest.allowCovers(myIP, false) {
|
||||
bad = n + " покрывает ваш адрес " + myIP + ", а кабинет сейчас ограничен белым списком: после удаления доступ закроется. Сначала снимите ограничение или выключите оборону."
|
||||
return nil
|
||||
}
|
||||
}
|
||||
s.AllowIPs = keep
|
||||
s.addEvent("fw_allow_remove", "", "", sess.Login, myIP, "IP "+n+" убран из белого списка")
|
||||
okMsg = n + " убран из белого списка"
|
||||
default:
|
||||
bad = "неизвестная команда"
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || bad != "" {
|
||||
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
|
||||
return
|
||||
}
|
||||
srv.fwWait(start)
|
||||
back(w, r, "/admin/security", okMsg, "")
|
||||
}
|
||||
|
||||
// securityBan — POST /admin/security/ban: заблокировать (op=ban) или разблокировать (op=unban) IP в jail.
|
||||
func (srv *server) securityBan(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
op, jail, myIP := r.PostFormValue("op"), r.PostFormValue("jail"), clientIP(r)
|
||||
ip := net.ParseIP(strings.TrimSpace(r.PostFormValue("ip")))
|
||||
known := false
|
||||
for _, j := range fwJails {
|
||||
known = known || j == jail
|
||||
}
|
||||
switch {
|
||||
case ip == nil:
|
||||
back(w, r, "/admin/security", "", "укажите IP-адрес")
|
||||
return
|
||||
case !known || (op != "ban" && op != "unban"):
|
||||
back(w, r, "/admin/security", "", "неверная команда")
|
||||
return
|
||||
case op == "ban" && ip.String() == net.ParseIP(myIP).String():
|
||||
back(w, r, "/admin/security", "", "это ваш текущий адрес — блокировать его нельзя")
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
status := fwReadStatus(srv.dataDir())
|
||||
err := srv.st.write(func(s *State) error {
|
||||
fwPrune(s, status)
|
||||
s.Firewall.Actions = append(s.Firewall.Actions, fwAction{ID: randHex(8), Op: op, Jail: jail, IP: ip.String(), At: start, By: sess.Login})
|
||||
verb := "заблокирован"
|
||||
if op == "unban" {
|
||||
verb = "разблокирован"
|
||||
}
|
||||
s.addEvent("fw_"+op, "", "", sess.Login, myIP, "IP "+ip.String()+" "+verb+" вручную в "+jail)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
back(w, r, "/admin/security", "", err.Error())
|
||||
return
|
||||
}
|
||||
srv.fwWait(start)
|
||||
if op == "ban" {
|
||||
back(w, r, "/admin/security", "IP "+ip.String()+" заблокирован в "+jail+" (на срок bantime jail).", "")
|
||||
return
|
||||
}
|
||||
back(w, r, "/admin/security", "IP "+ip.String()+" разблокирован в "+jail+".", "")
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// Command bj-license-server — сервер лицензирования Bridge&Join (https://lic.zetit.ru).
|
||||
//
|
||||
// Выдаёт и продлевает лицензии установкам bj по коду активации (контракт —
|
||||
// docs/license-server-api.md) и даёт веб-кабинет администратора: клиенты, лицензии,
|
||||
// установки, журнал, администраторы. Вход в кабинет — логин и пароль.
|
||||
//
|
||||
// Хранилище — один JSON-файл с атомарной записью и ежедневными копиями рядом: клиентов
|
||||
// десятки, записей немного, отдельная СУБД не нужна.
|
||||
//
|
||||
// bj-license-server serve -addr 127.0.0.1:8091 -data /var/lib/bj-license/state.json -key /etc/bj-license/license.priv
|
||||
// bj-license-server user-set -data /var/lib/bj-license/state.json -login admin
|
||||
// (пароль из stdin; пусто — сгенерировать; сервер должен быть остановлен)
|
||||
// bj-license-server fw-apply -dir /var/lib/bj-license [-off] (root, из systemd: защита, см. firewall.go)
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/ed25519"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "serve":
|
||||
serve(os.Args[2:])
|
||||
case "user-set":
|
||||
userSet(os.Args[2:])
|
||||
case "fw-apply":
|
||||
fwApply(os.Args[2:])
|
||||
case "fw-ignore":
|
||||
fwIgnore(os.Args[2:])
|
||||
case "admin-info":
|
||||
adminInfo(os.Args[2:])
|
||||
case "admin-config":
|
||||
adminConfig(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "bj-license-server serve [-addr 127.0.0.1:8091] [-data state.json] [-key license.priv] [-keyid main]")
|
||||
fmt.Fprintln(os.Stderr, "bj-license-server user-set -login admin [-name Имя] [-data state.json] (пароль из stdin)")
|
||||
fmt.Fprintln(os.Stderr, "bj-license-server fw-apply [-dir /var/lib/bj-license] [-off] (root: применить защиту; -off — аварийно выключить оборону)")
|
||||
fmt.Fprintln(os.Stderr, "bj-license-server fw-ignore [-dir /var/lib/bj-license] <ip> api|admin (ignorecommand fail2ban)")
|
||||
fmt.Fprintln(os.Stderr, "bj-license-server admin-info [-data state.json] (адрес кабинета, белый список, второй фактор)")
|
||||
fmt.Fprintln(os.Stderr, "bj-license-server admin-config [-data state.json] [-allow IP=комментарий ...] [-path свой|random|admin] [-allow-only on|off] [-reset-2fa логин] (сервер должен быть остановлен)")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func serve(args []string) {
|
||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
addr := fs.String("addr", "127.0.0.1:8091", "адрес прослушивания (снаружи — через nginx)")
|
||||
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
|
||||
keyPath := fs.String("key", "/etc/bj-license/license.priv", "закрытый ключ подписи лицензий (base64 seed Ed25519)")
|
||||
keyID := fs.String("keyid", "main", "идентификатор ключа в токене")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
priv, err := license.LoadPrivateKey(*keyPath)
|
||||
if err != nil {
|
||||
log.Fatalf("ключ подписи: %v", err)
|
||||
}
|
||||
st, err := openStore(*data)
|
||||
if err != nil {
|
||||
log.Fatalf("хранилище: %v", err)
|
||||
}
|
||||
srv := &server{
|
||||
st: st,
|
||||
priv: priv,
|
||||
pub: priv.Public().(ed25519.PublicKey),
|
||||
keyID: *keyID,
|
||||
sessions: newSessions(),
|
||||
fails: newLimiter(8, 15*time.Minute),
|
||||
apiRate: newLimiter(60, time.Minute),
|
||||
}
|
||||
st.read(func(s *State) { srv.fails.set(loginLimitOf(s)) })
|
||||
if err := srv.loadTemplates(); err != nil {
|
||||
log.Fatalf("шаблоны: %v", err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
srv.routes(mux)
|
||||
go st.backupLoop()
|
||||
|
||||
st.read(func(s *State) {
|
||||
log.Printf("bj-license-server: слушаю %s; клиентов %d, лицензий %d, установок %d, администраторов %d",
|
||||
*addr, len(s.Tenants), len(s.Licenses), len(s.Installs), len(s.Users))
|
||||
if len(s.Users) == 0 {
|
||||
log.Printf("bj-license-server: администраторов нет — создайте: bj-license-server user-set -login admin")
|
||||
}
|
||||
})
|
||||
hs := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: securityHeaders(srv.adminGate(mux)),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
}
|
||||
log.Fatal(hs.ListenAndServe())
|
||||
}
|
||||
|
||||
func userSet(args []string) {
|
||||
fs := flag.NewFlagSet("user-set", flag.ExitOnError)
|
||||
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
|
||||
login := fs.String("login", "", "логин администратора")
|
||||
name := fs.String("name", "", "имя (для журнала)")
|
||||
_ = fs.Parse(args)
|
||||
if !loginRe.MatchString(*login) {
|
||||
log.Fatal("логин: латиница, цифры, точка, дефис, подчёркивание")
|
||||
}
|
||||
st, err := openStore(*data)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
pw, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
pw = strings.TrimRight(pw, "\r\n")
|
||||
generated := false
|
||||
if pw == "" {
|
||||
pw, generated = randomPassword(16), true
|
||||
}
|
||||
if len(pw) < 10 {
|
||||
log.Fatal("пароль короче 10 символов")
|
||||
}
|
||||
if err := st.write(func(s *State) error {
|
||||
return s.setUserPassword(*login, *name, pw, "cli")
|
||||
}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if generated {
|
||||
fmt.Printf("Администратор %s: пароль %s\n", *login, pw)
|
||||
} else {
|
||||
fmt.Printf("Администратор %s: пароль задан\n", *login)
|
||||
}
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Признаки лицензии, которые понимает bj (internal/lkgateway/licensecheck.go).
|
||||
var knownFeatures = []struct{ Key, Title string }{
|
||||
{"updates", "Обновления"},
|
||||
{"web-cabinet", "Личный кабинет клиента (bj-lk)"},
|
||||
{"quik-mnp", "QUIK: неторговые поручения (M2M)"},
|
||||
{"quik-export", "QUIK: экспорт биржевой информации"},
|
||||
{"npr", "Риск-монитор НПР / УДС"},
|
||||
}
|
||||
|
||||
var knownPlans = []struct{ Key, Title string }{
|
||||
{"enterprise", "Enterprise"},
|
||||
{"pro", "Pro"},
|
||||
{"free", "Free"},
|
||||
}
|
||||
|
||||
const maxEvents = 20000
|
||||
|
||||
var (
|
||||
loginRe = regexp.MustCompile(`^[A-Za-z0-9_.\-]{2,40}$`)
|
||||
uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
innRe = regexp.MustCompile(`^(\d{10}|\d{12})$`)
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PassHash string `json:"pass_hash"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastLogin time.Time `json:"last_login,omitempty"`
|
||||
// Второй фактор (TOTP): секрет base32 и последний принятый шаг — один код не принимается дважды.
|
||||
TOTPSecret string `json:"totp_secret,omitempty"`
|
||||
TOTPLast int64 `json:"totp_last,omitempty"`
|
||||
HasTOTP bool `json:"-"` // для шаблонов: секрет в представления не отдаётся
|
||||
Email string `json:"email,omitempty"` // учётная запись в «ЗЕТИТ Ключ» (zetitkey.go)
|
||||
}
|
||||
|
||||
type Tenant struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
INN string `json:"inn,omitempty"`
|
||||
Contact string `json:"contact,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type License struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Plan string `json:"plan"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
MaxNodes int `json:"max_nodes"`
|
||||
ContractUntil time.Time `json:"contract_until"`
|
||||
Code string `json:"code"`
|
||||
Note string `json:"note,omitempty"`
|
||||
RevokedAt time.Time `json:"revoked_at,omitempty"`
|
||||
RevokeReason string `json:"revoke_reason,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
func (l License) Revoked() bool { return !l.RevokedAt.IsZero() }
|
||||
|
||||
func (l License) ContractExpired(now time.Time) bool {
|
||||
return !l.ContractUntil.IsZero() && now.After(l.ContractUntil)
|
||||
}
|
||||
|
||||
func (l License) HasFeature(f string) bool {
|
||||
for _, x := range l.Features {
|
||||
if x == f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Install struct {
|
||||
InstallID string `json:"install_id"`
|
||||
LicenseID string `json:"license_id"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
TokenExpires time.Time `json:"token_expires"`
|
||||
Blocked bool `json:"blocked,omitempty"`
|
||||
BlockReason string `json:"block_reason,omitempty"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
At time.Time `json:"at"`
|
||||
Kind string `json:"kind"`
|
||||
LicenseID string `json:"license_id,omitempty"`
|
||||
InstallID string `json:"install_id,omitempty"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// TrustedIP — адрес установки, которая успешно активировала или продлила лицензию. fail2ban
|
||||
// не блокирует такие адреса за ошибки API (jail bj-license-api, ignorecommand по trusted-ips.txt);
|
||||
// на вход в кабинет доверие не распространяется.
|
||||
type TrustedIP struct {
|
||||
IP string `json:"ip"`
|
||||
LicenseID string `json:"license_id"`
|
||||
InstallID string `json:"install_id"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Users []*User `json:"users"`
|
||||
Tenants []*Tenant `json:"tenants"`
|
||||
Licenses []*License `json:"licenses"`
|
||||
Installs []*Install `json:"installs"`
|
||||
Events []*Event `json:"events"`
|
||||
Trusted []*TrustedIP `json:"trusted,omitempty"`
|
||||
// Защита (раздел «Защита»): белый список администраторов и желаемый режим.
|
||||
AllowIPs []*AllowIP `json:"allow_ips,omitempty"`
|
||||
Firewall FirewallState `json:"firewall"`
|
||||
// Доступ к кабинету (access.go): свой адрес вместо /admin и вход только из белого списка.
|
||||
AdminPath string `json:"admin_path,omitempty"`
|
||||
AdminAllowOnly bool `json:"admin_allow_only,omitempty"`
|
||||
// Второй фактор через «ЗЕТИТ Ключ» (zetitkey.go).
|
||||
ZetitKey ZetitKeyConf `json:"zetit_key,omitempty"`
|
||||
}
|
||||
|
||||
// trustedFile — список доверенных IP установок для fail2ban и режима обороны.
|
||||
const trustedFile = "trusted-ips.txt"
|
||||
|
||||
type store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
s State
|
||||
}
|
||||
|
||||
func openStore(path string) (*store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := &store{path: path}
|
||||
b, err := os.ReadFile(path)
|
||||
switch {
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return st, nil
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &st.s); err != nil {
|
||||
return nil, fmt.Errorf("разбор %s: %w", path, err)
|
||||
}
|
||||
if err := st.writeTrustedLocked(); err != nil {
|
||||
log.Printf("доверенные IP: %v", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// trustedPath — список доверенных IP для fail2ban, рядом с файлом состояния.
|
||||
func (st *store) trustedPath() string {
|
||||
return filepath.Join(filepath.Dir(st.path), trustedFile)
|
||||
}
|
||||
|
||||
// writeTrustedLocked — переписывает trusted-ips.txt (по адресу в строке) и файлы защиты; вызывать под блокировкой.
|
||||
func (st *store) writeTrustedLocked() error {
|
||||
body := "# Доверенные IP установок bj (успешная активация или продление лицензии). Файл пишет bj-license-server.\n"
|
||||
for _, ip := range st.s.trustedIPs() {
|
||||
body += ip + "\n"
|
||||
}
|
||||
if err := writeIfChanged(st.trustedPath(), []byte(body), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return st.writeFirewallLocked()
|
||||
}
|
||||
|
||||
func (st *store) saveLocked() error {
|
||||
b, err := json.MarshalIndent(&st.s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := st.path + ".tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(b); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, st.path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.writeTrustedLocked(); err != nil {
|
||||
log.Printf("доверенные IP: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// write — изменение состояния под блокировкой; сохраняется, если fn не вернула ошибку.
|
||||
func (st *store) write(fn func(s *State) error) error {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
if err := fn(&st.s); err != nil {
|
||||
return err
|
||||
}
|
||||
return st.saveLocked()
|
||||
}
|
||||
|
||||
func (st *store) read(fn func(s *State)) {
|
||||
st.mu.RLock()
|
||||
defer st.mu.RUnlock()
|
||||
fn(&st.s)
|
||||
}
|
||||
|
||||
// backupLoop — раз в сутки копия state.json рядом (state.json.YYYYMMDD), хранится 30 копий.
|
||||
func (st *store) backupLoop() {
|
||||
for {
|
||||
st.mu.RLock()
|
||||
b, err := json.Marshal(&st.s)
|
||||
st.mu.RUnlock()
|
||||
if err == nil {
|
||||
name := st.path + "." + time.Now().Format("20060102")
|
||||
if _, e := os.Stat(name); errors.Is(e, os.ErrNotExist) {
|
||||
if e := os.WriteFile(name, b, 0o600); e != nil {
|
||||
log.Printf("резервная копия: %v", e)
|
||||
}
|
||||
}
|
||||
old, _ := filepath.Glob(st.path + ".2*")
|
||||
sort.Strings(old)
|
||||
for len(old) > 30 {
|
||||
_ = os.Remove(old[0])
|
||||
old = old[1:]
|
||||
}
|
||||
}
|
||||
time.Sleep(6 * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- выборки и изменения (вызывать внутри read/write) ----
|
||||
|
||||
// trustIP — адрес установки в доверенные после успешной активации или продления.
|
||||
// Возвращает true, если для этой установки адрес новый.
|
||||
func (s *State) trustIP(inst *Install, ip string, now time.Time) bool {
|
||||
p := net.ParseIP(strings.TrimSpace(ip))
|
||||
if p == nil || p.IsLoopback() || p.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
ip = p.String()
|
||||
for _, t := range s.Trusted {
|
||||
if t.IP == ip && t.InstallID == inst.InstallID {
|
||||
t.LicenseID, t.Hostname, t.LastSeen = inst.LicenseID, inst.Hostname, now
|
||||
return false
|
||||
}
|
||||
}
|
||||
s.Trusted = append(s.Trusted, &TrustedIP{IP: ip, LicenseID: inst.LicenseID, InstallID: inst.InstallID,
|
||||
Hostname: inst.Hostname, AddedAt: now, LastSeen: now})
|
||||
return true
|
||||
}
|
||||
|
||||
// untrust — убирает доверенные адреса, подходящие под условие; возвращает убранные.
|
||||
func (s *State) untrust(match func(t *TrustedIP) bool) []*TrustedIP {
|
||||
var keep, gone []*TrustedIP
|
||||
for _, t := range s.Trusted {
|
||||
if match(t) {
|
||||
gone = append(gone, t)
|
||||
} else {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
s.Trusted = keep
|
||||
return gone
|
||||
}
|
||||
|
||||
// trustedIPs — адреса для fail2ban: без отозванных лицензий и заблокированных установок.
|
||||
func (s *State) trustedIPs() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, t := range s.Trusted {
|
||||
if l := s.license(t.LicenseID); l == nil || l.Revoked() {
|
||||
continue
|
||||
}
|
||||
if i := s.install(t.InstallID); i == nil || i.Blocked {
|
||||
continue
|
||||
}
|
||||
if !seen[t.IP] {
|
||||
seen[t.IP] = true
|
||||
out = append(out, t.IP)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *State) user(login string) *User {
|
||||
for _, u := range s.Users {
|
||||
if strings.EqualFold(u.Login, login) {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) tenant(id string) *Tenant {
|
||||
for _, t := range s.Tenants {
|
||||
if t.ID == id {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) license(id string) *License {
|
||||
for _, l := range s.Licenses {
|
||||
if l.ID == id {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) licenseByCode(code string) *License {
|
||||
for _, l := range s.Licenses {
|
||||
if l.Code == code {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) install(id string) *Install {
|
||||
for _, i := range s.Installs {
|
||||
if strings.EqualFold(i.InstallID, id) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) installsOf(licenseID string) []*Install {
|
||||
var out []*Install
|
||||
for _, i := range s.Installs {
|
||||
if i.LicenseID == licenseID {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(a, b int) bool { return out[a].LastSeen.After(out[b].LastSeen) })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *State) activeInstalls(licenseID string) int {
|
||||
n := 0
|
||||
for _, i := range s.Installs {
|
||||
if i.LicenseID == licenseID && !i.Blocked {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *State) addEvent(kind, licenseID, installID, actor, ip, text string) {
|
||||
s.Events = append(s.Events, &Event{At: time.Now(), Kind: kind, LicenseID: licenseID, InstallID: installID, Actor: actor, IP: ip, Text: text})
|
||||
if len(s.Events) > maxEvents {
|
||||
s.Events = append([]*Event(nil), s.Events[len(s.Events)-maxEvents:]...)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) setUserPassword(login, name, password, actor string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := s.user(login)
|
||||
if u == nil {
|
||||
u = &User{Login: login, Name: name, CreatedAt: time.Now()}
|
||||
s.Users = append(s.Users, u)
|
||||
s.addEvent("user_add", "", "", actor, "", "добавлен администратор "+login)
|
||||
} else {
|
||||
s.addEvent("user_password", "", "", actor, "", "сменён пароль администратора "+login)
|
||||
}
|
||||
if name != "" {
|
||||
u.Name = name
|
||||
}
|
||||
u.PassHash = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- идентификаторы ----
|
||||
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
b[6] = b[6]&0x0f | 0x40
|
||||
b[8] = b[8]&0x3f | 0x80
|
||||
h := hex.EncodeToString(b)
|
||||
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
|
||||
}
|
||||
|
||||
const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // без 0/O и 1/I
|
||||
|
||||
func randomFrom(alphabet string, n int) string {
|
||||
var sb strings.Builder
|
||||
max := big.NewInt(int64(len(alphabet)))
|
||||
for i := 0; i < n; i++ {
|
||||
k, _ := rand.Int(rand.Reader, max)
|
||||
sb.WriteByte(alphabet[k.Int64()])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// newActivationCode — BJ-XXXX-XXXX-XXXX (60 бит случайности).
|
||||
func newActivationCode() string {
|
||||
c := randomFrom(codeAlphabet, 12)
|
||||
return "BJ-" + c[0:4] + "-" + c[4:8] + "-" + c[8:12]
|
||||
}
|
||||
|
||||
// normalizeCode — код в каноническом виде: верхний регистр, дефисы по местам.
|
||||
func normalizeCode(s string) string {
|
||||
s = strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(s)))
|
||||
if len(s) == 14 && strings.HasPrefix(s, "BJ") {
|
||||
s = s[2:]
|
||||
}
|
||||
if len(s) != 12 {
|
||||
return ""
|
||||
}
|
||||
return "BJ-" + s[0:4] + "-" + s[4:8] + "-" + s[8:12]
|
||||
}
|
||||
|
||||
func randomPassword(n int) string {
|
||||
return randomFrom("abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", n)
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
{{define "style"}}<style>
|
||||
:root{--bg:#f4f5f9;--card:#fff;--card2:#f8f9fc;--text:#1a1d26;--muted:#6b7280;--accent:#3b4fd8;--accent2:#e8562a;--border:#e3e5ee;--ok:#15803d;--warn:#b45309;--bad:#b91c1c;--okbg:#e8f6ee;--warnbg:#fdf3e2;--badbg:#fdecec}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#131620;--card:#1c202d;--card2:#232838;--text:#e6e8f0;--muted:#9aa0b3;--accent:#8190ff;--border:#2d3244;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171;--okbg:#16301f;--warnbg:#33280f;--badbg:#3a1a1a}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
|
||||
header{background:var(--card);border-bottom:1px solid var(--border);padding:0 20px;display:flex;align-items:center;gap:24px;flex-wrap:wrap;min-height:56px}
|
||||
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:16px;white-space:nowrap;color:var(--text)}
|
||||
.brand em{font-style:normal;color:var(--accent)}.brand small{font-weight:500;color:var(--muted);font-size:13px}
|
||||
nav{display:flex;gap:4px;flex-wrap:wrap;flex:1}
|
||||
nav a{padding:7px 11px;border-radius:8px;color:var(--muted);font-weight:500}
|
||||
nav a.on{background:var(--card2);color:var(--text)}nav a:hover{text-decoration:none;color:var(--text)}
|
||||
.me{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:13px}
|
||||
main{max-width:1180px;margin:0 auto;padding:22px 20px 60px}
|
||||
h1{font-size:22px;margin:0 0 16px}h2{font-size:16px;margin:0 0 12px}
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin-bottom:16px}
|
||||
.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
|
||||
.stats{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin-bottom:16px}
|
||||
.stat{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 16px}
|
||||
.stat b{display:block;font-size:26px;line-height:1.2}.stat span{color:var(--muted);font-size:13px}
|
||||
.table-wrap{overflow-x:auto}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{text-align:left;font-size:12px;font-weight:600;color:var(--muted);padding:8px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
|
||||
td{padding:9px 10px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.muted{color:var(--muted)}.small{font-size:12px}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600;white-space:nowrap}
|
||||
.badge.ok{background:var(--okbg);color:var(--ok)}.badge.warn{background:var(--warnbg);color:var(--warn)}.badge.bad{background:var(--badbg);color:var(--bad)}
|
||||
.chip{display:inline-block;padding:1px 7px;margin:1px 2px 1px 0;border:1px solid var(--border);border-radius:6px;font-size:12px;background:var(--card2)}
|
||||
.flash{padding:10px 14px;border-radius:10px;margin-bottom:16px;font-weight:500}
|
||||
.flash.ok{background:var(--okbg);color:var(--ok)}.flash.err{background:var(--badbg);color:var(--bad)}
|
||||
form.f{display:grid;gap:12px}
|
||||
label{display:block;font-size:12px;color:var(--muted);margin-bottom:4px}
|
||||
input[type=text],input[type=password],input[type=number],input[type=date],select,textarea{width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font:inherit}
|
||||
textarea{min-height:64px;resize:vertical}
|
||||
input:focus,select:focus,textarea:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent}
|
||||
.row2{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}
|
||||
.checks{display:flex;flex-wrap:wrap;gap:6px 16px}.checks label{display:flex;gap:6px;align-items:center;font-size:14px;color:var(--text);margin:0}
|
||||
.btn{display:inline-block;padding:8px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font:inherit;font-weight:600;cursor:pointer}
|
||||
.btn:hover{background:var(--card2);text-decoration:none}
|
||||
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}.btn.primary:hover{filter:brightness(1.07)}
|
||||
.btn.danger{color:var(--bad)}.btn.sm{padding:4px 9px;font-size:12px}
|
||||
.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||||
.code{font:600 20px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.06em;padding:10px 14px;background:var(--card2);border:1px dashed var(--border);border-radius:10px;display:inline-block;user-select:all}
|
||||
.token{font:12px/1.4 ui-monospace,monospace;word-break:break-all;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px;user-select:all}
|
||||
.kv{display:grid;grid-template-columns:minmax(140px,max-content) 1fr;gap:6px 16px}.kv dt{color:var(--muted)}.kv dd{margin:0}
|
||||
details summary{cursor:pointer;font-weight:600}
|
||||
.mono{font-family:ui-monospace,monospace;font-size:12px}
|
||||
.none::before{content:"\2014";color:var(--muted)} /* пустое значение */
|
||||
@media (max-width:640px){header{padding:8px 16px;gap:8px}main{padding:16px}.kv{grid-template-columns:1fr}}
|
||||
</style>{{end}}
|
||||
|
||||
{{define "logo"}}<svg width="24" height="24" viewBox="0 0 26 26" fill="none" aria-hidden="true"><path d="M5.5 16 C 9 8.5, 17 8.5, 20.5 16" stroke="#3b4fd8" stroke-width="1.9" stroke-linecap="round"/><circle cx="5.5" cy="16" r="3.1" fill="#3b4fd8"/><circle cx="20.5" cy="16" r="3.1" fill="#e8562a"/></svg>{{end}}
|
||||
|
||||
{{define "head"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · лицензии Bridge&Join</title>{{template "style"}}</head><body>
|
||||
<header>
|
||||
<a class="brand" href="{{adm}}/">{{template "logo"}}<span>Bridge<em>&Join</em></span><small>лицензии</small></a>
|
||||
<nav>
|
||||
<a href="{{adm}}/" {{if eq .Nav "dash"}}class="on"{{end}}>Обзор</a>
|
||||
<a href="{{adm}}/tenants" {{if eq .Nav "tenants"}}class="on"{{end}}>Клиенты</a>
|
||||
<a href="{{adm}}/licenses" {{if eq .Nav "licenses"}}class="on"{{end}}>Лицензии</a>
|
||||
<a href="{{adm}}/installs" {{if eq .Nav "installs"}}class="on"{{end}}>Установки</a>
|
||||
<a href="{{adm}}/security" {{if eq .Nav "security"}}class="on"{{end}}>Защита</a>
|
||||
<a href="{{adm}}/events" {{if eq .Nav "events"}}class="on"{{end}}>Журнал</a>
|
||||
<a href="{{adm}}/users" {{if eq .Nav "users"}}class="on"{{end}}>Администраторы</a>
|
||||
</nav>
|
||||
<div class="me">{{if .Me}}<span>{{if .Me.Name}}{{.Me.Name}}{{else}}{{.Me.Login}}{{end}}</span>
|
||||
<form method="post" action="{{adm}}/logout" style="margin:0"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="btn sm" type="submit">Выйти</button></form>{{end}}</div>
|
||||
</header>
|
||||
<main>
|
||||
{{if .OK}}<div class="flash ok">{{.OK}}</div>{{end}}
|
||||
{{if .Err}}<div class="flash err">{{.Err}}</div>{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "foot"}}</main></body></html>{{end}}
|
||||
|
||||
{{define "login"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Вход · лицензии Bridge&Join</title>{{template "style"}}</head>
|
||||
<body style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px">
|
||||
<div class="card" style="width:100%;max-width:380px;padding:28px 30px">
|
||||
<div class="brand" style="font-size:20px;margin-bottom:4px">{{template "logo"}}<span>Bridge<em>&Join</em></span></div>
|
||||
<p class="muted" style="margin:0 0 18px;font-size:13px">Сервер лицензирования: вход администратора</p>
|
||||
<form class="f" method="post" action="{{adm}}/login">
|
||||
<input type="hidden" name="next" value="{{.Data.Next}}">
|
||||
<div><label for="login">Логин</label><input id="login" type="text" name="login" value="{{.Data.Login}}" autocomplete="username" autofocus required></div>
|
||||
<div><label for="password">Пароль</label><input id="password" type="password" name="password" autocomplete="current-password" required></div>
|
||||
<div><label for="code">Код из приложения <span class="muted" style="font-weight:400">(если подключён второй фактор)</span></label><input id="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6"></div>
|
||||
<button class="btn primary" type="submit" style="width:100%;padding:10px">Войти</button>
|
||||
{{if .Err}}<div class="flash err" style="margin:0">{{.Err}}</div>{{end}}
|
||||
</form>
|
||||
</div></body></html>{{end}}
|
||||
|
||||
{{define "login2fa"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Подтверждение входа · лицензии Bridge&Join</title>{{template "style"}}</head>
|
||||
<body style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px">
|
||||
<div class="card" style="width:100%;max-width:400px;padding:28px 30px">
|
||||
<div class="brand" style="font-size:20px;margin-bottom:4px">{{template "logo"}}<span>Bridge<em>&Join</em></span></div>
|
||||
<h2 style="margin:10px 0 6px">Подтвердите вход</h2>
|
||||
<p class="muted" style="margin:0 0 6px;font-size:13px" id="zk-msg">Запрос отправлен в приложение «ЗЕТИТ Ключ» ({{.Data.Email}}). Подтвердите вход на телефоне, и страница продолжит сама.</p>
|
||||
<p style="font-size:30px;font-weight:700;margin:4px 0 14px" id="zk-count">{{.Data.Left}}</p>
|
||||
<form id="zk-finish" method="post" action="{{adm}}/login/2fa/finish"><input type="hidden" name="t" value="{{.Data.Token}}"></form>
|
||||
<form class="f" method="post" action="{{adm}}/login/2fa/code">
|
||||
<input type="hidden" name="t" value="{{.Data.Token}}">
|
||||
<div><label for="code">Или введите код из приложения</label><input id="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required></div>
|
||||
<button class="btn primary" type="submit" style="width:100%;padding:10px">Войти с кодом</button>
|
||||
{{if .Err}}<div class="flash err" style="margin:0">{{.Err}}</div>{{end}}
|
||||
</form>
|
||||
<p class="small" style="margin:14px 0 0"><a href="{{adm}}/login">Отмена</a></p>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var token = {{.Data.Token}}, left = {{.Data.Left}}, base = {{adm}}, done = false;
|
||||
var cnt = document.getElementById('zk-count'), msg = document.getElementById('zk-msg');
|
||||
function expired(){ msg.textContent = 'Время подтверждения истекло. Введите код из приложения или войдите заново.'; }
|
||||
var tick = setInterval(function(){
|
||||
left = Math.max(0, left - 1); cnt.textContent = left;
|
||||
if (left === 0) { clearInterval(tick); if (!done) expired(); }
|
||||
}, 1000);
|
||||
function poll(){
|
||||
if (done || left <= 0) return;
|
||||
fetch(base + '/login/2fa/status?t=' + encodeURIComponent(token), {credentials: 'same-origin', cache: 'no-store'})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(j){
|
||||
if (j.status === 'approved' || j.status === 'denied') {
|
||||
done = true;
|
||||
msg.textContent = j.status === 'approved' ? 'Вход подтверждён.' : 'Вход отклонён в приложении.';
|
||||
document.getElementById('zk-finish').submit();
|
||||
return;
|
||||
}
|
||||
if (j.status === 'expired') { done = true; expired(); return; }
|
||||
setTimeout(poll, 2500);
|
||||
})
|
||||
.catch(function(){ setTimeout(poll, 4000); });
|
||||
}
|
||||
if (left > 0) setTimeout(poll, 2000); else expired();
|
||||
})();
|
||||
</script></body></html>{{end}}
|
||||
|
||||
{{define "evtable"}}<div class="table-wrap"><table>
|
||||
<thead><tr><th>Когда</th><th>Событие</th><th>Клиент</th><th>Кто</th><th>IP</th></tr></thead><tbody>
|
||||
{{range .}}<tr>
|
||||
<td class="small" style="white-space:nowrap">{{dt .At}}</td>
|
||||
<td>{{.Text}}{{if .InstallID}} <span class="mono muted">{{short .InstallID}}</span>{{end}}</td>
|
||||
<td>{{if .LicenseID}}<a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a>{{else}}<span class="none"></span>{{end}}</td>
|
||||
<td class="small">{{.Actor}}</td><td class="small mono">{{.IP}}</td>
|
||||
</tr>{{else}}<tr><td colspan="5" class="muted">Событий пока нет</td></tr>{{end}}
|
||||
</tbody></table></div>{{end}}
|
||||
|
||||
{{define "dashboard"}}{{template "head" .}}
|
||||
<h1>Обзор</h1>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>{{.Data.Tenants}}</b><span>клиентов</span></div>
|
||||
<div class="stat"><b>{{.Data.Active}}</b><span>действующих лицензий</span></div>
|
||||
<div class="stat"><b>{{.Data.Revoked}}</b><span>отозвано</span></div>
|
||||
<div class="stat"><b>{{.Data.Installs}}</b><span>установок всего</span></div>
|
||||
<div class="stat"><b>{{.Data.Online}}</b><span>на связи за 48 ч</span></div>
|
||||
</div>
|
||||
{{if .Data.Expiring}}<div class="card"><h2>Требуют внимания</h2><div class="table-wrap"><table>
|
||||
<thead><tr><th>Клиент</th><th>План</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
|
||||
{{range .Data.Expiring}}<tr><td><a href="{{adm}}/licenses/{{.ID}}">{{.TenantName}}</a></td><td>{{plan .Plan}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td></tr>{{end}}
|
||||
</tbody></table></div></div>{{end}}
|
||||
<div class="card"><h2>Последние события</h2>{{template "evtable" .Data.Events}}<p class="small" style="margin:10px 0 0"><a href="{{adm}}/events">Весь журнал →</a></p></div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "tenants"}}{{template "head" .}}
|
||||
<h1>Клиенты</h1>
|
||||
<div class="card"><div class="table-wrap"><table>
|
||||
<thead><tr><th>Клиент</th><th>ИНН</th><th>Контакт</th><th>Лицензий</th><th>Установок</th><th>Добавлен</th></tr></thead><tbody>
|
||||
{{range .Data}}<tr><td><a href="{{adm}}/tenants/{{.ID}}"><b>{{.Name}}</b></a></td><td class="mono">{{.INN}}</td><td class="small">{{.Contact}}</td><td>{{.Licenses}}</td><td>{{.Installs}}</td><td class="small">{{d .CreatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="6" class="muted">Клиентов пока нет. Добавьте первого ниже.</td></tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
<div class="card"><h2>Новый клиент</h2>
|
||||
<form class="f" method="post" action="{{adm}}/tenants"><input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="row2">
|
||||
<div><label>Название</label><input type="text" name="name" required placeholder="АО «ИК Фонтвьель»"></div>
|
||||
<div><label>ИНН</label><input type="text" name="inn" inputmode="numeric" pattern="\d{10}|\d{12}" placeholder="10 или 12 цифр"></div>
|
||||
</div>
|
||||
<div><label>Контакт</label><input type="text" name="contact" placeholder="ответственный, почта, телефон"></div>
|
||||
<div><label>Примечание</label><textarea name="note"></textarea></div>
|
||||
<div><button class="btn primary" type="submit">Добавить клиента</button></div>
|
||||
</form></div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "tenant"}}{{template "head" .}}
|
||||
<p class="small" style="margin:0 0 6px"><a href="{{adm}}/tenants">← Клиенты</a></p>
|
||||
<h1>{{.Data.Tenant.Name}}</h1>
|
||||
<div class="card"><div class="actions" style="justify-content:space-between;margin-bottom:10px"><h2 style="margin:0">Лицензии</h2><a class="btn primary" href="{{adm}}/licenses?tenant={{.Data.Tenant.ID}}#new">Выпустить лицензию</a></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Лицензия</th><th>План</th><th>Установок</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
|
||||
{{range .Data.Licenses}}<tr><td><a class="mono" href="{{adm}}/licenses/{{.ID}}">{{.Code}}</a></td><td>{{plan .Plan}}</td><td>{{.Active}} из {{if .MaxNodes}}{{.MaxNodes}}{{else}}∞{{end}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td></tr>
|
||||
{{else}}<tr><td colspan="5" class="muted">Лицензий нет</td></tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
<div class="card"><h2>Реквизиты</h2>
|
||||
<form class="f" method="post" action="{{adm}}/tenants/{{.Data.Tenant.ID}}"><input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="row2">
|
||||
<div><label>Название</label><input type="text" name="name" value="{{.Data.Tenant.Name}}" required></div>
|
||||
<div><label>ИНН</label><input type="text" name="inn" value="{{.Data.Tenant.INN}}" inputmode="numeric" pattern="\d{10}|\d{12}"></div>
|
||||
</div>
|
||||
<div><label>Контакт</label><input type="text" name="contact" value="{{.Data.Tenant.Contact}}"></div>
|
||||
<div><label>Примечание</label><textarea name="note">{{.Data.Tenant.Note}}</textarea></div>
|
||||
<div class="actions"><button class="btn primary" type="submit">Сохранить</button><span class="muted small">добавлен {{dt .Data.Tenant.CreatedAt}}</span></div>
|
||||
</form></div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "terms"}}
|
||||
<div class="row2">
|
||||
<div><label>План</label><select name="plan">{{$cur := .Plan}}{{range $.Plans}}<option value="{{.Key}}" {{if eq .Key $cur}}selected{{end}}>{{.Title}}</option>{{end}}</select></div>
|
||||
<div><label>Установок (0 = без лимита)</label><input type="number" name="max_nodes" min="0" max="1000" value="{{.MaxNodes}}"></div>
|
||||
<div><label>Договор действует до</label><input type="date" name="contract_until" value="{{dateInput .ContractUntil}}" required></div>
|
||||
</div>
|
||||
<div><label>Модули</label><div class="checks">{{$f := .Features}}{{range $.All}}<label><input type="checkbox" name="features" value="{{.Key}}" {{if has $f .Key}}checked{{end}}> {{.Title}}</label>{{end}}</div></div>
|
||||
<div><label>Примечание (видно только здесь)</label><textarea name="note">{{.Note}}</textarea></div>
|
||||
{{end}}
|
||||
|
||||
{{define "licenses"}}{{template "head" .}}
|
||||
<h1>Лицензии</h1>
|
||||
<div class="card"><div class="table-wrap"><table>
|
||||
<thead><tr><th>Клиент</th><th>Код активации</th><th>План</th><th>Модули</th><th>Установок</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
|
||||
{{range .Data.Rows}}<tr>
|
||||
<td><a href="{{adm}}/licenses/{{.ID}}"><b>{{.TenantName}}</b></a></td><td class="mono">{{.Code}}</td><td>{{plan .Plan}}</td>
|
||||
<td>{{range .Features}}<span class="chip">{{feature .}}</span>{{else}}<span class="none"></span>{{end}}</td>
|
||||
<td>{{.Active}} из {{if .MaxNodes}}{{.MaxNodes}}{{else}}∞{{end}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td>
|
||||
</tr>{{else}}<tr><td colspan="7" class="muted">Лицензий пока нет.</td></tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
<div class="card" id="new"><h2>Выпустить лицензию</h2>
|
||||
{{if .Data.Tenants}}
|
||||
<form class="f" method="post" action="{{adm}}/licenses"><input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div><label>Клиент</label><select name="tenant_id" required>{{$sel := .Data.Tenant}}{{range .Data.Tenants}}<option value="{{.ID}}" {{if eq .ID $sel}}selected{{end}}>{{.Name}}{{if .INN}} · ИНН {{.INN}}{{end}}</option>{{end}}</select></div>
|
||||
{{template "terms" (dict2 .Plans .Features)}}
|
||||
<div><button class="btn primary" type="submit">Выпустить и получить код активации</button></div>
|
||||
</form>
|
||||
{{else}}<p class="muted">Сначала <a href="{{adm}}/tenants">добавьте клиента</a>.</p>{{end}}
|
||||
</div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "license"}}{{template "head" .}}{{$row := .Data.Row}}{{$csrf := .CSRF}}
|
||||
<p class="small" style="margin:0 0 6px"><a href="{{adm}}/licenses">← Лицензии</a> · <a href="{{adm}}/tenants/{{$row.TenantID}}">{{$row.TenantName}}</a></p>
|
||||
<h1>{{$row.TenantName}} <span class="badge {{$row.StatusClass}}" style="vertical-align:middle">{{$row.Status}}</span></h1>
|
||||
|
||||
{{if .Data.OfflineToken}}<div class="card" style="border-color:var(--accent)"><h2>Офлайн-ключ выпущен</h2>
|
||||
<p class="muted small" style="margin-top:0">Ключ показывается один раз. Вставьте его в bj: Настройка → Лицензия. Действует до {{d $row.ContractUntil}} без связи с сервером; отзыв на него не распространяется.</p>
|
||||
<div class="token">{{.Data.OfflineToken}}</div></div>{{end}}
|
||||
|
||||
<div class="grid">
|
||||
<div class="card"><h2>Код активации</h2>
|
||||
<div class="code">{{$row.Code}}</div>
|
||||
<p class="muted small">Клиент вводит код в bj (Настройка → Лицензия → «Активировать»). Одним кодом активируется до {{if $row.MaxNodes}}{{$row.MaxNodes}}{{else}}любого числа{{end}} установок; сейчас активных {{$row.Active}}.</p>
|
||||
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/code" onsubmit="return confirm('Выпустить новый код? Прежний перестанет действовать; активированные установки продолжат работать.')">
|
||||
<input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Выпустить новый код</button></form>
|
||||
</div>
|
||||
<div class="card"><h2>Сводка</h2><dl class="kv">
|
||||
<dt>План</dt><dd>{{plan $row.Plan}}</dd>
|
||||
<dt>Модули</dt><dd>{{range $row.Features}}<span class="chip">{{feature .}}</span>{{else}}<span class="none"></span>{{end}}</dd>
|
||||
<dt>Договор до</dt><dd>{{d $row.ContractUntil}}{{if not $row.Revoked}} <span class="muted small">(осталось {{daysLeft $row.ContractUntil}} дн.)</span>{{end}}</dd>
|
||||
<dt>Выпущена</dt><dd>{{dt $row.CreatedAt}}{{if $row.CreatedBy}} · {{$row.CreatedBy}}{{end}}</dd>
|
||||
<dt>ID</dt><dd class="mono">{{$row.ID}}</dd>
|
||||
{{if $row.Revoked}}<dt>Отозвана</dt><dd>{{dt $row.RevokedAt}}: {{$row.RevokeReason}}</dd>{{end}}
|
||||
</dl></div>
|
||||
</div>
|
||||
|
||||
<div class="card"><h2>Установки</h2><div class="table-wrap"><table>
|
||||
<thead><tr><th>Сервер</th><th>Версия</th><th>IP</th><th>Первая активация</th><th>Последнее продление</th><th>Токен до</th><th></th></tr></thead><tbody>
|
||||
{{range .Data.Installs}}<tr>
|
||||
<td><b>{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}</b><div class="mono muted">{{.InstallID}}</div>{{if .Blocked}}<span class="badge bad">заблокирована</span> <span class="small">{{.BlockReason}}</span>{{else if .Stale}}<span class="badge warn">нет связи</span>{{end}}</td>
|
||||
<td class="small">{{.Version}}</td><td class="small mono">{{.IP}}</td><td class="small">{{dt .FirstSeen}}</td>
|
||||
<td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td><td class="small">{{d .TokenExpires}}</td>
|
||||
<td>{{if .Blocked}}<form method="post" action="{{adm}}/licenses/{{$row.ID}}/installs/{{.InstallID}}/unblock"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Разблокировать</button></form>
|
||||
{{else}}<details><summary class="small">Заблокировать</summary><form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/installs/{{.InstallID}}/block" style="margin-top:6px;min-width:200px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="text" name="reason" placeholder="причина" required><button class="btn sm danger" type="submit">Заблокировать</button></form></details>{{end}}</td>
|
||||
</tr>{{else}}<tr><td colspan="7" class="muted">Установок ещё нет: они появятся после активации кодом.</td></tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
|
||||
<div class="card"><h2>Условия</h2>
|
||||
<form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/edit"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
{{template "terms" (dict3 .Plans .Features $row.License)}}
|
||||
<div class="actions"><button class="btn primary" type="submit">Сохранить условия</button><span class="muted small">установки получат их при следующем продлении</span></div>
|
||||
</form></div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card"><h2>Офлайн-ключ</h2>
|
||||
<p class="muted small" style="margin-top:0">Для закрытого контура без доступа к серверу: ключ без привязки к установке, действует до окончания договора. Отозвать его нельзя.</p>
|
||||
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/offline" onsubmit="return confirm('Выпустить офлайн-ключ до {{d $row.ContractUntil}}? Отозвать его будет нельзя.')"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn" type="submit" {{if ne $row.StatusClass "ok"}}{{if ne $row.StatusClass "warn"}}disabled{{end}}{{end}}>Выпустить офлайн-ключ</button></form>
|
||||
</div>
|
||||
<div class="card"><h2>{{if $row.Revoked}}Восстановление{{else}}Отзыв{{end}}</h2>
|
||||
{{if $row.Revoked}}
|
||||
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/restore"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn" type="submit">Восстановить лицензию</button></form>
|
||||
{{else}}
|
||||
<p class="muted small" style="margin-top:0">Продления прекращаются; выданные токены доживают свой срок (до 60 дней).</p>
|
||||
<form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/revoke" onsubmit="return confirm('Отозвать лицензию?')"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<input type="text" name="reason" placeholder="причина отзыва" required><div><button class="btn danger" type="submit">Отозвать лицензию</button></div></form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card"><h2>Журнал лицензии</h2>{{template "evtable" .Data.Events}}</div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "installs"}}{{template "head" .}}{{$csrf := .CSRF}}
|
||||
<h1>Установки</h1>
|
||||
<div class="card"><div class="table-wrap"><table>
|
||||
<thead><tr><th>Клиент</th><th>Сервер</th><th>Версия</th><th>IP</th><th>Последнее продление</th><th>Токен до</th><th>Статус</th></tr></thead><tbody>
|
||||
{{range .Data.Installs}}<tr>
|
||||
<td><a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a></td>
|
||||
<td><b>{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}</b><div class="mono muted">{{short .InstallID}}</div></td>
|
||||
<td class="small">{{.Version}}</td><td class="small mono">{{.IP}}</td><td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td><td class="small">{{d .TokenExpires}}</td>
|
||||
<td>{{if .Blocked}}<span class="badge bad">заблокирована</span>{{else if .Stale}}<span class="badge warn">нет связи</span>{{else}}<span class="badge ok">на связи</span>{{end}}</td>
|
||||
</tr>{{else}}<tr><td colspan="7" class="muted">Установок пока нет.</td></tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
<div class="card"><h2>Доверенные IP (fail2ban)</h2>
|
||||
<p class="muted small" style="margin:0 0 10px">Адрес попадает сюда после успешной активации или продления лицензии. fail2ban не блокирует его за ошибки API (неверный код, испорченный токен). На вход в кабинет и SSH доверие не распространяется. При отзыве лицензии или блокировке установки адрес исключается автоматически.</p>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>IP</th><th>Клиент</th><th>Сервер</th><th>Добавлен</th><th>Последнее обращение</th><th></th></tr></thead><tbody>
|
||||
{{range .Data.Trusted}}<tr>
|
||||
<td class="mono"><b>{{.IP}}</b></td>
|
||||
<td><a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a></td>
|
||||
<td class="small">{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}<div class="mono muted">{{short .InstallID}}</div></td>
|
||||
<td class="small">{{dt .AddedAt}}</td><td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td>
|
||||
<td><form method="post" action="{{adm}}/trusted/remove"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="ip" value="{{.IP}}"><input type="hidden" name="install" value="{{.InstallID}}"><button class="btn sm danger" type="submit">Исключить</button></form></td>
|
||||
</tr>{{else}}<tr><td colspan="6" class="muted">Доверенных адресов пока нет: они появятся после первой активации или продления.</td></tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "security"}}{{template "head" .}}{{$csrf := .CSRF}}{{$d := .Data}}
|
||||
<h1>Защита</h1>
|
||||
<div class="card"><h2>Доступ к кабинету</h2>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h3 style="margin:0 0 6px;font-size:15px">Адрес кабинета</h3>
|
||||
<p style="margin:0 0 8px"><span class="mono"><b>https://{{$d.Host}}{{$d.AdminBase}}/</b></span> {{if $d.AdminDefault}}<span class="badge warn">стандартный</span>{{else}}<span class="badge ok">свой</span>{{end}}</p>
|
||||
<form method="post" action="{{adm}}/security/access" class="actions" onsubmit="return confirm('Сменить адрес кабинета? Прежний перестанет открываться.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path"><input name="path" placeholder="свой адрес, 8–64 символа: a-z, 0-9, дефис" pattern="[a-z0-9][a-z0-9\-]{7,63}" required style="max-width:280px"><button class="btn" type="submit">Сохранить</button></form>
|
||||
<div class="actions" style="margin-top:6px">
|
||||
<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Сгенерировать новый адрес кабинета? Прежний перестанет открываться.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path-random"><button class="btn" type="submit">Сгенерировать случайный</button></form>
|
||||
{{if not $d.AdminDefault}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Вернуть стандартный адрес /admin?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path-default"><button class="btn sm" type="submit">Вернуть /admin</button></form>{{end}}
|
||||
</div>
|
||||
<p class="muted small" style="margin:8px 0 0">При своём адресе /admin и главная страница отвечают «не найдено», и боты не находят форму входа. Сохраните адрес в закладки и не пересылайте. Узнать по SSH: <span class="mono">/opt/bj-license/bj-license-server admin-info</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin:0 0 6px;font-size:15px">Только из белого списка</h3>
|
||||
<p style="margin:0 0 8px">{{if and $d.AdminAllowOnly $d.AdminOpen}}<span class="badge warn">открыт аварийно по SSH</span> Ограничение включено, но сейчас не действует.{{else if $d.AdminAllowOnly}}<span class="badge ok">включено</span> Кабинет и страница входа видны только адресам белого списка администраторов, остальным отвечает «не найдено».{{else}}<span class="badge warn">выключено</span> Страница входа открывается с любого адреса.{{end}}</p>
|
||||
{{if and $d.AdminAllowOnly (not $d.AdminOpen)}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Снять ограничение? Страница входа станет доступна с любого адреса.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="allow-only-off"><button class="btn" type="submit">Снять ограничение</button></form>
|
||||
{{else}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Открывать кабинет только с адресов белого списка?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="allow-only-on"><button class="btn primary" type="submit">Включить ограничение</button></form>{{end}}
|
||||
<p class="muted small" style="margin:8px 0 0">API активации и продления это не затрагивает. Ваш адрес <span class="mono">{{$d.MyIP}}</span> {{if $d.MyIPCovered}}в белом списке{{else}}добавится в белый список при включении{{end}}. Аварийно открыть кабинет по SSH: <span class="mono">/opt/bj-license/bj-license-server fw-apply -off</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin:0 0 6px;font-size:15px">Второй фактор</h3>
|
||||
<p style="margin:0 0 8px">{{if eq $d.TwoFA $d.Admins}}<span class="badge ok">у всех</span>{{else}}<span class="badge warn">{{$d.TwoFA}} из {{$d.Admins}}</span>{{end}} администраторов входят с кодом из приложения.</p>
|
||||
<div class="actions"><a class="btn" href="{{adm}}/me/2fa">Мой второй фактор</a><a class="btn" href="{{adm}}/users">Администраторы</a></div>
|
||||
<p class="muted small" style="margin:8px 0 0">Даже зная пароль, без телефона войти нельзя. Потерянный телефон: второй фактор сбрасывает другой администратор или по SSH <span class="mono">admin-config -reset-2fa логин</span> при остановленном сервере.</p>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<div class="card"><h2>ЗЕТИТ Ключ</h2>
|
||||
<p style="margin:0 0 10px">{{if $d.ZKEnabled}}<span class="badge ok">включён</span> После пароля вход подтверждается push-уведомлением в приложении «ЗЕТИТ Ключ» или кодом из него. Действует для администраторов с указанным e-mail ({{$d.ZKWithEmail}} из {{$d.Admins}}).{{else}}<span class="badge warn">выключен</span> Второй фактор через приложение «ЗЕТИТ Ключ», как при входе в ZETIT Connect.{{end}}</p>
|
||||
<form class="f" method="post" action="{{adm}}/security/zetit-key" style="max-width:620px"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<div><label>Адрес API</label><input type="url" name="url" value="{{$d.ZKURL}}" placeholder="https://key.zetit.ru/api/v1/connect-auth"></div>
|
||||
<div><label>Ключ приложения (X-Connect-Key)</label><input type="password" name="app_key" autocomplete="off" placeholder="{{if $d.ZKHasKey}}задан, оставьте пустым, чтобы не менять{{else}}панель ЗЕТИТ Ключ → Система → Внешние приложения → Добавить{{end}}"></div>
|
||||
<label style="display:flex;gap:8px;align-items:center;font-weight:600"><input type="checkbox" name="enabled" value="1" {{if $d.ZKEnabled}}checked{{end}}> Требовать подтверждение в ЗЕТИТ Ключ при входе</label>
|
||||
<div class="actions"><button class="btn primary" type="submit" name="op" value="save">Сохранить</button><button class="btn" type="submit" name="op" value="test">Проверить мой e-mail</button></div>
|
||||
</form>
|
||||
<p class="muted small" style="margin:10px 0 0">E-mail администраторов задаётся на странице <a href="{{adm}}/users">«Администраторы»</a> и должен совпадать с учётной записью в ЗЕТИТ Ключ. Включить можно, только если ваш e-mail найден в Ключе с привязанным приложением. У администраторов без e-mail действует пароль и код приложения-аутентификатора, если он подключён. Если Ключ недоступен, вход с e-mail невозможен. Аварийно выключить по SSH при остановленном сервере: <span class="mono">bj-license-server admin-config -zetit-key off</span></p>
|
||||
</div>
|
||||
|
||||
{{if not $d.StatusFresh}}<div class="card"><span class="badge bad">Помощник защиты не отвечает</span>
|
||||
<p class="small" style="margin:8px 0 0">{{if $d.Status.At.IsZero}}Статуса ещё нет.{{else}}Последний отчёт {{dt $d.Status.At}} ({{ago $d.Status.At}}).{{end}} Команды из этого раздела не применяются. На сервере: <span class="mono">systemctl status bj-license-fw.timer bj-license-fw.path</span></p></div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Режим обороны</h2>
|
||||
<p>{{if $d.Status.Lockdown}}<span class="badge bad">Оборона включена</span> На сайт и API пускаются только адреса из белого списка ({{$d.Status.AllowCount}}){{if not $d.Status.Until.IsZero}}, автоматически выключится {{dt $d.Status.Until}}{{end}}.
|
||||
{{else if $d.Status.Emergency}}<span class="badge warn">Оборона выключена аварийно по SSH</span> Включить снова можно кнопкой ниже.
|
||||
{{else}}<span class="badge ok">Обычный режим</span> Доступ открыт всем, fail2ban блокирует адреса, которые перебирают пароли и коды.{{end}}</p>
|
||||
{{if $d.WantedActive}}
|
||||
<form method="post" action="{{adm}}/security/mode" class="actions" onsubmit="return confirm('Выключить режим обороны и открыть доступ всем?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="off"><button class="btn primary" type="submit">Выключить оборону</button></form>
|
||||
{{else}}
|
||||
<form method="post" action="{{adm}}/security/mode" class="actions" onsubmit="return confirm('Включить режим обороны? Сайт и API станут недоступны всем, кроме белого списка.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="on">
|
||||
<select name="hours"><option value="1">на 1 час</option><option value="6">на 6 часов</option><option value="24" selected>на 24 часа</option><option value="0">без срока</option></select>
|
||||
<button class="btn danger" type="submit">Включить оборону</button></form>
|
||||
{{end}}
|
||||
<p class="muted small" style="margin:10px 0 0">Для атаки на сервер: все, кроме белого списка, получают отказ на уровне файрвола (порты 80 и 443). В белом списке: доверенные IP установок ({{$d.TrustedCount}}, <a href="{{adm}}/installs">Установки</a>) и адреса администраторов ниже. Ваш адрес <span class="mono">{{$d.MyIP}}</span> {{if $d.MyIPCovered}}уже в белом списке{{else}}добавится в белый список автоматически при включении{{end}}. SSH не затрагивается. Пока оборона включена, Let's Encrypt не сможет продлить сертификат. Аварийно выключить по SSH: <span class="mono">/opt/bj-license/bj-license-server fw-apply -off</span></p>
|
||||
{{if $d.Status.Errors}}<div style="margin-top:10px">{{range $d.Status.Errors}}<div class="badge bad" style="display:block;margin-top:4px;white-space:normal">{{.}}</div>{{end}}</div>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card"><h2>Белый список администраторов</h2>
|
||||
<p class="muted small" style="margin:0 0 10px">Адреса офиса и администраторов. Они не блокируются fail2ban (ни за вход в кабинет, ни за API) и пропускаются в режиме обороны.</p>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>IP или сеть</th><th>Комментарий</th><th>Добавил</th><th></th></tr></thead><tbody>
|
||||
{{range $d.Allow}}<tr><td class="mono"><b>{{.Net}}</b></td><td class="small">{{.Comment}}</td><td class="small">{{.AddedBy}}<div class="muted">{{dt .AddedAt}}</div></td>
|
||||
<td><form method="post" action="{{adm}}/security/allow"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="remove"><input type="hidden" name="net" value="{{.Net}}"><button class="btn sm danger" type="submit">Убрать</button></form></td></tr>
|
||||
{{else}}<tr><td colspan="4" class="muted">Пока пусто.</td></tr>{{end}}
|
||||
</tbody></table></div>
|
||||
<form method="post" action="{{adm}}/security/allow" class="actions" style="margin-top:10px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="add">
|
||||
<input name="net" placeholder="IP или сеть, например 203.0.113.10 или 203.0.113.0/24" required style="max-width:340px"><input name="comment" placeholder="комментарий" style="max-width:260px"><button class="btn" type="submit">Добавить</button></form>
|
||||
</div>
|
||||
|
||||
<div class="card"><h2>Параметры блокировок</h2>
|
||||
<p class="muted small" style="margin:0 0 10px">Сколько неудачных попыток за период приводят к блокировке адреса и на какой срок. Долгие блокировки сохраняются и после перезапуска fail2ban. Белый список и доверенные установки не блокируются.</p>
|
||||
<form method="post" action="{{adm}}/security/limits"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Что защищаем</th><th>Попыток</th><th>За период</th><th>Блокировать на</th></tr></thead><tbody>
|
||||
{{range $d.Limits}}{{$row := .}}<tr><td><b>{{.Title}}</b>{{if .HasEffective}}<div class="muted small">{{.Effective}}</div>{{end}}</td>
|
||||
<td><select name="retry_{{.Jail}}">{{range .RetryOpts}}<option value="{{.}}" {{if eq . $row.Conf.MaxRetry}}selected{{end}}>{{.}}</option>{{end}}</select></td>
|
||||
<td><select name="find_{{.Jail}}">{{range .FindOpts}}<option value="{{.Sec}}" {{if eq .Sec $row.Conf.FindTime}}selected{{end}}>{{.Label}}</option>{{end}}</select></td>
|
||||
<td><select name="ban_{{.Jail}}">{{range .BanOpts}}<option value="{{.Sec}}" {{if eq .Sec $row.Conf.BanTime}}selected{{end}}>{{.Label}}</option>{{end}}</select></td></tr>
|
||||
{{end}}
|
||||
<tr><td><b>Вход в кабинет (приложение)</b><div class="muted small">срабатывает раньше fail2ban: после N неудачных попыток форма входа отказывает этому адресу и логину</div></td>
|
||||
<td><select name="login_tries">{{range $d.RetryPresets}}<option value="{{.}}" {{if eq . $d.LoginTries}}selected{{end}}>{{.}}</option>{{end}}{{if and (ne $d.LoginTries 3) (ne $d.LoginTries 5) (ne $d.LoginTries 10) (ne $d.LoginTries 20)}}<option value="{{$d.LoginTries}}" selected>{{$d.LoginTries}}</option>{{end}}</select></td>
|
||||
<td class="muted small">за срок блокировки</td>
|
||||
<td><select name="login_lock">{{range $d.BanPresets}}{{if ne .Sec -1}}<option value="{{.Sec}}" {{if eq .Sec $d.LoginLockSec}}selected{{end}}>{{.Label}}</option>{{end}}{{end}}</select></td></tr>
|
||||
</tbody></table></div>
|
||||
<div class="actions" style="margin-top:10px"><button class="btn primary" type="submit">Сохранить параметры</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card"><h2>fail2ban</h2>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Jail</th><th>Ошибок сейчас / всего</th><th>Заблокировано сейчас / всего</th><th>Заблокированные адреса</th></tr></thead><tbody>
|
||||
{{range $d.Status.Jails}}{{$jail := .Name}}<tr><td><b>{{.Name}}</b><div class="muted small">{{if eq .Name "bj-license-api"}}коды активации и токены{{else if eq .Name "bj-license-admin"}}вход в кабинет{{else if eq .Name "sshd"}}SSH{{end}}</div></td>
|
||||
<td>{{.CurrentlyFailed}} / {{.TotalFailed}}</td><td>{{.CurrentlyBanned}} / {{.TotalBanned}}</td>
|
||||
<td>{{range .Banned}}<form method="post" action="{{adm}}/security/ban" class="actions" style="margin:0 0 4px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="unban"><input type="hidden" name="jail" value="{{$jail}}"><input type="hidden" name="ip" value="{{.}}"><span class="mono">{{.}}</span><button class="btn sm" type="submit">Разблокировать</button></form>{{else}}<span class="muted">нет</span>{{end}}</td></tr>
|
||||
{{else}}<tr><td colspan="4" class="muted">Данных пока нет.</td></tr>{{end}}
|
||||
</tbody></table></div>
|
||||
{{if $d.Pending}}<p class="small" style="margin:10px 0 0"><b>Ожидают применения:</b> {{range $d.Pending}}<span class="mono">{{.Op}} {{.IP}} ({{.Jail}})</span> {{end}}</p>{{end}}
|
||||
<form method="post" action="{{adm}}/security/ban" class="actions" style="margin-top:12px"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<input name="ip" placeholder="IP-адрес" required style="max-width:220px">
|
||||
<select name="jail">{{range $d.Jails}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
||||
<select name="op"><option value="ban">Заблокировать</option><option value="unban">Разблокировать</option></select>
|
||||
<button class="btn" type="submit">Выполнить</button></form>
|
||||
<p class="muted small" style="margin:8px 0 0">Ручная блокировка действует срок блокировки jail из «Параметров блокировок». Статус обновляется раз в 30 секунд{{if not $d.Status.At.IsZero}}, последний раз {{dt $d.Status.At}}{{end}}.</p>
|
||||
</div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "totp"}}{{template "head" .}}{{$csrf := .CSRF}}{{$d := .Data}}
|
||||
<h1>Второй фактор входа</h1>
|
||||
<div class="card">
|
||||
{{if $d.Enabled}}
|
||||
<p><span class="badge ok">Подключён</span> При входе в кабинет, кроме пароля, нужен шестизначный код из приложения.</p>
|
||||
<form class="f" method="post" action="{{adm}}/me/2fa/disable" style="max-width:360px"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<div><label>Код из приложения, чтобы отключить</label><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required></div>
|
||||
<div><button class="btn danger" type="submit">Отключить второй фактор</button></div></form>
|
||||
{{else}}
|
||||
<p style="margin-top:0">Установите на телефон приложение для одноразовых кодов: Яндекс Ключ, Google Authenticator, Microsoft Authenticator или любое с поддержкой TOTP.</p>
|
||||
<div class="grid">
|
||||
<div><h2>1. Добавьте ключ в приложение</h2>
|
||||
{{if $d.QR}}<div style="background:#fff;display:inline-block;padding:8px;border-radius:8px">{{$d.QR}}</div>{{end}}
|
||||
<p class="small">Отсканируйте QR-код или введите ключ вручную: <span class="mono"><b>{{$d.SecretGrouped}}</b></span>. Если открыли кабинет на телефоне, <a href="{{$d.URI}}">добавить в приложение</a>.</p></div>
|
||||
<div><h2>2. Подтвердите кодом</h2>
|
||||
<form class="f" method="post" action="{{adm}}/me/2fa/enable"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<div><label>Шестизначный код из приложения</label><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required autofocus></div>
|
||||
<div><button class="btn primary" type="submit">Подключить</button></div></form>
|
||||
<p class="muted small">Ключ показывается только на этой странице. Если телефон потеряется, второй фактор сбросит другой администратор на странице «Администраторы».</p></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "events"}}{{template "head" .}}
|
||||
<h1>Журнал</h1>
|
||||
<div class="card">{{template "evtable" .Data}}<p class="muted small" style="margin:10px 0 0">Последние 500 событий.</p></div>
|
||||
{{template "foot" .}}{{end}}
|
||||
|
||||
{{define "users"}}{{template "head" .}}{{$csrf := .CSRF}}{{$me := .Me}}
|
||||
<h1>Администраторы</h1>
|
||||
<div class="card"><div class="table-wrap"><table>
|
||||
<thead><tr><th>Логин</th><th>Имя</th><th>E-mail (ЗЕТИТ Ключ)</th><th>Последний вход</th><th>Статус</th><th>Второй фактор</th><th>Сменить пароль</th><th></th></tr></thead><tbody>
|
||||
{{range .Data}}<tr>
|
||||
<td><b>{{.Login}}</b></td><td>{{.Name}}</td><td><form method="post" action="{{adm}}/users/{{.Login}}/email" class="actions" style="margin:0"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="email" name="email" value="{{.Email}}" placeholder="e-mail в ЗЕТИТ Ключ" style="max-width:220px"><button class="btn sm" type="submit">Сохранить</button></form></td><td class="small">{{dt .LastLogin}}</td>
|
||||
<td>{{if .Disabled}}<span class="badge bad">отключён</span>{{else}}<span class="badge ok">активен</span>{{end}}</td>
|
||||
<td>{{if .HasTOTP}}<span class="badge ok">подключён</span>{{if ne .Login $me.Login}}<form method="post" action="{{adm}}/users/{{.Login}}/2fa-reset" style="margin:4px 0 0" onsubmit="return confirm('Сбросить второй фактор администратора?')"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Сбросить</button></form>{{end}}{{else}}<span class="badge warn">нет</span>{{end}}{{if eq .Login $me.Login}}<div><a class="small" href="{{adm}}/me/2fa">{{if .HasTOTP}}управлять{{else}}подключить{{end}}</a></div>{{end}}</td>
|
||||
<td><form class="actions" method="post" action="{{adm}}/users/{{.Login}}/password"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="password" name="password" placeholder="новый пароль, от 10 символов" minlength="10" required autocomplete="new-password" style="max-width:230px"><button class="btn sm" type="submit">Задать</button></form></td>
|
||||
<td>{{if ne .Login $me.Login}}<form method="post" action="{{adm}}/users/{{.Login}}/toggle"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm {{if not .Disabled}}danger{{end}}" type="submit">{{if .Disabled}}Включить{{else}}Отключить{{end}}</button></form>{{end}}</td>
|
||||
</tr>{{end}}
|
||||
</tbody></table></div></div>
|
||||
<div class="grid">
|
||||
<div class="card"><h2>Новый администратор</h2>
|
||||
<form class="f" method="post" action="{{adm}}/users"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<div class="row2"><div><label>Логин</label><input type="text" name="login" required pattern="[A-Za-z0-9_.\-]{2,40}"></div><div><label>Имя</label><input type="text" name="name"></div></div>
|
||||
<div><label>E-mail в ЗЕТИТ Ключ <span class="muted" style="font-weight:400">(для подтверждения входа в приложении)</span></label><input type="email" name="email"></div>
|
||||
<div><label>Пароль (от 10 символов)</label><input type="password" name="password" minlength="10" required autocomplete="new-password"></div>
|
||||
<div><button class="btn primary" type="submit">Добавить</button></div>
|
||||
</form></div>
|
||||
<div class="card"><h2>Мой пароль</h2>
|
||||
<form class="f" method="post" action="{{adm}}/me/password"><input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<div><label>Текущий пароль</label><input type="password" name="current" required autocomplete="current-password"></div>
|
||||
<div class="row2"><div><label>Новый пароль</label><input type="password" name="password" minlength="10" required autocomplete="new-password"></div><div><label>Повтор</label><input type="password" name="password2" minlength="10" required autocomplete="new-password"></div></div>
|
||||
<div><button class="btn primary" type="submit">Сменить пароль</button></div>
|
||||
</form></div>
|
||||
</div>
|
||||
{{template "foot" .}}{{end}}
|
||||
@@ -1,389 +0,0 @@
|
||||
package main
|
||||
|
||||
// Второй фактор через «ЗЕТИТ Ключ» — так же, как вход в ZETIT Connect. После пароля сервер лицензий
|
||||
// просит Ключ подтвердить вход: push-уведомление на телефон администратора или код из приложения.
|
||||
// Статус опрашивает только сервер; вход разрешается лишь при approved (push) или success (код).
|
||||
// API: Zetit_key/docs/EXTERNAL_APP_2FA.md, способ 1 (приложение с X-Connect-Key).
|
||||
//
|
||||
// Действует для администраторов с указанным e-mail, когда интеграция включена в разделе «Защита».
|
||||
// Остальные входят по паролю и, если подключён, по коду приложения-аутентификатора (access.go).
|
||||
// Аварийно выключить по SSH: bj-license-server admin-config -zetit-key off (сервер остановлен).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
zetitKeyDefaultURL = "https://key.zetit.ru/api/v1/connect-auth"
|
||||
zkPendingTTL = 5 * time.Minute // push живёт 60 секунд, код из приложения можно ввести и позже
|
||||
)
|
||||
|
||||
// ZetitKeyConf — настройки интеграции с «ЗЕТИТ Ключ».
|
||||
type ZetitKeyConf struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
URL string `json:"url,omitempty"` // по умолчанию https://key.zetit.ru/api/v1/connect-auth
|
||||
AppKey string `json:"app_key,omitempty"` // X-Connect-Key приложения из панели Ключа
|
||||
}
|
||||
|
||||
func (c ZetitKeyConf) active() bool { return c.Enabled && c.AppKey != "" }
|
||||
|
||||
var zkClient = &http.Client{Timeout: 8 * time.Second}
|
||||
|
||||
func zkCall(c ZetitKeyConf, method, path string, body, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, strings.TrimRight(pickStr(c.URL, zetitKeyDefaultURL), "/")+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Connect-Key", c.AppKey)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := zkClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("сервер недоступен: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("ответ %d: %s", resp.StatusCode, clip(string(data), 200))
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return fmt.Errorf("ответ не разобран: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type zkLookupResult struct {
|
||||
Exists bool `json:"exists"`
|
||||
Has2FA bool `json:"has_2fa"`
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
|
||||
type zkVerifyResult struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Status string `json:"status"`
|
||||
NeedsSetup bool `json:"needs_setup"`
|
||||
}
|
||||
|
||||
func zkLookup(c ZetitKeyConf, email string) (zkLookupResult, error) {
|
||||
var r zkLookupResult
|
||||
return r, zkCall(c, http.MethodGet, "/lookup?email="+url.QueryEscape(email), nil, &r)
|
||||
}
|
||||
|
||||
func zkStart(c ZetitKeyConf, email, ip string) (zkVerifyResult, error) {
|
||||
var r zkVerifyResult
|
||||
return r, zkCall(c, http.MethodPost, "/verify", map[string]string{"email": email, "source_ip": ip}, &r)
|
||||
}
|
||||
|
||||
func zkStatus(c ZetitKeyConf, requestID string) (string, error) {
|
||||
var r zkVerifyResult
|
||||
err := zkCall(c, http.MethodGet, "/verify/"+url.PathEscape(requestID), nil, &r)
|
||||
return r.Status, err
|
||||
}
|
||||
|
||||
func zkCode(c ZetitKeyConf, email, code string) (bool, error) {
|
||||
var r struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
err := zkCall(c, http.MethodPost, "/verify-totp", map[string]string{"email": email, "code": code}, &r)
|
||||
return r.Success, err
|
||||
}
|
||||
|
||||
// ---- вход с подтверждением ----
|
||||
|
||||
// pendingLogin — пароль проверен, ждём подтверждения в Ключе.
|
||||
type pendingLogin struct {
|
||||
Token, Login, Email, IP, RequestID, Next string
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
type pendingLogins struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*pendingLogin
|
||||
}
|
||||
|
||||
var zkPending = &pendingLogins{m: map[string]*pendingLogin{}}
|
||||
|
||||
func (p *pendingLogins) put(pl *pendingLogin) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for k, v := range p.m {
|
||||
if time.Since(v.Created) > zkPendingTTL {
|
||||
delete(p.m, k)
|
||||
}
|
||||
}
|
||||
p.m[pl.Token] = pl
|
||||
}
|
||||
|
||||
// get — ожидающий вход по токену с того же адреса.
|
||||
func (p *pendingLogins) get(token, ip string) *pendingLogin {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
pl := p.m[token]
|
||||
if pl == nil || pl.IP != ip || time.Since(pl.Created) > zkPendingTTL {
|
||||
return nil
|
||||
}
|
||||
return pl
|
||||
}
|
||||
|
||||
func (p *pendingLogins) drop(token string) {
|
||||
p.mu.Lock()
|
||||
delete(p.m, token)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (srv *server) zetitKey() ZetitKeyConf {
|
||||
var c ZetitKeyConf
|
||||
srv.st.read(func(s *State) { c = s.ZetitKey })
|
||||
return c
|
||||
}
|
||||
|
||||
// finishLogin — второй фактор пройден (или не требуется): сессия и переход в кабинет.
|
||||
func (srv *server) finishLogin(w http.ResponseWriter, r *http.Request, login, ip, next string, totpStep int64, how string) {
|
||||
srv.fails.reset(ip + "|" + strings.ToLower(login))
|
||||
sess := srv.sessions.create(login)
|
||||
_ = srv.st.write(func(s *State) error {
|
||||
if u := s.user(login); u != nil {
|
||||
u.LastLogin = time.Now()
|
||||
sess.Login = u.Login
|
||||
if totpStep > 0 {
|
||||
u.TOTPLast = totpStep
|
||||
}
|
||||
}
|
||||
s.addEvent("login", "", "", login, ip, "вход в кабинет"+how)
|
||||
return nil
|
||||
})
|
||||
srv.setSessionCookie(w, r, sess.Token)
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (srv *server) renderLoginErr(w http.ResponseWriter, login, next, msg string, status int) {
|
||||
p := page{Title: "Вход", Err: msg, Data: map[string]string{"Next": next, "Login": login}}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = srv.tpl.ExecuteTemplate(w, "login", p)
|
||||
}
|
||||
|
||||
// loginReject — отказ на втором факторе: счётчик неудач, строка для fail2ban, журнал.
|
||||
func (srv *server) loginReject(w http.ResponseWriter, login, ip, next, eventText, msg string) {
|
||||
srv.fails.add(ip + "|" + strings.ToLower(login))
|
||||
srv.fails.add(ip)
|
||||
log.Printf("login failed: user=%q ip=%s", login, ip) // строку ловит fail2ban (deploy/license/fail2ban)
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
_ = srv.st.write(func(s *State) error {
|
||||
s.addEvent("login_failed", "", "", login, ip, eventText)
|
||||
return nil
|
||||
})
|
||||
srv.renderLoginErr(w, login, next, msg, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func (srv *server) renderLogin2FA(w http.ResponseWriter, pl *pendingLogin, errMsg string) {
|
||||
left := 60 - int(time.Since(pl.Created).Seconds())
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
p := page{Title: "Подтверждение входа", Err: errMsg, Data: map[string]any{"Token": pl.Token, "Email": pl.Email, "Left": left}}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = srv.tpl.ExecuteTemplate(w, "login2fa", p)
|
||||
}
|
||||
|
||||
// login2FAStatus — GET /admin/login/2fa/status?t=…: статус push, опрос идёт с сервера.
|
||||
func (srv *server) login2FAStatus(w http.ResponseWriter, r *http.Request) {
|
||||
pl := zkPending.get(r.URL.Query().Get("t"), clientIP(r))
|
||||
status := "expired"
|
||||
if pl != nil && time.Since(pl.Created) <= 70*time.Second {
|
||||
if st, err := zkStatus(srv.zetitKey(), pl.RequestID); err == nil {
|
||||
status = st
|
||||
} else {
|
||||
status = "pending" // сбой связи — страница спросит ещё раз
|
||||
}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": status})
|
||||
}
|
||||
|
||||
// login2FAFinish — POST /admin/login/2fa/finish: страница увидела ответ, сервер перепроверяет его сам.
|
||||
func (srv *server) login2FAFinish(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
ip := clientIP(r)
|
||||
pl := zkPending.get(r.PostFormValue("t"), ip)
|
||||
if pl == nil {
|
||||
srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
st, err := zkStatus(srv.zetitKey(), pl.RequestID)
|
||||
switch {
|
||||
case err != nil:
|
||||
srv.renderLogin2FA(w, pl, "ЗЕТИТ Ключ не ответил: "+err.Error()+". Введите код из приложения.")
|
||||
case st == "approved":
|
||||
zkPending.drop(pl.Token)
|
||||
srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, push)")
|
||||
case st == "denied":
|
||||
zkPending.drop(pl.Token)
|
||||
srv.loginReject(w, pl.Login, ip, pl.Next, "вход отклонён в ЗЕТИТ Ключ", "Вход отклонён в приложении «ЗЕТИТ Ключ».")
|
||||
case st == "expired":
|
||||
srv.renderLogin2FA(w, pl, "Время подтверждения истекло. Введите код из приложения или войдите заново.")
|
||||
default:
|
||||
srv.renderLogin2FA(w, pl, "Вход ещё не подтверждён в приложении.")
|
||||
}
|
||||
}
|
||||
|
||||
// login2FACode — POST /admin/login/2fa/code: код из приложения «ЗЕТИТ Ключ».
|
||||
func (srv *server) login2FACode(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
ip := clientIP(r)
|
||||
pl := zkPending.get(r.PostFormValue("t"), ip)
|
||||
if pl == nil {
|
||||
srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if srv.fails.blocked(ip+"|"+strings.ToLower(pl.Login)) || srv.fails.blocked(ip) {
|
||||
zkPending.drop(pl.Token)
|
||||
srv.renderLoginErr(w, pl.Login, pl.Next, "Слишком много неудачных попыток. Вход заблокирован на "+durRu(int64(srv.fails.window()/time.Second))+".", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
ok, err := zkCode(srv.zetitKey(), pl.Email, strings.TrimSpace(r.PostFormValue("code")))
|
||||
switch {
|
||||
case err != nil:
|
||||
srv.renderLogin2FA(w, pl, "Не удалось проверить код: "+err.Error())
|
||||
case ok:
|
||||
zkPending.drop(pl.Token)
|
||||
srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, код)")
|
||||
default:
|
||||
srv.fails.add(ip + "|" + strings.ToLower(pl.Login))
|
||||
srv.fails.add(ip)
|
||||
log.Printf("login failed: user=%q ip=%s", pl.Login, ip)
|
||||
_ = srv.st.write(func(s *State) error {
|
||||
s.addEvent("login_failed", "", "", pl.Login, ip, "неверный код ЗЕТИТ Ключ")
|
||||
return nil
|
||||
})
|
||||
srv.renderLogin2FA(w, pl, "Код неверен или уже использован.")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- настройки ----
|
||||
|
||||
func validEmail(e string) bool {
|
||||
at := strings.Index(e, "@")
|
||||
return len(e) <= 120 && at > 0 && at < len(e)-3 && strings.Contains(e[at:], ".") && !strings.ContainsAny(e, " <>\"'")
|
||||
}
|
||||
|
||||
// userEmail — POST /admin/users/{login}/email: e-mail администратора в «ЗЕТИТ Ключ».
|
||||
func (srv *server) userEmail(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
login, email := r.PathValue("login"), strings.TrimSpace(r.PostFormValue("email"))
|
||||
if email != "" && !validEmail(email) {
|
||||
back(w, r, "/admin/users", "", "неверный e-mail")
|
||||
return
|
||||
}
|
||||
var bad string
|
||||
err := srv.st.write(func(s *State) error {
|
||||
u := s.user(login)
|
||||
if u == nil {
|
||||
return fmt.Errorf("администратор не найден")
|
||||
}
|
||||
if strings.EqualFold(u.Login, sess.Login) && email == "" && s.ZetitKey.active() {
|
||||
bad = "вход через ЗЕТИТ Ключ включён: без e-mail вы войдёте только по паролю. Сначала выключите интеграцию в «Защите»."
|
||||
return nil
|
||||
}
|
||||
u.Email = email
|
||||
s.addEvent("user_email", "", "", sess.Login, clientIP(r), "e-mail администратора "+u.Login+": "+pickStr(email, "удалён"))
|
||||
return nil
|
||||
})
|
||||
if err != nil || bad != "" {
|
||||
back(w, r, "/admin/users", "", pickStr(bad, fmt.Sprint(err)))
|
||||
return
|
||||
}
|
||||
back(w, r, "/admin/users", "E-mail "+login+" сохранён", "")
|
||||
}
|
||||
|
||||
// securityZetitKey — POST /admin/security/zetit-key: адрес API, ключ приложения, включение, проверка.
|
||||
func (srv *server) securityZetitKey(w http.ResponseWriter, r *http.Request, sess *session) {
|
||||
op := r.PostFormValue("op")
|
||||
var conf ZetitKeyConf
|
||||
var myEmail string
|
||||
srv.st.read(func(s *State) {
|
||||
conf = s.ZetitKey
|
||||
if u := s.user(sess.Login); u != nil {
|
||||
myEmail = u.Email
|
||||
}
|
||||
})
|
||||
if u := strings.TrimSpace(r.PostFormValue("url")); u != "" {
|
||||
pu, err := url.Parse(u)
|
||||
if err != nil || pu.Scheme != "https" || pu.Host == "" {
|
||||
back(w, r, "/admin/security", "", "адрес API ЗЕТИТ Ключ: https://…")
|
||||
return
|
||||
}
|
||||
conf.URL = strings.TrimRight(u, "/")
|
||||
}
|
||||
if k := strings.TrimSpace(r.PostFormValue("app_key")); k != "" {
|
||||
conf.AppKey = k
|
||||
}
|
||||
check := func() (string, bool) {
|
||||
switch {
|
||||
case conf.AppKey == "":
|
||||
return "не задан ключ приложения", false
|
||||
case myEmail == "":
|
||||
return "укажите свой e-mail на странице «Администраторы»", false
|
||||
}
|
||||
lk, err := zkLookup(conf, myEmail)
|
||||
switch {
|
||||
case err != nil:
|
||||
return "ЗЕТИТ Ключ: " + err.Error(), false
|
||||
case !lk.Exists:
|
||||
return myEmail + " не найден в ЗЕТИТ Ключ", false
|
||||
case !lk.Has2FA:
|
||||
return myEmail + " найден в ЗЕТИТ Ключ, но приложение не привязано", false
|
||||
}
|
||||
return "ЗЕТИТ Ключ отвечает: " + myEmail + pickStr(" ("+lk.FullName+")", "") + " найден, приложение привязано", true
|
||||
}
|
||||
msg, ok := "", true
|
||||
if op == "test" || r.PostFormValue("enabled") == "1" {
|
||||
msg, ok = check()
|
||||
}
|
||||
switch {
|
||||
case op == "test":
|
||||
case r.PostFormValue("enabled") == "1" && !ok:
|
||||
back(w, r, "/admin/security", "", "Не включено: "+msg)
|
||||
return
|
||||
default:
|
||||
conf.Enabled = r.PostFormValue("enabled") == "1"
|
||||
}
|
||||
err := srv.st.write(func(s *State) error {
|
||||
s.ZetitKey = conf
|
||||
state := "выключен"
|
||||
if conf.Enabled {
|
||||
state = "включён"
|
||||
}
|
||||
s.addEvent("zetit_key", "", "", sess.Login, clientIP(r), "настройки ЗЕТИТ Ключ сохранены, вход через Ключ "+state)
|
||||
return nil
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
back(w, r, "/admin/security", "", err.Error())
|
||||
case op == "test" && !ok:
|
||||
back(w, r, "/admin/security", "", "Проверка: "+msg)
|
||||
case op == "test":
|
||||
back(w, r, "/admin/security", "Проверка: "+msg, "")
|
||||
case conf.Enabled:
|
||||
back(w, r, "/admin/security", "Вход через ЗЕТИТ Ключ включён. "+msg+".", "")
|
||||
default:
|
||||
back(w, r, "/admin/security", "Настройки ЗЕТИТ Ключ сохранены, вход через Ключ выключен.", "")
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
// Command bj-license — инструмент издателя: генерация ключей подписи,
|
||||
// выпуск годовых лицензий и проверка.
|
||||
//
|
||||
// bj-license keygen -out ./keys/license
|
||||
// bj-license issue -tenant "ООО Ромашка" -plan pro -days 365 \
|
||||
// -features updates,web-cabinet -key ./keys/license.priv -keyid main
|
||||
// bj-license verify -key-file license.key -pub ./keys/license.pub
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
|
||||
)
|
||||
|
||||
// newUUID — UUID v4 без внешних зависимостей.
|
||||
func newUUID() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // версия 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // вариант
|
||||
h := hex.EncodeToString(b[:])
|
||||
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "keygen":
|
||||
keygen(os.Args[2:])
|
||||
case "issue":
|
||||
issue(os.Args[2:])
|
||||
case "verify":
|
||||
verify(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "bj-license keygen -out <prefix>")
|
||||
fmt.Fprintln(os.Stderr, "bj-license issue -tenant <name> -plan free|pro|enterprise -days <n> -features a,b -key <priv> [-keyid id] [-max-nodes n] [-note txt]")
|
||||
fmt.Fprintln(os.Stderr, "bj-license verify -key-file <license.key> -pub <pubkey.pub>")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func keygen(args []string) {
|
||||
out := "license"
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == "-out" {
|
||||
out = args[i+1]
|
||||
}
|
||||
}
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
fatal("keygen: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(out+".priv", []byte(base64.StdEncoding.EncodeToString(priv.Seed())+"\n"), 0o600); err != nil {
|
||||
fatal("write priv: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(out+".pub", []byte(base64.StdEncoding.EncodeToString(pub)+"\n"), 0o644); err != nil {
|
||||
fatal("write pub: %v", err)
|
||||
}
|
||||
fmt.Printf("Приватный ключ лицензий: %s.priv (СЕКРЕТ)\n", out)
|
||||
fmt.Printf("Публичный ключ (зашить в bj-server):\n %s\n", base64.StdEncoding.EncodeToString(pub))
|
||||
}
|
||||
|
||||
func issue(args []string) {
|
||||
a := parseArgs(args)
|
||||
tenant := a["tenant"]
|
||||
keyPath := a["key"]
|
||||
if tenant == "" || keyPath == "" {
|
||||
fatal("issue: требуются -tenant и -key")
|
||||
}
|
||||
plan := license.Plan(orDefault(a["plan"], "pro"))
|
||||
days := atoiDefault(a["days"], 365)
|
||||
keyID := orDefault(a["keyid"], "main")
|
||||
|
||||
priv, err := license.LoadPrivateKey(keyPath)
|
||||
if err != nil {
|
||||
fatal("load key: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var feats []string
|
||||
if a["features"] != "" {
|
||||
feats = strings.Split(a["features"], ",")
|
||||
}
|
||||
l := &license.License{
|
||||
Schema: license.CurrentSchema,
|
||||
ID: newUUID(),
|
||||
Tenant: tenant,
|
||||
Product: "bj-server",
|
||||
Plan: plan,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now.AddDate(0, 0, days),
|
||||
Features: feats,
|
||||
MaxNodes: atoiDefault(a["max-nodes"], 0),
|
||||
Note: a["note"],
|
||||
}
|
||||
tok, err := license.Sign(l, priv, keyID)
|
||||
if err != nil {
|
||||
fatal("sign: %v", err)
|
||||
}
|
||||
fmt.Printf("Лицензия выпущена: tenant=%q plan=%s до %s (%d дней)\n",
|
||||
tenant, plan, l.ExpiresAt.Format("02.01.2006"), days)
|
||||
fmt.Printf("ID: %s\n", l.ID)
|
||||
fmt.Println("Ключ для клиента (вставить в bj-server → Лицензия):")
|
||||
fmt.Println(tok.Encode())
|
||||
}
|
||||
|
||||
func verify(args []string) {
|
||||
a := parseArgs(args)
|
||||
if a["key-file"] == "" || a["pub"] == "" {
|
||||
fatal("verify: требуются -key-file и -pub")
|
||||
}
|
||||
raw, err := os.ReadFile(a["key-file"])
|
||||
if err != nil {
|
||||
fatal("read key-file: %v", err)
|
||||
}
|
||||
pubB, err := os.ReadFile(a["pub"])
|
||||
if err != nil {
|
||||
fatal("read pub: %v", err)
|
||||
}
|
||||
pub, err := license.ParsePublicKey(strings.TrimSpace(string(pubB)))
|
||||
if err != nil {
|
||||
fatal("pub: %v", err)
|
||||
}
|
||||
tok, err := license.DecodeToken(string(raw))
|
||||
if err != nil {
|
||||
fatal("decode: %v", err)
|
||||
}
|
||||
l, err := license.Verify(tok, pub)
|
||||
if err != nil {
|
||||
fatal("verify: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
fmt.Printf("Подпись валидна. tenant=%q plan=%s\n", l.Tenant, l.Plan)
|
||||
fmt.Printf("Действует: %s — %s (осталось %d дней)\n",
|
||||
l.IssuedAt.Format("02.01.2006"), l.ExpiresAt.Format("02.01.2006"), l.DaysLeft(now))
|
||||
if err := l.Valid(now); err != nil {
|
||||
fmt.Printf("СТАТУС: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("СТАТУС: активна, обновления %v\n", l.AllowsUpdates())
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func parseArgs(args []string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for i := 0; i < len(args); i++ {
|
||||
if strings.HasPrefix(args[i], "-") && i+1 < len(args) {
|
||||
m[strings.TrimPrefix(args[i], "-")] = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func orDefault(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func atoiDefault(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
var n int
|
||||
_, err := fmt.Sscanf(s, "%d", &n)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func fatal(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, "bj-license: "+format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
+27
-417
@@ -1,10 +1,10 @@
|
||||
// Команда bj-lk — Личный кабинет клиента (отдельный модуль).
|
||||
// Команда bj-lk — Личный кабинет клиента (modules/lk), отдельный модуль, обновляется независимо.
|
||||
//
|
||||
// Ставится на любой веб-сервер клиента. Сам к базам данных НЕ подключается —
|
||||
// работает только с bj-server по HTTP API с ключом доступа. Клиент входит по
|
||||
// логину/паролю, видит свои бумаги и подаёт заявку на перевод другому брокеру.
|
||||
//
|
||||
// Настройка — через переменные окружения или флаги:
|
||||
// Настройка — через переменные окружения (служба: /etc/bj/lk.env) или флаги:
|
||||
//
|
||||
// BJ_URL адрес bj-server (напр. https://bj.example.ru)
|
||||
// BJ_KEY ключ доступа модуля (выдаётся в админке bj-server)
|
||||
@@ -13,435 +13,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/lk"
|
||||
)
|
||||
|
||||
// cookieTTL — срок жизни кук кабинета. Должен быть не меньше срока сессии на
|
||||
// bj-server (12ч), иначе браузер забудет токен раньше, чем тот протухнет, и
|
||||
// клиент увидит «сессия истекла». Кука продлевается при каждом запросе.
|
||||
const cookieTTL = 12 * 3600
|
||||
|
||||
type config struct {
|
||||
BJURL string
|
||||
BJKey string
|
||||
Listen string
|
||||
Title string
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
c := config{
|
||||
BJURL: os.Getenv("BJ_URL"),
|
||||
BJKey: os.Getenv("BJ_KEY"),
|
||||
Listen: os.Getenv("LISTEN"),
|
||||
Title: os.Getenv("LK_TITLE"),
|
||||
}
|
||||
flag.StringVar(&c.BJURL, "bj-url", c.BJURL, "адрес bj-server (https://...)")
|
||||
flag.StringVar(&c.BJKey, "bj-key", c.BJKey, "ключ доступа модуля")
|
||||
flag.StringVar(&c.Listen, "listen", c.Listen, "адрес прослушивания (:8081)")
|
||||
flag.StringVar(&c.Title, "title", c.Title, "заголовок кабинета")
|
||||
flag.Parse()
|
||||
if c.Listen == "" {
|
||||
c.Listen = ":8081"
|
||||
}
|
||||
if c.Title == "" {
|
||||
c.Title = "Личный кабинет"
|
||||
}
|
||||
c.BJURL = strings.TrimRight(c.BJURL, "/")
|
||||
return c
|
||||
}
|
||||
|
||||
// ---- клиент к bj-server ----
|
||||
|
||||
type bjClient struct {
|
||||
cfg config
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error {
|
||||
var rdr *bytes.Reader
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(raw)
|
||||
} else {
|
||||
rdr = bytes.NewReader(nil)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, b.cfg.BJURL+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-BJ-Key", b.cfg.BJKey)
|
||||
if session != "" {
|
||||
req.Header.Set("X-BJ-Session", session)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := b.hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("нет связи с сервером: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
var e struct{ Error string }
|
||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||
if e.Error == "" {
|
||||
e.Error = fmt.Sprintf("ошибка сервера (%d)", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("%s", e.Error)
|
||||
}
|
||||
if out != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- модели ответов ----
|
||||
|
||||
type position struct {
|
||||
ISIN string `json:"isin"`
|
||||
SecurityCode string `json:"security_code"`
|
||||
SecurityName string `json:"security_name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Blocked float64 `json:"blocked"`
|
||||
Available float64 `json:"available"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Transferable bool `json:"transferable"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type claimRow struct {
|
||||
ID int64 `json:"id"`
|
||||
GUID string `json:"guid"`
|
||||
StatusRu string `json:"status_ru"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Securit int `json:"securities"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
// version — версия модуля (go build -ldflags "-X main.version=…").
|
||||
var version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
if cfg.BJURL == "" || cfg.BJKey == "" {
|
||||
log.Fatal("не заданы BJ_URL и BJ_KEY — укажите адрес bj-server и ключ доступа (см. инструкцию)")
|
||||
cfg := lk.Config{BJURL: os.Getenv("BJ_URL"), BJKey: os.Getenv("BJ_KEY"), Title: os.Getenv("LK_TITLE")}
|
||||
listen := os.Getenv("LISTEN")
|
||||
flag.StringVar(&cfg.BJURL, "bj-url", cfg.BJURL, "адрес bj-server (https://...)")
|
||||
flag.StringVar(&cfg.BJKey, "bj-key", cfg.BJKey, "ключ доступа модуля")
|
||||
flag.StringVar(&listen, "listen", listen, "адрес прослушивания (:8081)")
|
||||
flag.StringVar(&cfg.Title, "title", cfg.Title, "заголовок кабинета")
|
||||
showVersion := flag.Bool("version", false, "показать версию")
|
||||
flag.Parse()
|
||||
if *showVersion {
|
||||
fmt.Println("bj-lk", version)
|
||||
return
|
||||
}
|
||||
bj := &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}}
|
||||
tpl := template.Must(template.New("t").Funcs(template.FuncMap{
|
||||
"fmtnum": func(f float64) string {
|
||||
if f == float64(int64(f)) {
|
||||
return strconv.FormatInt(int64(f), 10)
|
||||
}
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
},
|
||||
"shortid": func(s string) string {
|
||||
if len(s) > 8 {
|
||||
return s[:8] + "…"
|
||||
}
|
||||
return s
|
||||
},
|
||||
}).Parse(pageTemplates))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
// признаки режима (админ / просмотр кабинета клиента) — из кук
|
||||
var curReq *http.Request
|
||||
render := func(w http.ResponseWriter, name string, data map[string]any) {
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
data["Title"] = cfg.Title
|
||||
if _, ok := data["Nav"]; !ok {
|
||||
data["Nav"] = ""
|
||||
}
|
||||
if curReq != nil {
|
||||
if _, ok := data["IsAdmin"]; !ok {
|
||||
if c, err := curReq.Cookie("bj_lk_admin"); err == nil && c.Value == "1" {
|
||||
data["IsAdmin"] = true
|
||||
}
|
||||
}
|
||||
if c, err := curReq.Cookie("bj_lk_as"); err == nil && c.Value != "" {
|
||||
data["AsClient"] = c.Value
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("шаблон %s: %v", name, err)
|
||||
}
|
||||
if listen == "" {
|
||||
listen = ":8081"
|
||||
}
|
||||
setFlag := func(w http.ResponseWriter, name, val string) {
|
||||
age := cookieTTL
|
||||
if val == "" {
|
||||
age = -1
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age, SameSite: http.SameSiteLaxMode})
|
||||
srv, err := lk.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("bj-lk: %v", err)
|
||||
}
|
||||
// sessionW — как session, но заодно продлевает куку, чтобы у активного
|
||||
// клиента кабинет не сбрасывался на середине работы.
|
||||
sessionW := func(w http.ResponseWriter, r *http.Request) string {
|
||||
curReq = r
|
||||
c, err := r.Cookie("bj_lk")
|
||||
if err != nil || c.Value == "" {
|
||||
return ""
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: c.Value, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
||||
return c.Value
|
||||
if !srv.Configured() {
|
||||
log.Printf("bj-lk: не заданы BJ_URL и BJ_KEY — кабинет показывает страницу настройки (см. /etc/bj/lk.env)")
|
||||
}
|
||||
session := func(r *http.Request) string {
|
||||
curReq = r
|
||||
c, err := r.Cookie("bj_lk")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// Вход
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
render(w, "login", map[string]any{"Flash": r.URL.Query().Get("e")})
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
var out struct {
|
||||
Session string `json:"session"`
|
||||
FullName string `json:"full_name"`
|
||||
Contract string `json:"contract"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
err := bj.call(r.Context(), http.MethodPost, "/api/lk/login", "", map[string]string{
|
||||
"login": r.FormValue("login"), "password": r.FormValue("password"),
|
||||
}, &out)
|
||||
if err != nil {
|
||||
render(w, "login", map[string]any{"Flash": err.Error()})
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: out.Session, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
||||
if out.IsAdmin {
|
||||
setFlag(w, "bj_lk_admin", "1")
|
||||
http.Redirect(w, r, "/clients", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
setFlag(w, "bj_lk_admin", "")
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = bj.call(r.Context(), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
|
||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: "", Path: "/", MaxAge: -1})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Портфель
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s := sessionW(w, r)
|
||||
if s == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var out struct {
|
||||
Contract string `json:"contract"`
|
||||
FullName string `json:"full_name"`
|
||||
Positions []position `json:"positions"`
|
||||
Source string `json:"source"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", s, nil, &out); err != nil {
|
||||
render(w, "portfolio", map[string]any{"Nav": "p", "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
render(w, "portfolio", map[string]any{
|
||||
"Nav": "p", "Contract": out.Contract, "FullName": out.FullName,
|
||||
"Positions": out.Positions, "Flash": r.URL.Query().Get("m"),
|
||||
"Source": out.Source, "ReadOnly": out.ReadOnly,
|
||||
})
|
||||
})
|
||||
|
||||
// Заявка на перевод
|
||||
mux.HandleFunc("/transfer", func(w http.ResponseWriter, r *http.Request) {
|
||||
s := sessionW(w, r)
|
||||
if s == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
var out struct {
|
||||
Positions []position `json:"positions"`
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", s, nil, &out); err != nil {
|
||||
render(w, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
render(w, "transfer", map[string]any{"Nav": "t", "Positions": out.Positions, "FullName": out.FullName})
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
var secs []map[string]any
|
||||
for _, isin := range r.Form["isin"] {
|
||||
q := strings.TrimSpace(r.FormValue("qty_" + isin))
|
||||
if q == "" || q == "0" {
|
||||
continue
|
||||
}
|
||||
f, err := strconv.ParseFloat(strings.ReplaceAll(q, ",", "."), 64)
|
||||
if err != nil || f <= 0 {
|
||||
continue
|
||||
}
|
||||
secs = append(secs, map[string]any{"isin": isin, "quantity": f})
|
||||
}
|
||||
if len(secs) == 0 {
|
||||
render(w, "transfer", map[string]any{"Nav": "t", "Error": "Укажите количество хотя бы по одной бумаге"})
|
||||
return
|
||||
}
|
||||
var res struct {
|
||||
Message string `json:"message"`
|
||||
GUID string `json:"guid"`
|
||||
}
|
||||
err := bj.call(r.Context(), http.MethodPost, "/api/lk/transfer", s, 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")),
|
||||
"comment": strings.TrimSpace(r.FormValue("comment")),
|
||||
"securities": secs,
|
||||
}, &res)
|
||||
if err != nil {
|
||||
render(w, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/claims?m="+res.Message, http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Мои заявки
|
||||
mux.HandleFunc("/claims", func(w http.ResponseWriter, r *http.Request) {
|
||||
s := sessionW(w, r)
|
||||
if s == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var out struct {
|
||||
Claims []claimRow `json:"claims"`
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/claims", s, nil, &out); err != nil {
|
||||
render(w, "claims", map[string]any{"Nav": "c", "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
render(w, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Flash": r.URL.Query().Get("m")})
|
||||
})
|
||||
|
||||
// Администратор: список клиентов
|
||||
mux.HandleFunc("/clients", func(w http.ResponseWriter, r *http.Request) {
|
||||
s := sessionW(w, r)
|
||||
if s == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var out struct {
|
||||
Clients []struct {
|
||||
Login string `json:"login"`
|
||||
Contract string `json:"contract_number"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
Active bool `json:"active"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
} `json:"clients"`
|
||||
Depo []struct {
|
||||
Contract string `json:"contract"`
|
||||
Name string `json:"name"`
|
||||
Securities int `json:"securities"`
|
||||
} `json:"depo"`
|
||||
DepoError string `json:"depo_error"`
|
||||
}
|
||||
search := strings.TrimSpace(r.URL.Query().Get("search"))
|
||||
path := "/api/lk/admin/clients"
|
||||
if search != "" {
|
||||
path += "?search=" + url.QueryEscape(search)
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodGet, path, s, nil, &out); err != nil {
|
||||
render(w, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
render(w, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Clients": out.Clients,
|
||||
"Depo": out.Depo, "DepoError": out.DepoError, "Search": search,
|
||||
"Flash": r.URL.Query().Get("m")})
|
||||
})
|
||||
|
||||
// Администратор: войти в кабинет клиента
|
||||
mux.HandleFunc("/impersonate", func(w http.ResponseWriter, r *http.Request) {
|
||||
s := sessionW(w, r)
|
||||
if s == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
login := r.FormValue("login")
|
||||
if login == "" {
|
||||
login = r.URL.Query().Get("login")
|
||||
}
|
||||
contract := r.FormValue("contract")
|
||||
if contract == "" {
|
||||
contract = r.URL.Query().Get("contract")
|
||||
}
|
||||
var out struct {
|
||||
Session string `json:"session"`
|
||||
AsClient string `json:"as_client"`
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodPost, "/api/lk/admin/impersonate", s,
|
||||
map[string]string{"login": login, "contract": contract}, &out); err != nil {
|
||||
http.Redirect(w, r, "/clients?m="+err.Error(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// сохраняем админскую сессию, чтобы можно было вернуться
|
||||
if c, err := r.Cookie("bj_lk"); err == nil {
|
||||
setFlag(w, "bj_lk_admin_sess", c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: out.Session, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
||||
setFlag(w, "bj_lk_as", out.AsClient)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Администратор: вернуться из кабинета клиента
|
||||
mux.HandleFunc("/back-to-admin", func(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
|
||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: c.Value, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
||||
}
|
||||
setFlag(w, "bj_lk_as", "")
|
||||
setFlag(w, "bj_lk_admin_sess", "")
|
||||
http.Redirect(w, r, "/clients", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Проверка связи с bj-server (для админа при установке)
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
var out map[string]any
|
||||
err := bj.call(r.Context(), http.MethodGet, "/api/lk/ping", "", nil, &out)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error(), "bj_url": cfg.BJURL})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "bj": out, "bj_url": cfg.BJURL})
|
||||
})
|
||||
|
||||
log.Printf("bj-lk (Личный кабинет) слушает %s, bj-server: %s", cfg.Listen, cfg.BJURL)
|
||||
srv := &http.Server{Addr: cfg.Listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("bj-lk %s (Личный кабинет) слушает %s, bj-server: %s", version, listen, cfg.BJURL)
|
||||
hs := &http.Server{Addr: listen, Handler: srv.Handler(), ReadHeaderTimeout: 5 * time.Second}
|
||||
if err := hs.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
_ = context.Background
|
||||
}
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
package main
|
||||
|
||||
// pageTemplates — встроенные страницы Личного кабинета (без внешних зависимостей,
|
||||
// чтобы модуль был одним файлом и ставился на любой веб-сервер).
|
||||
const pageTemplates = `
|
||||
{{define "head"}}<!DOCTYPE html><html lang="ru"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
:root{--bg:#f6f7f9;--card:#fff;--bd:#e3e6ec;--tx:#1b2029;--mut:#5b6472;--acc:#2563eb;--ok:#15803d;--err:#c22a2a;
|
||||
--bd-strong:#c9cfda;--bg-2:#eef0f4;
|
||||
--bg-neu:rgba(37,99,235,.12);--tx-neu:#1d4ed8;--bg-ok:rgba(21,128,61,.14);--tx-ok:#15803d;--bg-err:rgba(194,42,42,.12);--tx-err:#b91c1c;--bg-warn:rgba(180,120,0,.16);--tx-warn:#a15c07}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#0f1116;--card:#181b22;--bd:#2a2f3a;--tx:#e8eaed;--mut:#a2acbd;--acc:#7ba7ff;--ok:#5bd085;--err:#ff8f8f;
|
||||
--bd-strong:#3a4150;--bg-2:#20242e;
|
||||
--bg-neu:rgba(123,167,255,.16);--tx-neu:#93c5fd;--bg-ok:rgba(91,208,133,.18);--tx-ok:#86efac;--bg-err:rgba(255,143,143,.18);--tx-err:#fca5a5;--bg-warn:rgba(245,180,80,.18);--tx-warn:#fcd34d}}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
background:var(--bg);color:var(--tx);line-height:1.5}
|
||||
.top{background:var(--card);border-bottom:1px solid var(--bd);padding:0 20px;height:56px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:5;overflow-x:auto}
|
||||
.brand{font-weight:700;font-size:16px;white-space:nowrap;margin-right:8px}.top a{color:var(--mut);text-decoration:none;font-size:14px;padding:8px 10px;border-radius:7px;white-space:nowrap}
|
||||
.top a:hover{background:var(--bg);color:var(--tx)}.top a.on{background:var(--bg-neu);color:var(--tx-neu)}
|
||||
.top .right{margin-left:auto;display:flex;align-items:center;gap:12px;font-size:13px;color:var(--mut);white-space:nowrap}
|
||||
main{max-width:980px;margin:0 auto;padding:22px 20px}
|
||||
h1{font-size:22px;margin:0 0 14px}h2{font-size:16px;margin:0 0 12px}
|
||||
.card{background:var(--card);border:1px solid var(--bd);border-radius:12px;padding:18px 20px;margin-bottom:16px}
|
||||
.tw{overflow-x:auto;-webkit-overflow-scrolling:touch;margin:0 -4px}
|
||||
table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--bd);white-space:nowrap}
|
||||
th{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--mut);font-weight:600}
|
||||
.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
code{font-size:12px;background:var(--bg-2);padding:1px 5px;border-radius:4px}
|
||||
input,select,textarea{padding:10px 12px;border:1px solid var(--bd-strong);border-radius:8px;background:var(--card);color:var(--tx);font:inherit;font-size:16px;width:100%}
|
||||
input:focus,select:focus,textarea:focus{outline:2px solid var(--acc);outline-offset:1px;border-color:var(--acc)}
|
||||
label{font-size:13px;font-weight:500;display:block;margin-bottom:4px}
|
||||
.btn{background:var(--acc);color:#fff;border:0;border-radius:8px;padding:11px 18px;font:inherit;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block;min-height:44px;line-height:22px}
|
||||
.btn:hover{filter:brightness(1.06)}.btn.sec{background:var(--card);color:var(--tx);border:1px solid var(--bd-strong)}
|
||||
.btn.sm{min-height:0;padding:6px 10px;font-size:12px}
|
||||
.msg{padding:12px 14px;border-radius:9px;margin-bottom:14px;font-size:14px}
|
||||
.msg.err{background:var(--bg-err);color:var(--tx-err)}.msg.ok{background:var(--bg-ok);color:var(--tx-ok)}
|
||||
.mut{color:var(--mut);font-size:13px}
|
||||
.badge{display:inline-block;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:700;background:var(--bg-neu);color:var(--tx-neu)}
|
||||
.badge.ok{background:var(--bg-ok);color:var(--tx-ok)}.badge.err{background:var(--bg-err);color:var(--tx-err)}.badge.warn{background:var(--bg-warn);color:var(--tx-warn)}
|
||||
.login-wrap{max-width:380px;margin:8vh auto;padding:0 20px}
|
||||
.ovl{position:fixed;inset:0;background:rgba(10,12,18,.55);display:none;align-items:center;justify-content:center;padding:16px;z-index:20}
|
||||
.ovl.on{display:flex}
|
||||
.modal{background:var(--card);border:1px solid var(--bd);border-radius:14px;max-width:460px;width:100%;padding:22px;max-height:90vh;overflow:auto}
|
||||
.modal h2{margin-top:0}
|
||||
.sumrow{display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid var(--bd);font-size:14px}
|
||||
.warnbox{background:var(--bg-warn);color:var(--tx-warn);border-radius:9px;padding:11px 13px;font-size:13px;font-weight:500;margin:14px 0}
|
||||
@media(max-width:600px){main{padding:16px 14px}.card{padding:15px 15px}h1{font-size:20px}
|
||||
input,select,textarea{font-size:16px}}
|
||||
</style></head><body>{{end}}
|
||||
|
||||
{{define "nav"}}<div class="top"><span class="brand">{{.Title}}</span>
|
||||
{{if .IsAdmin}}<a href="/clients" class="{{if eq .Nav "a"}}on{{end}}">Клиенты</a>{{end}}
|
||||
<a href="/" class="{{if eq .Nav "p"}}on{{end}}">Мои бумаги</a>
|
||||
<a href="/transfer" class="{{if eq .Nav "t"}}on{{end}}">Перевести бумаги</a>
|
||||
<a href="/claims" class="{{if eq .Nav "c"}}on{{end}}">Мои заявки</a>
|
||||
<span class="right">{{with .FullName}}{{.}}{{end}}{{with .Contract}} · договор {{.}}{{end}} <a href="/logout">Выйти</a></span>
|
||||
</div>
|
||||
{{if .AsClient}}<div style="background:#fff4d6;border-bottom:1px solid #e8c877;padding:9px 20px;font-size:13px">
|
||||
👤 Вы просматриваете кабинет клиента <b>{{.AsClient}}</b> от имени администратора ·
|
||||
<a href="/back-to-admin">вернуться к списку клиентов</a></div>{{end}}{{end}}
|
||||
|
||||
{{define "login"}}{{template "head" .}}
|
||||
<div class="login-wrap">
|
||||
<div class="card">
|
||||
<h1>{{.Title}}</h1>
|
||||
<p class="mut">Войдите, чтобы посмотреть свои ценные бумаги и подать заявку на перевод к другому брокеру.</p>
|
||||
{{with .Flash}}<div class="msg err">{{.}}</div>{{end}}
|
||||
<form method="post" action="/login" style="display:grid;gap:12px;margin-top:14px">
|
||||
<div><label for="f-login">Логин</label><input id="f-login" name="login" autocomplete="username" required autofocus></div>
|
||||
<div><label for="f-pass">Пароль</label><input id="f-pass" name="password" type="password" autocomplete="current-password" required></div>
|
||||
<button class="btn" type="submit" style="width:100%">Войти</button>
|
||||
</form>
|
||||
<p class="mut" style="margin-top:14px">🔒 Соединение защищено. Мы не запрашиваем пароль по телефону или в письмах.</p>
|
||||
</div>
|
||||
<p class="mut" style="text-align:center">Логин и пароль выдаёт ваш брокер. Не можете войти — обратитесь в поддержку брокера.</p>
|
||||
</div>
|
||||
</body></html>{{end}}
|
||||
|
||||
{{define "portfolio"}}{{template "head" .}}{{template "nav" .}}
|
||||
<main>
|
||||
<h1>Мои ценные бумаги</h1>
|
||||
{{if .Source}}<p class="mut">Источник данных: {{.Source}}{{if .Contract}} · договор {{.Contract}}{{end}}</p>{{end}}
|
||||
{{with .Flash}}<div class="msg ok">{{.}}</div>{{end}}
|
||||
{{with .Error}}<div class="msg err">{{.}}</div>{{end}}
|
||||
<div class="card">
|
||||
{{if .Positions}}
|
||||
<div class="tw"><table>
|
||||
<thead><tr><th>Бумага</th><th>ISIN</th><th class="num">Всего</th><th class="num">Заблокировано</th><th class="num">Доступно к переводу</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Positions}}
|
||||
<tr>
|
||||
<td>{{if .SecurityName}}{{.SecurityName}}{{else}}{{.SecurityCode}}{{end}}</td>
|
||||
<td><code>{{.ISIN}}</code></td>
|
||||
<td class="num">{{fmtnum .Quantity}}</td>
|
||||
<td class="num">{{if .Blocked}}{{fmtnum .Blocked}}{{else}}—{{end}}</td>
|
||||
<td class="num">{{if .Transferable}}<b>{{fmtnum .Available}}</b>{{else}}<span class="mut" title="{{.Note}}">не переводится</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
<p class="mut" style="margin-top:12px">Через сервис MOEX МОСТ переводятся только российские ценные бумаги (ISIN начинается с RU). Иностранные бумаги показаны для полноты портфеля, но заявку по ним подать нельзя.</p>
|
||||
{{if .ReadOnly}}
|
||||
<p class="mut" style="margin-top:16px">📋 Данные загружены из депозитарного учёта. Подача заявок на перевод для этого договора пока недоступна — раздел работает в режиме просмотра.</p>
|
||||
{{else}}
|
||||
<p style="margin-top:16px"><a class="btn" href="/transfer">Перевести бумаги другому брокеру</a></p>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="mut">Бумаг на счёте не найдено. Если вы считаете, что это ошибка — обратитесь к вашему брокеру.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</main></body></html>{{end}}
|
||||
|
||||
{{define "transfer"}}{{template "head" .}}{{template "nav" .}}
|
||||
<main>
|
||||
<h1>Перевод бумаг другому брокеру</h1>
|
||||
{{with .Error}}<div class="msg err">{{.}}</div>{{end}}
|
||||
<form method="post" action="/transfer" id="tform">
|
||||
<div class="card">
|
||||
<h2>Куда переводим</h2>
|
||||
<p class="mut">Укажите депозитарий брокера, к которому переводите бумаги. Перевод оформляется на вас же — как перевод «самому себе» в другой депозитарий.</p>
|
||||
<div style="display:grid;gap:12px;max-width:520px;margin-top:12px">
|
||||
<div><label for="rinn">ИНН депозитария-получателя *</label>
|
||||
<input id="rinn" name="receiving_inn" required inputmode="numeric" pattern="\d{{"{"}}10{{"}"}}|\d{{"{"}}12{{"}"}}" maxlength="12" placeholder="7722061076"
|
||||
title="ИНН организации — 10 цифр">
|
||||
<p class="mut" style="margin-top:4px">ИНН депозитария вашего нового брокера — уточните его у брокера-получателя.</p></div>
|
||||
<div><label for="rname">Наименование брокера/депозитария</label><input id="rname" name="receiving_name" placeholder="например, ООО «Брокер»"></div>
|
||||
<div><label for="racc">Ваш счёт депо у нового брокера (если известен)</label><input id="racc" name="account_at_recv" placeholder="необязательно"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Что переводим</h2>
|
||||
{{if .Positions}}
|
||||
<div class="tw"><table>
|
||||
<thead><tr><th>Бумага</th><th>ISIN</th><th class="num">Доступно</th><th style="min-width:190px">Сколько перевести</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Positions}}
|
||||
<tr>
|
||||
<td>{{if .SecurityName}}{{.SecurityName}}{{else}}{{.SecurityCode}}{{end}}</td>
|
||||
<td><code>{{.ISIN}}</code></td>
|
||||
<td class="num">{{if .Transferable}}{{fmtnum .Available}}{{else}}—{{end}}</td>
|
||||
<td>{{if .Transferable}}<input type="hidden" name="isin" value="{{.ISIN}}">
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<input class="qty" name="qty_{{.ISIN}}" type="number" min="0" step="any" max="{{printf "%g" .Available}}"
|
||||
inputmode="decimal" placeholder="0" style="max-width:120px"
|
||||
aria-label="Сколько перевести: {{if .SecurityName}}{{.SecurityName}}{{else}}{{.ISIN}}{{end}}"
|
||||
data-name="{{if .SecurityName}}{{.SecurityName}}{{else}}{{.ISIN}}{{end}}" data-isin="{{.ISIN}}" data-avail="{{printf "%g" .Available}}"
|
||||
{{if not .Available}}disabled{{end}}>
|
||||
<button type="button" class="btn sec sm qall">всё</button>
|
||||
</div>
|
||||
{{else}}<span class="mut">{{.Note}}</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div style="margin-top:14px;max-width:520px"><label for="cmt">Комментарий (необязательно)</label><textarea id="cmt" name="comment" rows="2"></textarea></div>
|
||||
<div style="margin-top:16px;display:flex;gap:10px;flex-wrap:wrap">
|
||||
<button class="btn" type="submit">Проверить и подать</button>
|
||||
<a class="btn sec" href="/">Отмена</a>
|
||||
</div>
|
||||
<p class="mut" style="margin-top:12px">Перед подачей вы увидите сводку для проверки. После подачи заявка проходит проверку у брокера, затем направляется в НРД. Статус виден в разделе «Мои заявки».</p>
|
||||
{{else}}<p class="mut">Нет доступных бумаг для перевода.</p>{{end}}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="ovl" id="cov" role="dialog" aria-modal="true" aria-labelledby="ctitle">
|
||||
<div class="modal">
|
||||
<h2 id="ctitle">Проверьте заявку на перевод</h2>
|
||||
<div class="sumrow"><span class="mut">Депозитарий-получатель</span><span id="c-recv" style="text-align:right"></span></div>
|
||||
<div id="c-secs" style="margin-top:4px"></div>
|
||||
<div class="warnbox">⚠️ После подачи заявку нельзя отменить или изменить. Бумаги переводятся другому брокеру. Проверьте получателя и количество.</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||
<button class="btn" id="cyes" type="button">Подтверждаю, подать</button>
|
||||
<button class="btn sec" id="cno" type="button">Вернуться</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var f=document.getElementById('tform'); if(!f) return;
|
||||
var ov=document.getElementById('cov');
|
||||
// «всё» — заполнить доступным количеством
|
||||
f.querySelectorAll('.qall').forEach(function(b){
|
||||
b.addEventListener('click',function(){
|
||||
var q=b.parentNode.querySelector('.qty'); if(q){q.value=q.getAttribute('data-avail'); q.focus();}
|
||||
});
|
||||
});
|
||||
function fmt(n){ n=Number(n); return (n%1===0)? String(n): String(n); }
|
||||
f.addEventListener('submit',function(e){
|
||||
e.preventDefault();
|
||||
// собрать выбранные бумаги
|
||||
var rows=[], bad=null;
|
||||
f.querySelectorAll('.qty').forEach(function(q){
|
||||
var v=parseFloat((q.value||'').replace(',','.'));
|
||||
if(!q.value||isNaN(v)||v<=0) return;
|
||||
var av=parseFloat(q.getAttribute('data-avail'));
|
||||
if(v>av){ bad=q.getAttribute('data-name')+': запрошено '+fmt(v)+', доступно '+fmt(av); }
|
||||
rows.push({name:q.getAttribute('data-name'), isin:q.getAttribute('data-isin'), qty:v});
|
||||
});
|
||||
var inn=(f.receiving_inn.value||'').trim();
|
||||
if(!/^(\d{10}|\d{12})$/.test(inn)){ f.receiving_inn.focus(); alert('Проверьте ИНН депозитария-получателя: должно быть 10 цифр.'); return; }
|
||||
if(rows.length===0){ alert('Укажите количество хотя бы по одной бумаге.'); return; }
|
||||
if(bad){ alert('Превышен доступный остаток. '+bad); return; }
|
||||
// заполнить сводку
|
||||
var rn=(f.receiving_name.value||'').trim();
|
||||
document.getElementById('c-recv').innerHTML='<b>ИНН '+inn+'</b>'+(rn?('<br><span class="mut">'+rn+'</span>'):'');
|
||||
var h=''; rows.forEach(function(r){
|
||||
h+='<div class="sumrow"><span>'+r.name+' <span class="mut">('+r.isin+')</span></span><b>'+fmt(r.qty)+' шт</b></div>';
|
||||
});
|
||||
document.getElementById('c-secs').innerHTML=h;
|
||||
ov.classList.add('on');
|
||||
});
|
||||
document.getElementById('cno').addEventListener('click',function(){ ov.classList.remove('on'); });
|
||||
ov.addEventListener('click',function(e){ if(e.target===ov) ov.classList.remove('on'); });
|
||||
document.getElementById('cyes').addEventListener('click',function(){
|
||||
var b=this; b.disabled=true; b.textContent='Отправляем…'; f.submit();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</main></body></html>{{end}}
|
||||
|
||||
{{define "claims"}}{{template "head" .}}{{template "nav" .}}
|
||||
<main>
|
||||
<h1>Мои заявки</h1>
|
||||
{{with .Flash}}<div class="msg ok">{{.}}</div>{{end}}
|
||||
{{with .Error}}<div class="msg err">{{.}}</div>{{end}}
|
||||
<div class="card">
|
||||
{{if .Claims}}
|
||||
<div class="tw"><table>
|
||||
<thead><tr><th>Дата</th><th>Номер</th><th class="num">Бумаг</th><th>Статус</th><th>Примечание</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Claims}}
|
||||
<tr>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td><code title="{{.GUID}}">{{shortid .GUID}}</code></td>
|
||||
<td class="num">{{.Securit}}</td>
|
||||
<td>{{if or (eq .Status "rejected") (eq .Status "declined") (eq .Status "error")}}<span class="badge err">{{.StatusRu}}</span>
|
||||
{{else if eq .Status "done"}}<span class="badge ok">{{.StatusRu}}</span>
|
||||
{{else}}<span class="badge">{{.StatusRu}}</span>{{end}}</td>
|
||||
<td class="mut" style="white-space:normal;min-width:180px">{{.Reason}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
<p class="mut" style="margin-top:12px">Наведите на номер, чтобы увидеть полный идентификатор заявки. Он понадобится при обращении в поддержку.</p>
|
||||
{{else}}
|
||||
<p class="mut">Заявок пока нет. <a href="/transfer">Подать заявку на перевод</a></p>
|
||||
{{end}}
|
||||
</div>
|
||||
</main></body></html>{{end}}
|
||||
|
||||
{{define "clients"}}{{template "head" .}}{{template "nav" .}}
|
||||
<main>
|
||||
<h1>Клиенты</h1>
|
||||
<p class="mut">Список клиентов личного кабинета. Можно открыть кабинет любого клиента, чтобы посмотреть его бумаги и заявки его глазами.</p>
|
||||
{{with .Flash}}<div class="msg err">{{.}}</div>{{end}}
|
||||
{{with .Error}}<div class="msg err">{{.}}</div>{{end}}
|
||||
<div class="card">
|
||||
{{if .Clients}}
|
||||
<div class="tw"><table>
|
||||
<thead><tr><th>Клиент</th><th>Логин</th><th>Договор</th><th>E-mail</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Clients}}
|
||||
<tr>
|
||||
<td>{{if .FullName}}{{.FullName}}{{else}}—{{end}}{{if .IsAdmin}} <span class="badge">админ</span>{{end}}</td>
|
||||
<td><code>{{.Login}}</code></td>
|
||||
<td>{{.Contract}}</td>
|
||||
<td class="mut">{{.Email}}</td>
|
||||
<td>{{if .Active}}<span class="badge ok">активен</span>{{else}}<span class="badge err">заблокирован</span>{{end}}</td>
|
||||
<td>{{if not .IsAdmin}}<a class="btn sec" href="/impersonate?login={{.Login}}">Открыть кабинет</a>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{else if not .Error}}
|
||||
<p class="mut">Клиентов пока нет.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<h2>Клиенты депозитария</h2>
|
||||
<p class="mut">Реальные договоры из депозитарного учёта. Учётная запись в кабинете им не нужна —
|
||||
администратор открывает кабинет по договору в режиме просмотра.</p>
|
||||
{{with .DepoError}}<div class="msg err">Депозитарий недоступен: {{.}}</div>{{end}}
|
||||
<div class="card">
|
||||
<form method="get" action="/clients" class="row" style="margin-bottom:12px">
|
||||
<input type="text" name="search" value="{{.Search}}" placeholder="номер договора или ФИО, например БР-1001 или Гравс" style="flex:1">
|
||||
<button class="btn" type="submit">Найти</button>
|
||||
{{if .Search}}<a class="btn sec" href="/clients">Сбросить</a>{{end}}
|
||||
</form>
|
||||
{{if .Depo}}
|
||||
<div class="tw"><table>
|
||||
<thead><tr><th>Клиент</th><th>Договор</th><th>Бумаг</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Depo}}
|
||||
<tr>
|
||||
<td>{{if .Name}}{{.Name}}{{else}}—{{end}}</td>
|
||||
<td><code>{{.Contract}}</code></td>
|
||||
<td>{{.Securities}}</td>
|
||||
<td><a class="btn sec" href="/impersonate?contract={{.Contract}}">Открыть кабинет</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
<p class="mut">Показаны первые 100 договоров с ненулевыми остатками. Уточните поиск, чтобы найти нужного клиента.</p>
|
||||
{{else if not .DepoError}}
|
||||
<p class="mut">По запросу «{{.Search}}» договоров с бумагами не найдено.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</main></body></html>{{end}}
|
||||
`
|
||||
@@ -0,0 +1,44 @@
|
||||
// Command bj-npr — служба модуля «НПР / УДС» (modules/npr), обновляется независимо от ядра bj.
|
||||
//
|
||||
// Слушает только локальный адрес: пользователи работают через bj-server, который проверяет вход,
|
||||
// права и лицензию и проксирует раздел /admin/npr со служебным ключом.
|
||||
//
|
||||
// bj-npr [-addr 127.0.0.1:8083] [-setup /var/lib/bj/.bj/setup.json] [-key /var/lib/bj/.bj/module.key]
|
||||
// bj-npr -version
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/npr"
|
||||
)
|
||||
|
||||
// version — версия модуля (go build -ldflags "-X main.version=…").
|
||||
var version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "127.0.0.1:8083", "адрес службы (только локальный)")
|
||||
setup := flag.String("setup", "/var/lib/bj/.bj/setup.json", "настройки bj (раздел npr)")
|
||||
key := flag.String("key", "/var/lib/bj/.bj/module.key", "служебный ключ модулей (создаёт bj-server)")
|
||||
showVersion := flag.Bool("version", false, "показать версию")
|
||||
flag.Parse()
|
||||
if *showVersion {
|
||||
fmt.Println("bj-npr", version)
|
||||
return
|
||||
}
|
||||
srv, err := npr.New(npr.Config{SetupPath: *setup, KeyPath: *key})
|
||||
if err != nil {
|
||||
log.Fatalf("bj-npr: %v", err)
|
||||
}
|
||||
log.Printf("bj-npr %s: слушаю %s", version, *addr)
|
||||
hs := &http.Server{Addr: *addr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
|
||||
if err := hs.ListenAndServe(); err != nil {
|
||||
log.Printf("bj-npr: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Command bj-quikexport — служба модуля «Экспорт биржевой информации» (modules/quikexport):
|
||||
// управление ARQA quikexport, обновляется независимо от ядра bj.
|
||||
//
|
||||
// Слушает только локальный адрес: раздел настроек показывает bj-server, который проверяет вход,
|
||||
// права и лицензию и передаёт запросы со служебным ключом. Привилегированные операции
|
||||
// (установка файлов в каталог экспортёра, служба, база) идут через root-воркер bj-keymedia-worker.
|
||||
//
|
||||
// bj-quikexport [-addr 127.0.0.1:8084] [-store /var/lib/bj/.bj/quik-export/settings.json]
|
||||
// [-setup /var/lib/bj/.bj/setup.json] [-key /var/lib/bj/.bj/module.key]
|
||||
// bj-quikexport -version
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/quikexport"
|
||||
)
|
||||
|
||||
// version — версия модуля (go build -ldflags "-X main.version=…").
|
||||
var version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "127.0.0.1:8084", "адрес службы (только локальный)")
|
||||
store := flag.String("store", "/var/lib/bj/.bj/quik-export/settings.json", "настройки модуля")
|
||||
setup := flag.String("setup", "/var/lib/bj/.bj/setup.json", "настройки bj (перенос раздела quik_export при первом запуске)")
|
||||
key := flag.String("key", "/var/lib/bj/.bj/module.key", "служебный ключ модулей (создаёт bj-server)")
|
||||
showVersion := flag.Bool("version", false, "показать версию")
|
||||
flag.Parse()
|
||||
if *showVersion {
|
||||
fmt.Println("bj-quikexport", version)
|
||||
return
|
||||
}
|
||||
srv, err := quikexport.New(quikexport.Config{StorePath: *store, SetupPath: *setup, KeyPath: *key, Version: version})
|
||||
if err != nil {
|
||||
log.Fatalf("bj-quikexport: %v", err)
|
||||
}
|
||||
log.Printf("bj-quikexport %s: слушаю %s", version, *addr)
|
||||
// WriteTimeout не задаём: применение настроек ждёт root-воркер до нескольких минут.
|
||||
hs := &http.Server{Addr: *addr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
|
||||
if err := hs.ListenAndServe(); err != nil {
|
||||
log.Printf("bj-quikexport: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
+17
-8
@@ -48,6 +48,7 @@ func main() {
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "bj-release keygen -out <prefix>")
|
||||
fmt.Fprintln(os.Stderr, "bj-release build -dir <artifacts> -version <v> -channel <c> -key <priv> -keyid <id> -out <manifest.json> [-notes <txt>]")
|
||||
fmt.Fprintln(os.Stderr, " версия отдельного файла — в <файл>.version рядом (иначе версия релиза -version)")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
@@ -97,11 +98,11 @@ func build(args []string) {
|
||||
|
||||
// Имена артефактов, которые издаём (логическое имя → ставить +x).
|
||||
known := map[string]bool{
|
||||
"bj-server": true, // Go-бинарь
|
||||
"crypto-service.jar": false, // Java сайдкар
|
||||
"install-validata.sh": true,
|
||||
"install.sh": true,
|
||||
"configure-ish.sql": false,
|
||||
"bj-server": true, // Go-бинарь
|
||||
"crypto-service.jar": false, // Java сайдкар
|
||||
"install-validata.sh": true,
|
||||
"install.sh": true,
|
||||
"configure-ish.sql": false,
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(*dir)
|
||||
@@ -110,7 +111,7 @@ func build(args []string) {
|
||||
}
|
||||
var arts []release.Artifact
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || e.Name() == "manifest.json" {
|
||||
if e.IsDir() || e.Name() == "manifest.json" || strings.HasSuffix(e.Name(), ".version") {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(*dir, e.Name())
|
||||
@@ -119,6 +120,14 @@ func build(args []string) {
|
||||
fatal("hash %s: %v", e.Name(), err)
|
||||
}
|
||||
exec, ok := known[e.Name()]
|
||||
if c, isComp := release.ComponentByName(e.Name()); isComp {
|
||||
exec, ok = c.Exec, true
|
||||
}
|
||||
// Своя версия компонента: <файл>.version рядом с файлом (независимые обновления).
|
||||
ver := *version
|
||||
if vb, err := os.ReadFile(full + ".version"); err == nil && strings.TrimSpace(string(vb)) != "" {
|
||||
ver = strings.TrimSpace(string(vb))
|
||||
}
|
||||
if !ok {
|
||||
// неизвестный файл — включаем, +x по расширению
|
||||
exec = strings.HasSuffix(e.Name(), ".sh")
|
||||
@@ -126,7 +135,7 @@ func build(args []string) {
|
||||
arts = append(arts, release.Artifact{
|
||||
Name: e.Name(),
|
||||
File: e.Name(),
|
||||
Version: *version,
|
||||
Version: ver,
|
||||
SHA256: sha,
|
||||
Size: size,
|
||||
Exec: exec,
|
||||
@@ -159,7 +168,7 @@ func build(args []string) {
|
||||
fmt.Printf("Манифест %s: версия %s, канал %s, артефактов %d, подписан ключом %s\n",
|
||||
*out, *version, *channel, len(arts), *keyID)
|
||||
for _, a := range arts {
|
||||
fmt.Printf(" %-22s %10d B %s\n", a.Name, a.Size, a.SHA256[:16])
|
||||
fmt.Printf(" %-22s %-10s %10d B %s\n", a.Name, a.Version, a.Size, a.SHA256[:16])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package deploy — файлы развёртывания bj, которые ставит установщик (cmd/bj-installer):
|
||||
// root-скрипты для /usr/local/sbin и unit-файлы служб systemd.
|
||||
package deploy
|
||||
|
||||
import "embed"
|
||||
|
||||
// Files — linux/sbin/* (в /usr/local/sbin) и systemd/*.service (в /etc/systemd/system).
|
||||
//
|
||||
//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service systemd/bj-npr.service systemd/bj-quikexport.service systemd/bj-lk.service
|
||||
var Files embed.FS
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# Первая загрузка сервера, установленного с образа Bridge&Join: на экране сервера спрашивает ключ
|
||||
# продукта и запускает bj-installer. После успешной установки служба выключается; адрес первичной
|
||||
# настройки и одноразовый код остаются на экране входа (/etc/issue.d/bj-setup.issue).
|
||||
set -u
|
||||
MARK=/var/lib/bj/.bj/installed-from-iso
|
||||
SERVER=https://lic.zetit.ru
|
||||
CHANNEL=stable
|
||||
[ -s /etc/bj/channel ] && CHANNEL=$(tr -d ' \n' < /etc/bj/channel)
|
||||
|
||||
finish() {
|
||||
mkdir -p "$(dirname "$MARK")" && date -Iseconds > "$MARK"
|
||||
systemctl disable bj-firstboot.service >/dev/null 2>&1
|
||||
}
|
||||
|
||||
clear
|
||||
printf '\n Bridge&Join: установка на сервер\n ────────────────────────────────────────────────\n\n'
|
||||
printf ' Debian установлен. Осталось установить bj.\n'
|
||||
printf ' Нужен ключ продукта (BJ-XXXX-XXXX-XXXX): он есть в портале клиента %s/portal/\n\n' "$SERVER"
|
||||
|
||||
for i in $(seq 1 60); do
|
||||
curl -fsS -m 5 -o /dev/null "$SERVER/healthz" && break
|
||||
[ "$i" = 1 ] && printf ' Жду сеть и связь с %s…\n' "$SERVER"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
while true; do
|
||||
read -r -p " Ключ продукта (пусто — установить позже): " CODE
|
||||
CODE=$(printf '%s' "$CODE" | tr -d ' ' | tr '[:lower:]' '[:upper:]')
|
||||
if [ -z "$CODE" ]; then
|
||||
printf '\n Установка bj отложена. Позже выполните: sudo bj-installer install --code КЛЮЧ-ПРОДУКТА\n\n'
|
||||
finish
|
||||
sleep 3
|
||||
exit 0
|
||||
fi
|
||||
if /usr/local/sbin/bj-installer install --code "$CODE" --server "$SERVER" --channel "$CHANNEL"; then
|
||||
finish
|
||||
read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _
|
||||
exit 0
|
||||
fi
|
||||
printf '\n Установка не завершилась. Проверьте ключ продукта и сеть и попробуйте ещё раз.\n\n'
|
||||
done
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Bridge&Join: установка bj при первой загрузке (ключ продукта на экране сервера)
|
||||
After=network-online.target systemd-user-sessions.service
|
||||
Wants=network-online.target
|
||||
Before=getty@tty1.service
|
||||
ConditionPathExists=!/var/lib/bj/.bj/installed-from-iso
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/bj-firstboot
|
||||
StandardInput=tty
|
||||
StandardOutput=tty
|
||||
StandardError=tty
|
||||
TTYPath=/dev/tty1
|
||||
TTYReset=yes
|
||||
TTYVHangup=yes
|
||||
TimeoutSec=0
|
||||
RemainAfterExit=no
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/bin/sh
|
||||
# Сборка образа ISO Bridge&Join на основе официального Debian 13 netinst.
|
||||
#
|
||||
# deploy/iso/build-iso.sh <debian-13.x-amd64-netinst.iso> <bj-installer> <версия> [канал] [выходной.iso]
|
||||
#
|
||||
# В образ добавляются: файл ответов установщику (preseed.cfg), bj-installer и служба первой загрузки.
|
||||
# Загрузочное меню BIOS (isolinux) и UEFI (grub) по умолчанию запускает установку с этими ответами.
|
||||
# Загрузочные записи исходного образа сохраняются (xorriso -boot_image any replay).
|
||||
# Нужен xorriso. Проверка исходного образа — по SHA256SUMS с cdimage.debian.org.
|
||||
set -eu
|
||||
SRC=${1:?исходный debian-13.x-amd64-netinst.iso}
|
||||
INSTALLER=${2:?файл bj-installer}
|
||||
VERSION=${3:?версия образа, например 2026.10.1}
|
||||
CHANNEL=${4:-stable}
|
||||
OUT=${5:-bj-debian13-$VERSION.iso}
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
command -v xorriso >/dev/null || { echo "нужен xorriso: apt-get install xorriso"; exit 1; }
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
mkdir -p "$WORK/bj" "$WORK/preseed" "$WORK/isolinux" "$WORK/grub"
|
||||
cp "$HERE/preseed.cfg" "$WORK/preseed/bj.cfg"
|
||||
install -m 0755 "$INSTALLER" "$WORK/bj/bj-installer"
|
||||
install -m 0755 "$HERE/bj-firstboot" "$WORK/bj/bj-firstboot"
|
||||
cp "$HERE/bj-firstboot.service" "$WORK/bj/bj-firstboot.service"
|
||||
echo "$CHANNEL" > "$WORK/bj/channel"
|
||||
echo "$VERSION" > "$WORK/bj/version"
|
||||
|
||||
BOOT="auto=true priority=high preseed/file=/cdrom/preseed/bj.cfg language=ru country=RU locale=ru_RU.UTF-8 keyboard-configuration/xkb-keymap=ru"
|
||||
|
||||
# BIOS: пункт по умолчанию в isolinux
|
||||
xorriso -osirrox on -indev "$SRC" -extract /isolinux/txt.cfg "$WORK/isolinux/txt.orig" >/dev/null 2>&1
|
||||
KERNEL=$(awk '/^\s*kernel/{print $2; exit}' "$WORK/isolinux/txt.orig")
|
||||
INITRD=$(grep -o 'initrd=[^ ]*' "$WORK/isolinux/txt.orig" | head -1 | cut -d= -f2)
|
||||
{
|
||||
echo "default bj"
|
||||
echo "label bj"
|
||||
echo " menu label ^Установить Bridge&Join $VERSION (Debian 13)"
|
||||
echo " menu default"
|
||||
echo " kernel $KERNEL"
|
||||
echo " append vga=788 initrd=$INITRD $BOOT --- quiet"
|
||||
sed '/menu default/d;/^default /d' "$WORK/isolinux/txt.orig"
|
||||
} > "$WORK/isolinux/txt.cfg"
|
||||
xorriso -osirrox on -indev "$SRC" -extract /isolinux/isolinux.cfg "$WORK/isolinux/isolinux.cfg" >/dev/null 2>&1
|
||||
sed -i 's/^timeout .*/timeout 100/; s/^prompt .*/prompt 0/' "$WORK/isolinux/isolinux.cfg"
|
||||
grep -q '^timeout' "$WORK/isolinux/isolinux.cfg" || echo "timeout 100" >> "$WORK/isolinux/isolinux.cfg"
|
||||
|
||||
# UEFI: первый пункт в grub
|
||||
xorriso -osirrox on -indev "$SRC" -extract /boot/grub/grub.cfg "$WORK/grub/grub.orig" >/dev/null 2>&1
|
||||
# текстовый установщик (без gtk), как в BIOS: установка идёт без графической среды
|
||||
LINUX=$(awk '/^\s*linux\s/ && $2 !~ /gtk/ {print $2; exit}' "$WORK/grub/grub.orig")
|
||||
INITRDG=$(awk '/^\s*initrd\s/ && $2 !~ /gtk/ {print $2; exit}' "$WORK/grub/grub.orig")
|
||||
{
|
||||
echo "set default=0"
|
||||
echo "set timeout=10"
|
||||
echo "menuentry --hotkey=i 'Установить Bridge&Join $VERSION (Debian 13)' {"
|
||||
echo " set background_color=black"
|
||||
echo " linux $LINUX vga=788 $BOOT --- quiet"
|
||||
echo " initrd $INITRDG"
|
||||
echo "}"
|
||||
sed '/^set default/d;/^set timeout/d' "$WORK/grub/grub.orig"
|
||||
} > "$WORK/grub/grub.cfg"
|
||||
|
||||
rm -f "$OUT"
|
||||
xorriso -indev "$SRC" -outdev "$OUT" -boot_image any replay \
|
||||
-volid "BJ_DEBIAN13" \
|
||||
-map "$WORK/preseed" /preseed \
|
||||
-map "$WORK/bj" /bj \
|
||||
-map "$WORK/isolinux/txt.cfg" /isolinux/txt.cfg \
|
||||
-map "$WORK/isolinux/isolinux.cfg" /isolinux/isolinux.cfg \
|
||||
-map "$WORK/grub/grub.cfg" /boot/grub/grub.cfg \
|
||||
-end >/dev/null 2>"$WORK/xorriso.log" || { cat "$WORK/xorriso.log"; exit 1; }
|
||||
echo "Образ собран: $OUT ($(du -h "$OUT" | cut -f1)), канал обновлений $CHANNEL"
|
||||
@@ -0,0 +1,71 @@
|
||||
#_preseed_V1
|
||||
# Ответы установщику Debian 13 для образа Bridge&Join (deploy/iso/build-iso.sh).
|
||||
# Спрашиваются только пароли, диск (если их несколько) и подтверждение стирания; остальное задано здесь.
|
||||
# После установки служба bj-firstboot на экране сервера спросит ключ продукта и установит bj.
|
||||
|
||||
# Язык, клавиатура, время
|
||||
d-i debian-installer/language string ru
|
||||
d-i debian-installer/country string RU
|
||||
d-i debian-installer/locale string ru_RU.UTF-8
|
||||
d-i localechooser/supported-locales multiselect ru_RU.UTF-8, en_US.UTF-8
|
||||
d-i keyboard-configuration/xkb-keymap select ru
|
||||
d-i keyboard-configuration/toggle select Alt+Shift
|
||||
d-i clock-setup/utc boolean true
|
||||
d-i time/zone string Europe/Moscow
|
||||
d-i clock-setup/ntp boolean true
|
||||
|
||||
# Сеть: DHCP на первом подключённом интерфейсе
|
||||
d-i netcfg/choose_interface select auto
|
||||
d-i netcfg/get_hostname string bj-server
|
||||
d-i netcfg/get_domain string
|
||||
d-i netcfg/hostname string bj-server
|
||||
d-i hw-detect/load_firmware boolean true
|
||||
|
||||
# Зеркало пакетов
|
||||
d-i mirror/country string manual
|
||||
d-i mirror/http/hostname string deb.debian.org
|
||||
d-i mirror/http/directory string /debian
|
||||
d-i mirror/http/proxy string
|
||||
d-i apt-setup/cdrom/set-first boolean false
|
||||
d-i apt-setup/non-free-firmware boolean true
|
||||
d-i apt-setup/services-select multiselect security, updates
|
||||
|
||||
# Учётные записи: пароль root и администратор ОС задаются при установке
|
||||
d-i passwd/root-login boolean true
|
||||
d-i passwd/make-user boolean true
|
||||
d-i passwd/user-fullname string Администратор
|
||||
d-i passwd/username string bjadmin
|
||||
|
||||
# Разметка: весь выбранный диск, одна файловая система; диск и стирание подтверждаются при установке
|
||||
d-i partman-auto/method string regular
|
||||
d-i partman-auto/choose_recipe select atomic
|
||||
d-i partman-auto-lvm/guided_size string max
|
||||
d-i partman-partitioning/confirm_write_new_label boolean true
|
||||
d-i partman/choose_partition select finish
|
||||
d-i partman/default_filesystem string ext4
|
||||
d-i partman-md/confirm boolean true
|
||||
d-i partman-lvm/device_remove_lvm boolean true
|
||||
d-i partman-md/device_remove_md boolean true
|
||||
|
||||
# Пакеты: без графики; SSH и всё, что нужно bj, ставится сразу
|
||||
tasksel tasksel/first multiselect standard, ssh-server
|
||||
d-i pkgsel/include string ca-certificates curl postgresql postgresql-client default-jre-headless pcscd python3 sudo
|
||||
d-i pkgsel/upgrade select safe-upgrade
|
||||
popularity-contest popularity-contest/participate boolean false
|
||||
|
||||
# Загрузчик
|
||||
d-i grub-installer/only_debian boolean true
|
||||
d-i grub-installer/with_other_os boolean false
|
||||
d-i grub-installer/bootdev string default
|
||||
|
||||
# Установщик bj и служба первой загрузки (файлы лежат в /bj на образе)
|
||||
d-i preseed/late_command string \
|
||||
install -m 0755 /cdrom/bj/bj-installer /target/usr/local/sbin/bj-installer; \
|
||||
install -m 0755 /cdrom/bj/bj-firstboot /target/usr/local/sbin/bj-firstboot; \
|
||||
install -m 0644 /cdrom/bj/bj-firstboot.service /target/etc/systemd/system/bj-firstboot.service; \
|
||||
mkdir -p /target/etc/bj; \
|
||||
if [ -f /cdrom/bj/channel ]; then install -m 0644 /cdrom/bj/channel /target/etc/bj/channel; fi; \
|
||||
in-target systemctl enable bj-firstboot.service
|
||||
|
||||
d-i finish-install/reboot_in_progress note
|
||||
d-i cdrom-detect/eject boolean true
|
||||
@@ -1,71 +0,0 @@
|
||||
# Лицензирование Bridge-and-Join-s (#19)
|
||||
|
||||
Годовые лицензии с офлайн-проверкой по подписи Ed25519 + опциональный
|
||||
онлайн-отзыв.
|
||||
|
||||
## Компоненты
|
||||
|
||||
- `internal/license` — формат лицензии + подпись/проверка (offline).
|
||||
- `cmd/bj-license` — издательский CLI: keygen, issue, verify.
|
||||
- `cmd/bj-license-server` — онлайн-реестр отзывов (revocation).
|
||||
- Интеграция в bj-server: `internal/lkgateway/licensecheck.go` — проверка
|
||||
лицензии + гейт обновлений; UI раздел «Лицензия».
|
||||
|
||||
## Модель
|
||||
|
||||
Лицензия — самодостаточный подписанный токен: `payload.signature.keyid`.
|
||||
bj-server проверяет подпись зашитым публичным ключом и срок **офлайн** —
|
||||
работает без связи с сервером. Online-сервер нужен только для отзыва.
|
||||
|
||||
**Гейт обновлений:** если лицензирование включено (есть публичный ключ),
|
||||
авто-обновление (#20) выполняется только при валидной лицензии с фичей
|
||||
`updates`. Без лицензирования (публичный ключ не зашит) — открытый режим,
|
||||
гейты не действуют (бесплатная редакция / разработка).
|
||||
|
||||
## Издателю
|
||||
|
||||
```bash
|
||||
# 1. Ключи лицензий (однократно; приватный — в секрете!)
|
||||
bj-license keygen -out ./keys/license
|
||||
# публичный base64 — зашить в bj-server (DefaultLicensePublicKey)
|
||||
|
||||
# 2. Выпустить годовую лицензию клиенту
|
||||
bj-license issue -tenant "ООО Ромашка" -plan pro -days 365 \
|
||||
-features updates,web-cabinet -key ./keys/license.priv -keyid main
|
||||
# → выводит ключ payload.signature.keyid — отдать клиенту
|
||||
|
||||
# 3. Проверить
|
||||
bj-license verify -key-file license.key -pub ./keys/license.pub
|
||||
```
|
||||
|
||||
Планы: `free` (без фич), `pro` (перечисленные features), `enterprise`
|
||||
(всё включено). Фичи: `updates`, `web-cabinet`, …
|
||||
|
||||
## Клиенту (on-prem bj-server)
|
||||
|
||||
Админ → Настройка → **Лицензия** → вставить ключ → «Активировать».
|
||||
Проверка офлайн; статус (организация, план, срок, обновления) виден сразу.
|
||||
|
||||
## Онлайн-отзыв (опционально)
|
||||
|
||||
```bash
|
||||
bj-license-server --addr :8091 --revoked /var/lib/bj-license/revoked.json
|
||||
```
|
||||
|
||||
`revoked.json` — JSON-массив отозванных license ID:
|
||||
|
||||
```json
|
||||
["28db4973-fde8-434c-b102-e83623eede2c"]
|
||||
```
|
||||
|
||||
`GET /v1/check?id=<id>` → `{"revoked":true|false}`. Перечитывается раз в
|
||||
минуту. В проде заменить файл на PostgreSQL + admin API выпуска/отзыва.
|
||||
|
||||
## Зашивка публичного ключа в релиз
|
||||
|
||||
```bash
|
||||
go build -ldflags "\
|
||||
-X .../lkgateway.DefaultLicensePublicKey=<base64-pub> \
|
||||
-X .../lkgateway.DefaultUpdatePublicKey=<base64-pub-артефактории> \
|
||||
-X .../lkgateway.BuildVersion=1.0.0" -o bj-server ./cmd/bj-server/
|
||||
```
|
||||
@@ -1,11 +0,0 @@
|
||||
[Unit]
|
||||
Description=Bridge&Join — команды защиты из кабинета сервера лицензий
|
||||
|
||||
[Path]
|
||||
PathChanged=/var/lib/bj-license/fw-control.json
|
||||
PathChanged=/var/lib/bj-license/allow-ips.txt
|
||||
PathChanged=/var/lib/bj-license/trusted-ips.txt
|
||||
Unit=bj-license-fw.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,7 +0,0 @@
|
||||
[Unit]
|
||||
Description=Bridge&Join — применение защиты сервера лицензий (белый список, режим обороны, команды fail2ban)
|
||||
After=fail2ban.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/bj-license/bj-license-server fw-apply -dir /var/lib/bj-license
|
||||
@@ -1,11 +0,0 @@
|
||||
[Unit]
|
||||
Description=Bridge&Join — статус защиты сервера лицензий раз в 30 секунд (и восстановление правил после перезагрузки)
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30s
|
||||
OnUnitActiveSec=30s
|
||||
AccuracySec=5s
|
||||
Unit=bj-license-fw.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,22 +0,0 @@
|
||||
[Unit]
|
||||
Description=Bridge&Join — сервер лицензирования (lic.zetit.ru)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj-license
|
||||
Group=bj-license
|
||||
ExecStart=/opt/bj-license/bj-license-server serve -addr 127.0.0.1:8091 -data /var/lib/bj-license/state.json -key /etc/bj-license/license.priv -keyid main
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/lib/bj-license
|
||||
ReadOnlyPaths=/etc/bj-license
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,6 +0,0 @@
|
||||
# fail2ban: перебор паролей кабинета сервера лицензий bj
|
||||
# (строки пишет bj-license-server в журнал systemd; IP клиента берётся из X-Real-IP nginx).
|
||||
[Definition]
|
||||
failregex = ^.*login failed: user=".*" ip=<HOST>$
|
||||
ignoreregex =
|
||||
journalmatch = _SYSTEMD_UNIT=bj-license-server.service
|
||||
@@ -1,5 +0,0 @@
|
||||
# fail2ban: перебор кодов активации и токенов API сервера лицензий bj.
|
||||
[Definition]
|
||||
failregex = ^.*api denied: (unknown_code|bad_token) ip=<HOST>$
|
||||
ignoreregex =
|
||||
journalmatch = _SYSTEMD_UNIT=bj-license-server.service
|
||||
@@ -1,30 +0,0 @@
|
||||
# Сервер лицензий lic.zetit.ru: блокировка IP на уровне файрвола.
|
||||
|
||||
# Вход в кабинет: не блокируется только белый список администраторов (раздел «Защита»).
|
||||
[bj-license-admin]
|
||||
enabled = true
|
||||
backend = systemd
|
||||
filter = bj-license-admin
|
||||
port = http,https
|
||||
maxretry = 10
|
||||
findtime = 10m
|
||||
bantime = 1h
|
||||
ignorecommand = /opt/bj-license/bj-license-server fw-ignore -dir /var/lib/bj-license <ip> admin
|
||||
|
||||
# API активации и продления: не блокируются адреса установок, успешно активировавших или продливших
|
||||
# лицензию (trusted-ips.txt), и белый список администраторов (allow-ips.txt).
|
||||
[bj-license-api]
|
||||
enabled = true
|
||||
backend = systemd
|
||||
filter = bj-license-api
|
||||
port = http,https
|
||||
maxretry = 10
|
||||
findtime = 10m
|
||||
bantime = 1h
|
||||
ignorecommand = /opt/bj-license/bj-license-server fw-ignore -dir /var/lib/bj-license <ip> api
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
maxretry = 5
|
||||
findtime = 10m
|
||||
bantime = 1h
|
||||
@@ -10,6 +10,7 @@ process_one(){
|
||||
rm -f "$req"
|
||||
case "$action" in
|
||||
quikexport-*) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-quikexport-admin "${action#quikexport-}" "$staging" 2>&1) ;;
|
||||
component-install) out=$(/usr/local/sbin/bj-component-install "$staging" 2>&1) ;;
|
||||
*) out=$(BJ_TARGET_DEV="$tdev" BJ_TARGET_MNT="$tmnt" BJ_EXTRA="$extra" /usr/local/sbin/bj-keymedia-install "$action" "$staging" "$profile" 2>&1) ;;
|
||||
esac
|
||||
printf '%s' "$out" > "$res.tmp"; chown bj:bj "$res.tmp" 2>/dev/null
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
[Unit]
|
||||
Description=Bridge&Join: Личный кабинет клиента (bj-lk)
|
||||
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
|
||||
After=network-online.target bj-server.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj
|
||||
Group=bj
|
||||
# Значения по умолчанию. Адрес bj-server, ключ доступа модуля (BJ_KEY), порт (LISTEN) и заголовок
|
||||
# (LK_TITLE) задаются в /etc/bj/lk.env (root, 0600), чтобы ключ не лежал в unit-файле.
|
||||
Environment=BJ_URL=http://127.0.0.1:8080
|
||||
Environment=LISTEN=:8099
|
||||
EnvironmentFile=-/etc/bj/lk.env
|
||||
ExecStart=/opt/bj/bj-lk
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=append:/var/log/bj/bj-lk.log
|
||||
StandardError=append:/var/log/bj/bj-lk.log
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/log/bj
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=Bridge&Join: модуль «НПР / УДС» (риск-монитор по данным QUIK)
|
||||
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj
|
||||
Group=bj
|
||||
ExecStart=/opt/bj/bj-npr -addr 127.0.0.1:8083 -setup /var/lib/bj/.bj/setup.json -key /var/lib/bj/.bj/module.key
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=append:/var/log/bj/bj-npr.log
|
||||
StandardError=append:/var/log/bj/bj-npr.err
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/log/bj
|
||||
ReadOnlyPaths=/var/lib/bj
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=Bridge&Join: модуль «Экспорт биржевой информации» (управление ARQA quikexport)
|
||||
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj
|
||||
Group=bj
|
||||
ExecStart=/opt/bj/bj-quikexport -addr 127.0.0.1:8084 -store /var/lib/bj/.bj/quik-export/settings.json -setup /var/lib/bj/.bj/setup.json -key /var/lib/bj/.bj/module.key
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=append:/var/log/bj/bj-quikexport.log
|
||||
StandardError=append:/var/log/bj/bj-quikexport.err
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
ReadOnlyPaths=/var/lib/bj
|
||||
# Настройки и ключи модуля и staging для root-воркера (systemd создаёт их с владельцем bj),
|
||||
# очередь запросов root-воркера (создаёт bj-keymedia) и журналы.
|
||||
StateDirectory=bj/.bj/quik-export bj/quik-export-apply
|
||||
StateDirectoryMode=0700
|
||||
ReadWritePaths=-/var/lib/bj/keymedia-requests /var/log/bj
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -15,7 +15,7 @@ RestartSec=5
|
||||
|
||||
# Конфигурация — через ENV или ~/.bj/setup.json (UI /admin/setup).
|
||||
Environment=BJ_HTTP_ADDR=:8080
|
||||
Environment=BJ_SETUP_PATH=/var/lib/bj/setup.json
|
||||
Environment=BJ_SETUP_PATH=/var/lib/bj/.bj/setup.json
|
||||
Environment=BJ_M2M_SENDER=MC0079200000
|
||||
Environment=BJ_M2M_RECEIVER=MC0010300000
|
||||
|
||||
@@ -32,6 +32,8 @@ ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/lib/bj /var/log/bj /run/bj
|
||||
StandardOutput=append:/var/log/bj/bj-server.log
|
||||
StandardError=append:/var/log/bj/bj-server.err
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Сервер лицензирования и обновлений bj — контракт API
|
||||
|
||||
> Сервер лицензий живёт в отдельном репозитории **zuevav/lic_Bridge-and-Join-s** (наша инфраструктура, клиентам не поставляется). Здесь — контракт API для клиентской части bj (`internal/lkgateway/licenseclient.go`). Формат токена общий: `internal/license`, тест `TestTokenCompat` одинаков в обоих репозиториях.
|
||||
|
||||
|
||||
Сервер: **https://lic.zetit.ru** (ВМ 46.173.19.220, Ubuntu 26.04). Адрес зашит в bj
|
||||
(`internal/lkgateway`, константа), в интерфейсе не меняется. С этого же сервера bj
|
||||
получает обновления.
|
||||
|
||||
@@ -11,8 +11,6 @@ require (
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require rsc.io/qr v0.2.0 // indirect
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
|
||||
@@ -12,6 +12,8 @@ github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDP
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -37,6 +39,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -45,6 +49,18 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
|
||||
@@ -94,5 +110,3 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package license
|
||||
|
||||
// Совместимость формата лицензии между bj (проверяет токены) и сервером лицензий
|
||||
// lic_Bridge-and-Join-s (выпускает их). Пакет лежит копией в обоих репозиториях;
|
||||
// этот тест с одинаковым ожидаемым токеном есть в обоих — если формат разойдётся,
|
||||
// упадёт тот, где пакет изменили. Подпись Ed25519 детерминирована.
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const compatToken = "eyJzY2hlbWEiOjEsImlkIjoiMTExMTExMTEtMjIyMi0zMzMzLTQ0NDQtNTU1NTU1NTU1NTU1IiwidGVuYW50Ijoi0J7QntCeIMKr0J/RgNC+0LLQtdGA0LrQsMK7IiwicHJvZHVjdCI6ImJqLXNlcnZlciIsInBsYW4iOiJwcm8iLCJpc3N1ZWRfYXQiOiIyMDI2LTA5LTAxVDAwOjAwOjAwWiIsImV4cGlyZXNfYXQiOiIyMDI2LTExLTAxVDAwOjAwOjAwWiIsImZlYXR1cmVzIjpbInVwZGF0ZXMiLCJucHIiXSwibWF4X25vZGVzIjoyLCJpbnN0YWxsX2lkIjoiYWFhYWFhYWEtYmJiYi1jY2NjLWRkZGQtZWVlZWVlZWVlZWVlIiwiY29udHJhY3RfdW50aWwiOiIyMDI3LTA5LTAxVDAwOjAwOjAwWiJ9.VIKhe7NZxwvTQfCNCNMwrADvlWd/izmzZrE2h1rkwliOBHQd5rGU00/v/V8uArkw4YShc5NW4BLY35wIPQ3GDg==.main"
|
||||
|
||||
func TestTokenCompat(t *testing.T) {
|
||||
seed := make([]byte, ed25519.SeedSize)
|
||||
for i := range seed {
|
||||
seed[i] = byte(i + 1)
|
||||
}
|
||||
priv := ed25519.NewKeyFromSeed(seed)
|
||||
l := &License{
|
||||
ID: "11111111-2222-3333-4444-555555555555", Tenant: "ООО «Проверка»", Product: "bj-server", Plan: PlanPro,
|
||||
IssuedAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC),
|
||||
Features: []string{"updates", "npr"}, MaxNodes: 2,
|
||||
InstallID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", ContractUntil: time.Date(2027, 9, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
tok, err := Sign(l, priv, "main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := tok.Encode(); got != compatToken {
|
||||
t.Fatalf("формат токена изменился — синхронизируйте internal/license в bj и сервере лицензий\nполучено: %s", got)
|
||||
}
|
||||
back, err := DecodeToken(compatToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := Verify(back, priv.Public().(ed25519.PublicKey))
|
||||
if err != nil || v.InstallID != l.InstallID || !v.ContractUntil.Equal(l.ContractUntil) || v.Tenant != l.Tenant {
|
||||
t.Fatalf("проверка токена: %v %+v", err, v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package license
|
||||
|
||||
// DefaultPublicKey — открытый ключ проверки лицензий Bridge&Join (закрытый — на сервере лицензий,
|
||||
// /etc/bj-license/license.priv). Общий для bj-server и установщика.
|
||||
const DefaultPublicKey = "UuvEYFxxzOFk59FjCQTdxKuzkNAJpJyWTuUdlCYfsdI="
|
||||
@@ -38,14 +38,14 @@ const (
|
||||
// License — содержимое лицензии (подписывается целиком).
|
||||
type License struct {
|
||||
Schema int `json:"schema"`
|
||||
ID string `json:"id"` // UUID лицензии
|
||||
Tenant string `json:"tenant"` // организация-клиент
|
||||
Product string `json:"product"` // "bj-server"
|
||||
Plan Plan `json:"plan"` // free|pro|enterprise
|
||||
IssuedAt time.Time `json:"issued_at"` // дата выпуска
|
||||
ExpiresAt time.Time `json:"expires_at"` // дата окончания (годовой ключ)
|
||||
Features []string `json:"features,omitempty"` // "updates","web-cabinet",...
|
||||
MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита)
|
||||
ID string `json:"id"` // UUID лицензии
|
||||
Tenant string `json:"tenant"` // организация-клиент
|
||||
Product string `json:"product"` // "bj-server"
|
||||
Plan Plan `json:"plan"` // free|pro|enterprise
|
||||
IssuedAt time.Time `json:"issued_at"` // дата выпуска
|
||||
ExpiresAt time.Time `json:"expires_at"` // дата окончания (годовой ключ)
|
||||
Features []string `json:"features,omitempty"` // "updates","web-cabinet",...
|
||||
MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита)
|
||||
Note string `json:"note,omitempty"`
|
||||
|
||||
// Онлайн-лицензирование (lic.zetit.ru, docs/license-server-api.md): токен выдаётся
|
||||
@@ -53,8 +53,16 @@ type License struct {
|
||||
// пока действует договор (ContractUntil). У офлайн-ключей оба поля пустые.
|
||||
InstallID string `json:"install_id,omitempty"`
|
||||
ContractUntil time.Time `json:"contract_until,omitempty"`
|
||||
|
||||
// DevUntil — режим разработки до указанного момента: сервер лицензий временно включает его
|
||||
// установке, чтобы разработчики bj подключились к серверу клиента (тестовые разделы и
|
||||
// инструменты). Указатель — чтобы у ключей без режима разработки подписанный JSON не менялся.
|
||||
DevUntil *time.Time `json:"dev_until,omitempty"`
|
||||
}
|
||||
|
||||
// DevMode — действует ли режим разработки на момент now.
|
||||
func (l *License) DevMode(now time.Time) bool { return l.DevUntil != nil && now.Before(*l.DevUntil) }
|
||||
|
||||
// Token — лицензия + подпись. Именно это вводит клиент (одна base64-строка
|
||||
// или JSON-файл). Формат: base64url(payload).base64url(sig) — компактно.
|
||||
type Token struct {
|
||||
|
||||
+31
-27
@@ -22,13 +22,13 @@ var templatesFS embed.FS
|
||||
// конкретный content-шаблон). Так html/template не путается с несколькими
|
||||
// {{define "content"}} в разных файлах.
|
||||
type admin struct {
|
||||
home, claims, claim, status, setup *template.Template
|
||||
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, npr, nprClient, nprRows *template.Template
|
||||
incoming, incomingDetail, fancyQueue, quikInstr *template.Template
|
||||
wizard, news, keyWizard *template.Template
|
||||
handbook *template.Template
|
||||
m2mtest, m2mpreview, depoSim *template.Template
|
||||
journal *template.Template
|
||||
home, claims, claim, status, setup *template.Template
|
||||
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, modulePage *template.Template
|
||||
incoming, incomingDetail, fancyQueue, quikInstr *template.Template
|
||||
wizard, news, keyWizard *template.Template
|
||||
handbook *template.Template
|
||||
m2mtest, m2mpreview, depoSim *template.Template
|
||||
journal *template.Template
|
||||
}
|
||||
|
||||
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
|
||||
@@ -51,7 +51,6 @@ var templateFuncs = template.FuncMap{
|
||||
// m2mCode — человекочитаемое название кода M2M: «Текст (M2Mxx)».
|
||||
"m2mCode": m2mCodeLabel,
|
||||
"quikCodeMap": quikClientCodeMapText,
|
||||
"nprF": nprF,
|
||||
"curUser": authTemplateFuncs["curUser"],
|
||||
"can": authTemplateFuncs["can"],
|
||||
"authOpen": authOpen,
|
||||
@@ -277,20 +276,13 @@ func newAdmin() (*admin, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help_fancy_events: %w", err)
|
||||
}
|
||||
parseNPR := func(content string) (*template.Template, error) {
|
||||
return template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html", "web/templates/npr_css.html", "web/templates/"+content)
|
||||
// Страница модуля (служба модуля отдаёт содержимое, макет и меню — bj): npr.go.
|
||||
modulePage, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html")
|
||||
if err == nil {
|
||||
_, err = modulePage.New("content").Parse(`{{.Content}}`)
|
||||
}
|
||||
nprT, err := parseNPR("admin_npr.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_npr: %w", err)
|
||||
}
|
||||
nprC, err := parseNPR("admin_npr_client.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_npr_client: %w", err)
|
||||
}
|
||||
nprR, err := template.New("npr_rows").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/admin_npr_rows.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_npr_rows: %w", err)
|
||||
return nil, fmt.Errorf("parse module page: %w", err)
|
||||
}
|
||||
keyWizard, err := parse("admin_keywizard.html")
|
||||
if err != nil {
|
||||
@@ -335,7 +327,7 @@ func newAdmin() (*admin, error) {
|
||||
return &admin{
|
||||
home: home, claims: claims, claim: claim, status: status, setup: setup,
|
||||
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCrypto: helpCrypto, helpSystems: helpSys,
|
||||
helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, npr: nprT, nprClient: nprC, nprRows: nprR,
|
||||
helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, modulePage: modulePage,
|
||||
wizard: wizard, news: news, keyWizard: keyWizard,
|
||||
incoming: incoming, incomingDetail: incomingDetail, fancyQueue: fancyQueue, quikInstr: quikInstr,
|
||||
handbook: handbook,
|
||||
@@ -351,9 +343,10 @@ type page struct {
|
||||
Title string
|
||||
Active string
|
||||
Now string
|
||||
IsMockMode bool // true если ИШ не настроен — bj-server в режиме эмуляции
|
||||
MockReason string // короткое описание почему mock
|
||||
IsMockMode bool // true если ИШ или СКЗИ не настроены — обмена с НРД нет
|
||||
MockReason string // короткое описание причины (у клиента — без «mock»)
|
||||
NPR bool // модуль «НПР / УДС» включён и разрешён лицензией
|
||||
DevMode bool // режим разработки (сервер лицензий): видны служебные разделы
|
||||
}
|
||||
|
||||
// globalRC — ссылка на runtime-конфиг для template-funcs/page helpers.
|
||||
@@ -487,7 +480,7 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service,
|
||||
page: nowPage("Дашборд", "home"),
|
||||
Status: status,
|
||||
Recent: recent.Items,
|
||||
News: topNews(rc.Snapshot().News.Items, 3),
|
||||
News: topNews(visibleNews(rc, rc.Snapshot().News.Items), 3),
|
||||
LKUrl: rc.Snapshot().LK.PublicURL,
|
||||
}
|
||||
// Готовность системы считаем ТОЛЬКО по обязательным компонентам.
|
||||
@@ -617,7 +610,11 @@ func buildClaimTimeline(ctx context.Context, v ClaimView) []TimelineEvent {
|
||||
rej++
|
||||
}
|
||||
}
|
||||
add("Получено решение НРД (робот)", "response",
|
||||
title := "Получено решение контрагента (НРД)"
|
||||
if devMode(globalRC) {
|
||||
title = "Получено решение НРД (робот)"
|
||||
}
|
||||
add(title, "response",
|
||||
fmt.Sprintf("Принято %d, отклонено %d.", conf, rej), v.UpdatedAt, v.CreatedAt)
|
||||
} else if v.M2MResponse != nil {
|
||||
var codes string
|
||||
@@ -712,14 +709,21 @@ func nowPage(title, active string) page {
|
||||
p := page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
|
||||
if globalRC != nil {
|
||||
p.NPR = nprAvailable(globalRC)
|
||||
p.DevMode = devMode(globalRC)
|
||||
s := globalRC.Snapshot()
|
||||
switch {
|
||||
case s.NSD.IGWBaseURL == "":
|
||||
p.IsMockMode = true
|
||||
p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)"
|
||||
p.MockReason = "ИШ НРД не настроен — обмен с НРД не выполняется"
|
||||
if p.DevMode {
|
||||
p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)"
|
||||
}
|
||||
case s.Crypto.Provider == "" || s.Crypto.Provider == "stub":
|
||||
p.IsMockMode = true
|
||||
p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен"
|
||||
p.MockReason = "Не настроено средство подписи (СКЗИ) — обмен с НРД невозможен"
|
||||
if p.DevMode {
|
||||
p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен"
|
||||
}
|
||||
}
|
||||
}
|
||||
return p
|
||||
|
||||
@@ -37,6 +37,9 @@ func advancedUnlocked(r *http.Request) bool {
|
||||
if globalRC != nil && moduleAllowed(globalRC, "quik-mnp") {
|
||||
return true
|
||||
}
|
||||
if !devMode(globalRC) { // пароль на модули — инструмент разработчиков
|
||||
return false
|
||||
}
|
||||
c, err := r.Cookie(advancedCookie)
|
||||
if err != nil {
|
||||
return false
|
||||
|
||||
@@ -54,7 +54,7 @@ type roleDef struct {
|
||||
// settings — настройка системы и пользователи; npr/quik/export — модули.
|
||||
var roleDefs = []roleDef{
|
||||
{"admin", "Администратор", "Полный доступ: переводы, настройки, пользователи, все модули.", []string{"view", "operate", "settings", "npr", "quik", "export", "users"}},
|
||||
{"operator", "Оператор депозитария", "Журнал, заявки, входящие/исходящие переводы, обмен с DEPO и QUIK, тестирование M2M. Без настроек.", []string{"view", "operate", "quik"}},
|
||||
{"operator", "Оператор депозитария", "Журнал, заявки, входящие/исходящие переводы, обмен с DEPO и QUIK. Без настроек.", []string{"view", "operate", "quik"}},
|
||||
{"risk", "Риск-менеджер", "Только риск-монитор НПР / УДС.", []string{"npr"}},
|
||||
{"viewer", "Наблюдатель", "Просмотр журнала и переводов без права действий.", []string{"view"}},
|
||||
}
|
||||
@@ -180,6 +180,9 @@ type User struct {
|
||||
}
|
||||
|
||||
func (u *User) Can(perm string) bool {
|
||||
if perm == "dev" { // режим разработки: включает сервер лицензий, внутри — права настройки
|
||||
return u != nil && devMode(globalRC) && u.Can("settings")
|
||||
}
|
||||
if u == nil || !permLicensed(perm) {
|
||||
return false
|
||||
}
|
||||
@@ -490,7 +493,7 @@ type AuthSettings struct {
|
||||
|
||||
// Перепроверка групп доменных пользователей (adrecheck.go): поиск от имени
|
||||
// сервисной учётной записи только на чтение. Пусто — роль меняется только при входе.
|
||||
ADBindUser string `json:"ad_bind_user"` // svc-bj@corp.example.ru, CORP\svc-bj или DN
|
||||
ADBindUser string `json:"ad_bind_user"` // svc-bj@corp.example.ru, CORP\svc-bj или DN
|
||||
ADBindPassword string `json:"ad_bind_password"`
|
||||
ADRecheckMinutes int `json:"ad_recheck_minutes"` // 0 = 15
|
||||
}
|
||||
@@ -805,20 +808,34 @@ func currentUser(w http.ResponseWriter) *User {
|
||||
// authExempt — пути без входа: API модулей (свой ключ/токен), health, статика.
|
||||
func authExempt(p string) bool {
|
||||
switch {
|
||||
case p == "/login" || p == "/logout" || p == "/healthz":
|
||||
case p == "/login" || p == "/logout" || p == "/healthz" || p == "/first-run":
|
||||
return true
|
||||
case strings.HasPrefix(p, "/api/"):
|
||||
return true
|
||||
case p == "/admin/api/callback-url": // служебный вызов эмулятора ЛК
|
||||
return true
|
||||
case strings.HasPrefix(p, "/admin/npr/static/"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// devOnlyPath — служебные разделы разработчиков (тестирование, имитации, прогоны с роботом НРД):
|
||||
// доступны только в режиме разработки.
|
||||
func devOnlyPath(p string) bool {
|
||||
for _, pre := range []string{"/admin/m2m-testing", "/admin/incoming/depo-sim", "/admin/setup/test-nsd", "/admin/setup/test-run",
|
||||
"/admin/setup/lk-simulate", "/admin/setup/postgres/quick-start", "/admin/help/robot", "/admin/api/clients", "/admin/api/callback-url",
|
||||
"/admin/setup/advanced-unlock", "/admin/setup/advanced-lock"} {
|
||||
if p == pre || strings.HasPrefix(p, pre+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// requiredPerm — какое право нужно для пути и метода.
|
||||
func requiredPerm(p, method string) string {
|
||||
if devOnlyPath(p) {
|
||||
return "dev"
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(p, "/admin/setup") || p == "/admin/wizard":
|
||||
return "settings"
|
||||
@@ -853,10 +870,22 @@ func clientIP(r *http.Request) string {
|
||||
func authMiddleware(rc *RuntimeConfig, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
if devOnlyPath(p) && !devMode(rc) { // вне режима разработки служебных разделов нет вовсе
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if authExempt(p) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if firstRunPending() { // свежая установка: сначала администратор по одноразовому коду
|
||||
if isAjax(r) {
|
||||
http.Error(w, "требуется первичная настройка", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/first-run", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if authOpen() {
|
||||
next.ServeHTTP(&authRW{ResponseWriter: w, user: nil}, r)
|
||||
return
|
||||
@@ -924,9 +953,14 @@ type loginData struct {
|
||||
var loginTpl = template.Must(template.New("login").Parse(loginHTML))
|
||||
|
||||
func registerAuth(mux *http.ServeMux, rc *RuntimeConfig) {
|
||||
registerFirstRun(mux)
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
a := rc.Snapshot().Auth
|
||||
next := safeReturnPath(r.FormValue("next"))
|
||||
if firstRunPending() {
|
||||
http.Redirect(w, r, "/first-run", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if authOpen() {
|
||||
http.Redirect(w, r, "/admin/setup#users", http.StatusSeeOther)
|
||||
return
|
||||
@@ -978,10 +1012,10 @@ func registerAuth(mux *http.ServeMux, rc *RuntimeConfig) {
|
||||
// ---------- обработчики настроек ----------
|
||||
|
||||
type UsersView struct {
|
||||
Users []*User
|
||||
Roles []roleDef // доступные при текущей лицензии
|
||||
Hidden []struct{ Title, Missing string } // роли, недоступные без модулей
|
||||
Modules []struct{ Key, Title string } // подключённые модули
|
||||
Users []*User
|
||||
Roles []roleDef // доступные при текущей лицензии
|
||||
Hidden []struct{ Title, Missing string } // роли, недоступные без модулей
|
||||
Modules []struct{ Key, Title string } // подключённые модули
|
||||
Sessions []*Session
|
||||
Open bool
|
||||
Me *User
|
||||
@@ -1259,6 +1293,9 @@ func renderFor(w http.ResponseWriter, t *template.Template, data any) error {
|
||||
t2.Funcs(template.FuncMap{
|
||||
"curUser": func() *User { return u },
|
||||
"can": func(perm string) bool {
|
||||
if perm == "dev" {
|
||||
return devMode(globalRC) && (authOpen() || u.Can("settings"))
|
||||
}
|
||||
if authOpen() {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Служебные разделы разработчиков вне режима разработки отвечают 404 — даже при открытом входе.
|
||||
func TestDevOnlyPaths(t *testing.T) {
|
||||
for _, p := range []string{"/admin/m2m-testing", "/admin/m2m-testing/preview", "/admin/setup/test-run", "/admin/api/callback-url", "/admin/help/robot"} {
|
||||
if !devOnlyPath(p) || requiredPerm(p, http.MethodGet) != "dev" {
|
||||
t.Errorf("%s должен быть служебным", p)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"/admin/m2m-testingx", "/admin/incoming", "/admin/setup", "/admin/help"} {
|
||||
if devOnlyPath(p) {
|
||||
t.Errorf("%s не служебный", p)
|
||||
}
|
||||
}
|
||||
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) })
|
||||
h := authMiddleware(rc, next)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/m2m-testing", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("вне режима разработки: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,11 @@ func checkCryptoSocket(o CheckOptions) Status {
|
||||
s.Message = "сокет открыт"
|
||||
s.Detail = o.CryptoSocket
|
||||
if o.CryptoProvider == "stub" || o.CryptoProvider == "" {
|
||||
s.Message += ", провайдер stub (реальная криптография не подключена)"
|
||||
if devMode(globalRC) {
|
||||
s.Message += ", провайдер stub (реальная криптография не подключена)"
|
||||
} else {
|
||||
s.Message += ", средство подписи не настроено"
|
||||
}
|
||||
} else {
|
||||
s.Message += ", провайдер " + o.CryptoProvider
|
||||
}
|
||||
@@ -136,7 +140,10 @@ func checkNSDAdapter(ctx context.Context, o CheckOptions) Status {
|
||||
if o.NSDAdapterURL == "" {
|
||||
s.OK = true
|
||||
s.Optional = true
|
||||
s.Message = "не подключён — режим эмуляции (mock)"
|
||||
s.Message = "не настроен — обмен с НРД не выполняется"
|
||||
if devMode(globalRC) {
|
||||
s.Message = "не подключён — режим эмуляции (mock)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
// У ИШ нет /healthz — проверяем рабочий эндпоинт Web API (engine/state
|
||||
|
||||
@@ -44,6 +44,7 @@ type depoCheck struct {
|
||||
OK bool
|
||||
Code string // код причины отказа (M2Mxx или QTY/PLACE)
|
||||
Warn bool // пройдено с предупреждением
|
||||
Strict bool // предупреждение, которое автоматический режим считает отказом
|
||||
}
|
||||
|
||||
// depoSecResult — результат по одной бумаге.
|
||||
@@ -84,6 +85,7 @@ type depoParty struct {
|
||||
iisDet string
|
||||
qualOK bool
|
||||
qualWarn bool
|
||||
qualAll bool // квалифицирован по всем видам бумаг
|
||||
qualDet string
|
||||
}
|
||||
|
||||
@@ -109,7 +111,10 @@ func depoResolveParty(ctx context.Context, db *sql.DB, fs FancyMSSQLSettings, se
|
||||
num = strings.TrimSpace(num)
|
||||
if ser != "" && num != "" {
|
||||
e := db.QueryRowContext(ctx,
|
||||
`SELECT TOP 1 ID, ISNULL(NAME,''), DATE_BIRTH FROM OD_FACES WHERE REPLACE(ISNULL(DOC_SER,''),' ','')=@p1 AND DOC_NUM=@p2`,
|
||||
// Карточка лица в DEPO версионная: действующая версия (LAST_FLAG=1) даёт ФИО и дату рождения,
|
||||
// а счета и опции привязаны к исходной записи (SELF_ID).
|
||||
`SELECT TOP 1 ISNULL(NULLIF(SELF_ID,0),ID), ISNULL(NAME,''), DATE_BIRTH FROM OD_FACES WHERE REPLACE(ISNULL(DOC_SER,''),' ','')=@p1 AND DOC_NUM=@p2
|
||||
ORDER BY ISNULL(LAST_FLAG,0) DESC, ID DESC`,
|
||||
sql.Named("p1", ser), sql.Named("p2", num)).Scan(&p.faceID, &p.faceName, &p.birth)
|
||||
p.faceOK = e == nil
|
||||
}
|
||||
@@ -150,7 +155,7 @@ func depoResolveParty(ctx context.Context, db *sql.DB, fs FancyMSSQLSettings, se
|
||||
p.qualWarn, p.qualDet = true, "инвестор не найден — квал не проверялся"
|
||||
return p
|
||||
}
|
||||
p.qualWarn, p.qualDet = depoFaceQualified(ctx, db, p.faceID)
|
||||
p.qualWarn, p.qualAll, p.qualDet = depoFaceQualified(ctx, db, p.faceID)
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -169,8 +174,8 @@ var depoQualKinds = []string{
|
||||
|
||||
// depoFaceQualified — признак квалифицированного инвестора: действующая на сегодня опция лица
|
||||
// IS_QUAL («Вхождение в реестр лиц, признанных квалифицированными инвесторами»). Значение —
|
||||
// маска видов бумаг (511 и -1 — все виды, 0 — ни одного). Возвращает (предупреждение, пояснение).
|
||||
func depoFaceQualified(ctx context.Context, db *sql.DB, faceID int64) (bool, string) {
|
||||
// маска видов бумаг (511 и -1 — все виды, 0 — ни одного). Возвращает (предупреждение, по всем видам, пояснение).
|
||||
func depoFaceQualified(ctx context.Context, db *sql.DB, faceID int64) (warn, all bool, det string) {
|
||||
var val sql.NullString
|
||||
err := db.QueryRowContext(ctx, `SELECT TOP 1 CAST(o.VAL AS varchar(20)) FROM OD_OPTIONS o
|
||||
JOIN OP_FIELDS f ON f.ID = o.DESCR AND f.AS_NAME = 'IS_QUAL'
|
||||
@@ -178,21 +183,20 @@ func depoFaceQualified(ctx context.Context, db *sql.DB, faceID int64) (bool, str
|
||||
ORDER BY o.B_DATE DESC`, sql.Named("p1", faceID)).Scan(&val)
|
||||
const notQual = " — если бумага только для квал., нужен отказ M2M08; решает оператор"
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return true, "инвестор НЕ квалифицированный (нет опции «Вхождение в реестр квалифицированных инвесторов» в DEPO)" + notQual
|
||||
return true, false, "инвестор НЕ квалифицированный (нет опции «Вхождение в реестр квалифицированных инвесторов» в DEPO)" + notQual
|
||||
}
|
||||
if err != nil {
|
||||
return true, "признак квалифицированного инвестора не прочитан: " + quikErrorDetail(err)
|
||||
return true, false, "признак квалифицированного инвестора не прочитан: " + quikErrorDetail(err)
|
||||
}
|
||||
mask, e := strconv.ParseInt(strings.TrimSpace(val.String), 10, 64)
|
||||
if e != nil {
|
||||
return true, fmt.Sprintf("признак квалифицированного инвестора в DEPO не распознан (%q) — решает оператор", val.String)
|
||||
return true, false, fmt.Sprintf("признак квалифицированного инвестора в DEPO не распознан (%q) — решает оператор", val.String)
|
||||
}
|
||||
if mask == 0 {
|
||||
return true, "инвестор НЕ квалифицированный (опция в DEPO = 0)" + notQual
|
||||
return true, false, "инвестор НЕ квалифицированный (опция в DEPO = 0)" + notQual
|
||||
}
|
||||
all := mask < 0 || mask&511 == 511
|
||||
if all {
|
||||
return false, "инвестор — квалифицированный по всем видам бумаг (опция DEPO)"
|
||||
if mask < 0 || mask&511 == 511 {
|
||||
return false, true, "инвестор — квалифицированный по всем видам бумаг (опция DEPO)"
|
||||
}
|
||||
var kinds []string
|
||||
for bit, name := range depoQualKinds {
|
||||
@@ -200,7 +204,7 @@ func depoFaceQualified(ctx context.Context, db *sql.DB, faceID int64) (bool, str
|
||||
kinds = append(kinds, name)
|
||||
}
|
||||
}
|
||||
return false, "инвестор — квалифицированный (опция DEPO): " + strings.Join(kinds, "; ")
|
||||
return false, false, "инвестор — квалифицированный (опция DEPO): " + strings.Join(kinds, "; ")
|
||||
}
|
||||
|
||||
// depoAccountIIS — относится ли торговый счёт (OD_CLIENTS.ID) к договору ИИС: название договора
|
||||
@@ -248,6 +252,68 @@ func depoSecurity(ctx context.Context, db *sql.DB, isin string) (id int64, name
|
||||
return
|
||||
}
|
||||
|
||||
// depoSecurityQualOnly — бумага «Предназначена для квалифицированных инвесторов»: действующая на сегодня
|
||||
// опция бумаги RESTRICTED («Ограничение ценной бумаги по обращению», 1 — для квал. инвесторов).
|
||||
func depoSecurityQualOnly(ctx context.Context, db *sql.DB, valID int64) (bool, string) {
|
||||
var val sql.NullString
|
||||
err := db.QueryRowContext(ctx, `SELECT TOP 1 CAST(o.VAL AS varchar(20)) FROM OD_OPTIONS o
|
||||
JOIN OP_FIELDS f ON f.ID = o.DESCR AND f.AS_NAME = 'RESTRICTED'
|
||||
WHERE o.OBJECT = @p1 AND o.B_DATE <= GETDATE() AND (o.E_DATE IS NULL OR o.E_DATE > GETDATE())
|
||||
ORDER BY o.B_DATE DESC`, sql.Named("p1", valID)).Scan(&val)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return false, "ограничений по обращению нет (опция DEPO)"
|
||||
case err != nil:
|
||||
return false, "ограничение бумаги по обращению не прочитано: " + quikErrorDetail(err)
|
||||
case strings.TrimSpace(val.String) == "1":
|
||||
return true, "бумага предназначена для квалифицированных инвесторов (опция DEPO)"
|
||||
}
|
||||
return false, "ограничений по обращению нет (опция DEPO)"
|
||||
}
|
||||
|
||||
// qualOnlyCheck — ЗАЧИСЛЕНИЕ: бумага только для квал. инвесторов против квалификации инвестора у нас
|
||||
// (не квалифицирован — отказ M2M08; бумага без ограничений — квалификация не требуется).
|
||||
func qualOnlyCheck(found, qualOnly bool, qdet string, p depoParty) depoCheck {
|
||||
c := depoCheck{Name: "Бумага только для квал. инвесторов", OK: true, Code: "M2M08", Detail: qdet}
|
||||
switch {
|
||||
case !found:
|
||||
c.Detail = "не проверяется — бумаги нет в справочнике"
|
||||
case !qualOnly:
|
||||
c.Detail += " — квалификация клиента не требуется"
|
||||
case !p.faceOK:
|
||||
c.Detail += " — инвестор не найден, квалификация не проверялась"
|
||||
case p.qualAll:
|
||||
c.Detail += " — инвестор квалифицированный по всем видам бумаг"
|
||||
case p.qualWarn:
|
||||
c.OK = false
|
||||
c.Detail += " — инвестор НЕ квалифицированный: операция нарушит закон РФ"
|
||||
default:
|
||||
c.Warn, c.Strict = true, true
|
||||
c.Detail += " — " + p.qualDet + "; вид бумаги сверяет оператор (в автоматическом режиме — отказ M2M08)"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// outQualCheck — СПИСАНИЕ: бумага только для квал. инвесторов, а наш клиент у нас не квалифицирован —
|
||||
// предупреждение, не отказ: у принимающего депозитария клиент может быть квалифицирован, решает он.
|
||||
func outQualCheck(found, qualOnly bool, qdet string, p depoParty) depoCheck {
|
||||
c := depoCheck{Name: "Бумага только для квал. инвесторов", OK: true, Code: "M2M08", Detail: qdet}
|
||||
switch {
|
||||
case !found:
|
||||
c.Detail = "не проверяется — бумаги нет в справочнике"
|
||||
case !qualOnly:
|
||||
c.Detail += " — квалификация клиента не требуется"
|
||||
case !p.faceOK:
|
||||
c.Detail += " — инвестор не найден, квалификация не проверялась"
|
||||
case p.qualAll:
|
||||
c.Detail += " — клиент квалифицированный по всем видам бумаг"
|
||||
default:
|
||||
c.Warn = true
|
||||
c.Detail += " — у нас клиент не квалифицирован полностью (" + p.qualDet + "); принимающий депозитарий проверит квалификацию у себя и может отказать M2M08"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// depoBlocking — есть ли блокировка бумаги на счёте (BLOCK_DATE либо остаток на 98070.05/.07/.08/.09).
|
||||
func depoBlocking(ctx context.Context, db *sql.DB, valID, clientID int64) (ok bool, det string) {
|
||||
var blockDate sql.NullTime
|
||||
@@ -281,22 +347,35 @@ func finishSec(sr *depoSecResult) {
|
||||
}
|
||||
}
|
||||
|
||||
// finishResult — итог по переводу. Пакет принимается только целиком (решение 16.09.2026): если хотя
|
||||
// бы по одной бумаге отказ, отказ рекомендуется по всем бумагам с кодом первой отказной.
|
||||
func finishResult(res *depoResult) {
|
||||
confirmN, rejectN := 0, 0
|
||||
for _, s := range res.Secs {
|
||||
if s.Recommend == "confirm" {
|
||||
confirmN++
|
||||
} else {
|
||||
rejectN++
|
||||
first := -1
|
||||
for i, s := range res.Secs {
|
||||
if s.Recommend != "confirm" {
|
||||
first = i
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case rejectN == 0 && confirmN > 0:
|
||||
case len(res.Secs) == 0:
|
||||
case first < 0:
|
||||
res.Recommend, res.Summary = "confirm", "принять"
|
||||
case confirmN == 0 && rejectN > 0:
|
||||
res.Recommend, res.Summary = "reject", "отказ "+res.Secs[0].RejectCode
|
||||
case confirmN > 0 && rejectN > 0:
|
||||
res.Recommend, res.Summary = "mixed", fmt.Sprintf("принять %d, отказ %d", confirmN, rejectN)
|
||||
default:
|
||||
code, sec := res.Secs[first].RejectCode, res.Secs[first].Security
|
||||
passed := 0
|
||||
for i := range res.Secs {
|
||||
if res.Secs[i].Recommend == "confirm" {
|
||||
passed++
|
||||
res.Secs[i].Recommend, res.Secs[i].RejectCode = "reject", code
|
||||
res.Secs[i].Checks = append(res.Secs[i].Checks, depoCheck{Name: "Пакет принимается только целиком", OK: false, Code: code,
|
||||
Detail: "сама бумага проверку прошла; отказ всем пакетом из-за " + sec + " (" + code + ")"})
|
||||
}
|
||||
}
|
||||
res.Recommend, res.Summary = "reject", "отказ "+code
|
||||
if passed > 0 {
|
||||
res.Summary = fmt.Sprintf("отказ пакетом %s (из-за %s)", code, sec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,15 +383,18 @@ func finishResult(res *depoResult) {
|
||||
func partyChecks(p depoParty, ser, num, fio string) []depoCheck {
|
||||
ser = strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
|
||||
return []depoCheck{
|
||||
{Name: "ФИО + документ инвестора", OK: p.faceOK && p.fioMatch, Code: "M2M04",
|
||||
Detail: pick3(p.faceOK, pick3(p.fioMatch, p.faceName, "паспорт найден, но ФИО не совпадает: в картотеке «"+p.faceName+"», в запросе «"+fio+"»"),
|
||||
// Инвестор — наш клиент: лицо по паспорту с тем же ФИО И хотя бы один торговый счёт (договор).
|
||||
// Физлицо в картотеке без счёта у нас — не клиент депозитария: отказ M2M04, а не M2M02 (16.09.2026).
|
||||
{Name: "ФИО + документ инвестора", OK: p.faceOK && p.fioMatch && p.accAny != "", Code: "M2M04",
|
||||
Detail: pick3(p.faceOK, pick3(p.fioMatch,
|
||||
pick3(p.accAny != "", p.faceName, p.faceName+" — есть в картотеке лиц, но торгового счёта (договора) у нас нет: не наш клиент"),
|
||||
"паспорт найден, но ФИО не совпадает: в картотеке «"+p.faceName+"», в запросе «"+fio+"»"),
|
||||
"паспорт "+ser+" № "+num+" не найден в картотеке (OD_FACES)")},
|
||||
{Name: "Торговый счёт депо есть", OK: p.accAny != "", Code: "M2M02",
|
||||
Detail: pick3(p.accAny != "", p.accAny, pick3(p.faceOK, "у инвестора нет счёта ВТНКЦ… в OD_CLIENTS", "не проверяется — инвестор не найден"))},
|
||||
{Name: "Статус счёта строго «открыт»", OK: p.accOpen != "", Code: "M2M02",
|
||||
Detail: pick3(p.accAny == "", "не проверяется — счёта нет", pick3(p.accOpen != "", p.accOpen+" — открыт (STATE=0, без даты закрытия)", p.accAny+" — НЕ открыт (закрыт или STATE≠0)"))},
|
||||
{Name: "Счёт не ИИС", OK: p.iisOK, Code: "M2M08", Detail: p.iisDet},
|
||||
{Name: "Квал-инвестор (если бумага только для квал.)", OK: p.qualOK, Warn: p.qualWarn, Code: "M2M08", Detail: p.qualDet},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +434,11 @@ func runDepoCheck(it *IncomingTransfer) depoResult {
|
||||
Detail: pick3(found, vname+" (ID "+fmt.Sprint(valID)+")", "ISIN "+isin+" в справочнике OD_VALUES нет")})
|
||||
sr.Checks = append(sr.Checks, depoCheck{Name: "Принята на обслуживание (галка)", OK: accepted, Code: "M2M05",
|
||||
Detail: pick3(!found, "не проверяется — бумаги нет в справочнике", pick3(accepted, "галка стоит (IS_IN=1)", "бумага есть, но галка НЕ стоит (IS_IN≠1)"))})
|
||||
qualOnly, qdet := false, ""
|
||||
if found {
|
||||
qualOnly, qdet = depoSecurityQualOnly(ctx, db, valID)
|
||||
}
|
||||
sr.Checks = append(sr.Checks, qualOnlyCheck(found, qualOnly, qdet, p))
|
||||
sr.Checks = append(sr.Checks, partyChecks(p, it.DocSeries, it.DocNumber, fio)...)
|
||||
blockOK, blockDet := true, "не проверяется — нет открытого счёта или бумаги"
|
||||
if p.accOpen != "" && found {
|
||||
@@ -472,6 +559,11 @@ func runDepoCheckOutAccount(last, first, middle, ser, num, birth string, secs []
|
||||
valID, vname, found, _ := depoSecurity(ctx, db, isin)
|
||||
sr.Checks = append(sr.Checks, depoCheck{Name: "Бумага есть в справочнике", OK: found, Code: "M2M05",
|
||||
Detail: pick3(found, vname+" (ID "+fmt.Sprint(valID)+")", "ISIN "+isin+" в справочнике OD_VALUES нет")})
|
||||
qualOnly, qdet := false, ""
|
||||
if found {
|
||||
qualOnly, qdet = depoSecurityQualOnly(ctx, db, valID)
|
||||
}
|
||||
sr.Checks = append(sr.Checks, outQualCheck(found, qualOnly, qdet, p))
|
||||
sr.Checks = append(sr.Checks, partyChecks(p, ser, num, fio)...)
|
||||
if birthCheck != nil {
|
||||
sr.Checks = append(sr.Checks, *birthCheck)
|
||||
|
||||
@@ -3,7 +3,7 @@ package lkgateway
|
||||
import "testing"
|
||||
|
||||
func TestPartyChecksFIO(t *testing.T) {
|
||||
p := depoParty{faceOK: true, faceName: "Сёмин Пётр Ильич"}
|
||||
p := depoParty{faceOK: true, faceName: "Сёмин Пётр Ильич", accAny: "ВТНКЦ00000001"}
|
||||
p.fioMatch = normFIO(p.faceName) == normFIO("СЕМИН петр ильич")
|
||||
if c := partyChecks(p, "4500", "123456", "СЕМИН петр ильич")[0]; !c.OK {
|
||||
t.Errorf("регистр, пробелы и ё не должны давать расхождение: %+v", c)
|
||||
@@ -13,4 +13,15 @@ func TestPartyChecksFIO(t *testing.T) {
|
||||
if c.OK || c.Code != "M2M04" {
|
||||
t.Errorf("паспорт совпал, ФИО нет — ожидали отказ M2M04: %+v", c)
|
||||
}
|
||||
// Физлицо в картотеке без торгового счёта — не наш клиент: M2M04 первой проверкой.
|
||||
noAcc := depoParty{faceOK: true, faceName: "Баранкин Сергей Сергеевич", fioMatch: true}
|
||||
checks := partyChecks(noAcc, "3763", "290089", "Баранкин Сергей Сергеевич")
|
||||
if c := checks[0]; c.OK || c.Code != "M2M04" {
|
||||
t.Errorf("лицо без счёта — ожидали отказ M2M04: %+v", c)
|
||||
}
|
||||
sr := depoSecResult{Checks: checks}
|
||||
finishSec(&sr)
|
||||
if sr.RejectCode != "M2M04" {
|
||||
t.Errorf("итоговый код по бумаге %q, ожидали M2M04", sr.RejectCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Пакет принимается только целиком: отказ по одной бумаге — отказ по всем с тем же кодом.
|
||||
func TestFinishResultWholePackage(t *testing.T) {
|
||||
res := depoResult{Secs: []depoSecResult{
|
||||
{Security: "RU0009029540", Recommend: "confirm"},
|
||||
{Security: "RU0007661625", Recommend: "reject", RejectCode: "M2M05"},
|
||||
{Security: "RU000A0JUR61", Recommend: "confirm"},
|
||||
}}
|
||||
finishResult(&res)
|
||||
if res.Recommend != "reject" || !strings.Contains(res.Summary, "отказ пакетом M2M05") {
|
||||
t.Fatalf("итог: %s %q", res.Recommend, res.Summary)
|
||||
}
|
||||
for _, s := range res.Secs {
|
||||
if s.Recommend != "reject" || s.RejectCode != "M2M05" {
|
||||
t.Fatalf("бумага %s: %s %s", s.Security, s.Recommend, s.RejectCode)
|
||||
}
|
||||
}
|
||||
if last := res.Secs[0].Checks[len(res.Secs[0].Checks)-1]; last.OK || !strings.Contains(last.Detail, "RU0007661625") {
|
||||
t.Fatalf("пояснение отказа пакетом: %+v", last)
|
||||
}
|
||||
|
||||
ok := depoResult{Secs: []depoSecResult{{Recommend: "confirm"}, {Recommend: "confirm"}}}
|
||||
finishResult(&ok)
|
||||
if ok.Recommend != "confirm" {
|
||||
t.Fatalf("все бумаги прошли: %s", ok.Recommend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageDecisions(t *testing.T) {
|
||||
in := []SecurityDecision{{ReferenceID: "A", Confirm: true}, {ReferenceID: "B", RejectCode: "M2M08"}, {ReferenceID: "C", Confirm: true}}
|
||||
out := packageDecisions(in)
|
||||
for _, d := range out {
|
||||
if d.Confirm || d.RejectCode != "M2M08" {
|
||||
t.Fatalf("частичное согласие не должно уходить: %+v", out)
|
||||
}
|
||||
}
|
||||
if !in[0].Confirm {
|
||||
t.Fatal("исходный срез не должен меняться")
|
||||
}
|
||||
all := []SecurityDecision{{ReferenceID: "A", Confirm: true}, {ReferenceID: "B", Confirm: true}}
|
||||
if got := packageDecisions(all); !got[0].Confirm || !got[1].Confirm {
|
||||
t.Fatalf("согласие по всем: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualOnlyCheck(t *testing.T) {
|
||||
notQual := depoParty{faceOK: true, qualWarn: true, qualDet: "инвестор НЕ квалифицированный"}
|
||||
qualAll := depoParty{faceOK: true, qualAll: true}
|
||||
partial := depoParty{faceOK: true, qualDet: "инвестор — квалифицированный (опция DEPO): облигации"}
|
||||
const q = "бумага предназначена для квалифицированных инвесторов (опция DEPO)"
|
||||
|
||||
if c := qualOnlyCheck(true, true, q, notQual); c.OK || c.Code != "M2M08" {
|
||||
t.Fatalf("неквал + бумага для квал: %+v", c)
|
||||
}
|
||||
if c := qualOnlyCheck(true, true, q, qualAll); !c.OK || c.Warn {
|
||||
t.Fatalf("квал по всем видам: %+v", c)
|
||||
}
|
||||
if c := qualOnlyCheck(true, true, q, partial); !c.OK || !c.Warn {
|
||||
t.Fatalf("квал по части видов — решает оператор: %+v", c)
|
||||
}
|
||||
if c := qualOnlyCheck(true, false, "ограничений по обращению нет (опция DEPO)", notQual); !c.OK || c.Warn {
|
||||
t.Fatalf("бумага без ограничений: %+v", c)
|
||||
}
|
||||
if c := qualOnlyCheck(true, true, q, depoParty{}); !c.OK {
|
||||
t.Fatalf("инвестор не найден — отказ даёт M2M04, не эта проверка: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Списание: бумага для квал. инвесторов у неквалифицированного у нас клиента — предупреждение, не отказ.
|
||||
func TestOutQualCheck(t *testing.T) {
|
||||
const q = "бумага предназначена для квалифицированных инвесторов (опция DEPO)"
|
||||
notQual := depoParty{faceOK: true, qualWarn: true, qualDet: "инвестор НЕ квалифицированный"}
|
||||
if c := outQualCheck(true, true, q, notQual); !c.OK || !c.Warn || c.Strict {
|
||||
t.Fatalf("неквал у нас отправляет бумагу для квал: %+v", c)
|
||||
}
|
||||
if c := outQualCheck(true, true, q, depoParty{faceOK: true, qualAll: true}); !c.OK || c.Warn {
|
||||
t.Fatalf("квал по всем видам: %+v", c)
|
||||
}
|
||||
if c := outQualCheck(true, false, "ограничений по обращению нет (опция DEPO)", notQual); !c.OK || c.Warn {
|
||||
t.Fatalf("бумага без ограничений — зелёный: %+v", c)
|
||||
}
|
||||
for _, c := range partyChecks(notQual, "1", "2", "x") {
|
||||
if strings.HasPrefix(c.Name, "Квал") {
|
||||
t.Fatalf("общая жёлтая строка квалификации больше не нужна: %+v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,8 +87,8 @@ func registerDepoSim(mux *http.ServeMux, a *admin) {
|
||||
Mode, Last, First, Middle, Ser, Num, ISIN, Code, Qty, Account, Section string
|
||||
}{
|
||||
Mode: v("mode", "in"),
|
||||
Last: v("last", "Силаев"), First: v("first", "Михаил"), Middle: v("middle", "Иванович"),
|
||||
Ser: v("ser", "4505"), Num: v("num", "620343"),
|
||||
Last: v("last", "Иванов"), First: v("first", "Иван"), Middle: v("middle", "Иванович"),
|
||||
Ser: v("ser", "0000"), Num: v("num", "000000"),
|
||||
ISIN: strings.ToUpper(v("isin", "RU0009029540")), Code: v("code", ""), Qty: v("qty", "10"),
|
||||
Account: v("account", defAcc), Section: v("section", defSec),
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package lkgateway
|
||||
|
||||
// Обмен с DEPO (Fancy) через трансферные таблицы в нашей копии базы
|
||||
// (BJ_DEPO_DATA): FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS / FTV_M2M_HANDBOOK.
|
||||
// Согласовано с программистом Fancy 09–10.09.2026 (страница /admin/help/fancy-events):
|
||||
// - bj пишет поручения и события BJ_*; джоба bj2depo создаёт сущности DEPO и
|
||||
// пишет в ответ только DEPO_ORDER_CREATED / DEPO_ERROR;
|
||||
// - справочник участников — TRUNCATE + полная перезапись при каждом получении;
|
||||
// - STATE: TODO при записи → SUCCESS/ERROR после обработки получателем.
|
||||
// Согласовано с программистом Fancy (страница /admin/help/fancy-events), уточнено 17.09.2026:
|
||||
// - bj — мастер: пишет поручения и события BJ_*; джоба bj2depo создаёт сущности DEPO, событий от
|
||||
// себя не пишет — результат bj читает в FTV_M2M.DOC и в STATE/COMMENT своих событий;
|
||||
// - COMMENT и STATE заполняет только bj2depo (STATE по умолчанию TODO); параметры события bj пишет
|
||||
// в PARAMETERS; EVENT_DATE — фактическое время события в bj;
|
||||
// - справочник участников — TRUNCATE + полная перезапись при каждом получении.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -124,13 +125,15 @@ func depoTableExists(ctx context.Context, db *sql.DB, name string) bool {
|
||||
|
||||
var depoTablesWarned time.Time
|
||||
|
||||
// depoEvent — одна строка FTV_M2M_EVENTS (идемпотентно по GUID+REFERENCE+EVENT_TYPE), дата = сейчас.
|
||||
func depoEvent(rc *RuntimeConfig, guid, ref, typ, codes, comment string) {
|
||||
depoEventAt(rc, time.Time{}, guid, ref, typ, codes, comment)
|
||||
// depoEvent — одна строка FTV_M2M_EVENTS (идемпотентно по GUID+REFERENCE+EVENT_TYPE), время события — сейчас.
|
||||
// params — параметры события для bj2depo (PARAMETERS): номер поручения НРД, номер поручения МНП, текст причины отказа.
|
||||
func depoEvent(rc *RuntimeConfig, guid, ref, typ, codes, params string) {
|
||||
depoEventAt(rc, time.Now(), guid, ref, typ, codes, params)
|
||||
}
|
||||
|
||||
// depoEventAt — то же с явной датой события (для выгрузки истории).
|
||||
func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, comment string) {
|
||||
// depoEventAt — то же с явным временем события (выгрузка истории). COMMENT и STATE не пишем:
|
||||
// их заполняет bj2depo (STATE по умолчанию TODO).
|
||||
func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, params string) {
|
||||
guid = strings.ToUpper(strings.TrimSpace(guid))
|
||||
if !uuidRe.MatchString(guid) {
|
||||
return // тестовые/служебные идентификаторы в DEPO не отдаём
|
||||
@@ -149,8 +152,11 @@ func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, comment
|
||||
}
|
||||
return
|
||||
}
|
||||
if len([]rune(comment)) > 4000 {
|
||||
comment = string([]rune(comment)[:4000])
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
if len([]rune(params)) > 4000 {
|
||||
params = string([]rune(params)[:4000])
|
||||
}
|
||||
var n int
|
||||
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g AND ISNULL(REFERENCE,'')=@r AND EVENT_TYPE=@t",
|
||||
@@ -158,17 +164,18 @@ func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, comment
|
||||
if n > 0 {
|
||||
return
|
||||
}
|
||||
// Параметр даты — типизированный NULL: при нетипизированном nil драйвер шлёт nvarchar(1),
|
||||
// ISNULL берёт тип первого аргумента и GETDATE() обрезается до одного символа.
|
||||
evDate := sql.NullTime{Time: at, Valid: !at.IsZero()}
|
||||
_, err = db.ExecContext(ctx, `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE, COMMENT)
|
||||
VALUES (@g, @r, 'TODO', @t, COALESCE(@at, GETDATE()), @c, @cm)`,
|
||||
sql.Named("g", guid), sql.Named("r", nullStr(ref)), sql.Named("t", typ), sql.Named("at", evDate), sql.Named("c", nullStr(codes)), sql.Named("cm", nullStr(comment)))
|
||||
q := `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE) VALUES (@g, @r, @t, @at, @c)`
|
||||
if depoHasColumn(ctx, db, "FTV_M2M_EVENTS", "PARAMETERS") {
|
||||
q = `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE, PARAMETERS) VALUES (@g, @r, @t, @at, @c, @p)`
|
||||
}
|
||||
_, err = db.ExecContext(ctx, q,
|
||||
sql.Named("g", guid), sql.Named("r", nullStr(ref)), sql.Named("t", typ), sql.Named("at", at.In(time.FixedZone("МСК", 3*3600)).Format("2006-01-02T15:04:05.000")),
|
||||
sql.Named("c", nullStr(codes)), sql.Named("p", nullStr(params)))
|
||||
if err != nil {
|
||||
log.Printf("depo-events: %s %s/%s: %v", typ, shortGUID(guid), ref, err)
|
||||
return
|
||||
}
|
||||
log.Printf("depo-events: %s %s %s %s", typ, shortGUID(guid), ref, codes)
|
||||
log.Printf("depo-events: %s %s %s %s %s", typ, shortGUID(guid), ref, codes, params)
|
||||
}
|
||||
|
||||
type depoLine struct {
|
||||
@@ -206,15 +213,27 @@ func depoUpsertOrder(rc *RuntimeConfig, guid, direction, cpCode, deponentName, d
|
||||
if serN != "" && strings.TrimSpace(num) != "" {
|
||||
_ = db.QueryRowContext(ctx, "SELECT TOP 1 SELF_ID FROM OD_FACES WHERE REPLACE(DOC_SER,' ','')=@s AND DOC_NUM=@n ORDER BY ID", sql.Named("s", serN), sql.Named("n", strings.TrimSpace(num))).Scan(&deponentID)
|
||||
}
|
||||
// счёт клиента для DEPO_NUMBER строк: открытый торговый ВТНКЦ…, при нескольких договорах — самый
|
||||
// ранний (то же правило, что при зачислении в QUIK); джоба по номеру находит OD_CLIENTS.ID (DEPO).
|
||||
var clientAcc sql.NullString
|
||||
if serN != "" && strings.TrimSpace(num) != "" {
|
||||
_ = db.QueryRowContext(ctx, `SELECT TOP 1 c.NUMBER FROM OD_FACES f
|
||||
JOIN OD_CLIENTS c ON c.FACE=f.ID LEFT JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID
|
||||
WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL
|
||||
ORDER BY ISNULL(p.PORT_DATE,'2099-01-01'), ISNULL(c.D_OPEN,'2099-01-01'), c.NUMBER`,
|
||||
sql.Named("s", serN), sql.Named("n", strings.TrimSpace(num))).Scan(&clientAcc)
|
||||
}
|
||||
hasDepoNumber := depoHasColumn(ctx, db, "FTV_M2M_LINES", "DEPO_NUMBER")
|
||||
hasDeponentID := depoHasColumn(ctx, db, "FTV_M2M", "DEPONENT_ID")
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
log.Printf("depo-events: FTV_M2M %s: %v", shortGUID(guid), err)
|
||||
return false
|
||||
}
|
||||
head := `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, COMMENT) VALUES (@g, @d, @cp, @nm, @doc, @cm)`
|
||||
// COMMENT не пишем — его заполняет bj2depo (17.09.2026); comment остаётся только для журнала bj.
|
||||
head := `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC) VALUES (@g, @d, @cp, @nm, @doc)`
|
||||
if hasDeponentID {
|
||||
head = `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, DEPONENT_ID, COMMENT) VALUES (@g, @d, @cp, @nm, @doc, @did, @cm)`
|
||||
head = `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, DEPONENT_ID) VALUES (@g, @d, @cp, @nm, @doc, @did)`
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, head,
|
||||
sql.Named("g", guid), sql.Named("d", direction), sql.Named("cp", nullStr(cpCode)), sql.Named("nm", nullStr(deponentName)), sql.Named("doc", nullStr(deponentDoc)),
|
||||
@@ -230,12 +249,16 @@ func depoUpsertOrder(rc *RuntimeConfig, guid, direction, cpCode, deponentName, d
|
||||
if ln.ISIN != "" && hasShare {
|
||||
_ = db.QueryRowContext(ctx, "SELECT TOP 1 ID FROM OD_VALUES WHERE ISIN=@i ORDER BY ID", sql.Named("i", ln.ISIN)).Scan(&share)
|
||||
}
|
||||
q := `INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, AMOUNT, PLACE_NUMBER, LINK_NUMBER) VALUES (@g, @r, @i, @c, @a, @p, '')`
|
||||
cols, vals := "GUID, REFERENCE, ISIN, SEC_CODE, AMOUNT, PLACE_NUMBER, LINK_NUMBER", "@g, @r, @i, @c, @a, @p, ''"
|
||||
if hasShare {
|
||||
q = `INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, SHARE, AMOUNT, PLACE_NUMBER, LINK_NUMBER) VALUES (@g, @r, @i, @c, @sh, @a, @p, '')`
|
||||
cols, vals = cols+", SHARE", vals+", @sh"
|
||||
}
|
||||
if hasDepoNumber {
|
||||
cols, vals = cols+", DEPO_NUMBER", vals+", @acc"
|
||||
}
|
||||
q := `INSERT INTO dbo.FTV_M2M_LINES (` + cols + `) VALUES (` + vals + `)`
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
sql.Named("g", guid), sql.Named("r", ln.Ref), sql.Named("i", nullStr(ln.ISIN)), sql.Named("c", nullStr(ln.Code)), sql.Named("sh", share), sql.Named("a", ln.Amount), sql.Named("p", nullStr(ln.Place))); err != nil {
|
||||
sql.Named("g", guid), sql.Named("r", ln.Ref), sql.Named("i", nullStr(ln.ISIN)), sql.Named("c", nullStr(ln.Code)), sql.Named("sh", share), sql.Named("a", ln.Amount), sql.Named("p", nullStr(ln.Place)), sql.Named("acc", clientAcc)); err != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("depo-events: FTV_M2M_LINES %s/%s: %v", shortGUID(guid), ln.Ref, err)
|
||||
return false
|
||||
@@ -245,7 +268,7 @@ func depoUpsertOrder(rc *RuntimeConfig, guid, direction, cpCode, deponentName, d
|
||||
log.Printf("depo-events: commit %s: %v", shortGUID(guid), err)
|
||||
return false
|
||||
}
|
||||
log.Printf("depo-events: FTV_M2M %s %s создано (%d строк)", direction, shortGUID(guid), len(lines))
|
||||
log.Printf("depo-events: FTV_M2M %s %s создано (%d строк, счёт клиента %s)", direction, shortGUID(guid), len(lines), pickStr(clientAcc.String, "не найден"))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -295,7 +318,7 @@ func depoOnIncomingReceived(rc *RuntimeConfig, it *IncomingTransfer) {
|
||||
name := strings.TrimSpace(strings.Join([]string{it.InvestorLast, it.InvestorFirst, it.InvestorMiddle}, " "))
|
||||
doc := strings.TrimSpace(it.DocSeries + " " + it.DocNumber)
|
||||
if depoUpsertOrder(rc, it.GUID, "IN", it.SenderCode, name, doc, it.DocSeries, it.DocNumber, lines, "входящий перевод M2M от "+it.SenderCode) {
|
||||
depoEvent(rc, it.GUID, "", "BJ_RECEIVED", "", "входящий перевод получен, ждёт решения")
|
||||
depoEvent(rc, it.GUID, "", "BJ_RECEIVED", "", "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +343,7 @@ func depoOnOutgoingSent(rc *RuntimeConfig, req *m2m.M2MTransferRequest) {
|
||||
name := strings.TrimSpace(strings.Join([]string{inv.LastName, inv.FirstName, inv.MiddleName}, " "))
|
||||
guid := string(req.Header.GUID)
|
||||
if depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "исходящий перевод M2M, получатель ИНН "+inn) {
|
||||
depoEvent(rc, guid, "", "BJ_SENT", "", "запрос на перевод отправлен в НРД")
|
||||
depoEvent(rc, guid, "", "BJ_SENT", "", "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,9 +354,9 @@ func depoOnIncomingDecision(rc *RuntimeConfig, it *IncomingTransfer, decisions [
|
||||
}
|
||||
for _, d := range decisions {
|
||||
if d.Confirm {
|
||||
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём бумаги подтверждён контрагенту")
|
||||
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
|
||||
} else {
|
||||
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_REJECT", d.RejectCode, "отказ контрагенту: "+m2mCodeLabel(d.RejectCode))
|
||||
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_REJECT", d.RejectCode, m2mCodeLabel(d.RejectCode))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,33 +371,33 @@ func depoOnCounterpartyDecision(rc *RuntimeConfig, decision *m2m.M2MTransferDeci
|
||||
ref := string(sec.ReferenceID)
|
||||
if sec.TransferDecision.Rejection != nil {
|
||||
codes := strings.Join(sec.TransferDecision.Rejection.Codes, ";")
|
||||
depoEvent(rc, guid, ref, "BJ_CP_REJECT", codes, "отказ принимающего депозитария: "+rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
|
||||
depoEvent(rc, guid, ref, "BJ_CP_REJECT", codes, rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
|
||||
} else if sec.TransferDecision.Confirmation != nil {
|
||||
depoEvent(rc, guid, ref, "BJ_CP_CONFIRM", "", "принимающий депозитарий подтвердил")
|
||||
depoEvent(rc, guid, ref, "BJ_CP_CONFIRM", "", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func depoOnServiceError(rc *RuntimeConfig, guid, codes, reason string) {
|
||||
depoEvent(rc, guid, "", "BJ_SERVICE_ERROR", strings.ReplaceAll(strings.TrimSpace(codes), " ", ";"), "отклонено сервисом MOEX МОСТ: "+reason)
|
||||
depoEvent(rc, guid, "", "BJ_SERVICE_ERROR", strings.ReplaceAll(strings.TrimSpace(codes), " ", ";"), reason)
|
||||
}
|
||||
|
||||
func depoOnOrderSent(rc *RuntimeConfig, guid string, contrDIDs []string, form string) {
|
||||
depoEvent(rc, guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение "+form+" в НРД, CONTR_D_ID "+strings.Join(contrDIDs, ", "))
|
||||
depoEvent(rc, guid, "", "BJ_ORDER_SENT", "", strings.Join(contrDIDs, ";"))
|
||||
}
|
||||
|
||||
// результат по депо-поручению: accepted | rejected | late | expired
|
||||
func depoOnOrderResult(rc *RuntimeConfig, guid, kind, text string) {
|
||||
switch kind {
|
||||
case "accepted":
|
||||
depoEvent(rc, guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)")
|
||||
depoEvent(rc, guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)")
|
||||
depoEvent(rc, guid, "", "BJ_ORDER_ACCEPTED", "", "")
|
||||
depoEvent(rc, guid, "", "BJ_EXECUTED", "", "")
|
||||
case "rejected":
|
||||
depoEvent(rc, guid, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text)
|
||||
depoEvent(rc, guid, "", "BJ_ORDER_REJECTED", "", text)
|
||||
case "late":
|
||||
depoEvent(rc, guid, "", "BJ_ORDER_LATE", "", "отчёт MS010 не получен за 5 минут")
|
||||
depoEvent(rc, guid, "", "BJ_ORDER_LATE", "", "")
|
||||
case "expired":
|
||||
depoEvent(rc, guid, "", "BJ_CANCELLED", "", "истёк срок 30 дней без исполнения — перевод считается отменённым")
|
||||
depoEvent(rc, guid, "", "BJ_CANCELLED", "", "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +406,7 @@ func depoOnQuik(rc *RuntimeConfig, guid, isin, evt, instrNo string) {
|
||||
if typ == "" {
|
||||
return
|
||||
}
|
||||
depoEvent(rc, guid, "", typ, "", "QUIK МНП: поручение №"+instrNo+", "+isin)
|
||||
depoEvent(rc, guid, "", typ, "", instrNo)
|
||||
}
|
||||
|
||||
// ---- чтение событий DEPO ----
|
||||
@@ -399,55 +422,10 @@ func depoPollEvents(ctx context.Context, rc *RuntimeConfig) {
|
||||
if !depoTableExists(cctx, db, "FTV_M2M_EVENTS") {
|
||||
return
|
||||
}
|
||||
docCol := "NULL"
|
||||
if depoHasColumn(cctx, db, "FTV_M2M", "DOC") {
|
||||
docCol = "(SELECT TOP 1 CAST(m.DOC AS varchar(32)) FROM dbo.FTV_M2M m WHERE m.GUID=e.GUID)"
|
||||
} else if depoHasColumn(cctx, db, "FTV_M2M", "FANSY_DOC") {
|
||||
docCol = "(SELECT TOP 1 CAST(m.FANSY_DOC AS varchar(32)) FROM dbo.FTV_M2M m WHERE m.GUID=e.GUID)"
|
||||
}
|
||||
// Событий от bj2depo нет (17.09.2026: мастер — bj): номер документа — FTV_M2M.DOC, результат
|
||||
// обработки — STATE/COMMENT наших событий и шапки.
|
||||
depoPollOrders(cctx, db, rc)
|
||||
depoAlertEventFailures(cctx, db, rc)
|
||||
rows, err := db.QueryContext(cctx, `SELECT TOP 100 e.ID, CAST(e.GUID AS varchar(36)), ISNULL(e.REFERENCE,''), e.EVENT_TYPE, ISNULL(e.COMMENT,''), ISNULL(e.M2M_REJECT_CODE,''),
|
||||
ISNULL(`+docCol+`,'')
|
||||
FROM dbo.FTV_M2M_EVENTS e WHERE e.EVENT_TYPE LIKE 'DEPO[_]%' AND ISNULL(e.STATE,'TODO')='TODO' ORDER BY e.ID`)
|
||||
if err != nil {
|
||||
log.Printf("depo-events: чтение: %v", err)
|
||||
return
|
||||
}
|
||||
type ev struct {
|
||||
id int64
|
||||
guid, ref, typ, comment, codes, doc string
|
||||
}
|
||||
var list []ev
|
||||
for rows.Next() {
|
||||
var e ev
|
||||
if rows.Scan(&e.id, &e.guid, &e.ref, &e.typ, &e.comment, &e.codes, &e.doc) == nil {
|
||||
list = append(list, e)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, e := range list {
|
||||
state, note := "SUCCESS", "применено в bj"
|
||||
switch e.typ {
|
||||
case "DEPO_ORDER_CREATED":
|
||||
doc := strings.TrimSpace(e.comment)
|
||||
if e.doc != "" && e.doc != "0" {
|
||||
doc = e.doc
|
||||
}
|
||||
globalDepoLinks.update(e.guid, func(l *DepoLink) { l.DocNo, l.DocAt, l.LastEvent = doc, time.Now(), e.typ })
|
||||
log.Printf("depo-events: DEPO создала поручение по %s: %s", shortGUID(e.guid), doc)
|
||||
case "DEPO_ERROR":
|
||||
globalDepoLinks.update(e.guid, func(l *DepoLink) {
|
||||
l.Errors = append(l.Errors, time.Now().Format("02.01 15:04")+" "+e.comment)
|
||||
l.LastEvent = e.typ
|
||||
})
|
||||
raiseSystemAlert(rc, "DEPO: ошибка обработки перевода "+shortGUID(e.guid), e.comment)
|
||||
default:
|
||||
state, note = "ERROR", "тип события не поддерживается bj"
|
||||
}
|
||||
_, _ = db.ExecContext(cctx, "UPDATE dbo.FTV_M2M_EVENTS SET STATE=@s, TimeBeg=ISNULL(TimeBeg,GETDATE()), TimeEnd=GETDATE(), COMMENT=ISNULL(COMMENT,'')+@n WHERE ID=@id",
|
||||
sql.Named("s", state), sql.Named("n", " | bj: "+note), sql.Named("id", e.id))
|
||||
}
|
||||
}
|
||||
|
||||
// depoPollOrders — джоба bj2depo ставит номер поручения DEPO прямо в FTV_M2M.DOC (и DOL по строкам),
|
||||
@@ -730,7 +708,7 @@ ALTER ROLE SQLAgentOperatorRole ADD MEMBER %[1]s;
|
||||
|
||||
func depoJobPing(ctx context.Context, db *sql.DB, wait time.Duration) (bool, string) {
|
||||
guid := strings.ToUpper(fmt.Sprintf("%08x-0000-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF))
|
||||
if _, err := db.ExecContext(ctx, "INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, COMMENT) VALUES (@g, NULL, 'TODO', 'BJ_PING', GETDATE(), N'проверка обработчика из bj')", sql.Named("g", guid)); err != nil {
|
||||
if _, err := db.ExecContext(ctx, "INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE) VALUES (@g, NULL, 'BJ_PING', GETDATE())", sql.Named("g", guid)); err != nil {
|
||||
return false, "не удалось записать проверочное событие: " + quikErrorDetail(err)
|
||||
}
|
||||
defer db.ExecContext(context.Background(), "DELETE FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g", sql.Named("g", guid))
|
||||
@@ -883,27 +861,27 @@ func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onl
|
||||
cp := handbookNameByCode(it.SenderCode)
|
||||
depoUpsertOrder(rc, it.GUID, "IN", it.SenderCode, name, strings.TrimSpace(it.DocSeries+" "+it.DocNumber), it.DocSeries, it.DocNumber, lines, "TEST3: входящий от "+it.SenderCode+" "+cp)
|
||||
nIn++
|
||||
ev(it.ReceivedAt, it.GUID, "", "BJ_RECEIVED", "", "входящий перевод получен от "+it.SenderCode+" "+cp)
|
||||
ev(it.ReceivedAt, it.GUID, "", "BJ_RECEIVED", "", "")
|
||||
if it.Status == "answered" {
|
||||
for _, s := range it.Securities {
|
||||
switch s.Decision {
|
||||
case "confirmed":
|
||||
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём подтверждён контрагенту")
|
||||
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
|
||||
case "rejected":
|
||||
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, "отказ контрагенту: "+m2mCodeLabel(s.RejectCode))
|
||||
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, m2mCodeLabel(s.RejectCode))
|
||||
}
|
||||
}
|
||||
}
|
||||
if it.OrderSubmitted {
|
||||
ev(it.OrderAt, it.GUID, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16/1 (зачисление), CONTR_D_ID "+it.OrderContrDID)
|
||||
ev(it.OrderAt, it.GUID, "", "BJ_ORDER_SENT", "", it.OrderContrDID)
|
||||
resps := findOrderResponses(it.OrderContrDID)
|
||||
if done, rejected, text := orderRespVerdict(resps); done {
|
||||
at := orderRespAt(resps)
|
||||
if rejected {
|
||||
ev(at, it.GUID, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text)
|
||||
ev(at, it.GUID, "", "BJ_ORDER_REJECTED", "", text)
|
||||
} else {
|
||||
ev(at, it.GUID, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)")
|
||||
ev(at, it.GUID, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)")
|
||||
ev(at, it.GUID, "", "BJ_ORDER_ACCEPTED", "", "")
|
||||
ev(at, it.GUID, "", "BJ_EXECUTED", "", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -942,14 +920,14 @@ func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onl
|
||||
name := strings.TrimSpace(strings.Join([]string{inv.LastName, inv.FirstName, inv.MiddleName}, " "))
|
||||
depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "TEST3: исходящий, получатель "+cp+" "+handbookNameByCode(cp))
|
||||
nOut++
|
||||
ev(d.CreatedAt, guid, "", "BJ_SENT", "", "запрос на перевод отправлен в НРД")
|
||||
ev(d.CreatedAt, guid, "", "BJ_SENT", "", "")
|
||||
if d.Decision != nil {
|
||||
for _, sec := range d.Decision.Data.Securities {
|
||||
ref := string(sec.ReferenceID)
|
||||
if sec.TransferDecision.Rejection != nil {
|
||||
ev(d.UpdatedAt, guid, ref, "BJ_CP_REJECT", strings.Join(sec.TransferDecision.Rejection.Codes, ";"), "отказ принимающего депозитария: "+rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
|
||||
ev(d.UpdatedAt, guid, ref, "BJ_CP_REJECT", strings.Join(sec.TransferDecision.Rejection.Codes, ";"), rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
|
||||
} else if sec.TransferDecision.Confirmation != nil {
|
||||
ev(d.UpdatedAt, guid, ref, "BJ_CP_CONFIRM", "", "принимающий депозитарий подтвердил")
|
||||
ev(d.UpdatedAt, guid, ref, "BJ_CP_CONFIRM", "", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -958,19 +936,19 @@ func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onl
|
||||
for _, rr := range d.Response.Responses {
|
||||
codes = append(codes, string(rr.Code))
|
||||
}
|
||||
ev(d.UpdatedAt, guid, "", "BJ_SERVICE_ERROR", strings.Join(codes, ";"), "отклонено сервисом MOEX МОСТ: "+rejectCodesLabel(codes))
|
||||
ev(d.UpdatedAt, guid, "", "BJ_SERVICE_ERROR", strings.Join(codes, ";"), rejectCodesLabel(codes))
|
||||
}
|
||||
if globalOutgoingStore != nil {
|
||||
if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok && od.Submitted {
|
||||
ev(od.OrderAt, guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16 (списание), CONTR_D_ID "+strings.Join(od.ContrDIDs, ", "))
|
||||
ev(od.OrderAt, guid, "", "BJ_ORDER_SENT", "", strings.Join(od.ContrDIDs, ";"))
|
||||
resps, _ := outgoingResponses(od.ContrDIDs)
|
||||
if done, rejected, text := orderRespVerdict(resps); done {
|
||||
at := orderRespAt(resps)
|
||||
if rejected {
|
||||
ev(at, guid, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text)
|
||||
ev(at, guid, "", "BJ_ORDER_REJECTED", "", text)
|
||||
} else {
|
||||
ev(at, guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)")
|
||||
ev(at, guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)")
|
||||
ev(at, guid, "", "BJ_ORDER_ACCEPTED", "", "")
|
||||
ev(at, guid, "", "BJ_EXECUTED", "", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1013,24 +991,24 @@ func depoSimulateIncoming(ctx context.Context, rc *RuntimeConfig, it *IncomingTr
|
||||
return "", fmt.Errorf("не удалось записать шапку в FTV_M2M")
|
||||
}
|
||||
now := time.Now()
|
||||
depoEventAt(rc, now, guid, "", "BJ_RECEIVED", "", "входящий перевод получен от "+it.SenderCode+" "+cp+" (тест по образцу "+shortGUID(it.GUID)+")")
|
||||
depoEventAt(rc, now, guid, "", "BJ_RECEIVED", "", "")
|
||||
anyConfirmed := false
|
||||
for _, s := range it.Securities {
|
||||
switch s.Decision {
|
||||
case "confirmed":
|
||||
anyConfirmed = true
|
||||
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём подтверждён контрагенту")
|
||||
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
|
||||
case "rejected":
|
||||
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, "отказ контрагенту: "+m2mCodeLabel(s.RejectCode))
|
||||
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, m2mCodeLabel(s.RejectCode))
|
||||
default:
|
||||
anyConfirmed = true
|
||||
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём подтверждён контрагенту (тест)")
|
||||
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
|
||||
}
|
||||
}
|
||||
if anyConfirmed {
|
||||
depoEventAt(rc, now.Add(2*time.Minute), guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16/1 (зачисление) в НРД (тест)")
|
||||
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010) (тест)")
|
||||
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010) (тест)")
|
||||
depoEventAt(rc, now.Add(2*time.Minute), guid, "", "BJ_ORDER_SENT", "", "")
|
||||
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_ORDER_ACCEPTED", "", "")
|
||||
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_EXECUTED", "", "")
|
||||
}
|
||||
return guid, nil
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestDepoQualifiedLive(t *testing.T) {
|
||||
if rows.Scan(&v, &face) != nil {
|
||||
continue
|
||||
}
|
||||
warn, det := depoFaceQualified(ctx, db, face)
|
||||
warn, _, det := depoFaceQualified(ctx, db, face)
|
||||
t.Logf("VAL=%s лицо %d: предупреждение=%v — %s", v, face, warn, det)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build live
|
||||
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Вставка события в новом формате в транзакции с откатом: EVENT_DATE из bj, PARAMETERS, STATE по умолчанию.
|
||||
func TestEventInsertRollback(t *testing.T) {
|
||||
rc, _ := NewRuntimeConfig("/var/lib/bj/.bj/setup.json")
|
||||
db, err := depoEventsDB(rc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
at := time.Date(2026, 9, 17, 9, 22, 24, 0, time.FixedZone("МСК", 3*3600))
|
||||
guid := "00000000-0000-4000-8000-000000000017"
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE, PARAMETERS) VALUES (@g, @r, @t, @at, @c, @p)`,
|
||||
sql.Named("g", guid), sql.Named("r", nullStr("")), sql.Named("t", "BJ_ORDER_SENT"), sql.Named("at", at.In(time.FixedZone("МСК", 3*3600)).Format("2006-01-02T15:04:05.000")),
|
||||
sql.Named("c", nullStr("")), sql.Named("p", nullStr("20608")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var st, dt, par, cmt sql.NullString
|
||||
err = tx.QueryRowContext(ctx, `SELECT STATE, CONVERT(varchar(23), EVENT_DATE, 121), CAST(PARAMETERS AS nvarchar(100)), COMMENT FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g`, sql.Named("g", guid)).Scan(&st, &dt, &par, &cmt)
|
||||
t.Logf("STATE=%q EVENT_DATE=%q PARAMETERS=%q COMMENT=%v (err %v) — откат", st.String, dt.String, par.String, cmt.Valid, err)
|
||||
}
|
||||
@@ -269,7 +269,7 @@ func compareNote(va, vb ClaimView, identical bool) []byte {
|
||||
b.WriteString("response.xml/decision.xml (ответ/решение НРД), а также отчёт otchet.html.\n")
|
||||
b.WriteString("Все XML — в исходной кодировке windows-1251, дословно.\n\n")
|
||||
b.WriteString("Просьба: пояснить причину изменения результата и порядок устранения\n")
|
||||
b.WriteString("(в т.ч. если это связано с учётом тестовых ценных бумаг на стороне НРД).\n")
|
||||
b.WriteString("(в т.ч. если это связано с особенностями учёта ценных бумаг на стороне НРД).\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package lkgateway
|
||||
|
||||
// Первичная настройка после установки. Пока в bj нет ни одного пользователя, установщик
|
||||
// (cmd/bj-installer) оставляет файл с хешем одноразового кода, который показан на экране сервера.
|
||||
// Все страницы, кроме этой, перенаправляются сюда: по коду создаётся первый администратор, файл
|
||||
// удаляется, дальше обычный вход и раздел «Настройка». Установки без файла работают как раньше
|
||||
// (открытый режим до создания первого пользователя).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const firstRunFile = "/var/lib/bj/.bj/first-run.json"
|
||||
|
||||
type firstRunState struct {
|
||||
CodeHash string `json:"code_hash"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// firstRunPending — ждём первичной настройки: пользователей нет, установщик оставил код.
|
||||
func firstRunPending() bool {
|
||||
if globalUsers.Count() > 0 {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(firstRunFile)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
type firstRunData struct {
|
||||
Error, Login, Name string
|
||||
}
|
||||
|
||||
var firstRunTpl = template.Must(template.New("first-run").Parse(firstRunHTML))
|
||||
|
||||
func registerFirstRun(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/first-run", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !firstRunPending() {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var d firstRunData
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method == http.MethodPost {
|
||||
ip := clientIP(r)
|
||||
key := "first-run|" + ip
|
||||
d.Login, d.Name = strings.TrimSpace(r.FormValue("login")), strings.TrimSpace(r.FormValue("name"))
|
||||
pw, pw2 := r.FormValue("password"), r.FormValue("password2")
|
||||
code := strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(r.FormValue("code")))
|
||||
var st firstRunState
|
||||
raw, readErr := os.ReadFile(firstRunFile)
|
||||
if readErr == nil {
|
||||
_ = json.Unmarshal(raw, &st)
|
||||
}
|
||||
switch {
|
||||
case loginFailCount(key) >= 8:
|
||||
d.Error = "Слишком много неудачных попыток. Подождите 15 минут."
|
||||
case readErr != nil || st.CodeHash == "" || bcrypt.CompareHashAndPassword([]byte(st.CodeHash), []byte(code)) != nil:
|
||||
loginFailAdd(key)
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
log.Printf("auth: первичная настройка: неверный код с %s", ip)
|
||||
d.Error = "Неверный одноразовый код"
|
||||
case d.Login == "" || strings.ContainsAny(d.Login, " /\\@\"'<>"):
|
||||
d.Error = "Логин пустой или содержит недопустимые символы"
|
||||
case len(pw) < 10 || pw != pw2:
|
||||
d.Error = "Пароль не короче 10 символов и должен совпадать с повтором"
|
||||
default:
|
||||
hash, err := hashPassword(pw)
|
||||
if err != nil {
|
||||
d.Error = err.Error()
|
||||
break
|
||||
}
|
||||
now := time.Now()
|
||||
u := &User{Login: d.Login, Name: d.Name, Role: "admin", Source: "local", PassHash: hash, Active: true, CreatedAt: now, LastLogin: now}
|
||||
if err := globalUsers.Put(u); err != nil {
|
||||
d.Error = "Не удалось сохранить пользователя: " + err.Error()
|
||||
break
|
||||
}
|
||||
_ = os.Remove(firstRunFile)
|
||||
loginFailReset(key)
|
||||
s := globalSessions.New(u.Login, ip, r.UserAgent())
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: s.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, MaxAge: int(sessionMaxLife.Seconds())})
|
||||
log.Printf("auth: первичная настройка: создан администратор %s с %s", u.Login, ip)
|
||||
http.Redirect(w, r, "/admin/setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
_ = firstRunTpl.Execute(w, d)
|
||||
})
|
||||
}
|
||||
|
||||
const firstRunHTML = `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Первичная настройка · Bridge&Join</title>
|
||||
<style>
|
||||
:root{--bg:#f4f5f9;--card:#fff;--text:#1a1d26;--muted:#6b7280;--accent:#3b4fd8;--err:#c62828;--border:#e3e5ee}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#141721;--card:#1d2130;--text:#e6e8f0;--muted:#9aa0b3;--accent:#7c8cff;--border:#2b3044}}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px}
|
||||
.box{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:28px 30px;width:100%;max-width:420px;box-shadow:0 12px 40px rgba(0,0,0,.08)}
|
||||
.brand{display:flex;align-items:center;gap:10px;font-size:20px;font-weight:700;margin-bottom:4px}.brand em{font-style:normal;color:var(--accent)}
|
||||
.sub{color:var(--muted);font-size:12.5px;margin-bottom:14px}
|
||||
label{display:block;font-size:12px;color:var(--muted);margin:10px 0 4px}
|
||||
input{width:100%;box-sizing:border-box;padding:9px 11px;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--text);font-size:14px}
|
||||
input:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent}
|
||||
input.code{font-size:20px;letter-spacing:.12em;text-align:center}
|
||||
button{margin-top:16px;width:100%;padding:10px;border:0;border-radius:8px;background:var(--accent);color:#fff;font-weight:600;font-size:14px;cursor:pointer}
|
||||
.err{background:rgba(198,40,40,.1);color:var(--err);padding:8px 10px;border-radius:8px;font-size:13px;margin-top:12px}
|
||||
.hint{color:var(--muted);font-size:12px;margin-top:12px}
|
||||
code{font-size:12px}
|
||||
</style></head><body><div class="box">
|
||||
<div class="brand"><svg width="26" height="26" viewBox="0 0 26 26" fill="none"><path d="M5.5 16 C 9 8.5, 17 8.5, 20.5 16" stroke="#3b4fd8" stroke-width="1.9" stroke-linecap="round"/><circle cx="5.5" cy="16" r="3.1" fill="#3b4fd8"/><circle cx="20.5" cy="16" r="3.1" fill="#e8562a"/></svg><span>Bridge<em>&Join</em></span></div>
|
||||
<div class="sub">Первичная настройка</div>
|
||||
<p class="hint" style="margin-top:0">Введите одноразовый код, который установщик показал на экране сервера, и создайте учётную запись администратора. Остальные настройки откроются сразу после этого.</p>
|
||||
<form method="post" action="/first-run">
|
||||
<label>Одноразовый код</label><input class="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="0000-0000" autofocus required>
|
||||
<label>Логин администратора</label><input type="text" name="login" value="{{.Login}}" autocomplete="username" required>
|
||||
<label>Имя</label><input type="text" name="name" value="{{.Name}}" autocomplete="name">
|
||||
<label>Пароль, не короче 10 символов</label><input type="password" name="password" minlength="10" autocomplete="new-password" required>
|
||||
<label>Повтор пароля</label><input type="password" name="password2" minlength="10" autocomplete="new-password" required>
|
||||
<button type="submit">Создать администратора</button>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
<div class="hint">Код потерян или истёк: на сервере выполните <code>sudo bj-installer first-run-code</code>.</div>
|
||||
</form>
|
||||
</div></body></html>`
|
||||
@@ -36,6 +36,9 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions
|
||||
if !ok {
|
||||
return fmt.Errorf("входящий перевод %s не найден", id)
|
||||
}
|
||||
if it.Status == "answered" {
|
||||
return fmt.Errorf("по переводу уже отправлено решение (%s)", it.AnswerNote)
|
||||
}
|
||||
if deps.igwClient == nil || deps.igwChan == "" {
|
||||
return fmt.Errorf("ИШ не настроен — нельзя отправить решение")
|
||||
}
|
||||
@@ -47,6 +50,8 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions
|
||||
}
|
||||
nsd := cfg.NSD
|
||||
|
||||
decisions = packageDecisions(decisions)
|
||||
|
||||
// Карта решений по ReferenceID.
|
||||
byRef := make(map[string]SecurityDecision, len(decisions))
|
||||
for _, d := range decisions {
|
||||
@@ -146,8 +151,36 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions
|
||||
return nil
|
||||
}
|
||||
|
||||
// transferringINN — ИНН нашей организации (принимающий депозитарий в решении).
|
||||
// ИНН ООО ИК «Фонтвьель» (участник M2M MC0413600000).
|
||||
func transferringINN(_ Settings) string {
|
||||
return "7703807489"
|
||||
// transferringINN — ИНН нашей организации (передающий депозитарий в запросе, принимающий — в решении).
|
||||
func transferringINN(s Settings) string { return orgINN(s) }
|
||||
|
||||
// packageDecisions — пакет принимается только целиком (решение 16.09.2026): если хотя бы по одной бумаге
|
||||
// отказ, отказываем по всем; принятым бумагам ставится код первой отказной.
|
||||
func packageDecisions(decisions []SecurityDecision) []SecurityDecision {
|
||||
code := ""
|
||||
for _, d := range decisions {
|
||||
if !d.Confirm {
|
||||
code = d.RejectCode
|
||||
break
|
||||
}
|
||||
}
|
||||
if code == "" {
|
||||
for _, d := range decisions {
|
||||
if !d.Confirm {
|
||||
code = "M2M05"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if code == "" {
|
||||
return decisions
|
||||
}
|
||||
out := make([]SecurityDecision, len(decisions))
|
||||
for i, d := range decisions {
|
||||
if d.Confirm {
|
||||
d.Confirm, d.RejectCode = false, code
|
||||
}
|
||||
out[i] = d
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func renderIncoming(t *testing.T, d incomingDetailData) string {
|
||||
t.Helper()
|
||||
a, err := newAdmin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
d.page = nowPage("Входящий перевод", "incoming")
|
||||
render(rec, a.incomingDetail, d)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("код %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
// Отклонённый перевод: 16/1 не требуется, операция завершена, без кнопок подачи и «черновик получен».
|
||||
func TestIncomingDetailRejected(t *testing.T) {
|
||||
it := &IncomingTransfer{ID: "x", GUID: "9F5E120D-EFD1-46EE-A9BA-35CB2E794B81", Status: "answered", Auto: true, AnsweredAt: time.Now(),
|
||||
Securities: []IncomingSecurity{{ReferenceID: "R1", SecurityCode: "SBER/03", Decision: "rejected", RejectCode: "M2M04"}}}
|
||||
rejected, codes := incomingRejectedAll(it)
|
||||
if !rejected || codes != "M2M04" {
|
||||
t.Fatalf("incomingRejectedAll: %v %q", rejected, codes)
|
||||
}
|
||||
body := renderIncoming(t, incomingDetailData{It: it, Rejected: rejected, RejectCodes: codes, AutoMode: true})
|
||||
for _, want := range []string{"не требуется — перевод отклонён", "Операция завершена отказом", "автоматически", "Зачисление в QUIK не требуется"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("нет %q", want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"Подать поручение 16/1 в НРД", "Черновик от НРД", "нужно подать"} {
|
||||
if strings.Contains(body, bad) {
|
||||
t.Errorf("лишнее в завершённом переводе: %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Перевод ждёт решения: шаги 16/1 не отмечены выполненными.
|
||||
func TestIncomingDetailPending(t *testing.T) {
|
||||
it := &IncomingTransfer{ID: "y", GUID: "G", Status: "pending", Securities: []IncomingSecurity{{ReferenceID: "R1", SecurityCode: "SBER/03"}}}
|
||||
body := renderIncoming(t, incomingDetailData{It: it})
|
||||
if !strings.Contains(body, "после нашего согласия") || strings.Contains(body, "Черновик от НРД") || strings.Contains(body, "Подать поручение 16/1 в НРД") {
|
||||
t.Error("ожидающий перевод не должен показывать полученный черновик и кнопку подачи")
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,8 @@ func exportIncoming(w http.ResponseWriter, r *http.Request, deps incomingDeps, i
|
||||
}
|
||||
}
|
||||
// Ответ НРД по поручению (отчёт MS010 + квитанции) — сырые файлы из INBOX.
|
||||
for _, rf := range collectOrderResponseRaw(it.OrderContrDID) {
|
||||
if f, e := zw.Create("otvet-nrd/" + rf.Name); e == nil {
|
||||
_, _ = f.Write(rf.Data)
|
||||
}
|
||||
if docs := nsdOrderDocs(it.OrderContrDID); len(docs) > 0 {
|
||||
writeNSDDocsZip(zw, "Ответ НРД/", "Документы НРД по поручению 16/1 (зачисление) № "+it.OrderContrDID, docs, true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,20 +176,11 @@ func registerIncoming(mux *http.ServeMux, a *admin, deps incomingDeps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
data := struct {
|
||||
page
|
||||
It *IncomingTransfer
|
||||
Flash string
|
||||
OrderResponses []orderResponse
|
||||
OrderDone bool
|
||||
OrderDoneAt string
|
||||
DraftReceived string
|
||||
Depo depoResult // сверка с DEPO — эмуляция ответа, ничего не отправляется
|
||||
Quik []*QuikLink
|
||||
QuikOn bool
|
||||
Watch *OrderWatch
|
||||
DepoL *DepoLink
|
||||
}{nowPage("Входящий перевод", "incoming"), it, r.URL.Query().Get("flash"), orderResps, orderDone, orderDoneAt, draftReceivedTime(it.GUID), depoResult{}, globalQuikLinks.ForGUID(it.GUID), deps.rc.Snapshot().Quik.WritesEnabled, globalOrderWatch.Get(it.GUID), globalDepoLinks.Get(it.GUID)}
|
||||
rejected, codes := incomingRejectedAll(it)
|
||||
data := incomingDetailData{page: nowPage("Входящий перевод", "incoming"), It: it, Flash: r.URL.Query().Get("flash"),
|
||||
OrderResponses: orderResps, OrderDone: orderDone, OrderDoneAt: orderDoneAt, DraftReceived: draftReceivedTime(it.GUID),
|
||||
Quik: globalQuikLinks.ForGUID(it.GUID), QuikOn: quikActive(deps.rc.Snapshot().Quik), Watch: globalOrderWatch.Get(it.GUID), DepoL: globalDepoLinks.Get(it.GUID),
|
||||
Rejected: rejected, RejectCodes: codes, AutoMode: deps.rc.Snapshot().M2MAuto.Enabled}
|
||||
// Сверка выполняется при открытии карточки; итог сохраняем для бейджа в списке.
|
||||
data.Depo = runDepoCheck(it)
|
||||
if data.Depo.Ran {
|
||||
@@ -228,9 +219,47 @@ func handleIncomingAnswer(w http.ResponseWriter, r *http.Request, deps incomingD
|
||||
}
|
||||
decisions = append(decisions, d)
|
||||
}
|
||||
msg := "Решение отправлено роботу"
|
||||
msg := "Решение отправлено контрагенту через НРД"
|
||||
if devMode(deps.rc) {
|
||||
msg = "Решение отправлено роботу"
|
||||
}
|
||||
if err := answerIncoming(r.Context(), deps, id, decisions); err != nil {
|
||||
msg = "Ошибка отправки решения: " + err.Error()
|
||||
}
|
||||
http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// incomingDetailData — карточка входящего перевода.
|
||||
type incomingDetailData struct {
|
||||
page
|
||||
It *IncomingTransfer
|
||||
Flash string
|
||||
OrderResponses []orderResponse
|
||||
OrderDone bool
|
||||
OrderDoneAt string
|
||||
DraftReceived string
|
||||
Depo depoResult // сверка с DEPO — рекомендация по ответу, ничего не отправляется
|
||||
Quik []*QuikLink
|
||||
QuikOn bool
|
||||
Watch *OrderWatch
|
||||
DepoL *DepoLink
|
||||
Rejected bool // решение отправлено и ни одна бумага не принята — перевод завершён отказом
|
||||
RejectCodes string // коды отказа через запятую
|
||||
AutoMode bool // автоматический режим M2M (поручение 16/1 подаст bj)
|
||||
}
|
||||
|
||||
// incomingRejectedAll — перевод отвечен и ни одна бумага не принята (поручение 16/1 не нужно).
|
||||
func incomingRejectedAll(it *IncomingTransfer) (bool, string) {
|
||||
if it.Status != "answered" || incomingHasConfirmed(it) {
|
||||
return false, ""
|
||||
}
|
||||
var codes []string
|
||||
seen := map[string]bool{}
|
||||
for _, s := range it.Securities {
|
||||
if s.RejectCode != "" && !seen[s.RejectCode] {
|
||||
seen[s.RejectCode] = true
|
||||
codes = append(codes, s.RejectCode)
|
||||
}
|
||||
}
|
||||
return true, strings.Join(codes, ", ")
|
||||
}
|
||||
|
||||
@@ -64,10 +64,27 @@ func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps,
|
||||
redirect := func(msg string) {
|
||||
http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
if deps.igwClient == nil || deps.igwChan == "" {
|
||||
redirect("ИШ не настроен — нельзя отправить поручение")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
sent, firstErr, err := submitIncomingOrderFor(ctx, deps, it)
|
||||
if err != nil {
|
||||
redirect(err.Error())
|
||||
return
|
||||
}
|
||||
note := fmt.Sprintf("Поручение 16/1 (тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+
|
||||
"Ждём отчёт по поручению депо (XMLRP) = «MS010». Ответ появится ниже на странице.", strings.Join(sent, ", "))
|
||||
if firstErr != "" {
|
||||
note += " ⚠ часть файлов не ушла: " + firstErr
|
||||
}
|
||||
redirect(note)
|
||||
}
|
||||
|
||||
// submitIncomingOrderFor — подать поручение 16/1 (ORDXM) по черновику MF010 из INBOX ИШ (кнопка в
|
||||
// карточке и автоматический режим). firstErr — если часть файлов не ушла.
|
||||
func submitIncomingOrderFor(ctx context.Context, deps incomingDeps, it *IncomingTransfer) (sent []string, firstErr string, err error) {
|
||||
if deps.igwClient == nil || deps.igwChan == "" {
|
||||
return nil, "", fmt.Errorf("ИШ не настроен — нельзя отправить поручение")
|
||||
}
|
||||
// только .xml (без .SGN) — подпись НРД нам не нужна, ИШ подпишет своей ГОСТ.
|
||||
var xmls []string
|
||||
for _, p := range findMF010Files(it.GUID) {
|
||||
@@ -76,13 +93,11 @@ func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps,
|
||||
}
|
||||
}
|
||||
if len(xmls) == 0 {
|
||||
redirect("Черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден")
|
||||
return
|
||||
return nil, "", fmt.Errorf("Черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
var sent, sentXML []string
|
||||
var firstErr, contrDID string
|
||||
id := it.ID
|
||||
var sentXML []string
|
||||
var contrDID string
|
||||
for _, p := range xmls {
|
||||
raw, e := os.ReadFile(p) // windows-1251, шлём как есть
|
||||
if e != nil {
|
||||
@@ -116,22 +131,16 @@ func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps,
|
||||
if firstErr != "" {
|
||||
msg = firstErr
|
||||
}
|
||||
redirect("Поручение 16/1 (ORDXM): отправка в НРД не удалась: " + msg)
|
||||
return
|
||||
return nil, "", fmt.Errorf("Поручение 16/1 (ORDXM): отправка в НРД не удалась: %s", msg)
|
||||
}
|
||||
note := fmt.Sprintf("Поручение 16/1 (тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+
|
||||
"Ждём отчёт по поручению депо (XMLRP) = «MS010». Ответ появится ниже на странице.", strings.Join(sent, ", "))
|
||||
if firstErr != "" {
|
||||
note += " ⚠ часть файлов не ушла: " + firstErr
|
||||
}
|
||||
redirect(note)
|
||||
return sent, firstErr, nil
|
||||
}
|
||||
|
||||
// writeOrderResponseZip отдаёт на скачивание ZIP с сырыми файлами ответа НРД
|
||||
// (отчёт MS010 + квитанции) по поручению с данным contr_d_id.
|
||||
func writeOrderResponseZip(w http.ResponseWriter, contrDID, fname string) {
|
||||
files := collectOrderResponseRaw(contrDID)
|
||||
if len(files) == 0 {
|
||||
docs := nsdOrderDocs(contrDID)
|
||||
if len(docs) == 0 {
|
||||
http.Error(w, "ответ НРД (MS010) по этому поручению в INBOX ИШ ещё не найден", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -139,11 +148,7 @@ func writeOrderResponseZip(w http.ResponseWriter, contrDID, fname string) {
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, fname))
|
||||
zw := zip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
for _, f := range files {
|
||||
if wf, e := zw.Create(f.Name); e == nil {
|
||||
_, _ = wf.Write(f.Data)
|
||||
}
|
||||
}
|
||||
writeNSDDocsZip(zw, "", "Документы НРД по поручению № "+contrDID, docs, true)
|
||||
}
|
||||
|
||||
// downloadSentOrder отдаёт на скачивание XML отправленного поручения 16/1
|
||||
@@ -351,8 +356,9 @@ func orderRespKind(pkg, body string) (string, bool) {
|
||||
u := strings.ToUpper(body)
|
||||
low := strings.ToLower(body)
|
||||
switch {
|
||||
case strings.Contains(u, "MS010") || strings.Contains(u, "SESE.025"):
|
||||
return "Отчёт по поручению депо MS010 — операция проведена, бумаги зачислены", true
|
||||
case strings.Contains(u, "MS010") || strings.Contains(u, "SESE.025") || strings.Contains(u, "SESE.024") || strings.Contains(u, "GS036") || strings.Contains(u, "GS116") || strings.Contains(low, "<reg_no>"):
|
||||
d := classifyNSDDoc(pkg, body)
|
||||
return d.Title + " — " + d.About, d.Done
|
||||
case strings.Contains(u, "ERROR") || strings.Contains(strings.ToUpper(pkg), "ERROR") || strings.Contains(low, "ошибк"):
|
||||
return "Ошибка обработки пакета (CERROR)", false
|
||||
case strings.Contains(u, "REORD") || strings.Contains(low, "принят"):
|
||||
|
||||
@@ -65,12 +65,16 @@ func (s *keyWizardState) reset() {
|
||||
}
|
||||
|
||||
func defaultKeySteps() []keyWizardStep {
|
||||
last := "Готово — можно проверить подключения"
|
||||
if devMode(globalRC) {
|
||||
last = "Готово — можно отправлять тестовый документ"
|
||||
}
|
||||
return []keyWizardStep{
|
||||
{Title: "Загрузка архива и распаковка", Status: "pending"},
|
||||
{Title: "Запись ключа на выбранную флешку (с бэкапом)", Status: "pending"},
|
||||
{Title: "Формирование справочника сертификатов (CRL)", Status: "pending"},
|
||||
{Title: "Перезапуск и проверка ИШ", Status: "pending"},
|
||||
{Title: "Готово — можно отправлять тестовый документ", Status: "pending"},
|
||||
{Title: last, Status: "pending"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,14 +360,23 @@ func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
h.keyWiz.set(3, "ok", "ИШ перезапущен и работает: "+ishMsg)
|
||||
h.keyWiz.set(4, "ok", "Теперь подпишите новым ключом — отправьте тестовый документ роботу НРД ниже")
|
||||
dev := devMode(h.rc)
|
||||
if dev {
|
||||
h.keyWiz.set(4, "ok", "Теперь подпишите новым ключом — отправьте тестовый документ роботу НРД ниже")
|
||||
} else {
|
||||
h.keyWiz.set(4, "ok", "Ключ установлен — проверьте подключения на странице «Статус»")
|
||||
}
|
||||
h.keyWiz.Done = true
|
||||
h.keyWiz.mu.Unlock()
|
||||
|
||||
// Ключ уже на флешке и в ИШ — распаковка staging больше не нужна.
|
||||
_ = os.RemoveAll(staging)
|
||||
|
||||
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ("Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает. Финальная проверка — тестовым документом роботу."), http.StatusSeeOther)
|
||||
final := "Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает."
|
||||
if dev {
|
||||
final += " Финальная проверка — тестовым документом роботу."
|
||||
}
|
||||
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ(final), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// restartAndVerifyISH перезапускает Интеграционный шлюз и проверяет, что он
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// DefaultLicensePublicKey — открытый ключ проверки лицензий: пара к ключу подписи сервера
|
||||
// лицензий lic.zetit.ru. Зашит в bj и в настройках не меняется, поэтому ключ, подписанный
|
||||
// чем-то другим, не принимается. var — только для подмены в тестах.
|
||||
var DefaultLicensePublicKey = "UuvEYFxxzOFk59FjCQTdxKuzkNAJpJyWTuUdlCYfsdI="
|
||||
var DefaultLicensePublicKey = license.DefaultPublicKey
|
||||
|
||||
// LicenseStatus — сводка состояния лицензии для UI и гейтов.
|
||||
type LicenseStatus struct {
|
||||
@@ -24,7 +24,8 @@ type LicenseStatus struct {
|
||||
ContractUntil time.Time
|
||||
DaysLeft int
|
||||
AllowsUpdates bool
|
||||
Features []string // признаки (модули), разрешённые ключом
|
||||
Features []string // признаки (модули), разрешённые ключом
|
||||
DevUntil time.Time // режим разработки до (выдаёт сервер лицензий; нулевое — выключен)
|
||||
Message string
|
||||
}
|
||||
|
||||
@@ -71,6 +72,22 @@ func modulesAllowed(rc *RuntimeConfig) map[string]bool {
|
||||
return m
|
||||
}
|
||||
|
||||
// devModeForced — режим разработки в тестах пакета.
|
||||
var devModeForced bool
|
||||
|
||||
// devMode — режим разработки: сервер лицензий временно открыл разработчикам bj тестовые разделы и
|
||||
// служебные инструменты на этой установке. Без него у клиента их нет (адреса отвечают 404).
|
||||
func devMode(rc *RuntimeConfig) bool {
|
||||
if devModeForced {
|
||||
return true
|
||||
}
|
||||
if rc == nil {
|
||||
return false
|
||||
}
|
||||
st := licenseStatus(rc)
|
||||
return st.Valid && time.Now().Before(st.DevUntil)
|
||||
}
|
||||
|
||||
// moduleAllowed — разрешён ли модуль (лицензионный признак). План enterprise — все модули.
|
||||
func moduleAllowed(rc *RuntimeConfig, feature string) bool {
|
||||
st := licenseStatus(rc)
|
||||
@@ -127,6 +144,9 @@ func licenseStatus(rc *RuntimeConfig) LicenseStatus {
|
||||
st.DaysLeft = lic.DaysLeft(now)
|
||||
st.AllowsUpdates = lic.AllowsUpdates()
|
||||
st.Features = lic.Features
|
||||
if lic.DevUntil != nil {
|
||||
st.DevUntil = *lic.DevUntil
|
||||
}
|
||||
st.Online = lic.InstallID != ""
|
||||
if lic.Product != "" && lic.Product != "bj-server" {
|
||||
st.Message = "ключ выпущен для другого продукта (" + lic.Product + ")"
|
||||
|
||||
@@ -171,18 +171,22 @@ func LicenseRenew(ctx context.Context, rc *RuntimeConfig) error {
|
||||
return licenseAccept(rc, r.Token, "продлена")
|
||||
}
|
||||
|
||||
// licenseNeedsRenew — онлайн-ключ, и сутки без успешного продления.
|
||||
// licenseNeedsRenew — онлайн-ключ и 15 минут без продления: так режим разработки, включённый или
|
||||
// выключенный на сервере лицензий, доходит до установки быстро. После неудачи — не чаще раза в час.
|
||||
func licenseNeedsRenew(rc *RuntimeConfig) bool {
|
||||
s := rc.Snapshot().License
|
||||
lic, err := verifyLicenseToken(s.Key)
|
||||
if err != nil || lic.InstallID == "" {
|
||||
return false
|
||||
}
|
||||
return time.Since(s.LastSync) >= 23*time.Hour
|
||||
if !s.LastOK && !s.LastAttempt.IsZero() && time.Since(s.LastAttempt) < time.Hour && time.Since(s.LastSync) < 23*time.Hour {
|
||||
return false
|
||||
}
|
||||
return time.Since(s.LastSync) >= 15*time.Minute
|
||||
}
|
||||
|
||||
// licenseLoop — раз в час проверяет, пора ли продлить ключ (продление — раз в сутки,
|
||||
// при сбое — повтор через час).
|
||||
// licenseLoop — раз в 5 минут предупреждает о скором окончании лицензии и проверяет, пора ли продлить
|
||||
// ключ (продление — раз в сутки, при сбое — повтор через час).
|
||||
func (s *Server) licenseLoop(ctx context.Context) {
|
||||
ensureInstallID(s.rc)
|
||||
wait := 2 * time.Minute
|
||||
@@ -192,7 +196,8 @@ func (s *Server) licenseLoop(ctx context.Context) {
|
||||
return
|
||||
case <-time.After(wait):
|
||||
}
|
||||
wait = time.Hour
|
||||
wait = 5 * time.Minute
|
||||
licenseExpiryWarn(s.rc, time.Now())
|
||||
if !licenseNeedsRenew(s.rc) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package lkgateway
|
||||
|
||||
// Предупреждения лицензии.
|
||||
//
|
||||
// 1. Заранее об окончании: за 30, 14, 7 и 1 день до окончания договора (или ключа, если договора
|
||||
// нет или сервер лицензий перестал продлевать ключ). Каждый порог — одно уведомление; если
|
||||
// договор продлили (дата сменилась), отсчёт начинается заново.
|
||||
// 2. При закрытых обновлениях bj — о смене версии внешней системы (МНП QUIK, ИШ, DEPO, …).
|
||||
//
|
||||
// Решение 16.09.2026: после окончания лицензии фоновые процессы (цикл QUIK МНП, личный кабинет,
|
||||
// обмен с НРД и DEPO) не останавливаются. Последствие одно — bj перестаёт обновляться, и новые
|
||||
// версии внешних систем со временем могут перестать с ним корректно работать; об этом и уведомляем.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// licenseWatchFile — что уже отправлено и последние известные версии внешних систем.
|
||||
var licenseWatchFile = "/var/lib/bj/.bj/license-watch.json"
|
||||
|
||||
type licenseWatch struct {
|
||||
EndDate string `json:"end_date,omitempty"` // дата окончания, по которой шли предупреждения
|
||||
WarnedDays int `json:"warned_days,omitempty"` // последний отправленный порог (дней)
|
||||
Versions map[string]string `json:"versions,omitempty"` // ключ системы → последняя известная версия
|
||||
}
|
||||
|
||||
var (
|
||||
licenseWatchMu sync.Mutex
|
||||
licenseWarnSteps = []int{30, 14, 7, 1}
|
||||
)
|
||||
|
||||
// externalSystems — внешние системы, с которыми работает bj (свои компоненты bj обновляются вместе с ним).
|
||||
var externalSystems = map[string]bool{
|
||||
"quik-mnp": true, "ish": true, "depo": true, "quikexport": true,
|
||||
"validata": true, "java": true, "postgres": true, "os": true,
|
||||
}
|
||||
|
||||
func loadLicenseWatch() licenseWatch {
|
||||
var w licenseWatch
|
||||
if b, err := os.ReadFile(licenseWatchFile); err == nil {
|
||||
_ = json.Unmarshal(b, &w)
|
||||
}
|
||||
if w.Versions == nil {
|
||||
w.Versions = map[string]string{}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func saveLicenseWatch(w licenseWatch) {
|
||||
b, _ := json.MarshalIndent(w, "", " ")
|
||||
tmp := licenseWatchFile + ".tmp"
|
||||
if err := os.MkdirAll(filepath.Dir(licenseWatchFile), 0o700); err == nil {
|
||||
if err = os.WriteFile(tmp, b, 0o600); err == nil {
|
||||
err = os.Rename(tmp, licenseWatchFile)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("lk-gateway: лицензия: запись %s: %v", licenseWatchFile, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 1. заранее об окончании ----
|
||||
|
||||
// licenseEnd — когда лицензия перестанет действовать и что именно заканчивается.
|
||||
func licenseEnd(st LicenseStatus, lastSync, now time.Time) (end time.Time, what string) {
|
||||
if !st.Valid {
|
||||
return time.Time{}, "" // уже не действует — об этом говорит статус и отказ в продлении
|
||||
}
|
||||
if !st.ContractUntil.IsZero() {
|
||||
end, what = st.ContractUntil, "договор"
|
||||
}
|
||||
renewStuck := st.Online && now.Sub(lastSync) > 48*time.Hour
|
||||
if (!st.Online || renewStuck) && !st.ExpiresAt.IsZero() && (end.IsZero() || st.ExpiresAt.Before(end)) {
|
||||
end, what = st.ExpiresAt, "ключ"
|
||||
if renewStuck {
|
||||
what = "ключ (сервер лицензий не продлевает его)"
|
||||
}
|
||||
}
|
||||
return end, what
|
||||
}
|
||||
|
||||
// expiryStep — какой порог отправить (0 — ничего): наименьший порог, в который уже попали, если он
|
||||
// ещё не отправлен для этой даты окончания.
|
||||
func expiryStep(daysLeft int, w licenseWatch, endDate string) int {
|
||||
warned := w.WarnedDays
|
||||
if w.EndDate != endDate {
|
||||
warned = 0
|
||||
}
|
||||
step := 0
|
||||
for _, s := range licenseWarnSteps {
|
||||
if daysLeft <= s {
|
||||
step = s
|
||||
}
|
||||
}
|
||||
if step == 0 || (warned != 0 && step >= warned) {
|
||||
return 0
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
func ruDays(n int) string {
|
||||
switch {
|
||||
case n%10 == 1 && n%100 != 11:
|
||||
return fmt.Sprintf("%d день", n)
|
||||
case n%10 >= 2 && n%10 <= 4 && (n%100 < 12 || n%100 > 14):
|
||||
return fmt.Sprintf("%d дня", n)
|
||||
}
|
||||
return fmt.Sprintf("%d дней", n)
|
||||
}
|
||||
|
||||
// licenseExpiryWarn — вызывается раз в час из licenseLoop.
|
||||
func licenseExpiryWarn(rc *RuntimeConfig, now time.Time) {
|
||||
st := licenseStatus(rc)
|
||||
end, what := licenseEnd(st, rc.Snapshot().License.LastSync, now)
|
||||
if end.IsZero() {
|
||||
return
|
||||
}
|
||||
endDate := end.Local().Format("2006-01-02")
|
||||
daysLeft := int(math.Ceil(end.Sub(now).Hours() / 24))
|
||||
if daysLeft < 0 {
|
||||
daysLeft = 0
|
||||
}
|
||||
licenseWatchMu.Lock()
|
||||
defer licenseWatchMu.Unlock()
|
||||
w := loadLicenseWatch()
|
||||
step := expiryStep(daysLeft, w, endDate)
|
||||
dateChanged := w.EndDate != endDate
|
||||
if dateChanged {
|
||||
w.EndDate, w.WarnedDays = endDate, 0 // договор продлили или сменился ключ — отсчёт заново
|
||||
}
|
||||
if step != 0 {
|
||||
w.WarnedDays = step
|
||||
}
|
||||
if dateChanged || step != 0 {
|
||||
saveLicenseWatch(w)
|
||||
}
|
||||
if step == 0 {
|
||||
return
|
||||
}
|
||||
left := "осталось " + ruDays(daysLeft)
|
||||
if daysLeft == 0 {
|
||||
left = "сегодня последний день"
|
||||
}
|
||||
raiseSystemAlert(rc, "Лицензия: "+strings.SplitN(what, " ", 2)[0]+" заканчивается через "+ruDays(daysLeft),
|
||||
fmt.Sprintf("Лицензия bj (%s) действует до %s — %s. После этого переводы M2M, обмен с НРД и DEPO, цикл QUIK МНП и личный кабинет продолжат работать, "+
|
||||
"но отключатся модули по лицензии и обновления bj: новые версии МНП, ИШ и других систем со временем могут перестать корректно работать с bj. "+
|
||||
"Для продления обратитесь к поставщику bj.", what, end.Local().Format("02.01.2006"), left))
|
||||
}
|
||||
|
||||
// ---- 2. смена версии внешней системы при закрытых обновлениях ----
|
||||
|
||||
// versionChanges — сверка с последними известными версиями (запоминает новые); список изменений
|
||||
// возвращается только когда обновления bj недоступны.
|
||||
func versionChanges(w *licenseWatch, items []SystemVersion, updatesOK bool) []string {
|
||||
var changed []string
|
||||
for _, it := range items {
|
||||
if !externalSystems[it.Key] || it.Version == "" {
|
||||
continue
|
||||
}
|
||||
if prev := w.Versions[it.Key]; prev != "" && prev != it.Version && !updatesOK {
|
||||
changed = append(changed, fmt.Sprintf("%s: %s → %s", it.Title, prev, it.Version))
|
||||
}
|
||||
w.Versions[it.Key] = it.Version
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// licenseVersionWatch — вызывается после каждого опроса версий систем.
|
||||
func licenseVersionWatch(rc *RuntimeConfig, v SystemVersions) {
|
||||
st := licenseStatus(rc)
|
||||
updatesOK := !licensingEnabled(rc) || (st.Valid && st.AllowsUpdates)
|
||||
licenseWatchMu.Lock()
|
||||
w := loadLicenseWatch()
|
||||
before, _ := json.Marshal(w.Versions)
|
||||
changed := versionChanges(&w, v.Items, updatesOK)
|
||||
if after, _ := json.Marshal(w.Versions); string(after) != string(before) {
|
||||
saveLicenseWatch(w)
|
||||
}
|
||||
licenseWatchMu.Unlock()
|
||||
if len(changed) == 0 {
|
||||
return
|
||||
}
|
||||
reason := "лицензия: " + st.Message
|
||||
if st.Valid {
|
||||
reason = "обновления не входят в лицензию"
|
||||
}
|
||||
raiseSystemAlert(rc, "Версия внешней системы изменилась, а обновления bj недоступны",
|
||||
fmt.Sprintf("Изменилось: %s. Обновления bj сейчас недоступны (%s). bj продолжает работать, но совместимость с новой версией не проверена и не гарантируется — "+
|
||||
"при сбоях обмена причина может быть в этом. Совместимое обновление bj придёт после продления лицензии.", strings.Join(changed, "; "), reason))
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLicenseEnd(t *testing.T) {
|
||||
now := time.Date(2027, 8, 20, 12, 0, 0, 0, time.UTC)
|
||||
contract := time.Date(2027, 9, 14, 0, 0, 0, 0, time.UTC)
|
||||
key := now.Add(55 * 24 * time.Hour)
|
||||
|
||||
// Онлайн-ключ продлевается — важна дата договора.
|
||||
st := LicenseStatus{Valid: true, Online: true, ContractUntil: contract, ExpiresAt: key}
|
||||
if end, what := licenseEnd(st, now.Add(-time.Hour), now); !end.Equal(contract) || what != "договор" {
|
||||
t.Fatalf("онлайн с договором: %v %q", end, what)
|
||||
}
|
||||
// Онлайн без договора и продление идёт — предупреждать не о чем.
|
||||
st = LicenseStatus{Valid: true, Online: true, ExpiresAt: key}
|
||||
if end, _ := licenseEnd(st, now.Add(-time.Hour), now); !end.IsZero() {
|
||||
t.Fatalf("бессрочная онлайн-лицензия: %v", end)
|
||||
}
|
||||
// Продление не проходит 3 суток — считаем до конца ключа.
|
||||
st = LicenseStatus{Valid: true, Online: true, ExpiresAt: now.Add(10 * 24 * time.Hour), ContractUntil: contract}
|
||||
if end, what := licenseEnd(st, now.Add(-72*time.Hour), now); !end.Equal(st.ExpiresAt) || !strings.HasPrefix(what, "ключ") {
|
||||
t.Fatalf("продление не проходит: %v %q", end, what)
|
||||
}
|
||||
// Офлайн-ключ — до конца ключа.
|
||||
st = LicenseStatus{Valid: true, ExpiresAt: key}
|
||||
if end, what := licenseEnd(st, time.Time{}, now); !end.Equal(key) || what != "ключ" {
|
||||
t.Fatalf("офлайн: %v %q", end, what)
|
||||
}
|
||||
// Уже не действует — без предупреждения заранее.
|
||||
if end, _ := licenseEnd(LicenseStatus{ContractUntil: contract}, now, now); !end.IsZero() {
|
||||
t.Fatal("недействующая лицензия")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryStep(t *testing.T) {
|
||||
end := "2027-09-14"
|
||||
cases := []struct {
|
||||
days int
|
||||
w licenseWatch
|
||||
expect int
|
||||
}{
|
||||
{45, licenseWatch{}, 0},
|
||||
{30, licenseWatch{}, 30},
|
||||
{30, licenseWatch{EndDate: end, WarnedDays: 30}, 0},
|
||||
{20, licenseWatch{EndDate: end, WarnedDays: 30}, 0},
|
||||
{14, licenseWatch{EndDate: end, WarnedDays: 30}, 14},
|
||||
{5, licenseWatch{}, 7}, // первый запуск за 5 дней — одно уведомление, без пачки 30/14/7
|
||||
{1, licenseWatch{EndDate: end, WarnedDays: 7}, 1},
|
||||
{0, licenseWatch{EndDate: end, WarnedDays: 1}, 0},
|
||||
{10, licenseWatch{EndDate: "2026-09-14", WarnedDays: 1}, 14}, // договор продлили — заново
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := expiryStep(c.days, c.w, end); got != c.expect {
|
||||
t.Errorf("осталось %d, %+v: порог %d, ожидался %d", c.days, c.w, got, c.expect)
|
||||
}
|
||||
}
|
||||
if ruDays(1) != "1 день" || ruDays(3) != "3 дня" || ruDays(11) != "11 дней" || ruDays(22) != "22 дня" || ruDays(14) != "14 дней" {
|
||||
t.Errorf("склонение: %s %s %s %s %s", ruDays(1), ruDays(3), ruDays(11), ruDays(22), ruDays(14))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionChanges(t *testing.T) {
|
||||
w := licenseWatch{Versions: map[string]string{}}
|
||||
items := []SystemVersion{
|
||||
{Key: "quik-mnp", Title: "QUIK — Модуль неторговых поручений", Version: "21.4.1"},
|
||||
{Key: "bj-server", Title: "Ядро bj", Version: "0.1.0"},
|
||||
{Key: "depo", Title: "Fancy DEPO", Version: ""},
|
||||
}
|
||||
if ch := versionChanges(&w, items, false); len(ch) != 0 {
|
||||
t.Fatalf("первый опрос только запоминает: %v", ch)
|
||||
}
|
||||
if w.Versions["quik-mnp"] != "21.4.1" || w.Versions["bj-server"] != "" || w.Versions["depo"] != "" {
|
||||
t.Fatalf("запомнены: %v", w.Versions)
|
||||
}
|
||||
items[0].Version = "21.5.0"
|
||||
if ch := versionChanges(&w, items, true); len(ch) != 0 {
|
||||
t.Fatalf("при доступных обновлениях без уведомления: %v", ch)
|
||||
}
|
||||
items[0].Version = "21.6.0"
|
||||
ch := versionChanges(&w, items, false)
|
||||
if len(ch) != 1 || ch[0] != "QUIK — Модуль неторговых поручений: 21.5.0 → 21.6.0" {
|
||||
t.Fatalf("изменение при закрытых обновлениях: %v", ch)
|
||||
}
|
||||
if ch := versionChanges(&w, items, false); len(ch) != 0 {
|
||||
t.Fatalf("повторно о той же версии не уведомляем: %v", ch)
|
||||
}
|
||||
}
|
||||
@@ -236,15 +236,108 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
}
|
||||
pos = p2
|
||||
}
|
||||
total, priced := lkApplyPrices(r.Context(), rc, pos)
|
||||
lkJSON(w, http.StatusOK, map[string]any{
|
||||
"contract": u.Contract,
|
||||
"full_name": u.FullName,
|
||||
"positions": pos,
|
||||
"source": source,
|
||||
"read_only": readOnly,
|
||||
"contract": u.Contract,
|
||||
"full_name": u.FullName,
|
||||
"positions": pos,
|
||||
"source": source,
|
||||
"read_only": readOnly,
|
||||
"total_value": total, // оценка портфеля, 0 — цен нет
|
||||
"priced": priced, // по скольким бумагам есть цена
|
||||
})
|
||||
})
|
||||
|
||||
// Карточка бумаги: реквизиты выпуска, цена, позиция клиента, график выплат.
|
||||
mux.HandleFunc("/api/lk/security", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
}
|
||||
u, ok := current(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
isin := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("isin")))
|
||||
if isin == "" {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": "не указана бумага"})
|
||||
return
|
||||
}
|
||||
var pos *ClientPosition
|
||||
if dp, err := depoClientPositions(r.Context(), rc.Snapshot().Fancy, u.Contract); err == nil && len(dp) > 0 {
|
||||
for _, p := range depoPositionsAsClient(dp) {
|
||||
if strings.EqualFold(p.ISIN, isin) {
|
||||
cp := p
|
||||
pos = &cp
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if p2, err2 := lkClientPositions(r.Context(), rc, u.Contract); err2 == nil {
|
||||
for _, p := range markTransferable(p2) {
|
||||
if strings.EqualFold(p.ISIN, isin) {
|
||||
cp := p
|
||||
pos = &cp
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if pos != nil {
|
||||
one := []ClientPosition{*pos}
|
||||
lkApplyPrices(r.Context(), rc, one)
|
||||
pos = &one[0]
|
||||
}
|
||||
card, err := securityCard(r.Context(), rc, isin, pos)
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
lkJSON(w, http.StatusOK, map[string]any{"security": card})
|
||||
})
|
||||
|
||||
// Движения по счетам депо за период.
|
||||
mux.HandleFunc("/api/lk/operations", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
}
|
||||
u, ok := current(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
ops, err := lkOperations(r.Context(), rc.Snapshot().Fancy, u.Contract, q.Get("from"), q.Get("to"), 200)
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить операции: " + err.Error()})
|
||||
return
|
||||
}
|
||||
lkJSON(w, http.StatusOK, map[string]any{"operations": ops, "from": q.Get("from"), "to": q.Get("to")})
|
||||
})
|
||||
|
||||
// Выплаты дохода и удержанный налог.
|
||||
mux.HandleFunc("/api/lk/income", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
}
|
||||
u, ok := current(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
rows, tax, err := lkIncome(r.Context(), rc.Snapshot().Fancy, u.Contract, q.Get("from"), q.Get("to"))
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить доходы: " + err.Error()})
|
||||
return
|
||||
}
|
||||
var accrued, paid, taxRows float64
|
||||
for _, p := range rows {
|
||||
accrued += p.Accrued
|
||||
paid += p.Paid
|
||||
taxRows += p.Tax
|
||||
}
|
||||
if tax == 0 {
|
||||
tax = taxRows
|
||||
}
|
||||
lkJSON(w, http.StatusOK, map[string]any{"income": rows, "accrued": accrued, "paid": paid, "tax": tax})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/lk/claims", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
|
||||
@@ -506,7 +506,8 @@ func lkClaimToRequest(ctx context.Context, rc *RuntimeConfig, ep LKEndpoint, cl,
|
||||
// MC0012500000, а серия/номер ДУЛ переопределяются на сценарий (робот
|
||||
// читает сценарий из DocumentSeries). Прод-заявки этим маркером не
|
||||
// затрагиваются. Позволяет прогонять все сценарии через путь ЛК.
|
||||
if sc := lkDetectRobotScenario(str(data["contragent_name"])); sc != "" {
|
||||
// Только в режиме разработки: у клиентов маршрута к роботу нет.
|
||||
if sc := lkDetectRobotScenario(str(data["contragent_name"])); sc != "" && devMode(rc) {
|
||||
doc := Document{DocumentType: "21", Series: sc, Number: robotDocumentNumbers[sc]}
|
||||
req.Investor.Document = doc
|
||||
req.InvestorDocumentOverride = &doc
|
||||
|
||||
@@ -35,11 +35,11 @@ var lkEmuSender *mock.Sender
|
||||
// lkEmulateNSD — идут ли заявки ЛК через имитатор НРД и почему.
|
||||
func lkEmulateNSD(rc *RuntimeConfig) (bool, string) {
|
||||
s := rc.Snapshot()
|
||||
if s.LK.EmulateNSD {
|
||||
if s.LK.EmulateNSD && devMode(rc) {
|
||||
return true, "включена в настройках"
|
||||
}
|
||||
if s.LK.ActiveEnv == "prod" && strings.HasPrefix(strings.ToLower(s.NSD.Profile), "test") {
|
||||
return true, "боевой ЛК при тестовом профиле ИШ " + s.NSD.Profile
|
||||
return true, "боевой ЛК при тестовом контуре НРД (профиль ИШ " + s.NSD.Profile + ")"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
@@ -106,7 +106,11 @@ func lkProcessEmulated(ctx context.Context, rc *RuntimeConfig, svc *Service, ep
|
||||
lkMarkSent(id)
|
||||
lkLinkGUID(guid, ep, id)
|
||||
globalLKFlows.update(guid, func(f *LKFlow) { f.Stage = "sent" })
|
||||
globalLKFlows.step(guid, id, "gateway", "Бумаги заблокированы, заявка передана имитатору НРД",
|
||||
sentTitle := "Бумаги заблокированы, заявка обработана шлюзом bj без отправки в НРД"
|
||||
if devMode(rc) {
|
||||
sentTitle = "Бумаги заблокированы, заявка передана имитатору НРД"
|
||||
}
|
||||
globalLKFlows.step(guid, id, "gateway", sentTitle,
|
||||
lkQuikSummary(guid)+fmt.Sprintf(" В НРД не отправлялась; ответ контрагента — через %s.", lkEmuDecisionDelay))
|
||||
if pc, pb := lkPatch(ctx, ep, fmt.Sprintf("/claims/%d", id), []byte(`{"claim":{"status":"in_process"}}`)); pc >= 400 {
|
||||
log.Printf("lk-gateway: ЛК #%d: PATCH «В работе» не прошёл: HTTP %d %s", id, pc, pb)
|
||||
@@ -233,7 +237,11 @@ func (s *Server) consumeEmulatedDecisions(ctx context.Context) {
|
||||
rej++
|
||||
}
|
||||
}
|
||||
globalLKFlows.stepIfExists(guid, "response", "Решение контрагента (имитатор НРД)", fmt.Sprintf("Принято %d, отклонено %d.", conf, rej))
|
||||
respTitle := "Решение по заявке (без отправки в НРД)"
|
||||
if devMode(s.rc) {
|
||||
respTitle = "Решение контрагента (имитатор НРД)"
|
||||
}
|
||||
globalLKFlows.stepIfExists(guid, "response", respTitle, fmt.Sprintf("Принято %d, отклонено %d.", conf, rej))
|
||||
s.applyDecisionEffects(ctx, d, "имитатор НРД", lkEmuNote)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestLKEmulateNSD(t *testing.T) {
|
||||
if err := rc.UpdateNSD(s.NSD); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if on, why := lkEmulateNSD(rc); !on || !strings.Contains(why, "тестовом профиле") {
|
||||
if on, why := lkEmulateNSD(rc); !on || !strings.Contains(why, "тестовом контуре НРД") {
|
||||
t.Errorf("боевой ЛК + тестовый ИШ: ожидали имитацию, получили %v %q", on, why)
|
||||
}
|
||||
s.LK.ActiveEnv = "test"
|
||||
@@ -55,6 +55,11 @@ func TestLKEmulateNSD(t *testing.T) {
|
||||
}
|
||||
s.LK.EmulateNSD = true
|
||||
_ = rc.UpdateLK(s.LK)
|
||||
if on, _ := lkEmulateNSD(rc); on {
|
||||
t.Error("флаг в настройках без режима разработки не действует")
|
||||
}
|
||||
devModeForced = true
|
||||
defer func() { devModeForced = false }()
|
||||
if on, why := lkEmulateNSD(rc); !on || why != "включена в настройках" {
|
||||
t.Errorf("флаг в настройках: %v %q", on, why)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package lkgateway
|
||||
|
||||
// lkhistory.go — история для кабинета клиента: движения по счетам депо и выплаты дохода
|
||||
// с удержанным налогом. Источник — учётная система депозитария (DEPO):
|
||||
//
|
||||
// операции: OD_DOCS + U_OP_INV_ZACH / U_OP_INV_SPIS / U_OP_INV_PEREVOD (связь по DEPO = OD_CLIENTS.ID);
|
||||
// доходы: U_OP_INF_INCOME + U_OP_INF_INCOME_DOL (связь по DEPO или INCOME_FACE);
|
||||
// налог: OD_NDFL по лицу.
|
||||
//
|
||||
// Кабинет ничего не считает сам: суммы и статусы показываются как в учёте.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LKOperation — движение по счёту депо.
|
||||
type LKOperation struct {
|
||||
Date string `json:"date"` // дата регистрации, ДД.ММ.ГГГГ
|
||||
DoneAt string `json:"done_at,omitempty"` // дата проведения
|
||||
Kind string `json:"kind"` // зачисление | списание | перевод между счетами
|
||||
Number string `json:"number,omitempty"` // номер документа депозитария
|
||||
Account string `json:"account,omitempty"` // счёт депо
|
||||
ISIN string `json:"isin,omitempty"`
|
||||
Security string `json:"security,omitempty"`
|
||||
Quantity float64 `json:"quantity"` // со знаком: списание отрицательное
|
||||
State string `json:"state"` // исполнено | в обработке | отменено
|
||||
Note string `json:"note,omitempty"` // комментарий или причина отказа
|
||||
}
|
||||
|
||||
// LKIncome — выплата дохода клиенту.
|
||||
type LKIncome struct {
|
||||
Date string `json:"date"`
|
||||
Kind string `json:"kind"` // купон | дивиденды | доход
|
||||
ISIN string `json:"isin,omitempty"`
|
||||
Security string `json:"security,omitempty"`
|
||||
Quantity float64 `json:"quantity,omitempty"` // бумаг в расчёте
|
||||
Accrued float64 `json:"accrued"` // начислено
|
||||
Tax float64 `json:"tax"` // удержан налог
|
||||
Paid float64 `json:"paid"` // к выплате
|
||||
State string `json:"state,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
}
|
||||
|
||||
// depoClientIDs — внутренние идентификаторы счетов депо клиента и его лицо в DEPO.
|
||||
func depoClientIDs(ctx context.Context, s FancyMSSQLSettings, contract string) (ids []int64, face int64, accounts map[int64]string, err error) {
|
||||
conn, err := fancyOpen(s, s.DepoDB())
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
rows, err := conn.QueryContext(cctx, `SELECT DISTINCT c.ID, ISNULL(c.NUMBER,''), ISNULL(c.FACE,0)
|
||||
FROM OD_CLIENTS c JOIN OD_CLIENTS_PORTS p ON p.CLIENT = c.ID
|
||||
WHERE p.PORT_NUM = @p1 OR p.PORT_NUM LIKE @p2`, contract, contract+" %")
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
accounts = map[int64]string{}
|
||||
for rows.Next() {
|
||||
var id, f int64
|
||||
var num string
|
||||
if rows.Scan(&id, &num, &f) != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
accounts[id] = num
|
||||
if face == 0 {
|
||||
face = f
|
||||
}
|
||||
}
|
||||
return ids, face, accounts, rows.Err()
|
||||
}
|
||||
|
||||
// docStateRu — состояние документа депозитария словами.
|
||||
func docStateRu(state sql.NullInt64, proc sql.NullTime) string {
|
||||
switch {
|
||||
case state.Valid && state.Int64 == 2:
|
||||
return "исполнено"
|
||||
case state.Valid && state.Int64 == 3:
|
||||
return "отменено"
|
||||
case proc.Valid:
|
||||
return "исполнено"
|
||||
}
|
||||
return "в обработке"
|
||||
}
|
||||
|
||||
// inClause — список идентификаторов для IN (...) — значения свои, не от пользователя.
|
||||
func inClause(ids []int64) string {
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
parts = append(parts, fmt.Sprint(id))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// parseRange — период запроса: пустые значения — последний год по сегодня.
|
||||
func parseRange(from, to string) (time.Time, time.Time) {
|
||||
fromT, err := time.Parse("2006-01-02", strings.TrimSpace(from))
|
||||
if err != nil {
|
||||
fromT = time.Now().AddDate(-1, 0, 0)
|
||||
}
|
||||
toT, err := time.Parse("2006-01-02", strings.TrimSpace(to))
|
||||
if err != nil {
|
||||
toT = time.Now()
|
||||
}
|
||||
return time.Date(fromT.Year(), fromT.Month(), fromT.Day(), 0, 0, 0, 0, time.UTC),
|
||||
time.Date(toT.Year(), toT.Month(), toT.Day(), 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// lkOperations — движения по счетам депо клиента за период (from/to — ГГГГ-ММ-ДД, можно пустыми).
|
||||
func lkOperations(ctx context.Context, s FancyMSSQLSettings, contract, from, to string, limit int) ([]LKOperation, error) {
|
||||
if strings.TrimSpace(s.Server) == "" {
|
||||
return nil, fmt.Errorf("учётная система депозитария не подключена")
|
||||
}
|
||||
ids, _, accounts, err := depoClientIDs(ctx, s, contract)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
conn, err := fancyOpen(s, s.DepoDB())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 200
|
||||
}
|
||||
fromT, toT := parseRange(from, to)
|
||||
// Три вида операций в одном списке: зачисление, списание, перевод между счетами.
|
||||
q := fmt.Sprintf(`SELECT TOP %d * FROM (
|
||||
SELECT d.R_DATE AS RD, d.PROC_DATE AS PD, ISNULL(d.NUM,'') AS NUM, d.STATE AS ST, N'зачисление' AS KIND,
|
||||
z.DEPO AS ACC, ISNULL(v.ISIN,'') AS ISIN, ISNULL(v.NAME,'') AS SEC, CAST(ISNULL(z.AMOUNT,0) AS float) AS QTY,
|
||||
LTRIM(RTRIM(ISNULL(z.COMMENT,'') + ' ' + ISNULL(z.REJECT_REASON,''))) AS NOTE
|
||||
FROM U_OP_INV_ZACH z JOIN OD_DOCS d ON d.ID = z.DOC LEFT JOIN OD_VALUES v ON v.ID = z.SHARE
|
||||
WHERE z.DEPO IN (%s)
|
||||
UNION ALL
|
||||
SELECT d.R_DATE, d.PROC_DATE, ISNULL(d.NUM,''), d.STATE, N'списание',
|
||||
p.DEPO, ISNULL(v.ISIN,''), ISNULL(v.NAME,''), -CAST(ISNULL(p.AMOUNT,0) AS float),
|
||||
LTRIM(RTRIM(ISNULL(p.COMMENT,'') + ' ' + ISNULL(p.REJECT_REASON,'')))
|
||||
FROM U_OP_INV_SPIS p JOIN OD_DOCS d ON d.ID = p.DOC LEFT JOIN OD_VALUES v ON v.ID = p.SHARE
|
||||
WHERE p.DEPO IN (%s)
|
||||
UNION ALL
|
||||
SELECT d.R_DATE, d.PROC_DATE, ISNULL(d.NUM,''), d.STATE, N'перевод: списание',
|
||||
t.D_DEPO, ISNULL(v.ISIN,''), ISNULL(v.NAME,''), -CAST(ISNULL(t.AMOUNT,0) AS float),
|
||||
LTRIM(RTRIM(ISNULL(t.COMMENT,'') + ' ' + ISNULL(t.REJECT_REASON,'')))
|
||||
FROM U_OP_INV_PEREVOD t JOIN OD_DOCS d ON d.ID = t.DOC LEFT JOIN OD_VALUES v ON v.ID = t.SHARE
|
||||
WHERE t.D_DEPO IN (%s)
|
||||
UNION ALL
|
||||
SELECT d.R_DATE, d.PROC_DATE, ISNULL(d.NUM,''), d.STATE, N'перевод: зачисление',
|
||||
t.K_DEPO, ISNULL(v.ISIN,''), ISNULL(v.NAME,''), CAST(ISNULL(t.AMOUNT,0) AS float),
|
||||
LTRIM(RTRIM(ISNULL(t.COMMENT,'') + ' ' + ISNULL(t.REJECT_REASON,'')))
|
||||
FROM U_OP_INV_PEREVOD t JOIN OD_DOCS d ON d.ID = t.DOC LEFT JOIN OD_VALUES v ON v.ID = t.SHARE
|
||||
WHERE t.K_DEPO IN (%s)) x
|
||||
WHERE x.RD >= @p1 AND x.RD < DATEADD(day, 1, @p2)
|
||||
ORDER BY x.RD DESC, x.NUM DESC`, limit, inClause(ids), inClause(ids), inClause(ids), inClause(ids))
|
||||
rows, err := conn.QueryContext(cctx, q, fromT, toT)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []LKOperation
|
||||
for rows.Next() {
|
||||
var rd, pd sql.NullTime
|
||||
var num, kind, isin, sec, note string
|
||||
var st sql.NullInt64
|
||||
var acc int64
|
||||
var qty float64
|
||||
if rows.Scan(&rd, &pd, &num, &st, &kind, &acc, &isin, &sec, &qty, ¬e) != nil {
|
||||
continue
|
||||
}
|
||||
op := LKOperation{Kind: kind, Number: num, ISIN: isin, Security: sec, Quantity: qty,
|
||||
Account: accounts[acc], State: docStateRu(st, pd), Note: strings.TrimSpace(note)}
|
||||
if rd.Valid {
|
||||
op.Date = rd.Time.Format("02.01.2006")
|
||||
}
|
||||
if pd.Valid && pd.Time.Year() < 9000 {
|
||||
op.DoneAt = pd.Time.Format("02.01.2006")
|
||||
}
|
||||
out = append(out, op)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// incomeKindRu — вид дохода из учёта.
|
||||
func incomeKindRu(kind sql.NullInt64) string {
|
||||
switch {
|
||||
case !kind.Valid:
|
||||
return "доход"
|
||||
case kind.Int64 == 1:
|
||||
return "дивиденды"
|
||||
case kind.Int64 == 2:
|
||||
return "купон"
|
||||
case kind.Int64 == 3:
|
||||
return "погашение"
|
||||
}
|
||||
return "доход"
|
||||
}
|
||||
|
||||
// lkIncome — выплаты дохода клиенту за период и удержанный налог по данным учёта.
|
||||
func lkIncome(ctx context.Context, s FancyMSSQLSettings, contract, from, to string) (rowsOut []LKIncome, taxTotal float64, err error) {
|
||||
if strings.TrimSpace(s.Server) == "" {
|
||||
return nil, 0, fmt.Errorf("учётная система депозитария не подключена")
|
||||
}
|
||||
ids, face, accounts, err := depoClientIDs(ctx, s, contract)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
conn, err := fancyOpen(s, s.DepoDB())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer conn.Close()
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
fromT, toT := parseRange(from, to)
|
||||
q := fmt.Sprintf(`SELECT TOP 200 h.P_DATE, h.INCOME_KIND, ISNULL(v.ISIN,''), ISNULL(v.NAME,''),
|
||||
CAST(ISNULL(l.AMOUNT,0) AS float), CAST(ISNULL(l.SUMMA_TOTAL,0) AS float),
|
||||
CAST(ISNULL(l.TAX_HOLDED,0) AS float), CAST(ISNULL(l.TOTAL_TO_PAY,0) AS float),
|
||||
ISNULL(l.STATUS,''), ISNULL(l.DEPO,0)
|
||||
FROM U_OP_INF_INCOME_DOL l
|
||||
JOIN OD_DOLS o ON o.ID = l.DOL
|
||||
JOIN U_OP_INF_INCOME h ON h.DOC = o.DOC
|
||||
LEFT JOIN OD_VALUES v ON v.ID = h.SHARE
|
||||
WHERE (l.DEPO IN (%s) OR l.INCOME_FACE = @p3)
|
||||
AND h.P_DATE >= @p1 AND h.P_DATE < DATEADD(day, 1, @p2)
|
||||
ORDER BY h.P_DATE DESC`, inClause(ids))
|
||||
rows, err := conn.QueryContext(cctx, q, fromT, toT, face)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pd sql.NullTime
|
||||
var kind sql.NullInt64
|
||||
var isin, sec, status string
|
||||
var qty, accrued, tax, paid float64
|
||||
var acc int64
|
||||
if rows.Scan(&pd, &kind, &isin, &sec, &qty, &accrued, &tax, &paid, &status, &acc) != nil {
|
||||
continue
|
||||
}
|
||||
in := LKIncome{Kind: incomeKindRu(kind), ISIN: isin, Security: sec, Quantity: qty,
|
||||
Accrued: accrued, Tax: tax, Paid: paid, Account: accounts[acc]}
|
||||
if st := strings.TrimSpace(status); st != "" && st != "0" { // «0» — служебное значение учёта
|
||||
in.State = st
|
||||
}
|
||||
if pd.Valid {
|
||||
in.Date = pd.Time.Format("02.01.2006")
|
||||
}
|
||||
rowsOut = append(rowsOut, in)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return rowsOut, 0, err
|
||||
}
|
||||
// Удержанный налог по лицу за период — по журналу НДФЛ депозитария.
|
||||
if face > 0 {
|
||||
_ = conn.QueryRowContext(cctx, `SELECT CAST(ISNULL(SUM(CAST(SUMMA AS float)),0) AS float) FROM OD_NDFL
|
||||
WHERE FACE = @p1 AND R_DATE >= @p2 AND R_DATE < DATEADD(day, 1, @p3)`,
|
||||
sql.Named("p1", face), sql.Named("p2", fromT), sql.Named("p3", toT)).Scan(&taxTotal)
|
||||
}
|
||||
return rowsOut, taxTotal, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build live
|
||||
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Движения и доходы клиента из учётной системы.
|
||||
func TestLiveHistory(t *testing.T) {
|
||||
rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalRC = rc
|
||||
fs := rc.Snapshot().Fancy
|
||||
for _, contract := range []string{"БР-2000", "БР-1529", "БР-859"} {
|
||||
ops, err := lkOperations(context.Background(), fs, contract, "2024-01-01", "", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("операции %s: %v", contract, err)
|
||||
}
|
||||
t.Logf("%s: операций %d", contract, len(ops))
|
||||
for i, o := range ops {
|
||||
if i > 4 {
|
||||
break
|
||||
}
|
||||
t.Logf(" %s %s %s %s %.0f %s %s", o.Date, o.Kind, o.Account, o.Security, o.Quantity, o.State, o.Note)
|
||||
}
|
||||
inc, tax, err := lkIncome(context.Background(), fs, contract, "2024-01-01", "")
|
||||
if err != nil {
|
||||
t.Fatalf("доходы %s: %v", contract, err)
|
||||
}
|
||||
t.Logf("%s: выплат %d, налог по журналу %.2f", contract, len(inc), tax)
|
||||
for i, p := range inc {
|
||||
if i > 4 {
|
||||
break
|
||||
}
|
||||
t.Logf(" %s %s %s начислено %.2f налог %.2f к выплате %.2f %s", p.Date, p.Kind, p.Security, p.Accrued, p.Tax, p.Paid, p.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,11 @@ type ClientPosition struct {
|
||||
// Transferable — бумагу можно перевести через M2M (только российские выпуски).
|
||||
Transferable bool `json:"transferable"`
|
||||
Note string `json:"note"`
|
||||
// Оценка по данным торговой системы (пакет «Цены»); 0 — цены нет.
|
||||
Price float64 `json:"price,omitempty"`
|
||||
PriceAt string `json:"price_board,omitempty"` // класс торгов, откуда цена
|
||||
Value float64 `json:"value,omitempty"` // стоимость позиции
|
||||
ChangePct float64 `json:"change_pct,omitempty"` // изменение цены к закрытию, %
|
||||
}
|
||||
|
||||
// lkClientPositions — позиции клиента модуля ЛК по номеру договора (тестовые клиенты;
|
||||
@@ -192,7 +197,10 @@ func lkInsertRequest(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransfe
|
||||
if doc.DocumentSeries != nil {
|
||||
docSeries = string(*doc.DocumentSeries)
|
||||
}
|
||||
ourINN := transferringINN(Settings{})
|
||||
ourINN := ""
|
||||
if globalRC != nil {
|
||||
ourINN = transferringINN(globalRC.Snapshot())
|
||||
}
|
||||
opType := ""
|
||||
switch ourINN {
|
||||
case string(req.Data.TransferringDepository.INN):
|
||||
|
||||
@@ -204,7 +204,8 @@ func (s *Service) CreateLKTransfer(ctx context.Context, rc *RuntimeConfig, u LKU
|
||||
// ТЕСТОВЫЙ КЛИЕНТ: если серия ДУЛ совпадает с кодом сценария робота НРД
|
||||
// (1111/2001/2002/3333), заявка адресуется роботу-тестировщику, а серия/номер
|
||||
// документа задают сценарий. Так из ЛК можно прогнать полный цикл с НРД.
|
||||
if _, isRobot := robotScenarios[u.DocSeries]; isRobot {
|
||||
// Только в режиме разработки: у реальных паспортов такие серии тоже встречаются.
|
||||
if _, isRobot := robotScenarios[u.DocSeries]; isRobot && devMode(rc) {
|
||||
claim.ReceiverCodeOverride = NSDRobotCode
|
||||
claim.InvestorDocumentOverride = &doc
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package lkgateway
|
||||
|
||||
// Автоматический режим M2M (решение 16.09.2026): ручные шаги оператора выполняются без участия
|
||||
// человека, чтобы соблюдать сроки и убрать человеческий фактор.
|
||||
//
|
||||
// - Входящий перевод ждёт решения → свежая сверка с DEPO → решение по её рекомендации: пакет
|
||||
// принимается только целиком; предупреждение со Strict (бумага для квалифицированных инвесторов
|
||||
// при неполной квалификации клиента) — отказ M2M08. Без сверки ничего не принимается: если DEPO
|
||||
// или ИШ недоступны, bj повторяет каждые m2mAutoRetry и шлёт оповещение.
|
||||
// - После нашего согласия НРД присылает черновик 16/1 (MF010) → поручение подаётся автоматически.
|
||||
// - По исходящему после согласия контрагента НРД присылает черновик 16 → подаётся автоматически.
|
||||
//
|
||||
// Обрабатывается только то, что появилось не раньше чем за сутки до включения режима (в INBOX ИШ
|
||||
// лежат старые черновики тестов — их подавать нельзя). Шаги в QUIK МНП — по настройкам цикла
|
||||
// (в том числе в режиме имитации).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// M2MAutoSettings — автоматический режим M2M.
|
||||
type M2MAutoSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ChangedAt time.Time `json:"changed_at,omitempty"`
|
||||
ChangedBy string `json:"changed_by,omitempty"`
|
||||
}
|
||||
|
||||
func (r *RuntimeConfig) UpdateM2MAuto(s M2MAutoSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.M2MAuto = s
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
const (
|
||||
m2mAutoTick = 30 * time.Second
|
||||
m2mAutoRetry = 3 * time.Minute
|
||||
m2mAutoAlertEvery = time.Hour
|
||||
m2mAutoLookback = 24 * time.Hour // что обрабатывать при включении
|
||||
m2mAutoDraftWait = 2 * time.Hour // черновик 16/1 не пришёл — оповещение
|
||||
)
|
||||
|
||||
var (
|
||||
globalIncomingDeps *incomingDeps
|
||||
globalOutgoingDeps *outgoingDeps
|
||||
)
|
||||
|
||||
// m2mAutoState — повторы и оповещения по ключу шага (answer:<id>, order:<id>, form16:<guid>).
|
||||
type m2mAutoState struct {
|
||||
mu sync.Mutex
|
||||
next map[string]time.Time
|
||||
alerted map[string]time.Time
|
||||
}
|
||||
|
||||
var m2mAuto = &m2mAutoState{next: map[string]time.Time{}, alerted: map[string]time.Time{}}
|
||||
|
||||
func (st *m2mAutoState) due(key string, now time.Time) bool {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
return !now.Before(st.next[key])
|
||||
}
|
||||
|
||||
func (st *m2mAutoState) retry(key string, now time.Time) {
|
||||
st.mu.Lock()
|
||||
st.next[key] = now.Add(m2mAutoRetry)
|
||||
st.mu.Unlock()
|
||||
}
|
||||
|
||||
func (st *m2mAutoState) done(key string) {
|
||||
st.mu.Lock()
|
||||
delete(st.next, key)
|
||||
delete(st.alerted, key)
|
||||
st.mu.Unlock()
|
||||
}
|
||||
|
||||
// alert — оповещение по шагу не чаще раза в m2mAutoAlertEvery.
|
||||
func (st *m2mAutoState) alert(rc *RuntimeConfig, key string, now time.Time, title, body string) {
|
||||
st.mu.Lock()
|
||||
last, ok := st.alerted[key]
|
||||
send := !ok || now.Sub(last) >= m2mAutoAlertEvery
|
||||
if send {
|
||||
st.alerted[key] = now
|
||||
}
|
||||
st.mu.Unlock()
|
||||
if send {
|
||||
raiseSystemAlert(rc, title, body)
|
||||
}
|
||||
}
|
||||
|
||||
// m2mAutoLoop — фоновый исполнитель автоматического режима.
|
||||
func (s *Server) m2mAutoLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(m2mAutoTick)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
m2mAutoTickOnce(ctx, s.rc, time.Now())
|
||||
healthBeat("m2m-auto")
|
||||
}
|
||||
}
|
||||
|
||||
func m2mAutoTickOnce(ctx context.Context, rc *RuntimeConfig, now time.Time) {
|
||||
cfg := rc.Snapshot().M2MAuto
|
||||
if !cfg.Enabled {
|
||||
return
|
||||
}
|
||||
since := cfg.ChangedAt.Add(-m2mAutoLookback)
|
||||
if d := globalIncomingDeps; d != nil {
|
||||
for _, it := range d.store.List() {
|
||||
switch {
|
||||
case it.Status == "pending" && !it.ReceivedAt.Before(since):
|
||||
m2mAutoAnswer(ctx, rc, *d, it, now)
|
||||
case it.Status == "answered" && !it.OrderSubmitted && !it.AnsweredAt.Before(since) && incomingHasConfirmed(it):
|
||||
m2mAutoOrder(ctx, rc, *d, it, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
if d := globalOutgoingDeps; d != nil {
|
||||
for _, od := range scanForm16Drafts(d.store) {
|
||||
if !od.Submitted && len(od.Secs) > 0 && !od.DraftAt.Before(since) {
|
||||
m2mAutoForm16(ctx, rc, *d, od, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func incomingHasConfirmed(it *IncomingTransfer) bool {
|
||||
for _, s := range it.Securities {
|
||||
if s.Decision == "confirmed" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// autoDecisions — решение по результату сверки: рекомендация по каждой бумаге, Strict-предупреждение
|
||||
// даёт отказ M2M08, пакет только целиком. why — пояснение для карточки.
|
||||
func autoDecisions(it *IncomingTransfer, r depoResult) (decisions []SecurityDecision, why string) {
|
||||
bySec := map[string]depoSecResult{}
|
||||
strict := ""
|
||||
for _, sr := range r.Secs {
|
||||
bySec[sr.ReferenceID] = sr
|
||||
for _, c := range sr.Checks {
|
||||
if c.Strict && c.Warn && strict == "" {
|
||||
strict = sr.Security
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, sec := range it.Securities {
|
||||
sr, ok := bySec[sec.ReferenceID]
|
||||
d := SecurityDecision{ReferenceID: sec.ReferenceID}
|
||||
switch {
|
||||
case !ok:
|
||||
d.RejectCode = "M2M05"
|
||||
case strict != "":
|
||||
d.RejectCode = "M2M08"
|
||||
case sr.Recommend == "confirm":
|
||||
d.Confirm = true
|
||||
default:
|
||||
d.RejectCode = sr.RejectCode
|
||||
if d.RejectCode == "" {
|
||||
d.RejectCode = "M2M05"
|
||||
}
|
||||
}
|
||||
decisions = append(decisions, d)
|
||||
}
|
||||
decisions = packageDecisions(decisions)
|
||||
why = r.Summary
|
||||
if strict != "" {
|
||||
why = "отказ M2M08: бумага " + strict + " для квалифицированных инвесторов, квалификация клиента неполная"
|
||||
}
|
||||
return decisions, why
|
||||
}
|
||||
|
||||
func m2mAutoAnswer(ctx context.Context, rc *RuntimeConfig, d incomingDeps, it *IncomingTransfer, now time.Time) {
|
||||
key := "answer:" + it.ID
|
||||
if !m2mAuto.due(key, now) {
|
||||
return
|
||||
}
|
||||
who := fmt.Sprintf("Перевод %s от %s (%s %s)", shortGUID(it.GUID), it.SenderCode, it.InvestorLast, it.InvestorFirst)
|
||||
r := runDepoCheck(it)
|
||||
if !r.Ran || r.Err != "" || len(r.Secs) == 0 {
|
||||
m2mAuto.retry(key, now)
|
||||
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: ответ отложен — нет сверки с DEPO",
|
||||
fmt.Sprintf("%s: %s. bj повторит через %d мин и ответит, как только сверка пройдёт; без сверки ничего не принимается.", who, pickStr(r.Err, "сверка не выполнена"), int(m2mAutoRetry.Minutes())))
|
||||
return
|
||||
}
|
||||
d.store.SetDepo(it.ID, r.Recommend, r.Summary)
|
||||
if cur, ok := d.store.Get(it.ID); !ok || cur.Status != "pending" {
|
||||
m2mAuto.done(key) // оператор успел ответить сам
|
||||
return
|
||||
}
|
||||
decisions, why := autoDecisions(it, r)
|
||||
cctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
if err := answerIncoming(cctx, d, it.ID, decisions); err != nil {
|
||||
if cur, ok := d.store.Get(it.ID); ok && cur.Status == "answered" {
|
||||
m2mAuto.done(key)
|
||||
return
|
||||
}
|
||||
m2mAuto.retry(key, now)
|
||||
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: решение не отправлено",
|
||||
fmt.Sprintf("%s: %v. bj повторит через %d мин.", who, err, int(m2mAutoRetry.Minutes())))
|
||||
return
|
||||
}
|
||||
if cur, ok := d.store.Get(it.ID); ok {
|
||||
d.store.MarkAuto(it.ID, "Автоматически ("+why+"). "+cur.AnswerNote)
|
||||
}
|
||||
m2mAuto.done(key)
|
||||
log.Printf("m2m-auto: %s — решение отправлено автоматически: %s", who, why)
|
||||
}
|
||||
|
||||
func m2mAutoOrder(ctx context.Context, rc *RuntimeConfig, d incomingDeps, it *IncomingTransfer, now time.Time) {
|
||||
key := "order:" + it.ID
|
||||
if !m2mAuto.due(key, now) {
|
||||
return
|
||||
}
|
||||
who := fmt.Sprintf("Перевод %s от %s (%s %s)", shortGUID(it.GUID), it.SenderCode, it.InvestorLast, it.InvestorFirst)
|
||||
if len(findMF010Files(it.GUID)) == 0 {
|
||||
if now.Sub(it.AnsweredAt) > m2mAutoDraftWait {
|
||||
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: нет черновика поручения 16/1",
|
||||
fmt.Sprintf("%s: согласие отправлено %s, но черновик 16/1 (MF010) из НРД так и не пришёл. bj подаст поручение, как только черновик появится в INBOX ИШ.", who, it.AnsweredAt.Local().Format("02.01.2006 15:04")))
|
||||
}
|
||||
return // черновик ещё не пришёл — проверим на следующем проходе
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer cancel()
|
||||
sent, partial, err := submitIncomingOrderFor(cctx, d, it)
|
||||
if err != nil {
|
||||
m2mAuto.retry(key, now)
|
||||
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: поручение 16/1 не подано",
|
||||
fmt.Sprintf("%s: %v. bj повторит через %d мин.", who, err, int(m2mAutoRetry.Minutes())))
|
||||
return
|
||||
}
|
||||
m2mAuto.done(key)
|
||||
log.Printf("m2m-auto: %s — поручение 16/1 подано автоматически, пакеты ИШ %s%s", who, strings.Join(sent, ", "), pickStr(partial, ""))
|
||||
if partial != "" {
|
||||
raiseSystemAlert(rc, "Автоматический режим M2M: поручение 16/1 подано не полностью", who+": часть файлов не ушла: "+partial)
|
||||
}
|
||||
}
|
||||
|
||||
func m2mAutoForm16(ctx context.Context, rc *RuntimeConfig, d outgoingDeps, od *OutgoingDeal, now time.Time) {
|
||||
key := "form16:" + od.GUID
|
||||
if !m2mAuto.due(key, now) {
|
||||
return
|
||||
}
|
||||
who := fmt.Sprintf("Исходящий перевод %s (контрагент %s)", shortGUID(od.GUID), od.CounterpartyCode)
|
||||
cctx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer cancel()
|
||||
pkgs, _, _, err := submitOutgoingFor(cctx, d, od.GUID)
|
||||
if len(pkgs) == 0 {
|
||||
m2mAuto.retry(key, now)
|
||||
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: поручение 16 не подано",
|
||||
fmt.Sprintf("%s: %v. bj повторит через %d мин.", who, err, int(m2mAutoRetry.Minutes())))
|
||||
return
|
||||
}
|
||||
m2mAuto.done(key)
|
||||
log.Printf("m2m-auto: %s — поручение 16 подано автоматически, пакеты ИШ %s", who, strings.Join(pkgs, ", "))
|
||||
if err != nil {
|
||||
raiseSystemAlert(rc, "Автоматический режим M2M: поручение 16 подано не полностью", who+": "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// saveM2MAuto — POST /admin/setup/m2m-auto.
|
||||
func (h *setupHandlers) saveM2MAuto(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
cur := h.rc.Snapshot().M2MAuto
|
||||
on := r.FormValue("enabled") != ""
|
||||
if on == cur.Enabled {
|
||||
setupFlash(w, r, "Автоматический режим M2M: без изменений")
|
||||
return
|
||||
}
|
||||
s := M2MAutoSettings{Enabled: on, ChangedAt: time.Now()}
|
||||
if u := currentUser(w); u != nil {
|
||||
s.ChangedBy = u.Login
|
||||
}
|
||||
if err := h.rc.UpdateM2MAuto(s); err != nil {
|
||||
setupFlash(w, r, "Автоматический режим M2M: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
if on {
|
||||
log.Printf("m2m-auto: автоматический режим ВКЛЮЧЁН (%s)", s.ChangedBy)
|
||||
setupFlash(w, r, "Автоматический режим M2M включён: решения по входящим, поручения 16/1 и 16 bj отправляет сам (первый проход — в течение 30 секунд).")
|
||||
return
|
||||
}
|
||||
log.Printf("m2m-auto: автоматический режим выключен (%s)", s.ChangedBy)
|
||||
setupFlash(w, r, "Автоматический режим M2M выключен — решения и поручения снова отправляет оператор.")
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAutoDecisions(t *testing.T) {
|
||||
it := &IncomingTransfer{Securities: []IncomingSecurity{{ReferenceID: "A"}, {ReferenceID: "B"}}}
|
||||
|
||||
// Всё прошло — согласие по всем.
|
||||
r := depoResult{Summary: "принять", Secs: []depoSecResult{{ReferenceID: "A", Recommend: "confirm"}, {ReferenceID: "B", Recommend: "confirm"}}}
|
||||
dec, _ := autoDecisions(it, r)
|
||||
if !dec[0].Confirm || !dec[1].Confirm {
|
||||
t.Fatalf("согласие: %+v", dec)
|
||||
}
|
||||
|
||||
// Отказ по одной — отказ всем пакетом с её кодом.
|
||||
r = depoResult{Summary: "отказ пакетом M2M05", Secs: []depoSecResult{{ReferenceID: "A", Recommend: "confirm"}, {ReferenceID: "B", Recommend: "reject", RejectCode: "M2M05"}}}
|
||||
dec, _ = autoDecisions(it, r)
|
||||
for _, d := range dec {
|
||||
if d.Confirm || d.RejectCode != "M2M05" {
|
||||
t.Fatalf("пакет целиком: %+v", dec)
|
||||
}
|
||||
}
|
||||
|
||||
// Бумага для квал. инвесторов при неполной квалификации — отказ M2M08 всем.
|
||||
r = depoResult{Summary: "принять", Secs: []depoSecResult{
|
||||
{ReferenceID: "A", Security: "RU000A10F5K6", Recommend: "confirm", Checks: []depoCheck{{Name: "Бумага только для квал. инвесторов", OK: true, Warn: true, Strict: true, Code: "M2M08"}}},
|
||||
{ReferenceID: "B", Recommend: "confirm", Checks: []depoCheck{{Name: "Квал-инвестор (если бумага только для квал.)", OK: true, Warn: true, Code: "M2M08"}}},
|
||||
}}
|
||||
dec, why := autoDecisions(it, r)
|
||||
for _, d := range dec {
|
||||
if d.Confirm || d.RejectCode != "M2M08" {
|
||||
t.Fatalf("строгое предупреждение: %+v", dec)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(why, "RU000A10F5K6") {
|
||||
t.Fatalf("пояснение: %q", why)
|
||||
}
|
||||
|
||||
// Обычное предупреждение (клиент не квал, бумага без ограничений) отказа не даёт.
|
||||
r = depoResult{Secs: []depoSecResult{
|
||||
{ReferenceID: "A", Recommend: "confirm", Checks: []depoCheck{{OK: true, Warn: true, Code: "M2M08"}}},
|
||||
{ReferenceID: "B", Recommend: "confirm"},
|
||||
}}
|
||||
if dec, _ = autoDecisions(it, r); !dec[0].Confirm || !dec[1].Confirm {
|
||||
t.Fatalf("информационное предупреждение не должно отказывать: %+v", dec)
|
||||
}
|
||||
|
||||
// Бумага без результата сверки — не принимаем.
|
||||
r = depoResult{Secs: []depoSecResult{{ReferenceID: "A", Recommend: "confirm"}}}
|
||||
if dec, _ = autoDecisions(it, r); dec[0].Confirm || dec[1].Confirm {
|
||||
t.Fatalf("без сверки по бумаге: %+v", dec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM2MAutoState(t *testing.T) {
|
||||
st := &m2mAutoState{next: map[string]time.Time{}, alerted: map[string]time.Time{}}
|
||||
now := time.Now()
|
||||
if !st.due("k", now) {
|
||||
t.Fatal("новый шаг должен выполняться сразу")
|
||||
}
|
||||
st.retry("k", now)
|
||||
if st.due("k", now.Add(time.Minute)) || !st.due("k", now.Add(m2mAutoRetry)) {
|
||||
t.Fatal("повтор через m2mAutoRetry")
|
||||
}
|
||||
st.done("k")
|
||||
if !st.due("k", now) {
|
||||
t.Fatal("после выполнения ключ сбрасывается")
|
||||
}
|
||||
}
|
||||
|
||||
// Выключенный режим ничего не делает (и не трогает сторы).
|
||||
func TestM2MAutoDisabled(t *testing.T) {
|
||||
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved := globalIncomingDeps
|
||||
globalIncomingDeps = nil
|
||||
defer func() { globalIncomingDeps = saved }()
|
||||
m2mAutoTickOnce(t.Context(), rc, time.Now())
|
||||
}
|
||||
@@ -31,7 +31,7 @@ var m2mRejectCatalog = []m2mCodeInfo{
|
||||
{"M2M08", "Операция нарушит законодательство РФ",
|
||||
"Проведение перевода приведёт к нарушению закона: например, бумага предназначена только для квалифицированных инвесторов, а инвестор не квалифицирован."},
|
||||
{"M2M09", "Отказ принимающей стороны (доп.)",
|
||||
"Дополнительный код отказа зачисляющей стороны (используется в тест-сценариях)."},
|
||||
"Дополнительный код отказа зачисляющей стороны."},
|
||||
}
|
||||
|
||||
// m2mServiceCatalog — коды сервиса МОСТ (ошибки/информирование). Приходят в ответе
|
||||
@@ -107,9 +107,9 @@ var m2mCodeTexts = map[string]string{
|
||||
"M2M04": "Инвестор не найден",
|
||||
"M2M05": "Зачисляющий депозитарий не оказывает услуги по учёту прав на ценную бумагу",
|
||||
"M2M06": "Приостановление операций по счёту депо зачисления",
|
||||
"M2M07": "Отказ принимающей стороны (тест 07)",
|
||||
"M2M07": "Отказ принимающей стороны",
|
||||
"M2M08": "Проведение операции приведёт к нарушению законодательства РФ",
|
||||
"M2M09": "Отказ принимающей стороны (тест 09)",
|
||||
"M2M09": "Отказ принимающей стороны (дополнительный код)",
|
||||
"M2M10": "Получено невалидное сообщение",
|
||||
"M2M14": "Отправитель не зарегистрирован в справочнике участников M2M",
|
||||
"M2M15": "Получатель (зачисляющий депозитарий) не зарегистрирован в справочнике участников M2M", // предположительно (пара к M2M14); уточнить по Спецификации ЭД
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
type m2mClientCard struct{ Birth, BirthPlace, Issued, INN string }
|
||||
|
||||
var m2mClientCards = map[string]m2mClientCard{
|
||||
"К1": {Birth: "14.03.1999", BirthPlace: "г. Москва", Issued: "ГУ МВД России по г. Москве, 28.03.2019, код подразделения 770-093", INN: "772941830554"},
|
||||
"К1": {}, // заводится у обеих сторон; дополнительные анкетные данные у участников свои
|
||||
}
|
||||
|
||||
type m2mDocSec struct{ ISIN, Code, Name, Qty string }
|
||||
@@ -344,7 +344,7 @@ select.btn{padding:4px 8px}
|
||||
<div class="tw"><table>
|
||||
<thead><tr><th>Клиент</th><th>ФИО</th><th>Документ</th><th>Анкетные данные</th><th>Что сделать</th></tr></thead><tbody>
|
||||
{{range .Clients}}<tr><td><b>{{.Key}}</b></td><td>{{.FIO}}</td><td>{{.Doc}}{{if .Card.Issued}}<div class="note">выдан {{.Card.Issued}}</div>{{end}}</td>
|
||||
<td>{{if .Register}}дата рождения {{.Card.Birth}}<br>место рождения {{.Card.BirthPlace}}<br>гражданство РФ<br>ИНН <span class="mono">{{.Card.INN}}</span>{{else}}<span class="note">не требуются</span>{{end}}</td>
|
||||
<td>{{if and .Register .Card.Birth}}дата рождения {{.Card.Birth}}<br>место рождения {{.Card.BirthPlace}}<br>гражданство РФ<br>ИНН <span class="mono">{{.Card.INN}}</span>{{else if .Register}}<span class="note">по учётным данным участника</span>{{else}}<span class="note">не требуются</span>{{end}}</td>
|
||||
<td>{{if .Register}}Завести у обеих сторон: открытый торговый счёт депо, без признака ИИС, не квалифицированный инвестор. ФИО и паспорт — строго как в таблице.{{else}}<b>Не заводить</b> у стороны, которая зачисляет в кейсах с M2M04. Для отправки запроса по К2 достаточно данных из таблицы.{{end}}</td></tr>
|
||||
{{end}}</tbody></table></div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestM2MQtyOverrides(t *testing.T) {
|
||||
c := m2mCase{ID: "2", Secs: []m2mSec{sSBER("10"), {ISIN: "RU000A0JUR61", Code: "PAI", Qty: "5", Frac: true}}}
|
||||
form := url.Values{"qty_RU0009029540": {"1"}, "qty_RU000A0JUR61": {"2,5"}}
|
||||
r := httptest.NewRequest("POST", "/admin/m2m-testing/initiate", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
got := m2mQtyOverrides(r, c)
|
||||
if got["RU0009029540"] != "1" || got["RU000A0JUR61"] != "2.5" {
|
||||
t.Fatalf("количество: %v", got)
|
||||
}
|
||||
if note := m2mQtyNote(c, got); !strings.Contains(note, "SBER/03 × 1 (по плану 10)") {
|
||||
t.Fatalf("отметка: %q", note)
|
||||
}
|
||||
bad := httptest.NewRequest("GET", "/?qty_RU0009029540=1.5&qty_RU000A0JUR61=-1", nil)
|
||||
if got := m2mQtyOverrides(bad, c); len(got) != 0 {
|
||||
t.Fatalf("дробное для акций и отрицательное — игнорировать: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -36,7 +37,7 @@ type m2mSec struct {
|
||||
type m2mClient struct{ Last, First, Middle, DocSeries, DocNumber, SeedID string }
|
||||
|
||||
var m2mTestClients = map[string]m2mClient{
|
||||
"К1": {"Мостовиков", "Арсений", "Тимурович", "4519", "736284", seedClientK1},
|
||||
"К1": {"Бубликов", "Павел", "Иванович", "3453", "289090", seedClientK1},
|
||||
"К2": {"Баранкин", "Сергей", "Сергеевич", "3763", "290089", seedClientK2},
|
||||
}
|
||||
|
||||
@@ -373,10 +374,11 @@ func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) {
|
||||
ISIN, Code, Name, Qty string
|
||||
Bad bool
|
||||
}
|
||||
qtys := m2mQtyOverrides(r, *c)
|
||||
var secs []pvSec
|
||||
for _, s := range c.Secs {
|
||||
secs = append(secs, pvSec{
|
||||
ISIN: s.ISIN, Code: s.Code, Name: m2mSecName[s.ISIN], Qty: s.Qty,
|
||||
ISIN: s.ISIN, Code: s.Code, Name: m2mSecName[s.ISIN], Qty: pickStr(qtys[s.ISIN], s.Qty),
|
||||
Bad: m2mSecBad(*c, s),
|
||||
})
|
||||
}
|
||||
@@ -420,7 +422,7 @@ func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) {
|
||||
}
|
||||
var ss []depoOutSec
|
||||
for _, s := range c.Secs {
|
||||
ss = append(ss, depoOutSec{ReferenceID: s.Code, ISIN: s.ISIN, Code: s.Code, Qty: s.Qty})
|
||||
ss = append(ss, depoOutSec{ReferenceID: s.Code, ISIN: s.ISIN, Code: s.Code, Qty: pickStr(qtys[s.ISIN], s.Qty)})
|
||||
}
|
||||
return runDepoCheckOut(cl.Last, cl.First, cl.Middle, cl.DocSeries, cl.DocNumber, ss, nsd.AccountID, nsd.SectionID)
|
||||
}(),
|
||||
@@ -562,7 +564,8 @@ func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) {
|
||||
back("Сначала укажите код участника контрагента (кнопка «Контрагент»)")
|
||||
return
|
||||
}
|
||||
guid, err := h.m2mInitiate(*cs, plan)
|
||||
qtys := m2mQtyOverrides(r, *cs)
|
||||
guid, err := h.m2mInitiate(*cs, plan, qtys)
|
||||
m2mMu.Lock()
|
||||
plan = m2mLoadPlan()
|
||||
st := plan.States[id]
|
||||
@@ -574,7 +577,7 @@ func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) {
|
||||
} else {
|
||||
st.Status = "in_progress"
|
||||
st.GUID = guid
|
||||
st.Note = "Отправлено контрагенту " + plan.CounterpartyCode + " (GUID " + guid + "). Ждём ответ."
|
||||
st.Note = "Отправлено контрагенту " + plan.CounterpartyCode + " (GUID " + guid + ")" + m2mQtyNote(*cs, qtys) + ". Ждём ответ."
|
||||
msg = "Кейс " + id + ": исходящий M2M отправлен (GUID " + guid + "). Ответ придёт во «Входящие»."
|
||||
}
|
||||
st.Updated = time.Now()
|
||||
@@ -585,8 +588,41 @@ func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) {
|
||||
})
|
||||
}
|
||||
|
||||
// m2mInitiate строит исходящий M2M по кейсу и отправляет через НРД контрагенту.
|
||||
func (h *setupHandlers) m2mInitiate(c m2mCase, plan m2mPlan) (string, error) {
|
||||
// m2mQtyOverrides — количество по бумагам, выбранное в предпросмотре (qty_<ISIN>); неверное значение
|
||||
// игнорируется (остаётся количество из плана).
|
||||
func m2mQtyOverrides(r *http.Request, c m2mCase) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, s := range c.Secs {
|
||||
v := strings.ReplaceAll(strings.TrimSpace(r.FormValue("qty_"+s.ISIN)), ",", ".")
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil || f <= 0 || (!s.Frac && f != float64(int64(f))) {
|
||||
continue
|
||||
}
|
||||
out[s.ISIN] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// m2mQtyNote — «, количество: SBER/03 × 1» для отметки кейса, если количество меняли.
|
||||
func m2mQtyNote(c m2mCase, qtys map[string]string) string {
|
||||
var parts []string
|
||||
for _, s := range c.Secs {
|
||||
if q, ok := qtys[s.ISIN]; ok && q != s.Qty {
|
||||
parts = append(parts, s.Code+" × "+q+" (по плану "+s.Qty+")")
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return ", количество: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// m2mInitiate строит исходящий M2M по кейсу и отправляет через НРД контрагенту. qtys — количество,
|
||||
// выбранное в предпросмотре (ISIN → количество), иначе — из плана.
|
||||
func (h *setupHandlers) m2mInitiate(c m2mCase, plan m2mPlan, qtys map[string]string) (string, error) {
|
||||
if len(c.Secs) == 0 {
|
||||
return "", fmt.Errorf("в кейсе %s нет бумаг для отправки (это кейс сценария 2 — формируется вручную)", c.ID)
|
||||
}
|
||||
@@ -596,9 +632,10 @@ func (h *setupHandlers) m2mInitiate(c m2mCase, plan m2mPlan) (string, error) {
|
||||
|
||||
secs := make([]ClaimSec, 0, len(c.Secs))
|
||||
for _, s := range c.Secs {
|
||||
qty := Quantity{Whole: uint64(atoiSafe(s.Qty))}
|
||||
q := pickStr(qtys[s.ISIN], s.Qty)
|
||||
qty := Quantity{Whole: uint64(atoiSafe(q))}
|
||||
if s.Frac {
|
||||
qty = Quantity{Fractional: s.Qty}
|
||||
qty = Quantity{Fractional: q}
|
||||
}
|
||||
secs = append(secs, ClaimSec{
|
||||
SecurityCode: s.Code,
|
||||
|
||||
@@ -115,10 +115,8 @@ func writeIncomingBundleZip(zw *zip.Writer, it *IncomingTransfer, prefix string)
|
||||
_, _ = f.Write(faithfulXMLBytes(it.OrderXML))
|
||||
}
|
||||
}
|
||||
for _, rf := range collectOrderResponseRaw(it.OrderContrDID) {
|
||||
if f, e := zw.Create(prefix + "otvet-nrd/" + rf.Name); e == nil {
|
||||
_, _ = f.Write(rf.Data)
|
||||
}
|
||||
if docs := nsdOrderDocs(it.OrderContrDID); len(docs) > 0 {
|
||||
writeNSDDocsZip(zw, prefix+"Ответ НРД/", "Документы НРД по поручению 16/1 № "+it.OrderContrDID, docs, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
package lkgateway
|
||||
|
||||
// manual.go — ручной ввод ИСХОДЯЩЕГО перевода M2M по бумажному поручению клиента.
|
||||
//
|
||||
// Клиент приносит поручение на бумаге, сотрудник депозитария вводит его в bj так, как клиент
|
||||
// заполнил бы заявку в личном кабинете. Дальше перевод идёт тем же путём, что и заявка ЛК:
|
||||
// пакет M2MTransferRequest в НРД → запись в DEPO (sentHook) → блокировка в QUIK (quikSendHook,
|
||||
// если сотрудник не заблокировал вручную) → ответ контрагента → черновик формы 16 → поручение 16
|
||||
// (вручную кнопкой или автоматическим режимом) → отчёт НРД MS010.
|
||||
//
|
||||
// Отправить нельзя, пока не заполнены реквизиты, не выполнена сверка с DEPO (если учётная система
|
||||
// подключена) и не проставлены обязательные отметки сверки с учётной и торговой системами. Каждая
|
||||
// отметка хранит, кто и когда её поставил. Поручения — в manual-orders.json.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
const manualFile = "/var/lib/bj/.bj/manual-orders.json"
|
||||
|
||||
// ManualSec — бумага в поручении.
|
||||
type ManualSec struct {
|
||||
ISIN string `json:"isin"`
|
||||
Code string `json:"code,omitempty"` // код НРД (необязательно; пусто — ISIN)
|
||||
Name string `json:"name,omitempty"`
|
||||
Qty string `json:"qty"`
|
||||
}
|
||||
|
||||
// ManualMark — отметка сотрудника (кто и когда).
|
||||
type ManualMark struct {
|
||||
Done bool `json:"done"`
|
||||
By string `json:"by,omitempty"`
|
||||
At time.Time `json:"at,omitempty"`
|
||||
}
|
||||
|
||||
// ManualDepoCheck — результат сверки с DEPO на момент Hash (данные поручения).
|
||||
type ManualDepoCheck struct {
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by,omitempty"`
|
||||
Hash string `json:"hash"`
|
||||
Ran bool `json:"ran"`
|
||||
Err string `json:"err,omitempty"`
|
||||
Recommend string `json:"recommend,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Investor string `json:"investor,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
Secs []depoSecResult `json:"secs,omitempty"`
|
||||
}
|
||||
|
||||
// ManualOrder — поручение, введённое вручную.
|
||||
type ManualOrder struct {
|
||||
ID int `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Основание
|
||||
PaperNo string `json:"paper_no"`
|
||||
PaperDate string `json:"paper_date"` // ГГГГ-ММ-ДД
|
||||
ReceivedVia string `json:"received_via"`
|
||||
|
||||
// Клиент
|
||||
LastName string `json:"last_name"`
|
||||
FirstName string `json:"first_name"`
|
||||
MiddleName string `json:"middle_name,omitempty"`
|
||||
BirthDate string `json:"birth_date"` // ГГГГ-ММ-ДД
|
||||
DocType string `json:"doc_type"`
|
||||
DocSeries string `json:"doc_series,omitempty"`
|
||||
DocNumber string `json:"doc_number"`
|
||||
DocIssueDate string `json:"doc_issue_date,omitempty"`
|
||||
DocIssuer string `json:"doc_issuer,omitempty"`
|
||||
Contract string `json:"contract,omitempty"`
|
||||
Account string `json:"account"`
|
||||
QuikCode string `json:"quik_code,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
|
||||
// Принимающая сторона
|
||||
RecvINN string `json:"recv_inn"`
|
||||
RecvCode string `json:"recv_code,omitempty"`
|
||||
RecvName string `json:"recv_name,omitempty"`
|
||||
RecvAccount string `json:"recv_account,omitempty"` // счёт клиента у принимающего (для сведения)
|
||||
SettlePlace string `json:"settle_place"` // nrd (СПБ Банк пока не используется)
|
||||
CostInfo bool `json:"cost_info"`
|
||||
|
||||
Securities []ManualSec `json:"securities"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
|
||||
// Сверка и отметки до отправки
|
||||
Depo *ManualDepoCheck `json:"depo,omitempty"`
|
||||
Checks map[string]ManualMark `json:"checks,omitempty"`
|
||||
QuikMode string `json:"quik_mode,omitempty"` // auto | manual | none
|
||||
QuikInstr string `json:"quik_instr,omitempty"`
|
||||
|
||||
// Отправка
|
||||
Status string `json:"status"` // draft | sent | declined
|
||||
GUID string `json:"guid,omitempty"`
|
||||
DealID string `json:"deal_id,omitempty"`
|
||||
SentAt time.Time `json:"sent_at,omitempty"`
|
||||
SentBy string `json:"sent_by,omitempty"`
|
||||
SendErr string `json:"send_err,omitempty"`
|
||||
DeclineReason string `json:"decline_reason,omitempty"`
|
||||
DeclinedBy string `json:"declined_by,omitempty"`
|
||||
DeclinedAt time.Time `json:"declined_at,omitempty"`
|
||||
|
||||
// Отметки после ответа
|
||||
Post map[string]ManualMark `json:"post,omitempty"`
|
||||
}
|
||||
|
||||
// FullName — ФИО клиента.
|
||||
func (o *ManualOrder) FullName() string {
|
||||
return strings.TrimSpace(strings.Join([]string{o.LastName, o.FirstName, o.MiddleName}, " "))
|
||||
}
|
||||
|
||||
// Number — номер поручения в bj.
|
||||
func (o *ManualOrder) Number() string { return fmt.Sprintf("Р-%05d", o.ID) }
|
||||
|
||||
// dataHash — отпечаток данных, от которых зависят сверка и отметки: изменили данные — сверку и
|
||||
// отметки нужно пройти заново.
|
||||
func (o *ManualOrder) dataHash() string {
|
||||
b, _ := json.Marshal([]any{o.LastName, o.FirstName, o.MiddleName, o.BirthDate, o.DocType, o.DocSeries, o.DocNumber,
|
||||
o.Account, o.RecvINN, o.SettlePlace, o.CostInfo, o.Securities})
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// manualCheckDef — обязательная отметка.
|
||||
type manualCheckDef struct{ Key, Group, Title, Hint string }
|
||||
|
||||
// manualPreChecks — отметки перед отправкой (все обязательны).
|
||||
var manualPreChecks = []manualCheckDef{
|
||||
{"paper", "Поручение клиента", "Поручение оформлено правильно",
|
||||
"подпись клиента или представителя, дата, реквизиты бумаг и принимающего депозитария; полномочия представителя (доверенность) проверены"},
|
||||
{"client", "Учётная система депозитария", "Клиент идентифицирован",
|
||||
"ФИО, документ, удостоверяющий личность, и дата рождения совпадают с анкетой депонента"},
|
||||
{"account", "Учётная система депозитария", "Счёт депо открыт, принадлежит клиенту и не является ИИС",
|
||||
"перевод с индивидуального инвестиционного счёта через M2M не выполняется"},
|
||||
{"balance", "Учётная система депозитария", "Остаток достаточен, бумаги свободны",
|
||||
"на торговом разделе не меньше количества в поручении; нет обременений, ареста и блокировок"},
|
||||
{"security", "Учётная система депозитария", "Бумаги допускаются к переводу M2M",
|
||||
"российские выпуски (ISIN начинается с RU), приняты на обслуживание"},
|
||||
{"qual", "Учётная система депозитария", "Ограничения для квалифицированных инвесторов проверены",
|
||||
"если бумага только для квалифицированных инвесторов — статус клиента проверен, клиент предупреждён о возможном отказе"},
|
||||
{"fees", "Учётная система депозитария", "Задолженности по оплате услуг нет",
|
||||
"или задолженность урегулирована по правилам депозитария"},
|
||||
{"counterparty", "Принимающая сторона", "Принимающий депозитарий — участник M2M",
|
||||
"ИНН и наименование сверены со справочником участников НРД"},
|
||||
}
|
||||
|
||||
// manualPostDef — отметки после ответа: when — done (исполнено) | rejected (отказ); quik — только при
|
||||
// ручной блокировке в торговой системе.
|
||||
type manualPostDef struct {
|
||||
Key, When, Title string
|
||||
Quik bool
|
||||
}
|
||||
|
||||
var manualPostChecks = []manualPostDef{
|
||||
{"quik_debit", "done", "Списание в торговой системе проведено (лимит уменьшен окончательно)", true},
|
||||
{"depo_done", "done", "Операция списания отражена в учётной системе депозитария", false},
|
||||
{"client_done", "done", "Клиент уведомлён об исполнении", false},
|
||||
{"paper_done", "done", "На поручении проставлена отметка об исполнении", false},
|
||||
{"quik_unblock", "rejected", "Блокировка в торговой системе снята (лимит восстановлен)", true},
|
||||
{"client_rejected", "rejected", "Клиент уведомлён об отказе и его причине", false},
|
||||
{"paper_rejected", "rejected", "На поручении проставлена отметка об отказе", false},
|
||||
}
|
||||
|
||||
// manualStore — поручения ручного ввода (переживают перезапуск).
|
||||
type manualStore struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
items map[int]*ManualOrder
|
||||
next int
|
||||
}
|
||||
|
||||
var globalManual = loadManualStore(manualFile)
|
||||
|
||||
func loadManualStore(path string) *manualStore {
|
||||
s := &manualStore{path: path, items: map[int]*ManualOrder{}, next: 1}
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
var list []*ManualOrder
|
||||
if json.Unmarshal(b, &list) == nil {
|
||||
for _, o := range list {
|
||||
if o.Status == "sending" { // отправка прервана перезапуском
|
||||
o.Status, o.SendErr = "draft", "отправка прервана перезапуском сервиса — перед повтором проверьте журнал исходящих переводов"
|
||||
}
|
||||
s.items[o.ID] = o
|
||||
if o.ID >= s.next {
|
||||
s.next = o.ID + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *manualStore) saveLocked() error {
|
||||
list := make([]*ManualOrder, 0, len(s.items))
|
||||
for _, o := range s.items {
|
||||
list = append(list, o)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
|
||||
b, err := json.MarshalIndent(list, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
func (s *manualStore) Get(id int) (*ManualOrder, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
o, ok := s.items[id]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
b, _ := json.Marshal(o) // глубокая копия
|
||||
var cp ManualOrder
|
||||
_ = json.Unmarshal(b, &cp)
|
||||
return &cp, true
|
||||
}
|
||||
|
||||
func (s *manualStore) List() []*ManualOrder {
|
||||
s.mu.Lock()
|
||||
ids := make([]int, 0, len(s.items))
|
||||
for id := range s.items {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
|
||||
out := make([]*ManualOrder, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if o, ok := s.Get(id); ok {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Create — новое поручение (ID присваивается).
|
||||
func (s *manualStore) Create(o *ManualOrder) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
o.ID = s.next
|
||||
s.next++
|
||||
o.CreatedAt, o.UpdatedAt = time.Now(), time.Now()
|
||||
s.items[o.ID] = o
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// Update — изменение под замком: fn получает текущую запись.
|
||||
func (s *manualStore) Update(id int, fn func(o *ManualOrder) error) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
o, ok := s.items[id]
|
||||
if !ok {
|
||||
return errors.New("поручение не найдено")
|
||||
}
|
||||
b, _ := json.Marshal(o)
|
||||
var cp ManualOrder
|
||||
_ = json.Unmarshal(b, &cp)
|
||||
if err := fn(&cp); err != nil {
|
||||
return err
|
||||
}
|
||||
cp.UpdatedAt = time.Now()
|
||||
s.items[id] = &cp
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
var (
|
||||
manualINNRe = regexp.MustCompile(`^\d{10}$`)
|
||||
manualISINRe = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{9}\d$`)
|
||||
manualDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
)
|
||||
|
||||
// manualQty — количество: целое или дробное (паи), > 0.
|
||||
func manualQty(s string) (Quantity, bool) {
|
||||
s = strings.ReplaceAll(strings.TrimSpace(s), ",", ".")
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || f <= 0 {
|
||||
return Quantity{}, false
|
||||
}
|
||||
if f == float64(uint64(f)) {
|
||||
return Quantity{Whole: uint64(f)}, true
|
||||
}
|
||||
return Quantity{Fractional: strconv.FormatFloat(f, 'f', -1, 64)}, true
|
||||
}
|
||||
|
||||
// manualDataProblems — что не заполнено или заполнено неверно.
|
||||
func manualDataProblems(o *ManualOrder) []string {
|
||||
var p []string
|
||||
need := func(v, what string) {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
p = append(p, "не заполнено: "+what)
|
||||
}
|
||||
}
|
||||
need(o.PaperNo, "номер поручения клиента")
|
||||
if !manualDateRe.MatchString(o.PaperDate) {
|
||||
p = append(p, "не заполнена дата поручения клиента")
|
||||
}
|
||||
need(o.LastName, "фамилия клиента")
|
||||
need(o.FirstName, "имя клиента")
|
||||
if !manualDateRe.MatchString(o.BirthDate) {
|
||||
p = append(p, "не заполнена дата рождения клиента")
|
||||
}
|
||||
need(o.DocType, "вид документа")
|
||||
need(o.DocNumber, "номер документа")
|
||||
if o.DocType == "21" && len(strings.ReplaceAll(o.DocSeries, " ", "")) != 4 {
|
||||
p = append(p, "серия паспорта РФ — 4 цифры")
|
||||
}
|
||||
need(o.Account, "счёт депо клиента")
|
||||
if !manualINNRe.MatchString(strings.TrimSpace(o.RecvINN)) {
|
||||
p = append(p, "ИНН принимающего депозитария — 10 цифр")
|
||||
}
|
||||
switch {
|
||||
case len(o.Securities) == 0:
|
||||
p = append(p, "не указана бумага")
|
||||
case len(o.Securities) > 1:
|
||||
p = append(p, "в одном поручении — одна бумага: на каждую бумагу оформите отдельное поручение")
|
||||
}
|
||||
for i, s := range o.Securities {
|
||||
isin := strings.ToUpper(strings.TrimSpace(s.ISIN))
|
||||
switch {
|
||||
case !manualISINRe.MatchString(isin):
|
||||
p = append(p, fmt.Sprintf("бумага %d: неверный ISIN «%s»", i+1, s.ISIN))
|
||||
case !isRussianISIN(isin):
|
||||
p = append(p, fmt.Sprintf("бумага %d: %s — иностранный выпуск, через M2M не переводится", i+1, isin))
|
||||
}
|
||||
if _, ok := manualQty(s.Qty); !ok {
|
||||
p = append(p, fmt.Sprintf("бумага %d: количество должно быть больше нуля", i+1))
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// manualDepoConfigured — подключена ли учётная система (сверка обязательна).
|
||||
func manualDepoConfigured(rc *RuntimeConfig) bool {
|
||||
return rc != nil && strings.TrimSpace(rc.Snapshot().Fancy.Server) != ""
|
||||
}
|
||||
|
||||
// manualQuikAutoAvailable — может ли bj сам заблокировать бумаги в торговой системе при отправке.
|
||||
func manualQuikAutoAvailable(rc *RuntimeConfig) bool {
|
||||
if rc == nil {
|
||||
return false
|
||||
}
|
||||
q := rc.Snapshot().Quik
|
||||
return quikActive(q) && q.AutoOut
|
||||
}
|
||||
|
||||
// manualSendBlockers — почему поручение пока нельзя отправить (пусто — можно).
|
||||
func manualSendBlockers(rc *RuntimeConfig, o *ManualOrder) []string {
|
||||
if o.Status != "draft" {
|
||||
return []string{"поручение уже " + manualStatusRu(o.Status)}
|
||||
}
|
||||
b := manualDataProblems(o)
|
||||
if manualDepoConfigured(rc) {
|
||||
switch {
|
||||
case o.Depo == nil:
|
||||
b = append(b, "не выполнена сверка с учётной системой (DEPO)")
|
||||
case o.Depo.Hash != o.dataHash():
|
||||
b = append(b, "данные изменены после сверки с DEPO — выполните сверку заново")
|
||||
case !o.Depo.Ran:
|
||||
b = append(b, "сверка с DEPO не выполнена: "+o.Depo.Err)
|
||||
case o.Depo.Recommend != "confirm":
|
||||
b = append(b, "сверка с DEPO не пройдена: "+o.Depo.Summary)
|
||||
}
|
||||
}
|
||||
for _, c := range manualPreChecks {
|
||||
if !o.Checks[c.Key].Done {
|
||||
b = append(b, "нет отметки: "+c.Title)
|
||||
}
|
||||
}
|
||||
switch o.QuikMode {
|
||||
case "auto":
|
||||
if !manualQuikAutoAvailable(rc) {
|
||||
b = append(b, "автоматическая блокировка в торговой системе не включена — заблокируйте бумаги вручную")
|
||||
}
|
||||
case "manual":
|
||||
if strings.TrimSpace(o.QuikInstr) == "" {
|
||||
b = append(b, "укажите номер поручения на блокировку в торговой системе")
|
||||
}
|
||||
case "none":
|
||||
default:
|
||||
b = append(b, "не выбрано, как заблокированы бумаги в торговой системе")
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func manualStatusRu(s string) string {
|
||||
switch s {
|
||||
case "draft":
|
||||
return "черновик"
|
||||
case "sent":
|
||||
return "отправлено в НРД"
|
||||
case "sending":
|
||||
return "отправляется"
|
||||
case "declined":
|
||||
return "отклонено депозитарием"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// manualClaim — заявка в том же виде, что из личного кабинета.
|
||||
func manualClaim(rc *RuntimeConfig, o *ManualOrder) (CreateClaimRequest, error) {
|
||||
nsd := rc.Snapshot().NSD
|
||||
var secs []ClaimSec
|
||||
for _, s := range o.Securities {
|
||||
isin := strings.ToUpper(strings.TrimSpace(s.ISIN))
|
||||
q, ok := manualQty(s.Qty)
|
||||
if !ok {
|
||||
return CreateClaimRequest{}, fmt.Errorf("количество по %s", isin)
|
||||
}
|
||||
secs = append(secs, ClaimSec{
|
||||
SecurityCode: pick(strings.TrimSpace(s.Code), isin),
|
||||
SecurityDetails: SecurityDetails{ISIN: isin},
|
||||
Quantity: q,
|
||||
SettlementAccounts: []SettlementAccount{{
|
||||
SettlementRequisitesINN: transferringINN(rc.Snapshot()),
|
||||
SettlementLocation: SettlementLocation{DeponentCode: nsd.DeponentCode, AccountID: nsd.AccountID, SectionID: nsd.SectionID},
|
||||
}},
|
||||
})
|
||||
}
|
||||
cost := CostInfo{No: &struct{}{}}
|
||||
if o.CostInfo {
|
||||
cost = CostInfo{Yes: &CostInfoYes{Code: nsd.DeponentCode}}
|
||||
}
|
||||
ser := strings.ReplaceAll(strings.TrimSpace(o.DocSeries), " ", "")
|
||||
return CreateClaimRequest{
|
||||
Investor: Investor{
|
||||
ID: lkInvestorUUID("manual:" + ser + strings.TrimSpace(o.DocNumber)),
|
||||
LastName: strings.TrimSpace(o.LastName), FirstName: strings.TrimSpace(o.FirstName), MiddleName: strings.TrimSpace(o.MiddleName),
|
||||
Document: Document{DocumentType: o.DocType, Series: ser, Number: strings.TrimSpace(o.DocNumber)},
|
||||
BirthDate: o.BirthDate,
|
||||
},
|
||||
TransferringDepositoryINN: transferringINN(rc.Snapshot()),
|
||||
ReceivingDepositoryINN: strings.TrimSpace(o.RecvINN),
|
||||
CostInfo: cost,
|
||||
Securities: secs,
|
||||
SignatureFormat: "PAPER",
|
||||
ClientAccount: strings.ToUpper(strings.TrimSpace(o.Account)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// manualDepoRun — сверка поручения с DEPO теми же проверками, что и заявка ЛК.
|
||||
func manualDepoRun(rc *RuntimeConfig, o *ManualOrder) *ManualDepoCheck {
|
||||
nsd := rc.Snapshot().NSD
|
||||
var secs []depoOutSec
|
||||
for i, s := range o.Securities {
|
||||
secs = append(secs, depoOutSec{ReferenceID: strconv.Itoa(i + 1), ISIN: strings.ToUpper(strings.TrimSpace(s.ISIN)), Code: s.Code, Qty: s.Qty})
|
||||
}
|
||||
res := runDepoCheckOutAccount(o.LastName, o.FirstName, o.MiddleName, strings.ReplaceAll(o.DocSeries, " ", ""), strings.TrimSpace(o.DocNumber), o.BirthDate,
|
||||
secs, nsd.AccountID, nsd.SectionID, strings.ToUpper(strings.TrimSpace(o.Account)))
|
||||
return &ManualDepoCheck{At: time.Now(), Hash: o.dataHash(), Ran: res.Ran, Err: res.Err, Recommend: res.Recommend,
|
||||
Summary: res.Summary, Investor: res.Investor, Account: res.Account, Secs: res.Secs}
|
||||
}
|
||||
|
||||
// manualDepoClient — клиент по документу из DEPO: ФИО, дата рождения, открытый торговый счёт.
|
||||
type manualDepoClient struct {
|
||||
Found bool
|
||||
Name, Birth, Account string
|
||||
Err string
|
||||
}
|
||||
|
||||
func manualDepoLookup(rc *RuntimeConfig, ser, num string) manualDepoClient {
|
||||
var out manualDepoClient
|
||||
if !manualDepoConfigured(rc) {
|
||||
out.Err = "учётная система (DEPO) не подключена"
|
||||
return out
|
||||
}
|
||||
fs := rc.Snapshot().Fancy
|
||||
db, ctx, cancel, errS := depoOpen(fs)
|
||||
if errS != "" {
|
||||
out.Err = errS
|
||||
return out
|
||||
}
|
||||
defer cancel()
|
||||
defer db.Close()
|
||||
p := depoResolveParty(ctx, db, fs, strings.ReplaceAll(ser, " ", ""), strings.TrimSpace(num), "")
|
||||
if !p.faceOK {
|
||||
out.Err = "клиент с таким документом в DEPO не найден"
|
||||
return out
|
||||
}
|
||||
out.Found, out.Name, out.Account = true, p.faceName, p.accOpen
|
||||
if p.birth.Valid {
|
||||
out.Birth = p.birth.Time.Format("2006-01-02")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// manualDepoSecNames — наименования бумаг из DEPO по ISIN (не найденные — без записи).
|
||||
func manualDepoSecNames(rc *RuntimeConfig, isins []string) map[string]string {
|
||||
out := map[string]string{}
|
||||
if !manualDepoConfigured(rc) || len(isins) == 0 {
|
||||
return out
|
||||
}
|
||||
fs := rc.Snapshot().Fancy
|
||||
db, ctx, cancel, errS := depoOpen(fs)
|
||||
if errS != "" {
|
||||
return out
|
||||
}
|
||||
defer cancel()
|
||||
defer db.Close()
|
||||
for _, isin := range isins {
|
||||
if _, name, found, _ := depoSecurity(ctx, db, isin); found {
|
||||
out[isin] = name
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// manualQuikSkipKey — в контексте отправки: бумаги в торговой системе заблокированы вручную
|
||||
// (или торговая система не используется) — хук QUIK не вызывается.
|
||||
type manualQuikSkipKey struct{}
|
||||
|
||||
func manualQuikSkipped(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(manualQuikSkipKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// manualSend — отправка поручения в НРД тем же путём, что и заявка из личного кабинета.
|
||||
func manualSend(ctx context.Context, rc *RuntimeConfig, svc *Service, o *ManualOrder) (guid, dealID string, err error) {
|
||||
claim, err := manualClaim(rc, o)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if ss, ok := svc.store.(*SeedStore); ok {
|
||||
nsd := rc.Snapshot().NSD
|
||||
ss.EnsureLKClient(LKUser{
|
||||
Login: "manual:" + o.Number(), Contract: claim.Investor.ID,
|
||||
LastName: claim.Investor.LastName, FirstName: claim.Investor.FirstName, MiddleName: claim.Investor.MiddleName,
|
||||
DocType: claim.Investor.Document.DocumentType, DocSeries: claim.Investor.Document.Series, DocNumber: claim.Investor.Document.Number,
|
||||
Active: true,
|
||||
}, nil, m2mcore.DepoAccount{
|
||||
DeponentCode: nsd.DeponentCode,
|
||||
AccountID: m2m.AccountID(nsd.AccountID),
|
||||
SectionID: nsd.SectionID,
|
||||
DepositoryINN: m2m.OrganizationINN(settlementPlaceINN(o.SettlePlace)),
|
||||
})
|
||||
}
|
||||
req, err := svc.BuildRequest(ctx, claim)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("пакет не прошёл проверку формата НРД: %w", err)
|
||||
}
|
||||
sender := svc.sender
|
||||
if emu, _ := lkEmulateNSD(rc); emu && lkEmuSender != nil {
|
||||
sender = lkEmuSender
|
||||
}
|
||||
if o.QuikMode != "auto" {
|
||||
ctx = context.WithValue(ctx, manualQuikSkipKey{}, true)
|
||||
}
|
||||
resp, err := svc.SendBuiltRequestVia(ctx, sender, req, claim.Investor.ID, nil)
|
||||
if err != nil {
|
||||
return string(req.Header.GUID), "", err
|
||||
}
|
||||
return string(req.Header.GUID), resp.ID, nil
|
||||
}
|
||||
|
||||
// ---- ход исполнения (для карточки) ----
|
||||
|
||||
// manualStep — шаг исполнения: State — done | wait | todo | fail | skip.
|
||||
type manualStep struct {
|
||||
Title, Detail, State string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// manualProgress — ход исполнения отправленного поручения и итог: outcome — "" (в работе) |
|
||||
// done | rejected.
|
||||
type manualProgress struct {
|
||||
Steps []manualStep
|
||||
Outcome string
|
||||
Claim *ClaimView
|
||||
Depo *OutgoingDeal
|
||||
Quik []*QuikLink
|
||||
}
|
||||
|
||||
func manualProgressFor(ctx context.Context, svc *Service, o *ManualOrder) manualProgress {
|
||||
var pr manualProgress
|
||||
add := func(title, detail, state string, at time.Time) {
|
||||
pr.Steps = append(pr.Steps, manualStep{Title: title, Detail: detail, State: state, At: at})
|
||||
}
|
||||
add("Поручение введено", o.CreatedBy, "done", o.CreatedAt)
|
||||
if o.Depo != nil {
|
||||
st := "done"
|
||||
if !o.Depo.Ran || o.Depo.Recommend != "confirm" {
|
||||
st = "fail"
|
||||
}
|
||||
add("Сверка с учётной системой", pick(o.Depo.Summary, o.Depo.Err), st, o.Depo.At)
|
||||
}
|
||||
if o.Status == "declined" {
|
||||
add("Отклонено депозитарием", o.DeclineReason, "fail", o.DeclinedAt)
|
||||
pr.Outcome = "rejected"
|
||||
return pr
|
||||
}
|
||||
if o.Status != "sent" {
|
||||
add("Отправка в НРД", "после сверки и отметок", "todo", time.Time{})
|
||||
return pr
|
||||
}
|
||||
add("Пакет отправлен в НРД", "GUID "+o.GUID, "done", o.SentAt)
|
||||
if svc != nil && o.DealID != "" {
|
||||
if v, err := svc.GetClaim(ctx, o.DealID); err == nil {
|
||||
pr.Claim = &v
|
||||
}
|
||||
}
|
||||
if o.GUID != "" {
|
||||
pr.Quik = globalQuikLinks.ForGUID(o.GUID)
|
||||
if globalOutgoingStore != nil {
|
||||
if d, ok := scanForm16Deal(globalOutgoingStore, o.GUID); ok {
|
||||
pr.Depo = d
|
||||
}
|
||||
}
|
||||
}
|
||||
c := pr.Claim
|
||||
switch {
|
||||
case c == nil:
|
||||
add("Ответ сервиса MOEX МОСТ", "нет данных о переводе", "wait", time.Time{})
|
||||
case c.M2MResponse != nil && c.M2MResponse.StatusCode == "ERROR":
|
||||
var codes []string
|
||||
for _, r := range c.M2MResponse.Responses {
|
||||
codes = append(codes, strings.TrimSpace(r.Code+" "+r.Text))
|
||||
}
|
||||
add("Ответ сервиса MOEX МОСТ: ошибка", strings.Join(codes, "; "), "fail", c.UpdatedAt)
|
||||
pr.Outcome = "rejected"
|
||||
return pr
|
||||
case c.M2MResponse != nil:
|
||||
add("Пакет принят сервисом MOEX МОСТ", c.M2MResponse.StatusCode, "done", time.Time{})
|
||||
default:
|
||||
add("Ответ сервиса MOEX МОСТ", "ждём квитанцию", "wait", time.Time{})
|
||||
}
|
||||
if c != nil && c.M2MDecision != nil {
|
||||
rejected := false
|
||||
var codes []string
|
||||
for _, s := range c.M2MDecision.Securities {
|
||||
if s.Outcome == "rejected" {
|
||||
rejected = true
|
||||
codes = append(codes, s.RejectCodes...)
|
||||
}
|
||||
}
|
||||
if rejected {
|
||||
add("Принимающий депозитарий отказал", rejectCodesLabel(codes), "fail", time.Time{})
|
||||
pr.Outcome = "rejected"
|
||||
return pr
|
||||
}
|
||||
add("Принимающий депозитарий согласился", "", "done", time.Time{})
|
||||
} else if c != nil && (c.Status == "rejected" || c.Status == "timed_out") {
|
||||
add("Перевод не состоялся", russianState(c.Status), "fail", c.UpdatedAt)
|
||||
pr.Outcome = "rejected"
|
||||
return pr
|
||||
} else if pr.Depo == nil {
|
||||
add("Решение принимающего депозитария", "ждём ответ контрагента", "wait", time.Time{})
|
||||
}
|
||||
d := pr.Depo
|
||||
switch {
|
||||
case d == nil:
|
||||
add("Черновик поручения 16 от НРД", "придёт после согласия контрагента", "todo", time.Time{})
|
||||
add("Поручение 16 подано", "", "todo", time.Time{})
|
||||
add("Отчёт НРД об исполнении (MS010)", "", "todo", time.Time{})
|
||||
return pr
|
||||
case !d.Submitted:
|
||||
add("Черновик поручения 16 получен", "", "done", d.DraftAt)
|
||||
add("Поручение 16 подано", "подайте поручение кнопкой ниже (в автоматическом режиме bj подаёт сам)", "wait", time.Time{})
|
||||
add("Отчёт НРД об исполнении (MS010)", "", "todo", time.Time{})
|
||||
return pr
|
||||
}
|
||||
add("Черновик поручения 16 получен", "", "done", d.DraftAt)
|
||||
add("Поручение 16 подано", "№ "+strings.Join(d.ContrDIDs, ", "), "done", d.OrderAt)
|
||||
if d.Done {
|
||||
add("Отчёт НРД об исполнении (MS010)", "бумаги списаны", "done", time.Time{})
|
||||
pr.Outcome = "done"
|
||||
} else {
|
||||
add("Отчёт НРД об исполнении (MS010)", "ждём встречное поручение контрагента и отчёт НРД", "wait", time.Time{})
|
||||
}
|
||||
return pr
|
||||
}
|
||||
|
||||
// manualDocTypes — виды документов (справочник НРД).
|
||||
var manualDocTypes = []struct{ Code, Title string }{
|
||||
{"21", "Паспорт гражданина РФ"},
|
||||
{"03", "Свидетельство о рождении"},
|
||||
{"10", "Паспорт иностранного гражданина"},
|
||||
{"12", "Вид на жительство в РФ"},
|
||||
{"22", "Заграничный паспорт гражданина РФ"},
|
||||
{"07", "Военный билет"},
|
||||
{"91", "Иной документ"},
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package lkgateway
|
||||
|
||||
// manual_depo.go — подбор клиента и бумаг из DEPO для ручного ввода: поиск по ФИО или номеру
|
||||
// документа, открытые торговые счета, бумаги на торговом разделе с остатком.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// manualDepoAcc — открытый торговый счёт клиента.
|
||||
type manualDepoAcc struct {
|
||||
ClientID int64 `json:"client_id"`
|
||||
Number string `json:"number"`
|
||||
Contract string `json:"contract,omitempty"`
|
||||
IIS bool `json:"iis,omitempty"`
|
||||
}
|
||||
|
||||
// manualDepoFace — клиент из картотеки DEPO.
|
||||
type manualDepoFace struct {
|
||||
LastName string `json:"last_name"`
|
||||
FirstName string `json:"first_name"`
|
||||
MiddleName string `json:"middle_name,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Birth string `json:"birth,omitempty"` // ГГГГ-ММ-ДД
|
||||
DocType string `json:"doc_type,omitempty"`
|
||||
DocSer string `json:"doc_ser,omitempty"`
|
||||
DocNum string `json:"doc_num"`
|
||||
DocDate string `json:"doc_date,omitempty"`
|
||||
DocIssuer string `json:"doc_issuer,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Accounts []manualDepoAcc `json:"accounts"`
|
||||
}
|
||||
|
||||
// manualDepoPos — бумага на торговом разделе клиента.
|
||||
type manualDepoPos struct {
|
||||
ISIN string `json:"isin"`
|
||||
Name string `json:"name"`
|
||||
Qty float64 `json:"qty"`
|
||||
Russian bool `json:"russian"`
|
||||
}
|
||||
|
||||
// depoDocTypeNSD — вид документа DEPO → код справочника НРД (паспорт РФ в DEPO — 20).
|
||||
func depoDocTypeNSD(t int64) string {
|
||||
if t == 20 {
|
||||
return "21"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// manualDepoSearch — клиенты с открытым торговым счётом по ФИО (часть, без учёта регистра) или номеру документа.
|
||||
func manualDepoSearch(rc *RuntimeConfig, q string) ([]manualDepoFace, string) {
|
||||
q = strings.Join(strings.Fields(q), " ")
|
||||
if len([]rune(q)) < 3 {
|
||||
return nil, "введите не меньше 3 символов фамилии или номер документа"
|
||||
}
|
||||
if !manualDepoConfigured(rc) {
|
||||
return nil, "учётная система (DEPO) не подключена"
|
||||
}
|
||||
fs := rc.Snapshot().Fancy
|
||||
db, ctx, cancel, errS := depoOpen(fs)
|
||||
if errS != "" {
|
||||
return nil, errS
|
||||
}
|
||||
defer cancel()
|
||||
defer db.Close()
|
||||
digits := strings.ReplaceAll(q, " ", "")
|
||||
// Действующая версия карточки (LAST_FLAG=1); счета привязаны к исходной записи (SELF_ID).
|
||||
rows, err := db.QueryContext(ctx, `SELECT TOP 20 ISNULL(NULLIF(f.SELF_ID,0),f.ID), ISNULL(f.NAME,''), f.DATE_BIRTH, ISNULL(f.DOC_TYPE,0), ISNULL(f.DOC_SER,''), ISNULL(f.DOC_NUM,''),
|
||||
f.DOC_DAT, ISNULL(f.DOC_PLACE,''), ISNULL(f.DOC_CODE,''), ISNULL(f.PHONE,'')
|
||||
FROM OD_FACES f
|
||||
WHERE ISNULL(f.LAST_FLAG,1)=1 AND (UPPER(f.NAME) LIKE UPPER(@q) OR f.DOC_NUM=@d OR REPLACE(ISNULL(f.DOC_SER,''),' ','')+f.DOC_NUM=@d)
|
||||
AND EXISTS (SELECT 1 FROM OD_CLIENTS c WHERE c.FACE=ISNULL(NULLIF(f.SELF_ID,0),f.ID) AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL)
|
||||
ORDER BY f.NAME`, sql.Named("q", q+"%"), sql.Named("d", digits))
|
||||
if err != nil {
|
||||
return nil, "поиск в DEPO: " + err.Error()
|
||||
}
|
||||
type faceRow struct {
|
||||
id int64
|
||||
f manualDepoFace
|
||||
}
|
||||
var found []faceRow
|
||||
for rows.Next() {
|
||||
var r faceRow
|
||||
var birth, docDat sql.NullTime
|
||||
var docType int64
|
||||
var place, code string
|
||||
if rows.Scan(&r.id, &r.f.Name, &birth, &docType, &r.f.DocSer, &r.f.DocNum, &docDat, &place, &code, &r.f.Phone) != nil {
|
||||
continue
|
||||
}
|
||||
p := strings.Fields(r.f.Name)
|
||||
if len(p) > 0 {
|
||||
r.f.LastName = p[0]
|
||||
}
|
||||
if len(p) > 1 {
|
||||
r.f.FirstName = p[1]
|
||||
}
|
||||
if len(p) > 2 {
|
||||
r.f.MiddleName = strings.Join(p[2:], " ")
|
||||
}
|
||||
if birth.Valid {
|
||||
r.f.Birth = birth.Time.Format("2006-01-02")
|
||||
}
|
||||
if docDat.Valid {
|
||||
r.f.DocDate = docDat.Time.Format("2006-01-02")
|
||||
}
|
||||
r.f.DocType = depoDocTypeNSD(docType)
|
||||
r.f.DocSer = strings.ReplaceAll(r.f.DocSer, " ", "")
|
||||
r.f.DocIssuer = strings.TrimSpace(place)
|
||||
if code = strings.TrimSpace(code); code != "" {
|
||||
r.f.DocIssuer += ", код подразделения " + code
|
||||
}
|
||||
found = append(found, r)
|
||||
}
|
||||
rows.Close()
|
||||
out := make([]manualDepoFace, 0, len(found))
|
||||
for _, r := range found {
|
||||
ar, err := db.QueryContext(ctx, `SELECT c.ID, ISNULL(c.NUMBER,''),
|
||||
ISNULL((SELECT TOP 1 p.PORT_NUM FROM OD_CLIENTS_PORTS p WHERE p.CLIENT=c.ID ORDER BY p.PORT_DATE, p.PORT_NUM),'')
|
||||
FROM OD_CLIENTS c WHERE c.FACE=@f AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL ORDER BY c.ID`, sql.Named("f", r.id))
|
||||
if err == nil {
|
||||
for ar.Next() {
|
||||
var a manualDepoAcc
|
||||
if ar.Scan(&a.ClientID, &a.Number, &a.Contract) == nil {
|
||||
r.f.Accounts = append(r.f.Accounts, a)
|
||||
}
|
||||
}
|
||||
ar.Close()
|
||||
}
|
||||
for i := range r.f.Accounts {
|
||||
ok, _ := depoAccountIIS(ctx, db, fs, r.f.Accounts[i].ClientID)
|
||||
r.f.Accounts[i].IIS = !ok
|
||||
}
|
||||
out = append(out, r.f)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, "клиентов с открытым торговым счётом не найдено"
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
// manualDepoPositions — бумаги с положительным остатком на торговом разделе счёта (98070.04).
|
||||
func manualDepoPositions(rc *RuntimeConfig, clientID int64) ([]manualDepoPos, string) {
|
||||
if !manualDepoConfigured(rc) {
|
||||
return nil, "учётная система (DEPO) не подключена"
|
||||
}
|
||||
db, ctx, cancel, errS := depoOpen(rc.Snapshot().Fancy)
|
||||
if errS != "" {
|
||||
return nil, errS
|
||||
}
|
||||
defer cancel()
|
||||
defer db.Close()
|
||||
rows, err := db.QueryContext(ctx, `SELECT ISNULL(v.ISIN,''), ISNULL(v.NAME,''), SUM(CAST(r.K AS float))
|
||||
FROM OD_RESTS r JOIN OD_VALUES v ON v.ID=r.VALUE_ID
|
||||
WHERE r.REG_1=@c AND r.BAL_ACC=@b
|
||||
GROUP BY v.ISIN, v.NAME HAVING SUM(CAST(r.K AS float)) > 0 ORDER BY v.NAME`,
|
||||
sql.Named("c", clientID), sql.Named("b", depoTradeBalAcc))
|
||||
if err != nil {
|
||||
return nil, "остатки в DEPO: " + err.Error()
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []manualDepoPos
|
||||
for rows.Next() {
|
||||
var p manualDepoPos
|
||||
if rows.Scan(&p.ISIN, &p.Name, &p.Qty) == nil {
|
||||
p.ISIN = strings.ToUpper(strings.TrimSpace(p.ISIN))
|
||||
p.Russian = isRussianISIN(p.ISIN)
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
// registerManualDepo — GET /admin/manual/depo/search?q= и /admin/manual/depo/positions?client=ID (JSON).
|
||||
func registerManualDepo(mux *http.ServeMux, rc *RuntimeConfig) {
|
||||
reply := func(w http.ResponseWriter, v any, errS string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": v, "error": errS})
|
||||
}
|
||||
mux.HandleFunc("/admin/manual/depo/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
items, errS := manualDepoSearch(rc, r.URL.Query().Get("q"))
|
||||
reply(w, items, errS)
|
||||
})
|
||||
mux.HandleFunc("/admin/manual/depo/positions", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.URL.Query().Get("client"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
reply(w, nil, "не указан счёт")
|
||||
return
|
||||
}
|
||||
items, errS := manualDepoPositions(rc, id)
|
||||
reply(w, items, errS)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package lkgateway
|
||||
|
||||
// manual_handlers.go — страницы раздела «Ручной ввод M2M» (/admin/manual).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type manualPageData struct {
|
||||
page
|
||||
Mode string // list | edit | card | print
|
||||
Flash string
|
||||
List []manualListRow
|
||||
O *ManualOrder
|
||||
Handbook []HandbookEntry
|
||||
DocTypes []struct{ Code, Title string }
|
||||
PreChecks []manualCheckDef
|
||||
PostChecks []manualPostDef
|
||||
Problems []string // незаполненные реквизиты (для формы)
|
||||
Blockers []string // почему нельзя отправить
|
||||
DepoOn bool // учётная система подключена — сверка обязательна
|
||||
DepoStale bool // данные изменены после сверки
|
||||
QuikAuto bool // bj может сам заблокировать бумаги в торговой системе
|
||||
Progress manualProgress
|
||||
NSD NSDSettings
|
||||
Auto bool // автоматический режим M2M включён (поручение 16 подаётся само)
|
||||
}
|
||||
|
||||
// manualListRow — строка списка.
|
||||
type manualListRow struct {
|
||||
O *ManualOrder
|
||||
State string // draft | sent | declined | done | rejected
|
||||
StateRu string
|
||||
}
|
||||
|
||||
func manualActor(w http.ResponseWriter) string {
|
||||
if u := currentUser(w); u != nil {
|
||||
if u.Name != "" {
|
||||
return u.Name + " (" + u.Login + ")"
|
||||
}
|
||||
return u.Login
|
||||
}
|
||||
return "без входа"
|
||||
}
|
||||
|
||||
func registerManual(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
tpl, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS,
|
||||
"web/templates/layout.html", "web/templates/admin_manual.html")
|
||||
if err != nil {
|
||||
log.Fatalf("lk-gateway: шаблон admin_manual: %v", err)
|
||||
}
|
||||
help, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS,
|
||||
"web/templates/layout.html", "web/templates/admin_help_manual.html")
|
||||
if err != nil {
|
||||
log.Fatalf("lk-gateway: шаблон admin_help_manual: %v", err)
|
||||
}
|
||||
mux.HandleFunc("/admin/help/manual", func(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, help, nowPage("Ручной ввод M2M: инструкция", "help"))
|
||||
})
|
||||
base := func(title string) manualPageData {
|
||||
return manualPageData{
|
||||
page: nowPage(title, "manual"), DocTypes: manualDocTypes, PreChecks: manualPreChecks, PostChecks: manualPostChecks,
|
||||
DepoOn: manualDepoConfigured(rc), QuikAuto: manualQuikAutoAvailable(rc), NSD: rc.Snapshot().NSD,
|
||||
}
|
||||
}
|
||||
back := func(w http.ResponseWriter, r *http.Request, to, msg, anchor string) {
|
||||
if msg != "" {
|
||||
to += "?flash=" + url.QueryEscape(msg)
|
||||
}
|
||||
http.Redirect(w, r, to+anchor, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
mux.HandleFunc("/admin/manual", func(w http.ResponseWriter, r *http.Request) {
|
||||
d := base("Ручной ввод M2M")
|
||||
d.Mode, d.Flash = "list", r.URL.Query().Get("flash")
|
||||
var drafts map[string]*OutgoingDeal
|
||||
for _, o := range globalManual.List() {
|
||||
row := manualListRow{O: o, State: o.Status, StateRu: manualStatusRu(o.Status)}
|
||||
if o.Status == "sent" {
|
||||
if drafts == nil { // INBOX читаем один раз на список
|
||||
drafts = map[string]*OutgoingDeal{}
|
||||
for _, dd := range scanForm16Drafts(globalOutgoingStore) {
|
||||
drafts[dd.GUID] = dd
|
||||
}
|
||||
}
|
||||
row.StateRu = "в работе"
|
||||
if dd := drafts[o.GUID]; dd != nil && dd.Done {
|
||||
row.State, row.StateRu = "done", "исполнено"
|
||||
} else if o.DealID != "" && svc != nil {
|
||||
if v, err := svc.GetClaim(r.Context(), o.DealID); err == nil && (v.Status == "rejected" || v.Status == "timed_out") {
|
||||
row.State, row.StateRu = "rejected", "отказ"
|
||||
}
|
||||
}
|
||||
}
|
||||
d.List = append(d.List, row)
|
||||
}
|
||||
render(w, tpl, d)
|
||||
})
|
||||
|
||||
registerManualDepo(mux, rc)
|
||||
|
||||
mux.HandleFunc("/admin/manual/new", func(w http.ResponseWriter, r *http.Request) {
|
||||
d := base("Новое поручение")
|
||||
d.Mode = "edit"
|
||||
d.O = &ManualOrder{Status: "draft", DocType: "21", SettlePlace: "nrd", PaperDate: time.Now().Format("2006-01-02"),
|
||||
ReceivedVia: "лично в офисе", Securities: []ManualSec{{}}}
|
||||
d.Handbook = handbookParticipants()
|
||||
render(w, tpl, d)
|
||||
})
|
||||
|
||||
// Сохранение формы (новое или черновик). do: save | lookup (заполнить из DEPO) | depo (сверка).
|
||||
mux.HandleFunc("/admin/manual/save", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
in := manualFromForm(r)
|
||||
id, _ := strconv.Atoi(r.FormValue("id"))
|
||||
actor := manualActor(w)
|
||||
msg := "Поручение сохранено"
|
||||
if id == 0 {
|
||||
in.Status, in.CreatedBy = "draft", actor
|
||||
if err := globalManual.Create(in); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
id = in.ID
|
||||
} else {
|
||||
reset := false
|
||||
err := globalManual.Update(id, func(o *ManualOrder) error {
|
||||
if o.Status != "draft" {
|
||||
return fmt.Errorf("поручение уже %s — изменить нельзя", manualStatusRu(o.Status))
|
||||
}
|
||||
old := o.dataHash()
|
||||
manualCopyData(o, in)
|
||||
if o.dataHash() != old && (o.Depo != nil || len(o.Checks) > 0) {
|
||||
o.Depo, o.Checks, reset = nil, nil, true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
back(w, r, fmt.Sprintf("/admin/manual/%d", id), err.Error(), "")
|
||||
return
|
||||
}
|
||||
if reset {
|
||||
msg = "Данные изменены — сверка и отметки сброшены, пройдите их заново"
|
||||
}
|
||||
}
|
||||
switch r.FormValue("do") {
|
||||
case "lookup":
|
||||
msg = manualDoLookup(rc, id)
|
||||
case "depo":
|
||||
msg = manualDoDepo(rc, id, actor)
|
||||
}
|
||||
anchor := ""
|
||||
if r.FormValue("do") == "depo" {
|
||||
anchor = "#depo"
|
||||
}
|
||||
back(w, r, fmt.Sprintf("/admin/manual/%d", id), msg, anchor)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/admin/manual/", func(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/admin/manual/")
|
||||
parts := strings.SplitN(rest, "/", 2)
|
||||
id, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
action := ""
|
||||
if len(parts) == 2 {
|
||||
action = parts[1]
|
||||
}
|
||||
o, ok := globalManual.Get(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
card := fmt.Sprintf("/admin/manual/%d", id)
|
||||
if action != "" && action != "print" && r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
actor := manualActor(w)
|
||||
switch action {
|
||||
case "", "print":
|
||||
d := base("Поручение " + o.Number())
|
||||
d.O, d.Flash = o, r.URL.Query().Get("flash")
|
||||
d.Mode = "card"
|
||||
if action == "print" {
|
||||
d.Mode = "print"
|
||||
}
|
||||
if o.Status == "draft" {
|
||||
d.Handbook = handbookParticipants()
|
||||
d.Problems = manualDataProblems(o)
|
||||
d.Blockers = manualSendBlockers(rc, o)
|
||||
d.DepoStale = o.Depo != nil && o.Depo.Hash != o.dataHash()
|
||||
if len(o.Securities) == 0 { // форма показывает строку бумаги всегда
|
||||
o.Securities = []ManualSec{{}}
|
||||
}
|
||||
}
|
||||
d.Progress = manualProgressFor(r.Context(), svc, o)
|
||||
d.Auto = rc.Snapshot().M2MAuto.Enabled
|
||||
render(w, tpl, d)
|
||||
case "checks":
|
||||
_ = r.ParseForm()
|
||||
err := globalManual.Update(id, func(o *ManualOrder) error {
|
||||
if o.Status != "draft" {
|
||||
return fmt.Errorf("поручение уже %s", manualStatusRu(o.Status))
|
||||
}
|
||||
if o.Checks == nil {
|
||||
o.Checks = map[string]ManualMark{}
|
||||
}
|
||||
for _, c := range manualPreChecks {
|
||||
manualMark(o.Checks, c.Key, r.FormValue("check_"+c.Key) != "", actor)
|
||||
}
|
||||
switch m := r.FormValue("quik_mode"); m {
|
||||
case "auto", "manual", "none":
|
||||
o.QuikMode = m
|
||||
}
|
||||
o.QuikInstr = strings.TrimSpace(r.FormValue("quik_instr"))
|
||||
return nil
|
||||
})
|
||||
msg := ""
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
back(w, r, card, msg, "#checks")
|
||||
case "send":
|
||||
var blockers []string
|
||||
err := globalManual.Update(id, func(o *ManualOrder) error {
|
||||
if blockers = manualSendBlockers(rc, o); len(blockers) > 0 {
|
||||
return fmt.Errorf("отправить нельзя: %s", strings.Join(blockers, "; "))
|
||||
}
|
||||
o.Status, o.SendErr = "sending", ""
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
back(w, r, card, err.Error(), "")
|
||||
return
|
||||
}
|
||||
o, _ = globalManual.Get(id)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
guid, dealID, serr := manualSend(ctx, rc, svc, o)
|
||||
cancel()
|
||||
msg := ""
|
||||
_ = globalManual.Update(id, func(o *ManualOrder) error {
|
||||
if serr != nil {
|
||||
o.Status, o.SendErr = "draft", serr.Error()
|
||||
msg = "Поручение не отправлено: " + serr.Error()
|
||||
return nil
|
||||
}
|
||||
o.Status, o.GUID, o.DealID, o.SentAt, o.SentBy = "sent", guid, dealID, time.Now(), actor
|
||||
msg = "Поручение отправлено в НРД. Ход исполнения — ниже на этой странице."
|
||||
return nil
|
||||
})
|
||||
log.Printf("lk-gateway: ручной ввод %s (%s): отправка — %s", o.Number(), o.FullName(), msg)
|
||||
back(w, r, card, msg, "")
|
||||
case "decline":
|
||||
reason := strings.TrimSpace(r.FormValue("reason"))
|
||||
err := globalManual.Update(id, func(o *ManualOrder) error {
|
||||
if o.Status != "draft" {
|
||||
return fmt.Errorf("поручение уже %s", manualStatusRu(o.Status))
|
||||
}
|
||||
if reason == "" {
|
||||
return fmt.Errorf("укажите причину отказа")
|
||||
}
|
||||
o.Status, o.DeclineReason, o.DeclinedBy, o.DeclinedAt = "declined", reason, actor, time.Now()
|
||||
return nil
|
||||
})
|
||||
msg := "Поручение отклонено депозитарием"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
back(w, r, card, msg, "")
|
||||
case "post":
|
||||
_ = r.ParseForm()
|
||||
err := globalManual.Update(id, func(o *ManualOrder) error {
|
||||
if o.Post == nil {
|
||||
o.Post = map[string]ManualMark{}
|
||||
}
|
||||
for _, c := range manualPostChecks {
|
||||
if _, shown := r.Form["shown_"+c.Key]; shown {
|
||||
manualMark(o.Post, c.Key, r.FormValue("post_"+c.Key) != "", actor)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
msg := ""
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
back(w, r, card, msg, "#post")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// manualMark — поставить или снять отметку (время и автор — при постановке).
|
||||
func manualMark(m map[string]ManualMark, key string, on bool, actor string) {
|
||||
cur := m[key]
|
||||
switch {
|
||||
case on && !cur.Done:
|
||||
m[key] = ManualMark{Done: true, By: actor, At: time.Now()}
|
||||
case !on && cur.Done:
|
||||
delete(m, key)
|
||||
}
|
||||
}
|
||||
|
||||
// manualFromForm — данные поручения из формы.
|
||||
func manualFromForm(r *http.Request) *ManualOrder {
|
||||
f := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
|
||||
o := &ManualOrder{
|
||||
PaperNo: f("paper_no"), PaperDate: f("paper_date"), ReceivedVia: f("received_via"),
|
||||
LastName: f("last_name"), FirstName: f("first_name"), MiddleName: f("middle_name"), BirthDate: f("birth_date"),
|
||||
DocType: f("doc_type"), DocSeries: strings.ReplaceAll(f("doc_series"), " ", ""), DocNumber: strings.ReplaceAll(f("doc_number"), " ", ""),
|
||||
DocIssueDate: f("doc_issue_date"), DocIssuer: f("doc_issuer"),
|
||||
Contract: f("contract"), Account: strings.ToUpper(f("account")), QuikCode: f("quik_code"), Phone: f("phone"),
|
||||
RecvINN: f("recv_inn"), RecvName: f("recv_name"), RecvAccount: f("recv_account"), SettlePlace: f("settle_place"),
|
||||
CostInfo: r.FormValue("cost_info") != "", Comment: f("comment"),
|
||||
}
|
||||
o.SettlePlace = "nrd" // СПБ Банк пока не используется
|
||||
isins, codes, names, qtys := r.Form["sec_isin"], r.Form["sec_code"], r.Form["sec_name"], r.Form["sec_qty"]
|
||||
at := func(a []string, i int) string {
|
||||
if i < len(a) {
|
||||
return strings.TrimSpace(a[i])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for i := range isins {
|
||||
s := ManualSec{ISIN: strings.ToUpper(at(isins, i)), Code: at(codes, i), Name: at(names, i), Qty: at(qtys, i)}
|
||||
if s.ISIN == "" && s.Qty == "" && s.Code == "" {
|
||||
continue
|
||||
}
|
||||
o.Securities = append(o.Securities, s)
|
||||
}
|
||||
if o.RecvINN != "" {
|
||||
o.RecvCode = handbookCodeByINN(o.RecvINN)
|
||||
if o.RecvName == "" {
|
||||
for _, p := range handbookParticipants() {
|
||||
if p.INN == o.RecvINN && (o.RecvCode == "" || p.Code == o.RecvCode) {
|
||||
o.RecvName = p.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// manualCopyData — переносит данные формы в сохранённое поручение (служебные поля не трогает).
|
||||
func manualCopyData(dst, src *ManualOrder) {
|
||||
keep := *dst
|
||||
*dst = *src
|
||||
dst.ID, dst.CreatedAt, dst.CreatedBy, dst.UpdatedAt = keep.ID, keep.CreatedAt, keep.CreatedBy, keep.UpdatedAt
|
||||
dst.Depo, dst.Checks, dst.QuikMode, dst.QuikInstr = keep.Depo, keep.Checks, keep.QuikMode, keep.QuikInstr
|
||||
dst.Status, dst.GUID, dst.DealID, dst.SentAt, dst.SentBy, dst.SendErr = keep.Status, keep.GUID, keep.DealID, keep.SentAt, keep.SentBy, keep.SendErr
|
||||
dst.DeclineReason, dst.DeclinedBy, dst.DeclinedAt, dst.Post = keep.DeclineReason, keep.DeclinedBy, keep.DeclinedAt, keep.Post
|
||||
}
|
||||
|
||||
// manualDoLookup — заполнить из DEPO пустые ФИО, дату рождения, счёт и наименования бумаг.
|
||||
func manualDoLookup(rc *RuntimeConfig, id int) string {
|
||||
o, ok := globalManual.Get(id)
|
||||
if !ok {
|
||||
return "поручение не найдено"
|
||||
}
|
||||
if strings.TrimSpace(o.DocNumber) == "" {
|
||||
return "Для поиска в DEPO укажите серию и номер документа"
|
||||
}
|
||||
c := manualDepoLookup(rc, o.DocSeries, o.DocNumber)
|
||||
if !c.Found {
|
||||
return "Поиск в DEPO: " + c.Err
|
||||
}
|
||||
var isins []string
|
||||
for _, s := range o.Securities {
|
||||
if s.Name == "" && s.ISIN != "" {
|
||||
isins = append(isins, s.ISIN)
|
||||
}
|
||||
}
|
||||
names := manualDepoSecNames(rc, isins)
|
||||
var filled []string
|
||||
_ = globalManual.Update(id, func(o *ManualOrder) error {
|
||||
old := o.dataHash()
|
||||
if o.LastName == "" && o.FirstName == "" {
|
||||
if p := strings.Fields(c.Name); len(p) >= 2 {
|
||||
o.LastName, o.FirstName = p[0], p[1]
|
||||
if len(p) > 2 {
|
||||
o.MiddleName = strings.Join(p[2:], " ")
|
||||
}
|
||||
filled = append(filled, "ФИО")
|
||||
}
|
||||
}
|
||||
if o.BirthDate == "" && c.Birth != "" {
|
||||
o.BirthDate = c.Birth
|
||||
filled = append(filled, "дата рождения")
|
||||
}
|
||||
if o.Account == "" && c.Account != "" {
|
||||
o.Account = c.Account
|
||||
filled = append(filled, "счёт "+c.Account)
|
||||
}
|
||||
for i := range o.Securities {
|
||||
if n := names[o.Securities[i].ISIN]; n != "" && o.Securities[i].Name == "" {
|
||||
o.Securities[i].Name = n
|
||||
filled = append(filled, "наименование "+o.Securities[i].ISIN)
|
||||
}
|
||||
}
|
||||
if o.dataHash() != old {
|
||||
o.Depo, o.Checks = nil, nil
|
||||
}
|
||||
return nil
|
||||
})
|
||||
msg := "Клиент в DEPO: " + c.Name
|
||||
if c.Account == "" {
|
||||
msg += " (открытого торгового счёта нет)"
|
||||
}
|
||||
if len(filled) > 0 {
|
||||
msg += ". Заполнено: " + strings.Join(filled, ", ") + " — проверьте с поручением клиента"
|
||||
} else {
|
||||
msg += ". Пустых полей для заполнения нет"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// manualDoDepo — сверка с DEPO.
|
||||
func manualDoDepo(rc *RuntimeConfig, id int, actor string) string {
|
||||
o, ok := globalManual.Get(id)
|
||||
if !ok {
|
||||
return "поручение не найдено"
|
||||
}
|
||||
if p := manualDataProblems(o); len(p) > 0 {
|
||||
return "Сверка не выполнена — сначала заполните реквизиты: " + strings.Join(p, "; ")
|
||||
}
|
||||
chk := manualDepoRun(rc, o)
|
||||
chk.By = actor
|
||||
_ = globalManual.Update(id, func(o *ManualOrder) error {
|
||||
o.Depo = chk
|
||||
return nil
|
||||
})
|
||||
if !chk.Ran {
|
||||
return "Сверка с DEPO не выполнена: " + chk.Err
|
||||
}
|
||||
return "Сверка с DEPO: " + chk.Summary
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func manualTestOrder() *ManualOrder {
|
||||
return &ManualOrder{
|
||||
Status: "draft", PaperNo: "15", PaperDate: "2026-09-17", ReceivedVia: "лично в офисе",
|
||||
LastName: "Иванов", FirstName: "Иван", MiddleName: "Иванович", BirthDate: "1980-01-02",
|
||||
DocType: "21", DocSeries: "0000", DocNumber: "000000", Account: "СЧЁТ-1",
|
||||
RecvINN: "7702165310", RecvName: "Депозитарий", SettlePlace: "nrd",
|
||||
Securities: []ManualSec{{ISIN: "RU0009029540", Qty: "10"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualSendBlockers(t *testing.T) {
|
||||
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o := manualTestOrder()
|
||||
b := manualSendBlockers(rc, o)
|
||||
if len(b) != len(manualPreChecks)+1 { // все отметки + способ блокировки в QUIK
|
||||
t.Fatalf("без отметок: %v", b)
|
||||
}
|
||||
o.Checks = map[string]ManualMark{}
|
||||
for _, c := range manualPreChecks {
|
||||
o.Checks[c.Key] = ManualMark{Done: true}
|
||||
}
|
||||
o.QuikMode = "manual"
|
||||
if b = manualSendBlockers(rc, o); len(b) != 1 || !strings.Contains(b[0], "номер поручения") {
|
||||
t.Fatalf("ручная блокировка без номера: %v", b)
|
||||
}
|
||||
o.QuikInstr = "123"
|
||||
if b = manualSendBlockers(rc, o); len(b) != 0 {
|
||||
t.Fatalf("всё заполнено: %v", b)
|
||||
}
|
||||
o.QuikMode = "auto" // цикл QUIK не включён
|
||||
if b = manualSendBlockers(rc, o); len(b) != 1 {
|
||||
t.Fatalf("автоблокировка недоступна: %v", b)
|
||||
}
|
||||
o.QuikMode = "none"
|
||||
o.Securities = []ManualSec{{ISIN: "US0378331005", Qty: "0"}}
|
||||
if b = manualSendBlockers(rc, o); len(b) != 2 {
|
||||
t.Fatalf("иностранная бумага и нулевое количество: %v", b)
|
||||
}
|
||||
o.Securities = []ManualSec{{ISIN: "RU0009029540", Qty: "1"}, {ISIN: "RU000A0JUR61", Qty: "1"}}
|
||||
if b = manualSendBlockers(rc, o); len(b) != 1 || !strings.Contains(b[0], "одна бумага") {
|
||||
t.Fatalf("одно поручение — одна бумага: %v", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualQtyAndClaim(t *testing.T) {
|
||||
if q, ok := manualQty("1,5"); !ok || q.Fractional != "1.5" {
|
||||
t.Fatalf("дробное: %+v", q)
|
||||
}
|
||||
if q, ok := manualQty("7"); !ok || q.Whole != 7 {
|
||||
t.Fatalf("целое: %+v", q)
|
||||
}
|
||||
if _, ok := manualQty("-1"); ok {
|
||||
t.Fatal("отрицательное")
|
||||
}
|
||||
rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json")
|
||||
c, err := manualClaim(rc, manualTestOrder())
|
||||
if err != nil || c.ClientAccount != "СЧЁТ-1" || c.Investor.BirthDate != "1980-01-02" || c.Securities[0].SecurityCode != "RU0009029540" || c.CostInfo.No == nil {
|
||||
t.Fatalf("заявка: %+v %v", c, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Страницы раздела: список, форма, карточка черновика, отметки, отказ, печать.
|
||||
func TestManualPages(t *testing.T) {
|
||||
saved := globalManual
|
||||
globalManual = loadManualStore(t.TempDir() + "/manual.json")
|
||||
defer func() { globalManual = saved }()
|
||||
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
registerManual(mux, rc, nil)
|
||||
do := func(method, path string, form url.Values) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if form != nil {
|
||||
req = httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
if rec := do("GET", "/admin/help/manual", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Порядок работы") {
|
||||
t.Fatalf("инструкция: %d", rec.Code)
|
||||
}
|
||||
if rec := do("GET", "/admin/manual/new", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Новое поручение") {
|
||||
t.Fatalf("форма: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
form := url.Values{"paper_no": {"15"}, "paper_date": {"2026-09-17"}, "last_name": {"Иванов"}, "first_name": {"Иван"},
|
||||
"birth_date": {"1980-01-02"}, "doc_type": {"21"}, "doc_series": {"00 00"}, "doc_number": {"000000"}, "account": {"счёт-1"},
|
||||
"recv_inn": {"7702165310"}, "sec_isin": {"ru0009029540", ""}, "sec_qty": {"10", ""}, "sec_code": {"", ""}, "sec_name": {"", ""}, "do": {"save"}}
|
||||
rec := do("POST", "/admin/manual/save", form)
|
||||
if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/admin/manual/1") {
|
||||
t.Fatalf("сохранение: %d %s", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
o, _ := globalManual.Get(1)
|
||||
if o.DocSeries != "0000" || o.Account != "СЧЁТ-1" || len(o.Securities) != 1 || o.Securities[0].ISIN != "RU0009029540" {
|
||||
t.Fatalf("данные: %+v", o)
|
||||
}
|
||||
if rec = do("GET", "/admin/manual/1", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Отправить пока нельзя") {
|
||||
t.Fatalf("карточка: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
chk := url.Values{"quik_mode": {"none"}}
|
||||
for _, c := range manualPreChecks {
|
||||
chk.Set("check_"+c.Key, "1")
|
||||
}
|
||||
do("POST", "/admin/manual/1/checks", chk)
|
||||
o, _ = globalManual.Get(1)
|
||||
if len(manualSendBlockers(rc, o)) != 0 || o.Checks["paper"].At.IsZero() {
|
||||
t.Fatalf("отметки: %+v %v", o.Checks, manualSendBlockers(rc, o))
|
||||
}
|
||||
if rec = do("GET", "/admin/manual/1", nil); !strings.Contains(rec.Body.String(), "Все проверки и отметки пройдены") {
|
||||
t.Fatalf("готово к отправке: %s", rec.Body.String())
|
||||
}
|
||||
// Изменение данных сбрасывает отметки.
|
||||
form.Set("id", "1")
|
||||
form.Set("sec_qty", "11")
|
||||
do("POST", "/admin/manual/save", form)
|
||||
if o, _ = globalManual.Get(1); len(o.Checks) != 0 {
|
||||
t.Fatalf("отметки должны сброситься: %+v", o.Checks)
|
||||
}
|
||||
do("POST", "/admin/manual/1/decline", url.Values{"reason": {"нет подписи клиента"}})
|
||||
o, _ = globalManual.Get(1)
|
||||
if o.Status != "declined" || o.DeclinedAt.Before(time.Now().Add(-time.Minute)) {
|
||||
t.Fatalf("отказ: %+v", o)
|
||||
}
|
||||
for _, p := range []string{"/admin/manual", "/admin/manual/1", "/admin/manual/1/print"} {
|
||||
if rec = do("GET", p, nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "нет подписи клиента") && p != "/admin/manual" {
|
||||
t.Fatalf("%s: %d %s", p, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgIdentity(t *testing.T) {
|
||||
var s Settings
|
||||
s.NSD.DeponentCode = "MC0000000001"
|
||||
if orgCode(s) != "MC0000000001" {
|
||||
t.Fatalf("код по депозитарному коду: %q", orgCode(s))
|
||||
}
|
||||
s.NSD.OrgCode, s.NSD.OrgINN, s.NSD.OrgName = "mc0000000002", "7700000000", "Депозитарий"
|
||||
if orgCode(s) != "MC0000000002" || orgINN(s) != "7700000000" || transferringINN(s) != "7700000000" || orgName(s) != "Депозитарий" {
|
||||
t.Fatalf("организация из настроек: %q %q %q", orgCode(s), orgINN(s), orgName(s))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package lkgateway
|
||||
|
||||
// Службы модулей (bj-npr, bj-quikexport, …) — отдельные программы на локальных портах, которые
|
||||
// обновляются независимо от ядра. bj-server проверяет вход, права и лицензию, передаёт службе
|
||||
// служебный ключ (X-BJ-Module-Key) и встраивает её страницы в свой макет. Сами службы — modules/.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const moduleKeyFile = "/var/lib/bj/.bj/module.key"
|
||||
|
||||
var moduleKeyMu sync.Mutex
|
||||
|
||||
// moduleKey — служебный ключ для служб модулей (создаётся один раз, читается службами от того же пользователя).
|
||||
func moduleKey() (string, error) {
|
||||
moduleKeyMu.Lock()
|
||||
defer moduleKeyMu.Unlock()
|
||||
if b, err := os.ReadFile(moduleKeyFile); err == nil && len(strings.TrimSpace(string(b))) >= 32 {
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := hex.EncodeToString(buf)
|
||||
if err := os.WriteFile(moduleKeyFile, []byte(key+"\n"), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// noRedirect — ответы служб (в т. ч. редиректы) передаются браузеру как есть.
|
||||
func noRedirect(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
|
||||
// modulePageData — страница модуля в макете bj.
|
||||
type modulePageData struct {
|
||||
page
|
||||
Content template.HTML
|
||||
}
|
||||
|
||||
func moduleMessage(title, text, hint string) template.HTML {
|
||||
return template.HTML(`<div class="card"><h2>` + template.HTMLEscapeString(title) + `</h2><p>` + template.HTMLEscapeString(text) + `</p>` +
|
||||
`<p class="muted">` + template.HTMLEscapeString(hint) + `</p></div>`)
|
||||
}
|
||||
|
||||
func pickStr(v, def string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -402,10 +402,35 @@ func (h *setupHandlers) checkDocsNow(w http.ResponseWriter, r *http.Request) {
|
||||
setupFlash(w, r, "Проверка обновлений документации завершена. "+strings.TrimSpace(summary))
|
||||
}
|
||||
|
||||
// devNewsIDs — стартовые новости для разработчиков (TEST3, робот НРД): у клиента не показываем.
|
||||
var devNewsIDs = map[string]bool{
|
||||
"test3-maintenance-2026-05": true,
|
||||
"robot-autotest-2026-05-12": true,
|
||||
}
|
||||
|
||||
// visibleNews — новости, которые видны на этой установке: вне режима разработки
|
||||
// без служебных новостей про тестирование.
|
||||
func visibleNews(rc *RuntimeConfig, items []NewsItem) []NewsItem {
|
||||
if devMode(rc) {
|
||||
return items
|
||||
}
|
||||
out := make([]NewsItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
if !devNewsIDs[it.ID] {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SeedDefaultNews добавляет в ленту известные на момент запуска события
|
||||
// (окно техработ TEST3 в мае 2026 и появление робота-автотестирования).
|
||||
// Вызывается из server.go при старте — дедуп по ID гарантирован AddNews.
|
||||
// Только в режиме разработки: клиентам эти новости не нужны.
|
||||
func SeedDefaultNews(rc *RuntimeConfig) {
|
||||
if !devMode(rc) {
|
||||
return
|
||||
}
|
||||
defaults := []NewsItem{
|
||||
{
|
||||
ID: "test3-maintenance-2026-05",
|
||||
|
||||
+94
-440
@@ -1,32 +1,26 @@
|
||||
package lkgateway
|
||||
|
||||
// Модуль «НПР / УДС» — риск-монитор клиентов маржинальной торговли поверх базы
|
||||
// экспорта QUIK (quik_export, схема npr). Перенесён «как есть» с 10.10.10.23
|
||||
// (FastAPI ui/app.py) 14.09.2026; читает базу логином только для чтения.
|
||||
// Включается настройкой NPR.Enabled и лицензионным признаком "npr".
|
||||
// Модуль «НПР / УДС» — риск-монитор клиентов маржинальной торговли по базе экспорта QUIK.
|
||||
// Сам модуль работает отдельной службой bj-npr (modules/npr, обновляется независимо). Здесь —
|
||||
// его настройки и проверка подключения, а раздел /admin/npr bj-server проксирует службе: проверяет
|
||||
// включение и лицензию (права — authMiddleware), передаёт служебный ключ и встраивает полные
|
||||
// страницы модуля в свой макет. Если служба не отвечает, показывает понятную страницу.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed web/static/npr/*
|
||||
var nprStaticFS embed.FS
|
||||
|
||||
// NPRSettings — модуль НПР/УДС.
|
||||
type NPRSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -50,6 +44,84 @@ func nprAvailable(rc *RuntimeConfig) bool {
|
||||
return s.Enabled && strings.TrimSpace(s.DSN) != "" && moduleAllowed(rc, "npr")
|
||||
}
|
||||
|
||||
// ---- служба модуля ----
|
||||
|
||||
var nprHTTP = &http.Client{Timeout: 60 * time.Second, CheckRedirect: noRedirect}
|
||||
|
||||
func nprURL() string {
|
||||
if u := strings.TrimSpace(os.Getenv("BJ_NPR_URL")); u != "" {
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
return "http://127.0.0.1:8083"
|
||||
}
|
||||
|
||||
// registerNPR — прокси раздела /admin/npr к службе bj-npr.
|
||||
func registerNPR(mux *http.ServeMux, a *admin, rc *RuntimeConfig) {
|
||||
unavailable := func(w http.ResponseWriter, r *http.Request, status int, title, text, hint string) {
|
||||
if isAjax(r) || !strings.HasPrefix(r.Header.Get("Accept"), "text/html") && r.URL.Path != "/admin/npr" && !strings.HasPrefix(r.URL.Path, "/admin/npr/c/") {
|
||||
http.Error(w, text, status)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
render(w, a.modulePage, modulePageData{page: nowPage("НПР / УДС", "npr"), Content: moduleMessage(title, text, hint)})
|
||||
}
|
||||
proxy := func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/admin/npr/static/") && !nprAvailable(rc) {
|
||||
unavailable(w, r, http.StatusForbidden, "Модуль «НПР / УДС» недоступен",
|
||||
"Модуль не включён в настройках или не входит в лицензию.", "Включение и подключение к базе: Настройка → НПР / УДС. Состав лицензии: Настройка → Лицензия.")
|
||||
return
|
||||
}
|
||||
key, err := moduleKey()
|
||||
if err != nil {
|
||||
http.Error(w, "служебный ключ модулей: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, nprURL()+r.URL.RequestURI(), r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, h := range []string{"Accept", "Content-Type", "HX-Request", "HX-Target", "HX-Trigger", "HX-Current-URL", "If-Modified-Since", "If-None-Match", "Range"} {
|
||||
if v := r.Header.Get(h); v != "" {
|
||||
req.Header.Set(h, v)
|
||||
}
|
||||
}
|
||||
req.Header.Set("X-BJ-Module-Key", key)
|
||||
if u := currentUser(w); u != nil {
|
||||
req.Header.Set("X-BJ-User", url.QueryEscape(u.Login))
|
||||
}
|
||||
resp, err := nprHTTP.Do(req)
|
||||
if err != nil {
|
||||
unavailable(w, r, http.StatusServiceUnavailable, "Служба модуля не отвечает",
|
||||
"Служба bj-npr не запущена или ещё не установлена на этом сервере.",
|
||||
"Установка и обновление модуля: Настройка → Обновления. Состояние службы: systemctl status bj-npr.")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.Header.Get("X-BJ-Layout") == "page" {
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
title, _ := url.PathUnescape(resp.Header.Get("X-BJ-Title"))
|
||||
render(w, a.modulePage, modulePageData{page: nowPage(pickStr(title, "НПР / УДС"), "npr"), Content: template.HTML(body)})
|
||||
return
|
||||
}
|
||||
for _, h := range []string{"Content-Type", "Content-Disposition", "Content-Length", "Cache-Control", "Last-Modified", "ETag", "Accept-Ranges", "Content-Range", "Location"} {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
w.Header().Set(h, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
mux.HandleFunc("/admin/npr", proxy)
|
||||
mux.HandleFunc("/admin/npr/", proxy)
|
||||
}
|
||||
|
||||
// ---- настройки: проверка подключения ----
|
||||
|
||||
var (
|
||||
nprPoolMu sync.Mutex
|
||||
nprPool *pgxpool.Pool
|
||||
@@ -74,7 +146,7 @@ func nprDB(rc *RuntimeConfig) (*pgxpool.Pool, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.MaxConns = 5
|
||||
cfg.MaxConns = 2
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
p, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
@@ -85,429 +157,6 @@ func nprDB(rc *RuntimeConfig) (*pgxpool.Pool, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
var nprMSK = time.FixedZone("MSK", 3*3600)
|
||||
|
||||
var nprSort = map[string]string{
|
||||
"uds_asc": "uds ASC NULLS LAST, client_code ASC",
|
||||
"uds_desc": "uds DESC NULLS LAST, client_code ASC",
|
||||
"client_asc": "client_code ASC",
|
||||
"client_desc": "client_code DESC",
|
||||
"ts_desc": "ts DESC",
|
||||
}
|
||||
|
||||
var nprTabs = map[string]string{
|
||||
// Маржинальный риск: клиенты с реально открытыми позициями.
|
||||
"margin": "COALESCE(init_margin,0) > 0 OR COALESCE(min_margin,0) > 0",
|
||||
// Дебиторская задолженность: позиций нет, но портфель в минусе.
|
||||
"debt": "COALESCE(init_margin,0) = 0 AND COALESCE(min_margin,0) = 0 AND COALESCE(portfolio_value,0) < 0",
|
||||
}
|
||||
|
||||
type nprRow struct {
|
||||
ClientCode, MarginType, UDSFlag string
|
||||
UDS, RCV1, RCV2, MarginReq *float64
|
||||
PortfolioValue *float64
|
||||
InitMargin, MinMargin, CorrMargin *float64
|
||||
Status, RiskLevel *int
|
||||
TS time.Time
|
||||
Zone string
|
||||
}
|
||||
|
||||
func (r nprRow) Class() string {
|
||||
switch {
|
||||
case r.UDSFlag == "CALL" || (r.UDS != nil && *r.UDS < 1):
|
||||
return "row-call"
|
||||
case r.UDS != nil && *r.UDS < 1.5:
|
||||
return "row-warn"
|
||||
case r.UDSFlag == "INF":
|
||||
return "row-inf"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r nprRow) Badge() (cls, text string) {
|
||||
switch {
|
||||
case r.UDSFlag == "CALL":
|
||||
return "badge-call", "МАРЖИН-КОЛЛ"
|
||||
case r.UDSFlag == "INF":
|
||||
return "badge-inf", "∞ избыточно"
|
||||
case r.UDS != nil && *r.UDS < 1:
|
||||
return "badge-call", "маржин-колл"
|
||||
case r.UDS != nil && *r.UDS < 1.5:
|
||||
return "badge-warn", "внимание"
|
||||
case r.UDS != nil:
|
||||
return "badge-ok", "норма"
|
||||
}
|
||||
return "", "—"
|
||||
}
|
||||
func (r nprRow) BadgeClass() string { c, _ := r.Badge(); return c }
|
||||
func (r nprRow) BadgeText() string { _, t := r.Badge(); return t }
|
||||
|
||||
func nprF(v *float64) string {
|
||||
if v == nil || math.IsNaN(*v) {
|
||||
return "—"
|
||||
}
|
||||
return strings.ReplaceAll(strconv.FormatFloat(*v, 'f', 2, 64), ".", ",")
|
||||
}
|
||||
func nprI(v *int) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(*v)
|
||||
}
|
||||
|
||||
func nprParseF(s string) *float64 {
|
||||
s = strings.TrimSpace(strings.ReplaceAll(s, ",", "."))
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &f
|
||||
}
|
||||
|
||||
func nprWhere(q string, mt []string, umn, umx *float64, onlyCalls bool, tab string) (string, []any) {
|
||||
var cl []string
|
||||
var params []any
|
||||
cl = append(cl, "("+nprTabs[tab]+")")
|
||||
if q != "" {
|
||||
params = append(params, "%"+q+"%")
|
||||
cl = append(cl, fmt.Sprintf("client_code ILIKE $%d", len(params)))
|
||||
}
|
||||
if len(mt) > 0 {
|
||||
params = append(params, mt)
|
||||
cl = append(cl, fmt.Sprintf("margin_type = ANY($%d)", len(params)))
|
||||
}
|
||||
if umn != nil {
|
||||
params = append(params, *umn)
|
||||
cl = append(cl, fmt.Sprintf("uds >= $%d", len(params)))
|
||||
}
|
||||
if umx != nil {
|
||||
params = append(params, *umx)
|
||||
cl = append(cl, fmt.Sprintf("uds <= $%d", len(params)))
|
||||
}
|
||||
if onlyCalls {
|
||||
cl = append(cl, "uds IS NOT NULL AND uds < 1")
|
||||
}
|
||||
return strings.Join(cl, " AND "), params
|
||||
}
|
||||
|
||||
func scanNPR(rows pgx.Rows) ([]nprRow, int, error) {
|
||||
var out []nprRow
|
||||
total := 0
|
||||
for rows.Next() {
|
||||
var r nprRow
|
||||
var mt, fl *string
|
||||
var zone *string
|
||||
if err := rows.Scan(&r.ClientCode, &mt, &r.UDS, &fl, &r.RCV1, &r.RCV2, &r.MarginReq, &r.PortfolioValue, &r.InitMargin, &r.MinMargin, &r.CorrMargin, &r.Status, &r.RiskLevel, &r.TS, &zone, &total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if mt != nil {
|
||||
r.MarginType = *mt
|
||||
}
|
||||
if fl != nil {
|
||||
r.UDSFlag = *fl
|
||||
}
|
||||
if zone != nil {
|
||||
r.Zone = *zone
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
const nprCols = `client_code, margin_type, uds::float8, uds_flag, rcv1::float8, rcv2::float8, margin_requirement::float8, portfolio_value::float8, init_margin::float8, min_margin::float8, corrected_margin::float8, status, risk_level, ts, zone`
|
||||
|
||||
func nprParsePeriod(hours int, from, to string) (time.Time, time.Time, bool) {
|
||||
parse := func(s string) (time.Time, bool) {
|
||||
for _, f := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", "2006-01-02 15:04", "2006-01-02"} {
|
||||
if t, err := time.ParseInLocation(f, s, nprMSK); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
now := time.Now()
|
||||
tf, okf := parse(from)
|
||||
tt, okt := parse(to)
|
||||
if okf || okt {
|
||||
if !okf {
|
||||
tf = now.Add(-2 * 365 * 24 * time.Hour)
|
||||
}
|
||||
if !okt {
|
||||
tt = now
|
||||
}
|
||||
return tf, tt, true
|
||||
}
|
||||
if hours <= 0 {
|
||||
hours = 24
|
||||
}
|
||||
return now.Add(-time.Duration(hours) * time.Hour), now, false
|
||||
}
|
||||
|
||||
// registerNPR — маршруты модуля.
|
||||
func registerNPR(mux *http.ServeMux, a *admin, rc *RuntimeConfig) {
|
||||
sub, _ := fs.Sub(nprStaticFS, "web/static/npr")
|
||||
mux.Handle("/admin/npr/static/", http.StripPrefix("/admin/npr/static/", http.FileServer(http.FS(sub))))
|
||||
|
||||
guard := func(w http.ResponseWriter, r *http.Request) (*pgxpool.Pool, bool) {
|
||||
if !nprAvailable(rc) {
|
||||
http.Error(w, "модуль «НПР / УДС» не включён или не входит в лицензию", http.StatusForbidden)
|
||||
return nil, false
|
||||
}
|
||||
p, err := nprDB(rc)
|
||||
if err != nil {
|
||||
http.Error(w, "модуль НПР: "+err.Error(), http.StatusServiceUnavailable)
|
||||
return nil, false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
qp := func(r *http.Request) (q string, mt []string, umn, umx *float64, onlyCalls bool, sort, tab string, page int) {
|
||||
v := r.URL.Query()
|
||||
q = strings.TrimSpace(v.Get("q"))
|
||||
for _, m := range v["mt"] {
|
||||
if m = strings.TrimSpace(m); m != "" {
|
||||
mt = append(mt, m)
|
||||
}
|
||||
}
|
||||
umn, umx = nprParseF(v.Get("umn")), nprParseF(v.Get("umx"))
|
||||
onlyCalls = v.Get("only_calls") == "true" || v.Get("only_calls") == "on"
|
||||
sort = v.Get("sort")
|
||||
if _, ok := nprSort[sort]; !ok {
|
||||
sort = "uds_asc"
|
||||
}
|
||||
tab = v.Get("tab")
|
||||
if _, ok := nprTabs[tab]; !ok {
|
||||
tab = "margin"
|
||||
}
|
||||
page, _ = strconv.Atoi(v.Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
mux.HandleFunc("/admin/npr", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := guard(w, r); !ok {
|
||||
return
|
||||
}
|
||||
q, mt, _, _, onlyCalls, sort, tab, pg := qp(r)
|
||||
data := struct {
|
||||
page
|
||||
Q, Sort, Tab string
|
||||
MT []string
|
||||
UMN, UMX string
|
||||
OnlyCalls bool
|
||||
Page int
|
||||
}{nowPage("НПР / УДС", "npr"), q, sort, tab, mt, r.URL.Query().Get("umn"), r.URL.Query().Get("umx"), onlyCalls, pg}
|
||||
render(w, a.npr, data)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/admin/npr/rows", func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := guard(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q, mt, umn, umx, onlyCalls, sort, tab, page := qp(r)
|
||||
limit := 30
|
||||
where, params := nprWhere(q, mt, umn, umx, onlyCalls, tab)
|
||||
params = append(params, limit, (page-1)*limit)
|
||||
sql := fmt.Sprintf(`WITH per_client AS (
|
||||
SELECT DISTINCT ON (client_code) %s FROM npr.v_current
|
||||
ORDER BY client_code, (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC)
|
||||
SELECT %s, count(*) OVER () AS total FROM per_client WHERE %s ORDER BY %s LIMIT $%d OFFSET $%d`,
|
||||
nprCols, nprCols, where, nprSort[sort], len(params)-1, len(params))
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
rows, err := p.Query(ctx, sql, params...)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
list, total, err := scanNPR(rows)
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
pages := (total + limit - 1) / limit
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = a.nprRows.ExecuteTemplate(w, "npr_rows", map[string]any{"Rows": list, "Total": total, "Page": page, "Pages": pages, "HasNext": total > page*limit})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/admin/npr/api/summary", func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := guard(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tab := r.URL.Query().Get("tab")
|
||||
if _, ok := nprTabs[tab]; !ok {
|
||||
tab = "margin"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
var total, below1, below0, calls, inf, warn, okc, tabM, tabD int64
|
||||
err := p.QueryRow(ctx, `WITH per_client AS (SELECT DISTINCT ON (client_code) client_code, uds, uds_flag, zone, init_margin, min_margin, portfolio_value FROM npr.v_current WHERE `+nprTabs[tab]+` ORDER BY client_code, (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC)
|
||||
SELECT count(*), count(*) FILTER (WHERE uds IS NOT NULL AND uds < 1), count(*) FILTER (WHERE uds IS NOT NULL AND uds < 0), count(*) FILTER (WHERE zone='CALL'), count(*) FILTER (WHERE zone='INF'), count(*) FILTER (WHERE zone='WARN'), count(*) FILTER (WHERE zone='OK') FROM per_client`).
|
||||
Scan(&total, &below1, &below0, &calls, &inf, &warn, &okc)
|
||||
if err == nil {
|
||||
err = p.QueryRow(ctx, `SELECT count(DISTINCT client_code) FILTER (WHERE `+nprTabs["margin"]+`), count(DISTINCT client_code) FILTER (WHERE `+nprTabs["debt"]+`) FROM npr.v_current`).Scan(&tabM, &tabD)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"total": total, "below_one": below1, "below_zero": below0, "calls": calls, "inf": inf, "warn": warn, "ok": okc, "tab_margin": tabM, "tab_debt": tabD, "tab": tab})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/admin/npr/api/freshness", func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := guard(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
var lastTick *time.Time
|
||||
var ticks int64
|
||||
_ = p.QueryRow(ctx, "SELECT last_tick, tick_count FROM npr.heartbeat WHERE id=1").Scan(&lastTick, &ticks)
|
||||
var lastTS *time.Time
|
||||
var total, hour, day int64
|
||||
_ = p.QueryRow(ctx, `SELECT max(ts), count(*), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '1 hour'), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '24 hours') FROM npr.history`).Scan(&lastTS, &total, &hour, &day)
|
||||
out := map[string]any{"last_tick": nil, "tick_count": ticks, "last_event_ts": nil, "total_rows": total, "events_last_hour": hour, "events_last_24h": day, "server_now": time.Now().Format(time.RFC3339)}
|
||||
if lastTick != nil {
|
||||
out["last_tick"] = lastTick.Format(time.RFC3339)
|
||||
}
|
||||
if lastTS != nil {
|
||||
out["last_event_ts"] = lastTS.Format(time.RFC3339)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/admin/npr/c/", func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := guard(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/admin/npr/c/")
|
||||
code := rest
|
||||
sub := ""
|
||||
if i := strings.Index(rest, "/"); i >= 0 {
|
||||
code, sub = rest[:i], rest[i+1:]
|
||||
}
|
||||
if code == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
hours, _ := strconv.Atoi(r.URL.Query().Get("hours"))
|
||||
tf, tt, custom := nprParsePeriod(hours, r.URL.Query().Get("dt_from"), r.URL.Query().Get("dt_to"))
|
||||
if hours <= 0 {
|
||||
hours = 24
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
switch sub {
|
||||
case "series":
|
||||
rows, err := p.Query(ctx, "SELECT ts, rcv1::float8, rcv2::float8, uds::float8 FROM npr.v_npr WHERE client_code=$1 AND ts BETWEEN $2 AND $3 ORDER BY ts", code, tf, tt)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
labels, r1, r2, u := []string{}, []*float64{}, []*float64{}, []*float64{}
|
||||
for rows.Next() {
|
||||
var ts time.Time
|
||||
var a, b, c *float64
|
||||
if rows.Scan(&ts, &a, &b, &c) == nil {
|
||||
labels = append(labels, ts.Format(time.RFC3339))
|
||||
r1, r2, u = append(r1, a), append(r2, b), append(u, c)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"labels": labels, "rcv1": r1, "rcv2": r2, "uds": u})
|
||||
return
|
||||
case "export.csv":
|
||||
rows, err := p.Query(ctx, `SELECT h.ts, h.firm_id, h.client_code, COALESCE(h.margin_type,''), h.rcv1::float8, h.rcv2::float8,
|
||||
CASE WHEN h.funds_level >= 9.99 THEN NULL ELSE h.funds_level::float8 END, CASE WHEN h.funds_level >= 9.99 THEN 'INF' WHEN h.funds_level <= -9.99 THEN 'CALL' ELSE '' END,
|
||||
h.portfolio_value::float8, h.init_margin::float8, h.min_margin::float8, h.corrected_margin::float8, GREATEST(-h.rcv1, 0)::float8, h.status, h.risk_level
|
||||
FROM npr.history h WHERE h.client_code=$1 AND h.ts BETWEEN $2 AND $3 ORDER BY h.ts`, code, tf, tt)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="npr_%s_%s-%s.csv"`, code, tf.Format("20060102"), tt.Format("20060102")))
|
||||
w.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
cw := csv.NewWriter(w)
|
||||
cw.Comma = ';'
|
||||
cw.Write([]string{"Время (UTC)", "Время (МСК)", "Фирма", "Код клиента", "Тип клиента", "НПР1", "НПР2", "УДС", "Флаг УДС", "Стоимость портфеля", "Начальная маржа", "Минимальная маржа", "Скорректированная маржа", "Маржинальное требование", "Статус", "Уровень риска"})
|
||||
f := func(v *float64) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(strconv.FormatFloat(*v, 'f', 4, 64), ".", ",")
|
||||
}
|
||||
for rows.Next() {
|
||||
var ts time.Time
|
||||
var firm, cc, mt, flag string
|
||||
var a, b, c, pv, im, mm, cm, mr *float64
|
||||
var st, rl *int
|
||||
if rows.Scan(&ts, &firm, &cc, &mt, &a, &b, &c, &flag, &pv, &im, &mm, &cm, &mr, &st, &rl) != nil {
|
||||
continue
|
||||
}
|
||||
cw.Write([]string{ts.UTC().Format("2006-01-02 15:04:05"), ts.In(nprMSK).Format("2006-01-02 15:04:05"), firm, cc, mt, f(a), f(b), f(c), flag, f(pv), f(im), f(mm), f(cm), f(mr), nprI(st), nprI(rl)})
|
||||
}
|
||||
cw.Flush()
|
||||
return
|
||||
case "":
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rows, err := p.Query(ctx, `SELECT `+nprCols+`, 0 FROM npr.v_current WHERE client_code=$1 ORDER BY (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC LIMIT 1`, code)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
cur, _, err := scanNPR(rows)
|
||||
rows.Close()
|
||||
if err != nil || len(cur) == 0 {
|
||||
http.Error(w, "Клиент "+code+" не найден в текущем состоянии QUIK", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
hrows, err := p.Query(ctx, `SELECT client_code, margin_type, uds::float8, uds_flag, rcv1::float8, rcv2::float8, margin_requirement::float8, portfolio_value::float8, init_margin::float8, min_margin::float8, NULL::float8, NULL::int, NULL::int, ts, NULL::text, 0 FROM npr.v_npr WHERE client_code=$1 AND ts BETWEEN $2 AND $3 ORDER BY ts DESC LIMIT 200`, code, tf, tt)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
hist, _, _ := scanNPR(hrows)
|
||||
hrows.Close()
|
||||
data := struct {
|
||||
page
|
||||
C nprRow
|
||||
History []nprRow
|
||||
Hours int
|
||||
DtFrom, DtTo string
|
||||
TF, TT time.Time
|
||||
Custom bool
|
||||
Periods []struct {
|
||||
H int
|
||||
T string
|
||||
}
|
||||
}{nowPage("Клиент "+code+" — НПР/УДС", "npr"), cur[0], hist, hours, r.URL.Query().Get("dt_from"), r.URL.Query().Get("dt_to"), tf, tt, custom,
|
||||
[]struct {
|
||||
H int
|
||||
T string
|
||||
}{{1, "за 1 час"}, {6, "за 6 часов"}, {24, "за сутки"}, {168, "за 7 дней"}, {720, "за 30 дней"}}}
|
||||
render(w, a.nprClient, data)
|
||||
})
|
||||
log.Printf("npr: модуль зарегистрирован (включён: %v)", nprAvailable(rc))
|
||||
}
|
||||
|
||||
// nprTest — проверка подключения для настроек.
|
||||
func nprTest(rc *RuntimeConfig) (string, error) {
|
||||
p, err := nprDB(rc)
|
||||
@@ -527,7 +176,12 @@ func nprTest(rc *RuntimeConfig) (string, error) {
|
||||
if tick != nil {
|
||||
age = fmt.Sprintf("пульс экспортёра %s назад", time.Since(*tick).Round(time.Second))
|
||||
}
|
||||
return fmt.Sprintf("клиентов в текущем состоянии %d, записей истории %d, %s", clients, hist, age), nil
|
||||
service := "служба bj-npr не отвечает"
|
||||
if resp, err := (&http.Client{Timeout: 3 * time.Second}).Get(nprURL() + "/healthz"); err == nil {
|
||||
resp.Body.Close()
|
||||
service = "служба bj-npr работает"
|
||||
}
|
||||
return fmt.Sprintf("клиентов в текущем состоянии %d, записей истории %d, %s; %s", clients, hist, age, service), nil
|
||||
}
|
||||
|
||||
func (h *setupHandlers) saveNPR(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Страница службы модуля встраивается в макет bj: меню и заголовок bj, содержимое — от модуля.
|
||||
func TestModulePageWrap(t *testing.T) {
|
||||
a, err := newAdmin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
frag := `<div id="npr-frag"><script src="/admin/npr/static/htmx.min.js"></script>фрагмент</div>`
|
||||
render(rec, a.modulePage, modulePageData{page: nowPage("Клиент V3BR061 · НПР / УДС", "npr"), Content: template.HTML(frag)})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("код %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{frag, "Клиент V3BR061 · НПР / УДС", "</html>"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("в странице нет %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Count(body, "<html") != 1 {
|
||||
t.Errorf("ожидался один документ, <html встречается %d раз", strings.Count(body, "<html"))
|
||||
}
|
||||
}
|
||||
|
||||
// Раздел «Экспорт QUIK» от службы встраивается в страницу настроек, индикатор меню — от службы.
|
||||
func TestSetupPageEmbedsQuikExport(t *testing.T) {
|
||||
a, err := newAdmin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
frag := `<h1>QUIK — Экспорт биржевой информации</h1><div id="qx-frag">раздел службы</div>`
|
||||
render(rec, a.setup, SetupData{page: nowPage("Настройка", "setup"), AdvancedUnlocked: true, Modules: map[string]bool{"quik-export": true, "quik-mnp": true, "npr": true},
|
||||
QuikExport: QuikExportView{HTML: template.HTML(frag), Ind: "warn"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("код %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, frag) {
|
||||
t.Error("в странице настроек нет раздела от службы")
|
||||
}
|
||||
if !strings.Contains(body, `Экспорт QUIK<span class="ind warn">`) {
|
||||
t.Error("индикатор меню не взят у службы")
|
||||
}
|
||||
}
|
||||
|
||||
// Версии: общий список в «Обзоре» и строка в разделе модуля.
|
||||
func TestSetupPageShowsVersions(t *testing.T) {
|
||||
a, err := newAdmin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vers := SystemVersions{At: time.Now(), Items: []SystemVersion{
|
||||
{Key: "quik-mnp", Title: "QUIK — Модуль неторговых поручений", Version: "21.3.0", Detail: "обновлена 05.06.2026", Section: "quik"},
|
||||
{Key: "depo", Title: "Fancy DEPO (база DEPO)", Section: "fancy", Err: "нет связи"},
|
||||
}}
|
||||
rec := httptest.NewRecorder()
|
||||
render(rec, a.setup, SetupData{page: nowPage("Настройка", "setup"), AdvancedUnlocked: true, Modules: map[string]bool{"quik-mnp": true}, Versions: vers})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("код %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"Версии систем", "<strong>21.3.0</strong>", "QUIK — Модуль неторговых поручений: <strong>21.3.0</strong> · обновлена 05.06.2026", "не определена", "нет связи"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("в странице нет %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package lkgateway
|
||||
|
||||
// nsddocs.go — документы НРД по депо-поручению в понятном виде. Пакеты в INBOX ИШ называются
|
||||
// номерами (K1699092, 50818781 …); по содержимому определяем, что это за документ (квитанция о
|
||||
// регистрации, уведомление GS036, отчёт о статусе GS116, отчёт об исполнении MS010, ошибка) и
|
||||
// выгружаем папками с названием, описью и PDF для депозитария (как печатные формы из канала НРД).
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// nsdDoc — один документ (пакет) НРД по поручению.
|
||||
type nsdDoc struct {
|
||||
Pkg string
|
||||
Time time.Time
|
||||
Code string // REG | REG_REJECT | GS036 | GS116 | MS010 | ERROR | ""
|
||||
Title string
|
||||
About string
|
||||
Fields [][2]string
|
||||
Done bool // отчёт об исполнении (MS010)
|
||||
Rejected bool
|
||||
Files []respFile // имена без папки пакета
|
||||
}
|
||||
|
||||
var nsdTagCache sync.Map // имя элемента → *regexp.Regexp
|
||||
|
||||
// xmlTag — первое значение элемента name (без учёта префикса пространства имён).
|
||||
func xmlTag(body, name string) string {
|
||||
v, ok := nsdTagCache.Load(name)
|
||||
if !ok {
|
||||
v, _ = nsdTagCache.LoadOrStore(name, regexp.MustCompile(`<(?:\w+:)?`+name+`(?:\s[^>]*)?>\s*([^<]*?)\s*</(?:\w+:)?`+name+`>`))
|
||||
}
|
||||
if m := v.(*regexp.Regexp).FindStringSubmatch(body); m != nil {
|
||||
return html.UnescapeString(m[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func addField(d *nsdDoc, k, v string) {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
d.Fields = append(d.Fields, [2]string{k, strings.TrimSpace(v)})
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
othrIDRe = regexp.MustCompile(`(?s)<OthrId>\s*<Id>([^<]+)</Id>`)
|
||||
sfkpgAcctRe = regexp.MustCompile(`(?s)<SfkpgAcct>\s*<Id>([^<]+)</Id>`)
|
||||
gs036PairRe = regexp.MustCompile(`(?s)<Ntfctns>\s*(?:<NtfctnHdr>([^<]*)</NtfctnHdr>)?\s*(?:<NtfctnTxt>([^<]*)</NtfctnTxt>)?\s*</Ntfctns>`)
|
||||
prtryIDRe = regexp.MustCompile(`(?s)<(Rcvg|Dlvrg)SttlmPties>.*?<PrtryId>\s*<Id>([^<]+)</Id>`)
|
||||
qtyUnitRe = regexp.MustCompile(`(?s)<(?:SttldQty|SttlmQty)>\s*<Qty>\s*<(?:Unit|FaceAmt|AmtsdVal)>([^<]+)<`)
|
||||
dateInRe = regexp.MustCompile(`(?s)<(FctvSttlmDt|SttlmDt)>\s*<Dt>\s*<Dt>([^<]+)</Dt>`)
|
||||
)
|
||||
|
||||
func securityLine(body string) string {
|
||||
code, isin, desc := "", xmlTag(body, "ISIN"), xmlTag(body, "Desc")
|
||||
if m := othrIDRe.FindStringSubmatch(body); m != nil {
|
||||
code = m[1]
|
||||
}
|
||||
parts := []string{}
|
||||
for _, p := range []string{code, isin, desc} {
|
||||
if p != "" {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// classifyNSDDoc — что за документ НРД в пакете pkg (текст всех файлов пакета — body).
|
||||
func classifyNSDDoc(pkg, body string) nsdDoc {
|
||||
d := nsdDoc{Pkg: pkg}
|
||||
biz := xmlTag(body, "BizSvc")
|
||||
up := strings.ToUpper(body)
|
||||
switch {
|
||||
case biz == "MS010" || strings.Contains(body, "sese.025"):
|
||||
d.Code, d.Done = "MS010", true
|
||||
op := map[string]string{"DELI": "списание", "RECE": "зачисление"}[xmlTag(body, "SctiesMvmntTp")]
|
||||
d.Title = "Отчёт об исполнении поручения (MS010)"
|
||||
d.About = "Итоговый документ: НРД исполнил депо-поручение" + pick3(op != "", " — "+op+" бумаг проведено", "") + "."
|
||||
addField(&d, "№ поручения", xmlTag(body, "AcctOwnrTxId"))
|
||||
addField(&d, "Референс сделки M2M", xmlTag(body, "TradId"))
|
||||
addField(&d, "Операция", op)
|
||||
addField(&d, "Ценная бумага", securityLine(body))
|
||||
if m := qtyUnitRe.FindStringSubmatch(body); m != nil {
|
||||
addField(&d, "Количество", m[1])
|
||||
}
|
||||
if m := sfkpgAcctRe.FindStringSubmatch(body); m != nil {
|
||||
addField(&d, "Счёт депо", m[1])
|
||||
}
|
||||
if m := dateInRe.FindStringSubmatch(body); m != nil {
|
||||
addField(&d, "Дата расчётов", m[2])
|
||||
}
|
||||
case biz == "GS116" || strings.Contains(body, "sese.024"):
|
||||
d.Code = "GS116"
|
||||
d.Title = "Отчёт о статусе поручения (GS116)"
|
||||
switch {
|
||||
case strings.Contains(body, "<Mtchd>"):
|
||||
d.About = "Поручение сквитовано со встречным поручением контрагента (MACH) — НРД переходит к исполнению."
|
||||
addField(&d, "Статус сверки", "сквитовано (MACH)")
|
||||
case strings.Contains(body, "<NMtchd>"):
|
||||
d.About = "Поручение не сквитовано: встречное поручение контрагента не найдено или не совпадает по реквизитам."
|
||||
addField(&d, "Статус сверки", "не сквитовано")
|
||||
default:
|
||||
d.About = "Промежуточный отчёт НРД о статусе поручения."
|
||||
}
|
||||
addField(&d, "№ поручения", xmlTag(body, "AcctOwnrTxId"))
|
||||
addField(&d, "Рег. № в НРД", xmlTag(body, "AcctSvcrTxId"))
|
||||
addField(&d, "Ценная бумага", securityLine(body))
|
||||
if m := qtyUnitRe.FindStringSubmatch(body); m != nil {
|
||||
addField(&d, "Количество", m[1])
|
||||
}
|
||||
if m := prtryIDRe.FindStringSubmatch(body); m != nil {
|
||||
addField(&d, pick3(m[1] == "Rcvg", "Получатель", "Отправитель"), m[2])
|
||||
}
|
||||
if m := dateInRe.FindStringSubmatch(body); m != nil {
|
||||
addField(&d, "Дата расчётов", m[2])
|
||||
}
|
||||
case biz == "GS036" || strings.Contains(body, "gs036"):
|
||||
d.Code = "GS036"
|
||||
subject := ""
|
||||
for _, m := range gs036PairRe.FindAllStringSubmatch(body, -1) {
|
||||
hdr, txt := strings.TrimSpace(html.UnescapeString(m[1])), strings.TrimSpace(html.UnescapeString(m[2]))
|
||||
switch {
|
||||
case hdr == "" && txt != "" && subject == "":
|
||||
subject = txt
|
||||
case hdr != "":
|
||||
addField(&d, hdr, txt)
|
||||
}
|
||||
}
|
||||
d.Title = "Уведомление НРД (GS036)"
|
||||
if subject = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(subject, "об "), "о ")); subject != "" {
|
||||
prep := "о "
|
||||
if r := []rune(subject); strings.ContainsRune("аоуэиыАОУЭИЫ", r[0]) {
|
||||
prep = "об "
|
||||
}
|
||||
d.Title = "Уведомление " + prep + strings.TrimSuffix(subject, " к поручению депо") + " (GS036)"
|
||||
}
|
||||
d.About = "Промежуточное уведомление по поручению; итог — в отчёте о статусе (GS116) и об исполнении (MS010)."
|
||||
case strings.Contains(body, "<reg_no>") || strings.Contains(body, "<contragent_doc_id>"):
|
||||
abort, cause := xmlTag(body, "abort_code"), xmlTag(body, "abort_cause")
|
||||
if abort != "" || cause != "" {
|
||||
d.Code, d.Rejected = "REG_REJECT", true
|
||||
d.Title = "Отказ в регистрации поручения"
|
||||
d.About = "НРД не принял поручение к исполнению: " + strings.TrimSpace(abort+" "+cause) + "."
|
||||
} else {
|
||||
d.Code = "REG"
|
||||
d.Title = "Квитанция о регистрации поручения"
|
||||
d.About = "НРД принял поручение к обработке и присвоил регистрационный номер."
|
||||
}
|
||||
addField(&d, "Рег. № в НРД", xmlTag(body, "reg_no"))
|
||||
addField(&d, "Дата регистрации", xmlTag(body, "reg_date"))
|
||||
addField(&d, "№ поручения", xmlTag(body, "contragent_doc_id"))
|
||||
addField(&d, "Код отказа", abort)
|
||||
addField(&d, "Причина отказа", cause)
|
||||
case strings.Contains(up, "ERROR") || strings.Contains(strings.ToUpper(pkg), "ERROR") || strings.Contains(strings.ToLower(body), "ошибк"):
|
||||
d.Code, d.Rejected = "ERROR", true
|
||||
d.Title = "Ошибка обработки пакета"
|
||||
d.About = "НРД не смог обработать пакет: " + orderRespSummary(body)
|
||||
case biz != "":
|
||||
d.Title = "Отчёт НРД (" + biz + ")"
|
||||
d.About = "Документ НРД по поручению."
|
||||
default:
|
||||
d.Title = "Документ НРД"
|
||||
d.About = "Документ НРД по поручению (тип не распознан — см. файлы в папке)."
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// pkgFiles — файлы пакета (xml/rpt/htm/dbf/sgn, содержимое zip) без имени папки.
|
||||
func pkgFiles(dir string) []respFile {
|
||||
var out []respFile
|
||||
files, _ := os.ReadDir(dir)
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(dir, f.Name())
|
||||
low := strings.ToLower(f.Name())
|
||||
switch {
|
||||
case strings.HasSuffix(low, ".xml"), strings.HasSuffix(low, ".rpt"), strings.HasSuffix(low, ".htm"), strings.HasSuffix(low, ".dbf"), strings.HasSuffix(low, ".sgn"):
|
||||
if b, e := os.ReadFile(p); e == nil {
|
||||
out = append(out, respFile{Name: f.Name(), Data: b})
|
||||
}
|
||||
case strings.HasSuffix(low, ".zip"):
|
||||
if b, e := os.ReadFile(p); e == nil {
|
||||
if zr, e2 := zip.NewReader(bytes.NewReader(b), int64(len(b))); e2 == nil {
|
||||
for _, zf := range zr.File {
|
||||
if rc, e3 := zf.Open(); e3 == nil {
|
||||
data, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
out = append(out, respFile{Name: zf.Name, Data: data})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nsdOrderDocs — документы НРД по поручениям (contr_d_id), по времени.
|
||||
func nsdOrderDocs(contrDIDs ...string) []nsdDoc {
|
||||
entries, err := os.ReadDir(igwInboxDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []nsdDoc
|
||||
for _, cd := range contrDIDs {
|
||||
cd = strings.TrimSpace(cd)
|
||||
if cd == "" {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
dir := e.Name()
|
||||
if !e.IsDir() || strings.HasPrefix(dir, "#") || dir == "INBOX" || seen[dir] {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(igwInboxDir, dir)
|
||||
body, mt := readPkgText(full)
|
||||
if body == "" || !strings.Contains(body, cd) {
|
||||
continue
|
||||
}
|
||||
seen[dir] = true
|
||||
d := classifyNSDDoc(dir, body)
|
||||
d.Time, d.Files = mt, pkgFiles(full)
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) })
|
||||
return out
|
||||
}
|
||||
|
||||
var folderBadChars = strings.NewReplacer("/", "-", `\`, "-", ":", " ", "*", "", "?", "", `"`, "", "<", "", ">", "", "|", "")
|
||||
|
||||
// nsdDocFolder — «02 Уведомление об отсутствии встречного поручения (GS036) — 50818777».
|
||||
func nsdDocFolder(i int, d nsdDoc) string {
|
||||
return folderBadChars.Replace(fmt.Sprintf("%02d %s — %s", i+1, d.Title, d.Pkg))
|
||||
}
|
||||
|
||||
func mskTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") + " МСК"
|
||||
}
|
||||
|
||||
// writeNSDDocsZip — документы НРД папками с понятными названиями + «Опись.txt» + PDF (если withPDF).
|
||||
func writeNSDDocsZip(zw *zip.Writer, prefix, title string, docs []nsdDoc, withPDF bool) {
|
||||
var opis strings.Builder
|
||||
opis.WriteString(title + "\r\n")
|
||||
opis.WriteString("Сформировано bj " + mskTime(time.Now()) + ". Папки — документы НРД в порядке поступления; внутри — подлинные файлы из INBOX ИШ (XML, печатная форма .rpt, таблицы .dbf, подпись .SGN).\r\n\r\n")
|
||||
for i, d := range docs {
|
||||
folder := nsdDocFolder(i, d)
|
||||
for _, f := range d.Files {
|
||||
if w, e := zw.Create(prefix + folder + "/" + f.Name); e == nil {
|
||||
_, _ = w.Write(f.Data)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&opis, "%s\r\n Получен: %s\r\n Что это: %s\r\n", folder, mskTime(d.Time), d.About)
|
||||
for _, kv := range d.Fields {
|
||||
fmt.Fprintf(&opis, " %s: %s\r\n", kv[0], kv[1])
|
||||
}
|
||||
opis.WriteString("\r\n")
|
||||
}
|
||||
if w, e := zw.Create(prefix + "Опись.txt"); e == nil {
|
||||
_, _ = w.Write([]byte("\ufeff" + opis.String()))
|
||||
}
|
||||
if !withPDF {
|
||||
return
|
||||
}
|
||||
page := nsdDocsReportHTML(title, docs)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
if pdf, err := htmlToPDF(ctx, page); err == nil {
|
||||
if w, e := zw.Create(prefix + "Документы НРД.pdf"); e == nil {
|
||||
_, _ = w.Write(pdf)
|
||||
}
|
||||
return
|
||||
} else {
|
||||
log.Printf("nsd-docs: PDF не собран (%v) — в архиве HTML", err)
|
||||
}
|
||||
if w, e := zw.Create(prefix + "Документы НРД.html"); e == nil {
|
||||
_, _ = w.Write(page)
|
||||
}
|
||||
}
|
||||
|
||||
// nsdDocsReportHTML — читаемая сводка документов НРД (для PDF).
|
||||
func nsdDocsReportHTML(title string, docs []nsdDoc) []byte {
|
||||
esc := html.EscapeString
|
||||
var b strings.Builder
|
||||
b.WriteString(`<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>` + esc(title) + `</title><style>`)
|
||||
b.WriteString(`@page{size:A4;margin:16mm 14mm}body{font-family:"PT Sans","DejaVu Sans",Arial,sans-serif;color:#1a1a1a;font-size:12px;line-height:1.45}`)
|
||||
b.WriteString(`h1{font-size:18px;margin:0 0 4px}.sub{color:#666;margin:0 0 14px}.doc{border:1px solid #cfd4dc;border-radius:8px;padding:10px 12px;margin:0 0 12px;break-inside:avoid}`)
|
||||
b.WriteString(`.doc h2{font-size:14px;margin:0 0 2px}.meta{color:#666;font-size:11px;margin-bottom:6px}.about{margin:4px 0 8px}`)
|
||||
b.WriteString(`table{border-collapse:collapse;width:100%}td{border-top:1px solid #e4e7ec;padding:4px 6px;vertical-align:top}td.k{color:#555;width:38%}`)
|
||||
b.WriteString(`.ok{color:#0f7a37}.bad{color:#b3261e}.brand{border-bottom:2px solid #ff0508;padding-bottom:8px;margin-bottom:12px}</style></head><body>`)
|
||||
b.WriteString(`<div class="brand"><h1>` + esc(title) + `</h1><p class="sub">Документы НРД по депо-поручению в порядке поступления · сформировано bj ` + esc(mskTime(time.Now())) + `</p></div>`)
|
||||
for i, d := range docs {
|
||||
cls := ""
|
||||
switch {
|
||||
case d.Done:
|
||||
cls = "ok"
|
||||
case d.Rejected:
|
||||
cls = "bad"
|
||||
}
|
||||
fmt.Fprintf(&b, `<div class="doc"><h2 class="%s">%d. %s</h2><div class="meta">пакет НРД %s · получен %s</div><div class="about">%s</div>`,
|
||||
cls, i+1, esc(d.Title), esc(d.Pkg), esc(mskTime(d.Time)), esc(d.About))
|
||||
if len(d.Fields) > 0 {
|
||||
b.WriteString("<table>")
|
||||
for _, kv := range d.Fields {
|
||||
fmt.Fprintf(&b, `<tr><td class="k">%s</td><td>%s</td></tr>`, esc(kv[0]), esc(kv[1]))
|
||||
}
|
||||
b.WriteString("</table>")
|
||||
}
|
||||
b.WriteString("</div>")
|
||||
}
|
||||
b.WriteString(`<p class="sub">Подлинные документы — файлы в папках архива (XML с подписью НРД .SGN). Этот PDF — их читаемое представление.</p></body></html>`)
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// htmlToPDF — печать HTML в PDF headless-браузером (Chromium есть на серверах bj с Astra).
|
||||
func htmlToPDF(ctx context.Context, page []byte) ([]byte, error) {
|
||||
bin := ""
|
||||
for _, name := range []string{"chromium", "chromium-browser", "google-chrome"} {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
bin = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if bin == "" {
|
||||
return nil, fmt.Errorf("браузер для печати в PDF не найден")
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bj-pdf-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
in, out := filepath.Join(dir, "page.html"), filepath.Join(dir, "page.pdf")
|
||||
if err := os.WriteFile(in, page, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, bin, "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage",
|
||||
"--no-pdf-header-footer", "--user-data-dir="+filepath.Join(dir, "profile"), "--print-to-pdf="+out, "file://"+in)
|
||||
cmd.Env = append(os.Environ(), "HOME="+dir)
|
||||
if msg, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("%v: %s", err, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
return os.ReadFile(out)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build live
|
||||
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Живой прогон: документы НРД по поручению 20608 (исходящий 16.09) → ZIP с папками, описью и PDF.
|
||||
// go test -c -tags live ./internal/lkgateway/ ; запуск от bj: -test.run TestNSDDocsLive -test.v
|
||||
func TestNSDDocsLive(t *testing.T) {
|
||||
docs := nsdOrderDocs("20608")
|
||||
for i, d := range docs {
|
||||
t.Logf("%s | %v", nsdDocFolder(i, d), d.Fields)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
writeNSDDocsZip(zw, "", "Документы НРД по поручению 16 (списание) № 20608", docs, true)
|
||||
zw.Close()
|
||||
zr, _ := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "Опись.txt" || f.Name == "Документы НРД.pdf" || f.Name == "Документы НРД.html" {
|
||||
t.Logf("в архиве: %s (%d байт)", f.Name, f.UncompressedSize64)
|
||||
}
|
||||
}
|
||||
_ = os.WriteFile(os.TempDir()+"/nsd-docs-20608.zip", buf.Bytes(), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyNSDDoc(t *testing.T) {
|
||||
cases := []struct{ pkg, body, code, title string }{
|
||||
{"K1699092", `<Batch><Document><reg_no>14462249</reg_no><reg_date>2026-09-16 16:03:53</reg_date><contragent_doc_id>20608</contragent_doc_id><abort_code /><abort_cause /></Document></Batch>`,
|
||||
"REG", "Квитанция о регистрации поручения"},
|
||||
{"K1", `<Batch><Document><reg_no>1</reg_no><contragent_doc_id>20608</contragent_doc_id><abort_code>12</abort_code><abort_cause>Нет встречного</abort_cause></Document></Batch>`,
|
||||
"REG_REJECT", "Отказ в регистрации поручения"},
|
||||
{"50818777", `<BizSvc>GS036</BizSvc><GS036><Ntfctns><NtfctnTxt> отсутствии встречного поручения к поручению депо</NtfctnTxt></Ntfctns><Ntfctns><NtfctnHdr>Код операции</NtfctnHdr><NtfctnTxt>16</NtfctnTxt></Ntfctns></GS036>`,
|
||||
"GS036", "Уведомление об отсутствии встречного поручения (GS036)"},
|
||||
{"50818779", `<BizSvc>GS116</BizSvc><AcctOwnrTxId>20608</AcctOwnrTxId><AcctSvcrTxId>14462249</AcctSvcrTxId><MtchgSts><Mtchd><Rsn><Id>MACH</Id></Rsn></Mtchd></MtchgSts>`,
|
||||
"GS116", "Отчёт о статусе поручения (GS116)"},
|
||||
{"50818781", `<BizSvc>MS010</BizSvc><AcctOwnrTxId>20608</AcctOwnrTxId><SctiesMvmntTp>DELI</SctiesMvmntTp><SttldQty><Qty><Unit>10</Unit></Qty></SttldQty><SfkpgAcct><Id>HL171004001C</Id></SfkpgAcct>`,
|
||||
"MS010", "Отчёт об исполнении поручения (MS010)"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
d := classifyNSDDoc(c.pkg, c.body)
|
||||
if d.Code != c.code || d.Title != c.title {
|
||||
t.Errorf("%s: %s «%s», ожидали %s «%s»", c.pkg, d.Code, d.Title, c.code, c.title)
|
||||
}
|
||||
}
|
||||
ms := classifyNSDDoc("50818781", cases[4].body)
|
||||
if !ms.Done || !strings.Contains(ms.About, "списание") {
|
||||
t.Errorf("MS010: %+v", ms)
|
||||
}
|
||||
var acc string
|
||||
for _, kv := range ms.Fields {
|
||||
if kv[0] == "Счёт депо" {
|
||||
acc = kv[1]
|
||||
}
|
||||
}
|
||||
if acc != "HL171004001C" {
|
||||
t.Errorf("счёт депо: %q (%v)", acc, ms.Fields)
|
||||
}
|
||||
if f := nsdDocFolder(1, classifyNSDDoc("50818777", cases[2].body)); f != "02 Уведомление об отсутствии встречного поручения (GS036) — 50818777" {
|
||||
t.Errorf("папка: %q", f)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user