938a859cd4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
344 lines
13 KiB
Go
344 lines
13 KiB
Go
package lkgateway
|
|
|
|
// Версии систем, с которыми работает bj: собственные компоненты (ядро и модули) и внешние системы
|
|
// (ИШ НРД, МНП QUIK, экспортёр ARQA, DEPO, СКЗИ «Валидата», криптосервис, PostgreSQL, Java, ОС).
|
|
// Показываются общим списком в «Настройка → Обзор» и строкой в разделе каждого модуля.
|
|
// Опрос баз и служб идёт с кэшем и в фоне, страница настроек не ждёт медленных источников.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
|
)
|
|
|
|
// SystemVersion — версия одной системы.
|
|
type SystemVersion struct {
|
|
Key string // bj-server, bj-npr, quik-mnp, depo, …
|
|
Title string // пусто — система на этом сервере не используется
|
|
Version string // пусто — не удалось определить
|
|
Detail string // дата установки, сервер и т. п.
|
|
Section string // раздел настроек (якорь без «sec-»)
|
|
Err string
|
|
}
|
|
|
|
// SystemVersions — сводка версий на момент At.
|
|
type SystemVersions struct {
|
|
Items []SystemVersion
|
|
At time.Time
|
|
}
|
|
|
|
// Get — версия по ключу (пустая, если система не используется).
|
|
func (v SystemVersions) Get(key string) SystemVersion {
|
|
for _, x := range v.Items {
|
|
if x.Key == key {
|
|
return x
|
|
}
|
|
}
|
|
return SystemVersion{Key: key}
|
|
}
|
|
|
|
const versionsTTL = 10 * time.Minute
|
|
|
|
var versionsCache struct {
|
|
sync.Mutex
|
|
v SystemVersions
|
|
refreshing bool
|
|
}
|
|
|
|
// systemVersions — версии из кэша. Первый раз собираются сразу, устаревшие обновляются в фоне.
|
|
func systemVersions(rc *RuntimeConfig) SystemVersions {
|
|
versionsCache.Lock()
|
|
v := versionsCache.v
|
|
if v.At.IsZero() {
|
|
versionsCache.Unlock()
|
|
return refreshVersions(rc)
|
|
}
|
|
if time.Since(v.At) > versionsTTL && !versionsCache.refreshing {
|
|
versionsCache.refreshing = true
|
|
go refreshVersions(rc)
|
|
}
|
|
versionsCache.Unlock()
|
|
return v
|
|
}
|
|
|
|
// refreshVersions — опросить все источники (параллельно, у каждого свой тайм-аут).
|
|
func refreshVersions(rc *RuntimeConfig) SystemVersions {
|
|
probes := versionProbes(rc)
|
|
items := make([]SystemVersion, len(probes))
|
|
var wg sync.WaitGroup
|
|
for i, p := range probes {
|
|
items[i] = SystemVersion{Key: p.key, Title: p.title, Section: p.section}
|
|
if p.when != nil && !p.when() {
|
|
items[i].Title = ""
|
|
continue
|
|
}
|
|
wg.Add(1)
|
|
go func(i int, p versionProbe) {
|
|
defer wg.Done()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
ver, detail, err := p.fn(ctx)
|
|
items[i].Version, items[i].Detail = strings.TrimSpace(ver), strings.TrimSpace(detail)
|
|
if err != nil {
|
|
items[i].Err = err.Error()
|
|
}
|
|
}(i, p)
|
|
}
|
|
wg.Wait()
|
|
out := SystemVersions{At: time.Now()}
|
|
for _, it := range items {
|
|
if it.Title != "" {
|
|
out.Items = append(out.Items, it)
|
|
}
|
|
}
|
|
versionsCache.Lock()
|
|
versionsCache.v, versionsCache.refreshing = out, false
|
|
versionsCache.Unlock()
|
|
licenseVersionWatch(rc, out) // смена версии внешней системы при закрытых обновлениях bj
|
|
return out
|
|
}
|
|
|
|
type versionProbe struct {
|
|
key, title, section string
|
|
when func() bool
|
|
fn func(ctx context.Context) (version, detail string, err error)
|
|
}
|
|
|
|
func fileExists(p string) bool {
|
|
_, err := os.Stat(p)
|
|
return err == nil
|
|
}
|
|
|
|
func versionProbes(rc *RuntimeConfig) []versionProbe {
|
|
s := rc.Snapshot()
|
|
comp := func(name string) func() bool {
|
|
return func() bool {
|
|
c, ok := release.ComponentByName(name)
|
|
return ok && fileExists(c.Path)
|
|
}
|
|
}
|
|
return []versionProbe{
|
|
{key: "bj-server", title: "Ядро bj (bj-server)", section: "update",
|
|
fn: func(context.Context) (string, string, error) { return BuildVersion, "", nil }},
|
|
{key: "bj-component-install", title: "Установщик обновлений bj", section: "update", when: comp("bj-component-install"),
|
|
fn: func(ctx context.Context) (string, string, error) {
|
|
return componentVersion(ctx, "bj-component-install")
|
|
}},
|
|
{key: "bj-npr", title: "Модуль «НПР / УДС» (bj-npr)", section: "npr", when: comp("bj-npr"),
|
|
fn: func(ctx context.Context) (string, string, error) { return componentVersion(ctx, "bj-npr") }},
|
|
{key: "bj-quikexport", title: "Модуль «Экспорт QUIK» (bj-quikexport)", section: "quikexport", when: comp("bj-quikexport"),
|
|
fn: func(ctx context.Context) (string, string, error) {
|
|
if m, _, err := quikExportVersions(ctx, rc); err == nil && m != "" {
|
|
return m, "", nil
|
|
}
|
|
return componentVersion(ctx, "bj-quikexport")
|
|
}},
|
|
{key: "quikexport", title: "Экспортёр QUIK (ARQA quikexport)", section: "quikexport", when: comp("bj-quikexport"),
|
|
fn: func(ctx context.Context) (string, string, error) {
|
|
_, e, err := quikExportVersions(ctx, rc)
|
|
return e, "", err
|
|
}},
|
|
{key: "bj-lk", title: "Личный кабинет клиента (bj-lk)", section: "lk", when: comp("bj-lk"),
|
|
fn: func(ctx context.Context) (string, string, error) { return componentVersion(ctx, "bj-lk") }},
|
|
{key: "crypto-service.jar", title: "Проверка ключа подписи (bj-crypto)", section: "crypto",
|
|
when: func() bool { return s.Crypto.Provider != "" && s.Crypto.Provider != string(cryptocli.ProviderStub) },
|
|
fn: func(ctx context.Context) (string, string, error) {
|
|
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.Provider(s.Crypto.Provider), SocketPath: s.Crypto.SocketPath, ModulePath: s.Crypto.ModulePath})
|
|
defer cli.Close()
|
|
h, err := cli.Health(ctx)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
ver, _, _ := strings.Cut(h.Message, " (")
|
|
return ver, "провайдер " + h.Provider, nil
|
|
}},
|
|
{key: "validata", title: "СКЗИ «Валидата Клиент L» (версия производителя, как на moex.com)", section: "crypto", when: func() bool { return fileExists(validataUtil) },
|
|
fn: validataVersion},
|
|
{key: "java", title: "Java (для проверки ключа)", section: "crypto",
|
|
when: func() bool { _, err := exec.LookPath("java"); return err == nil },
|
|
fn: javaVersion},
|
|
{key: "ish", title: "Интеграционный шлюз НРД (ИШ)", section: "nsd", when: func() bool { return fileExists(ishDepsPath) },
|
|
fn: func(ctx context.Context) (string, string, error) {
|
|
info := ishVersionInfo(ctx)
|
|
return info.Installed, info.Note, nil
|
|
}},
|
|
{key: "quik-mnp", title: "QUIK — Модуль неторговых поручений", section: "quik", when: func() bool { return s.Quik.Server != "" },
|
|
fn: func(ctx context.Context) (string, string, error) { return quikMNPVersion(ctx, s.Quik) }},
|
|
{key: "depo", title: "Fancy DEPO (база DEPO)", section: "fancy", when: func() bool { return s.Fancy.Server != "" },
|
|
fn: func(ctx context.Context) (string, string, error) { return depoVersion(ctx, s.Fancy) }},
|
|
{key: "postgres", title: "PostgreSQL (база bj)", section: "db", when: func() bool { return s.Postgres.DSN != "" },
|
|
fn: func(ctx context.Context) (string, string, error) { return postgresVersion(ctx, s.Postgres.DSN) }},
|
|
{key: "os", title: "Операционная система", section: "overview", fn: osVersion},
|
|
}
|
|
}
|
|
|
|
// componentVersion — версия компонента bj: запись установщика обновлений, иначе «<файл> -version».
|
|
func componentVersion(ctx context.Context, name string) (string, string, error) {
|
|
installed := release.LoadInstalled(release.InstalledFile)
|
|
for _, st := range release.Plan("", nil, installed, BuildVersion) {
|
|
// Только запись установщика с совпавшим файлом: для ядра без записи Plan подставляет версию bj-server.
|
|
if st.Name == name && st.Installed != "" && st.Installed == installed[name].Version {
|
|
return st.Installed, "", nil
|
|
}
|
|
}
|
|
c, _ := release.ComponentByName(name)
|
|
out, err := exec.CommandContext(ctx, c.Path, "-version").Output()
|
|
if err != nil {
|
|
return "", "", nil // старые сборки без -version — версия неизвестна, это не ошибка
|
|
}
|
|
f := strings.Fields(string(out))
|
|
if len(f) >= 2 && f[0] == name {
|
|
return f[1], "", nil
|
|
}
|
|
return "", "", nil
|
|
}
|
|
|
|
// quikExportVersions — версии модуля и экспортёра ARQA от службы bj-quikexport.
|
|
func quikExportVersions(ctx context.Context, rc *RuntimeConfig) (module, exporter string, err error) {
|
|
resp, err := quikExportDo(ctx, rc, http.MethodGet, "/version", nil, "")
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("служба bj-quikexport не отвечает")
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", "", fmt.Errorf("служба bj-quikexport ответила %d", resp.StatusCode)
|
|
}
|
|
var v struct {
|
|
Module string `json:"module"`
|
|
Exporter string `json:"exporter"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&v); err != nil {
|
|
return "", "", err
|
|
}
|
|
return v.Module, v.Exporter, nil
|
|
}
|
|
|
|
const validataUtil = "/opt/Validata/VDCSP/bin/amd64/zpki1utl"
|
|
|
|
var validataVerRe = regexp.MustCompile(`версия\s+(\d+(?:\.\d+)+)`)
|
|
|
|
func validataVersion(ctx context.Context) (string, string, error) {
|
|
out, _ := exec.CommandContext(ctx, validataUtil, "-help").CombinedOutput() // справка завершается с ненулевым кодом
|
|
if m := validataVerRe.FindSubmatch(out); m != nil {
|
|
return string(m[1]), "/opt/Validata/VDCSP", nil
|
|
}
|
|
return "", "", fmt.Errorf("версия не найдена в справке zpki1utl")
|
|
}
|
|
|
|
var javaVerRe = regexp.MustCompile(`version "([^"]+)"`)
|
|
|
|
func javaVersion(ctx context.Context) (string, string, error) {
|
|
out, err := exec.CommandContext(ctx, "java", "-version").CombinedOutput()
|
|
if m := javaVerRe.FindSubmatch(out); m != nil {
|
|
first, _, _ := strings.Cut(string(out), " version")
|
|
return string(m[1]), first, nil
|
|
}
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return "", "", fmt.Errorf("версия не распознана")
|
|
}
|
|
|
|
// quikMNPVersion — последняя запись VersionInfo в БД МНП (история обновлений ARQA).
|
|
func quikMNPVersion(ctx context.Context, q QuikMNPSettings) (string, string, error) {
|
|
conn, err := quikOpen(q)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer conn.Close()
|
|
var maj, min, patch int64
|
|
var at *time.Time
|
|
if err := conn.QueryRowContext(ctx, "SELECT TOP 1 MajVersion, MinVersion, Patch, UPDATETime FROM VersionInfo ORDER BY MajVersion DESC, MinVersion DESC, Patch DESC").
|
|
Scan(&maj, &min, &patch, &at); err != nil {
|
|
return "", "", fmt.Errorf("VersionInfo: %v", err)
|
|
}
|
|
detail := ""
|
|
if at != nil {
|
|
detail = "обновлена " + at.Format("02.01.2006")
|
|
}
|
|
return fmt.Sprintf("%d.%d.%d", maj, min, patch), detail, nil
|
|
}
|
|
|
|
// depoVersion — последняя версия данных DEPO (OD_DATA_VERSION).
|
|
func depoVersion(ctx context.Context, f FancyMSSQLSettings) (string, string, error) {
|
|
db := f.DepoDB()
|
|
conn, err := fancyOpen(f, db)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer conn.Close()
|
|
q := "SELECT TOP 1 DATA_VERSION, V_DATE FROM OD_DATA_VERSION ORDER BY V_DATE DESC"
|
|
if f.IsPostgres() {
|
|
q = "SELECT data_version, v_date FROM od_data_version ORDER BY v_date DESC LIMIT 1"
|
|
}
|
|
var ver string
|
|
var at *time.Time
|
|
if err := conn.QueryRowContext(ctx, q).Scan(&ver, &at); err != nil {
|
|
return "", "", fmt.Errorf("OD_DATA_VERSION: %v", err)
|
|
}
|
|
detail := "база " + db
|
|
if at != nil {
|
|
detail = "обновлена " + at.Format("02.01.2006") + " · " + detail
|
|
}
|
|
return ver, detail, nil
|
|
}
|
|
|
|
func postgresVersion(ctx context.Context, dsn string) (string, string, error) {
|
|
conn, err := pgx.Connect(ctx, dsn)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer conn.Close(context.Background())
|
|
var v string
|
|
if err := conn.QueryRow(ctx, "SHOW server_version").Scan(&v); err != nil {
|
|
return "", "", err
|
|
}
|
|
ver, rest, _ := strings.Cut(v, " ")
|
|
return ver, strings.Trim(rest, "()"), nil
|
|
}
|
|
|
|
func osVersion(context.Context) (string, string, error) {
|
|
if b, err := os.ReadFile("/etc/astra_version"); err == nil {
|
|
return "Astra Linux " + strings.TrimSpace(string(b)), "", nil
|
|
}
|
|
b, err := os.ReadFile("/etc/os-release")
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
for _, ln := range strings.Split(string(b), "\n") {
|
|
if v, ok := strings.CutPrefix(ln, "PRETTY_NAME="); ok {
|
|
return strings.Trim(v, `"`), "", nil
|
|
}
|
|
}
|
|
return "", "", nil
|
|
}
|
|
|
|
// refreshVersionsHandler — POST /admin/setup/versions/refresh.
|
|
func (h *setupHandlers) refreshVersionsHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
v := refreshVersions(h.rc)
|
|
known := 0
|
|
for _, it := range v.Items {
|
|
if it.Version != "" {
|
|
known++
|
|
}
|
|
}
|
|
setupFlash(w, r, fmt.Sprintf("Версии систем обновлены: определено %d из %d.", known, len(v.Items)))
|
|
}
|