diff --git a/cmd/bj-component-install/main.go b/cmd/bj-component-install/main.go index 6dc4500..6d25aef 100644 --- a/cmd/bj-component-install/main.go +++ b/cmd/bj-component-install/main.go @@ -5,6 +5,7 @@ // этот инструмент. Проверка и установка — release.InstallStaged: подпись манифеста ключом из // /etc/bj/update.pub (принадлежит root), sha256 файлов, только известные компоненты, без отката, // копия прежнего файла, перезапуск служб (ядро последним), записи в /var/lib/bj/.bj/components.json. +// Выделенным из ядра модулям, которые ставятся впервые, ставит и включает unit-файл службы из комплекта. // Вывод — JSON для bj-server. // // bj-component-install <каталог подготовки> @@ -17,6 +18,7 @@ import ( "fmt" "os" + "git.zetit.ru/zuevav/Bridge-and-Join-s/deploy" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" ) @@ -30,7 +32,7 @@ func main() { 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}) + 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)) diff --git a/cmd/bj-quikexport/main.go b/cmd/bj-quikexport/main.go new file mode 100644 index 0000000..9580a85 --- /dev/null +++ b/cmd/bj-quikexport/main.go @@ -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}) + 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) + } +} diff --git a/deploy/embed.go b/deploy/embed.go index ba9a9f0..fe5941c 100644 --- a/deploy/embed.go +++ b/deploy/embed.go @@ -6,5 +6,5 @@ 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 +//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service systemd/bj-npr.service systemd/bj-quikexport.service var Files embed.FS diff --git a/deploy/systemd/bj-quikexport.service b/deploy/systemd/bj-quikexport.service new file mode 100644 index 0000000..4d272d1 --- /dev/null +++ b/deploy/systemd/bj-quikexport.service @@ -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 diff --git a/internal/lkgateway/modules.go b/internal/lkgateway/modules.go new file mode 100644 index 0000000..d295618 --- /dev/null +++ b/internal/lkgateway/modules.go @@ -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(`

` + template.HTMLEscapeString(title) + `

` + template.HTMLEscapeString(text) + `

` + + `

` + template.HTMLEscapeString(hint) + `

`) +} + +func pickStr(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} diff --git a/internal/lkgateway/npr.go b/internal/lkgateway/npr.go index 8095115..961b0a4 100644 --- a/internal/lkgateway/npr.go +++ b/internal/lkgateway/npr.go @@ -8,8 +8,6 @@ package lkgateway import ( "context" - "crypto/rand" - "encoding/hex" "fmt" "html/template" "io" @@ -48,30 +46,7 @@ func nprAvailable(rc *RuntimeConfig) bool { // ---- служба модуля ---- -const moduleKeyFile = "/var/lib/bj/.bj/module.key" - -var ( - moduleKeyMu sync.Mutex - nprHTTP = &http.Client{Timeout: 60 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} -) - -// 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 -} +var nprHTTP = &http.Client{Timeout: 60 * time.Second, CheckRedirect: noRedirect} func nprURL() string { if u := strings.TrimSpace(os.Getenv("BJ_NPR_URL")); u != "" { @@ -80,17 +55,6 @@ func nprURL() string { return "http://127.0.0.1:8083" } -// modulePageData — страница модуля в макете bj. -type modulePageData struct { - page - Content template.HTML -} - -func moduleMessage(title, text, hint string) template.HTML { - return template.HTML(`

` + template.HTMLEscapeString(title) + `

` + template.HTMLEscapeString(text) + `

` + - `

` + template.HTMLEscapeString(hint) + `

`) -} - // 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) { @@ -156,13 +120,6 @@ func registerNPR(mux *http.ServeMux, a *admin, rc *RuntimeConfig) { mux.HandleFunc("/admin/npr/", proxy) } -func pickStr(v, def string) string { - if strings.TrimSpace(v) == "" { - return def - } - return v -} - // ---- настройки: проверка подключения ---- var ( diff --git a/internal/lkgateway/npr_proxy_test.go b/internal/lkgateway/npr_proxy_test.go index 0d88d98..86d2344 100644 --- a/internal/lkgateway/npr_proxy_test.go +++ b/internal/lkgateway/npr_proxy_test.go @@ -30,3 +30,25 @@ func TestModulePageWrap(t *testing.T) { t.Errorf("ожидался один документ, QUIK — Экспорт биржевой информации
раздел службы
` + 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`) { + t.Error("индикатор меню не взят у службы") + } +} diff --git a/internal/lkgateway/quikexport_proxy.go b/internal/lkgateway/quikexport_proxy.go new file mode 100644 index 0000000..ee514fc --- /dev/null +++ b/internal/lkgateway/quikexport_proxy.go @@ -0,0 +1,131 @@ +package lkgateway + +// Модуль «Экспорт биржевой информации» (ARQA quikexport) работает отдельной службой bj-quikexport +// (modules/quikexport, обновляется независимо). Раздел остаётся на странице «Настройка»: bj-server +// получает от службы готовый HTML раздела и цвет индикатора меню, а формы раздела +// (/admin/setup/quikexport/…) проксирует службе и превращает её ответ X-BJ-Flash в обычное +// сообщение страницы настроек. Лицензию проверяет bj-server и передаёт в X-BJ-Licensed. + +import ( + "bytes" + "context" + "fmt" + "html/template" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// quikExportFeature — лицензионный признак модуля. +const quikExportFeature = "quik-export" + +// Применение настроек ждёт root-воркер до 3 минут. +var quikExportHTTP = &http.Client{Timeout: 4 * time.Minute, CheckRedirect: noRedirect} + +func quikExportURL() string { + if u := strings.TrimSpace(os.Getenv("BJ_QUIKEXPORT_URL")); u != "" { + return strings.TrimRight(u, "/") + } + return "http://127.0.0.1:8084" +} + +// QuikExportView — раздел «Экспорт QUIK» на странице настроек. +type QuikExportView struct { + HTML template.HTML + Ind string // индикатор в меню: ok | warn | err | пусто +} + +func quikExportDo(ctx context.Context, rc *RuntimeConfig, method, uri string, body io.Reader, contentType string) (*http.Response, error) { + key, err := moduleKey() + if err != nil { + return nil, fmt.Errorf("служебный ключ модулей: %w", err) + } + req, err := http.NewRequestWithContext(ctx, method, quikExportURL()+uri, body) + if err != nil { + return nil, err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("X-BJ-Module-Key", key) + lic := "0" + if moduleAllowed(rc, quikExportFeature) { + lic = "1" + } + req.Header.Set("X-BJ-Licensed", lic) + return quikExportHTTP.Do(req) +} + +func quikExportView(rc *RuntimeConfig, showRO bool) QuikExportView { + uri := "/section" + if showRO { + uri += "?reveal_ro=1" + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + resp, err := quikExportDo(ctx, rc, http.MethodGet, uri, nil, "") + if err == nil { + b, rerr := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + resp.Body.Close() + switch { + case rerr != nil: + err = rerr + case resp.StatusCode != http.StatusOK: + err = fmt.Errorf("служба ответила %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + default: + return QuikExportView{HTML: template.HTML(b), Ind: resp.Header.Get("X-BJ-Indicator")} + } + } + return QuikExportView{Ind: "err", HTML: template.HTML(`

QUIK — Экспорт биржевой информации

`) + moduleMessage( + "Служба модуля не отвечает", + "Служба bj-quikexport не запущена или ещё не установлена на этом сервере ("+err.Error()+").", + "Установка и обновление модуля: Настройка → Обновления. Состояние службы: systemctl status bj-quikexport. Сам экспортёр ARQA от этого не зависит и продолжает выгрузку.")} +} + +// registerQuikExportSetup — формы раздела проксируются службе bj-quikexport. +func registerQuikExportSetup(mux *http.ServeMux, h *setupHandlers) { + proxy := func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(body) == 0 && r.PostForm != nil && !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") { + body = []byte(r.PostForm.Encode()) // форму уже разобрали раньше + } + // setupFlash читает return_to/section из формы — возвращаем тело на место. + restore := func() { r.Body = io.NopCloser(bytes.NewReader(body)) } + resp, err := quikExportDo(r.Context(), h.rc, r.Method, r.URL.RequestURI(), bytes.NewReader(body), r.Header.Get("Content-Type")) + if err != nil { + if r.Method == http.MethodPost { + restore() + setupFlash(w, r, "Экспорт QUIK: служба bj-quikexport не отвечает — "+err.Error()) + return + } + http.Error(w, "служба bj-quikexport не отвечает: "+err.Error(), http.StatusServiceUnavailable) + return + } + defer resp.Body.Close() + if f := resp.Header.Get("X-BJ-Flash"); f != "" { + msg, uerr := url.PathUnescape(f) + if uerr != nil { + msg = f + } + restore() + setupFlash(w, r, msg) + return + } + for _, k := range []string{"Content-Type", "Content-Disposition", "Cache-Control"} { + if v := resp.Header.Get(k); v != "" { + w.Header().Set(k, v) + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + } + mux.HandleFunc("/admin/setup/quikexport", proxy) + mux.HandleFunc("/admin/setup/quikexport/", proxy) +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index d3fc31d..087de99 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -36,7 +36,7 @@ type Settings struct { License LicenseSettings `json:"license"` Branding BrandingSettings `json:"branding"` NPR NPRSettings `json:"npr"` - QuikExport QuikExportSettings `json:"quik_export"` + QuikExport json.RawMessage `json:"quik_export,omitempty"` // устарело: настройки у службы bj-quikexport Auth AuthSettings `json:"auth"` LastTest *TestRunResult `json:"last_test,omitempty"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/lkgateway/updater.go b/internal/lkgateway/updater.go index 3e600eb..80aba7e 100644 --- a/internal/lkgateway/updater.go +++ b/internal/lkgateway/updater.go @@ -95,13 +95,16 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) { } st.Available, st.Notes = m.Version, m.Notes st.Components = release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion) + release.MarkSplitModules(st.Components, u.allowed) var upd []string avail := map[string]string{} for _, c := range st.Components { if c.Available != "" { avail[c.Name] = c.Available } - if c.Update { + if c.New { + upd = append(upd, c.Name+" "+c.Available+" (установка модуля)") + } else if c.Update { upd = append(upd, c.Name+" "+c.Available) } } @@ -150,6 +153,7 @@ func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, erro return "", fmt.Errorf("манифест: %w", err) } plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion) + release.MarkSplitModules(plan, u.allowed) want := map[string]bool{} for _, n := range names { want[n] = true @@ -167,6 +171,29 @@ func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, erro if len(selected) == 0 { return "", fmt.Errorf("нечего обновлять: все установленные компоненты актуальны (релиз %s)", m.Version) } + // Прежний root-установщик не умеет впервые ставить выделенные из ядра модули: пока он сам не + // обновлён, такие модули откладываются — их поставит ensureSplitModules после перезапуска. + installerOutdated := false + for _, c := range plan { + if c.Name == "bj-component-install" && c.Update { + installerOutdated = true + } + } + var deferred []string + if installerOutdated { + kept := selected[:0] + for _, c := range selected { + if c.New { + deferred = append(deferred, c.Name) + continue + } + kept = append(kept, c) + } + selected = kept + if len(selected) == 0 { + return "", fmt.Errorf("сначала обновите установщик обновлений (bj-component-install): текущий не ставит модули %s", strings.Join(deferred, ", ")) + } + } cleanupStaging(24 * time.Hour) staging := filepath.Join(updateStagingDir, fmt.Sprintf("%d", time.Now().UnixNano())) @@ -180,6 +207,7 @@ func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, erro var list []string var req struct { Names []string `json:"names"` + New []string `json:"new,omitempty"` } for _, c := range selected { for _, a := range m.Artifacts { @@ -190,6 +218,9 @@ func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, erro } } req.Names = append(req.Names, c.Name) + if c.New { + req.New = append(req.New, c.Name) + } list = append(list, c.Name+" "+c.Available) } rb, _ := json.Marshal(req) @@ -212,9 +243,42 @@ func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, erro } msg += "; перезапущены службы: " + strings.Join(units, ", ") } + if len(deferred) > 0 { + msg += "; модули " + strings.Join(deferred, ", ") + " будут установлены после перезапуска bj-server новым установщиком" + } return msg, nil } +// allowed — разрешён ли лицензией признак компонента. +func (u *Updater) allowed(feature string) bool { return moduleAllowed(u.rc, feature) } + +// ensureSplitModules — выделенные из ядра модули, которые входят в лицензию, но ещё не стоят на этом +// сервере (например, ядро обновилось прежним установщиком), ставятся сами: без своих служб разделы +// этих модулей в bj не работают. Раз за запуск bj-server; ошибки — только в журнал и «Последнюю проверку». +func (u *Updater) ensureSplitModules(ctx context.Context) { + var names []string + for _, c := range release.Plan("", nil, release.LoadInstalled(release.InstalledFile), BuildVersion) { + if c.FromCore && !c.Present && u.allowed(c.Feature) { + names = append(names, c.Name) + } + } + if len(names) == 0 { + return + } + cctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + msg, err := u.ApplyUpdate(cctx, names) + if err != nil { + msg = "Установка модулей " + strings.Join(names, ", ") + " не выполнена: " + err.Error() + } else { + msg = "Установка модулей: " + msg + } + log.Printf("lk-gateway: %s", msg) + cfg := u.rc.Snapshot().Update + cfg.LastResult, cfg.LastCheck = msg, time.Now().UTC() + _ = u.rc.SaveUpdateSettings(cfg) +} + // updateComponentRows — компоненты для раздела «Обновления» без обращения к артефактории: // установленное на этом сервере и версии из последней проверки. func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) { @@ -227,9 +291,12 @@ func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) { r.Update = r.Available != "" && (r.Present || r.Core) && (r.Installed == "" || release.IsNewer(r.Available, r.Installed)) if r.Present || r.Core || r.Available != "" { out = append(out, r) - any = any || r.Update } } + release.MarkSplitModules(out, func(f string) bool { return moduleAllowed(rc, f) }) + for _, r := range out { + any = any || r.Update + } return out, any } @@ -273,6 +340,7 @@ func (u *Updater) updateLoop(ctx context.Context) { return case <-time.After(time.Minute): cleanupStaging(time.Hour) + u.ensureSplitModules(ctx) check() } for { diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 6902a7a..272e5ae 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -8,7 +8,7 @@ {{if or .AdvancedUnlocked (index .Modules "quik-mnp")}} - {{end}} + {{end}} @@ -362,135 +362,7 @@ {{/* ============ ЭКСПОРТ БИРЖЕВОЙ ИНФОРМАЦИИ (QUIK) ============ */}}
- {{$X := .QuikExport}}{{$S := $X.S}} -

QUIK — Экспорт биржевой информации

-

Модуль ARQA «Экспорт биржевой информации» выгружает данные сервера QUIK (портфели клиентов, позиции, сделки, справочники) в базу PostgreSQL. bj управляет им отсюда: ключи и логин QUIK, где держим базу, какой объём данных выгружаем, состояние службы и место на диске. На этих данных работают модуль «НПР / УДС» и дополнительные проверки M2M. Лицензионный признак quik-export.

- {{range $X.Warnings}}
{{.}}
{{end}} - -
-

Состояние модуля

-
- - - - {{if not $X.Licensed}}не входит в лицензию — см. раздел «Лицензия»{{end}} -
-
-
Служба quikexport
- {{if not $X.Service.Installed}}не установлена{{else if eq $X.Service.Active "active"}}работает{{else}}{{$X.Service.Active}}{{end}} -
{{if $X.Service.PID}}PID {{$X.Service.PID}} · {{end}}{{if $X.Service.MemoryMB}}память {{$X.Service.MemoryMB}} МБ · {{end}}автозапуск {{if $X.Service.Enabled}}да{{else}}нет{{end}}{{if $X.Service.Restarts}} · перезапусков {{$X.Service.Restarts}}{{end}}
-
Данные из QUIK
- {{if $X.DB.OK}}{{if $X.DB.LastTick.IsZero}}нет пульса{{else if lt $X.DB.TickAgeSec 180}}свежие{{else}}устарели{{end}} -
{{if not $X.DB.LastTick.IsZero}}цикл {{msk $X.DB.LastTick}} ({{$X.DB.TickAgeSec}} с назад) · {{end}}клиентов {{$X.DB.Clients}}
- {{else}}база недоступна
{{$X.DB.Error}}
{{end}}
-
Объём базы {{$S.DBName}}
- {{printf "%.0f" $X.DB.DBSizeMB}} МБ -
история НПР {{$X.DB.HistoryRows}} строк{{if not $X.DB.HistoryFrom.IsZero}} с {{$X.DB.HistoryFrom.Format "02.01.2006"}}{{end}} · рост ≈ {{printf "%.1f" $X.DB.GrowthMBDay}} МБ/сутки ({{$X.DB.Rows24h}} событий за 24 ч)
-
Диск {{if $X.DiskDB.OK}}базы данных{{else}}экспортёра{{end}}
- {{with $X.DiskDB}}{{if .OK}}свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}
занято {{.UsedPct}}% · {{.Path}}
{{end}}{{end}} - {{if not $X.DiskDB.OK}}{{with $X.DiskExp}}свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}
занято {{.UsedPct}}%
{{end}}{{end}}
-
Логи и кэш экспортёра
- {{printf "%.0f" $X.LogsMB}} МБ логов -
{{range $X.Logs}}{{if .Exists}}{{.Name}} {{printf "%.0f" .SizeMB}} МБ · {{end}}{{end}}кэш QAPI {{printf "%.0f" $X.QAPIMB}} МБ · ротация: ежедневно / 200 МБ, 5 копий
-
- {{if $X.DB.Tables}}
Крупнейшие таблицы базы - {{range $X.DB.Tables}}{{end}}
ТаблицаРазмерСтрок (оценка)
{{.Name}}{{.Size}}{{.Rows}}
{{end}} -
-
-
- {{if and $X.Service.Installed (not $X.Service.Enabled)}}
{{end}} -
- {{if $X.LogTail}}
Последние строки export.log{{if $X.ErrTail}} и export.err{{end}} -
{{range $X.LogTail}}{{.}}
-{{end}}{{if $X.ErrTail}}--- export.err ---
-{{range $X.ErrTail}}{{.}}
-{{end}}{{end}}
{{end}} -
- -
-

1. Подключение к серверу QUIK

-

Логин экспорта на сервере QUIK брокера (выдаёт администратор QUIK / ARQA) и пара ключей pubring.txk / secring.txk к нему. Одна учётная запись = одна сессия: второй экспортёр с тем же логином получит «Вы уже работаете в системе».

-
- - - - - - -
-
-
- - - - -
-

Ключи: {{if $S.KeysNames}}{{range $S.KeysNames}}{{.}} {{end}}{{if not $S.KeysAt.IsZero}}(загружены {{msk $S.KeysAt}}){{end}}{{else}}не загружены{{end}}. - В каталоге экспортёра: {{range $X.Files}}{{.Name}} {{if .Exists}}✓ {{.ModTime.Format "02.01 15:04"}}{{else}}✗{{end}} · {{end}}

-
- -
-

2. База данных экспорта

-

Куда экспортёр пишет данные. Обычно — наш PostgreSQL на этом же сервере (bj создаст базу, роли и схему одной кнопкой). Внешний сервер — если базу держит заказчик. Логин только для чтения используют модуль «НПР / УДС», проверки M2M и внешние системы (Grafana, отчёты).

-
- -
- - -
-
- - -
-
- - - - - -
-
- - SQL для внешнего сервера
-
-

Доступ для внешних систем и других модулей: {{if $X.ShowRO}}{{$X.RODSNFull}}{{else}}{{$X.RODSN}}{{end}} - {{if $X.ShowRO}}скрыть пароль{{else}}показать пароль{{end}}{{if ne $S.DBMode "external"}} · снаружи: хост {{$X.ServerIP}}, доступ по IP открывает администратор (pg_hba){{end}}.

-
- -
-

3. Объём данных

-

Какие таблицы QUIK выгружаем. Чем больше набор, тем больше нагрузка на сервер QUIK, база и диск. Портфели пересчитываются каждые «период портфелей» секунд (ARQA рекомендует не менее 60); история изменений НПР/УДС хранится в схеме npr.

-
- -
{{range $X.Presets}} - {{end}} -
-
- {{range $g := $X.Groups}}
{{$g}}
-
{{range $X.Tables}}{{if eq .Group $g}} - {{end}}{{end}}
{{end}} -
-
Сейчас включено: {{len $S.Tables}} таблиц — {{range $S.Tables}}{{.}} {{end}}
-
- - - - -
-
-
-
- -
-

4. Применить к экспортёру

-

bj собирает export.ini, info.ini, секцию ODBC и ключи из настроек выше и устанавливает их в каталог службы ({{$S.Dir}}) с резервной копией прежних файлов, настраивает ротацию логов и перезапускает экспортёр. {{if not $S.AppliedAt.IsZero}}Последнее применение: {{msk $S.AppliedAt}}.{{end}}{{if not $S.Imported}} Если экспортёр уже настроен вручную — сначала «Прочитать настройки из экспортёра».{{end}}

-
- Предпросмотр файлов -
-
-
-

Установка на новом сервере: дистрибутив ARQA (install_quikexport-12.x.run, Linux) ставится установщиком bj в {{$S.Dir}}; нужны пакеты unixodbc, odbc-postgresql, libjemalloc2. Обновления экспортёра приходят через канал обновлений bj после проверки у нас.

-
+ {{.QuikExport.HTML}}
{{/* ============ КРИПТОГРАФИЯ ============ */}} @@ -923,16 +795,16 @@

Компоненты

-

Каждый компонент обновляется отдельно: своя версия в релизе, перезапускается только его служба. Файлы ставит root-установщик после повторной проверки подписи и контрольных сумм. Модули, которых нет на этом сервере, обновлением не ставятся.

+

Каждый компонент обновляется отдельно: своя версия в релизе, перезапускается только его служба. Файлы ставит root-установщик после повторной проверки подписи и контрольных сумм. Модули, которых нет на этом сервере, обновлением не ставятся — кроме выделенных из ядра (НПР / УДС, экспорт QUIK): если модуль входит в лицензию, он ставится вместе с обновлением, иначе его раздел перестал бы работать.

{{range .Components}} - + - + {{end}}
КомпонентУстановленаДоступнаСлужба
{{.Title}}
{{.Name}}
{{if .Present}}{{if .Installed}}{{.Installed}}{{else}}неизвестна{{end}}{{else}}не установлен{{end}}{{if .Available}}{{.Available}}{{if .Update}} новее{{end}}{{else}}нет данных{{end}}{{if .Available}}{{.Available}}{{if .New}} к установке{{else if .Update}} новее{{end}}{{else}}нет данных{{end}} {{.Unit}}{{if .Update}}
{{end}}
{{if .Update}}
{{end}}
diff --git a/internal/release/components.go b/internal/release/components.go index 2127412..f14e266 100644 --- a/internal/release/components.go +++ b/internal/release/components.go @@ -21,6 +21,9 @@ type Component struct { Unit string // служба systemd, перезапускаемая после установки ("" — без перезапуска) Exec bool // исполняемый файл Core bool // ядро: ставится всегда; модули — только если уже установлены на этом сервере + // FromCore — модуль выделен из bj-server: обновление ставит его и там, где его ещё нет, если он + // входит в лицензию (иначе после обновления ядра раздел модуля перестал бы работать). + FromCore bool // Feature — признак лицензии, без которого сервер обновлений не отдаёт файл компонента. Feature string } @@ -31,8 +34,8 @@ var Components = []Component{ {Name: "bj-component-install", Title: "Установщик обновлений", Path: "/usr/local/sbin/bj-component-install", Exec: true, Core: true, Feature: "updates"}, {Name: "crypto-service.jar", Title: "Криптосервис", Path: "/opt/bj/crypto-service.jar", Unit: "bj-crypto", Feature: "updates"}, {Name: "bj-lk", Title: "Личный кабинет клиента", Path: "/opt/bj/bj-lk", Unit: "bj-lk", Exec: true, Feature: "web-cabinet"}, - {Name: "bj-npr", Title: "Риск-монитор НПР / УДС", Path: "/opt/bj/bj-npr", Unit: "bj-npr", Exec: true, Feature: "npr"}, - {Name: "bj-quikexport", Title: "Экспорт QUIK", Path: "/opt/bj/bj-quikexport", Unit: "bj-quikexport", Exec: true, Feature: "quik-export"}, + {Name: "bj-npr", Title: "Риск-монитор НПР / УДС", Path: "/opt/bj/bj-npr", Unit: "bj-npr", Exec: true, FromCore: true, Feature: "npr"}, + {Name: "bj-quikexport", Title: "Экспорт QUIK", Path: "/opt/bj/bj-quikexport", Unit: "bj-quikexport", Exec: true, FromCore: true, Feature: "quik-export"}, } // ComponentByName — компонент по имени артефакта. @@ -96,6 +99,7 @@ type ComponentState struct { Available string // версия в манифесте ("" — компонента нет в релизе) SameFile bool // установленный файл совпадает с файлом релиза Update bool // можно обновить + New bool // модуль выделен из ядра и ставится впервые (FromCore) } // Plan — что можно обновить. m == nil — только состояние установленного (без сети). @@ -133,6 +137,17 @@ func Plan(root string, m *Manifest, installed map[string]Installed, selfVersion return out } +// MarkSplitModules — отметить к установке выделенные из ядра модули (FromCore), которых ещё нет на +// этом сервере, если они есть в релизе и разрешены лицензией (allowed — по признаку компонента). +func MarkSplitModules(states []ComponentState, allowed func(feature string) bool) { + for i := range states { + s := &states[i] + if s.FromCore && !s.Present && s.Available != "" && allowed(s.Feature) { + s.Update, s.New = true, true + } + } +} + // cachedHash — sha256 файла с кэшем по размеру и времени изменения: страница настроек // не пересчитывает десятки мегабайт на каждый показ. var hashCache = struct { diff --git a/internal/release/components_test.go b/internal/release/components_test.go index 8b88ad4..ec6fce0 100644 --- a/internal/release/components_test.go +++ b/internal/release/components_test.go @@ -64,3 +64,24 @@ func TestPlanIndependentVersions(t *testing.T) { t.Fatalf("откат на 0.9.0 не должен предлагаться: %+v", s) } } + +func TestSplitModulesInstalledWithCore(t *testing.T) { + root := t.TempDir() + writeComp(t, root, "bj-server", "core") + m := &Manifest{Version: "2026.09.2", Artifacts: []Artifact{ + {Name: "bj-npr", Version: "0.1.0", SHA256: "n"}, + {Name: "bj-quikexport", Version: "0.1.0", SHA256: "q"}, + {Name: "bj-lk", Version: "0.2.0", SHA256: "l"}, + }} + st := Plan(root, m, nil, "2.0.0") + MarkSplitModules(st, func(f string) bool { return f == "npr" || f == "web-cabinet" }) + if s := state(st, "bj-npr"); !s.Update || !s.New { + t.Fatalf("выделенный из ядра модуль по лицензии должен ставиться: %+v", s) + } + if s := state(st, "bj-quikexport"); s.Update || s.New { + t.Fatalf("модуль без лицензии не ставится: %+v", s) + } + if s := state(st, "bj-lk"); s.Update || s.New { + t.Fatalf("самостоятельный модуль обновлением не ставится: %+v", s) + } +} diff --git a/internal/release/install.go b/internal/release/install.go index 4e0a8a8..207d25f 100644 --- a/internal/release/install.go +++ b/internal/release/install.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -29,6 +30,7 @@ type InstallOptions struct { PubKey ed25519.PublicKey // открытый ключ подписи релизов Restart bool // перезапустить службы установленных компонентов (ядро последним) AllowNew bool // ставить модули, которых ещё нет на сервере (первичная установка) + Units fs.FS // unit-файлы служб (systemd/<служба>.service) для впервые устанавливаемых модулей } // InstallResult — итог установки (JSON для bj-server). @@ -68,6 +70,7 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) { res.Release = m.Version var req struct { Names []string `json:"names"` + New []string `json:"new"` // выделенные из ядра модули, которые ставятся впервые } if err := readStagedJSON(staging, "request.json", &req); err != nil { return fail("запрос: %v", err) @@ -105,7 +108,7 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) { if art.Version == "" { art.Version = m.Version } - if _, err := os.Stat(c.PathAt(opt.Root)); err != nil && !c.Core && !opt.AllowNew { + if _, err := os.Stat(c.PathAt(opt.Root)); err != nil && !c.Core && !opt.AllowNew && !(c.FromCore && contains(req.New, name)) { return fail("%s не установлен на этом сервере: модуль ставится установщиком, а не обновлением", name) } if prev, ok := installed[name]; ok && prev.Version != "" && IsNewer(prev.Version, art.Version) { @@ -124,7 +127,7 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) { sort.SliceStable(jobs, func(i, j int) bool { return componentOrder(jobs[i].c.Name) < componentOrder(jobs[j].c.Name) }) ts := time.Now().Format("20060102-150405") - var units []string + var units, newUnits []string for _, j := range jobs { if err := installFile(j.file, j.c.PathAt(opt.Root), j.c.Exec, ts); err != nil { return fail("установка %s: %v", j.c.Name, err) @@ -133,6 +136,21 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) { res.Installed = append(res.Installed, map[string]string{"name": j.c.Name, "version": j.a.Version}) if j.c.Unit != "" { units = append(units, j.c.Unit) + added, err := ensureUnit(opt.Root, opt.Units, j.c.Unit) + if err != nil { + return fail("служба %s: %v", j.c.Unit, err) + } + if added { + newUnits = append(newUnits, j.c.Unit) + } + } + } + if len(newUnits) > 0 && opt.Root == "" { + if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { + return fail("systemctl daemon-reload: %v: %s", err, strings.TrimSpace(string(out))) + } + if out, err := exec.Command("systemctl", append([]string{"enable"}, newUnits...)...).CombinedOutput(); err != nil { + return fail("включение служб %s: %v: %s", strings.Join(newUnits, ", "), err, strings.TrimSpace(string(out))) } } if err := SaveInstalled(installedPath, installed); err != nil { @@ -153,6 +171,31 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) { return res } +// ensureUnit — поставить unit-файл службы из комплекта, если его ещё нет (true — поставлен). +func ensureUnit(root string, units fs.FS, unit string) (bool, error) { + dst := filepath.Join(root, "/etc/systemd/system", unit+".service") + if _, err := os.Stat(dst); err == nil || units == nil { + return false, nil + } + b, err := fs.ReadFile(units, "systemd/"+unit+".service") + if err != nil { + return false, nil // в комплекте нет — служба ставится установщиком + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return false, err + } + return true, os.WriteFile(dst, b, 0o644) +} + +func contains(list []string, v string) bool { + for _, x := range list { + if x == v { + return true + } + } + return false +} + func componentOrder(name string) int { for i, c := range Components { if c.Name == name { diff --git a/internal/release/install_test.go b/internal/release/install_test.go new file mode 100644 index 0000000..7a91901 --- /dev/null +++ b/internal/release/install_test.go @@ -0,0 +1,75 @@ +package release + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" +) + +func stageNPR(t *testing.T, priv ed25519.PrivateKey, req string) string { + t.Helper() + staging := t.TempDir() + bin := filepath.Join(staging, "bj-npr") + if err := os.WriteFile(bin, []byte("npr 0.1.0"), 0o644); err != nil { + t.Fatal(err) + } + sha, size, err := HashFile(bin) + if err != nil { + t.Fatal(err) + } + m := &Manifest{Version: "2026.09.2", Channel: "beta", ReleasedAt: time.Now().UTC().Truncate(time.Second), + Artifacts: []Artifact{{Name: "bj-npr", File: "bj-npr", Version: "0.1.0", SHA256: sha, Size: size, Exec: true}}} + sm, err := Sign(m, priv, "main") + if err != nil { + t.Fatal(err) + } + b, _ := json.Marshal(sm) + if err := os.WriteFile(filepath.Join(staging, "manifest.json"), b, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(staging, "request.json"), []byte(req), 0o644); err != nil { + t.Fatal(err) + } + return staging +} + +// Выделенный из ядра модуль ставится впервые только по явному запросу (new) — вместе с unit-файлом. +func TestInstallSplitModuleWithUnit(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + units := fstest.MapFS{"systemd/bj-npr.service": {Data: []byte("[Service]\nExecStart=/opt/bj/bj-npr\n")}} + + root := t.TempDir() + res := InstallStaged(stageNPR(t, priv, `{"names":["bj-npr"]}`), InstallOptions{Root: root, PubKey: pub, Units: units}) + if res.OK || !strings.Contains(res.Error, "не установлен") { + t.Fatalf("без new модуль не должен ставиться: %+v", res) + } + + res = InstallStaged(stageNPR(t, priv, `{"names":["bj-npr"],"new":["bj-npr"]}`), InstallOptions{Root: root, PubKey: pub, Units: units}) + if !res.OK { + t.Fatalf("установка: %+v", res) + } + if fi, err := os.Stat(filepath.Join(root, "/opt/bj/bj-npr")); err != nil || fi.Mode().Perm() != 0o755 { + t.Fatalf("файл модуля: %v %v", fi, err) + } + if b, err := os.ReadFile(filepath.Join(root, "/etc/systemd/system/bj-npr.service")); err != nil || !strings.Contains(string(b), "ExecStart=/opt/bj/bj-npr") { + t.Fatalf("unit-файл: %q %v", b, err) + } + if inst := LoadInstalled(filepath.Join(root, InstalledFile)); inst["bj-npr"].Version != "0.1.0" { + t.Fatalf("components.json: %+v", inst) + } + + // Самостоятельный модуль (не FromCore) по new не ставится. + c, _ := ComponentByName("bj-lk") + if c.FromCore { + t.Fatal("bj-lk не выделялся из ядра") + } +} diff --git a/modules/modkit/modkit.go b/modules/modkit/modkit.go new file mode 100644 index 0000000..04606e8 --- /dev/null +++ b/modules/modkit/modkit.go @@ -0,0 +1,32 @@ +// Package modkit — общее для служб модулей bj (bj-npr, bj-quikexport, …). +// +// Службы модулей слушают только локальный адрес и принимают запросы только от bj-server: +// он проверяет вход, права и лицензию и передаёт служебный ключ в X-BJ-Module-Key +// (файл /var/lib/bj/.bj/module.key создаёт bj-server, службы читают его от того же пользователя). +package modkit + +import ( + "crypto/subtle" + "net/http" + "os" + "strings" +) + +// RequireKey — пропускает запросы только со служебным ключом (кроме /healthz). +// Ключ читается при каждом запросе, поэтому его замена не требует перезапуска службы. +func RequireKey(keyPath string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + next.ServeHTTP(w, r) + return + } + key, err := os.ReadFile(keyPath) + want := strings.TrimSpace(string(key)) + got := r.Header.Get("X-BJ-Module-Key") + if err != nil || want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(got)) != 1 { + http.Error(w, "модуль принимает запросы только от bj-server", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/modules/npr/npr.go b/modules/npr/npr.go index 684eeab..42ac68b 100644 --- a/modules/npr/npr.go +++ b/modules/npr/npr.go @@ -11,7 +11,6 @@ package npr import ( "bytes" "context" - "crypto/subtle" "embed" "encoding/csv" "encoding/json" @@ -29,6 +28,8 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/modules/modkit" ) //go:embed web/*.html @@ -148,24 +149,6 @@ func (s *Server) db() (*pgxpool.Pool, error) { return p, nil } -// requireKey — принимать запросы только от bj-server (кроме /healthz). -func (s *Server) requireKey(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/healthz" { - next.ServeHTTP(w, r) - return - } - key, err := os.ReadFile(s.cfg.KeyPath) - want := strings.TrimSpace(string(key)) - got := r.Header.Get("X-BJ-Module-Key") - if err != nil || want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(got)) != 1 { - http.Error(w, "модуль принимает запросы только от bj-server", http.StatusForbidden) - return - } - next.ServeHTTP(w, r) - }) -} - // page — полная страница фрагментом для макета bj-server. func (s *Server) page(w http.ResponseWriter, t *template.Template, name, title string, data any) { var buf bytes.Buffer @@ -597,5 +580,5 @@ func (s *Server) Handler() http.Handler { []period{{1, "за 1 час"}, {6, "за 6 часов"}, {24, "за сутки"}, {168, "за 7 дней"}, {720, "за 30 дней"}}} s.page(w, s.client, "npr_client", "Клиент "+code+" · НПР / УДС", data) }) - return s.requireKey(mux) + return modkit.RequireKey(s.cfg.KeyPath, mux) } diff --git a/internal/lkgateway/quikexport.go b/modules/quikexport/quikexport.go similarity index 87% rename from internal/lkgateway/quikexport.go rename to modules/quikexport/quikexport.go index c7ea815..ef3186a 100644 --- a/internal/lkgateway/quikexport.go +++ b/modules/quikexport/quikexport.go @@ -1,4 +1,4 @@ -package lkgateway +package quikexport // Модуль «Экспорт биржевой информации» — управление ARQA quikexport из bj: // ключи QUIK, логин/пароль, где держим базу (наш PostgreSQL или внешний), @@ -6,7 +6,10 @@ package lkgateway // свободное место и доступ для внешних систем. Файлы экспортёра принадлежат // arqasrv, служба — systemd, поэтому запись и перезапуск идут через root-воркер // (bj-keymedia-worker → /usr/local/sbin/bj-quikexport-admin), а чтение состояния -// (systemctl show, размеры логов, статистика базы) bj делает сам. +// (systemctl show, размеры логов, статистика базы) служба делает сама. +// +// Работает отдельной службой bj-quikexport (cmd/bj-quikexport, server.go), обновляется +// независимо от ядра; раздел настроек показывает bj-server на странице «Настройка». // // Лицензионный признак модуля: quik-export. @@ -147,14 +150,6 @@ func (s QuikExportSettings) RODSN(withPassword bool) string { return fmt.Sprintf("postgres://%s:%s@%s:%d/%s", s.ROUser, pw, s.dbHost(), s.DBPort, s.DBName) } -func (r *RuntimeConfig) UpdateQuikExport(s QuikExportSettings) error { - r.mu.Lock() - r.data.QuikExport = s - r.data.UpdatedAt = time.Now().UTC() - r.mu.Unlock() - return r.save() -} - // ---------- каталог таблиц экспорта ---------- // quikExportTable — одна таблица экспортёра ([Output tables] в export.ini). @@ -456,7 +451,7 @@ func quikExportRenderODBC(s QuikExportSettings) string { func quikExportRootOp(ctx context.Context, action, staging, extra string) (map[string]any, error) { ctx, cancel := context.WithTimeout(ctx, 3*time.Minute) defer cancel() - return runKeymediaOp(ctx, "quikexport-"+action, map[string]string{"staging": staging, "extra": extra}) + return rootOp(ctx, "quikexport-"+action, map[string]string{"staging": staging, "extra": extra}) } func quikExportNewStaging() (string, error) { @@ -468,8 +463,8 @@ func quikExportNewStaging() (string, error) { } // quikExportApply — собирает файлы, кладёт в staging и просит root установить их. -func quikExportApply(ctx context.Context, rc *RuntimeConfig, restart bool) (map[string]any, error) { - s := rc.Snapshot().QuikExport.withDefaults() +func quikExportApply(ctx context.Context, st *Store, restart bool) (map[string]any, error) { + s := st.Get() if s.QuikHost == "" || s.QuikUser == "" { return nil, fmt.Errorf("не заданы сервер QUIK и логин") } @@ -514,17 +509,17 @@ func quikExportApply(ctx context.Context, rc *RuntimeConfig, restart bool) (map[ return res, err } s.AppliedAt = time.Now() - _ = rc.UpdateQuikExport(s) + _ = st.Save(s) return res, nil } // quikExportImport — читает текущие export.ini/info.ini экспортёра и заполняет настройки. -func quikExportImport(ctx context.Context, rc *RuntimeConfig) (string, error) { +func quikExportImport(ctx context.Context, st *Store) (string, error) { res, err := quikExportRootOp(ctx, "dump", "", "") if err != nil { return "", err } - s := rc.Snapshot().QuikExport.withDefaults() + s := st.Get() dec := func(k string) string { v, _ := res[k].(string) b, _ := base64.StdEncoding.DecodeString(v) @@ -610,7 +605,7 @@ func quikExportImport(ctx context.Context, rc *RuntimeConfig) (string, error) { } } s.Imported = true - if err := rc.UpdateQuikExport(s); err != nil { + if err := st.Save(s); err != nil { return "", err } return fmt.Sprintf("сервер QUIK %s:%d, пользователь %s, таблиц включено %d (%s), период портфелей %d с", s.QuikHost, s.QuikPort, s.QuikUser, len(s.Tables), s.Preset, s.PortfolioPeriod), nil @@ -737,10 +732,13 @@ func diskUsage(path string) diskInfo { // quikExportDBStats — объём базы и скорость роста (логином только для чтения). type quikExportDBStats struct { - OK bool - Error string - DBSizeMB float64 - Tables []struct{ Name, Size string; Rows int64 } + OK bool + Error string + DBSizeMB float64 + Tables []struct { + Name, Size string + Rows int64 + } HistoryRows int64 HistoryFrom time.Time Rows24h int64 @@ -805,7 +803,10 @@ func quikExportStats(s QuikExportSettings) quikExportDBStats { rows, err := p.Query(ctx, `SELECT s.schemaname||'.'||s.relname, pg_size_pretty(pg_total_relation_size(s.relid)), COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid=s.relid),0) FROM pg_stat_user_tables s ORDER BY pg_total_relation_size(s.relid) DESC LIMIT 8`) if err == nil { for rows.Next() { - var t struct{ Name, Size string; Rows int64 } + var t struct { + Name, Size string + Rows int64 + } if rows.Scan(&t.Name, &t.Size, &t.Rows) == nil { st.Tables = append(st.Tables, t) } @@ -834,27 +835,27 @@ func quikExportStats(s QuikExportSettings) quikExportDBStats { // QuikExportView — данные раздела настроек. type QuikExportView struct { - S QuikExportSettings - Licensed bool - Service quikExportServiceState - Files []quikExportFileInfo // export.ini, info.ini, ключи - Logs []quikExportFileInfo - LogsMB float64 - QAPIMB float64 - DiskDB diskInfo - DiskExp diskInfo - DB quikExportDBStats - LogTail []string - ErrTail []string - Tables []quikExportTable - Groups []string - Presets []struct{ Key, Title, Desc string } - Enabled map[string]bool - RODSN string - RODSNFull string - ShowRO bool - ServerIP string - Warnings []string + S QuikExportSettings + Licensed bool + Service quikExportServiceState + Files []quikExportFileInfo // export.ini, info.ini, ключи + Logs []quikExportFileInfo + LogsMB float64 + QAPIMB float64 + DiskDB diskInfo + DiskExp diskInfo + DB quikExportDBStats + LogTail []string + ErrTail []string + Tables []quikExportTable + Groups []string + Presets []struct{ Key, Title, Desc string } + Enabled map[string]bool + RODSN string + RODSNFull string + ShowRO bool + ServerIP string + Warnings []string } var ( @@ -863,16 +864,16 @@ var ( qxViewLast QuikExportView ) -func quikExportView(rc *RuntimeConfig, showRO bool) QuikExportView { +func quikExportView(st *Store, licensed, showRO bool) QuikExportView { qxViewMu.Lock() defer qxViewMu.Unlock() - if time.Since(qxViewAt) < 15*time.Second && qxViewLast.S.Dir != "" { + if time.Since(qxViewAt) < 15*time.Second && qxViewLast.S.Dir != "" && qxViewLast.Licensed == licensed { v := qxViewLast v.ShowRO = showRO return v } - s := rc.Snapshot().QuikExport.withDefaults() - v := QuikExportView{S: s, Licensed: moduleAllowed(rc, quikExportFeature), Tables: quikExportTables, Presets: quikExportPresets, ShowRO: showRO} + s := st.Get() + v := QuikExportView{S: s, Licensed: licensed, Tables: quikExportTables, Presets: quikExportPresets, ShowRO: showRO} v.Enabled = map[string]bool{} for _, t := range s.Tables { v.Enabled[t] = true @@ -980,12 +981,12 @@ func quikExportServerIP() string { // ---------- обработчики ---------- -func (h *setupHandlers) saveQuikExport(w http.ResponseWriter, r *http.Request) { +func (srv *Server) saveQuikExport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } - s := h.rc.Snapshot().QuikExport.withDefaults() + s := srv.store.Get() f := r.FormValue atoi := func(v string, def int) int { if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 0 { @@ -1043,28 +1044,28 @@ func (h *setupHandlers) saveQuikExport(w http.ResponseWriter, r *http.Request) { s.Enabled = f("enabled") != "" } s = s.withDefaults() - if err := h.rc.UpdateQuikExport(s); err != nil { - setupFlash(w, r, "Экспорт QUIK: ошибка сохранения: "+err.Error()) + if err := srv.store.Save(s); err != nil { + flash(w, "Экспорт QUIK: ошибка сохранения: "+err.Error()) return } quikExportInvalidate() - setupFlash(w, r, "Экспорт QUIK: настройки сохранены. Чтобы они попали в экспортёр, нажмите «Применить настройки и перезапустить».") + flash(w, "Экспорт QUIK: настройки сохранены. Чтобы они попали в экспортёр, нажмите «Применить настройки и перезапустить».") } -func (h *setupHandlers) uploadQuikExportKeys(w http.ResponseWriter, r *http.Request) { +func (srv *Server) uploadQuikExportKeys(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } if err := r.ParseMultipartForm(4 << 20); err != nil { - setupFlash(w, r, "Экспорт QUIK: не удалось прочитать файлы: "+err.Error()) + flash(w, "Экспорт QUIK: не удалось прочитать файлы: "+err.Error()) return } if err := os.MkdirAll(quikExportKeysDir, 0o700); err != nil { - setupFlash(w, r, "Экспорт QUIK: "+err.Error()) + flash(w, "Экспорт QUIK: "+err.Error()) return } - s := h.rc.Snapshot().QuikExport.withDefaults() + s := srv.store.Get() var saved []string for _, field := range []string{"pubring", "secring"} { file, hdr, err := r.FormFile(field) @@ -1078,13 +1079,13 @@ func (h *setupHandlers) uploadQuikExportKeys(w http.ResponseWriter, r *http.Requ } name := field + ".txk" if err := os.WriteFile(filepath.Join(quikExportKeysDir, name), b, 0o600); err != nil { - setupFlash(w, r, "Экспорт QUIK: запись ключа: "+err.Error()) + flash(w, "Экспорт QUIK: запись ключа: "+err.Error()) return } saved = append(saved, fmt.Sprintf("%s (%s, %d байт)", name, hdr.Filename, len(b))) } if len(saved) == 0 { - setupFlash(w, r, "Экспорт QUIK: файлы ключей не выбраны.") + flash(w, "Экспорт QUIK: файлы ключей не выбраны.") return } s.KeysAt = time.Now() @@ -1094,24 +1095,24 @@ func (h *setupHandlers) uploadQuikExportKeys(w http.ResponseWriter, r *http.Requ s.KeysNames = append(s.KeysNames, k) } } - _ = h.rc.UpdateQuikExport(s) + _ = srv.store.Save(s) quikExportInvalidate() - setupFlash(w, r, "Экспорт QUIK: ключи загружены — "+strings.Join(saved, ", ")+". Они будут установлены в каталог экспортёра при «Применить настройки».") + flash(w, "Экспорт QUIK: ключи загружены — "+strings.Join(saved, ", ")+". Они будут установлены в каталог экспортёра при «Применить настройки».") } -func (h *setupHandlers) applyQuikExport(w http.ResponseWriter, r *http.Request) { +func (srv *Server) applyQuikExport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } - if !moduleAllowed(h.rc, quikExportFeature) { - setupFlash(w, r, "Экспорт QUIK: модуль не входит в лицензию.") + if !licensed(r) { + flash(w, "Экспорт QUIK: модуль не входит в лицензию.") return } - res, err := quikExportApply(r.Context(), h.rc, true) + res, err := quikExportApply(r.Context(), srv.store, true) quikExportInvalidate() if err != nil { - setupFlash(w, r, "Экспорт QUIK: применить не удалось — "+err.Error()) + flash(w, "Экспорт QUIK: применить не удалось — "+err.Error()) return } applied, _ := res["applied"].([]any) @@ -1119,24 +1120,24 @@ func (h *setupHandlers) applyQuikExport(w http.ResponseWriter, r *http.Request) for _, a := range applied { names = append(names, fmt.Sprint(a)) } - setupFlash(w, r, fmt.Sprintf("Экспорт QUIK: установлено %s; резервная копия %v; служба: %v.", strings.Join(names, ", "), res["backup"], res["service"])) + flash(w, fmt.Sprintf("Экспорт QUIK: установлено %s; резервная копия %v; служба: %v.", strings.Join(names, ", "), res["backup"], res["service"])) } -func (h *setupHandlers) importQuikExport(w http.ResponseWriter, r *http.Request) { +func (srv *Server) importQuikExport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } - msg, err := quikExportImport(r.Context(), h.rc) + msg, err := quikExportImport(r.Context(), srv.store) quikExportInvalidate() if err != nil { - setupFlash(w, r, "Экспорт QUIK: импорт не удался — "+err.Error()) + flash(w, "Экспорт QUIK: импорт не удался — "+err.Error()) return } - setupFlash(w, r, "Экспорт QUIK: настройки прочитаны из экспортёра — "+msg) + flash(w, "Экспорт QUIK: настройки прочитаны из экспортёра — "+msg) } -func (h *setupHandlers) serviceQuikExport(w http.ResponseWriter, r *http.Request) { +func (srv *Server) serviceQuikExport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return @@ -1145,35 +1146,35 @@ func (h *setupHandlers) serviceQuikExport(w http.ResponseWriter, r *http.Request switch op { case "start", "stop", "restart", "enable": default: - setupFlash(w, r, "Экспорт QUIK: неизвестная операция.") + flash(w, "Экспорт QUIK: неизвестная операция.") return } _, err := quikExportRootOp(r.Context(), "service", "", op) quikExportInvalidate() if err != nil { - setupFlash(w, r, "Экспорт QUIK: служба — "+err.Error()) + flash(w, "Экспорт QUIK: служба — "+err.Error()) return } - setupFlash(w, r, "Экспорт QUIK: операция «"+op+"» выполнена.") + flash(w, "Экспорт QUIK: операция «"+op+"» выполнена.") } -func (h *setupHandlers) dbInitQuikExport(w http.ResponseWriter, r *http.Request) { +func (srv *Server) dbInitQuikExport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } - s := h.rc.Snapshot().QuikExport.withDefaults() + s := srv.store.Get() if s.DBMode == "external" { - setupFlash(w, r, "Экспорт QUIK: для внешней базы создайте базу и роли на том сервере (SQL-скрипты: /admin/setup/quikexport/sql).") + flash(w, "Экспорт QUIK: для внешней базы создайте базу и роли на том сервере (SQL-скрипты: /admin/setup/quikexport/sql).") return } if s.DBPassword == "" || s.ROPassword == "" { - setupFlash(w, r, "Экспорт QUIK: задайте пароли пользователя экспорта и логина только для чтения.") + flash(w, "Экспорт QUIK: задайте пароли пользователя экспорта и логина только для чтения.") return } dir, err := quikExportNewStaging() if err != nil { - setupFlash(w, r, "Экспорт QUIK: "+err.Error()) + flash(w, "Экспорт QUIK: "+err.Error()) return } defer os.RemoveAll(dir) @@ -1186,15 +1187,15 @@ func (h *setupHandlers) dbInitQuikExport(w http.ResponseWriter, r *http.Request) res, err := quikExportRootOp(r.Context(), "db-init", dir, "") quikExportInvalidate() if err != nil { - setupFlash(w, r, "Экспорт QUIK: база — "+err.Error()) + flash(w, "Экспорт QUIK: база — "+err.Error()) return } - setupFlash(w, r, fmt.Sprintf("Экспорт QUIK: база готова — %v", res["steps"])) + flash(w, fmt.Sprintf("Экспорт QUIK: база готова — %v", res["steps"])) } // sqlQuikExport — отдаёт SQL для ручного создания базы на внешнем сервере. -func (h *setupHandlers) sqlQuikExport(w http.ResponseWriter, r *http.Request) { - s := h.rc.Snapshot().QuikExport.withDefaults() +func (srv *Server) sqlQuikExport(w http.ResponseWriter, r *http.Request) { + s := srv.store.Get() w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="quik_export_setup.sql"`) fmt.Fprintf(w, "-- База экспорта QUIK для bj. Выполнить на сервере PostgreSQL %s:%d от суперпользователя.\n", s.DBHost, s.DBPort) @@ -1208,8 +1209,8 @@ func (h *setupHandlers) sqlQuikExport(w http.ResponseWriter, r *http.Request) { } // previewQuikExport — показать export.ini/info.ini, которые будут установлены (пароли скрыты). -func (h *setupHandlers) previewQuikExport(w http.ResponseWriter, r *http.Request) { - s := h.rc.Snapshot().QuikExport.withDefaults() +func (srv *Server) previewQuikExport(w http.ResponseWriter, r *http.Request) { + s := srv.store.Get() exp, err := quikExportRenderExportINI(s) if err != nil { http.Error(w, err.Error(), 500) @@ -1227,14 +1228,3 @@ func (h *setupHandlers) previewQuikExport(w http.ResponseWriter, r *http.Request w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintf(w, "===== info.ini =====\n%s\n\n===== /etc/odbc.ini (секция) =====\n%s\n\n===== export.ini =====\n%s", mask(inf), quikExportRenderODBC(s), mask(exp)) } - -func registerQuikExportSetup(mux *http.ServeMux, h *setupHandlers) { - mux.HandleFunc("/admin/setup/quikexport", h.saveQuikExport) - mux.HandleFunc("/admin/setup/quikexport/keys", h.uploadQuikExportKeys) - mux.HandleFunc("/admin/setup/quikexport/apply", h.applyQuikExport) - mux.HandleFunc("/admin/setup/quikexport/import", h.importQuikExport) - mux.HandleFunc("/admin/setup/quikexport/service", h.serviceQuikExport) - mux.HandleFunc("/admin/setup/quikexport/dbinit", h.dbInitQuikExport) - mux.HandleFunc("/admin/setup/quikexport/sql", h.sqlQuikExport) - mux.HandleFunc("/admin/setup/quikexport/preview", h.previewQuikExport) -} diff --git a/modules/quikexport/server.go b/modules/quikexport/server.go new file mode 100644 index 0000000..b89097e --- /dev/null +++ b/modules/quikexport/server.go @@ -0,0 +1,236 @@ +package quikexport + +// Служба bj-quikexport: свои настройки, раздел настроек фрагментом для bj-server и действия раздела. +// +// Протокол с bj-server (всё со служебным ключом, см. modkit): +// - GET /section[?reveal_ro=1] — HTML раздела «Экспорт QUIK» для страницы «Настройка», +// в заголовке X-BJ-Indicator — цвет индикатора меню (ok | warn | err | пусто); +// - POST /admin/setup/quikexport[/keys|apply|import|service|dbinit] — действие; итог — 204 +// с сообщением в X-BJ-Flash, bj-server показывает его как обычное сообщение страницы настроек; +// - GET /admin/setup/quikexport/sql и /preview — файлы как есть. +// +// Лицензию проверяет bj-server и передаёт в X-BJ-Licensed: 1. + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/modules/modkit" +) + +//go:embed web/section.html +var sectionFS embed.FS + +// Config — где служба берёт настройки и ключ. +type Config struct { + StorePath string // собственные настройки модуля + SetupPath string // setup.json bj: отсюда настройки переносятся при первом запуске (раздел quik_export) + KeyPath string // служебный ключ модулей +} + +// Server — служба модуля. +type Server struct { + cfg Config + store *Store + section *template.Template +} + +// New — служба с разобранным шаблоном; при первом запуске переносит настройки из setup.json. +func New(cfg Config) (*Server, error) { + t, err := template.New("").Funcs(template.FuncMap{ + "msk": func(t time.Time) string { + if t.IsZero() { + return "—" + } + return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") + }, + }).ParseFS(sectionFS, "web/section.html") + if err != nil { + return nil, err + } + st := &Store{path: cfg.StorePath} + moved, err := st.migrate(cfg.SetupPath) + if err != nil { + return nil, fmt.Errorf("перенос настроек из %s: %w", cfg.SetupPath, err) + } + if moved { + log.Printf("bj-quikexport: настройки перенесены из %s в %s", cfg.SetupPath, cfg.StorePath) + } + return &Server{cfg: cfg, store: st, section: t}, nil +} + +// Handler — все маршруты службы; без служебного ключа отвечает 403. +func (srv *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + mux.HandleFunc("/section", func(w http.ResponseWriter, r *http.Request) { + v := quikExportView(srv.store, licensed(r), r.URL.Query().Get("reveal_ro") == "1") + var buf bytes.Buffer + if err := srv.section.ExecuteTemplate(&buf, "section", v); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-BJ-Indicator", indicator(v)) + _, _ = w.Write(buf.Bytes()) + }) + mux.HandleFunc("/admin/setup/quikexport", srv.saveQuikExport) + mux.HandleFunc("/admin/setup/quikexport/keys", srv.uploadQuikExportKeys) + mux.HandleFunc("/admin/setup/quikexport/apply", srv.applyQuikExport) + mux.HandleFunc("/admin/setup/quikexport/import", srv.importQuikExport) + mux.HandleFunc("/admin/setup/quikexport/service", srv.serviceQuikExport) + mux.HandleFunc("/admin/setup/quikexport/dbinit", srv.dbInitQuikExport) + mux.HandleFunc("/admin/setup/quikexport/sql", srv.sqlQuikExport) + mux.HandleFunc("/admin/setup/quikexport/preview", srv.previewQuikExport) + return modkit.RequireKey(srv.cfg.KeyPath, mux) +} + +func licensed(r *http.Request) bool { return r.Header.Get("X-BJ-Licensed") == "1" } + +// flash — итог действия для страницы настроек bj. +func flash(w http.ResponseWriter, msg string) { + w.Header().Set("X-BJ-Flash", url.PathEscape(msg)) + w.WriteHeader(http.StatusNoContent) +} + +// indicator — цвет индикатора раздела в меню настроек. +func indicator(v QuikExportView) string { + switch { + case !v.Licensed: + return "err" + case v.S.Enabled && v.Service.Active == "active": + return "ok" + case v.S.Enabled: + return "warn" + } + return "" +} + +// ---- настройки ---- + +// Store — настройки модуля в собственном файле (bj-server перезаписывает setup.json целиком, +// поэтому служба туда не пишет). +type Store struct { + path string + mu sync.Mutex +} + +// Get — настройки со значениями по умолчанию. +func (st *Store) Get() QuikExportSettings { + st.mu.Lock() + defer st.mu.Unlock() + var s QuikExportSettings + if b, err := os.ReadFile(st.path); err == nil { + _ = json.Unmarshal(b, &s) + } + return s.withDefaults() +} + +// Save — атомарная запись (0600: в настройках пароли). +func (st *Store) Save(s QuikExportSettings) error { + st.mu.Lock() + defer st.mu.Unlock() + raw, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(st.path), 0o700); err != nil { + return err + } + tmp := st.path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + return os.Rename(tmp, st.path) +} + +// migrate — если своих настроек ещё нет, берёт раздел quik_export из setup.json bj. +func (st *Store) migrate(setupPath string) (bool, error) { + if _, err := os.Stat(st.path); err == nil || setupPath == "" { + return false, nil + } + b, err := os.ReadFile(setupPath) + if err != nil { + return false, nil + } + var sj struct { + QuikExport json.RawMessage `json:"quik_export"` + } + if json.Unmarshal(b, &sj) != nil || len(sj.QuikExport) == 0 || string(sj.QuikExport) == "null" { + return false, nil + } + var s QuikExportSettings + if err := json.Unmarshal(sj.QuikExport, &s); err != nil { + return false, err + } + return true, st.Save(s) +} + +// ---- root-воркер ---- + +const keymediaReqDir = "/var/lib/bj/keymedia-requests" + +// rootOp — запрос root-воркеру bj-keymedia-worker через файловую очередь и ожидание ответа. +func rootOp(ctx context.Context, action string, fields map[string]string) (map[string]any, error) { + id := fmt.Sprintf("%s-%d", action, time.Now().UnixNano()) + reqPath := filepath.Join(keymediaReqDir, id+".req") + resPath := filepath.Join(keymediaReqDir, id+".res") + defer os.Remove(resPath) + + req := map[string]string{"action": action} + for k, v := range fields { + req[k] = v + } + reqBody, _ := json.Marshal(req) + // Пишем атомарно (tmp → rename), чтобы воркер не подхватил полупустой файл. + tmp := reqPath + ".tmp" + if err := os.WriteFile(tmp, reqBody, 0o660); err != nil { + return nil, fmt.Errorf("запись запроса: %w", err) + } + if err := os.Rename(tmp, reqPath); err != nil { + return nil, fmt.Errorf("публикация запроса: %w", err) + } + + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("таймаут ожидания root-воркера") + case <-ticker.C: + b, err := os.ReadFile(resPath) + if err != nil { + continue // ещё не готово + } + res := map[string]any{} + // systemd/скрипты могут напечатать предупреждение перед JSON — берём с первой «{». + if i := bytes.IndexByte(b, '{'); i > 0 { + b = b[i:] + } + if jerr := json.Unmarshal(b, &res); jerr != nil { + return nil, fmt.Errorf("разбор ответа воркера: %v (%s)", jerr, strings.TrimSpace(string(b))) + } + if res["ok"] != true { + msg, _ := res["error"].(string) + if msg == "" { + msg = "ошибка" + } + return res, fmt.Errorf("воркер: %s", msg) + } + return res, nil + } + } +} diff --git a/modules/quikexport/server_test.go b/modules/quikexport/server_test.go new file mode 100644 index 0000000..96f8eb1 --- /dev/null +++ b/modules/quikexport/server_test.go @@ -0,0 +1,74 @@ +package quikexport + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" +) + +const testKey = "k-0123456789abcdef0123456789abcdef" + +func TestServiceSectionAndSave(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "module.key") + setup := filepath.Join(dir, "setup.json") + store := filepath.Join(dir, "quik-export", "settings.json") + if err := os.WriteFile(keyPath, []byte(testKey+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(setup, []byte(`{"quik_export":{"enabled":true,"quik_host":"10.0.0.1","quik_user":"exp","preset":"npr"}}`), 0o600); err != nil { + t.Fatal(err) + } + srv, err := New(Config{StorePath: store, SetupPath: setup, KeyPath: keyPath}) + if err != nil { + t.Fatal(err) + } + if got := srv.store.Get(); got.QuikHost != "10.0.0.1" || !got.Enabled || got.QuikPort != 15100 { + t.Fatalf("перенос настроек из setup.json: %+v", got) + } + h := srv.Handler() + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/section", nil)) + if rec.Code != http.StatusForbidden { + t.Fatalf("без ключа: код %d", rec.Code) + } + + req := httptest.NewRequest(http.MethodGet, "/section", nil) + req.Header.Set("X-BJ-Module-Key", testKey) + req.Header.Set("X-BJ-Licensed", "1") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("раздел: код %d: %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{"Подключение к серверу QUIK", `value="10.0.0.1"`, "/admin/setup/quikexport/apply"} { + if !strings.Contains(body, want) { + t.Errorf("в разделе нет %q", want) + } + } + if ind := rec.Header().Get("X-BJ-Indicator"); ind == "err" { + t.Errorf("индикатор при действующей лицензии: %q", ind) + } + + form := url.Values{"part": {"quik"}, "quik_host": {"10.0.0.2"}, "quik_user": {"exp"}, "section": {"sec-quikexport"}} + req = httptest.NewRequest(http.MethodPost, "/admin/setup/quikexport", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-BJ-Module-Key", testKey) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent || rec.Header().Get("X-BJ-Flash") == "" { + t.Fatalf("сохранение: код %d, flash %q", rec.Code, rec.Header().Get("X-BJ-Flash")) + } + if got := srv.store.Get(); got.QuikHost != "10.0.0.2" { + t.Fatalf("после сохранения quik_host = %q", got.QuikHost) + } + if fi, err := os.Stat(store); err != nil || fi.Mode().Perm() != 0o600 { + t.Fatalf("файл настроек: %v %v", fi, err) + } +} diff --git a/internal/lkgateway/web/quikexport/export.ini.tmpl b/modules/quikexport/web/quikexport/export.ini.tmpl similarity index 100% rename from internal/lkgateway/web/quikexport/export.ini.tmpl rename to modules/quikexport/web/quikexport/export.ini.tmpl diff --git a/internal/lkgateway/web/quikexport/exporter_pg.sql b/modules/quikexport/web/quikexport/exporter_pg.sql similarity index 100% rename from internal/lkgateway/web/quikexport/exporter_pg.sql rename to modules/quikexport/web/quikexport/exporter_pg.sql diff --git a/internal/lkgateway/web/quikexport/info.ini.tmpl b/modules/quikexport/web/quikexport/info.ini.tmpl similarity index 100% rename from internal/lkgateway/web/quikexport/info.ini.tmpl rename to modules/quikexport/web/quikexport/info.ini.tmpl diff --git a/internal/lkgateway/web/quikexport/npr_migration.sql b/modules/quikexport/web/quikexport/npr_migration.sql similarity index 100% rename from internal/lkgateway/web/quikexport/npr_migration.sql rename to modules/quikexport/web/quikexport/npr_migration.sql diff --git a/modules/quikexport/web/section.html b/modules/quikexport/web/section.html new file mode 100644 index 0000000..d5c8774 --- /dev/null +++ b/modules/quikexport/web/section.html @@ -0,0 +1,131 @@ +{{define "section"}} + {{$X := .}}{{$S := $X.S}} +

QUIK — Экспорт биржевой информации

+

Модуль ARQA «Экспорт биржевой информации» выгружает данные сервера QUIK (портфели клиентов, позиции, сделки, справочники) в базу PostgreSQL. bj управляет им отсюда: ключи и логин QUIK, где держим базу, какой объём данных выгружаем, состояние службы и место на диске. На этих данных работают модуль «НПР / УДС» и дополнительные проверки M2M. Лицензионный признак quik-export.

+ {{range $X.Warnings}}
{{.}}
{{end}} + +
+

Состояние модуля

+
+ + + + {{if not $X.Licensed}}не входит в лицензию — см. раздел «Лицензия»{{end}} +
+
+
Служба quikexport
+ {{if not $X.Service.Installed}}не установлена{{else if eq $X.Service.Active "active"}}работает{{else}}{{$X.Service.Active}}{{end}} +
{{if $X.Service.PID}}PID {{$X.Service.PID}} · {{end}}{{if $X.Service.MemoryMB}}память {{$X.Service.MemoryMB}} МБ · {{end}}автозапуск {{if $X.Service.Enabled}}да{{else}}нет{{end}}{{if $X.Service.Restarts}} · перезапусков {{$X.Service.Restarts}}{{end}}
+
Данные из QUIK
+ {{if $X.DB.OK}}{{if $X.DB.LastTick.IsZero}}нет пульса{{else if lt $X.DB.TickAgeSec 180}}свежие{{else}}устарели{{end}} +
{{if not $X.DB.LastTick.IsZero}}цикл {{msk $X.DB.LastTick}} ({{$X.DB.TickAgeSec}} с назад) · {{end}}клиентов {{$X.DB.Clients}}
+ {{else}}база недоступна
{{$X.DB.Error}}
{{end}}
+
Объём базы {{$S.DBName}}
+ {{printf "%.0f" $X.DB.DBSizeMB}} МБ +
история НПР {{$X.DB.HistoryRows}} строк{{if not $X.DB.HistoryFrom.IsZero}} с {{$X.DB.HistoryFrom.Format "02.01.2006"}}{{end}} · рост ≈ {{printf "%.1f" $X.DB.GrowthMBDay}} МБ/сутки ({{$X.DB.Rows24h}} событий за 24 ч)
+
Диск {{if $X.DiskDB.OK}}базы данных{{else}}экспортёра{{end}}
+ {{with $X.DiskDB}}{{if .OK}}свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}
занято {{.UsedPct}}% · {{.Path}}
{{end}}{{end}} + {{if not $X.DiskDB.OK}}{{with $X.DiskExp}}свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}
занято {{.UsedPct}}%
{{end}}{{end}}
+
Логи и кэш экспортёра
+ {{printf "%.0f" $X.LogsMB}} МБ логов +
{{range $X.Logs}}{{if .Exists}}{{.Name}} {{printf "%.0f" .SizeMB}} МБ · {{end}}{{end}}кэш QAPI {{printf "%.0f" $X.QAPIMB}} МБ · ротация: ежедневно / 200 МБ, 5 копий
+
+ {{if $X.DB.Tables}}
Крупнейшие таблицы базы + {{range $X.DB.Tables}}{{end}}
ТаблицаРазмерСтрок (оценка)
{{.Name}}{{.Size}}{{.Rows}}
{{end}} +
+
+
+ {{if and $X.Service.Installed (not $X.Service.Enabled)}}
{{end}} +
+ {{if $X.LogTail}}
Последние строки export.log{{if $X.ErrTail}} и export.err{{end}} +
{{range $X.LogTail}}{{.}}
+{{end}}{{if $X.ErrTail}}--- export.err ---
+{{range $X.ErrTail}}{{.}}
+{{end}}{{end}}
{{end}} +
+ +
+

1. Подключение к серверу QUIK

+

Логин экспорта на сервере QUIK брокера (выдаёт администратор QUIK / ARQA) и пара ключей pubring.txk / secring.txk к нему. Одна учётная запись = одна сессия: второй экспортёр с тем же логином получит «Вы уже работаете в системе».

+
+ + + + + + +
+
+
+ + + + +
+

Ключи: {{if $S.KeysNames}}{{range $S.KeysNames}}{{.}} {{end}}{{if not $S.KeysAt.IsZero}}(загружены {{msk $S.KeysAt}}){{end}}{{else}}не загружены{{end}}. + В каталоге экспортёра: {{range $X.Files}}{{.Name}} {{if .Exists}}✓ {{.ModTime.Format "02.01 15:04"}}{{else}}✗{{end}} · {{end}}

+
+ +
+

2. База данных экспорта

+

Куда экспортёр пишет данные. Обычно — наш PostgreSQL на этом же сервере (bj создаст базу, роли и схему одной кнопкой). Внешний сервер — если базу держит заказчик. Логин только для чтения используют модуль «НПР / УДС», проверки M2M и внешние системы (Grafana, отчёты).

+
+ +
+ + +
+
+ + +
+
+ + + + + +
+
+ + SQL для внешнего сервера
+
+

Доступ для внешних систем и других модулей: {{if $X.ShowRO}}{{$X.RODSNFull}}{{else}}{{$X.RODSN}}{{end}} + {{if $X.ShowRO}}скрыть пароль{{else}}показать пароль{{end}}{{if ne $S.DBMode "external"}} · снаружи: хост {{$X.ServerIP}}, доступ по IP открывает администратор (pg_hba){{end}}.

+
+ +
+

3. Объём данных

+

Какие таблицы QUIK выгружаем. Чем больше набор, тем больше нагрузка на сервер QUIK, база и диск. Портфели пересчитываются каждые «период портфелей» секунд (ARQA рекомендует не менее 60); история изменений НПР/УДС хранится в схеме npr.

+
+ +
{{range $X.Presets}} + {{end}} +
+
+ {{range $g := $X.Groups}}
{{$g}}
+
{{range $X.Tables}}{{if eq .Group $g}} + {{end}}{{end}}
{{end}} +
+
Сейчас включено: {{len $S.Tables}} таблиц — {{range $S.Tables}}{{.}} {{end}}
+
+ + + + +
+
+
+
+ +
+

4. Применить к экспортёру

+

bj собирает export.ini, info.ini, секцию ODBC и ключи из настроек выше и устанавливает их в каталог службы ({{$S.Dir}}) с резервной копией прежних файлов, настраивает ротацию логов и перезапускает экспортёр. {{if not $S.AppliedAt.IsZero}}Последнее применение: {{msk $S.AppliedAt}}.{{end}}{{if not $S.Imported}} Если экспортёр уже настроен вручную — сначала «Прочитать настройки из экспортёра».{{end}}

+
+ Предпросмотр файлов +
+
+
+

Установка на новом сервере: дистрибутив ARQA (install_quikexport-12.x.run, Linux) ставится установщиком bj в {{$S.Dir}}; нужны пакеты unixodbc, odbc-postgresql, libjemalloc2. Обновления экспортёра приходят через канал обновлений bj после проверки у нас.

+
+{{end}} diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 6ac1e42..21fc7ad 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -6,7 +6,7 @@ # канал stable (по умолчанию) | beta | … # опубликовать publish — сразу загрузить на сервер (нужен репозиторий lic_Bridge-and-Join-s рядом) # -# Версии компонентов: переменные BJ_SERVER_VERSION, INSTALLER_VERSION, COMPONENT_INSTALL_VERSION, NPR_VERSION, +# Версии компонентов: переменные BJ_SERVER_VERSION, INSTALLER_VERSION, COMPONENT_INSTALL_VERSION, NPR_VERSION, QUIKEXPORT_VERSION, # CRYPTO_VERSION (по умолчанию — версия релиза). Криптосервис берётся из CRYPTO_JAR # (по умолчанию /opt/bj/crypto-service.jar). Ключ подписи: RELEASE_KEY (~/keys/release-signing.priv). set -eu @@ -26,6 +26,8 @@ CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.installerVersion=$(ver CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$DIST/bj-component-install" ./cmd/bj-component-install CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(ver NPR_VERSION)" -o "$DIST/bj-npr" ./cmd/bj-npr ver NPR_VERSION > "$DIST/bj-npr.version" +CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(ver QUIKEXPORT_VERSION)" -o "$DIST/bj-quikexport" ./cmd/bj-quikexport +ver QUIKEXPORT_VERSION > "$DIST/bj-quikexport.version" ver BJ_SERVER_VERSION > "$DIST/bj-server.version" ver INSTALLER_VERSION > "$DIST/bj-installer.version" ver COMPONENT_INSTALL_VERSION > "$DIST/bj-component-install.version"