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>
244 lines
8.7 KiB
Go
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
|
|
}
|
|
}
|
|
}
|