Files
Bridge-and-Join-s/modules/quikexport/server.go
T
zuevav 7c653a2490 Версии систем: общий список в «Обзоре» и строка в разделе каждого модуля
bj собирает версии своих компонентов и внешних систем (в фоне, кэш 10 минут, прогрев при старте):
ядро и модули bj (components.json или «-version»), экспортёр ARQA (служба bj-quikexport, /version),
криптосервис (Health), «Валидата CSP» (zpki1utl), Java, ИШ НРД (deps.json + сайт НРД),
МНП QUIK (VersionInfo), DEPO (OD_DATA_VERSION), PostgreSQL, ОС. «Настройка → Обзор» — таблица
«Версии систем» с кнопкой «Обновить сведения»; в разделах БД, Fancy, QUIK МНП, Экспорт QUIK,
Криптография, НРД, ЛК, НПР и Обновления — строка с версией. В таблице обновлений у компонентов,
выложенных вручную, вместо «неизвестна» — версия самого компонента. bj-component-install: -version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:00:00 +03:00

244 lines
8.7 KiB
Go

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 — файлы как есть;
// - GET /version — версии модуля и экспортёра ARQA (JSON) для общего списка версий bj.
//
// Лицензию проверяет 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 // служебный ключ модулей
Version string // версия модуля bj-quikexport
}
// 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")
v.ModuleVersion, v.ExporterVersion = srv.cfg.Version, exporterVersion(v.S.Dir)
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("/version", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"module": srv.cfg.Version, "exporter": exporterVersion(srv.store.Get().Dir)})
})
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
}
}
}