feat: живой цикл M2M с НРД + мастер установки ключа на флешку
Инфраструктура M2M (живой обмен с НРД через ИШ): - обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение ответа; INFO → ждём Decision; идемпотентность поллера - fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO - сырой XML ответа НРД в карточке заявки (для пересылки в ТП) - тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes, 4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта - редирект из теста сразу в карточку заявки Мастер установки ключа Валидаты на флешку (admin/setup/keywizard): - пошаговый: загрузка .7z+пароль → выбор флешки → запись → справочник сертификатов (CRL) → перезапуск+проверка ИШ → готово - привилегированный воркер (bj-keymedia) в host-namespace через файл-обмен, bj-server остаётся в песочнице - сохранение структуры профиля архива (spr<N>), перечисление съёмных USB Прочее: - пакет-доказательство для ТП НРД + форма регистрации участника M2M - эталонные образцы робота (DOC/m2m_robot_samples) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+219
-273
@@ -1,351 +1,297 @@
|
||||
// Package cryptocli — Go-клиент к СКЗИ через PKCS#11 (КриптоПро CSP,
|
||||
// Рутокен ЭЦП 2.0, ViPNet, Валидата). Загружает указанный .so модуль,
|
||||
// открывает сессию, перечисляет токены, читает сертификаты и
|
||||
// предоставляет операции Sign/Verify.
|
||||
// Package cryptocli — gRPC-клиент к crypto-service по Unix Domain
|
||||
// Socket. Сам Go-процесс не выполняет криптографию — всё делает
|
||||
// Java-сайдкар (services/crypto-service) поверх АПК «Валидата
|
||||
// Клиент L».
|
||||
//
|
||||
// На ВМ без установленного СКЗИ модуль не загрузится — клиент
|
||||
// возвращает понятную ошибку и помечает себя как «провайдер
|
||||
// недоступен». В этом случае lk-gateway переходит в режим stub:
|
||||
// XMLDSig-подписи проходят без реальной проверки (только для
|
||||
// дев-стендов и демо).
|
||||
// На дев-стендах без поднятого сайдкара (стандартный путь
|
||||
// /run/bj/crypto.sock не существует) клиент возвращает понятную
|
||||
// ошибку «провайдер недоступен» и lk-gateway работает в stub-режиме:
|
||||
// XMLDSig-подписи проходят без проверки (только для демо).
|
||||
package cryptocli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/pkcs11"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli/cryptopb"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
// Provider — тип СКЗИ-провайдера.
|
||||
// Provider — тип СКЗИ-провайдера (информативный — реальный выбор
|
||||
// делает crypto-service через переменную BJ_CRYPTO_PROVIDER).
|
||||
type Provider string
|
||||
|
||||
// Известные провайдеры.
|
||||
const (
|
||||
ProviderStub Provider = "stub"
|
||||
ProviderCryptoPro Provider = "cryptopro"
|
||||
ProviderRutoken Provider = "rutoken"
|
||||
ProviderValidata Provider = "validata"
|
||||
ProviderVipNet Provider = "vipnet"
|
||||
ProviderStub Provider = "stub"
|
||||
ProviderValidata Provider = "validata"
|
||||
)
|
||||
|
||||
// DefaultModulePath возвращает дефолтный путь до PKCS#11 .so модуля
|
||||
// для указанного провайдера. Используется в /admin/setup как placeholder.
|
||||
// DefaultModulePath сохранена для обратной совместимости с UI;
|
||||
// текущий путь интеграции — не PKCS#11-модуль, а UDS-сокет
|
||||
// crypto-service. Возвращаемое значение информативное.
|
||||
func DefaultModulePath(p Provider) string {
|
||||
switch p {
|
||||
case ProviderCryptoPro:
|
||||
return "/opt/cprocsp/lib/amd64/libcppkcs11.so"
|
||||
case ProviderRutoken:
|
||||
return "/usr/lib64/librtpkcs11ecp.so"
|
||||
case ProviderValidata:
|
||||
return "/opt/validata/lib/libvalidata-pkcs11.so"
|
||||
case ProviderVipNet:
|
||||
return "/opt/itcs/lib/libvipnet-pkcs11.so"
|
||||
if p == ProviderValidata {
|
||||
return "/opt/Validata/VDCSP/lib/amd64 (через сайдкар, не PKCS#11)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Config — конфигурация клиента.
|
||||
type Config struct {
|
||||
Provider Provider
|
||||
ModulePath string // путь до PKCS#11 .so модуля (libcppkcs11.so и т.п.)
|
||||
PIN string // PIN для сессии (логин на токен)
|
||||
SlotID uint // 0 = первый доступный
|
||||
Timeout time.Duration
|
||||
// SocketPath — путь к UDS-сокету crypto-service.
|
||||
// Пустое значение = /run/bj/crypto.sock.
|
||||
SocketPath string
|
||||
// Provider — желаемый провайдер; информативно (см. выше).
|
||||
Provider Provider
|
||||
// ModulePath — сохраняется для UI; в gRPC-режиме не используется.
|
||||
ModulePath string
|
||||
// Timeout — таймаут одной gRPC-операции.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Client — PKCS#11-клиент к СКЗИ.
|
||||
// Client — gRPC-клиент к crypto-service.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
mu sync.Mutex
|
||||
ctx *pkcs11.Ctx
|
||||
opened bool
|
||||
cfg Config
|
||||
mu sync.Mutex
|
||||
conn *grpc.ClientConn
|
||||
api cryptopb.CryptoServiceClient
|
||||
}
|
||||
|
||||
// New создаёт клиент. Сам Initialize() здесь не вызывается — это
|
||||
// делает Connect или явный Ping (Health-check на admin-странице).
|
||||
// New создаёт клиент. Само соединение поднимается лениво при первом
|
||||
// вызове.
|
||||
func New(cfg Config) *Client {
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 5 * time.Second
|
||||
}
|
||||
if cfg.SocketPath == "" {
|
||||
cfg.SocketPath = "/run/bj/crypto.sock"
|
||||
}
|
||||
return &Client{cfg: cfg}
|
||||
}
|
||||
|
||||
// Health — лёгкая проверка готовности. Шаги:
|
||||
// 1. Сам файл .so существует?
|
||||
// 2. Initialize модуля?
|
||||
// 3. Есть ли хотя бы один доступный слот с токеном?
|
||||
// 4. Информация о токене (label, manufacturer, serial).
|
||||
func (c *Client) Health(_ context.Context) (HealthInfo, error) {
|
||||
if c.cfg.Provider == "" || c.cfg.Provider == ProviderStub {
|
||||
return HealthInfo{Provider: string(ProviderStub),
|
||||
Message: "Провайдер stub — реальная криптография не подключена."}, nil
|
||||
}
|
||||
if c.cfg.ModulePath == "" {
|
||||
return HealthInfo{}, errors.New("cryptocli: ModulePath не задан")
|
||||
}
|
||||
if _, err := os.Stat(c.cfg.ModulePath); err != nil {
|
||||
return HealthInfo{}, fmt.Errorf("cryptocli: модуль %s не найден: %w", c.cfg.ModulePath, err)
|
||||
}
|
||||
|
||||
// Close закрывает gRPC-соединение.
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if err := c.ensureInitLocked(); err != nil {
|
||||
return HealthInfo{}, err
|
||||
if c.conn != nil {
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
c.api = nil
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := c.ctx.GetInfo()
|
||||
if err != nil {
|
||||
return HealthInfo{}, fmt.Errorf("cryptocli: GetInfo: %w", err)
|
||||
}
|
||||
|
||||
slots, err := c.ctx.GetSlotList(true) // только токены
|
||||
if err != nil {
|
||||
return HealthInfo{}, fmt.Errorf("cryptocli: GetSlotList: %w", err)
|
||||
}
|
||||
h := HealthInfo{
|
||||
Provider: string(c.cfg.Provider),
|
||||
ModulePath: c.cfg.ModulePath,
|
||||
CryptokiVersion: fmt.Sprintf("%d.%d", info.CryptokiVersion.Major, info.CryptokiVersion.Minor),
|
||||
ManufacturerID: info.ManufacturerID,
|
||||
LibraryVersion: fmt.Sprintf("%d.%d", info.LibraryVersion.Major, info.LibraryVersion.Minor),
|
||||
}
|
||||
for _, slot := range slots {
|
||||
tok, err := c.ctx.GetTokenInfo(slot)
|
||||
if err != nil {
|
||||
h.Tokens = append(h.Tokens, TokenInfo{SlotID: slot, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
h.Tokens = append(h.Tokens, TokenInfo{
|
||||
SlotID: slot,
|
||||
Label: tok.Label,
|
||||
Manufacturer: tok.ManufacturerID,
|
||||
Model: tok.Model,
|
||||
SerialNumber: tok.SerialNumber,
|
||||
})
|
||||
}
|
||||
if len(h.Tokens) == 0 {
|
||||
h.Message = "Модуль PKCS#11 загружен, но активных токенов не найдено. Подключите Рутокен или установите ключевой контейнер."
|
||||
} else {
|
||||
h.Message = fmt.Sprintf("Доступно токенов: %d. Криптография готова к работе.", len(h.Tokens))
|
||||
}
|
||||
return h, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Certificate — DER-сертификат с распарсенными атрибутами для UI.
|
||||
// ensureConn устанавливает gRPC-канал к UDS-сокету при первом
|
||||
// использовании. Используем встроенный в grpc-go резолвер unix:.
|
||||
func (c *Client) ensureConn() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.api != nil {
|
||||
return nil
|
||||
}
|
||||
target := "unix:" + c.cfg.SocketPath
|
||||
conn, err := grpc.NewClient(target,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cryptocli: dial %s: %w", c.cfg.SocketPath, err)
|
||||
}
|
||||
c.conn = conn
|
||||
c.api = cryptopb.NewCryptoServiceClient(conn)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Health — gRPC Health-вызов. Если сокет недоступен (сайдкар не
|
||||
// поднят) — вернёт «провайдер недоступен» с явной ошибкой.
|
||||
func (c *Client) Health(ctx context.Context) (HealthInfo, error) {
|
||||
if c.cfg.Provider == ProviderStub {
|
||||
return HealthInfo{
|
||||
Provider: string(ProviderStub),
|
||||
Message: "Провайдер stub — реальная криптография не подключена.",
|
||||
}, nil
|
||||
}
|
||||
if err := c.ensureConn(); err != nil {
|
||||
return HealthInfo{}, err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
|
||||
defer cancel()
|
||||
resp, err := c.api.Health(cctx, &cryptopb.HealthRequest{})
|
||||
if err != nil {
|
||||
return HealthInfo{}, fmt.Errorf("cryptocli: Health: %w", err)
|
||||
}
|
||||
return HealthInfo{
|
||||
Provider: resp.GetProvider(),
|
||||
Message: resp.GetVersion(),
|
||||
ModulePath: c.cfg.SocketPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Certificate — упрощённое описание сертификата (для совместимости с
|
||||
// прежним UI). В gRPC-режиме crypto-service возвращает информацию о
|
||||
// подписанте через VerifyResponse; полный список сертификатов
|
||||
// (FindCertificates) пока не реализован — для UI возвращаем пустой
|
||||
// список.
|
||||
type Certificate struct {
|
||||
SlotID uint
|
||||
TokenLabel string
|
||||
Label string // CKA_LABEL (объект на токене)
|
||||
Label string
|
||||
SubjectCN string
|
||||
IssuerCN string
|
||||
Serial string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
INN string // если есть в OID 1.2.643.3.131.1.1
|
||||
INN string
|
||||
DER []byte
|
||||
HasPrivateKey bool // найден ли парный приватный ключ на токене
|
||||
HasPrivateKey bool
|
||||
}
|
||||
|
||||
// FindCertificates перечисляет сертификаты на всех подключенных
|
||||
// токенах. Не требует Login для публичных сертификатов; для контейнеров
|
||||
// CryptoPro/Rutoken достаточно открыть сессию (CKU_USER не выполняется).
|
||||
// FindCertificates пока возвращает пустой список — список ключей
|
||||
// управляется самой Валидатой через её собственный справочник (zcs),
|
||||
// а bj-server о конкретных сертификатах узнаёт по результатам
|
||||
// Verify/Sign-операций. Эту функцию переопределим позже отдельным
|
||||
// gRPC-методом ListCertificates если потребуется.
|
||||
func (c *Client) FindCertificates(_ context.Context) ([]Certificate, error) {
|
||||
if c.cfg.Provider == "" || c.cfg.Provider == ProviderStub {
|
||||
return nil, errors.New("cryptocli: провайдер stub — нет реальных сертификатов")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if err := c.ensureInitLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
slots, err := c.ctx.GetSlotList(true)
|
||||
// Shutdown — отправляет команду «выйти с exit-code 2» сайдкару.
|
||||
// systemd с Restart=on-failure поднимет его обратно. Возвращает
|
||||
// ошибку если соединение разорвалось (что нормально и означает что
|
||||
// сайдкар уже завершается).
|
||||
func (c *Client) Shutdown(ctx context.Context) error {
|
||||
if c.cfg.Provider == ProviderStub {
|
||||
return errors.New("provider=stub: некуда отправлять Shutdown")
|
||||
}
|
||||
if err := c.ensureConn(); err != nil {
|
||||
return err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
|
||||
defer cancel()
|
||||
_, err := c.api.Shutdown(cctx, &cryptopb.ShutdownRequest{})
|
||||
// Закрываем соединение, чтобы не держать ссылку на падающий процесс.
|
||||
_ = c.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
// ActivateResult — результат переключения профиля Валидаты.
|
||||
type ActivateResult struct {
|
||||
OK bool
|
||||
Provider string
|
||||
Profile string
|
||||
Message string
|
||||
}
|
||||
|
||||
// Activate переключает crypto-service на указанный профиль pki1.conf.
|
||||
// Пустая строка = minimal mode (без профиля).
|
||||
func (c *Client) Activate(ctx context.Context, profile string) (ActivateResult, error) {
|
||||
if c.cfg.Provider == ProviderStub {
|
||||
return ActivateResult{
|
||||
OK: false,
|
||||
Provider: string(ProviderStub),
|
||||
Message: "Провайдер stub — переключение профиля недоступно.",
|
||||
}, nil
|
||||
}
|
||||
if err := c.ensureConn(); err != nil {
|
||||
return ActivateResult{}, err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
|
||||
defer cancel()
|
||||
resp, err := c.api.Activate(cctx, &cryptopb.ActivateRequest{Profile: profile})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cryptocli: GetSlotList: %w", err)
|
||||
return ActivateResult{}, fmt.Errorf("cryptocli: Activate: %w", err)
|
||||
}
|
||||
|
||||
var out []Certificate
|
||||
for _, slot := range slots {
|
||||
tokInfo, _ := c.ctx.GetTokenInfo(slot)
|
||||
certs, err := c.listSlotCertificates(slot, tokInfo.Label)
|
||||
if err != nil {
|
||||
// продолжаем — возможно один слот занят, другие доступны
|
||||
continue
|
||||
}
|
||||
out = append(out, certs...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listSlotCertificates открывает сессию на слоте, ищет CKO_CERTIFICATE,
|
||||
// читает DER и парсит x509.
|
||||
func (c *Client) listSlotCertificates(slot uint, tokenLabel string) ([]Certificate, error) {
|
||||
sess, err := c.ctx.OpenSession(slot, pkcs11.CKF_SERIAL_SESSION)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OpenSession: %w", err)
|
||||
}
|
||||
defer func() { _ = c.ctx.CloseSession(sess) }()
|
||||
|
||||
template := []*pkcs11.Attribute{
|
||||
pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_CERTIFICATE),
|
||||
}
|
||||
if err := c.ctx.FindObjectsInit(sess, template); err != nil {
|
||||
return nil, fmt.Errorf("FindObjectsInit: %w", err)
|
||||
}
|
||||
handles, _, err := c.ctx.FindObjects(sess, 32)
|
||||
_ = c.ctx.FindObjectsFinal(sess)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FindObjects: %w", err)
|
||||
}
|
||||
|
||||
out := make([]Certificate, 0, len(handles))
|
||||
for _, h := range handles {
|
||||
attrs, err := c.ctx.GetAttributeValue(sess, h, []*pkcs11.Attribute{
|
||||
pkcs11.NewAttribute(pkcs11.CKA_VALUE, nil),
|
||||
pkcs11.NewAttribute(pkcs11.CKA_LABEL, nil),
|
||||
pkcs11.NewAttribute(pkcs11.CKA_ID, nil),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cert := Certificate{
|
||||
SlotID: slot,
|
||||
TokenLabel: tokenLabel,
|
||||
}
|
||||
var idAttr []byte
|
||||
for _, a := range attrs {
|
||||
switch a.Type {
|
||||
case pkcs11.CKA_VALUE:
|
||||
cert.DER = a.Value
|
||||
case pkcs11.CKA_LABEL:
|
||||
cert.Label = string(a.Value)
|
||||
case pkcs11.CKA_ID:
|
||||
idAttr = a.Value
|
||||
}
|
||||
}
|
||||
// Парсим X.509 (ГОСТ-сертификаты тоже парсятся через crypto/x509
|
||||
// — Subject/Issuer/Serial/Validity не зависят от алгоритма подписи).
|
||||
parsed, err := x509.ParseCertificate(cert.DER)
|
||||
if err == nil {
|
||||
cert.SubjectCN = parsed.Subject.CommonName
|
||||
cert.IssuerCN = parsed.Issuer.CommonName
|
||||
cert.Serial = parsed.SerialNumber.Text(16)
|
||||
cert.NotBefore = parsed.NotBefore
|
||||
cert.NotAfter = parsed.NotAfter
|
||||
// ИНН в OID 1.2.643.3.131.1.1 — извлекаем из Subject.
|
||||
cert.INN = extractINN(parsed)
|
||||
}
|
||||
// Проверим есть ли парный приватный ключ.
|
||||
if len(idAttr) > 0 {
|
||||
cert.HasPrivateKey = c.hasPrivateKey(sess, idAttr)
|
||||
}
|
||||
out = append(out, cert)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// hasPrivateKey ищет CKO_PRIVATE_KEY с тем же CKA_ID что и сертификат.
|
||||
func (c *Client) hasPrivateKey(sess pkcs11.SessionHandle, id []byte) bool {
|
||||
tmpl := []*pkcs11.Attribute{
|
||||
pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_PRIVATE_KEY),
|
||||
pkcs11.NewAttribute(pkcs11.CKA_ID, id),
|
||||
}
|
||||
if err := c.ctx.FindObjectsInit(sess, tmpl); err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = c.ctx.FindObjectsFinal(sess) }()
|
||||
handles, _, err := c.ctx.FindObjects(sess, 1)
|
||||
return err == nil && len(handles) > 0
|
||||
}
|
||||
|
||||
// extractINN ищет ИНН в Subject сертификата по OID НРД 1.2.643.3.131.1.1.
|
||||
func extractINN(c *x509.Certificate) string {
|
||||
innOID := asn1.ObjectIdentifier{1, 2, 643, 3, 131, 1, 1}
|
||||
for _, name := range c.Subject.Names {
|
||||
if name.Type.Equal(innOID) {
|
||||
if s, ok := name.Value.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// VerifyXMLDSig — заглушка для интерфейса m2mcore.CryptoVerifier.
|
||||
// Реальная проверка XMLDSig потребует канонизации XML и parsing
|
||||
// сертификатов; пока возвращает CertInfo с подписанной полезной
|
||||
// нагрузкой как хеш SHA-256 и заглушку CN. На M3-M4 заменим на
|
||||
// полноценный verify через PKCS#11 + Apache Santuario-like канонизатор.
|
||||
func (c *Client) VerifyXMLDSig(ctx context.Context, payload []byte) (m2mcore.CertInfo, error) {
|
||||
if _, err := c.Health(ctx); err != nil {
|
||||
return m2mcore.CertInfo{}, err
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
return m2mcore.CertInfo{
|
||||
SignerCN: "stub-verifier",
|
||||
SignerINN: "",
|
||||
Serial: hex.EncodeToString(sum[:8]),
|
||||
NotBefore: time.Now().Add(-365 * 24 * time.Hour),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
return ActivateResult{
|
||||
OK: resp.GetOk(),
|
||||
Provider: resp.GetProvider(),
|
||||
Profile: resp.GetProfile(),
|
||||
Message: resp.GetMessage(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close завершает работу PKCS#11 модуля.
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.ctx == nil {
|
||||
return nil
|
||||
// VerifyXMLDSig — проксирует в crypto-service.VerifyXMLDSig.
|
||||
// Реализует m2mcore.CryptoVerifier — поэтому возвращает CertInfo,
|
||||
// заполненный из gRPC-ответа.
|
||||
func (c *Client) VerifyXMLDSig(ctx context.Context, payload []byte) (m2mcore.CertInfo, error) {
|
||||
if c.cfg.Provider == ProviderStub {
|
||||
return m2mcore.CertInfo{
|
||||
SignerCN: "stub-verifier",
|
||||
}, nil
|
||||
}
|
||||
_ = c.ctx.Finalize()
|
||||
c.ctx.Destroy()
|
||||
c.ctx = nil
|
||||
c.opened = false
|
||||
return nil
|
||||
if err := c.ensureConn(); err != nil {
|
||||
return m2mcore.CertInfo{}, err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
|
||||
defer cancel()
|
||||
resp, err := c.api.VerifyXMLDSig(cctx, &cryptopb.VerifyRequest{
|
||||
Payload: payload,
|
||||
})
|
||||
if err != nil {
|
||||
return m2mcore.CertInfo{}, fmt.Errorf("cryptocli: VerifyXMLDSig: %w", err)
|
||||
}
|
||||
if !resp.GetValid() {
|
||||
var msg string
|
||||
if errs := resp.GetErrors(); len(errs) > 0 {
|
||||
msg = errs[0]
|
||||
} else {
|
||||
msg = "подпись недействительна"
|
||||
}
|
||||
return m2mcore.CertInfo{}, errors.New("cryptocli: " + msg)
|
||||
}
|
||||
return m2mcore.CertInfo{
|
||||
SignerCN: resp.GetSignerCn(),
|
||||
SignerINN: resp.GetSignerInn(),
|
||||
Serial: resp.GetSerial(),
|
||||
NotBefore: time.Unix(resp.GetNotBefore(), 0),
|
||||
NotAfter: time.Unix(resp.GetNotAfter(), 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureInitLocked инициализирует PKCS#11 модуль если ещё не.
|
||||
// Должен вызываться под c.mu.Lock.
|
||||
func (c *Client) ensureInitLocked() error {
|
||||
if c.opened {
|
||||
return nil
|
||||
// SignXMLDSig — проксирует в crypto-service.SignXMLDSig. Возвращает
|
||||
// DER-байты CMS detached signature (готовы к включению в XMLDSig-обёртку
|
||||
// или к самостоятельной отправке как .p7s).
|
||||
//
|
||||
// keyAlias — alias ключа из ПСП Валидаты (пустой = ключ по умолчанию
|
||||
// активного профиля). profile — имя профиля в pki1.conf, пустой = тот
|
||||
// что инициализирован.
|
||||
func (c *Client) SignXMLDSig(ctx context.Context, payload []byte, keyAlias, profile string) ([]byte, error) {
|
||||
if c.cfg.Provider == ProviderStub {
|
||||
return nil, errors.New("provider=stub: подпись недоступна")
|
||||
}
|
||||
c.ctx = pkcs11.New(c.cfg.ModulePath)
|
||||
if c.ctx == nil {
|
||||
return fmt.Errorf("cryptocli: не получилось загрузить модуль %s", c.cfg.ModulePath)
|
||||
if err := c.ensureConn(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.ctx.Initialize(); err != nil {
|
||||
c.ctx.Destroy()
|
||||
c.ctx = nil
|
||||
return fmt.Errorf("cryptocli: Initialize: %w", err)
|
||||
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
|
||||
defer cancel()
|
||||
resp, err := c.api.SignXMLDSig(cctx, &cryptopb.SignRequest{
|
||||
Payload: payload,
|
||||
KeyAlias: keyAlias,
|
||||
Profile: profile,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cryptocli: SignXMLDSig: %w", err)
|
||||
}
|
||||
c.opened = true
|
||||
return nil
|
||||
return resp.GetSignedXml(), nil
|
||||
}
|
||||
|
||||
// HealthInfo — что показывает /admin/setup и /admin/status.
|
||||
// HealthInfo — что показывает /admin/setup → СКЗИ.
|
||||
type HealthInfo struct {
|
||||
Provider string
|
||||
ModulePath string
|
||||
CryptokiVersion string
|
||||
ManufacturerID string
|
||||
LibraryVersion string
|
||||
ModulePath string // в gRPC-режиме — UDS-сокет
|
||||
CryptokiVersion string // не используется
|
||||
ManufacturerID string // не используется
|
||||
LibraryVersion string // не используется
|
||||
Tokens []TokenInfo
|
||||
Message string
|
||||
}
|
||||
|
||||
// TokenInfo — описание подключённого токена/контейнера.
|
||||
// TokenInfo — для совместимости с UI; в gRPC-режиме пустой.
|
||||
type TokenInfo struct {
|
||||
SlotID uint
|
||||
Label string
|
||||
|
||||
@@ -8,55 +8,45 @@ import (
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
||||
)
|
||||
|
||||
// TestStubProviderHealthOK — провайдер stub не лезет в gRPC,
|
||||
// возвращает информативный Health без ошибки.
|
||||
func TestStubProviderHealthOK(t *testing.T) {
|
||||
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderStub})
|
||||
defer cli.Close()
|
||||
h, err := cli.Health(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Health: %v", err)
|
||||
}
|
||||
if h.Provider != string(cryptocli.ProviderStub) {
|
||||
t.Errorf("Provider = %q", h.Provider)
|
||||
t.Errorf("Provider = %q, ожидался stub", h.Provider)
|
||||
}
|
||||
if !strings.Contains(h.Message, "stub") {
|
||||
t.Errorf("сообщение не содержит 'stub': %q", h.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePathMissing(t *testing.T) {
|
||||
// TestValidataProviderNoSocket — провайдер validata пытается дойти до
|
||||
// сайдкара, но в тестах сокета нет. gRPC-клиент создаётся лениво
|
||||
// (NewClient не возвращает ошибку), а ошибка приходит при первом RPC.
|
||||
func TestValidataProviderNoSocket(t *testing.T) {
|
||||
cli := cryptocli.New(cryptocli.Config{
|
||||
Provider: cryptocli.ProviderCryptoPro,
|
||||
ModulePath: "/nonexistent/libcppkcs11.so",
|
||||
Provider: cryptocli.ProviderValidata,
|
||||
SocketPath: "/nonexistent/crypto.sock",
|
||||
})
|
||||
defer cli.Close()
|
||||
_, err := cli.Health(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка о ненайденном модуле")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "не найден") {
|
||||
t.Errorf("неинформативная ошибка: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyModulePath(t *testing.T) {
|
||||
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderCryptoPro})
|
||||
_, err := cli.Health(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка о пустом ModulePath")
|
||||
t.Fatal("ожидалась ошибка о недоступном сокете")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultModulePath — информативный текст для UI.
|
||||
func TestDefaultModulePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
p cryptocli.Provider
|
||||
want string
|
||||
}{
|
||||
{cryptocli.ProviderCryptoPro, "/opt/cprocsp/lib/amd64/libcppkcs11.so"},
|
||||
{cryptocli.ProviderRutoken, "/usr/lib64/librtpkcs11ecp.so"},
|
||||
{cryptocli.ProviderStub, ""},
|
||||
if cryptocli.DefaultModulePath(cryptocli.ProviderStub) != "" {
|
||||
t.Error("DefaultModulePath(stub) должен быть пустым")
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := cryptocli.DefaultModulePath(c.p)
|
||||
if got != c.want {
|
||||
t.Errorf("DefaultModulePath(%s) = %q, ожидалось %q", c.p, got, c.want)
|
||||
}
|
||||
v := cryptocli.DefaultModulePath(cryptocli.ProviderValidata)
|
||||
if v == "" {
|
||||
t.Error("DefaultModulePath(validata) не должен быть пустым")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.12.4
|
||||
// source: crypto.proto
|
||||
|
||||
package cryptopb
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type ActivateRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Имя профиля в pki1.conf. Пустая строка = minimal mode.
|
||||
Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ActivateRequest) Reset() {
|
||||
*x = ActivateRequest{}
|
||||
mi := &file_crypto_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ActivateRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ActivateRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ActivateRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crypto_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ActivateRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ActivateRequest) Descriptor() ([]byte, []int) {
|
||||
return file_crypto_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *ActivateRequest) GetProfile() string {
|
||||
if x != nil {
|
||||
return x.Profile
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ActivateResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// true если провайдер успешно (пере)инициализирован.
|
||||
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
|
||||
// Имя активного провайдера ("validata" / "stub").
|
||||
Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"`
|
||||
// Имя активного профиля (пусто для minimal).
|
||||
Profile string `protobuf:"bytes,3,opt,name=profile,proto3" json:"profile,omitempty"`
|
||||
// Сообщение о результате (для UI).
|
||||
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ActivateResponse) Reset() {
|
||||
*x = ActivateResponse{}
|
||||
mi := &file_crypto_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ActivateResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ActivateResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ActivateResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crypto_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ActivateResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ActivateResponse) Descriptor() ([]byte, []int) {
|
||||
return file_crypto_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *ActivateResponse) GetOk() bool {
|
||||
if x != nil {
|
||||
return x.Ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *ActivateResponse) GetProvider() string {
|
||||
if x != nil {
|
||||
return x.Provider
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ActivateResponse) GetProfile() string {
|
||||
if x != nil {
|
||||
return x.Profile
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ActivateResponse) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ShutdownRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ShutdownRequest) Reset() {
|
||||
*x = ShutdownRequest{}
|
||||
mi := &file_crypto_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ShutdownRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ShutdownRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ShutdownRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crypto_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ShutdownRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ShutdownRequest) Descriptor() ([]byte, []int) {
|
||||
return file_crypto_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
type ShutdownResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// true означает «запрос принят, процесс завершится через ~500ms».
|
||||
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ShutdownResponse) Reset() {
|
||||
*x = ShutdownResponse{}
|
||||
mi := &file_crypto_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ShutdownResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ShutdownResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ShutdownResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crypto_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ShutdownResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ShutdownResponse) Descriptor() ([]byte, []int) {
|
||||
return file_crypto_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *ShutdownResponse) GetOk() bool {
|
||||
if x != nil {
|
||||
return x.Ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type VerifyRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Целиком подписанный XML.
|
||||
Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
|
||||
// Профиль ключей и сертификатов: "guest-gost" | "test3-gost" |
|
||||
// "prod-gost" | "guest-rsa" | ... — определяет хранилище и trust store.
|
||||
Profile string `protobuf:"bytes,2,opt,name=profile,proto3" json:"profile,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *VerifyRequest) Reset() {
|
||||
*x = VerifyRequest{}
|
||||
mi := &file_crypto_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *VerifyRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*VerifyRequest) ProtoMessage() {}
|
||||
|
||||
func (x *VerifyRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crypto_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use VerifyRequest.ProtoReflect.Descriptor instead.
|
||||
func (*VerifyRequest) Descriptor() ([]byte, []int) {
|
||||
return file_crypto_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *VerifyRequest) GetPayload() []byte {
|
||||
if x != nil {
|
||||
return x.Payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *VerifyRequest) GetProfile() string {
|
||||
if x != nil {
|
||||
return x.Profile
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type VerifyResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Прошла ли проверка.
|
||||
Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"`
|
||||
// CN из сертификата подписанта.
|
||||
SignerCn string `protobuf:"bytes,2,opt,name=signer_cn,json=signerCn,proto3" json:"signer_cn,omitempty"`
|
||||
// ИНН из сертификата (если присутствует в OID 1.2.643.3.131.1.1).
|
||||
SignerInn string `protobuf:"bytes,3,opt,name=signer_inn,json=signerInn,proto3" json:"signer_inn,omitempty"`
|
||||
// Серийный номер сертификата (hex).
|
||||
Serial string `protobuf:"bytes,4,opt,name=serial,proto3" json:"serial,omitempty"`
|
||||
// Срок действия сертификата (unix epoch, секунды).
|
||||
NotBefore int64 `protobuf:"varint,5,opt,name=not_before,json=notBefore,proto3" json:"not_before,omitempty"`
|
||||
NotAfter int64 `protobuf:"varint,6,opt,name=not_after,json=notAfter,proto3" json:"not_after,omitempty"`
|
||||
// Тексты ошибок проверки (если valid=false).
|
||||
Errors []string `protobuf:"bytes,7,rep,name=errors,proto3" json:"errors,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *VerifyResponse) Reset() {
|
||||
*x = VerifyResponse{}
|
||||
mi := &file_crypto_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *VerifyResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*VerifyResponse) ProtoMessage() {}
|
||||
|
||||
func (x *VerifyResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crypto_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use VerifyResponse.ProtoReflect.Descriptor instead.
|
||||
func (*VerifyResponse) Descriptor() ([]byte, []int) {
|
||||
return file_crypto_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *VerifyResponse) GetValid() bool {
|
||||
if x != nil {
|
||||
return x.Valid
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *VerifyResponse) GetSignerCn() string {
|
||||
if x != nil {
|
||||
return x.SignerCn
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VerifyResponse) GetSignerInn() string {
|
||||
if x != nil {
|
||||
return x.SignerInn
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VerifyResponse) GetSerial() string {
|
||||
if x != nil {
|
||||
return x.Serial
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VerifyResponse) GetNotBefore() int64 {
|
||||
if x != nil {
|
||||
return x.NotBefore
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *VerifyResponse) GetNotAfter() int64 {
|
||||
if x != nil {
|
||||
return x.NotAfter
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *VerifyResponse) GetErrors() []string {
|
||||
if x != nil {
|
||||
return x.Errors
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SignRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Канонизированный XML, который нужно подписать.
|
||||
Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
|
||||
// Алиас ключа в JCP-keystore.
|
||||
KeyAlias string `protobuf:"bytes,2,opt,name=key_alias,json=keyAlias,proto3" json:"key_alias,omitempty"`
|
||||
// Профиль (тот же что у Verify).
|
||||
Profile string `protobuf:"bytes,3,opt,name=profile,proto3" json:"profile,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SignRequest) Reset() {
|
||||
*x = SignRequest{}
|
||||
mi := &file_crypto_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SignRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SignRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SignRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crypto_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SignRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SignRequest) Descriptor() ([]byte, []int) {
|
||||
return file_crypto_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *SignRequest) GetPayload() []byte {
|
||||
if x != nil {
|
||||
return x.Payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SignRequest) GetKeyAlias() string {
|
||||
if x != nil {
|
||||
return x.KeyAlias
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SignRequest) GetProfile() string {
|
||||
if x != nil {
|
||||
return x.Profile
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SignResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Подписанный XML (с детачированной или встроенной подписью —
|
||||
// зависит от профиля).
|
||||
SignedXml []byte `protobuf:"bytes,1,opt,name=signed_xml,json=signedXml,proto3" json:"signed_xml,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SignResponse) Reset() {
|
||||
*x = SignResponse{}
|
||||
mi := &file_crypto_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SignResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SignResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SignResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crypto_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SignResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SignResponse) Descriptor() ([]byte, []int) {
|
||||
return file_crypto_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *SignResponse) GetSignedXml() []byte {
|
||||
if x != nil {
|
||||
return x.SignedXml
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type HealthRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *HealthRequest) Reset() {
|
||||
*x = HealthRequest{}
|
||||
mi := &file_crypto_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *HealthRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*HealthRequest) ProtoMessage() {}
|
||||
|
||||
func (x *HealthRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crypto_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead.
|
||||
func (*HealthRequest) Descriptor() ([]byte, []int) {
|
||||
return file_crypto_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
type HealthResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
|
||||
Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"`
|
||||
// Активный провайдер криптографии: "cryptopro" | "validata" | "vipnet" | "stub".
|
||||
Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *HealthResponse) Reset() {
|
||||
*x = HealthResponse{}
|
||||
mi := &file_crypto_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *HealthResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*HealthResponse) ProtoMessage() {}
|
||||
|
||||
func (x *HealthResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crypto_proto_msgTypes[9]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead.
|
||||
func (*HealthResponse) Descriptor() ([]byte, []int) {
|
||||
return file_crypto_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *HealthResponse) GetOk() bool {
|
||||
if x != nil {
|
||||
return x.Ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *HealthResponse) GetVersion() string {
|
||||
if x != nil {
|
||||
return x.Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *HealthResponse) GetProvider() string {
|
||||
if x != nil {
|
||||
return x.Provider
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_crypto_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_crypto_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\fcrypto.proto\x12\x1abridge_and_joins.crypto.v1\"+\n" +
|
||||
"\x0fActivateRequest\x12\x18\n" +
|
||||
"\aprofile\x18\x01 \x01(\tR\aprofile\"r\n" +
|
||||
"\x10ActivateResponse\x12\x0e\n" +
|
||||
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x1a\n" +
|
||||
"\bprovider\x18\x02 \x01(\tR\bprovider\x12\x18\n" +
|
||||
"\aprofile\x18\x03 \x01(\tR\aprofile\x12\x18\n" +
|
||||
"\amessage\x18\x04 \x01(\tR\amessage\"\x11\n" +
|
||||
"\x0fShutdownRequest\"\"\n" +
|
||||
"\x10ShutdownResponse\x12\x0e\n" +
|
||||
"\x02ok\x18\x01 \x01(\bR\x02ok\"C\n" +
|
||||
"\rVerifyRequest\x12\x18\n" +
|
||||
"\apayload\x18\x01 \x01(\fR\apayload\x12\x18\n" +
|
||||
"\aprofile\x18\x02 \x01(\tR\aprofile\"\xce\x01\n" +
|
||||
"\x0eVerifyResponse\x12\x14\n" +
|
||||
"\x05valid\x18\x01 \x01(\bR\x05valid\x12\x1b\n" +
|
||||
"\tsigner_cn\x18\x02 \x01(\tR\bsignerCn\x12\x1d\n" +
|
||||
"\n" +
|
||||
"signer_inn\x18\x03 \x01(\tR\tsignerInn\x12\x16\n" +
|
||||
"\x06serial\x18\x04 \x01(\tR\x06serial\x12\x1d\n" +
|
||||
"\n" +
|
||||
"not_before\x18\x05 \x01(\x03R\tnotBefore\x12\x1b\n" +
|
||||
"\tnot_after\x18\x06 \x01(\x03R\bnotAfter\x12\x16\n" +
|
||||
"\x06errors\x18\a \x03(\tR\x06errors\"^\n" +
|
||||
"\vSignRequest\x12\x18\n" +
|
||||
"\apayload\x18\x01 \x01(\fR\apayload\x12\x1b\n" +
|
||||
"\tkey_alias\x18\x02 \x01(\tR\bkeyAlias\x12\x18\n" +
|
||||
"\aprofile\x18\x03 \x01(\tR\aprofile\"-\n" +
|
||||
"\fSignResponse\x12\x1d\n" +
|
||||
"\n" +
|
||||
"signed_xml\x18\x01 \x01(\fR\tsignedXml\"\x0f\n" +
|
||||
"\rHealthRequest\"V\n" +
|
||||
"\x0eHealthResponse\x12\x0e\n" +
|
||||
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x18\n" +
|
||||
"\aversion\x18\x02 \x01(\tR\aversion\x12\x1a\n" +
|
||||
"\bprovider\x18\x03 \x01(\tR\bprovider2\x88\x04\n" +
|
||||
"\rCryptoService\x12f\n" +
|
||||
"\rVerifyXMLDSig\x12).bridge_and_joins.crypto.v1.VerifyRequest\x1a*.bridge_and_joins.crypto.v1.VerifyResponse\x12`\n" +
|
||||
"\vSignXMLDSig\x12'.bridge_and_joins.crypto.v1.SignRequest\x1a(.bridge_and_joins.crypto.v1.SignResponse\x12_\n" +
|
||||
"\x06Health\x12).bridge_and_joins.crypto.v1.HealthRequest\x1a*.bridge_and_joins.crypto.v1.HealthResponse\x12e\n" +
|
||||
"\bActivate\x12+.bridge_and_joins.crypto.v1.ActivateRequest\x1a,.bridge_and_joins.crypto.v1.ActivateResponse\x12e\n" +
|
||||
"\bShutdown\x12+.bridge_and_joins.crypto.v1.ShutdownRequest\x1a,.bridge_and_joins.crypto.v1.ShutdownResponseBq\n" +
|
||||
"!ru.zetit.bridgeandjoins.crypto.v1P\x01ZJgit.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli/cryptopb;cryptopbb\x06proto3"
|
||||
|
||||
var (
|
||||
file_crypto_proto_rawDescOnce sync.Once
|
||||
file_crypto_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_crypto_proto_rawDescGZIP() []byte {
|
||||
file_crypto_proto_rawDescOnce.Do(func() {
|
||||
file_crypto_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_crypto_proto_rawDesc), len(file_crypto_proto_rawDesc)))
|
||||
})
|
||||
return file_crypto_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_crypto_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
|
||||
var file_crypto_proto_goTypes = []any{
|
||||
(*ActivateRequest)(nil), // 0: bridge_and_joins.crypto.v1.ActivateRequest
|
||||
(*ActivateResponse)(nil), // 1: bridge_and_joins.crypto.v1.ActivateResponse
|
||||
(*ShutdownRequest)(nil), // 2: bridge_and_joins.crypto.v1.ShutdownRequest
|
||||
(*ShutdownResponse)(nil), // 3: bridge_and_joins.crypto.v1.ShutdownResponse
|
||||
(*VerifyRequest)(nil), // 4: bridge_and_joins.crypto.v1.VerifyRequest
|
||||
(*VerifyResponse)(nil), // 5: bridge_and_joins.crypto.v1.VerifyResponse
|
||||
(*SignRequest)(nil), // 6: bridge_and_joins.crypto.v1.SignRequest
|
||||
(*SignResponse)(nil), // 7: bridge_and_joins.crypto.v1.SignResponse
|
||||
(*HealthRequest)(nil), // 8: bridge_and_joins.crypto.v1.HealthRequest
|
||||
(*HealthResponse)(nil), // 9: bridge_and_joins.crypto.v1.HealthResponse
|
||||
}
|
||||
var file_crypto_proto_depIdxs = []int32{
|
||||
4, // 0: bridge_and_joins.crypto.v1.CryptoService.VerifyXMLDSig:input_type -> bridge_and_joins.crypto.v1.VerifyRequest
|
||||
6, // 1: bridge_and_joins.crypto.v1.CryptoService.SignXMLDSig:input_type -> bridge_and_joins.crypto.v1.SignRequest
|
||||
8, // 2: bridge_and_joins.crypto.v1.CryptoService.Health:input_type -> bridge_and_joins.crypto.v1.HealthRequest
|
||||
0, // 3: bridge_and_joins.crypto.v1.CryptoService.Activate:input_type -> bridge_and_joins.crypto.v1.ActivateRequest
|
||||
2, // 4: bridge_and_joins.crypto.v1.CryptoService.Shutdown:input_type -> bridge_and_joins.crypto.v1.ShutdownRequest
|
||||
5, // 5: bridge_and_joins.crypto.v1.CryptoService.VerifyXMLDSig:output_type -> bridge_and_joins.crypto.v1.VerifyResponse
|
||||
7, // 6: bridge_and_joins.crypto.v1.CryptoService.SignXMLDSig:output_type -> bridge_and_joins.crypto.v1.SignResponse
|
||||
9, // 7: bridge_and_joins.crypto.v1.CryptoService.Health:output_type -> bridge_and_joins.crypto.v1.HealthResponse
|
||||
1, // 8: bridge_and_joins.crypto.v1.CryptoService.Activate:output_type -> bridge_and_joins.crypto.v1.ActivateResponse
|
||||
3, // 9: bridge_and_joins.crypto.v1.CryptoService.Shutdown:output_type -> bridge_and_joins.crypto.v1.ShutdownResponse
|
||||
5, // [5:10] is the sub-list for method output_type
|
||||
0, // [0:5] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_crypto_proto_init() }
|
||||
func file_crypto_proto_init() {
|
||||
if File_crypto_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_crypto_proto_rawDesc), len(file_crypto_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 10,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_crypto_proto_goTypes,
|
||||
DependencyIndexes: file_crypto_proto_depIdxs,
|
||||
MessageInfos: file_crypto_proto_msgTypes,
|
||||
}.Build()
|
||||
File_crypto_proto = out.File
|
||||
file_crypto_proto_goTypes = nil
|
||||
file_crypto_proto_depIdxs = nil
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v3.12.4
|
||||
// source: crypto.proto
|
||||
|
||||
package cryptopb
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
CryptoService_VerifyXMLDSig_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/VerifyXMLDSig"
|
||||
CryptoService_SignXMLDSig_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/SignXMLDSig"
|
||||
CryptoService_Health_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/Health"
|
||||
CryptoService_Activate_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/Activate"
|
||||
CryptoService_Shutdown_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/Shutdown"
|
||||
)
|
||||
|
||||
// CryptoServiceClient is the client API for CryptoService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// CryptoService — серверная криптография по ГОСТ через КриптоПро JCP.
|
||||
// Слушает на Unix Domain Socket (по умолчанию /run/bj/crypto.sock).
|
||||
type CryptoServiceClient interface {
|
||||
// Проверка XMLDSig-подписи (ГОСТ или RSA). Возвращает сведения о
|
||||
// подписанте: CN, ИНН (если есть), срок действия сертификата.
|
||||
VerifyXMLDSig(ctx context.Context, in *VerifyRequest, opts ...grpc.CallOption) (*VerifyResponse, error)
|
||||
// Подпись XML по ГОСТ — для резервного канала WS ONYX и для
|
||||
// серверной подписи действий оператора в admin-ui.
|
||||
SignXMLDSig(ctx context.Context, in *SignRequest, opts ...grpc.CallOption) (*SignResponse, error)
|
||||
// Health-check.
|
||||
Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error)
|
||||
// Activate — переинициализирует провайдер Валидаты на указанный
|
||||
// профиль из pki1.conf. Если profile пуст — переходит в
|
||||
// VCERT_InitMinimal (без доступа к ПСП/ЛСП/ССС). Не требует
|
||||
// перезапуска сайдкара.
|
||||
Activate(ctx context.Context, in *ActivateRequest, opts ...grpc.CallOption) (*ActivateResponse, error)
|
||||
// Shutdown — корректно завершает процесс сайдкара (System.exit(2)
|
||||
// после отправки ответа). systemd с Restart=on-failure поднимет
|
||||
// его снова через RestartSec секунд. Используется для UI-кнопки
|
||||
// «Перезапустить crypto-service» без sudo.
|
||||
Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error)
|
||||
}
|
||||
|
||||
type cryptoServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewCryptoServiceClient(cc grpc.ClientConnInterface) CryptoServiceClient {
|
||||
return &cryptoServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *cryptoServiceClient) VerifyXMLDSig(ctx context.Context, in *VerifyRequest, opts ...grpc.CallOption) (*VerifyResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(VerifyResponse)
|
||||
err := c.cc.Invoke(ctx, CryptoService_VerifyXMLDSig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *cryptoServiceClient) SignXMLDSig(ctx context.Context, in *SignRequest, opts ...grpc.CallOption) (*SignResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SignResponse)
|
||||
err := c.cc.Invoke(ctx, CryptoService_SignXMLDSig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *cryptoServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(HealthResponse)
|
||||
err := c.cc.Invoke(ctx, CryptoService_Health_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *cryptoServiceClient) Activate(ctx context.Context, in *ActivateRequest, opts ...grpc.CallOption) (*ActivateResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ActivateResponse)
|
||||
err := c.cc.Invoke(ctx, CryptoService_Activate_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *cryptoServiceClient) Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ShutdownResponse)
|
||||
err := c.cc.Invoke(ctx, CryptoService_Shutdown_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CryptoServiceServer is the server API for CryptoService service.
|
||||
// All implementations must embed UnimplementedCryptoServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// CryptoService — серверная криптография по ГОСТ через КриптоПро JCP.
|
||||
// Слушает на Unix Domain Socket (по умолчанию /run/bj/crypto.sock).
|
||||
type CryptoServiceServer interface {
|
||||
// Проверка XMLDSig-подписи (ГОСТ или RSA). Возвращает сведения о
|
||||
// подписанте: CN, ИНН (если есть), срок действия сертификата.
|
||||
VerifyXMLDSig(context.Context, *VerifyRequest) (*VerifyResponse, error)
|
||||
// Подпись XML по ГОСТ — для резервного канала WS ONYX и для
|
||||
// серверной подписи действий оператора в admin-ui.
|
||||
SignXMLDSig(context.Context, *SignRequest) (*SignResponse, error)
|
||||
// Health-check.
|
||||
Health(context.Context, *HealthRequest) (*HealthResponse, error)
|
||||
// Activate — переинициализирует провайдер Валидаты на указанный
|
||||
// профиль из pki1.conf. Если profile пуст — переходит в
|
||||
// VCERT_InitMinimal (без доступа к ПСП/ЛСП/ССС). Не требует
|
||||
// перезапуска сайдкара.
|
||||
Activate(context.Context, *ActivateRequest) (*ActivateResponse, error)
|
||||
// Shutdown — корректно завершает процесс сайдкара (System.exit(2)
|
||||
// после отправки ответа). systemd с Restart=on-failure поднимет
|
||||
// его снова через RestartSec секунд. Используется для UI-кнопки
|
||||
// «Перезапустить crypto-service» без sudo.
|
||||
Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error)
|
||||
mustEmbedUnimplementedCryptoServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedCryptoServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedCryptoServiceServer struct{}
|
||||
|
||||
func (UnimplementedCryptoServiceServer) VerifyXMLDSig(context.Context, *VerifyRequest) (*VerifyResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method VerifyXMLDSig not implemented")
|
||||
}
|
||||
func (UnimplementedCryptoServiceServer) SignXMLDSig(context.Context, *SignRequest) (*SignResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SignXMLDSig not implemented")
|
||||
}
|
||||
func (UnimplementedCryptoServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
|
||||
}
|
||||
func (UnimplementedCryptoServiceServer) Activate(context.Context, *ActivateRequest) (*ActivateResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Activate not implemented")
|
||||
}
|
||||
func (UnimplementedCryptoServiceServer) Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Shutdown not implemented")
|
||||
}
|
||||
func (UnimplementedCryptoServiceServer) mustEmbedUnimplementedCryptoServiceServer() {}
|
||||
func (UnimplementedCryptoServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeCryptoServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to CryptoServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeCryptoServiceServer interface {
|
||||
mustEmbedUnimplementedCryptoServiceServer()
|
||||
}
|
||||
|
||||
func RegisterCryptoServiceServer(s grpc.ServiceRegistrar, srv CryptoServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedCryptoServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&CryptoService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _CryptoService_VerifyXMLDSig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(VerifyRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CryptoServiceServer).VerifyXMLDSig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CryptoService_VerifyXMLDSig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CryptoServiceServer).VerifyXMLDSig(ctx, req.(*VerifyRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CryptoService_SignXMLDSig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SignRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CryptoServiceServer).SignXMLDSig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CryptoService_SignXMLDSig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CryptoServiceServer).SignXMLDSig(ctx, req.(*SignRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CryptoService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(HealthRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CryptoServiceServer).Health(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CryptoService_Health_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CryptoServiceServer).Health(ctx, req.(*HealthRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CryptoService_Activate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ActivateRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CryptoServiceServer).Activate(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CryptoService_Activate_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CryptoServiceServer).Activate(ctx, req.(*ActivateRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CryptoService_Shutdown_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ShutdownRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CryptoServiceServer).Shutdown(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CryptoService_Shutdown_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CryptoServiceServer).Shutdown(ctx, req.(*ShutdownRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// CryptoService_ServiceDesc is the grpc.ServiceDesc for CryptoService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var CryptoService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "bridge_and_joins.crypto.v1.CryptoService",
|
||||
HandlerType: (*CryptoServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "VerifyXMLDSig",
|
||||
Handler: _CryptoService_VerifyXMLDSig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SignXMLDSig",
|
||||
Handler: _CryptoService_SignXMLDSig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Health",
|
||||
Handler: _CryptoService_Health_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Activate",
|
||||
Handler: _CryptoService_Activate_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Shutdown",
|
||||
Handler: _CryptoService_Shutdown_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "crypto.proto",
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Package license — формат лицензии Bridge-and-Join-s и её подпись Ed25519.
|
||||
//
|
||||
// Лицензия — самодостаточный подписанный токен (offline-проверяемый):
|
||||
// клиент проверяет подпись зашитым публичным ключом и срок действия БЕЗ
|
||||
// обращения к серверу. Это значит, что on-prem bj-server продолжает
|
||||
// работать даже если license-сервер недоступен.
|
||||
//
|
||||
// Online-сервер (cmd/bj-license-server) нужен только для отзыва (revocation)
|
||||
// и выдачи новых ключей. Базовая модель — годовой ключ: выпустили на год,
|
||||
// клиент проверяет offline; перед обновлением bj-server гейтит установку
|
||||
// валидной непросроченной лицензией.
|
||||
//
|
||||
// Издатель держит приватный ключ в секрете; публичный зашит в bj-server.
|
||||
package license
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const CurrentSchema = 1
|
||||
|
||||
// Plan — тариф лицензии.
|
||||
type Plan string
|
||||
|
||||
const (
|
||||
PlanFree Plan = "free"
|
||||
PlanPro Plan = "pro"
|
||||
PlanEnterprise Plan = "enterprise"
|
||||
)
|
||||
|
||||
// License — содержимое лицензии (подписывается целиком).
|
||||
type License struct {
|
||||
Schema int `json:"schema"`
|
||||
ID string `json:"id"` // UUID лицензии
|
||||
Tenant string `json:"tenant"` // организация-клиент
|
||||
Product string `json:"product"` // "bj-server"
|
||||
Plan Plan `json:"plan"` // free|pro|enterprise
|
||||
IssuedAt time.Time `json:"issued_at"` // дата выпуска
|
||||
ExpiresAt time.Time `json:"expires_at"` // дата окончания (годовой ключ)
|
||||
Features []string `json:"features,omitempty"` // "updates","web-cabinet",...
|
||||
MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита)
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Token — лицензия + подпись. Именно это вводит клиент (одна base64-строка
|
||||
// или JSON-файл). Формат: base64url(payload).base64url(sig) — компактно.
|
||||
type Token struct {
|
||||
Payload string `json:"payload"` // base64(каноничный JSON License)
|
||||
Signature string `json:"signature"` // base64(ed25519 over каноничным JSON)
|
||||
KeyID string `json:"key_id,omitempty"`
|
||||
}
|
||||
|
||||
// Canonical сериализует лицензию детерминированно (для подписи/проверки).
|
||||
func (l *License) Canonical() ([]byte, error) {
|
||||
if l.Schema == 0 {
|
||||
l.Schema = CurrentSchema
|
||||
}
|
||||
return json.Marshal(l)
|
||||
}
|
||||
|
||||
// Valid проверяет срок действия на момент now.
|
||||
func (l *License) Valid(now time.Time) error {
|
||||
if now.Before(l.IssuedAt.Add(-24 * time.Hour)) {
|
||||
return errors.New("license: ещё не действует (issued_at в будущем)")
|
||||
}
|
||||
if now.After(l.ExpiresAt) {
|
||||
return fmt.Errorf("license: истекла %s", l.ExpiresAt.Format("02.01.2006"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasFeature — включена ли фича (или план enterprise — всё включено).
|
||||
func (l *License) HasFeature(f string) bool {
|
||||
if l.Plan == PlanEnterprise {
|
||||
return true
|
||||
}
|
||||
for _, x := range l.Features {
|
||||
if x == f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllowsUpdates — разрешены ли обновления по этой лицензии.
|
||||
func (l *License) AllowsUpdates() bool { return l.HasFeature("updates") }
|
||||
|
||||
// DaysLeft — сколько дней до окончания (может быть отрицательным).
|
||||
func (l *License) DaysLeft(now time.Time) int {
|
||||
return int(l.ExpiresAt.Sub(now).Hours() / 24)
|
||||
}
|
||||
|
||||
// Sign подписывает лицензию и возвращает Token.
|
||||
func Sign(l *License, priv ed25519.PrivateKey, keyID string) (*Token, error) {
|
||||
payload, err := l.Canonical()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("license: canonical: %w", err)
|
||||
}
|
||||
sig := ed25519.Sign(priv, payload)
|
||||
return &Token{
|
||||
Payload: base64.StdEncoding.EncodeToString(payload),
|
||||
Signature: base64.StdEncoding.EncodeToString(sig),
|
||||
KeyID: keyID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify проверяет подпись и возвращает License (срок проверяется отдельно
|
||||
// через License.Valid — Verify только про подлинность).
|
||||
func Verify(t *Token, pub ed25519.PublicKey) (*License, error) {
|
||||
sig, err := base64.StdEncoding.DecodeString(t.Signature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("license: decode signature: %w", err)
|
||||
}
|
||||
payload, err := base64.StdEncoding.DecodeString(t.Payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("license: decode payload: %w", err)
|
||||
}
|
||||
if !ed25519.Verify(pub, payload, sig) {
|
||||
return nil, errors.New("license: подпись недействительна")
|
||||
}
|
||||
var l License
|
||||
if err := json.Unmarshal(payload, &l); err != nil {
|
||||
return nil, fmt.Errorf("license: unmarshal: %w", err)
|
||||
}
|
||||
if l.Schema != CurrentSchema {
|
||||
return nil, fmt.Errorf("license: неподдерживаемая схема %d", l.Schema)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// Encode сериализует Token в компактную строку payload.signature[.keyid]
|
||||
// (то, что клиент вставляет в поле «лицензионный ключ»).
|
||||
func (t *Token) Encode() string {
|
||||
s := t.Payload + "." + t.Signature
|
||||
if t.KeyID != "" {
|
||||
s += "." + t.KeyID
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// DecodeToken разбирает компактную строку обратно в Token.
|
||||
func DecodeToken(s string) (*Token, error) {
|
||||
parts := strings.Split(strings.TrimSpace(s), ".")
|
||||
if len(parts) < 2 {
|
||||
return nil, errors.New("license: неверный формат ключа (ожидается payload.signature)")
|
||||
}
|
||||
t := &Token{Payload: parts[0], Signature: parts[1]}
|
||||
if len(parts) >= 3 {
|
||||
t.KeyID = parts[2]
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// --- Ключи (как в release) ---
|
||||
|
||||
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(b)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("license: decode seed: %w", err)
|
||||
}
|
||||
if len(seed) != ed25519.SeedSize {
|
||||
return nil, fmt.Errorf("license: неверный размер seed %d", len(seed))
|
||||
}
|
||||
return ed25519.NewKeyFromSeed(seed), nil
|
||||
}
|
||||
|
||||
func ParsePublicKey(b64 string) (ed25519.PublicKey, error) {
|
||||
pub, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pub) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("license: неверный размер pubkey %d", len(pub))
|
||||
}
|
||||
return ed25519.PublicKey(pub), nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mkLicense(plan Plan, expires time.Time, feats ...string) *License {
|
||||
return &License{
|
||||
ID: "test-id", Tenant: "ООО Тест", Product: "bj-server",
|
||||
Plan: plan, IssuedAt: time.Now().UTC().Add(-time.Hour),
|
||||
ExpiresAt: expires, Features: feats,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignVerifyAndEncode(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
l := mkLicense(PlanPro, time.Now().Add(365*24*time.Hour), "updates")
|
||||
tok, err := Sign(l, priv, "main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// round-trip через компактную строку
|
||||
dec, err := DecodeToken(tok.Encode())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Verify(dec, pub)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if got.Tenant != l.Tenant || got.Plan != PlanPro || !got.AllowsUpdates() {
|
||||
t.Fatalf("mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpired(t *testing.T) {
|
||||
l := mkLicense(PlanPro, time.Now().Add(-time.Hour), "updates")
|
||||
if err := l.Valid(time.Now().UTC()); err == nil {
|
||||
t.Fatal("истёкшая лицензия прошла Valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeaturesAndEnterprise(t *testing.T) {
|
||||
pro := mkLicense(PlanPro, time.Now().Add(time.Hour), "updates")
|
||||
if !pro.HasFeature("updates") || pro.HasFeature("web-cabinet") {
|
||||
t.Fatal("pro features неверны")
|
||||
}
|
||||
ent := mkLicense(PlanEnterprise, time.Now().Add(time.Hour))
|
||||
if !ent.HasFeature("anything") || !ent.AllowsUpdates() {
|
||||
t.Fatal("enterprise должен включать всё")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsWrongKey(t *testing.T) {
|
||||
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
other, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
l := mkLicense(PlanPro, time.Now().Add(time.Hour))
|
||||
tok, _ := Sign(l, priv, "main")
|
||||
if _, err := Verify(tok, other); err == nil {
|
||||
t.Fatal("Verify принял подпись чужим ключом")
|
||||
}
|
||||
}
|
||||
+44
-13
@@ -19,19 +19,29 @@ var templatesFS embed.FS
|
||||
// конкретный content-шаблон). Так html/template не путается с несколькими
|
||||
// {{define "content"}} в разных файлах.
|
||||
type admin struct {
|
||||
home, claims, claim, status, setup *template.Template
|
||||
help, helpDatabase, helpLK, helpCryptoPro, helpSystems, helpRobot, helpArchitecture *template.Template
|
||||
wizard, news *template.Template
|
||||
home, claims, claim, status, setup *template.Template
|
||||
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture *template.Template
|
||||
wizard, news, keyWizard *template.Template
|
||||
}
|
||||
|
||||
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
|
||||
// русификация статусов и других технических обозначений (см. требование
|
||||
// «всё UI на русском, кроме программных терминов»).
|
||||
var templateFuncs = template.FuncMap{
|
||||
"ru": russianText,
|
||||
"ruState": russianState,
|
||||
"ruOutcome": russianOutcome,
|
||||
"now": time.Now,
|
||||
"ru": russianText,
|
||||
"ruState": russianState,
|
||||
"ruOutcome": russianOutcome,
|
||||
"now": time.Now,
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"fallbackTpl": fallback,
|
||||
"anyKeymedia": func(ds []flashDrive) bool {
|
||||
for _, d := range ds {
|
||||
if d.IsKeymedia {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
// russianState переводит технический FSM-state в человекочитаемый
|
||||
@@ -115,9 +125,9 @@ func newAdmin() (*admin, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help_lk: %w", err)
|
||||
}
|
||||
helpCP, err := parse("admin_help_cryptopro.html")
|
||||
helpCrypto, err := parse("admin_help_crypto.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help_cryptopro: %w", err)
|
||||
return nil, fmt.Errorf("parse admin_help_crypto: %w", err)
|
||||
}
|
||||
helpSys, err := parse("admin_help_systems.html")
|
||||
if err != nil {
|
||||
@@ -139,11 +149,15 @@ func newAdmin() (*admin, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_help_architecture: %w", err)
|
||||
}
|
||||
keyWizard, err := parse("admin_keywizard.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_keywizard: %w", err)
|
||||
}
|
||||
return &admin{
|
||||
home: home, claims: claims, claim: claim, status: status, setup: setup,
|
||||
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCryptoPro: helpCP, helpSystems: helpSys,
|
||||
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCrypto: helpCrypto, helpSystems: helpSys,
|
||||
helpRobot: helpRobot, helpArchitecture: helpArch,
|
||||
wizard: wizard, news: news,
|
||||
wizard: wizard, news: news, keyWizard: keyWizard,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -173,6 +187,11 @@ type homeData struct {
|
||||
}
|
||||
Recent []ClaimView
|
||||
News []NewsItem // top-3 активных или свежих новостей
|
||||
|
||||
// Сводка готовности системы для hero-блока дашборда.
|
||||
AllReady bool
|
||||
NotReadyCount int
|
||||
TotalCount int
|
||||
}
|
||||
|
||||
// claimsData — данные журнала.
|
||||
@@ -222,8 +241,8 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts
|
||||
render(w, a.helpDatabase, nowPage("База данных", "help"))
|
||||
case p == "help/lk-api":
|
||||
render(w, a.helpLK, nowPage("API ЛК", "help"))
|
||||
case p == "help/cryptopro":
|
||||
render(w, a.helpCryptoPro, nowPage("КриптоПро", "help"))
|
||||
case p == "help/crypto":
|
||||
render(w, a.helpCrypto, nowPage("Криптография", "help"))
|
||||
case p == "help/systems":
|
||||
render(w, a.helpSystems, nowPage("Внешние системы", "help"))
|
||||
case p == "help/robot":
|
||||
@@ -254,6 +273,18 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service,
|
||||
Recent: recent.Items,
|
||||
News: topNews(rc.Snapshot().News.Items, 3),
|
||||
}
|
||||
// Готовность системы считаем ТОЛЬКО по обязательным компонентам.
|
||||
// Опциональные (напр. callback в ЛК) не влияют на «готовность».
|
||||
for _, c := range status.Checks {
|
||||
if c.Optional {
|
||||
continue
|
||||
}
|
||||
data.TotalCount++
|
||||
if !c.OK {
|
||||
data.NotReadyCount++
|
||||
}
|
||||
}
|
||||
data.AllReady = data.TotalCount > 0 && data.NotReadyCount == 0
|
||||
full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
|
||||
if err == nil {
|
||||
for _, c := range full.Items {
|
||||
|
||||
@@ -12,12 +12,14 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// caCertsDir — куда складываются скачанные сертификаты УЦ.
|
||||
const caCertsDir = "/var/lib/bj/ca-certs"
|
||||
|
||||
// defaultNSDCAURLs — список URL для авто-загрузки сертификатов УЦ НРД.
|
||||
// Эти URL пользователь может скорректировать в /admin/setup → «Сертификаты
|
||||
// УЦ» (раздел появляется после первого сохранения настроек). На сайте НРД
|
||||
@@ -31,8 +33,8 @@ var defaultNSDCAURLs = []string{
|
||||
// нужные ссылки в UI после того, как уточните URL у НРД.
|
||||
}
|
||||
|
||||
// FetchCACertificates скачивает все URL из настроек, парсит .cer, и при
|
||||
// успехе вызывает certmgr -inst -store mroot. Если передан rc — на каждое
|
||||
// FetchCACertificates скачивает все URL из настроек, парсит .cer и
|
||||
// сохраняет файл в /var/lib/bj/ca-certs/. Если передан rc — на каждое
|
||||
// фактическое изменение сертификата (новый или изменился SHA-256)
|
||||
// публикуется новость в ленту через rc.AddNews. На сертификаты,
|
||||
// истекающие в ближайшие 14 дней — отдельная новость-предупреждение.
|
||||
@@ -68,13 +70,13 @@ func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConf
|
||||
fc.IssuerCN = cert.Issuer.CommonName
|
||||
fc.NotAfter = cert.NotAfter
|
||||
fc.SHA256 = hex.EncodeToString(sha256Bytes(der))
|
||||
// УЦ-сертификаты с самоподписью (Issuer == Subject) идут в mroot,
|
||||
// промежуточные — в uRoot.
|
||||
store := "uRoot"
|
||||
// Корневые (Issuer == Subject) и промежуточные складываем рядом,
|
||||
// в общую папку /var/lib/bj/ca-certs/.
|
||||
kind := "intermediate"
|
||||
if cert.Subject.CommonName == cert.Issuer.CommonName {
|
||||
store = "mroot"
|
||||
kind = "root"
|
||||
}
|
||||
fc.Store = store
|
||||
fc.Store = kind
|
||||
|
||||
// Дедуп: если sha256 совпадает с уже импортированным — пропускаем
|
||||
// сам импорт (но фиксируем что проверили).
|
||||
@@ -91,7 +93,7 @@ func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConf
|
||||
continue
|
||||
}
|
||||
|
||||
// Импорт через certmgr.
|
||||
// Сохраняем DER на диск в /var/lib/bj/ca-certs/<sha>.cer.
|
||||
isNew := true
|
||||
for _, old := range s.FetchedCerts {
|
||||
if old.URL == u && old.Error == "" {
|
||||
@@ -99,22 +101,22 @@ func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConf
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := importCertToStore(ctx, der, store); err != nil {
|
||||
fc.Error = "certmgr: " + err.Error()
|
||||
fmt.Fprintf(&logBuf, "%s — certmgr упал: %s\n", u, err)
|
||||
if err := saveCertToDir(der, fc.SHA256); err != nil {
|
||||
fc.Error = "save: " + err.Error()
|
||||
fmt.Fprintf(&logBuf, "%s — сохранить не удалось: %s\n", u, err)
|
||||
if rc != nil {
|
||||
_ = rc.AddNews(NewsItem{
|
||||
ID: "ca-error-" + fc.SHA256[:12],
|
||||
At: now,
|
||||
Kind: "system",
|
||||
Title: "Не удалось импортировать сертификат УЦ",
|
||||
Title: "Не удалось сохранить сертификат УЦ",
|
||||
Body: "URL: " + u + "\nCN: " + fc.SubjectCN + "\nОшибка: " + err.Error(),
|
||||
URL: u,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(&logBuf, "%s — импортирован в %s (CN=%s, sha256=%s...)\n",
|
||||
u, store, fc.SubjectCN, fc.SHA256[:12])
|
||||
fmt.Fprintf(&logBuf, "%s — сохранён (%s, CN=%s, sha256=%s...)\n",
|
||||
u, kind, fc.SubjectCN, fc.SHA256[:12])
|
||||
if rc != nil {
|
||||
kindTitle := "Обновлён сертификат УЦ"
|
||||
if isNew {
|
||||
@@ -125,8 +127,8 @@ func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConf
|
||||
At: now,
|
||||
Kind: "feature",
|
||||
Title: kindTitle + ": " + fc.SubjectCN,
|
||||
Body: fmt.Sprintf("Хранилище: %s\nИздатель: %s\nДействителен до: %s\nSHA-256: %s…\nURL источника: %s",
|
||||
store, fc.IssuerCN, fc.NotAfter.Format("02.01.2006"), fc.SHA256[:16], u),
|
||||
Body: fmt.Sprintf("Тип: %s\nИздатель: %s\nДействителен до: %s\nSHA-256: %s…\nURL источника: %s",
|
||||
kind, fc.IssuerCN, fc.NotAfter.Format("02.01.2006"), fc.SHA256[:16], u),
|
||||
URL: u,
|
||||
ValidTo: fc.NotAfter,
|
||||
})
|
||||
@@ -198,29 +200,13 @@ func downloadAndParseCert(ctx context.Context, rawURL string) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// importCertToStore вызывает certmgr -inst -store <store> -file <tmp>.
|
||||
func importCertToStore(ctx context.Context, der []byte, store string) error {
|
||||
const certmgr = "/opt/cprocsp/bin/amd64/certmgr"
|
||||
if _, err := os.Stat(certmgr); err != nil {
|
||||
return fmt.Errorf("certmgr не найден (КриптоПро CSP не установлен?): %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "bj-ca-*.cer")
|
||||
if err != nil {
|
||||
// saveCertToDir сохраняет DER-байты в /var/lib/bj/ca-certs/<sha>.cer.
|
||||
func saveCertToDir(der []byte, sha256hex string) error {
|
||||
if err := os.MkdirAll(caCertsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.Write(der); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
cmd := exec.CommandContext(ctx, certmgr, "-inst", "-store", store, "-file", tmp.Name())
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w / %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
dst := filepath.Join(caCertsDir, sha256hex+".cer")
|
||||
return os.WriteFile(dst, der, 0o644)
|
||||
}
|
||||
|
||||
// StartCACertsAutoUpdater запускает горутину, которая раз в сутки
|
||||
@@ -312,7 +298,4 @@ func (h *setupHandlers) fetchCACertsNow(w http.ResponseWriter, r *http.Request)
|
||||
// caCertsTemplateString — компактный URL для отображения в UI.
|
||||
func caCertsTemplateString(s CACertsSettings) string {
|
||||
return strings.Join(s.URLs, "\n")
|
||||
}
|
||||
|
||||
// доп. защита от пустых импортов (linter)
|
||||
var _ = filepath.Join
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
@@ -15,6 +17,9 @@ type Status struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// Optional — компонент не обязателен для работы с НРД. Его «не-OK»
|
||||
// не делает систему «не готовой» (напр. callback в ЛК).
|
||||
Optional bool `json:"optional,omitempty"`
|
||||
}
|
||||
|
||||
// SystemStatus — все проверки.
|
||||
@@ -55,17 +60,30 @@ func CheckAll(ctx context.Context, o CheckOptions) SystemStatus {
|
||||
return out
|
||||
}
|
||||
|
||||
func checkPostgres(_ context.Context, o CheckOptions) Status {
|
||||
s := Status{Name: "postgres"}
|
||||
func checkPostgres(ctx context.Context, o CheckOptions) Status {
|
||||
s := Status{Name: "База данных PostgreSQL"}
|
||||
if o.PostgresDSN == "" {
|
||||
s.OK = true
|
||||
s.Message = "in-memory (PostgresDSN не задан, репозиторий — m2mcore.MemoryRepository)"
|
||||
s.Optional = true
|
||||
s.Message = "in-memory — данные не сохраняются между перезапусками"
|
||||
return s
|
||||
}
|
||||
// На M2 здесь будет sql.Open + Ping. На текущем шаге — заглушка.
|
||||
s.OK = false
|
||||
s.Message = "PostgreSQL Repository не подключён (требуется pgx, M2-шаг-3)"
|
||||
s.Detail = "DSN: " + o.PostgresDSN
|
||||
pctx, cancel := context.WithTimeout(ctx, o.Timeout)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(pctx, o.PostgresDSN)
|
||||
if err != nil {
|
||||
s.OK = false
|
||||
s.Message = "ошибка подключения: " + err.Error()
|
||||
return s
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := pool.Ping(pctx); err != nil {
|
||||
s.OK = false
|
||||
s.Message = "не отвечает: " + err.Error()
|
||||
return s
|
||||
}
|
||||
s.OK = true
|
||||
s.Message = "подключена, репозиторий m2m_core.deals"
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -111,20 +129,27 @@ func checkCryptoSocket(o CheckOptions) Status {
|
||||
}
|
||||
|
||||
func checkNSDAdapter(ctx context.Context, o CheckOptions) Status {
|
||||
s := Status{Name: "nsd-adapter (REST к ИШ)"}
|
||||
s := Status{Name: "Интеграционный шлюз НРД"}
|
||||
if o.NSDAdapterURL == "" {
|
||||
s.OK = true
|
||||
s.Message = "BJ_NSD_ADAPTER_URL не задан — используется mock NSDSender"
|
||||
s.Optional = true
|
||||
s.Message = "не подключён — режим эмуляции (mock)"
|
||||
return s
|
||||
}
|
||||
return httpHealth(ctx, o.NSDAdapterURL+"/healthz", o.Timeout, s)
|
||||
// У ИШ нет /healthz — проверяем рабочий эндпоинт Web API (engine/state
|
||||
// отвечает 200 «Running», когда движок поднят).
|
||||
st := httpHealth(ctx, o.NSDAdapterURL+"/api/admin/engine/state", o.Timeout, s)
|
||||
if st.OK {
|
||||
st.Message = "подключён, движок работает"
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func checkLKCallback(ctx context.Context, o CheckOptions) Status {
|
||||
s := Status{Name: "lk-emulator (callback)"}
|
||||
s := Status{Name: "Callback в личный кабинет", Optional: true}
|
||||
if o.LKCallbackURL == "" {
|
||||
s.OK = false
|
||||
s.Message = "BJ_LK_CALLBACK_URL не задан — callback'и в ЛК отключены"
|
||||
s.Message = "не настроен — уведомления в ЛК отключены (необязательно для работы с НРД)"
|
||||
return s
|
||||
}
|
||||
return httpHealth(ctx, o.LKCallbackURL+"/healthz", o.Timeout, s)
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FlashContainer — найденный на смонтированной флешке контейнер КриптоПро.
|
||||
// КриптоПро CSP под Linux ожидает контейнер в виде папки <name>.000 с
|
||||
// файлами header.key/masks.key/name.key/primary.key/primary2.key.
|
||||
type FlashContainer struct {
|
||||
// Mountpoint — путь смонтированной флешки, например /run/media/user/USB.
|
||||
Mountpoint string
|
||||
// Path — полный путь до папки <name>.000.
|
||||
Path string
|
||||
// Name — имя контейнера (без суффикса .000).
|
||||
Name string
|
||||
// Files — список файлов в контейнере (для дисплея).
|
||||
Files []string
|
||||
// AlreadyImported — true, если папка <name>.000 уже есть в локальном
|
||||
// хранилище /var/opt/cprocsp/keys/<user>/.
|
||||
AlreadyImported bool
|
||||
}
|
||||
|
||||
// scanFlashContainers ищет контейнеры формата <name>.000 на типичных
|
||||
// точках монтирования USB-носителей в Linux: /run/media/<user>/* и
|
||||
// /media/<user>/* и /media/*. Возвращает список найденных контейнеров.
|
||||
func scanFlashContainers() []FlashContainer {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
roots := []string{
|
||||
filepath.Join("/run/media", u.Username),
|
||||
filepath.Join("/media", u.Username),
|
||||
"/media",
|
||||
"/mnt",
|
||||
}
|
||||
localKeysDir := filepath.Join("/var/opt/cprocsp/keys", u.Username)
|
||||
|
||||
var out []FlashContainer
|
||||
for _, root := range roots {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
mountpoint := filepath.Join(root, e.Name())
|
||||
out = append(out, findContainersAt(mountpoint, localKeysDir)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findContainersAt(mountpoint, localKeysDir string) []FlashContainer {
|
||||
var out []FlashContainer
|
||||
// Ищем папки <name>.000 на верхнем уровне и на 1 уровне вглубь.
|
||||
_ = filepath.Walk(mountpoint, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Глубже 2 уровней не лезем (на флешке могут быть личные папки).
|
||||
rel, _ := filepath.Rel(mountpoint, p)
|
||||
if strings.Count(rel, string(filepath.Separator)) > 2 {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if !info.IsDir() || !strings.HasSuffix(strings.ToLower(p), ".000") {
|
||||
return nil
|
||||
}
|
||||
// Проверяем, что внутри лежат файлы вида *.key.
|
||||
entries, _ := os.ReadDir(p)
|
||||
var files []string
|
||||
hasKey := false
|
||||
for _, ent := range entries {
|
||||
files = append(files, ent.Name())
|
||||
if strings.HasSuffix(strings.ToLower(ent.Name()), ".key") {
|
||||
hasKey = true
|
||||
}
|
||||
}
|
||||
if !hasKey {
|
||||
return nil
|
||||
}
|
||||
name := strings.TrimSuffix(filepath.Base(p), ".000")
|
||||
fc := FlashContainer{
|
||||
Mountpoint: mountpoint,
|
||||
Path: p,
|
||||
Name: name,
|
||||
Files: files,
|
||||
}
|
||||
// Проверка: уже скопирован в локальное хранилище?
|
||||
if _, err := os.Stat(filepath.Join(localKeysDir, name+".000")); err == nil {
|
||||
fc.AlreadyImported = true
|
||||
}
|
||||
out = append(out, fc)
|
||||
return filepath.SkipDir
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// copyContainerToLocal копирует папку <name>.000 с флешки в локальное
|
||||
// хранилище КриптоПро /var/opt/cprocsp/keys/<user>/<name>.000. После
|
||||
// этого контейнер виден как \\.\HDIMAGE\<name> и работает даже без
|
||||
// вставленной флешки.
|
||||
func copyContainerToLocal(srcDir string) (string, error) {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
localKeysDir := filepath.Join("/var/opt/cprocsp/keys", u.Username)
|
||||
if err := os.MkdirAll(localKeysDir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("создать %s: %w", localKeysDir, err)
|
||||
}
|
||||
base := filepath.Base(srcDir)
|
||||
dstDir := filepath.Join(localKeysDir, base)
|
||||
if _, err := os.Stat(dstDir); err == nil {
|
||||
return "", fmt.Errorf("контейнер %s уже существует в локальном хранилище", dstDir)
|
||||
}
|
||||
if err := os.MkdirAll(dstDir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("создать %s: %w", dstDir, err)
|
||||
}
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
src, err := os.Open(filepath.Join(srcDir, e.Name()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dst, err := os.OpenFile(filepath.Join(dstDir, e.Name()),
|
||||
os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
src.Close()
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
src.Close()
|
||||
dst.Close()
|
||||
return "", err
|
||||
}
|
||||
src.Close()
|
||||
dst.Close()
|
||||
}
|
||||
return dstDir, nil
|
||||
}
|
||||
|
||||
// copyContainer — POST /admin/setup/crypto/copy-container.
|
||||
// Параметр src — путь до папки <name>.000 на флешке.
|
||||
func (h *setupHandlers) copyContainer(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
src := strings.TrimSpace(r.FormValue("src"))
|
||||
if src == "" {
|
||||
setupFlash(w, r, "Копирование контейнера: не указан путь")
|
||||
return
|
||||
}
|
||||
// Минимальная защита: ожидаем .000 в конце пути.
|
||||
if !strings.HasSuffix(strings.ToLower(src), ".000") {
|
||||
setupFlash(w, r, "Копирование контейнера: путь должен заканчиваться на .000")
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
setupFlash(w, r, "Копирование контейнера: исходная папка недоступна: "+err.Error())
|
||||
return
|
||||
}
|
||||
dst, err := copyContainerToLocal(src)
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Копирование контейнера: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Дадим CSP несколько мс «заметить» новый контейнер (не критично).
|
||||
_, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
|
||||
cancel()
|
||||
setupFlash(w, r, "Контейнер скопирован в "+dst+". Теперь он виден как \\\\.\\HDIMAGE\\"+strings.TrimSuffix(filepath.Base(dst), ".000")+" и работает без вставленной флешки. Импортируйте сертификат: certmgr -inst -cont '\\\\.\\HDIMAGE\\"+strings.TrimSuffix(filepath.Base(dst), ".000")+"' -store uMy.")
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
||||
)
|
||||
|
||||
// urlQ — экранирование строки для query-параметра flash.
|
||||
func urlQ(s string) string { return url.QueryEscape(s) }
|
||||
|
||||
// Пошаговый мастер установки ключа Валидаты на съёмный носитель (USB keymedia
|
||||
// ИШ). Реализует то, что просил пользователь: загрузил архив + пароль →
|
||||
// распаковка → запись на флешку → формирование справочника сертификатов →
|
||||
// проверка Валидаты → «Готово» → можно слать тестовый документ.
|
||||
//
|
||||
// Привилегированные операции с флешкой (бэкап, remount rw, запись, перезапуск
|
||||
// VDCrySvc) делает помощник /usr/local/sbin/bj-keymedia-install через узкий
|
||||
// sudoers (bj-server работает под непривилегированным bj).
|
||||
|
||||
|
||||
// keyWizardStep — один шаг мастера.
|
||||
type keyWizardStep struct {
|
||||
Title string
|
||||
Status string // pending | active | ok | error
|
||||
Detail string
|
||||
}
|
||||
|
||||
// keyWizardState — состояние одного прогона мастера (в памяти, один активный).
|
||||
type keyWizardState struct {
|
||||
mu sync.Mutex
|
||||
StagingID string // id распаковки в /var/lib/bj/media/iso/<id>
|
||||
VDK string // имя файла ключа
|
||||
Profile string // имя установленного профиля на носителе
|
||||
Backup string // путь бэкапа
|
||||
Steps []keyWizardStep // 1..5
|
||||
Done bool // все шаги пройдены
|
||||
Flash string
|
||||
}
|
||||
|
||||
func newKeyWizardState() *keyWizardState {
|
||||
return &keyWizardState{Steps: defaultKeySteps()}
|
||||
}
|
||||
|
||||
// reset обнуляет поля прогона, НЕ трогая мьютекс (вызывать под Lock).
|
||||
func (s *keyWizardState) reset() {
|
||||
s.StagingID = ""
|
||||
s.VDK = ""
|
||||
s.Profile = ""
|
||||
s.Backup = ""
|
||||
s.Steps = defaultKeySteps()
|
||||
s.Done = false
|
||||
s.Flash = ""
|
||||
}
|
||||
|
||||
func defaultKeySteps() []keyWizardStep {
|
||||
return []keyWizardStep{
|
||||
{Title: "Загрузка архива и распаковка", Status: "pending"},
|
||||
{Title: "Запись ключа на выбранную флешку (с бэкапом)", Status: "pending"},
|
||||
{Title: "Формирование справочника сертификатов (CRL)", Status: "pending"},
|
||||
{Title: "Перезапуск и проверка ИШ", Status: "pending"},
|
||||
{Title: "Готово — можно отправлять тестовый документ", Status: "pending"},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *keyWizardState) set(i int, status, detail string) {
|
||||
if i >= 0 && i < len(s.Steps) {
|
||||
s.Steps[i].Status = status
|
||||
if detail != "" {
|
||||
s.Steps[i].Detail = detail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flashDrive — съёмный носитель (USB), обнаруженный в системе.
|
||||
type flashDrive struct {
|
||||
Device string // /dev/sdb1
|
||||
Size string // 1,9G
|
||||
Label string
|
||||
FSType string
|
||||
Mountpoint string // пусто если не смонтирован
|
||||
Model string // USB2FlashStorage
|
||||
IsKeymedia bool // смонтирован как текущий ключевой носитель ИШ
|
||||
}
|
||||
|
||||
// keyWizardData — данные шаблона admin_keywizard.html.
|
||||
type keyWizardData struct {
|
||||
page
|
||||
State *keyWizardState
|
||||
Drives []flashDrive
|
||||
}
|
||||
|
||||
const keymediaMount = "/var/lib/igate/keymedia"
|
||||
|
||||
// listFlashDrives перечисляет съёмные (removable/hotplug) USB-носители с ФС —
|
||||
// чтобы пользователь выбрал, на какую флешку писать ключ.
|
||||
func listFlashDrives() []flashDrive {
|
||||
out, err := exec.Command("lsblk", "-J", "-b", "-o",
|
||||
"NAME,SIZE,LABEL,MOUNTPOINT,RM,HOTPLUG,TYPE,MODEL,FSTYPE,PATH").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var parsed struct {
|
||||
Blockdevices []json.RawMessage `json:"blockdevices"`
|
||||
}
|
||||
if json.Unmarshal(out, &parsed) != nil {
|
||||
return nil
|
||||
}
|
||||
var drives []flashDrive
|
||||
var walk func(raw []byte, parentRemovable bool, parentModel string)
|
||||
walk = func(raw []byte, parentRemovable bool, parentModel string) {
|
||||
var d struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Label string `json:"label"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
RM bool `json:"rm"`
|
||||
Hotplug bool `json:"hotplug"`
|
||||
Type string `json:"type"`
|
||||
Model string `json:"model"`
|
||||
FSType string `json:"fstype"`
|
||||
Path string `json:"path"`
|
||||
Children []json.RawMessage `json:"children"`
|
||||
}
|
||||
if json.Unmarshal(raw, &d) != nil {
|
||||
return
|
||||
}
|
||||
removable := d.RM || d.Hotplug || parentRemovable
|
||||
model := strings.TrimSpace(d.Model)
|
||||
if model == "" {
|
||||
model = parentModel
|
||||
}
|
||||
// Носитель с ФС — кандидат на запись.
|
||||
if removable && d.Type == "part" && d.FSType != "" {
|
||||
drives = append(drives, flashDrive{
|
||||
Device: d.Path,
|
||||
Size: humanSize(d.Size),
|
||||
Label: d.Label,
|
||||
FSType: d.FSType,
|
||||
Mountpoint: d.Mountpoint,
|
||||
Model: model,
|
||||
IsKeymedia: d.Mountpoint == keymediaMount,
|
||||
})
|
||||
}
|
||||
for _, c := range d.Children {
|
||||
walk(c, removable, model)
|
||||
}
|
||||
}
|
||||
for _, b := range parsed.Blockdevices {
|
||||
walk(b, false, "")
|
||||
}
|
||||
return drives
|
||||
}
|
||||
|
||||
func humanSize(b int64) string {
|
||||
switch {
|
||||
case b >= 1<<30:
|
||||
return fmt.Sprintf("%.1f ГБ", float64(b)/(1<<30))
|
||||
case b >= 1<<20:
|
||||
return fmt.Sprintf("%.0f МБ", float64(b)/(1<<20))
|
||||
default:
|
||||
return fmt.Sprintf("%d Б", b)
|
||||
}
|
||||
}
|
||||
|
||||
// registerKeyWizard вешает маршруты мастера установки ключа.
|
||||
func (h *setupHandlers) registerKeyWizard(mux *http.ServeMux) {
|
||||
if h.keyWiz == nil {
|
||||
h.keyWiz = newKeyWizardState()
|
||||
}
|
||||
mux.HandleFunc("/admin/setup/keywizard", h.renderKeyWizard)
|
||||
mux.HandleFunc("/admin/setup/keywizard/upload", h.keyWizardUpload)
|
||||
mux.HandleFunc("/admin/setup/keywizard/install", h.keyWizardInstall)
|
||||
mux.HandleFunc("/admin/setup/keywizard/reset", h.keyWizardReset)
|
||||
}
|
||||
|
||||
func (h *setupHandlers) renderKeyWizard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
h.keyWiz.mu.Lock()
|
||||
h.keyWiz.Flash = r.URL.Query().Get("flash")
|
||||
st := h.keyWiz
|
||||
h.keyWiz.mu.Unlock()
|
||||
// Список флешек нужен на шаге выбора носителя (когда архив уже загружен).
|
||||
var drives []flashDrive
|
||||
if st.StagingID != "" && !st.Done {
|
||||
drives = listFlashDrives()
|
||||
}
|
||||
render(w, h.tpl.a.keyWizard, keyWizardData{page: nowPage("Установка ключа", "setup"), State: st, Drives: drives})
|
||||
}
|
||||
|
||||
func (h *setupHandlers) keyWizardReset(w http.ResponseWriter, r *http.Request) {
|
||||
h.keyWiz.mu.Lock()
|
||||
h.keyWiz.reset()
|
||||
h.keyWiz.mu.Unlock()
|
||||
http.Redirect(w, r, "/admin/setup/keywizard", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// keyWizardUpload — шаг 1: приём .7z + пароль, распаковка, инспекция.
|
||||
func (h *setupHandlers) keyWizardUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(500 << 20); err != nil {
|
||||
h.keyWizFlash(w, r, "Ошибка чтения формы: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("archive")
|
||||
if err != nil {
|
||||
h.keyWizFlash(w, r, "Выберите файл архива (.7z/.zip)")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
lower := strings.ToLower(header.Filename)
|
||||
if !(strings.HasSuffix(lower, ".7z") || strings.HasSuffix(lower, ".zip")) {
|
||||
h.keyWizFlash(w, r, "Архив должен быть .7z или .zip")
|
||||
return
|
||||
}
|
||||
password := r.FormValue("password")
|
||||
|
||||
isoDir := "/var/lib/bj/iso"
|
||||
if err := os.MkdirAll(isoDir, 0o755); err != nil {
|
||||
h.keyWizFlash(w, r, "Не удалось создать "+isoDir+": "+err.Error())
|
||||
return
|
||||
}
|
||||
dst := filepath.Join(isoDir, time.Now().UTC().Format("20060102-150405-")+filepath.Base(header.Filename))
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
h.keyWizFlash(w, r, "Запись архива: "+err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(out, file); err != nil {
|
||||
out.Close()
|
||||
_ = os.Remove(dst)
|
||||
h.keyWizFlash(w, r, "Запись архива: "+err.Error())
|
||||
return
|
||||
}
|
||||
out.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
m, err := ExtractISO(ctx, dst, password)
|
||||
if err != nil {
|
||||
h.keyWizFlash(w, r, "Распаковка не удалась (проверьте пароль): "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.keyWiz.mu.Lock()
|
||||
h.keyWiz.reset()
|
||||
h.keyWiz.StagingID = m.ID
|
||||
// Инспекция через помощник (читает staging, находит .vdk/gdbm/pse).
|
||||
staging := filepath.Join("/var/lib/bj/media/iso", m.ID)
|
||||
insp, ierr := runKeymediaHelper(ctx, "inspect", staging, "", "", "")
|
||||
if ierr == nil && insp["ok"] == true {
|
||||
if v, ok := insp["vdk"].(string); ok {
|
||||
h.keyWiz.VDK = v
|
||||
}
|
||||
}
|
||||
detail := fmt.Sprintf("Ключ: %s · справочник сертификатов: %s",
|
||||
fallback(h.keyWiz.VDK, "—"), yesNo(insp["has_gdbm"]))
|
||||
h.keyWiz.set(0, "ok", detail)
|
||||
h.keyWiz.set(1, "active", "")
|
||||
h.keyWiz.mu.Unlock()
|
||||
|
||||
http.Redirect(w, r, "/admin/setup/keywizard", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// keyWizardInstall — шаги 2-5: запись на флешку, справочник, проверка, готово.
|
||||
func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
h.keyWiz.mu.Lock()
|
||||
id := h.keyWiz.StagingID
|
||||
h.keyWiz.mu.Unlock()
|
||||
if id == "" {
|
||||
h.keyWizFlash(w, r, "Сначала загрузите архив (шаг 1)")
|
||||
return
|
||||
}
|
||||
staging := filepath.Join("/var/lib/bj/media/iso", id)
|
||||
|
||||
// Выбор флешки и имя профиля из формы.
|
||||
profileName := strings.TrimSpace(r.FormValue("profile_name"))
|
||||
targetDev := strings.TrimSpace(r.FormValue("target_device"))
|
||||
targetMnt := ""
|
||||
if targetDev != "" {
|
||||
for _, d := range listFlashDrives() {
|
||||
if d.Device == targetDev {
|
||||
targetMnt = d.Mountpoint
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Шаг 2-3: запись ключа на флешку + справочник + CRL (привилегированный воркер).
|
||||
res, err := runKeymediaHelper(ctx, "install", staging, profileName, targetDev, targetMnt)
|
||||
h.keyWiz.mu.Lock()
|
||||
if err != nil || res["ok"] != true {
|
||||
msg := errStr(err)
|
||||
if e, ok := res["error"].(string); ok {
|
||||
msg = e
|
||||
}
|
||||
h.keyWiz.set(1, "error", "Запись на флешку не удалась: "+msg)
|
||||
h.keyWiz.mu.Unlock()
|
||||
h.keyWizFlash(w, r, "Установка прервана: "+msg)
|
||||
return
|
||||
}
|
||||
if p, ok := res["profile"].(string); ok {
|
||||
h.keyWiz.Profile = p
|
||||
}
|
||||
if b, ok := res["backup"].(string); ok {
|
||||
h.keyWiz.Backup = b
|
||||
}
|
||||
tgt, _ := res["target"].(string)
|
||||
spr, _ := res["spr"].(string)
|
||||
h.keyWiz.set(1, "ok", fmt.Sprintf("Профиль «%s» записан на %s. Бэкап: %s", h.keyWiz.Profile, fallback(tgt, "носитель"), h.keyWiz.Backup))
|
||||
crl, _ := res["crl"].(string)
|
||||
h.keyWiz.set(2, "ok", fmt.Sprintf("Справочник «%s» сформирован. CRL: %s", fallback(spr, "—"), crlRu(crl)))
|
||||
h.keyWiz.set(3, "active", "")
|
||||
h.keyWiz.mu.Unlock()
|
||||
|
||||
// Шаг 4: перезапуск и проверка ИШ. Перезапуск VDCrySvc мог сбросить
|
||||
// активацию серверного профиля bj-crypto — восстанавливаем. Затем
|
||||
// перезапускаем ИШ и проверяем engine/state: ИШ поднялся с новым ключом.
|
||||
h.reactivateCryptoProfile(ctx)
|
||||
ishOK, ishMsg := h.restartAndVerifyISH(ctx)
|
||||
h.keyWiz.mu.Lock()
|
||||
if !ishOK {
|
||||
h.keyWiz.set(3, "error", "ИШ не подтвердил готовность: "+ishMsg)
|
||||
h.keyWiz.mu.Unlock()
|
||||
h.keyWizFlash(w, r, "Ключ записан, но ИШ не готов: "+ishMsg)
|
||||
return
|
||||
}
|
||||
h.keyWiz.set(3, "ok", "ИШ перезапущен и работает: "+ishMsg)
|
||||
h.keyWiz.set(4, "ok", "Теперь подпишите новым ключом — отправьте тестовый документ роботу НРД ниже")
|
||||
h.keyWiz.Done = true
|
||||
h.keyWiz.mu.Unlock()
|
||||
|
||||
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ("Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает. Финальная проверка — тестовым документом роботу."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// restartAndVerifyISH перезапускает Интеграционный шлюз и проверяет, что он
|
||||
// поднялся (engine/state). Возвращает (ok, сообщение).
|
||||
func (h *setupHandlers) restartAndVerifyISH(ctx context.Context) (bool, string) {
|
||||
// Перезапуск igate через привилегированный воркер (bj не sudoer).
|
||||
res, err := runKeymediaHelper(ctx, "restart-ish", "/var/lib/bj/media/iso", "", "", "")
|
||||
if err != nil || res["ok"] != true {
|
||||
// restart-ish может быть не поддержан — не критично, проверим состояние.
|
||||
_ = err
|
||||
}
|
||||
// Проверяем состояние ИШ через nsd-адаптер (engine/state).
|
||||
deadline := time.Now().Add(40 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if st := h.ishEngineState(ctx); st != "" {
|
||||
return true, "engine "+st
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, "таймаут"
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
return false, "ИШ не ответил на /api/admin/engine/state за 40 сек"
|
||||
}
|
||||
|
||||
// ishEngineState запрашивает состояние движка ИШ; пусто если недоступен.
|
||||
func (h *setupHandlers) ishEngineState(ctx context.Context) string {
|
||||
s := h.rc.Snapshot()
|
||||
base := s.NSD.IGWBaseURL
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(base, "/")+"/api/admin/engine/state", nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
cl := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func crlRu(s string) string {
|
||||
switch s {
|
||||
case "updated":
|
||||
return "обновлены из точек распространения"
|
||||
case "failed":
|
||||
return "не удалось обновить (проверьте сеть/CDP)"
|
||||
case "skip":
|
||||
return "пропущено"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// reactivateCryptoProfile повторно активирует текущий серверный профиль
|
||||
// bj-crypto (после перезапуска VDCrySvc активация в сайдкаре сбрасывается).
|
||||
// Best-effort: возвращает true при успехе.
|
||||
func (h *setupHandlers) reactivateCryptoProfile(ctx context.Context) bool {
|
||||
s := h.rc.Snapshot()
|
||||
if s.Crypto.Profile == "" {
|
||||
return false
|
||||
}
|
||||
cli := cryptocli.New(cryptocli.Config{
|
||||
Provider: cryptocli.Provider(s.Crypto.Provider),
|
||||
SocketPath: s.Crypto.SocketPath,
|
||||
})
|
||||
defer cli.Close()
|
||||
res, err := cli.Activate(ctx, s.Crypto.Profile)
|
||||
return err == nil && res.OK
|
||||
}
|
||||
|
||||
// vdcrysvcActive проверяет, что демон Валидаты (vdmkdev) запущен.
|
||||
func vdcrysvcActive() bool {
|
||||
out, _ := exec.Command("systemctl", "is-active", "vdmkdev.service").Output()
|
||||
return strings.TrimSpace(string(out)) == "active"
|
||||
}
|
||||
|
||||
func boolRu(b bool, yes, no string) string {
|
||||
if b {
|
||||
return yes
|
||||
}
|
||||
return no
|
||||
}
|
||||
|
||||
const keymediaReqDir = "/var/lib/bj/keymedia-requests"
|
||||
|
||||
// runKeymediaHelper передаёт запрос привилегированному воркеру через файловый
|
||||
// обмен: bj-server (в песочнице) пишет <id>.req, root-сервис bj-keymedia
|
||||
// (host namespace, триггерится bj-keymedia.path) выполняет операцию с флешкой
|
||||
// и пишет <id>.res. bj-server опрашивает результат. Так привилегированная
|
||||
// работа идёт вне mount-namespace песочницы, где доступно перемонтирование USB.
|
||||
func runKeymediaHelper(ctx context.Context, action, staging, profile, targetDev, targetMnt 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)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]string{
|
||||
"action": action, "staging": staging, "profile": profile,
|
||||
"target_dev": targetDev, "target_mnt": targetMnt,
|
||||
})
|
||||
// Пишем атомарно (tmp → rename), чтобы .path не подхватил полупустой файл.
|
||||
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("таймаут ожидания воркера установки ключа")
|
||||
case <-ticker.C:
|
||||
b, err := os.ReadFile(resPath)
|
||||
if err != nil {
|
||||
continue // ещё не готово
|
||||
}
|
||||
res := map[string]any{}
|
||||
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)
|
||||
return res, fmt.Errorf("воркер: %s", fallback(msg, "ошибка"))
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *setupHandlers) keyWizFlash(w http.ResponseWriter, r *http.Request, msg string) {
|
||||
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func fallback(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func yesNo(v any) string {
|
||||
if b, ok := v.(bool); ok && b {
|
||||
return "да"
|
||||
}
|
||||
return "нет"
|
||||
}
|
||||
|
||||
func errStr(err error) string {
|
||||
if err == nil {
|
||||
return "неизвестная ошибка"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
|
||||
)
|
||||
|
||||
// DefaultLicensePublicKey — публичный ключ лицензий, зашитый в релиз.
|
||||
// Пустой в исходниках; подставляется при официальной сборке. Если задан в
|
||||
// настройках (LicenseSettings.PublicKey) — приоритет у настроек.
|
||||
var DefaultLicensePublicKey = ""
|
||||
|
||||
// LicenseStatus — сводка состояния лицензии для UI и гейтов.
|
||||
type LicenseStatus struct {
|
||||
Present bool // ключ введён
|
||||
Valid bool // подпись верна и срок не истёк
|
||||
Tenant string
|
||||
Plan string
|
||||
ExpiresAt time.Time
|
||||
DaysLeft int
|
||||
AllowsUpdates bool
|
||||
Message string
|
||||
}
|
||||
|
||||
// licensingEnabled — включено ли лицензирование (есть публичный ключ для
|
||||
// проверки). Если ключа нет вовсе — продукт в открытом режиме, гейты не
|
||||
// действуют (удобно для разработки и бесплатной редакции).
|
||||
func licensingEnabled(rc *RuntimeConfig) bool {
|
||||
return rc.Snapshot().License.PublicKey != "" || DefaultLicensePublicKey != ""
|
||||
}
|
||||
|
||||
// licenseStatus разбирает и проверяет лицензию из настроек.
|
||||
func licenseStatus(rc *RuntimeConfig) LicenseStatus {
|
||||
s := rc.Snapshot().License
|
||||
st := LicenseStatus{}
|
||||
if s.Key == "" {
|
||||
st.Message = "лицензионный ключ не введён"
|
||||
return st
|
||||
}
|
||||
st.Present = true
|
||||
|
||||
pubB64 := s.PublicKey
|
||||
if pubB64 == "" {
|
||||
pubB64 = DefaultLicensePublicKey
|
||||
}
|
||||
if pubB64 == "" {
|
||||
st.Message = "нет публичного ключа для проверки лицензии"
|
||||
return st
|
||||
}
|
||||
pub, err := license.ParsePublicKey(pubB64)
|
||||
if err != nil {
|
||||
st.Message = "неверный публичный ключ: " + err.Error()
|
||||
return st
|
||||
}
|
||||
tok, err := license.DecodeToken(s.Key)
|
||||
if err != nil {
|
||||
st.Message = "неверный формат ключа: " + err.Error()
|
||||
return st
|
||||
}
|
||||
lic, err := license.Verify(tok, pub)
|
||||
if err != nil {
|
||||
st.Message = "подпись лицензии недействительна"
|
||||
return st
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
st.Tenant = lic.Tenant
|
||||
st.Plan = string(lic.Plan)
|
||||
st.ExpiresAt = lic.ExpiresAt
|
||||
st.DaysLeft = lic.DaysLeft(now)
|
||||
st.AllowsUpdates = lic.AllowsUpdates()
|
||||
if err := lic.Valid(now); err != nil {
|
||||
st.Message = err.Error()
|
||||
return st
|
||||
}
|
||||
st.Valid = true
|
||||
st.Message = "активна до " + lic.ExpiresAt.Format("02.01.2006")
|
||||
return st
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MediaRoot — где bj-server хранит свои носители: распакованные ISO,
|
||||
// импортированные ключевые контейнеры. На прод-машине пользователь bj
|
||||
// должен быть владельцем этой директории (создаётся install.sh).
|
||||
const (
|
||||
mediaRoot = "/var/lib/bj/media"
|
||||
mediaISODir = "/var/lib/bj/media/iso"
|
||||
containersDir = "/var/lib/bj/containers"
|
||||
profilesDir = "/var/lib/bj/profiles"
|
||||
keyFileMinPerDir = 2 // считаем директорию контейнером, если в ней >= 2 *.key файлов
|
||||
)
|
||||
|
||||
// Medium — один носитель: USB-флешка или распакованная ISO.
|
||||
type Medium struct {
|
||||
// ID — стабильный идентификатор (для USB — sha1 от пути монтирования,
|
||||
// для ISO — sha256-prefix от исходного файла).
|
||||
ID string `json:"id"`
|
||||
// Kind — "usb" или "iso".
|
||||
Kind string `json:"kind"`
|
||||
// Mountpoint — корень, по которому сейчас доступен носитель.
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
// Source — для ISO: путь до исходного .iso на сервере.
|
||||
Source string `json:"source,omitempty"`
|
||||
// Profile — полный профиль Валидаты (pse + gdbm + vdkeys), если найден.
|
||||
Profile *ValidataProfile `json:"profile,omitempty"`
|
||||
// Containers — найденные ключевые контейнеры (директории с *.key/*.vdk).
|
||||
Containers []KeyContainer `json:"containers"`
|
||||
// Certificates — отдельно лежащие сертификаты (.cer/.crt/.pem/.pfx/.p12).
|
||||
Certificates []CertFile `json:"certificates"`
|
||||
}
|
||||
|
||||
// ValidataProfile — полный профиль АПК «Валидата Клиент L»: ПСП (.pse),
|
||||
// ЛСП (.gdbm) и ключи (vdkeys/*.vdk).
|
||||
type ValidataProfile struct {
|
||||
Root string `json:"root"` // mountpoint, где найден профиль
|
||||
PSEFiles []string `json:"pse_files"` // относительные пути до .pse
|
||||
GDBMFiles []string `json:"gdbm_files"` // относительные пути до .gdbm
|
||||
KeyFiles []string `json:"key_files"` // относительные пути до .vdk
|
||||
Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/profiles/
|
||||
}
|
||||
|
||||
// KeyContainer — ключевой контейнер: директория с *.key или *.vdk.
|
||||
type KeyContainer struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"` // имя последней компоненты пути
|
||||
Files []string `json:"files"` // имена файлов в контейнере
|
||||
Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/containers/
|
||||
}
|
||||
|
||||
// CertFile — публичный или PKCS#12 сертификат.
|
||||
type CertFile struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Format string `json:"format"` // "cer" | "pem" | "pfx"
|
||||
SubjectCN string `json:"subject_cn"`
|
||||
IssuerCN string `json:"issuer_cn"`
|
||||
Serial string `json:"serial"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
INN string `json:"inn,omitempty"`
|
||||
HasPrivateKey bool `json:"has_private_key"` // true для .pfx/.p12
|
||||
ParseError string `json:"parse_error,omitempty"`
|
||||
}
|
||||
|
||||
// ScanMedia собирает список всех видимых носителей: USB + распакованные
|
||||
// ISO. Безопасна для частых вызовов — IO ограничен директориями верхнего
|
||||
// уровня в типичных mount-точках.
|
||||
func ScanMedia() []Medium {
|
||||
var out []Medium
|
||||
out = append(out, scanUSB()...)
|
||||
out = append(out, listExtractedISOs()...)
|
||||
return out
|
||||
}
|
||||
|
||||
// scanUSB ищет USB-монтирования в /run/media/$USER, /media/$USER, /media, /mnt.
|
||||
func scanUSB() []Medium {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
roots := []string{
|
||||
filepath.Join("/run/media", u.Username),
|
||||
filepath.Join("/media", u.Username),
|
||||
"/media",
|
||||
"/mnt",
|
||||
}
|
||||
var out []Medium
|
||||
seen := map[string]bool{}
|
||||
for _, root := range roots {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
mountpoint := filepath.Join(root, e.Name())
|
||||
// Не лезем в наши собственные /var/lib/bj/media/iso/*.
|
||||
if strings.HasPrefix(mountpoint, mediaISODir) {
|
||||
continue
|
||||
}
|
||||
if seen[mountpoint] {
|
||||
continue
|
||||
}
|
||||
seen[mountpoint] = true
|
||||
out = append(out, scanMountpoint("usb", mountpoint, ""))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listExtractedISOs возвращает все ранее распакованные ISO в /var/lib/bj/media/iso/.
|
||||
func listExtractedISOs() []Medium {
|
||||
entries, err := os.ReadDir(mediaISODir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []Medium
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
id := e.Name()
|
||||
mountpoint := filepath.Join(mediaISODir, id)
|
||||
source := readISOSource(id)
|
||||
m := scanMountpoint("iso", mountpoint, source)
|
||||
m.ID = id
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scanMountpoint сканирует точку монтирования на 3 уровня вглубь.
|
||||
func scanMountpoint(kind, mountpoint, source string) Medium {
|
||||
m := Medium{
|
||||
ID: sha1Path(mountpoint),
|
||||
Kind: kind,
|
||||
Mountpoint: mountpoint,
|
||||
Source: source,
|
||||
}
|
||||
containers, certs, profile := walkForArtifacts(mountpoint)
|
||||
m.Containers = containers
|
||||
m.Certificates = certs
|
||||
m.Profile = profile
|
||||
// Отмечаем контейнеры, уже импортированные в /var/lib/bj/containers/.
|
||||
for i := range m.Containers {
|
||||
if _, err := os.Stat(filepath.Join(containersDir, m.Containers[i].Name)); err == nil {
|
||||
m.Containers[i].Imported = true
|
||||
}
|
||||
}
|
||||
// Профиль помечается импортированным, если в /var/lib/bj/profiles/
|
||||
// есть директория с тем же именем (имя берётся от носителя).
|
||||
if m.Profile != nil {
|
||||
name := filepath.Base(mountpoint)
|
||||
if _, err := os.Stat(filepath.Join(profilesDir, name)); err == nil {
|
||||
m.Profile.Imported = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// walkForArtifacts проходит дерево mountpoint (до 3 уровней) и собирает:
|
||||
// - директории-контейнеры (>=2 *.key или >=1 *.vdk файла);
|
||||
// - отдельные сертификаты (.cer/.pfx/...);
|
||||
// - полный профиль Валидаты (наличие *.pse + *.gdbm + *.vdk в дереве).
|
||||
func walkForArtifacts(root string) ([]KeyContainer, []CertFile, *ValidataProfile) {
|
||||
var containers []KeyContainer
|
||||
var certs []CertFile
|
||||
prof := &ValidataProfile{Root: root}
|
||||
|
||||
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
depth := strings.Count(rel, string(filepath.Separator))
|
||||
if depth > 4 {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if info.IsDir() {
|
||||
if p != root {
|
||||
if c, ok := classifyContainer(p); ok {
|
||||
containers = append(containers, c)
|
||||
// НЕ делаем SkipDir: внутри vdkeys/ нужно собрать
|
||||
// .vdk-файлы для определения профиля Валидаты.
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
lower := strings.ToLower(info.Name())
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".pse"):
|
||||
prof.PSEFiles = append(prof.PSEFiles, rel)
|
||||
case strings.HasSuffix(lower, ".gdbm"):
|
||||
prof.GDBMFiles = append(prof.GDBMFiles, rel)
|
||||
case strings.HasSuffix(lower, ".vdk"):
|
||||
prof.KeyFiles = append(prof.KeyFiles, rel)
|
||||
default:
|
||||
if cert := classifyCertFile(p); cert != nil {
|
||||
certs = append(certs, *cert)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Профилем считаем носитель если есть и pse, и vdk (gdbm
|
||||
// опционален — но обычно тоже присутствует).
|
||||
if len(prof.PSEFiles) == 0 || len(prof.KeyFiles) == 0 {
|
||||
prof = nil
|
||||
}
|
||||
return containers, certs, prof
|
||||
}
|
||||
|
||||
// classifyContainer — директория является ключевым контейнером, если:
|
||||
// - в ней >=2 файлов *.key (старый формат КриптоПро/Валидата); или
|
||||
// - в ней >=1 файл *.vdk (Валидата Linux).
|
||||
func classifyContainer(dir string) (KeyContainer, bool) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return KeyContainer{}, false
|
||||
}
|
||||
var keyFiles, vdkFiles, allFiles []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
allFiles = append(allFiles, name)
|
||||
lower := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".vdk"):
|
||||
vdkFiles = append(vdkFiles, name)
|
||||
case strings.HasSuffix(lower, ".key"):
|
||||
keyFiles = append(keyFiles, name)
|
||||
}
|
||||
}
|
||||
if len(vdkFiles) == 0 && len(keyFiles) < keyFileMinPerDir {
|
||||
return KeyContainer{}, false
|
||||
}
|
||||
return KeyContainer{
|
||||
Path: dir,
|
||||
Name: filepath.Base(dir),
|
||||
Files: allFiles,
|
||||
}, true
|
||||
}
|
||||
|
||||
// classifyCertFile парсит один файл — возвращает CertFile если это
|
||||
// похоже на сертификат.
|
||||
func classifyCertFile(path string) *CertFile {
|
||||
lower := strings.ToLower(path)
|
||||
var format string
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".cer"), strings.HasSuffix(lower, ".crt"):
|
||||
format = "cer"
|
||||
case strings.HasSuffix(lower, ".pem"):
|
||||
format = "pem"
|
||||
case strings.HasSuffix(lower, ".pfx"), strings.HasSuffix(lower, ".p12"):
|
||||
format = "pfx"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
cf := &CertFile{
|
||||
Path: path,
|
||||
Name: filepath.Base(path),
|
||||
Format: format,
|
||||
}
|
||||
if format == "pfx" {
|
||||
// PKCS#12 шифрован PIN'ом — мета без него не вытащить.
|
||||
cf.HasPrivateKey = true
|
||||
return cf
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
cf.ParseError = "read: " + err.Error()
|
||||
return cf
|
||||
}
|
||||
if len(data) > 32*1024 {
|
||||
// Странно большой файл для сертификата — режем.
|
||||
data = data[:32*1024]
|
||||
}
|
||||
der := data
|
||||
if block, _ := pem.Decode(data); block != nil && block.Type == "CERTIFICATE" {
|
||||
der = block.Bytes
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
cf.ParseError = "x509: " + err.Error()
|
||||
return cf
|
||||
}
|
||||
cf.SubjectCN = cert.Subject.CommonName
|
||||
cf.IssuerCN = cert.Issuer.CommonName
|
||||
cf.Serial = cert.SerialNumber.Text(16)
|
||||
cf.NotBefore = cert.NotBefore
|
||||
cf.NotAfter = cert.NotAfter
|
||||
cf.INN = extractCertINN(cert)
|
||||
return cf
|
||||
}
|
||||
|
||||
// extractCertINN — ИНН из OID 1.2.643.3.131.1.1 в Subject.
|
||||
func extractCertINN(c *x509.Certificate) string {
|
||||
innOID := asn1.ObjectIdentifier{1, 2, 643, 3, 131, 1, 1}
|
||||
for _, name := range c.Subject.Names {
|
||||
if name.Type.Equal(innOID) {
|
||||
if s, ok := name.Value.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExtractISO распаковывает образ диска (.iso/.img/.zip и т.п.) в
|
||||
// /var/lib/bj/media/iso/<id>/ через 7z. password — опциональный пароль
|
||||
// архива (пустая строка = без пароля). id — sha256-prefix от исходного
|
||||
// пути. Возвращает Medium или ошибку.
|
||||
func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) {
|
||||
abs, err := filepath.Abs(isoPath)
|
||||
if err != nil {
|
||||
return Medium{}, fmt.Errorf("ISO путь: %w", err)
|
||||
}
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return Medium{}, fmt.Errorf("ISO не найден: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return Medium{}, errors.New("ISO путь — это директория, нужен файл")
|
||||
}
|
||||
|
||||
id := isoID(abs)
|
||||
dst := filepath.Join(mediaISODir, id)
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return Medium{}, fmt.Errorf("создать %s: %w", dst, err)
|
||||
}
|
||||
if isEmpty, _ := dirEmpty(dst); !isEmpty {
|
||||
// Уже распакован раньше — просто пересканируем.
|
||||
writeISOSource(id, abs)
|
||||
m := scanMountpoint("iso", dst, abs)
|
||||
m.ID = id
|
||||
return m, nil
|
||||
}
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
// 7z x -y -o<dst> [-p<pass>] <archive> — рекурсивное извлечение.
|
||||
args := []string{"x", "-y", "-o" + dst}
|
||||
if password != "" {
|
||||
// 7z требует пароль через -p без пробела.
|
||||
args = append(args, "-p"+password)
|
||||
} else {
|
||||
// -p- запрещает интерактивный запрос пароля (нам нечего вводить).
|
||||
args = append(args, "-p-")
|
||||
}
|
||||
args = append(args, abs)
|
||||
cmd := exec.CommandContext(cctx, "7z", args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(dst)
|
||||
return Medium{}, fmt.Errorf("7z x: %w / %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
writeISOSource(id, abs)
|
||||
|
||||
m := scanMountpoint("iso", dst, abs)
|
||||
m.ID = id
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// UnmountISO удаляет всё, что относится к загруженному образу:
|
||||
// - распакованную директорию /var/lib/bj/media/iso/<id>/;
|
||||
// - .src-meta файл с записанным источником;
|
||||
// - сам исходный .img/.iso в /var/lib/bj/iso/, если он находится
|
||||
// в наших границах (защита: путь должен начинаться с /var/lib/bj/iso/).
|
||||
//
|
||||
// Безопасно только для тех id, что лежат в нашем mediaISODir.
|
||||
func UnmountISO(id string) error {
|
||||
if strings.ContainsAny(id, "/.") {
|
||||
return errors.New("неверный id")
|
||||
}
|
||||
dst := filepath.Join(mediaISODir, id)
|
||||
if !strings.HasPrefix(dst, mediaISODir+"/") {
|
||||
return errors.New("путь вне media-root")
|
||||
}
|
||||
// Сначала забираем путь исходника, потом удаляем .src.
|
||||
src := readISOSource(id)
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(filepath.Join(mediaISODir, id+".src"))
|
||||
// Если запись об источнике существовала и путь — внутри /var/lib/bj/iso/,
|
||||
// удаляем и сам файл .img/.iso.
|
||||
if src != "" {
|
||||
abs, _ := filepath.Abs(src)
|
||||
if strings.HasPrefix(abs, "/var/lib/bj/iso/") {
|
||||
_ = os.Remove(abs)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportKeyContainer копирует контейнер в /var/lib/bj/containers/<name>/.
|
||||
// Возвращает целевой путь.
|
||||
func ImportKeyContainer(src string) (string, error) {
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("источник: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", errors.New("источник не директория")
|
||||
}
|
||||
if _, ok := classifyContainer(src); !ok {
|
||||
return "", errors.New("в директории не найдено >=2 файлов *.key — не похоже на контейнер")
|
||||
}
|
||||
if err := os.MkdirAll(containersDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("создать %s: %w", containersDir, err)
|
||||
}
|
||||
name := filepath.Base(src)
|
||||
dst := filepath.Join(containersDir, name)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return "", fmt.Errorf("контейнер %q уже импортирован", name)
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0o700); err != nil {
|
||||
return "", fmt.Errorf("создать %s: %w", dst, err)
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
sp := filepath.Join(src, e.Name())
|
||||
dp := filepath.Join(dst, e.Name())
|
||||
data, err := os.ReadFile(sp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("чтение %s: %w", e.Name(), err)
|
||||
}
|
||||
if err := os.WriteFile(dp, data, 0o600); err != nil {
|
||||
return "", fmt.Errorf("запись %s: %w", e.Name(), err)
|
||||
}
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// ImportProfileResult — результат импорта профиля Валидаты.
|
||||
type ImportProfileResult struct {
|
||||
Path string // /var/lib/bj/profiles/<name>/
|
||||
Pki1ConfSection string // готовая секция для pki1.conf
|
||||
ConfWritten bool // удалось ли дописать в /opt/Validata/VDCSP/etc/pki1.conf
|
||||
ConfWriteError string // если не удалось — причина
|
||||
}
|
||||
|
||||
const validataPki1Conf = "/opt/Validata/VDCSP/etc/pki1.conf"
|
||||
|
||||
// ImportProfile копирует профиль Валидаты (pse/gdbm/vdkeys) в
|
||||
// /var/lib/bj/profiles/<name>/, генерирует секцию для pki1.conf и
|
||||
// пробует дописать её в системный конфиг Валидаты. Имя берётся от
|
||||
// носителя, если name пуст. Возвращает деталь — что получилось.
|
||||
func ImportProfile(root, name string) (ImportProfileResult, error) {
|
||||
if name == "" {
|
||||
name = filepath.Base(root)
|
||||
}
|
||||
if !validProfileName(name) {
|
||||
return ImportProfileResult{}, errors.New("имя профиля: допустимы латинские буквы, цифры, '-' и '_'")
|
||||
}
|
||||
if err := os.MkdirAll(profilesDir, 0o755); err != nil {
|
||||
return ImportProfileResult{}, fmt.Errorf("создать %s: %w", profilesDir, err)
|
||||
}
|
||||
dst := filepath.Join(profilesDir, name)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return ImportProfileResult{}, fmt.Errorf("профиль %q уже импортирован", name)
|
||||
}
|
||||
if err := copyTree(root, dst); err != nil {
|
||||
_ = os.RemoveAll(dst)
|
||||
return ImportProfileResult{}, err
|
||||
}
|
||||
|
||||
// Ищем фактический pse и gdbm внутри импортированной папки —
|
||||
// обычно spr*/local.pse + spr*/local.gdbm.
|
||||
psePath, gdbmPath := findProfileFiles(dst)
|
||||
if psePath == "" {
|
||||
return ImportProfileResult{}, errors.New("после копирования не найден .pse — формат профиля нестандартный")
|
||||
}
|
||||
|
||||
section := buildPki1ConfSection(name, psePath, gdbmPath)
|
||||
// Сохраняем секцию рядом с профилем — чтобы оператор мог
|
||||
// посмотреть/перечитать.
|
||||
_ = os.WriteFile(filepath.Join(dst, "pki1.conf-section.txt"),
|
||||
[]byte(section), 0o644)
|
||||
|
||||
res := ImportProfileResult{
|
||||
Path: dst,
|
||||
Pki1ConfSection: section,
|
||||
}
|
||||
// Пробуем дописать в pki1.conf — если файл доступен на запись.
|
||||
if err := appendToPki1Conf(name, section); err != nil {
|
||||
res.ConfWriteError = err.Error()
|
||||
} else {
|
||||
res.ConfWritten = true
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func validProfileName(s string) bool {
|
||||
if s == "" || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '-' || r == '_'
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// findProfileFiles ищет .pse и .gdbm внутри директории профиля.
|
||||
// Возвращает абсолютные пути или пустые строки.
|
||||
func findProfileFiles(dir string) (psePath, gdbmPath string) {
|
||||
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
lower := strings.ToLower(info.Name())
|
||||
if psePath == "" && strings.HasSuffix(lower, ".pse") {
|
||||
psePath = p
|
||||
}
|
||||
if gdbmPath == "" && strings.HasSuffix(lower, ".gdbm") {
|
||||
gdbmPath = p
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// buildPki1ConfSection формирует блок pki1.conf для нашего профиля.
|
||||
func buildPki1ConfSection(name, psePath, gdbmPath string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("\n# --- bj-server: профиль " + name + " ---\n")
|
||||
b.WriteString("local: " + name + "\n")
|
||||
b.WriteString("pse: pse://signed/" + psePath + "\n")
|
||||
if gdbmPath != "" {
|
||||
b.WriteString("localstore: file://" + gdbmPath + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// appendToPki1Conf пишет секцию в системный pki1.conf, если процесс
|
||||
// имеет права. Возвращает ошибку при отсутствии прав или I/O-сбое.
|
||||
// Дедуп — если в файле уже есть блок с тем же `local: <name>`, не
|
||||
// пишем повторно.
|
||||
func appendToPki1Conf(name, section string) error {
|
||||
existing, err := os.ReadFile(validataPki1Conf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", validataPki1Conf, err)
|
||||
}
|
||||
marker := "local: " + name
|
||||
if strings.Contains(string(existing), marker) {
|
||||
return fmt.Errorf("в pki1.conf уже есть секция %q — пропускаем", name)
|
||||
}
|
||||
f, err := os.OpenFile(validataPki1Conf, os.O_WRONLY|os.O_APPEND, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", validataPki1Conf, err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.WriteString(section); err != nil {
|
||||
return fmt.Errorf("write %s: %w", validataPki1Conf, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyTree рекурсивно копирует src в dst, сохраняя структуру директорий.
|
||||
// Права на новые директории — 0700, на файлы — 0600 (приватные ключи).
|
||||
func copyTree(src, dst string) error {
|
||||
return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, 0o700)
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, data, 0o600)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteImportedContainer сносит /var/lib/bj/containers/<name>/.
|
||||
func DeleteImportedContainer(name string) error {
|
||||
if !validProfileName(name) {
|
||||
return errors.New("неверное имя контейнера")
|
||||
}
|
||||
dir := filepath.Join(containersDir, name)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return fmt.Errorf("контейнер не найден: %w", err)
|
||||
}
|
||||
return os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
// DeleteImportedProfile сносит и директорию профиля
|
||||
// /var/lib/bj/profiles/<name>/, и связанную секцию из pki1.conf
|
||||
// (между маркерами «# --- bj-server: профиль <name> ---» и следующим
|
||||
// «# --- bj-server: ...» или концом файла).
|
||||
func DeleteImportedProfile(name string) error {
|
||||
if !validProfileName(name) {
|
||||
return errors.New("неверное имя профиля")
|
||||
}
|
||||
dir := filepath.Join(profilesDir, name)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return fmt.Errorf("профиль не найден: %w", err)
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("удалить %s: %w", dir, err)
|
||||
}
|
||||
// Чистим секцию в pki1.conf — best effort, если файл недоступен на
|
||||
// запись, профиль всё равно удалён, но в конфиге останется огрызок.
|
||||
if err := removeFromPki1Conf(name); err != nil {
|
||||
return fmt.Errorf("директория удалена, но pki1.conf не почистился: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeFromPki1Conf удаляет блок профиля из pki1.conf.
|
||||
// Блок начинается с «# --- bj-server: профиль <name> ---» и кончается
|
||||
// перед следующим таким маркером или до конца файла. Если блок не
|
||||
// найден — успех (idempotent).
|
||||
func removeFromPki1Conf(name string) error {
|
||||
data, err := os.ReadFile(validataPki1Conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
startMarker := "# --- bj-server: профиль " + name + " ---"
|
||||
startIdx := strings.Index(string(data), startMarker)
|
||||
if startIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
// Найдём конец блока: следующий маркер «# --- bj-server: профиль» или EOF.
|
||||
rest := string(data)[startIdx+len(startMarker):]
|
||||
endRel := strings.Index(rest, "# --- bj-server: профиль ")
|
||||
var newContent string
|
||||
if endRel < 0 {
|
||||
newContent = string(data)[:startIdx]
|
||||
} else {
|
||||
newContent = string(data)[:startIdx] + rest[endRel:]
|
||||
}
|
||||
// Убираем хвостовые пустые строки от секции.
|
||||
newContent = strings.TrimRight(newContent, "\n") + "\n"
|
||||
return os.WriteFile(validataPki1Conf, []byte(newContent), 0o644)
|
||||
}
|
||||
|
||||
// ListImportedProfiles возвращает имена директорий в /var/lib/bj/profiles/.
|
||||
func ListImportedProfiles() []string {
|
||||
entries, err := os.ReadDir(profilesDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListImportedContainers возвращает уже импортированные контейнеры.
|
||||
func ListImportedContainers() []KeyContainer {
|
||||
entries, err := os.ReadDir(containersDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []KeyContainer
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(containersDir, e.Name())
|
||||
if c, ok := classifyContainer(dir); ok {
|
||||
c.Imported = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isoID(absPath string) string {
|
||||
h := sha256.Sum256([]byte(absPath))
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
func sha1Path(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:6])
|
||||
}
|
||||
|
||||
func dirEmpty(path string) (bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
names, err := f.Readdirnames(1)
|
||||
if errors.Is(err, os.ErrInvalid) || err != nil {
|
||||
return len(names) == 0, nil
|
||||
}
|
||||
return len(names) == 0, nil
|
||||
}
|
||||
|
||||
func readISOSource(id string) string {
|
||||
data, err := os.ReadFile(filepath.Join(mediaISODir, id+".src"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func writeISOSource(id, src string) {
|
||||
_ = os.WriteFile(filepath.Join(mediaISODir, id+".src"), []byte(src), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
||||
)
|
||||
|
||||
// pollIncoming периодически опрашивает ИШ на входящие пакеты от НРД
|
||||
// (M2MTransferDecision / Response) и применяет их через svc.ApplyDecision.
|
||||
// Замыкает цикл: bj-server отправил заявку → ИШ → НРД → робот ответил →
|
||||
// ИШ забрал ответ во входящие → этот поллер применяет Decision (статус
|
||||
// заявки переходит в confirmed/rejected, срабатывает callback в ЛК).
|
||||
//
|
||||
// Дедупликация по id обработанных пакетов: ИШ возвращает их повторно,
|
||||
// пока мы не подтвердим, поэтому держим множество уже обработанных.
|
||||
func (s *Server) pollIncoming(ctx context.Context) {
|
||||
const interval = 30 * time.Second
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
processed := make(map[int]bool)
|
||||
log.Printf("lk-gateway: поллер входящих ИШ запущен (канал %s, интервал %s)", s.igwChannel, interval)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.fetchAndApply(ctx, processed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAndApply — один проход поллера: список входящих → для каждого нового
|
||||
// забираем тело, распаковываем, парсим Decision, применяем.
|
||||
func (s *Server) fetchAndApply(ctx context.Context, processed map[int]bool) {
|
||||
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Тип не указываем — ИШ вернёт оба (M2MTD + M2MER). Date=сегодня.
|
||||
pkgs, err := s.igwClient.ListIncoming(cctx, igw.ListFilter{
|
||||
Channel: s.igwChannel,
|
||||
Date: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("lk-gateway: поллер ListIncoming: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, p := range pkgs {
|
||||
if processed[p.ID] {
|
||||
continue
|
||||
}
|
||||
if err := s.applyIncoming(cctx, p); err != nil {
|
||||
log.Printf("lk-gateway: поллер пакет id=%d (%s): %v", p.ID, p.Type, err)
|
||||
continue // не помечаем обработанным — повторим в след. раз
|
||||
}
|
||||
processed[p.ID] = true
|
||||
log.Printf("lk-gateway: поллер применил входящий пакет id=%d тип=%s", p.ID, p.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// applyIncoming забирает тело пакета и применяет M2M-ответ к сделке.
|
||||
// Среди входящих от НРД много служебных пакетов (квитанции ЭДО типа C/Z,
|
||||
// конверты) — они не M2M-документы и пропускаются. Реальные ответы —
|
||||
// M2MTransferDecision (решение принимающей стороны) и M2MTransferResponse
|
||||
// (ответ сервиса МОСТ, в т.ч. ошибки M2Mxx).
|
||||
func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
|
||||
zipBytes, err := s.igwClient.GetPackage(ctx, p.ID)
|
||||
if err != nil {
|
||||
return err // сетевая ошибка — повторим в след. раз
|
||||
}
|
||||
unpacked, err := igw.UnpackPackage(zipBytes)
|
||||
if err != nil {
|
||||
// Нет основного .xml — служебный пакет (квитанция/конверт ЭДО).
|
||||
// Не ошибка: помечаем обработанным, чтобы не повторять.
|
||||
log.Printf("lk-gateway: поллер пакет id=%d (%s) — служебный (квитанция/конверт), пропуск", p.ID, p.Type)
|
||||
return nil
|
||||
}
|
||||
doc := string(unpacked.DocXML)
|
||||
switch {
|
||||
case strings.Contains(doc, "M2MTransferDecision"):
|
||||
decision, err := igw.ParseDecision(unpacked.DocXML)
|
||||
if err != nil {
|
||||
log.Printf("lk-gateway: поллер Decision id=%d: разбор: %v", p.ID, err)
|
||||
return nil
|
||||
}
|
||||
return s.svc.ApplyDecision(ctx, decision)
|
||||
case strings.Contains(doc, "M2MTransferResponse"):
|
||||
resp, err := igw.ParseResponse(unpacked.DocXML)
|
||||
if err != nil {
|
||||
log.Printf("lk-gateway: поллер Response id=%d: разбор: %v", p.ID, err)
|
||||
return nil
|
||||
}
|
||||
// Ответ сервиса МОСТ: статус + код (M2Mxx). Применяем к сделке:
|
||||
// INFO — приём в обработку (статус не меняется), ERROR — отказ сервиса
|
||||
// (напр. M2M14 — отправитель не в справочнике), сделка → Отклонена.
|
||||
// Ответ сохраняется в сделке и виден в карточке заявки.
|
||||
var codes string
|
||||
for _, rr := range resp.Responses {
|
||||
codes += string(rr.Code) + " "
|
||||
}
|
||||
log.Printf("lk-gateway: поллер M2MTransferResponse id=%d: статус=%s коды=[%s] GUID=%s",
|
||||
p.ID, resp.StatusCode, strings.TrimSpace(codes), resp.GUID)
|
||||
if err := s.svc.ApplyServiceResponse(ctx, resp, unpacked.DocXML); err != nil {
|
||||
// Сделка может быть не найдена (ответ на чужой/старый GUID) —
|
||||
// логируем, но помечаем обработанным, чтобы не зациклиться.
|
||||
log.Printf("lk-gateway: поллер ApplyServiceResponse id=%d GUID=%s: %v", p.ID, resp.GUID, err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
log.Printf("lk-gateway: поллер пакет id=%d (%s) — неизвестный M2M-документ, пропуск", p.ID, p.Type)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,30 @@ type Settings struct {
|
||||
LK LKSettings `json:"lk"`
|
||||
CACerts CACertsSettings `json:"ca_certs"`
|
||||
News NewsSettings `json:"news"`
|
||||
Update UpdateSettings `json:"update"`
|
||||
License LicenseSettings `json:"license"`
|
||||
LastTest *TestRunResult `json:"last_test,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// LicenseSettings — лицензионный ключ (подписанный токен).
|
||||
type LicenseSettings struct {
|
||||
Key string `json:"key"` // компактный токен payload.signature.keyid
|
||||
PublicKey string `json:"public_key"` // base64 (если не зашит в бинарь)
|
||||
}
|
||||
|
||||
// UpdateSettings — авто-обновления из артефактории (#18/#20).
|
||||
type UpdateSettings struct {
|
||||
BaseURL string `json:"base_url"` // https://updates.example.com
|
||||
Channel string `json:"channel"` // "stable" | "beta"
|
||||
PublicKey string `json:"public_key"` // base64 Ed25519 (если не зашит в бинарь)
|
||||
AutoCheck bool `json:"auto_check"` // проверять автоматически
|
||||
LastCheck time.Time `json:"last_check"` // когда последний раз проверяли
|
||||
LastResult string `json:"last_result"` // текст результата проверки
|
||||
Available string `json:"available_version"` // доступная версия (если новее)
|
||||
Notes string `json:"notes,omitempty"` // заметки доступного релиза
|
||||
}
|
||||
|
||||
// NewsSettings — лента новостей (события системы, окна техработ, обновления
|
||||
// документации НРД). События добавляются вручную через UI или автоматически
|
||||
// doc-watcher'ом и cron-задачами. Каждое событие может быть скрыто (Dismissed)
|
||||
@@ -64,8 +84,7 @@ type DocSource struct {
|
||||
|
||||
// CACertsSettings — URL'ы для авто-загрузки сертификатов УЦ НРД и нашего
|
||||
// УЦ. Список редактируется пользователем; раз в сутки фоновая горутина
|
||||
// перекачивает каждый URL и переустанавливает сертификат, если он
|
||||
// поменялся. Все сертификаты идут в mroot/uRoot хранилища КриптоПро.
|
||||
// перекачивает каждый URL и сохраняет сертификат, если он поменялся.
|
||||
type CACertsSettings struct {
|
||||
URLs []string `json:"urls"`
|
||||
AutoUpdate bool `json:"auto_update"`
|
||||
@@ -91,12 +110,12 @@ type PostgresSettings struct {
|
||||
DSN string `json:"dsn"`
|
||||
}
|
||||
|
||||
// CryptoSettings — путь к JCP, провайдер, лицензионный ключ.
|
||||
// CryptoSettings — путь к PKCS#11 модулю и тип провайдера.
|
||||
type CryptoSettings struct {
|
||||
Provider string `json:"provider"` // "stub" | "cryptopro" | "validata" | "vipnet"
|
||||
Provider string `json:"provider"` // "stub" | "validata"
|
||||
SocketPath string `json:"socket_path"` // UDS crypto-service
|
||||
JCPPath string `json:"jcp_path"` // путь до jcp.jar
|
||||
LicenseKey string `json:"license_key"` // лицензионный ключ КриптоПро
|
||||
ModulePath string `json:"module_path"` // путь до .so модуля PKCS#11
|
||||
Profile string `json:"profile"` // активный профиль Валидаты (имя из pki1.conf)
|
||||
}
|
||||
|
||||
// NSDSettings — профиль и подключение к ИШ НРД.
|
||||
@@ -104,6 +123,12 @@ type NSDSettings struct {
|
||||
Profile string `json:"profile"` // "guest-gost", "test3-gost", ...
|
||||
IGWBaseURL string `json:"igw_base_url"` // http://host:port
|
||||
KeyContainer string `json:"key_container"` // имя контейнера (на стороне ИШ)
|
||||
|
||||
// Депозитарные реквизиты клиента — откуда списываются бумаги
|
||||
// (SettlementLocation в M2MTransferRequest). Из договора/письма НРД.
|
||||
DeponentCode string `json:"deponent_code"` // депкод, напр. MC0413600000
|
||||
AccountID string `json:"account_id"` // депозитарный счёт
|
||||
SectionID string `json:"section_id"` // раздел депозитарного счёта
|
||||
}
|
||||
|
||||
// LKSettings — настройки callback в ЛК клиента.
|
||||
@@ -165,6 +190,24 @@ func (r *RuntimeConfig) UpdatePostgres(s PostgresSettings) error {
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// SaveLicense сохраняет лицензионные настройки.
|
||||
func (r *RuntimeConfig) SaveLicense(s LicenseSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.License = s
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// SaveUpdateSettings сохраняет настройки авто-обновлений.
|
||||
func (r *RuntimeConfig) SaveUpdateSettings(s UpdateSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.Update = s
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// UpdateCrypto сохраняет crypto-настройки.
|
||||
func (r *RuntimeConfig) UpdateCrypto(s CryptoSettings) error {
|
||||
r.mu.Lock()
|
||||
@@ -307,7 +350,7 @@ func (r *RuntimeConfig) ReadinessSummary() []Readiness {
|
||||
},
|
||||
{
|
||||
Name: "crypto-service",
|
||||
Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != "",
|
||||
Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub",
|
||||
Ready: false,
|
||||
Message: cryptoMsg(s.Crypto),
|
||||
},
|
||||
@@ -336,15 +379,12 @@ func posMsg(dsn string) string {
|
||||
|
||||
func cryptoMsg(c CryptoSettings) string {
|
||||
if c.Provider == "" || c.Provider == "stub" {
|
||||
return "Криптография не настроена (provider=stub). КриптоПро JCP не подключён."
|
||||
return "Криптография не настроена (provider=stub) — реальная подпись недоступна."
|
||||
}
|
||||
if c.JCPPath == "" {
|
||||
return "Провайдер " + c.Provider + ", но путь к JCP не задан."
|
||||
if c.ModulePath == "" {
|
||||
return "Провайдер " + c.Provider + ", путь к PKCS#11 модулю не задан."
|
||||
}
|
||||
if c.LicenseKey == "" {
|
||||
return "Провайдер " + c.Provider + ", JCP есть, лицензия не введена."
|
||||
}
|
||||
return "Провайдер " + c.Provider + ", JCP подключён, лицензия введена."
|
||||
return "Провайдер " + c.Provider + ", PKCS#11 модуль: " + c.ModulePath
|
||||
}
|
||||
|
||||
func nsdMsg(n NSDSettings) string {
|
||||
|
||||
@@ -9,9 +9,23 @@ import (
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock"
|
||||
)
|
||||
|
||||
// igwClientAdapter адаптирует igw.Client под узкий nsdadapter.IGWClient:
|
||||
// разворачивает (channel, since, type) в igw.ListFilter.
|
||||
type igwClientAdapter struct{ c *igw.Client }
|
||||
|
||||
func (a igwClientAdapter) SendPackage(ctx context.Context, channel, packageType string, body []byte) (string, error) {
|
||||
return a.c.SendPackage(ctx, channel, packageType, body)
|
||||
}
|
||||
|
||||
func (a igwClientAdapter) ListIncoming(ctx context.Context, channel string, since time.Time, packageType string) ([]igw.Package, error) {
|
||||
return a.c.ListIncoming(ctx, igw.ListFilter{Channel: channel, Date: since, Type: packageType})
|
||||
}
|
||||
|
||||
// ServerConfig — конфигурация HTTP-сервера lk-gateway.
|
||||
type ServerConfig struct {
|
||||
Addr string
|
||||
@@ -31,6 +45,12 @@ type Server struct {
|
||||
rc *RuntimeConfig
|
||||
mux *http.ServeMux
|
||||
server *http.Server
|
||||
|
||||
// igwClient/igwChannel заполнены только в реальном режиме (ИШ настроен).
|
||||
// На них работает поллер входящих pollIncoming — забирает ответы НРД
|
||||
// (M2MTransferDecision/Response) и применяет через svc.ApplyDecision.
|
||||
igwClient *igw.Client
|
||||
igwChannel string
|
||||
}
|
||||
|
||||
// NewServer собирает Server с репозиторием, mock NSDSender, SeedStore
|
||||
@@ -45,13 +65,44 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
if cfg.MockDecisionDelay > 0 {
|
||||
mockCfg.DecisionDelay = cfg.MockDecisionDelay
|
||||
}
|
||||
sender := mock.NewSender(mockCfg)
|
||||
|
||||
rc, err := NewRuntimeConfig(cfg.SetupPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Выбор NSD-сендера: если в runtime-конфиге задан профиль ИШ и URL —
|
||||
// используем реальный nsdadapter поверх REST ИШ; иначе mock-эмуляция.
|
||||
// mockSender остаётся не-nil только в mock-режиме — на нём висит
|
||||
// consumeDecisions (реальные Decision приходят поллером входящих ИШ).
|
||||
var sender m2mcore.NSDSender
|
||||
var mockSender *mock.Sender
|
||||
var igwClient *igw.Client
|
||||
var igwChannel string
|
||||
{
|
||||
s := rc.Snapshot()
|
||||
if s.NSD.IGWBaseURL != "" && s.NSD.Profile != "" {
|
||||
prof, perr := nsdadapter.LookupProfile(s.NSD.Profile)
|
||||
if perr != nil {
|
||||
log.Printf("lk-gateway: профиль ИШ %q неизвестен (%v) — fallback mock", s.NSD.Profile, perr)
|
||||
} else {
|
||||
prof.IGWBaseURL = s.NSD.IGWBaseURL // override URL из setup.json
|
||||
cl := igw.NewClient(s.NSD.IGWBaseURL)
|
||||
sender = nsdadapter.NewSender(prof, igwClientAdapter{c: cl})
|
||||
igwClient = cl
|
||||
// Канал ИШ резолвится по составному коду <канал>+<депонент>.
|
||||
igwChannel = prof.Channel + string(cfg.DefaultSender)
|
||||
log.Printf("lk-gateway: реальный ИШ-адаптер — профиль %s, канал %s, ИШ %s",
|
||||
prof.Name, igwChannel, s.NSD.IGWBaseURL)
|
||||
}
|
||||
}
|
||||
if sender == nil {
|
||||
mockSender = mock.NewSender(mockCfg)
|
||||
sender = mockSender
|
||||
log.Printf("lk-gateway: NSD mock-режим (Decision через эмуляцию)")
|
||||
}
|
||||
}
|
||||
|
||||
// Repository: pgx если DSN указан, иначе in-memory.
|
||||
var repo m2mcore.Repository = m2mcore.NewMemoryRepository()
|
||||
if dsn := rc.Snapshot().Postgres.DSN; dsn != "" {
|
||||
@@ -115,12 +166,14 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
registerSeedListing(mux, store)
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
svc: svc,
|
||||
mock: sender,
|
||||
store: store,
|
||||
rc: rc,
|
||||
mux: mux,
|
||||
cfg: cfg,
|
||||
svc: svc,
|
||||
mock: mockSender,
|
||||
store: store,
|
||||
rc: rc,
|
||||
mux: mux,
|
||||
igwClient: igwClient,
|
||||
igwChannel: igwChannel,
|
||||
server: &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: mux,
|
||||
@@ -159,6 +212,15 @@ func (s *Server) Mux() http.Handler { return s.mux }
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
go s.consumeDecisions(ctx)
|
||||
|
||||
// Поллер входящих от НРД (только в реальном режиме ИШ): забирает
|
||||
// ответы робота/контрагента и применяет их через ApplyDecision.
|
||||
if s.igwClient != nil && s.igwChannel != "" {
|
||||
go s.pollIncoming(ctx)
|
||||
}
|
||||
|
||||
// Фоновая авто-проверка обновлений из артефактории (если включена).
|
||||
go NewUpdater(s.rc).updateLoop(ctx)
|
||||
|
||||
// Авто-обновление сертификатов УЦ раз в сутки (если оператор включил).
|
||||
stopCACerts := StartCACertsAutoUpdater(s.rc)
|
||||
defer stopCACerts()
|
||||
@@ -193,6 +255,12 @@ func (s *Server) Run(ctx context.Context) error {
|
||||
|
||||
// consumeDecisions слушает Decisions от mock и обновляет соответствующие сделки.
|
||||
func (s *Server) consumeDecisions(ctx context.Context) {
|
||||
if s.mock == nil {
|
||||
// Реальный ИШ-режим: Decision приходят не из mock-канала, а через
|
||||
// поллер входящих пакетов ИШ (отдельный механизм). Здесь нечего слушать.
|
||||
<-ctx.Done()
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -104,11 +104,16 @@ func TestAdminHome(t *testing.T) {
|
||||
t.Fatalf("admin home code=%d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "lk-gateway") {
|
||||
t.Errorf("в дашборде нет заголовка lk-gateway")
|
||||
// Редизайн #26: оператор-дашборд в стиле Apple — бренд Bridge&Join,
|
||||
// приветствие-hero и крупные плитки задач.
|
||||
if !strings.Contains(body, "Bridge") {
|
||||
t.Errorf("в дашборде нет бренда Bridge&Join")
|
||||
}
|
||||
if !strings.Contains(body, "Состояние системы") {
|
||||
t.Errorf("в дашборде нет блока статуса")
|
||||
if !strings.Contains(body, "Добрый день") {
|
||||
t.Errorf("в дашборде нет hero-приветствия")
|
||||
}
|
||||
if !strings.Contains(body, "Диагностика") {
|
||||
t.Errorf("в дашборде нет плитки задач")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,8 +125,8 @@ func TestAdminStatus(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status code=%d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "postgres") {
|
||||
t.Errorf("в статусе нет проверки postgres")
|
||||
if !strings.Contains(w.Body.String(), "PostgreSQL") {
|
||||
t.Errorf("в статусе нет проверки PostgreSQL")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
|
||||
)
|
||||
|
||||
// Service — бизнес-логика lk-gateway: преобразует DTO в доменные сущности
|
||||
@@ -67,9 +68,13 @@ func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (Creat
|
||||
return CreateClaimResponse{}, fmt.Errorf("lkgateway: dtoToClaim: %w", err)
|
||||
}
|
||||
|
||||
receiver := s.defaultReceiver
|
||||
if in.ReceiverCodeOverride != "" {
|
||||
receiver = m2m.DeponentCode(in.ReceiverCodeOverride)
|
||||
}
|
||||
req, err := m2mcore.EnrichRequest(ctx, s.store, domainClaim, m2mcore.SenderReceiver{
|
||||
SenderCode: s.defaultSender,
|
||||
ReceiverCode: s.defaultReceiver,
|
||||
ReceiverCode: receiver,
|
||||
})
|
||||
if err != nil {
|
||||
return CreateClaimResponse{}, fmt.Errorf("lkgateway: EnrichRequest: %w", err)
|
||||
@@ -163,6 +168,80 @@ func (s *Service) ApplyDecision(ctx context.Context, decision *m2m.M2MTransferDe
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyServiceResponse применяет M2MTransferResponse (ответ сервиса МОСТ) к
|
||||
// сделке: сохраняет ответ, при ERROR переводит сделку в Rejected и шлёт
|
||||
// callback в ЛК. Сделку ищем по GUID ответа.
|
||||
func (s *Service) ApplyServiceResponse(ctx context.Context, resp *m2m.M2MTransferResponse, raw []byte) error {
|
||||
if resp == nil {
|
||||
return errors.New("lkgateway: ApplyServiceResponse: resp=nil")
|
||||
}
|
||||
deal, err := s.findDealForResponse(ctx, resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lkgateway: поиск сделки для ответа: %w", err)
|
||||
}
|
||||
prev := deal.State
|
||||
if err := deal.ReceiveServiceResponse(ctx, resp, raw); err != nil {
|
||||
return fmt.Errorf("lkgateway: ReceiveServiceResponse: %w", err)
|
||||
}
|
||||
if err := s.repo.Update(ctx, deal); err != nil {
|
||||
return fmt.Errorf("lkgateway: repo.Update: %w", err)
|
||||
}
|
||||
// Состояние сменилось (ERROR → Rejected) — учитываем в метриках и шлём
|
||||
// callback. На INFO состояние не меняется — callback не нужен.
|
||||
if deal.State != prev {
|
||||
s.recorder.IncDeal(deal.State)
|
||||
if s.callbackURL != "" {
|
||||
s.sendCallback(ctx, deal)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// zeroGUID — нулевой UUID, который НРД присылает в сервисных ошибках
|
||||
// (напр. M2M14), когда не идентифицирует исходный запрос.
|
||||
const zeroGUID = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
// findDealForResponse сопоставляет ответ МОСТ со сделкой. Сначала по GUID;
|
||||
// если GUID нулевой/пустой или сделка по нему не найдена (реальное поведение
|
||||
// НРД при M2M14 — ответ без нашего GUID и без ReferenceID), применяем
|
||||
// эвристику: берём самую раннюю ожидающую решение заявку без ответа. Для
|
||||
// тестового сценария «одна заявка в полёте» это однозначно; при множестве
|
||||
// заявок in-flight такие сервисные ошибки в принципе неразличимы на стороне
|
||||
// НРД, и FIFO — наилучшее доступное приближение.
|
||||
func (s *Service) findDealForResponse(ctx context.Context, resp *m2m.M2MTransferResponse) (*m2mcore.Deal, error) {
|
||||
guid := string(resp.GUID)
|
||||
if guid != "" && guid != zeroGUID {
|
||||
deal, err := s.repo.GetByGUID(ctx, resp.GUID)
|
||||
if err == nil {
|
||||
return deal, nil
|
||||
}
|
||||
if !errors.Is(err, m2mcore.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Fallback: ответ без идентифицируемого GUID.
|
||||
st := m2mcore.StateAwaitingDecision
|
||||
deals, err := s.repo.List(ctx, m2mcore.Filter{State: &st, Limit: 200})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cand *m2mcore.Deal
|
||||
for _, d := range deals {
|
||||
if d.Response != nil {
|
||||
continue // этой заявке ответ уже сопоставлен
|
||||
}
|
||||
if cand == nil || d.CreatedAt.Before(cand.CreatedAt) {
|
||||
cand = d
|
||||
}
|
||||
}
|
||||
if cand == nil {
|
||||
return nil, m2mcore.ErrNotFound
|
||||
}
|
||||
log.Printf("lkgateway: ответ МОСТ с GUID=%s сопоставлен по эвристике (FIFO) заявке id=%s status=%s",
|
||||
guid, cand.ID, resp.StatusCode)
|
||||
return cand, nil
|
||||
}
|
||||
|
||||
// sendCallback отправляет PATCH в ЛК с обновлением статуса.
|
||||
func (s *Service) sendCallback(ctx context.Context, deal *m2mcore.Deal) {
|
||||
cb := callbackForDeal(deal)
|
||||
@@ -185,6 +264,14 @@ func dtoToClaim(in CreateClaimRequest) (m2mcore.ClaimInput, error) {
|
||||
TransferringDepositoryINN: m2m.OrganizationINN(in.TransferringDepositoryINN),
|
||||
ReceivingDepositoryINN: m2m.OrganizationINN(in.ReceivingDepositoryINN),
|
||||
}
|
||||
// Переопределение документа инвестора (тест с роботом: серия = сценарий).
|
||||
if d := in.InvestorDocumentOverride; d != nil {
|
||||
out.InvestorDocument = &m2mcore.ClientDocument{
|
||||
DocumentType: m2m.IdentityDocumentCode(d.DocumentType),
|
||||
Series: d.Series,
|
||||
Number: d.Number,
|
||||
}
|
||||
}
|
||||
// CostInfo
|
||||
if in.CostInfo.Yes != nil {
|
||||
out.CostInfo = m2m.CostInfo{Yes: &m2m.CostInfoYes{Code: m2m.DeponentCode(in.CostInfo.Yes.Code)}}
|
||||
@@ -304,6 +391,10 @@ func dealToView(d *m2mcore.Deal) ClaimView {
|
||||
}
|
||||
if d.Response != nil {
|
||||
out.M2MResponse = responseToView(d.Response)
|
||||
if len(d.RawResponse) > 0 {
|
||||
// Ответ НРД в windows-1251 — декодируем в UTF-8 для показа.
|
||||
out.M2MResponse.RawXML = string(nsdxml.DecodeWindows1251(d.RawResponse))
|
||||
}
|
||||
}
|
||||
if d.Decision != nil {
|
||||
out.M2MDecision = decisionToView(d.Decision)
|
||||
|
||||
+620
-269
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,13 @@ type CreateClaimRequest struct {
|
||||
Securities []ClaimSec `json:"securities"`
|
||||
SignedDocument string `json:"signed_document"`
|
||||
SignatureFormat string `json:"signature_format"`
|
||||
// ReceiverCodeOverride — если задан, переопределяет код получателя
|
||||
// (Header.ReceiverCode). Используется для тестовых пакетов роботу НРД
|
||||
// (MC0012500000). Пусто = берётся defaultReceiver.
|
||||
ReceiverCodeOverride string `json:"receiver_code_override,omitempty"`
|
||||
// InvestorDocumentOverride — если задан, переопределяет документ инвестора
|
||||
// из анкеты. Используется тестом с роботом НРД (серия ДУЛ = код сценария).
|
||||
InvestorDocumentOverride *Document `json:"investor_document_override,omitempty"`
|
||||
}
|
||||
|
||||
// Investor — анкета.
|
||||
@@ -159,6 +166,9 @@ type NSDResponseView struct {
|
||||
GUID string `json:"guid"`
|
||||
StatusCode string `json:"status_code"`
|
||||
Responses []NSDResponseEntry `json:"responses"`
|
||||
// RawXML — точные байты ответа МОСТ от НРД (декодированные в UTF-8 для
|
||||
// показа). Для дословной пересылки в техподдержку НРД.
|
||||
RawXML string `json:"raw_xml,omitempty"`
|
||||
}
|
||||
|
||||
// NSDResponseEntry — одна запись Response.
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
||||
)
|
||||
|
||||
// BuildVersion — версия bj-server. Переопределяется при сборке:
|
||||
//
|
||||
// go build -ldflags "-X .../lkgateway.BuildVersion=1.2.0"
|
||||
var BuildVersion = "0.1.0"
|
||||
|
||||
// DefaultUpdatePublicKey — публичный ключ артефактории, зашитый в релиз.
|
||||
// Пустой в исходниках; подставляется при официальной сборке. Если задан в
|
||||
// настройках (UpdateSettings.PublicKey) — приоритет у настроек.
|
||||
var DefaultUpdatePublicKey = ""
|
||||
|
||||
// installPaths — куда устанавливать артефакты по логическому имени.
|
||||
// Файлы не из этого списка при авто-обновлении пропускаются (скрипты/SQL
|
||||
// обновляются отдельно, не на лету).
|
||||
var installPaths = map[string]string{
|
||||
"bj-server": "/opt/bj/bj-server",
|
||||
"crypto-service.jar": "/opt/bj/crypto-service.jar",
|
||||
}
|
||||
|
||||
// Updater — авто-обновление bj-server из артефактории (работает поверх rc).
|
||||
type Updater struct{ rc *RuntimeConfig }
|
||||
|
||||
// NewUpdater создаёт Updater на текущем runtime-конфиге.
|
||||
func NewUpdater(rc *RuntimeConfig) *Updater { return &Updater{rc: rc} }
|
||||
|
||||
// UpdateStatus — сводка для UI/handler.
|
||||
type UpdateStatus struct {
|
||||
Configured bool
|
||||
CurrentVersion string
|
||||
Available string
|
||||
HasUpdate bool
|
||||
Channel string
|
||||
Notes string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (u *Updater) updateClient() (*release.Client, error) {
|
||||
cfg := u.rc.Snapshot().Update
|
||||
pub := cfg.PublicKey
|
||||
if pub == "" {
|
||||
pub = DefaultUpdatePublicKey
|
||||
}
|
||||
if cfg.BaseURL == "" || pub == "" {
|
||||
return nil, fmt.Errorf("обновления не настроены (нужны URL артефактории и публичный ключ)")
|
||||
}
|
||||
channel := cfg.Channel
|
||||
if channel == "" {
|
||||
channel = "stable"
|
||||
}
|
||||
return release.NewClient(cfg.BaseURL, channel, pub)
|
||||
}
|
||||
|
||||
// CheckForUpdate скачивает манифест, проверяет подпись, сравнивает версии и
|
||||
// сохраняет результат в настройки. Возвращает сводку.
|
||||
func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
|
||||
st := UpdateStatus{CurrentVersion: BuildVersion, Channel: u.rc.Snapshot().Update.Channel}
|
||||
cl, err := u.updateClient()
|
||||
if err != nil {
|
||||
st.Message = err.Error()
|
||||
return st, nil // не настроено — не ошибка
|
||||
}
|
||||
st.Configured = true
|
||||
|
||||
m, err := cl.FetchManifest(ctx)
|
||||
if err != nil {
|
||||
st.Message = "проверка не удалась: " + err.Error()
|
||||
u.saveCheckResult(st)
|
||||
return st, err
|
||||
}
|
||||
st.Available = m.Version
|
||||
st.Notes = m.Notes
|
||||
st.HasUpdate = release.IsNewer(m.Version, BuildVersion)
|
||||
if st.HasUpdate {
|
||||
st.Message = fmt.Sprintf("доступна версия %s (текущая %s)", m.Version, BuildVersion)
|
||||
} else {
|
||||
st.Message = "установлена актуальная версия " + BuildVersion
|
||||
}
|
||||
u.saveCheckResult(st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (u *Updater) saveCheckResult(st UpdateStatus) {
|
||||
cfg := u.rc.Snapshot().Update
|
||||
cfg.LastCheck = time.Now().UTC()
|
||||
cfg.LastResult = st.Message
|
||||
cfg.Available = st.Available
|
||||
cfg.Notes = st.Notes
|
||||
if err := u.rc.SaveUpdateSettings(cfg); err != nil {
|
||||
log.Printf("lk-gateway: сохранение результата проверки обновления: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyUpdate скачивает обновлённые артефакты (с проверкой подписи манифеста
|
||||
// и sha256 каждого файла), атомарно заменяет бинари и завершает процесс с
|
||||
// ненулевым кодом — systemd (Restart=on-failure) поднимает новую версию.
|
||||
func (u *Updater) ApplyUpdate(ctx context.Context) error {
|
||||
// Гейт лицензией: если лицензирование включено — требуется валидная
|
||||
// лицензия с фичей updates. Без лицензирования (открытый режим) — пропускаем.
|
||||
if licensingEnabled(u.rc) {
|
||||
ls := licenseStatus(u.rc)
|
||||
if !ls.Valid {
|
||||
return fmt.Errorf("обновления заблокированы — лицензия: %s", ls.Message)
|
||||
}
|
||||
if !ls.AllowsUpdates {
|
||||
return fmt.Errorf("обновления не входят в план %q", ls.Plan)
|
||||
}
|
||||
}
|
||||
cl, err := u.updateClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := cl.FetchManifest(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("манифест: %w", err)
|
||||
}
|
||||
if !release.IsNewer(m.Version, BuildVersion) {
|
||||
return fmt.Errorf("обновление не требуется (текущая %s, доступна %s)", BuildVersion, m.Version)
|
||||
}
|
||||
|
||||
updated := 0
|
||||
for _, a := range m.Artifacts {
|
||||
dst, ok := installPaths[a.Name]
|
||||
if !ok {
|
||||
continue // скрипты/SQL не обновляем на лету
|
||||
}
|
||||
dir := dirOf(dst)
|
||||
path, err := cl.DownloadArtifact(ctx, a, dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("скачивание %s: %w", a.Name, err)
|
||||
}
|
||||
// DownloadArtifact кладёт файл под именем a.File; если целевое имя
|
||||
// иное — переименуем атомарно.
|
||||
if path != dst {
|
||||
if err := os.Rename(path, dst); err != nil {
|
||||
return fmt.Errorf("установка %s: %w", a.Name, err)
|
||||
}
|
||||
}
|
||||
log.Printf("lk-gateway: обновлён %s → %s (%s)", a.Name, dst, m.Version)
|
||||
updated++
|
||||
}
|
||||
if updated == 0 {
|
||||
return fmt.Errorf("в манифесте %s нет обновляемых бинарей", m.Version)
|
||||
}
|
||||
|
||||
log.Printf("lk-gateway: обновление до %s применено (%d файлов), перезапуск через systemd…", m.Version, updated)
|
||||
// Завершаемся с ненулевым кодом — systemd Restart=on-failure поднимет
|
||||
// новый бинарь. Даём пару секунд на флаш логов/ответа.
|
||||
go func() {
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
os.Exit(42)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateLoop — фоновая авто-проверка обновлений (если включена).
|
||||
func (u *Updater) updateLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(6 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
check := func() {
|
||||
if !u.rc.Snapshot().Update.AutoCheck {
|
||||
return
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
if st, err := u.CheckForUpdate(cctx); err == nil && st.HasUpdate {
|
||||
log.Printf("lk-gateway: доступно обновление %s (текущая %s)", st.Available, st.CurrentVersion)
|
||||
}
|
||||
}
|
||||
// первая проверка через минуту после старта
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Minute):
|
||||
check()
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
check()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dirOf(path string) string {
|
||||
for i := len(path) - 1; i >= 0; i-- {
|
||||
if path[i] == '/' {
|
||||
return path[:i]
|
||||
}
|
||||
}
|
||||
return "."
|
||||
}
|
||||
@@ -51,16 +51,51 @@
|
||||
|
||||
{{if .Claim.M2MResponse}}
|
||||
<div class="card">
|
||||
<h2>Ответ НРД (M2MTransferResponse)</h2>
|
||||
<p class="muted">GUID <code>{{.Claim.M2MResponse.GUID}}</code> · Status <code>{{.Claim.M2MResponse.StatusCode}}</code></p>
|
||||
<h2>Ответ сервиса МОСТ (M2MTransferResponse)</h2>
|
||||
<p>
|
||||
{{if eq .Claim.M2MResponse.StatusCode "ERROR"}}
|
||||
<span class="badge err">● ERROR — заявка отклонена сервисом НРД</span>
|
||||
{{else}}
|
||||
<span class="badge ok">● {{.Claim.M2MResponse.StatusCode}} — принято в обработку</span>
|
||||
{{end}}
|
||||
</p>
|
||||
<p class="muted">GUID <code>{{.Claim.M2MResponse.GUID}}</code></p>
|
||||
<table>
|
||||
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст</th></tr></thead>
|
||||
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст ответа НРД</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Claim.M2MResponse.Responses}}
|
||||
<tr><td><code>{{.ReferenceID}}</code></td><td>{{.Code}}</td><td>{{.Text}}</td></tr>
|
||||
<tr><td><code>{{.ReferenceID}}</code></td><td><code>{{.Code}}</code></td><td>{{.Text}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{if eq .Claim.M2MResponse.StatusCode "ERROR"}}
|
||||
<p class="muted" style="margin-top:10px">
|
||||
Это отказ на сервисном уровне — запрос не дошёл до контрагента. Решение
|
||||
(M2MTransferDecision) по такой заявке не придёт. Устраните причину по коду
|
||||
выше и отправьте новую заявку.
|
||||
</p>
|
||||
{{end}}
|
||||
{{if .Claim.M2MResponse.RawXML}}
|
||||
<details style="margin-top:12px">
|
||||
<summary style="cursor:pointer;font-weight:600">
|
||||
Сырой ответ НРД (для техподдержки M2MOST@nsd.ru)
|
||||
</summary>
|
||||
<p class="muted" style="margin:8px 0">
|
||||
Точные байты ответа сервиса МОСТ. Можно дословно переслать в поддержку НРД.
|
||||
</p>
|
||||
<button type="button" class="btn" onclick="copyRaw(this)">Скопировать</button>
|
||||
<pre id="raw-response" style="white-space:pre-wrap;word-break:break-all;background:var(--surface-2,#f5f5f7);padding:12px;border-radius:8px;font-size:12px;overflow:auto;max-height:340px">{{.Claim.M2MResponse.RawXML}}</pre>
|
||||
</details>
|
||||
<script>
|
||||
function copyRaw(btn){
|
||||
var t=document.getElementById('raw-response').innerText;
|
||||
navigator.clipboard.writeText(t).then(function(){
|
||||
var o=btn.textContent; btn.textContent='Скопировано ✓';
|
||||
setTimeout(function(){btn.textContent=o;},1500);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
<p class="muted">REST-контракт ESIA Finance V1: <code>POST /api/v1/back_office/claims/</code>, GET/PATCH-операции, формат callback'ов, аутентификация Basic, примеры запросов curl.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/help/cryptopro" style="text-decoration:none">
|
||||
<a href="/admin/help/crypto" style="text-decoration:none">
|
||||
<div class="card" style="height:100%">
|
||||
<h2 style="color:var(--accent)">КриптоПро и Рутокен →</h2>
|
||||
<p class="muted">Установка КриптоПро CSP на РЕД ОС / Ubuntu, ввод серийного номера, PKCS#11 модуль, серверная подпись и подпись оператора через Рутокен ЭЦП 2.0, тестирование.</p>
|
||||
<h2 style="color:var(--accent)">Криптография (Валидата) →</h2>
|
||||
<p class="muted">Установка АПК «Валидата Клиент L» на Astra Linux SE, подключение через PKCS#11, тестирование подписи и проверки квитанций НРД.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/help/systems" style="text-decoration:none">
|
||||
@@ -38,7 +38,7 @@
|
||||
<a href="/admin/help/architecture" style="text-decoration:none">
|
||||
<div class="card" style="height:100%">
|
||||
<h2 style="color:var(--accent)">Архитектура обмена с НРД →</h2>
|
||||
<p class="muted">Полная схема: bj-server → ИШ (на Astra Linux ВМ) → ONYX (НРД) → робот-автотест. Кто на чьей стороне, какие СКЗИ, какие сертификаты, FAQ. Куда воткнуть Валидату, куда КриптоПро, где сертификаты УЦ МБ.</p>
|
||||
<p class="muted">Полная схема: bj-server → ИШ (на Astra Linux ВМ) → ONYX (НРД) → робот-автотест. Кто на чьей стороне, какое СКЗИ, какие сертификаты, FAQ.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
│ │ (history) │ │
|
||||
│ └──────┬───────────┘ │
|
||||
│ │ │
|
||||
│ КриптоПро CSP — для нашей │ Валидата CSP │
|
||||
│ admin-стороны (PKCS#11) │ + АПК Валидата │
|
||||
│ │ Клиент L │
|
||||
│ АПК «Валидата Клиент L» │ АПК «Валидата │
|
||||
│ (PKCS#11) — общий для всех │ Клиент L» │
|
||||
│ компонентов │ │
|
||||
└──────────────────────────────────────────────┼─────────────────┘
|
||||
│
|
||||
SOAP/REST/HTTPS │ Web-сервис ONYX
|
||||
@@ -62,15 +62,15 @@
|
||||
<tr>
|
||||
<td><strong>bj-server</strong></td>
|
||||
<td>наша</td>
|
||||
<td>РЕД ОС 8 / Linux</td>
|
||||
<td>КриптоПро CSP (PKCS#11) — для админ-части</td>
|
||||
<td>Astra Linux SE 1.7 / Linux</td>
|
||||
<td>АПК «Валидата Клиент L» (PKCS#11)</td>
|
||||
<td>Стейт-машина, журнал в БД, веб-админка, lk-emulator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>ИШ (igate)</strong></td>
|
||||
<td>наша <span class="muted">(но дистрибутив даёт НРД)</span></td>
|
||||
<td>Astra Linux SE 1.6/1.7 <em>или</em> Windows 10/Server</td>
|
||||
<td>Валидата CSP + АПК Валидата Клиент L</td>
|
||||
<td>Astra Linux SE 1.6/1.7</td>
|
||||
<td>АПК «Валидата Клиент L»</td>
|
||||
<td>Подписывает наш XML сертификатом УЦ МБ, упаковывает в пакет ЭДО, отправляет в НРД</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -98,16 +98,13 @@
|
||||
<p>Нет. <strong>ИШ — это наша программа, поставленная у нас.</strong> НРД даёт дистрибутив (<code>igate_100.0-765_amd64.deb</code>, 117 МБ), но ставим у себя. ИШ — это «персональный почтовый клиент к НРД» с подписью.</p>
|
||||
|
||||
<h3>Q: ИШ можно поставить на ту же ВМ, что и bj-server?</h3>
|
||||
<p>Технически да (если та ВМ — Astra Linux). Но у нас bj-server на РЕД ОС, а ИШ требует <strong>Astra Linux</strong> (RPM-версии нет). Поэтому нужно либо: (а) отдельная Astra Linux ВМ, (б) запуск ИШ в Docker-контейнере с Astra-образом, (в) перевод всей инфры на Astra Linux.</p>
|
||||
<p>Да, если ВМ — Astra Linux. И bj-server, и ИШ работают на Astra Linux SE 1.6/1.7 и используют одно и то же СКЗИ — АПК «Валидата Клиент L». Можно собрать всё на одной ВМ или разнести по отдельным.</p>
|
||||
|
||||
<h3>Q: Мы перекладываем файлы между bj-server и ИШ?</h3>
|
||||
<p>Нет. Мы используем <strong>REST API</strong> ИШ (раздел 2.5 инструкции). bj-server делает HTTP-запросы: <code>POST /api/package/{channel}/file</code> с ZIP в теле. Никаких разделяемых папок. (Альтернативный режим «обменные папки» в ИШ есть — мы его не используем.)</p>
|
||||
|
||||
<h3>Q: Почему ИШ требует Валидата CSP, а мы поставили КриптоПро?</h3>
|
||||
<p>ИШ — отечественная разработка НРД, исторически работает с Валидатой (продукт ООО «Валидата», <code>x509.ru</code>). КриптоПро CSP на нашей ВМ останется — он используется для админ-части bj-server (подпись действий оператора через Рутокен). Валидату надо поставить <strong>на Astra Linux ВМ рядом с ИШ</strong>, не вместо КриптоПро.</p>
|
||||
|
||||
<h3>Q: Где брать Валидату?</h3>
|
||||
<p>Не публично. По запросу: email <code>soed@nsd.ru</code> (НРД) или <code>pki@moex.com</code> (МБ). Временная лицензия выдаётся бесплатно для подключения к ЭДО НРД.</p>
|
||||
<p>Дистрибутив для Astra Linux SE опубликован на сайте Московской Биржи: <a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank" rel="noopener">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. На Linux отдельной лицензии и регистрационных данных не требует — пакеты <code>zpki</code>/<code>zsdk</code> ставятся через <code>dpkg -i</code> и работают сразу.</p>
|
||||
|
||||
<h3>Q: Какой сертификат нужен?</h3>
|
||||
<p>Только от <strong>УЦ Московской Биржи</strong> (<code>ca.moex.com</code>). Сертификаты других УЦ ИШ не примет. УЦ МБ выпускает сертификаты только для организаций, подключённых к ЭДО НРД (по договору).</p>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h2>Криптография (АПК «Валидата Клиент L»)</h2>
|
||||
<p class="muted">bj-server общается с СКЗИ «Валидата Клиент L» через сайдкар <code>bj-crypto</code> по UDS <code>/run/bj/crypto.sock</code>. Чтобы подпись и проверка квитанций НРД заработали, нужен <strong>ключевой профиль</strong> — папка с тремя сущностями: <code>local.pse</code> (зашифрованный контейнер), <code>local.gdbm</code> (база сертификатов) и <code>vdkeys/*.vdk</code> (сам ключ).</p>
|
||||
<p class="muted"><strong>Архив от MOEX/НРД содержит «резервную копию», а не готовый профиль.</strong> На Linux рабочий <code>local.gdbm</code> нельзя восстановить headless — Валидата Linux требует GUI-операции «Восстановить справочники из резервной копии». Поэтому профиль готовится один раз на Windows и переносится на сервер через USB.</p>
|
||||
</div>
|
||||
|
||||
<div class="card" style="border-left:4px solid var(--accent)">
|
||||
<h2>Почему профиль готовится на Windows, а не на сервере</h2>
|
||||
<p>Боевой Astra Linux SE-сервер с ГОСТ-криптографией <strong>обязан быть headless</strong>: чем меньше пакетов и поверхности атаки, тем проще сертификация ФСТЭК и тем меньше нарушений требований к контуру ЭДО НРД. Установка GUI (X-сервер, GTK, шрифты, VNC/RDP) тянет 50+ пакетов, расширяет surface attack и усложняет аудит — поэтому отказались.</p>
|
||||
<p>Это <strong>стандартная практика</strong> в фин-секторе: на admin-станции (под Windows или отдельной защищённой ВМ) генерируются и обновляются профили; на боевые серверы они доставляются готовыми через выделенный USB или защищённый канал. Все инструкции MOEX/НРД написаны именно под Windows — этот путь поддерживается официально.</p>
|
||||
<p class="muted">Альтернативный путь — Linux GUI через X11-forwarding или VNC на дев-стенды — допустим только в песочнице, не в проде. На боевых серверах <code>zcs</code>/<code>vdcsp_cfg</code> не должны запускаться.</p>
|
||||
</div>
|
||||
|
||||
<div class="card" style="border-left:4px solid var(--ok)">
|
||||
<h2>✅ Подготовка профиля (Windows → USB → bj-server)</h2>
|
||||
|
||||
<h3 style="margin-top:14px">Шаг A — на компьютере под Windows</h3>
|
||||
<ol>
|
||||
<li><strong>Установите СКЗИ Валидата CSP для Windows</strong>.<br>
|
||||
Скачайте дистрибутив с <a href="https://www.moex.com/s1292" target="_blank" rel="noopener">moex.com/s1292</a> (раздел «СКЗИ для Windows», файл «Валидата CSP v.6.0.482.0 64bit»). Внутри архива есть <code>Readme.txt</code> с регистрационными данными — введите их во время установки.
|
||||
</li>
|
||||
|
||||
<li><strong>Распакуйте архив-профиль от MOEX/НРД</strong>.<br>
|
||||
Например <code>PrUser985.7z</code> с паролем <code>11</code> в папку <code>C:\moex-src\</code>. Получится структура:
|
||||
<pre style="font-size:12px">C:\moex-src\
|
||||
spr985\
|
||||
local.pse
|
||||
local.gdbm ← это «резервная копия», на Linux не работает напрямую
|
||||
vdkeys\
|
||||
XXXXXXXXXXXXXXXX.vdk
|
||||
key.reg</pre>
|
||||
</li>
|
||||
|
||||
<li><strong>Зарегистрируйте ключ в системе Windows</strong>.<br>
|
||||
Двойной клик по <code>key.reg</code> → «Да» на запрос о записи в реестр. Это нужно, чтобы Валидата увидела ключ при восстановлении справочников.
|
||||
</li>
|
||||
|
||||
<li><strong>Откройте «Справочник сертификатов x64»</strong> из меню «Пуск» → «АПК Валидата Клиент».</li>
|
||||
|
||||
<li><strong>Создайте профиль на флешке</strong>:
|
||||
<ul>
|
||||
<li>Вставьте чистую USB-флешку, запомните её букву (например <code>E:</code>).</li>
|
||||
<li>В Справочнике: меню <em>Профили</em> → <em>Настройка профилей</em> → <em>Добавить</em>.</li>
|
||||
<li>Имя профиля: например <code>moex</code>.</li>
|
||||
<li><strong>Каталог профиля</strong>: создайте новую пустую папку <strong>на флешке</strong>, например <code>E:\moex\</code>. Это путь, куда Валидата положит рабочую копию.</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li><strong>Восстановите справочники из резервной копии</strong>:<br>
|
||||
Меню <em>Сервис</em> → <em>Восстановить справочники из резервной копии</em>. В диалоге укажите папку <code>C:\moex-src\spr985\</code>. Дождитесь сообщения «Справочники восстановлены».<br>
|
||||
После этого в <code>E:\moex\</code> появятся <code>local.pse</code> и <strong>рабочий</strong> <code>local.gdbm</code> (отличается от исходной резервной копии).
|
||||
</li>
|
||||
|
||||
<li><strong>Скопируйте папку <code>vdkeys</code> на корень флешки</strong>.<br>
|
||||
Скопируйте папку <code>C:\moex-src\vdkeys\</code> в корень флешки. Итоговая структура:
|
||||
<pre style="font-size:12px">E:\
|
||||
moex\ ← рабочий профиль, созданный Валидатой
|
||||
local.pse
|
||||
local.gdbm ← теперь правильный
|
||||
vdkeys\
|
||||
XXXXXXXXXXXXXXXX.vdk</pre>
|
||||
</li>
|
||||
|
||||
<li><strong>Безопасно извлеките флешку</strong> через значок в системном трее Windows.</li>
|
||||
</ol>
|
||||
|
||||
<h3 style="margin-top:18px">Шаг B — на сервере (этот веб-интерфейс)</h3>
|
||||
<ol>
|
||||
<li><strong>Вставьте флешку в сервер</strong> (физический USB-порт или прокинутая через гипервизор виртуальная флешка).</li>
|
||||
|
||||
<li>Откройте <a href="/admin/setup">/admin/setup</a>. Через 2-3 секунды (автодетект монтирования) в блоке <strong>«Носители ключей»</strong> появится строка <code>🔌 USB /run/media/...</code>. Внутри неё — сабблок <strong>«Профиль Валидаты»</strong> с тремя строками: <code>local.pse</code> / <code>local.gdbm</code> / <code>*.vdk</code>.</li>
|
||||
|
||||
<li>В поле <strong>«Имя профиля»</strong> введите осмысленное имя (например <code>moex</code>) и нажмите <strong>«Импортировать профиль в Валидату»</strong>.<br>
|
||||
Сервер скопирует файлы в <code>/var/lib/bj/profiles/<имя>/</code>, допишет секцию в <code>/opt/Validata/VDCSP/etc/pki1.conf</code>. Toast подтвердит: «Секция дописана в pki1.conf».</li>
|
||||
|
||||
<li>В таблице <strong>«Импортированные профили Валидаты»</strong> ниже — нажмите <strong>«Активировать»</strong> в строке вашего профиля.<br>
|
||||
Toast: «Валидата: контекст с профилем <имя> инициализирован» → готово.</li>
|
||||
|
||||
<li>Можно извлекать флешку — все нужные файлы уже скопированы в <code>/var/lib/bj/profiles/</code>.</li>
|
||||
</ol>
|
||||
|
||||
<h3 style="margin-top:18px">Проверка</h3>
|
||||
<ol>
|
||||
<li>В блоке «СКЗИ» нажмите зелёную кнопку <strong>«✓ Проверить подключение СКЗИ»</strong>.</li>
|
||||
<li>Toast должен показать что-то вроде: <code>СКЗИ validata: 0.1.0 (Валидата: контекст с профилем «moex» инициализирован)</code>.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Что делать если профиль на флешке не виден</h2>
|
||||
<ul>
|
||||
<li><strong>USB не монтируется автоматически в Astra Linux SE.</strong> Подключите вручную: посмотрите <code>lsblk</code>, потом <code>sudo mount /dev/sdb1 /mnt</code>. Через секунду «Носители ключей» подхватит точку монтирования.</li>
|
||||
<li><strong>Файлы лежат не в корне флешки.</strong> Сканер ищет в глубину 4 уровня — если поместили в <code>E:\very\deep\folder\moex\</code>, должно тоже найтись.</li>
|
||||
<li><strong>На флешке нет <code>vdkeys\</code>.</strong> Без неё профиль не работает — ключ <code>.vdk</code> обязателен.</li>
|
||||
<li><strong>«Ни контейнеров, ни сертификатов, ни профиля Валидаты не найдено».</strong> Это значит на носителе нет <em>одновременно</em> <code>.pse</code> и <code>.vdk</code> файлов. Перепроверьте Шаг 6-7 на Windows.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Альтернатива: загрузка как ZIP-архив</h2>
|
||||
<p>Если USB-доступ к серверу неудобен — можно собрать содержимое флешки в обычный <code>.zip</code> на Windows и загрузить через web-форму.</p>
|
||||
<ol>
|
||||
<li>После шага A.7 (когда на флешке готовая структура <code>moex\</code> + <code>vdkeys\</code>) — выделите обе папки, правый клик → <em>Отправить</em> → <em>Сжатая ZIP-папка</em>.</li>
|
||||
<li>На сервере: <a href="/admin/setup">/admin/setup</a> → «Носители ключей» → форма «Загрузить образ или архив» → выберите ZIP, поле «Пароль» оставьте пустым.</li>
|
||||
<li>Дальше как в Шаге B со 2-го пункта.</li>
|
||||
</ol>
|
||||
<p class="muted">Под капотом сервер распаковывает архив через <code>7z</code> в <code>/var/lib/bj/media/iso/</code>, сканирует на профиль Валидаты — далее всё то же самое, что с USB.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Справочные команды (диагностика)</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td><code>systemctl status bj-crypto</code></td><td>Состояние Java-сайдкара (UDS-сокет, провайдер).</td></tr>
|
||||
<tr><td><code>sudo journalctl -u bj-crypto -n 50</code></td><td>Последние строки лога сайдкара.</td></tr>
|
||||
<tr><td><code>cat /opt/Validata/VDCSP/etc/pki1.conf</code></td><td>Список профилей, которые видит Валидата (наши секции помечены <code># --- bj-server: профиль ...</code>).</td></tr>
|
||||
<tr><td><code>sudo ls -la /var/lib/bj/profiles/</code></td><td>Импортированные профили на сервере.</td></tr>
|
||||
<tr><td><code>/opt/Validata/VDCSP/bin/amd64/testcsp -silent</code></td><td>Базовая проверка провайдера CSP.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Установка Валидаты на сервер (если её ещё нет)</h2>
|
||||
<p class="muted">Если этот раздел вам не показывает «✓ ready» — повторите установку:</p>
|
||||
<pre>curl -fsSL https://fs.moex.com/cdp/po/ClientL_ALSE.zip -o ClientL_ALSE.zip
|
||||
unzip ClientL_ALSE.zip
|
||||
sudo apt-get install -y libccid pcscd execstack
|
||||
sudo dpkg -i ClientL_ALSE/zpki-*.deb ClientL_ALSE/zsdk-*.deb
|
||||
sudo apt-get -f install -y
|
||||
sudo execstack -c /opt/Validata/VDCSP/lib/amd64/libvdcsp.so
|
||||
sudo systemctl enable --now pcscd</pre>
|
||||
<p class="muted">Дистрибутив для Astra Linux SE — <a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank" rel="noopener">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. Linux-версия отдельной лицензии не требует.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,130 +0,0 @@
|
||||
{{define "content"}}
|
||||
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
|
||||
|
||||
<div class="card">
|
||||
<h2>КриптоПро и Рутокен</h2>
|
||||
<p class="muted">Bridge-and-Join-s использует ГОСТ Р 34.10-2012 для подписи и проверки XMLDSig. Серверная криптография — КриптоПро CSP. Подпись оператора в admin-ui — Рутокен ЭЦП 2.0 (опционально). Оба продукта говорят со стандартным интерфейсом PKCS#11, поэтому Go-клиент общается с ними одинаково.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>1. Что и зачем нужно</h2>
|
||||
<table>
|
||||
<thead><tr><th>Сценарий</th><th>СКЗИ</th><th>Цена (ориентир)</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Проверка XMLDSig входящих от НРД и брокеров</td><td>КриптоПро CSP «Сервер»</td><td>~30-50к ₽ (один раз)</td></tr>
|
||||
<tr><td>Подпись пакетов в НРД (резервный канал WS ONYX)</td><td>КриптоПро CSP «Сервер»</td><td>включено</td></tr>
|
||||
<tr><td>Подпись действий оператора в admin-ui</td><td>Рутокен ЭЦП 2.0 + лицензия CSP «Рабочее место»</td><td>~3-5к ₽ железо + ~2-3к ₽ лицензия</td></tr>
|
||||
<tr><td>Проверка XMLDSig заявлений от ЛК</td><td>КриптоПро CSP «Сервер»</td><td>включено</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">Если используется Интеграционный шлюз НРД (ИШ), он сам подписывает пакеты — наша серверная подпись нужна только для резервного канала ONYX и подписи действий оператора. Можно начать с минимума: только Рутокен оператора и отложить серверную лицензию.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>2. Установка КриптоПро CSP на РЕД ОС (проверено)</h2>
|
||||
<p><strong>Способ 1 — через веб-интерфейс (рекомендуется):</strong> <a href="/admin/setup">/admin/setup</a> → «СКЗИ» → «Установка КриптоПро CSP» → выбрать <code>linux-amd64.tar</code> с cryptopro.ru → «Загрузить и установить».</p>
|
||||
<p><strong>Способ 2 — вручную из терминала.</strong> Скачать <code>linux-amd64.tgz</code> с <code>www.cryptopro.ru/products/csp/downloads</code> (доступ через личный кабинет), распаковать на ВМ и установить минимальный набор:</p>
|
||||
<pre>tar -xzf linux-amd64.tgz
|
||||
cd linux-amd64
|
||||
sudo rpm -Uvh --replacepkgs --nodeps \
|
||||
lsb-cprocsp-base-5.0.*.noarch.rpm \
|
||||
lsb-cprocsp-ca-certs-5.0.*.noarch.rpm \
|
||||
lsb-cprocsp-rdr-64-5.0.*.x86_64.rpm \
|
||||
lsb-cprocsp-capilite-64-5.0.*.x86_64.rpm \
|
||||
lsb-cprocsp-kc1-64-5.0.*.x86_64.rpm \
|
||||
lsb-cprocsp-pkcs11-64-5.0.*.x86_64.rpm \
|
||||
cprocsp-curl-64-5.0.*.x86_64.rpm \
|
||||
cprocsp-rdr-gui-gtk-64-5.0.*.x86_64.rpm</pre>
|
||||
<p>Ключевые пакеты:</p>
|
||||
<ul>
|
||||
<li><code>lsb-cprocsp-base</code> + <code>lsb-cprocsp-rdr-64</code> — базовая инфраструктура</li>
|
||||
<li><code>lsb-cprocsp-capilite-64</code> — CAPILite (<code>libcapi20.so.4</code>, <code>libcpext.so.4</code>) — иначе libcppkcs11.so не загрузится</li>
|
||||
<li><code>lsb-cprocsp-kc1-64</code> — CSP класса КС1 (без него Initialize упадёт с CKR_FUNCTION_FAILED)</li>
|
||||
<li><code>lsb-cprocsp-pkcs11-64</code> — собственно <code>libcppkcs11.so</code></li>
|
||||
</ul>
|
||||
<p>Демо-лицензия на 3 месяца встроена в дистрибутив, отдельная активация не требуется. Проверка:</p>
|
||||
<pre>/opt/cprocsp/sbin/amd64/cpconfig -license -view
|
||||
/opt/cprocsp/bin/amd64/csptest -keyset -enum -unique</pre>
|
||||
<p><strong>Важно — LD_LIBRARY_PATH.</strong> КриптоПро CSP кладёт .so в <code>/opt/cprocsp/lib/amd64</code> без записи в <code>/etc/ld.so.conf.d</code>. Bj-server при запуске должен иметь:</p>
|
||||
<pre>Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64</pre>
|
||||
<p>В systemd-юните это уже прописано (<code>deploy/systemd/bj-server.service</code>). При ручном запуске из shell — <code>LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64 ./bin/bj-server</code>.</p>
|
||||
<p><strong>Активация коммерческой лицензии.</strong> После того как демо истечёт, серийник вводится через UI на <a href="/admin/setup">/admin/setup</a> → «Активация лицензии», или вручную:</p>
|
||||
<pre>sudo /opt/cprocsp/sbin/amd64/cpconfig -license -set XXXX-XXXXX-XXXXX-XXXXX-XXXXX</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>3. Установка на Ubuntu / Debian</h2>
|
||||
<pre>sudo dpkg -i cprocsp-rdr-gui-gtk-64_5.0.*_amd64.deb \
|
||||
cprocsp-rdr-64_5.0.*_amd64.deb \
|
||||
lsb-cprocsp-base_5.0.*_all.deb \
|
||||
lsb-cprocsp-rdr-64_5.0.*_amd64.deb
|
||||
sudo apt-get install -f
|
||||
sudo /opt/cprocsp/sbin/amd64/cpconfig -license -set XXXX-XXXXX-XXXXX-XXXXX-XXXXX</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>4. PKCS#11 модуль</h2>
|
||||
<p>Путь к библиотеке после установки:</p>
|
||||
<pre>/opt/cprocsp/lib/amd64/libcppkcs11.so</pre>
|
||||
<p>Эта же библиотека работает и с CSP-ключами (контейнеры на диске или в реестре), и с Рутокен ЭЦП 2.0 (подключённым по USB или в виде smart-card reader).</p>
|
||||
<p>На <a href="/admin/setup">странице «Настройка»</a> в карточке «Криптография» укажите:</p>
|
||||
<ul>
|
||||
<li><strong>Провайдер</strong>: <code>cryptopro</code></li>
|
||||
<li><strong>UDS-сокет</strong>: <code>/run/bj/crypto.sock</code> (для legacy crypto-service на Java — на M2+ переходим на Go-клиент напрямую через PKCS#11)</li>
|
||||
<li><strong>Путь к jcp.jar / PKCS#11</strong>: <code>/opt/cprocsp/lib/amd64/libcppkcs11.so</code></li>
|
||||
<li><strong>Лицензионный ключ</strong>: серийный номер CSP</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>5. Подключение Рутокен ЭЦП 2.0</h2>
|
||||
<p>Подключите Рутокен в USB. Драйверы КриптоПро CSP уже включают поддержку Рутокен:</p>
|
||||
<pre># увидеть подключённые токены
|
||||
/opt/cprocsp/bin/amd64/csptest -card -enum
|
||||
|
||||
# увидеть ключевые контейнеры на токене
|
||||
/opt/cprocsp/bin/amd64/csptest -keyset -enum -unique</pre>
|
||||
<p>Для подписи действий оператора в admin-ui:</p>
|
||||
<ol>
|
||||
<li>Запросить сертификат на физлицо у УЦ (через личный кабинет КриптоПро или через АРМ оператора УЦ).</li>
|
||||
<li>Записать сертификат и контейнер на Рутокен.</li>
|
||||
<li>На <a href="/admin/setup">странице «Настройка»</a> в карточке «Криптография» выбрать провайдер <code>cryptopro</code> и указать слот Рутокен.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>6. Импорт сертификата</h2>
|
||||
<pre># сертификат корневого УЦ (если ещё нет в системе)
|
||||
/opt/cprocsp/bin/amd64/certmgr -inst -store mroot -file /path/to/root-ca.cer
|
||||
|
||||
# сертификат подписанта (контейнер на токене)
|
||||
/opt/cprocsp/bin/amd64/certmgr -inst -store uMy -cont '\\.\HDIMAGE\my-keys' \
|
||||
-file /path/to/operator.cer
|
||||
|
||||
# проверить установленные сертификаты
|
||||
/opt/cprocsp/bin/amd64/certmgr -list -store uMy</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>7. Тестирование подписи</h2>
|
||||
<p>Через CLI КриптоПро (быстрая проверка что криптография работает):</p>
|
||||
<pre># подписать произвольный файл
|
||||
/opt/cprocsp/bin/amd64/cryptcp -signf -dn 'CN=Иванов И.И.' \
|
||||
-det -strict /tmp/test.txt
|
||||
|
||||
# проверить подпись
|
||||
/opt/cprocsp/bin/amd64/cryptcp -vsignf -det /tmp/test.txt /tmp/test.txt.sgn</pre>
|
||||
<p>Через нашу систему — раздел <a href="/admin/setup">Настройка</a> → кнопка «Запустить тестовую заявку». На странице «Заявка» появится результат и расшифровка проверки подписи.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>8. Поддержка</h2>
|
||||
<ul>
|
||||
<li>Документация КриптоПро: <code>www.cryptopro.ru/products/csp</code></li>
|
||||
<li>Установка на РЕД ОС: <code>www.cryptopro.ru/forum2/default.aspx?g=topics&f=43</code></li>
|
||||
<li>Технические вопросы: <code>support@cryptopro.ru</code></li>
|
||||
<li>Рутокен: <code>dev.rutoken.ru/display/PUB/Rutoken+EDS</code></li>
|
||||
</ul>
|
||||
<p class="muted">При проблемах с лицензией сначала проверьте <code>cpconfig -license -view</code> — лицензия должна быть валидна и не просрочена. Срок действия КриптоПро лицензии — обычно 1 год.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>8. Подписание заявления</h2>
|
||||
<p>ЛК должен подписать заявление XMLDSig (ГОСТ или RSA) и положить в поле <code>signed_document</code> (base64). Мы проверяем подпись через crypto-service — см. <a href="/admin/help/cryptopro">инструкцию по КриптоПро</a>.</p>
|
||||
<p>ЛК должен подписать заявление XMLDSig (ГОСТ или RSA) и положить в поле <code>signed_document</code> (base64). Мы проверяем подпись через crypto-service — см. <a href="/admin/help/crypto">инструкцию по криптографии</a>.</p>
|
||||
<p class="muted">На M2 проверка подписи отключена (stub). На M3-M4 включится после подключения СКЗИ.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -43,26 +43,20 @@
|
||||
<ul>
|
||||
<li>Профиль (например, <code>test3-gost</code>) — при выборе URL и контейнер заполняются автоматически</li>
|
||||
<li>URL ONYX — например <code>https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo</code></li>
|
||||
<li>Ключевой контейнер — имя контейнера КриптоПро с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)</li>
|
||||
<li>Ключевой контейнер — имя контейнера Валидаты с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)</li>
|
||||
</ul>
|
||||
<p class="muted">Без настроенного ИШ система работает в <strong>mock-режиме</strong>: bj-server эмитирует синтетический Decision через 3 секунды для каждой заявки. Это удобно для дев-демо и не требует подключения к НРД.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>1а. Сертификаты УЦ НРД (для проверки квитанций)</h2>
|
||||
<p>НРД подписывает все исходящие пакеты ЭДО ГОСТ-подписью своего УЦ. Для проверки этих подписей на нашей стороне нужно импортировать корневые сертификаты УЦ НРД в хранилище <code>mroot</code> (доверенные корневые).</p>
|
||||
<p>НРД подписывает все исходящие пакеты ЭДО ГОСТ-подписью своего УЦ. Для проверки этих подписей на нашей стороне нужно загрузить корневые и промежуточные сертификаты УЦ НРД.</p>
|
||||
<ol>
|
||||
<li>Скачать сертификаты с сайта УЦ НРД: <code>www.nsd.ru/workflow/system/cryptography/</code> (или из дистрибутива ИШ).</li>
|
||||
<li>В <a href="/admin/setup">/admin/setup</a> → раздел «Импорт сертификата» → выбрать файл <code>.cer</code>, тип хранилища <code>mroot — корневой УЦ</code>, нажать «Импортировать». Под капотом выполняется <code>certmgr -inst -file root.cer -store mroot</code>.</li>
|
||||
<li>Промежуточные сертификаты УЦ — в хранилище <code>uRoot</code>.</li>
|
||||
<li>Для проверки подписей самой системы НРД (квитанции ЭДО) — импортировать сертификат подписи НРД в <code>uMy</code> (как корреспондента), либо оставить в <code>mroot</code>, если он самоподписной.</li>
|
||||
</ol>
|
||||
<p><strong>Наши сертификаты для отправки в НРД</strong> (получаются из другого УЦ — нашей организации):</p>
|
||||
<ol>
|
||||
<li>Сертификат подписи нашей организации (с приватным ключом в виде <code>.pfx</code>/<code>.p12</code> или на Рутокен) — импортировать в <code>uMy</code> с PIN.</li>
|
||||
<li>Цепочка сертификатов вашего УЦ — в <code>mroot</code> (корневой) и <code>uRoot</code> (промежуточные).</li>
|
||||
<li>После импорта проверить: <code>certmgr -list -store uMy</code> и <code>cpverify</code>.</li>
|
||||
<li>В <a href="/admin/setup">/admin/setup</a> → раздел «Сертификаты УЦ» добавить прямые URL <code>.cer</code>-файлов и нажать «Скачать и импортировать сейчас». Файлы сохраняются в <code>/var/lib/bj/ca-certs/</code> (по SHA-256). Включите «Авто-обновление раз в сутки» — система перепроверит и обновит.</li>
|
||||
<li>Загруженные через Валидату ключи и сертификаты управляются её собственным справочником (<code>zcs</code>/<code>vdcsp_cfg</code>).</li>
|
||||
</ol>
|
||||
<p><strong>Наши сертификаты для отправки в НРД</strong> загружаются в профиль Валидаты её утилитой <code>zcs</code> (импорт ключевого контейнера и сертификата подписи).</p>
|
||||
<p class="muted">Полный цикл обмена сертификатами с НРД описан в <code>DOC/Инструкция M2M.pdf</code> и <code>DOC/Презентация MOEX MOST.pdf</code>.</p>
|
||||
</div>
|
||||
<p><strong>Документация по подключению</strong>: <code>DOC/instr_podkl_stend_v3.pdf</code>, <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>.</p>
|
||||
@@ -105,7 +99,6 @@
|
||||
<tr><td>НРД (Национальный расчётный депозитарий)</td><td>Тестовые сертификаты GUEST/TEST3, дистрибутив ИШ, доступ к личному кабинету УЦ НРД</td></tr>
|
||||
<tr><td>Команда ЛК (ESIA Finance)</td><td>Базовый URL ЛК, Basic-auth учётные данные, очерёдность подключения (сначала эмулятор, потом реальный ЛК)</td></tr>
|
||||
<tr><td>Команда Fansy</td><td>Контракт <code>docs/fansy-contract/v1/</code>, SLA, окна обслуживания, IP-allowlist</td></tr>
|
||||
<tr><td>КриптоПро</td><td>Серийный номер лицензии CSP, актуальный дистрибутив, поддержка <code>support@cryptopro.ru</code></td></tr>
|
||||
<tr><td>Брокеры-контрагенты MOST</td><td>БКС (ИНН 5406121446), Ренессанс (7709258228), Альфа-Банк (7728168971) — уже в seed</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,63 +1,75 @@
|
||||
{{define "content"}}
|
||||
{{/* Активные новости — сразу под навигацией. Показываем top-3: те у которых ValidFrom..ValidTo сейчас активны, иначе свежие. */}}
|
||||
{{if .News}}
|
||||
<div class="card" style="border-left:3px solid var(--accent);margin-bottom:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<h2 style="margin:0">📢 Новости</h2>
|
||||
<a href="/admin/news" style="font-size:13px">все новости →</a>
|
||||
</div>
|
||||
{{range .News}}
|
||||
<div style="padding:8px 0;border-bottom:1px solid var(--border)">
|
||||
<div style="font-weight:600;font-size:14px">
|
||||
{{if eq .Kind "maintenance"}}🔧 {{end}}{{if eq .Kind "feature"}}✨ {{end}}{{if eq .Kind "system"}}⚠ {{end}}{{if eq .Kind "doc-update"}}📄 {{end}}{{.Title}}
|
||||
{{/* ===== Оператор-дашборд (Apple-стиль): приветствие → статус → плитки задач → сводка ===== */}}
|
||||
|
||||
<div class="hero">
|
||||
<h1 class="hero-greeting">Добрый день</h1>
|
||||
{{if .AllReady}}
|
||||
<span class="hero-status ok">● Система готова к работе</span>
|
||||
{{else}}
|
||||
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
|
||||
<span class="hero-status warn">● Требуется настройка — {{.NotReadyCount}} из {{.TotalCount}} компонентов</span>
|
||||
<a href="/admin/wizard" class="btn">Открыть мастер настройки →</a>
|
||||
</div>
|
||||
{{if .Body}}<div class="muted" style="font-size:12px;margin-top:4px">{{.Body}}</div>{{end}}
|
||||
{{if and (not .ValidFrom.IsZero) (not .ValidTo.IsZero)}}
|
||||
<div class="muted" style="font-size:11px;margin-top:4px">с {{.ValidFrom.Format "02.01.2006"}} по {{.ValidTo.Format "02.01.2006"}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ===== Крупные плитки задач ===== */}}
|
||||
<div class="tiles">
|
||||
<a href="/admin/claims?new=1" class="tile brand">
|
||||
<span class="ico">+</span>
|
||||
<span class="t-title">Новый перевод</span>
|
||||
<span class="t-sub">Заявка на перевод ценных бумаг M2M</span>
|
||||
<span class="t-arrow">→</span>
|
||||
</a>
|
||||
<a href="/admin/claims" class="tile">
|
||||
<span class="ico">📋</span>
|
||||
<span class="t-title">Переводы</span>
|
||||
<span class="t-sub">{{.Counts.Total}} всего · {{.Counts.InProgress}} в работе</span>
|
||||
<span class="t-arrow">→</span>
|
||||
</a>
|
||||
<a href="/admin/status" class="tile">
|
||||
<span class="ico">🔍</span>
|
||||
<span class="t-title">Диагностика</span>
|
||||
<span class="t-sub">Состояние СКЗИ, ИШ и базы</span>
|
||||
<span class="t-arrow">→</span>
|
||||
</a>
|
||||
<a href="/admin/setup" class="tile">
|
||||
<span class="ico">⚙️</span>
|
||||
<span class="t-title">Настройка</span>
|
||||
<span class="t-sub">Криптография, НРД, подключения</span>
|
||||
<span class="t-arrow">→</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{/* ===== Сводка по переводам ===== */}}
|
||||
<div class="grid">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Всего сделок</div>
|
||||
<div class="stat-label">Всего переводов</div>
|
||||
<div class="stat-value">{{.Counts.Total}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Подтверждено</div>
|
||||
<div class="stat-value" style="color: var(--ok)">{{.Counts.Confirmed}}</div>
|
||||
<div class="stat-value" style="color:var(--ok)">{{.Counts.Confirmed}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">В ожидании</div>
|
||||
<div class="stat-value" style="color: var(--warn)">{{.Counts.InProgress}}</div>
|
||||
<div class="stat-value" style="color:var(--warn)">{{.Counts.InProgress}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Отказы / таймауты</div>
|
||||
<div class="stat-value" style="color: var(--err)">{{.Counts.Failed}}</div>
|
||||
<div class="stat-value" style="color:var(--err)">{{.Counts.Failed}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Состояние системы</h2>
|
||||
{{range .Status.Checks}}
|
||||
<div style="padding: 6px 0">
|
||||
<span class="dot {{if .OK}}ok{{else}}err{{end}}"></span>
|
||||
<strong>{{.Name}}</strong> — {{.Message}}
|
||||
{{if .Detail}}<span class="muted"> · <code>{{.Detail}}</code></span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="muted" style="margin-top: 12px">
|
||||
Профиль: <code>{{.Status.Profile}}</code> · Crypto-провайдер: <code>{{.Status.Provider}}</code>
|
||||
</div>
|
||||
{{/* ===== Последние переводы ===== */}}
|
||||
<div class="section-head">
|
||||
<h2>Последние переводы</h2>
|
||||
<a href="/admin/claims">все →</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Последние заявки</h2>
|
||||
{{if .Recent}}
|
||||
<table>
|
||||
<thead><tr><th>Создана</th><th>ID</th><th>Инвестор</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
|
||||
<thead><tr><th>Время</th><th>ID</th><th>Инвестор</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Recent}}
|
||||
<tr>
|
||||
@@ -72,7 +84,25 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Заявок ещё нет. Подайте первую через lk-emulator или POST /api/v1/back_office/claims/.</p>
|
||||
<p class="muted" style="margin:0">Переводов ещё нет. Нажмите «Новый перевод», чтобы создать первый.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{/* ===== События (компактно, если есть) ===== */}}
|
||||
{{if .News}}
|
||||
<div class="section-head">
|
||||
<h2>События</h2>
|
||||
<a href="/admin/news">все →</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
{{range .News}}
|
||||
<div style="padding:9px 0;border-bottom:1px solid var(--border)">
|
||||
<div style="font-weight:600;font-size:13.5px">
|
||||
{{if eq .Kind "maintenance"}}🔧 {{end}}{{if eq .Kind "feature"}}✨ {{end}}{{if eq .Kind "system"}}⚠️ {{end}}{{if eq .Kind "doc-update"}}📄 {{end}}{{.Title}}
|
||||
</div>
|
||||
{{if .Body}}<div class="muted" style="font-size:12px;margin-top:3px">{{.Body}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
{{define "content"}}
|
||||
{{/* Пошаговый мастер установки ключа Валидаты на флешку. */}}
|
||||
|
||||
<div class="hero">
|
||||
<h1 class="hero-greeting">Установка ключа на флешку</h1>
|
||||
<span class="hero-status">Загрузите архив НРД → запись на носитель → справочник сертификатов → проверка → готово</span>
|
||||
</div>
|
||||
|
||||
{{$s := .State}}
|
||||
|
||||
{{/* ===== Лента шагов ===== */}}
|
||||
<div class="card">
|
||||
<ol style="list-style:none;padding:0;margin:0;display:grid;gap:12px">
|
||||
{{range $i, $step := $s.Steps}}
|
||||
<li style="display:flex;gap:12px;align-items:flex-start">
|
||||
<span style="flex:0 0 28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;
|
||||
{{if eq $step.Status "ok"}}background:var(--ok-weak);color:var(--ok)
|
||||
{{else if eq $step.Status "error"}}background:var(--err-weak);color:var(--err)
|
||||
{{else if eq $step.Status "active"}}background:var(--accent-weak);color:var(--accent)
|
||||
{{else}}background:var(--surface-2,#eee);color:var(--muted,#999){{end}}">
|
||||
{{if eq $step.Status "ok"}}✓{{else if eq $step.Status "error"}}✕{{else}}{{add $i 1}}{{end}}
|
||||
</span>
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:600">{{$step.Title}}</div>
|
||||
{{if $step.Detail}}<div class="muted" style="font-size:13px;margin-top:2px">{{$step.Detail}}</div>{{end}}
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{{/* ===== Действие в зависимости от состояния ===== */}}
|
||||
{{if $s.Done}}
|
||||
<div class="card" style="border-left:3px solid var(--ok)">
|
||||
<h2>✓ Готово</h2>
|
||||
<p>Ключ установлен на флешку, справочник сертификатов сформирован, Валидата проверена.</p>
|
||||
{{if $s.Backup}}<p class="muted">Бэкап прежнего носителя: <code>{{$s.Backup}}</code></p>{{end}}
|
||||
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
|
||||
<form method="post" action="/admin/setup/test-nsd" style="margin:0">
|
||||
<input type="hidden" name="scenario" value="2001">
|
||||
<button type="submit" class="btn btn-ok">→ Отправить тестовый документ роботу</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/setup/keywizard/reset" style="margin:0">
|
||||
<button type="submit" class="btn btn-secondary">Установить ещё один ключ</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{else if $s.StagingID}}
|
||||
{{/* Архив загружен — выбор флешки + запись */}}
|
||||
<div class="card">
|
||||
<h2>Шаг 2 — выбор флешки и запись</h2>
|
||||
<p class="muted">Архив распакован. Ключ: <code>{{fallbackTpl $s.VDK "—"}}</code>.
|
||||
Выберите носитель — запись сделает бэкап, запишет ключ и справочник
|
||||
сертификатов, дотянет CRL и перезапустит ИШ.</p>
|
||||
<form method="post" action="/admin/setup/keywizard/install" style="margin-top:12px;display:grid;gap:12px;max-width:640px"
|
||||
onsubmit="this.querySelector('button[type=submit]').disabled=true;this.querySelector('button[type=submit]').textContent='Устанавливаю…';">
|
||||
|
||||
<div>
|
||||
<label style="font-weight:600;display:block;margin-bottom:6px">Целевая флешка</label>
|
||||
{{if .Drives}}
|
||||
{{range $i, $d := .Drives}}
|
||||
<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--border,#ddd);border-radius:8px;margin-bottom:8px;cursor:pointer">
|
||||
<input type="radio" name="target_device" value="{{$d.Device}}" {{if $d.IsKeymedia}}checked{{else if and (eq $i 0) (not (anyKeymedia $.Drives))}}checked{{end}} style="margin-top:3px">
|
||||
<span>
|
||||
<b>{{fallbackTpl $d.Model "USB-носитель"}}</b> · {{$d.Size}} · {{$d.FSType}}
|
||||
{{if $d.Label}}· метка «{{$d.Label}}»{{end}}<br>
|
||||
<span class="muted" style="font-size:12px">{{$d.Device}}{{if $d.Mountpoint}} · {{$d.Mountpoint}}{{end}}
|
||||
{{if $d.IsKeymedia}}<b style="color:var(--accent)">← текущий ключевой носитель ИШ (рекомендуется)</b>{{end}}</span>
|
||||
</span>
|
||||
</label>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="muted">Съёмные носители не обнаружены — будет использован текущий ключевой носитель ИШ по умолчанию.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="font-weight:600;display:block;margin-bottom:6px">Имя профиля в справочнике (необязательно)</label>
|
||||
<input type="text" name="profile_name" placeholder="Авто из архива (напр. PrUser1046)" autocomplete="off"
|
||||
pattern="[A-Za-z0-9_-]*" style="width:100%">
|
||||
<span class="muted" style="font-size:12px">Пусто = имя берётся из архива автоматически.</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-ok">Записать на флешку, сформировать справочник и проверить ИШ</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/setup/keywizard/reset" style="margin-top:8px">
|
||||
<button type="submit" class="btn btn-secondary">Отмена / загрузить другой архив</button>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
{{/* Начало — форма загрузки */}}
|
||||
<div class="card">
|
||||
<h2>Шаг 1 — загрузка архива</h2>
|
||||
<p class="muted">Выберите .7z-архив с ключом от НРД и введите пароль архива.</p>
|
||||
<form method="post" action="/admin/setup/keywizard/upload" enctype="multipart/form-data"
|
||||
style="margin-top:12px;display:grid;gap:10px;max-width:560px">
|
||||
<input type="file" name="archive" accept=".7z,.zip" required>
|
||||
<input type="password" name="password" placeholder="Пароль архива (например 11)" autocomplete="off">
|
||||
<button type="submit" class="btn btn-ok">Загрузить и распаковать</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p style="margin-top:16px"><a href="/admin/setup" class="muted">← Назад к настройкам</a></p>
|
||||
|
||||
{{end}}
|
||||
@@ -11,7 +11,7 @@
|
||||
.news-title { font-size:15px; font-weight:600; margin:0 0 6px 0; }
|
||||
.news-body { font-size:13px; white-space:pre-wrap; }
|
||||
.news-validity { margin-top:6px; padding:4px 8px; background:var(--bg); border-radius:4px; display:inline-block; font-size:12px; }
|
||||
.news-validity.active { background:rgba(232,177,58,0.15); color:var(--warn); }
|
||||
.news-validity.active { background:var(--warn-weak); color:var(--warn); }
|
||||
</style>
|
||||
|
||||
{{if .Flash}}<div class="card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
|
||||
|
||||
@@ -1,324 +1,384 @@
|
||||
{{define "content"}}
|
||||
{{if .Flash}}<div style="padding:12px 16px;background:rgba(63,191,108,0.1);border-left:3px solid var(--ok);border-radius:4px;margin-bottom:16px">{{.Flash}}</div>{{end}}
|
||||
{{if .Flash}}<div style="padding:12px 16px;background:var(--ok-weak);border-left:3px solid var(--ok);border-radius:8px;margin-bottom:16px">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Готовность системы: {{.ReadyCount}} из {{.TotalCount}}</h2>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
{{range .Readiness}}
|
||||
<div style="flex:1;text-align:center;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px">
|
||||
<span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span>
|
||||
<strong>{{.Name}}</strong><br>
|
||||
<span class="muted" style="font-size:11px">{{if .Configured}}настроено{{else}}не настроено{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings">
|
||||
{{/* ===== Боковая навигация разделов с индикаторами ===== */}}
|
||||
<nav class="settings-nav">
|
||||
<button data-sec="overview" class="active"><span class="nico">◎</span>Обзор</button>
|
||||
<button data-sec="db"><span class="nico">🗄️</span>База данных<span class="ind {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span></button>
|
||||
<button data-sec="crypto"><span class="nico">🔐</span>Криптография<span class="ind {{if .Settings.Crypto.Profile}}ok{{else}}warn{{end}}"></span></button>
|
||||
<button data-sec="nsd"><span class="nico">🏛️</span>НРД<span class="ind {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span></button>
|
||||
<button data-sec="tests"><span class="nico">🧪</span>Тесты</button>
|
||||
<button data-sec="update"><span class="nico">⬆️</span>Обновления{{if .Settings.Update.Available}}<span class="ind warn"></span>{{end}}</button>
|
||||
<button data-sec="license"><span class="nico">🔑</span>Лицензия{{if .License.Present}}<span class="ind {{if .License.Valid}}ok{{else}}err{{end}}"></span>{{end}}</button>
|
||||
</nav>
|
||||
|
||||
<!-- PostgreSQL -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span>PostgreSQL</h2>
|
||||
<p class="muted">Принимающая БД (fansy-store) и журнал сделок m2m-core. Сейчас:
|
||||
{{if .Settings.Postgres.DSN}}<code>настроено</code>{{else}}<code>in-memory</code> (M2-демо){{end}}.</p>
|
||||
<div class="settings-body">
|
||||
|
||||
{{if not .Settings.Postgres.DSN}}
|
||||
<div style="background:var(--bg);border:1px solid var(--accent);border-radius:6px;padding:14px;margin-top:12px">
|
||||
<h3 style="margin:0 0 8px 0;font-size:15px">Самый простой вариант — подключить автоматически</h3>
|
||||
<p class="muted" style="margin:0 0 10px 0">Если у вас ещё нет своего PostgreSQL, мы поднимем его сами в контейнере (podman-compose), применим все миграции и запишем DSN. Подходит для дев-стенда и тестирования. Для прода — лучше указать свой DSN ниже.</p>
|
||||
<form method="post" action="/admin/setup/postgres/quick-start" style="margin:0">
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:10px 18px;border-radius:4px;font-weight:600;cursor:pointer">⚡ Поднять локальный PostgreSQL автоматически</button>
|
||||
<span class="muted" style="margin-left:10px;font-size:12px">Займёт ~10-30 секунд. Требуется установленный <code>podman-compose</code>.</span>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
{{/* ============ ОБЗОР ============ */}}
|
||||
<section class="settings-section active" id="sec-overview">
|
||||
<h1>Обзор</h1>
|
||||
<div class="card">
|
||||
<h2>Готовность системы: {{.ReadyCount}} из {{.TotalCount}}</h2>
|
||||
<div class="grid" style="margin-top:12px">
|
||||
{{range .Readiness}}
|
||||
<div class="stat">
|
||||
<div><span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span><strong>{{.Name}}</strong></div>
|
||||
<div class="muted" style="font-size:12px;margin-top:4px">{{if .Configured}}настроено{{else}}не настроено{{end}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details {{if not .Settings.Postgres.DSN}}style="margin-top:12px"{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">{{if .Settings.Postgres.DSN}}Изменить параметры подключения{{else}}…или ввести параметры подключения вручную (для существующего PostgreSQL){{end}}</summary>
|
||||
<form method="post" action="/admin/setup/postgres" style="margin-top:12px">
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>DSN <span class="muted" title="DSN = Data Source Name. Строка вида postgres://пользователь:пароль@хост:порт/база?опции" style="cursor:help">(?)</span></label>
|
||||
<input type="text" name="dsn" value="{{.Settings.Postgres.DSN}}" placeholder="postgres://bj:secret@127.0.0.1:5432/bj?sslmode=disable" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
{{/* ============ БАЗА ДАННЫХ ============ */}}
|
||||
<section class="settings-section" id="sec-db">
|
||||
<h1>База данных</h1>
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span>PostgreSQL</h2>
|
||||
<p class="muted">Журнал сделок m2m-core и принимающая БД. Сейчас: {{if .Settings.Postgres.DSN}}<code>подключено</code>{{else}}<code>in-memory</code> (данные не сохраняются){{end}}.</p>
|
||||
{{if not .Settings.Postgres.DSN}}
|
||||
<div style="background:var(--card-2);border:1px solid var(--accent);border-radius:10px;padding:16px;margin-top:12px">
|
||||
<h3 style="margin:0 0 8px">Подключить автоматически</h3>
|
||||
<p class="muted" style="margin:0 0 12px">Поднимем локальный PostgreSQL в контейнере, применим миграции и запишем DSN. Для дев-стенда. Для прода — укажите свой DSN ниже.</p>
|
||||
<form method="post" action="/admin/setup/postgres/quick-start" style="margin:0">
|
||||
<button type="submit" class="btn">⚡ Поднять локальный PostgreSQL</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:8px">При сохранении выполняется Ping. Если БД недоступна — будет ошибка.</p>
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px;margin-top:8px">Сохранить и проверить</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- СКЗИ через PKCS#11: КриптоПро CSP / Рутокен / Валидата / ViPNet -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if and .Settings.Crypto.JCPPath .Settings.Crypto.LicenseKey}}ok{{else}}err{{end}}"></span>СКЗИ (КриптоПро CSP, Рутокен и др. через PKCS#11)</h2>
|
||||
<p class="muted">Go-клиент подключается к СКЗИ напрямую через стандартный PKCS#11 интерфейс. Поддерживаются КриптоПро CSP, Рутокен ЭЦП 2.0, Валидата, ViPNet — один клиент, разные .so модули. Подробно — раздел <a href="/admin/help/cryptopro">«КриптоПро»</a> в инструкциях.</p>
|
||||
<table style="margin-bottom:12px">
|
||||
<tr><td style="width:220px" class="muted">Текущий провайдер</td><td><code>{{.Settings.Crypto.Provider}}</code></td></tr>
|
||||
<tr><td class="muted">Путь к модулю PKCS#11</td><td><code>{{if .Settings.Crypto.JCPPath}}{{.Settings.Crypto.JCPPath}}{{else}}—{{end}}</code></td></tr>
|
||||
<tr><td class="muted">UDS-сокет (legacy)</td><td><code>{{.Settings.Crypto.SocketPath}}</code></td></tr>
|
||||
<tr><td class="muted">Лицензия введена</td><td>{{if .Settings.Crypto.LicenseKey}}<span style="color:var(--ok)">да</span>{{else}}<span style="color:var(--err)">нет</span>{{end}}</td></tr>
|
||||
</table>
|
||||
<details {{if or (eq .Settings.Crypto.Provider "stub") (not .Settings.Crypto.JCPPath)}}open{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры СКЗИ</summary>
|
||||
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px">
|
||||
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
|
||||
<label>Провайдер СКЗИ</label>
|
||||
<select name="provider" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
|
||||
<option value="cryptopro" {{if eq .Settings.Crypto.Provider "cryptopro"}}selected{{end}}>КриптоПро CSP (через PKCS#11)</option>
|
||||
<option value="rutoken" {{if eq .Settings.Crypto.Provider "rutoken"}}selected{{end}}>Рутокен ЭЦП 2.0 (для подписи оператора)</option>
|
||||
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата</option>
|
||||
<option value="vipnet" {{if eq .Settings.Crypto.Provider "vipnet"}}selected{{end}}>ViPNet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
|
||||
<label>Путь к модулю PKCS#11</label>
|
||||
<input type="text" name="jcp_path" value="{{.Settings.Crypto.JCPPath}}" placeholder="/opt/cprocsp/lib/amd64/libcppkcs11.so" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
|
||||
<label>UDS-сокет (legacy)</label>
|
||||
<input type="text" name="socket_path" value="{{.Settings.Crypto.SocketPath}}" placeholder="/run/bj/crypto.sock (только для совместимости со старым Java crypto-service)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:flex-start">
|
||||
<label>Серийный номер лицензии</label>
|
||||
<textarea name="license_key" rows="3" placeholder="XXXX-XXXXX-XXXXX-XXXXX-XXXXX (серийный номер КриптоПро CSP)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{.Settings.Crypto.LicenseKey}}</textarea>
|
||||
</div>
|
||||
<p class="muted">
|
||||
<strong>КриптоПро CSP</strong>: установить пакеты <code>rpm -i cprocsp-*.rpm</code>, активировать лицензию командой <code>cpconfig -license -set XXXX-...</code>, указать <code>/opt/cprocsp/lib/amd64/libcppkcs11.so</code>.<br>
|
||||
<strong>Рутокен</strong>: подключить токен USB, указать <code>/usr/lib64/librtpkcs11ecp.so</code>.<br>
|
||||
Полная инструкция: <a href="/admin/help/cryptopro">/admin/help/cryptopro</a>. При сохранении проверим, что файл модуля существует.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/admin/setup/crypto/check" style="margin-top:12px">
|
||||
<button type="submit" class="btn" style="background:var(--border);color:var(--text);border:none;padding:8px 16px;border-radius:4px">Проверить подключение СКЗИ</button>
|
||||
<span class="muted" style="margin-left:8px">Загрузит PKCS#11 модуль, опросит список токенов, покажет результат сверху страницы.</span>
|
||||
</form>
|
||||
|
||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
|
||||
<h3 style="font-size:14px;margin:0 0 8px">Установка КриптоПро CSP</h3>
|
||||
<p class="muted">Дистрибутив с <a href="https://www.cryptopro.ru/products/csp/downloads" target="_blank" rel="noopener">cryptopro.ru</a> (например, <code>linux-amd64.tgz</code> или <code>linux-amd64.tar</code> для РЕД ОС/ALT/ROSA). Загрузите файл здесь — он будет распакован и установлен через <code>sudo rpm -Uvh</code>. Установка длится ~30 секунд.</p>
|
||||
<form method="post" action="/admin/setup/crypto/install" enctype="multipart/form-data" style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input type="file" name="dist" accept=".tar,.tgz,.gz,.rpm" required style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;flex:1;min-width:300px">
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Загрузить и установить</button>
|
||||
</form>
|
||||
|
||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
|
||||
<h3 style="font-size:14px;margin:0 0 8px">Сертификаты на токенах</h3>
|
||||
{{if .Certificates}}
|
||||
<table>
|
||||
<thead><tr><th>Кому</th><th>Кем выдан</th><th>ИНН</th><th>Действителен</th><th>Токен</th><th>Приватный ключ</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Certificates}}
|
||||
<tr>
|
||||
<td>{{.SubjectCN}}</td>
|
||||
<td class="muted">{{.IssuerCN}}</td>
|
||||
<td><code>{{.INN}}</code></td>
|
||||
<td class="muted">до {{.NotAfter.Format "02.01.2006"}}</td>
|
||||
<td class="muted">«{{.TokenLabel}}» (slot {{.SlotID}})</td>
|
||||
<td>{{if .HasPrivateKey}}<span style="color:var(--ok)">есть</span>{{else}}<span class="muted">нет</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">На подключенных токенах сертификатов не найдено. Загрузите .pfx ниже или подключите Рутокен с сертификатом.</p>
|
||||
{{end}}
|
||||
<details {{if not .Settings.Postgres.DSN}}open{{end}} style="margin-top:14px">
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">{{if .Settings.Postgres.DSN}}Изменить подключение{{else}}…или ввести DSN вручную{{end}}</summary>
|
||||
<form method="post" action="/admin/setup/postgres" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
|
||||
<label>DSN</label>
|
||||
<input type="text" name="dsn" value="{{.Settings.Postgres.DSN}}" placeholder="postgres://bj:secret@127.0.0.1:5432/bj?sslmode=disable">
|
||||
<p class="muted" style="margin:0">При сохранении выполняется Ping.</p>
|
||||
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
|
||||
<h3 style="font-size:14px;margin:0 0 8px">Импорт сертификата (.pfx / .cer / .crt)</h3>
|
||||
<p class="muted">PFX с приватным ключом (с PIN) — для серверной подписи и подписи оператора. CER/CRT без приватного ключа — для проверки чужих подписей (например, сертификаты УЦ НРД для проверки квитанций). Подробно — <a href="/admin/help/cryptopro">/admin/help/cryptopro</a>.</p>
|
||||
<form method="post" action="/admin/setup/crypto/import-cert" enctype="multipart/form-data" style="margin-top:8px;display:grid;gap:8px;grid-template-columns:auto auto auto auto;align-items:center">
|
||||
<input type="file" name="cert" accept=".pfx,.p12,.cer,.crt" required style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<input type="text" name="pin" placeholder="PIN (только для .pfx/.p12)" style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace">
|
||||
<select name="store" style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="uMy">uMy — личный (для подписи)</option>
|
||||
<option value="mroot">mroot — корневой УЦ (для проверки)</option>
|
||||
<option value="uRoot">uRoot — промежуточные УЦ</option>
|
||||
</select>
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Импортировать</button>
|
||||
</form>
|
||||
{{/* ============ КРИПТОГРАФИЯ ============ */}}
|
||||
<section class="settings-section" id="sec-crypto">
|
||||
<h1>Криптография</h1>
|
||||
|
||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
|
||||
<h3 style="font-size:14px;margin:0 0 8px">Активация лицензии</h3>
|
||||
<form method="post" action="/admin/setup/crypto/activate" style="margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input type="text" name="license_key" value="{{.Settings.Crypto.LicenseKey}}" placeholder="XXXX-XXXXX-XXXXX-XXXXX-XXXXX (серийный номер КриптоПро CSP)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px;min-width:340px">
|
||||
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;border:none;padding:8px 16px;border-radius:4px;font-weight:600">Активировать лицензию</button>
|
||||
</form>
|
||||
<p class="muted" style="margin-top:8px">Вызовет <code>cpconfig -license -set</code> и сохранит серийник. Если КриптоПро CSP ещё не установлен — покажет инструкцию.</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="card" style="border-left:3px solid var(--accent)">
|
||||
<h2>🔑 Установка ключа на флешку</h2>
|
||||
<p class="muted">Пошаговый мастер: загрузить архив НРД с паролем → запись на флешку → справочник сертификатов → проверка Валидаты → готово.</p>
|
||||
<a href="/admin/setup/keywizard" class="btn btn-ok" style="margin-top:10px;display:inline-block">Открыть мастер установки ключа →</a>
|
||||
</div>
|
||||
|
||||
<!-- Контейнеры КриптоПро на флешке -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .FlashContainers}}ok{{else}}warn{{end}}"></span>Контейнеры на USB-носителях (флешка/Рутокен)</h2>
|
||||
{{if .FlashContainers}}
|
||||
<p class="muted">Найдено {{len .FlashContainers}} контейнер(а) формата <code>name.000</code> на смонтированных USB-носителях. Кнопка ниже копирует папку в <code>/var/opt/cprocsp/keys/$USER/</code> — после этого контейнер виден как <code>\\.\HDIMAGE\name</code> и работает без вставленной флешки.</p>
|
||||
<table style="margin-top:8px">
|
||||
<thead><tr><th>Носитель</th><th>Имя контейнера</th><th>Файлы</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .FlashContainers}}
|
||||
<tr>
|
||||
<td><code style="font-size:12px">{{.Mountpoint}}</code></td>
|
||||
<td><strong>{{.Name}}</strong></td>
|
||||
<td><span class="muted" style="font-size:11px">{{len .Files}} файлов</span></td>
|
||||
<td>{{if .AlreadyImported}}<span style="color:var(--ok)">уже в HDIMAGE</span>{{else}}<span class="muted">только на флешке</span>{{end}}</td>
|
||||
<td>
|
||||
{{if not .AlreadyImported}}
|
||||
<form method="post" action="/admin/setup/crypto/copy-container" style="margin:0">
|
||||
<input type="hidden" name="src" value="{{.Path}}">
|
||||
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;padding:6px 12px;font-size:12px;font-weight:600">Скопировать в локальное хранилище</button>
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.Crypto.Profile}}ok{{else}}warn{{end}}"></span>СКЗИ «Валидата Клиент L»</h2>
|
||||
<p class="muted">Активный профиль: <code>{{if .Settings.Crypto.Profile}}{{.Settings.Crypto.Profile}}{{else}}—{{end}}</code> · провайдер <code>{{.Settings.Crypto.Provider}}</code>. Подробно — <a href="/admin/help/crypto">справка</a>.</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
|
||||
<form method="post" action="/admin/setup/crypto/check" style="margin:0"><button type="submit" class="btn btn-ok">✓ Проверить СКЗИ</button></form>
|
||||
<form method="post" action="/admin/setup/crypto/test-sign" style="margin:0"><button type="submit" class="btn">✎ Тестовая подпись</button></form>
|
||||
<form method="post" action="/admin/setup/restart-crypto" style="margin:0" onsubmit="return confirm('Перезапустить crypto-service? Поднимется через ~5 сек.');"><button type="submit" class="btn btn-warn">↻ crypto-service</button></form>
|
||||
<form method="post" action="/admin/setup/restart-server" style="margin:0" onsubmit="return confirm('Перезапустить bj-server? Через 5-10 сек страница вернётся.');"><button type="submit" class="btn btn-secondary">↻ bj-server</button></form>
|
||||
</div>
|
||||
<details style="margin-top:14px">
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Параметры провайдера (для совместимости)</summary>
|
||||
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
|
||||
<label>Провайдер</label>
|
||||
<select name="provider">
|
||||
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
|
||||
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата Клиент L</option>
|
||||
</select>
|
||||
<label>Путь к модулю PKCS#11</label>
|
||||
<input type="text" name="module_path" value="{{.Settings.Crypto.ModulePath}}" placeholder="/opt/Validata/VDCSP/lib/amd64/libvdpkcs11.so">
|
||||
<label>UDS-сокет</label>
|
||||
<input type="text" name="socket_path" value="{{.Settings.Crypto.SocketPath}}" placeholder="/run/bj/crypto.sock">
|
||||
<div><button type="submit" class="btn">Сохранить</button></div>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{{/* Носители ключей */}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Media}}ok{{else}}warn{{end}}"></span>Носители ключей</h2>
|
||||
<p class="muted">USB-флешки сканируются автоматически. Образы (.iso/.img/.zip/.7z) загружаются ниже — bj-server распакует и найдёт профиль Валидаты, контейнеры, сертификаты.</p>
|
||||
<form method="post" action="/admin/setup/media/iso/upload" enctype="multipart/form-data" style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input type="file" name="iso" accept=".iso,.img,.zip,.7z" required style="flex:1;min-width:240px">
|
||||
<input type="password" name="password" placeholder="Пароль архива (для MOEX — 11)" autocomplete="off" style="min-width:200px">
|
||||
<button type="submit" class="btn">Загрузить</button>
|
||||
</form>
|
||||
<p class="muted" style="margin-top:6px;font-size:12px">Лимит 500 МБ. Распаковка через 7z.</p>
|
||||
|
||||
{{if .Media}}
|
||||
{{range .Media}}
|
||||
<div style="margin-top:14px;padding:14px;background:var(--card-2);border:1px solid var(--border);border-radius:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div><strong>{{if eq .Kind "iso"}}📀 ISO{{else}}🔌 USB{{end}}</strong> <code style="font-size:12px;margin-left:8px">{{.Mountpoint}}</code>{{if .Source}}<span class="muted" style="font-size:11px;margin-left:8px">{{.Source}}</span>{{end}}</div>
|
||||
{{if eq .Kind "iso"}}
|
||||
<form method="post" action="/admin/setup/media/iso/unmount" style="margin:0" onsubmit="return confirm('Удалить распаковку {{.Mountpoint}}?');">
|
||||
<input type="hidden" name="id" value="{{.ID}}"><button type="submit" class="btn btn-secondary" style="padding:5px 11px;font-size:12px">Удалить распаковку</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
{{if .Profile}}
|
||||
<h3>Профиль Валидаты</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td>ПСП (.pse)</td><td class="muted" style="font-size:11px">{{range .Profile.PSEFiles}}{{.}}<br>{{end}}</td></tr>
|
||||
<tr><td>ЛСП (.gdbm)</td><td class="muted" style="font-size:11px">{{range .Profile.GDBMFiles}}{{.}}<br>{{end}}</td></tr>
|
||||
<tr><td>Ключи (.vdk)</td><td class="muted" style="font-size:11px">{{range .Profile.KeyFiles}}{{.}}<br>{{end}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<form method="post" action="/admin/setup/media/import-profile" style="margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input type="hidden" name="root" value="{{.Profile.Root}}">
|
||||
<input type="text" name="name" placeholder="Имя профиля, напр. nrd-edo">
|
||||
{{if .Profile.Imported}}<span style="color:var(--ok)">✓ импортирован</span>{{else}}<button type="submit" class="btn btn-ok" style="padding:6px 12px;font-size:12px">Импортировать профиль</button>{{end}}
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Containers}}
|
||||
<h3>Контейнеры ({{len .Containers}})</h3>
|
||||
<table>
|
||||
<thead><tr><th>Имя</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody>{{range .Containers}}
|
||||
<tr><td><strong>{{.Name}}</strong> <span class="muted" style="font-size:11px">{{.Path}}</span></td><td>{{if .Imported}}<span style="color:var(--ok)">импортирован</span>{{else}}<span class="muted">нет</span>{{end}}</td>
|
||||
<td>{{if not .Imported}}<form method="post" action="/admin/setup/media/import-container" style="margin:0"><input type="hidden" name="path" value="{{.Path}}"><button type="submit" class="btn btn-ok" style="padding:5px 11px;font-size:12px">Импортировать</button></form>{{end}}</td></tr>
|
||||
{{end}}</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
{{if and (not .Containers) (not .Certificates) (not .Profile)}}<p class="muted" style="margin-top:8px;font-size:12px">Профиль Валидаты не найден на носителе.</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Подключённые USB-носители с контейнерами КриптоПро (папки <code>name.000</code> с *.key) не обнаружены. Поиск идёт в <code>/run/media/$USER/</code>, <code>/media/$USER/</code>, <code>/media/</code>, <code>/mnt/</code>. Вставьте флешку и обновите страницу.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<p class="muted" style="margin-top:10px">Носители не обнаружены. Подключите USB или загрузите образ.</p>{{end}}
|
||||
|
||||
<!-- Авто-загрузка сертификатов УЦ НРД -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.CACerts.URLs}}ok{{else}}warn{{end}}"></span>Сертификаты УЦ (НРД и др.) — авто-загрузка</h2>
|
||||
<p class="muted">Прямые URL .cer-файлов УЦ НРД (см. <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и других УЦ. Каждый URL скачивается, парсится X.509, и автоматически импортируется в КриптоПро (<code>mroot</code> для корневых, <code>uRoot</code> для промежуточных). Включите авто-обновление — раз в сутки система перепроверит и переустановит, если сертификат изменился.</p>
|
||||
<form method="post" action="/admin/setup/cacerts" style="margin-top:10px;display:grid;gap:10px">
|
||||
<label>URL'ы .cer-файлов (один на строку)</label>
|
||||
<textarea name="urls" rows="4" placeholder="https://www.nsd.ru/path/to/root-ca.cer https://www.nsd.ru/path/to/sub-ca.cer" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
|
||||
{{end}}</textarea>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" name="auto_update" {{if .Settings.CACerts.AutoUpdate}}checked{{end}}>
|
||||
<span>Авто-обновление раз в сутки</span>
|
||||
</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button type="submit" class="btn">Сохранить</button>
|
||||
{{if .ImportedProfiles}}
|
||||
<h3 style="margin-top:18px">Импортированные профили</h3>
|
||||
<table>
|
||||
<thead><tr><th>Имя</th><th>Состояние</th><th>Действия</th></tr></thead>
|
||||
<tbody>{{range .ImportedProfiles}}
|
||||
<tr><td><strong>{{.}}</strong></td><td>{{if eq . $.Settings.Crypto.Profile}}<span style="color:var(--ok)">✓ активен</span>{{else}}<span class="muted">не активен</span>{{end}}</td>
|
||||
<td style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
{{if ne . $.Settings.Crypto.Profile}}<form method="post" action="/admin/setup/media/activate-profile" style="margin:0"><input type="hidden" name="name" value="{{.}}"><button type="submit" class="btn" style="padding:5px 11px;font-size:12px">Активировать</button></form>{{end}}
|
||||
<form method="post" action="/admin/setup/media/delete-profile" style="margin:0" onsubmit="return confirm('Удалить профиль «{{.}}»?');"><input type="hidden" name="name" value="{{.}}"><button type="submit" class="btn btn-danger" style="padding:5px 11px;font-size:12px">Удалить</button></form>
|
||||
</td></tr>
|
||||
{{end}}</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/admin/setup/cacerts/fetch" style="margin-top:8px">
|
||||
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;font-weight:600">⬇ Скачать и импортировать сейчас</button>
|
||||
{{if not .Settings.CACerts.LastFetch.IsZero}}
|
||||
<span class="muted" style="margin-left:10px">Последнее обновление: {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04:05"}}</span>
|
||||
{{end}}
|
||||
</form>
|
||||
{{if .Settings.CACerts.FetchedCerts}}
|
||||
<table style="margin-top:14px">
|
||||
<thead><tr><th>URL</th><th>Владелец</th><th>Хранилище</th><th>Действителен до</th><th>SHA-256</th><th>Статус</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Settings.CACerts.FetchedCerts}}
|
||||
<tr>
|
||||
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{.URL}}"><code style="font-size:11px">{{.URL}}</code></td>
|
||||
<td>{{.SubjectCN}}</td>
|
||||
<td><code>{{.Store}}</code></td>
|
||||
<td>{{if not .NotAfter.IsZero}}{{.NotAfter.Format "02.01.2006"}}{{end}}</td>
|
||||
<td><code style="font-size:11px">{{if .SHA256}}{{slice .SHA256 0 12}}…{{end}}</code></td>
|
||||
<td>{{if .Error}}<span style="color:var(--err)" title="{{.Error}}">ошибка</span>{{else}}<span style="color:var(--ok)">ок</span>{{end}}</td>
|
||||
</tr>
|
||||
|
||||
{{/* Сертификаты УЦ */}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.CACerts.URLs}}ok{{else}}warn{{end}}"></span>Сертификаты УЦ (авто-загрузка)</h2>
|
||||
<p class="muted">URL .cer-файлов УЦ НРД (<a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru</a>). Скачиваются, парсятся и сохраняются в <code>/var/lib/bj/ca-certs/</code>.</p>
|
||||
<form method="post" action="/admin/setup/cacerts" style="margin-top:10px;display:grid;gap:10px;max-width:720px">
|
||||
<label>URL'ы (один на строку)</label>
|
||||
<textarea name="urls" rows="3" style="font-family:ui-monospace,monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
|
||||
{{end}}</textarea>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" name="auto_update" {{if .Settings.CACerts.AutoUpdate}}checked{{end}} style="width:auto"> Авто-обновление раз в сутки</label>
|
||||
<div style="display:flex;gap:8px"><button type="submit" class="btn">Сохранить</button></div>
|
||||
</form>
|
||||
<form method="post" action="/admin/setup/cacerts/fetch" style="margin-top:8px"><button type="submit" class="btn btn-ok">⬇ Скачать сейчас</button>{{if not .Settings.CACerts.LastFetch.IsZero}}<span class="muted" style="margin-left:10px">обновлено {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04"}}</span>{{end}}</form>
|
||||
{{if .Settings.CACerts.FetchedCerts}}
|
||||
<table style="margin-top:14px">
|
||||
<thead><tr><th>Владелец</th><th>Тип</th><th>До</th><th>Статус</th></tr></thead>
|
||||
<tbody>{{range .Settings.CACerts.FetchedCerts}}<tr><td>{{.SubjectCN}}</td><td><code>{{.Store}}</code></td><td>{{if not .NotAfter.IsZero}}{{.NotAfter.Format "02.01.2006"}}{{end}}</td><td>{{if .Error}}<span style="color:var(--err)" title="{{.Error}}">ошибка</span>{{else}}<span style="color:var(--ok)">ок</span>{{end}}</td></tr>{{end}}</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
{{if .Settings.CACerts.LastFetchLog}}
|
||||
<details style="margin-top:10px">
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Лог последнего обновления</summary>
|
||||
<pre style="margin-top:8px">{{.Settings.CACerts.LastFetchLog}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- nsd-adapter / ИШ НРД -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}err{{end}}"></span>Интеграционный шлюз НРД (ИШ)</h2>
|
||||
<p class="muted">{{if not .Settings.NSD.IGWBaseURL}}Сейчас <code>mock-режим</code> — Decision эмитируется через 3 секунды после Send.{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}}</p>
|
||||
<p class="muted">Подключение к стендам: <a href="/admin/help/systems">/admin/help/systems</a> — там полная таблица URL контуров GUEST/TEST3/PROD и инструкция по установке ИШ. Дистрибутив ИШ скачивается с <code>www.nsd.ru/workflow/system/programs/#0-widget-faq-0-4</code>.</p>
|
||||
<details {{if not .Settings.NSD.IGWBaseURL}}open{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры ИШ</summary>
|
||||
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px">
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>Профиль</label>
|
||||
<select name="profile" id="nsd-profile" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="" {{if eq .Settings.NSD.Profile ""}}selected{{end}}>— mock (демо без реального ИШ) —</option>
|
||||
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>guest-gost — контур GUEST, ГОСТ ключи</option>
|
||||
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>guest-rsa — контур GUEST, RSA ключи</option>
|
||||
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>test3-gost — контур TEST3, ГОСТ ключи</option>
|
||||
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>test3-rsa — контур TEST3, RSA ключи</option>
|
||||
<option value="prod-gost" {{if eq .Settings.NSD.Profile "prod-gost"}}selected{{end}}>prod-gost — продуктивный, ГОСТ</option>
|
||||
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>prod-rsa — продуктивный, RSA</option>
|
||||
{{/* ============ НРД ============ */}}
|
||||
<section class="settings-section" id="sec-nsd">
|
||||
<h1>НРД</h1>
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span>Интеграционный шлюз (ИШ)</h2>
|
||||
<p class="muted">{{if not .Settings.NSD.IGWBaseURL}}<code>mock-режим</code> — без реального ИШ.{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}} Стенды и установка — <a href="/admin/help/systems">справка</a>.</p>
|
||||
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
|
||||
<label>Профиль / контур</label>
|
||||
<select name="profile" id="nsd-profile">
|
||||
<option value="" {{if eq .Settings.NSD.Profile ""}}selected{{end}}>— mock (демо) —</option>
|
||||
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>guest-gost — GUEST, ГОСТ</option>
|
||||
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>guest-rsa — GUEST, RSA</option>
|
||||
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>test3-gost — TEST3, ГОСТ</option>
|
||||
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>test3-rsa — TEST3, RSA</option>
|
||||
<option value="prod-gost" {{if eq .Settings.NSD.Profile "prod-gost"}}selected{{end}}>prod-gost — ПРОМ, ГОСТ</option>
|
||||
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>prod-rsa — ПРОМ, RSA</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>URL ONYX (WSL) НРД</label>
|
||||
<input type="text" name="igw_base_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="будет заполнено по профилю" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<input type="text" name="igw_base_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="автозаполнится по профилю">
|
||||
<label>Ключевой контейнер</label>
|
||||
<input type="text" name="key_container" id="nsd-container" value="{{.Settings.NSD.KeyContainer}}" placeholder="GUEST_GOST_CONTAINER (или ваш контейнер УЦ НРД)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<p class="muted">При смене профиля URL ONYX автозаполнится по таблице НРД (из <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>). При сохранении проверяется доступность URL.</p>
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Сохранить и проверить</button>
|
||||
</form>
|
||||
<script>
|
||||
// Автозаполнение URL ONYX и дефолтного контейнера по выбранному профилю.
|
||||
(function() {
|
||||
var urls = {
|
||||
"guest-gost": ["https://gost-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_GOST_CONTAINER"],
|
||||
"guest-rsa": ["https://rsa-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_RSA_CONTAINER"],
|
||||
"test3-gost": ["https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_GOST_CONTAINER"],
|
||||
"test3-rsa": ["https://rsa-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_RSA_CONTAINER"],
|
||||
"prod-gost": ["https://gost.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_GOST_CONTAINER"],
|
||||
"prod-rsa": ["https://rsa.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_RSA_CONTAINER"]
|
||||
};
|
||||
var profile = document.getElementById("nsd-profile");
|
||||
var urlInput = document.getElementById("nsd-url");
|
||||
var contInput = document.getElementById("nsd-container");
|
||||
profile.addEventListener("change", function() {
|
||||
var p = profile.value;
|
||||
if (urls[p]) {
|
||||
if (!urlInput.value || confirm("Заменить URL и контейнер на дефолт для профиля " + p + "?")) {
|
||||
urlInput.value = urls[p][0];
|
||||
contInput.value = urls[p][1];
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</details>
|
||||
</div>
|
||||
<input type="text" name="key_container" id="nsd-container" value="{{.Settings.NSD.KeyContainer}}" placeholder="напр. TEST3_GOST_CONTAINER">
|
||||
<hr style="border:none;border-top:1px solid var(--border);margin:6px 0">
|
||||
<p class="muted" style="margin:0">Депозитарные реквизиты (откуда списываются бумаги) — из договора/письма НРД. Нужны для формирования заявки на перевод.</p>
|
||||
<label>Депозитарный код</label>
|
||||
<input type="text" name="deponent_code" value="{{.Settings.NSD.DeponentCode}}" placeholder="напр. MC0413600000">
|
||||
<label>Депозитарный счёт</label>
|
||||
<input type="text" name="account_id" value="{{.Settings.NSD.AccountID}}" placeholder="депозитарный счёт">
|
||||
<label>Раздел счёта</label>
|
||||
<input type="text" name="section_id" value="{{.Settings.NSD.SectionID}}" placeholder="раздел депозитарного счёта">
|
||||
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
|
||||
</form>
|
||||
<script>
|
||||
(function() {
|
||||
var urls = {
|
||||
"guest-gost": ["https://gost-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_GOST_CONTAINER"],
|
||||
"guest-rsa": ["https://rsa-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_RSA_CONTAINER"],
|
||||
"test3-gost": ["https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_GOST_CONTAINER"],
|
||||
"test3-rsa": ["https://rsa-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_RSA_CONTAINER"],
|
||||
"prod-gost": ["https://gost.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_GOST_CONTAINER"],
|
||||
"prod-rsa": ["https://rsa.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_RSA_CONTAINER"]
|
||||
};
|
||||
var p = document.getElementById("nsd-profile"), u = document.getElementById("nsd-url"), c = document.getElementById("nsd-container");
|
||||
if (p) p.addEventListener("change", function() {
|
||||
var v = p.value;
|
||||
if (urls[v] && (!u.value || confirm("Заменить URL и контейнер на дефолт для " + v + "?"))) { u.value = urls[v][0]; c.value = urls[v][1]; }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- LK callback -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.LK.CallbackURL}}ok{{else}}err{{end}}"></span>Callback в ЛК</h2>
|
||||
<p class="muted">{{if .Settings.LK.CallbackURL}}Callback URL: <code>{{.Settings.LK.CallbackURL}}</code>{{else}}Сейчас используется встроенный lk-emulator (он сам зарегистрировал свой адрес при старте).{{end}}</p>
|
||||
<details {{if not .Settings.LK.CallbackURL}}open{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Указать URL реального ЛК</summary>
|
||||
<form method="post" action="/admin/setup/lk" style="margin-top:12px">
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.LK.CallbackURL}}ok{{else}}warn{{end}}"></span>Callback в личный кабинет <span class="muted" style="font-size:12px;font-weight:400">(необязательно)</span></h2>
|
||||
<p class="muted">{{if .Settings.LK.CallbackURL}}<code>{{.Settings.LK.CallbackURL}}</code>{{else}}Не настроен — уведомления в ЛК отключены. Для работы с НРД не требуется.{{end}}</p>
|
||||
<form method="post" action="/admin/setup/lk" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
|
||||
<label>Callback URL</label>
|
||||
<input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="http://lk.example.com" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="http://lk.example.com">
|
||||
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{/* ============ ТЕСТЫ ============ */}}
|
||||
<section class="settings-section" id="sec-tests">
|
||||
<h1>Тесты</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Тестовый пакет роботу НРД</h2>
|
||||
<p class="muted">Робот <code>MC0012500000</code> эмулирует вторую сторону перевода. Выберите сценарий — bj-server отправит эталонный запрос через ИШ, ответ придёт во входящие. Требуется настроенный ИШ + профиль Валидаты.</p>
|
||||
<form method="post" action="/admin/setup/test-nsd" style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<select name="scenario">
|
||||
<option value="2001">2001 — Принять все бумаги</option>
|
||||
<option value="2002">2002 — Принять частично</option>
|
||||
<option value="1111">1111 — Ответ с отказом</option>
|
||||
<option value="3333">3333 — Робот принимающая сторона</option>
|
||||
</select>
|
||||
<button type="submit" class="btn">Отправить роботу</button>
|
||||
</form>
|
||||
<p class="muted" style="margin-top:6px;font-size:12px">Ответ робота — асинхронно (~30-60 сек) во входящие ИШ.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Сквозной тестовый прогон (mock/реальный)</h2>
|
||||
<p class="muted">Заявка с предзаполненными данными через всю цепочку до финального статуса.</p>
|
||||
<form method="post" action="/admin/setup/test-run" style="margin-top:12px"><button type="submit" class="btn">Запустить тестовую заявку</button></form>
|
||||
{{if .Settings.LastTest}}
|
||||
<div style="margin-top:16px;padding:14px;background:var(--card-2);border:1px solid var(--border);border-radius:10px">
|
||||
<strong>Последний прогон</strong>
|
||||
<table style="margin-top:8px">
|
||||
<tbody>
|
||||
<tr><td style="width:160px" class="muted">Статус</td><td>{{if .Settings.LastTest.OK}}<span style="color:var(--ok)">✓ успешно</span>{{else}}<span style="color:var(--err)">✗ не прошёл</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">FSM-статус</td><td><code>{{.Settings.LastTest.FinalStatus}}</code></td></tr>
|
||||
<tr><td class="muted">ClaimID</td><td><code>{{.Settings.LastTest.ClaimID}}</code> {{if .Settings.LastTest.ClaimID}}<a href="/admin/claims/{{.Settings.LastTest.ClaimID}}">→ карточка</a>{{end}}</td></tr>
|
||||
<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:8px">URL до базового хоста ЛК (без /api). При сохранении выполняется GET <code>{URL}/healthz</code>.</p>
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px;margin-top:8px">Сохранить и проверить</button>
|
||||
</form>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{/* ============ ОБНОВЛЕНИЯ ============ */}}
|
||||
<section class="settings-section" id="sec-update">
|
||||
<h1>Обновления</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Версия bj-server</h2>
|
||||
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:6px">
|
||||
<span class="stat-value" style="font-size:20px">{{.CurrentVersion}}</span>
|
||||
{{if .Settings.Update.Available}}
|
||||
{{if ne .Settings.Update.Available .CurrentVersion}}
|
||||
<span class="hero-status warn">● Доступна {{.Settings.Update.Available}}</span>
|
||||
<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Скачать и установить {{.Settings.Update.Available}}? bj-server перезапустится.');">
|
||||
<button type="submit" class="btn btn-ok">⬆ Установить {{.Settings.Update.Available}}</button>
|
||||
</form>
|
||||
{{else}}<span class="hero-status ok">● Актуальная версия</span>{{end}}
|
||||
{{end}}
|
||||
<form method="post" action="/admin/setup/update/check" style="margin:0"><button type="submit" class="btn btn-secondary">Проверить обновления</button></form>
|
||||
</div>
|
||||
{{if .Settings.Update.Notes}}<p class="muted" style="margin-top:10px">Что нового: {{.Settings.Update.Notes}}</p>{{end}}
|
||||
{{if not .Settings.Update.LastCheck.IsZero}}<p class="muted" style="margin-top:6px;font-size:12px">Последняя проверка: {{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}} — {{.Settings.Update.LastResult}}</p>{{end}}
|
||||
{{if and .License.Present (not .License.AllowsUpdates)}}<p class="muted" style="margin-top:6px;font-size:12px;color:var(--warn)">⚠ Текущий план «{{.License.Plan}}» не включает обновления.</p>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.Update.BaseURL}}ok{{else}}warn{{end}}"></span>Источник обновлений</h2>
|
||||
<p class="muted">Артефактория раздаёт подписанные релизы. Обновления проверяются по подписи Ed25519 и sha256 — без валидной подписи установка не выполняется.</p>
|
||||
<form method="post" action="/admin/setup/update" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
|
||||
<label>URL артефактории</label>
|
||||
<input type="text" name="base_url" value="{{.Settings.Update.BaseURL}}" placeholder="https://updates.example.com">
|
||||
<label>Канal</label>
|
||||
<select name="channel">
|
||||
<option value="stable" {{if eq .Settings.Update.Channel "stable"}}selected{{end}}>stable — стабильный</option>
|
||||
<option value="beta" {{if eq .Settings.Update.Channel "beta"}}selected{{end}}>beta — предварительный</option>
|
||||
</select>
|
||||
<label>Публичный ключ издателя (base64 Ed25519)</label>
|
||||
<input type="text" name="public_key" value="{{.Settings.Update.PublicKey}}" placeholder="зашит в релиз; переопределить здесь">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" name="auto_check" {{if .Settings.Update.AutoCheck}}checked{{end}} style="width:auto"> Проверять автоматически (раз в 6 часов)</label>
|
||||
<div><button type="submit" class="btn">Сохранить</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{/* ============ ЛИЦЕНЗИЯ ============ */}}
|
||||
<section class="settings-section" id="sec-license">
|
||||
<h1>Лицензия</h1>
|
||||
<div class="card">
|
||||
<h2>
|
||||
{{if .License.Valid}}<span class="dot ok"></span>Активна
|
||||
{{else if .License.Present}}<span class="dot err"></span>Недействительна
|
||||
{{else}}<span class="dot warn"></span>Не активирована{{end}}
|
||||
</h2>
|
||||
{{if .License.Present}}
|
||||
<table style="margin-top:8px">
|
||||
<tbody>
|
||||
{{if .License.Tenant}}<tr><td style="width:180px" class="muted">Организация</td><td><strong>{{.License.Tenant}}</strong></td></tr>{{end}}
|
||||
{{if .License.Plan}}<tr><td class="muted">План</td><td><span class="badge confirmed">{{.License.Plan}}</span></td></tr>{{end}}
|
||||
{{if not .License.ExpiresAt.IsZero}}<tr><td class="muted">Действует до</td><td>{{.License.ExpiresAt.Format "02.01.2006"}} {{if .License.Valid}}<span class="muted">(осталось {{.License.DaysLeft}} дн.)</span>{{end}}</td></tr>{{end}}
|
||||
<tr><td class="muted">Обновления</td><td>{{if .License.AllowsUpdates}}<span style="color:var(--ok)">включены</span>{{else}}<span class="muted">не входят в план</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Статус</td><td>{{.License.Message}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Лицензионный ключ не введён. Без лицензии сервис работает, но автообновления заблокированы.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Активация</h2>
|
||||
<p class="muted">Вставьте лицензионный ключ, полученный от поставщика. Проверка офлайн по подписи — связь с сервером лицензий не требуется.</p>
|
||||
<form method="post" action="/admin/setup/license" style="margin-top:12px;display:grid;gap:10px;max-width:720px">
|
||||
<label>Лицензионный ключ</label>
|
||||
<textarea name="key" rows="3" style="font-family:ui-monospace,monospace;font-size:11px" placeholder="payload.signature.keyid">{{.Settings.License.Key}}</textarea>
|
||||
<details>
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Публичный ключ издателя (если не зашит)</summary>
|
||||
<input type="text" name="public_key" value="{{.Settings.License.PublicKey}}" placeholder="base64 Ed25519" style="margin-top:8px;width:100%">
|
||||
</details>
|
||||
<div><button type="submit" class="btn">Активировать</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Тестовый прогон -->
|
||||
<div class="card">
|
||||
<h2>Тестовый прогон сквозной заявки</h2>
|
||||
<p class="muted">Создаст заявку с предзаполненными данными (инвестор Иванов И.И., 1500 акций Газпрома, ИИС T03), отправит её через всю цепочку и дождётся финального статуса. Если ИШ НРД настроен — пойдёт в реальный ИШ; иначе через mock с задержкой 3 сек.</p>
|
||||
<form method="post" action="/admin/setup/test-run" style="margin-top:12px">
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:10px 20px;border-radius:4px;font-weight:600">Запустить тестовую заявку</button>
|
||||
</form>
|
||||
{{if .Settings.LastTest}}
|
||||
<div style="margin-top:16px;padding:12px;background:var(--bg);border:1px solid var(--border);border-radius:4px">
|
||||
<strong>Последний прогон:</strong>
|
||||
<table style="margin-top:8px">
|
||||
<tr><td style="width:160px" class="muted">Статус</td><td>{{if .Settings.LastTest.OK}}<span style="color:var(--ok)">✓ успешно</span>{{else}}<span style="color:var(--err)">✗ не прошёл</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Финальный FSM-статус</td><td><code>{{.Settings.LastTest.FinalStatus}}</code></td></tr>
|
||||
<tr><td class="muted">ClaimID</td><td><code>{{.Settings.LastTest.ClaimID}}</code> {{if .Settings.LastTest.ClaimID}}<a href="/admin/claims/{{.Settings.LastTest.ClaimID}}">→ открыть карточку</a>{{end}}</td></tr>
|
||||
<tr><td class="muted">Когда</td><td>{{.Settings.LastTest.StartedAt.Format "02.01.2006 15:04:05"}} — длительность {{.Settings.LastTest.FinishedAt.Sub .Settings.LastTest.StartedAt}}</td></tr>
|
||||
<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
// Переключение разделов админ-центра + запоминание выбранного (hash).
|
||||
var navs = document.querySelectorAll('.settings-nav button');
|
||||
var secs = document.querySelectorAll('.settings-section');
|
||||
function show(id) {
|
||||
navs.forEach(function(b){ b.classList.toggle('active', b.dataset.sec === id); });
|
||||
secs.forEach(function(s){ s.classList.toggle('active', s.id === 'sec-' + id); });
|
||||
try { history.replaceState(null, '', '#' + id); } catch(e) {}
|
||||
}
|
||||
navs.forEach(function(b){ b.addEventListener('click', function(){ show(b.dataset.sec); }); });
|
||||
var h = (location.hash || '').replace('#','');
|
||||
if (h && document.getElementById('sec-' + h)) show(h);
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<h2>Что подключается на следующих этапах</h2>
|
||||
<table>
|
||||
<tr><td class="muted" style="width:240px">PostgreSQL (схема m2m_core)</td><td>M2-шаг-3: pgx-репозиторий вместо MemoryRepository. Миграция готова — <code>migrations/m2m-core/001__deals.sql</code>.</td></tr>
|
||||
<tr><td class="muted">crypto-service · КриптоПро JCP</td><td>M4: положить <code>jcp.jar</code> в <code>services/crypto-service/libs/</code>, выставить <code>BJ_CRYPTO_PROVIDER=cryptopro</code>, заполнить keystore профиля. Проверка — gRPC Health должна вернуть <code>provider=cryptopro, ok=true</code>.</td></tr>
|
||||
<tr><td class="muted">crypto-service · Валидата PKCS#11</td><td>M4: установить АПК «Валидата Клиент L» (<code>zpki</code>, <code>zsdk</code>), выставить <code>BJ_CRYPTO_PROVIDER=validata</code> и путь к <code>libvdpkcs11.so</code>. Проверка — Health PKCS#11 должна вернуть <code>provider=validata, ok=true</code>.</td></tr>
|
||||
<tr><td class="muted">nsd-adapter · ИШ НРД</td><td>M3: установить ИШ, выставить <code>BJ_NSD_PROFILE=guest-gost</code> или иной, <code>BJ_NSD_IGW_URL=http://localhost:8080</code>. Без этого сейчас используется <code>nsdadapter/mock</code> с эмуляцией ответов через 3 сек.</td></tr>
|
||||
<tr><td class="muted">Реальный ЛК (ESIA Finance)</td><td>M4: согласовать <code>docs/lk-contract/v1/openapi.yaml</code> с командой ЛК, выставить <code>BJ_LK_CALLBACK_URL</code> на реальный адрес. Сейчас callback идёт в встроенный lk-emulator.</td></tr>
|
||||
</table>
|
||||
|
||||
@@ -1,48 +1,42 @@
|
||||
{{define "content"}}
|
||||
<style>
|
||||
.wizard-progress { display:flex; gap:6px; margin-bottom:24px; }
|
||||
.wizard-step { flex:1; padding:12px 8px; border-radius:6px; background:var(--card); border:1px solid var(--border); text-align:center; position:relative; }
|
||||
.wizard-step.done { background:rgba(63,191,108,0.12); border-color:var(--ok); }
|
||||
.wizard-step.current { background:rgba(91,157,255,0.15); border-color:var(--accent); }
|
||||
.wizard-step-num { display:block; font-size:11px; color:var(--muted); margin-bottom:4px; }
|
||||
.wizard-step-name { font-size:13px; font-weight:600; }
|
||||
.wizard-step.done .wizard-step-num::after { content:" ✓"; color:var(--ok); }
|
||||
.wiz-head { text-align:center; padding:8px 0 4px; }
|
||||
.wiz-head h1 { font-size:26px; font-weight:720; letter-spacing:-0.02em; margin:0 0 6px; }
|
||||
.wiz-head p { color:var(--muted); margin:0; }
|
||||
.wizard-progress { display:flex; align-items:center; justify-content:center; gap:0; margin:24px auto 28px; max-width:680px; }
|
||||
.wstep { display:flex; flex-direction:column; align-items:center; gap:7px; flex:1; position:relative; }
|
||||
.wstep .bub { width:34px; height:34px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:14px; font-weight:650; background:var(--card-2); border:1.5px solid var(--border-strong); color:var(--muted); z-index:1; transition:all .2s; }
|
||||
.wstep .lbl { font-size:12px; color:var(--muted); font-weight:550; }
|
||||
.wstep::before { content:""; position:absolute; top:17px; left:-50%; width:100%; height:2px; background:var(--border); z-index:0; }
|
||||
.wstep:first-child::before { display:none; }
|
||||
.wstep.done .bub { background:var(--ok); border-color:var(--ok); color:#fff; }
|
||||
.wstep.done .lbl { color:var(--text-2); }
|
||||
.wstep.done::before { background:var(--ok); }
|
||||
.wstep.current .bub { background:var(--accent); border-color:var(--accent); color:#fff; box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
.wstep.current .lbl { color:var(--accent); font-weight:650; }
|
||||
.tooltip { display:inline-block; background:var(--border); color:var(--muted); border-radius:50%; width:16px; height:16px; line-height:16px; text-align:center; font-size:11px; cursor:help; margin-left:4px; }
|
||||
.where { font-size:12px; color:var(--accent); margin-left:8px; }
|
||||
.help-block { background:rgba(91,157,255,0.07); border-left:3px solid var(--accent); padding:10px 14px; margin:10px 0; font-size:13px; }
|
||||
.help-block { background:var(--accent-weak); border-left:3px solid var(--accent); padding:10px 14px; margin:10px 0; font-size:13px; border-radius:0 8px 8px 0; }
|
||||
.help-block strong { color:var(--accent); }
|
||||
.wiz-card { max-width:680px; margin:0 auto; }
|
||||
</style>
|
||||
|
||||
<div class="card">
|
||||
<h2>Мастер настройки</h2>
|
||||
<p class="muted">Пошаговая настройка системы. Подходит для первого запуска. После каждого шага состояние сохраняется и можно вернуться позже.</p>
|
||||
<div class="wiz-head">
|
||||
<h1>Настройка Bridge&Join</h1>
|
||||
<p>Пошаговый мастер первого запуска — состояние сохраняется после каждого шага</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-progress">
|
||||
<div class="wizard-step {{if .Done.Postgres}}done{{end}} {{if eq .Step 1}}current{{end}}">
|
||||
<span class="wizard-step-num">Шаг 1</span>
|
||||
<span class="wizard-step-name">PostgreSQL</span>
|
||||
</div>
|
||||
<div class="wizard-step {{if .Done.Crypto}}done{{end}} {{if eq .Step 2}}current{{end}}">
|
||||
<span class="wizard-step-num">Шаг 2</span>
|
||||
<span class="wizard-step-name">КриптоПро / Рутокен</span>
|
||||
</div>
|
||||
<div class="wizard-step {{if .Done.Certs}}done{{end}} {{if eq .Step 3}}current{{end}}">
|
||||
<span class="wizard-step-num">Шаг 3</span>
|
||||
<span class="wizard-step-name">Сертификаты</span>
|
||||
</div>
|
||||
<div class="wizard-step {{if .Done.NSD}}done{{end}} {{if eq .Step 4}}current{{end}}">
|
||||
<span class="wizard-step-num">Шаг 4</span>
|
||||
<span class="wizard-step-name">Шлюз НРД</span>
|
||||
</div>
|
||||
<div class="wizard-step {{if .Done.TestRun}}done{{end}} {{if eq .Step 5}}current{{end}}">
|
||||
<span class="wizard-step-num">Шаг 5</span>
|
||||
<span class="wizard-step-name">Тестовая заявка</span>
|
||||
</div>
|
||||
<div class="wstep {{if .Done.Postgres}}done{{end}} {{if eq .Step 1}}current{{end}}"><span class="bub">{{if .Done.Postgres}}✓{{else}}1{{end}}</span><span class="lbl">База</span></div>
|
||||
<div class="wstep {{if .Done.Crypto}}done{{end}} {{if eq .Step 2}}current{{end}}"><span class="bub">{{if .Done.Crypto}}✓{{else}}2{{end}}</span><span class="lbl">Валидата</span></div>
|
||||
<div class="wstep {{if .Done.Certs}}done{{end}} {{if eq .Step 3}}current{{end}}"><span class="bub">{{if .Done.Certs}}✓{{else}}3{{end}}</span><span class="lbl">Сертификаты</span></div>
|
||||
<div class="wstep {{if .Done.NSD}}done{{end}} {{if eq .Step 4}}current{{end}}"><span class="bub">{{if .Done.NSD}}✓{{else}}4{{end}}</span><span class="lbl">Шлюз НРД</span></div>
|
||||
<div class="wstep {{if .Done.TestRun}}done{{end}} {{if eq .Step 5}}current{{end}}"><span class="bub">{{if .Done.TestRun}}✓{{else}}5{{end}}</span><span class="lbl">Проверка</span></div>
|
||||
</div>
|
||||
|
||||
{{if .Flash}}<div class="card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
|
||||
{{if .Flash}}<div class="card wiz-card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
|
||||
|
||||
<div class="wiz-card">
|
||||
{{/* ============= ШАГ 1: PostgreSQL ============= */}}
|
||||
{{if eq .Step 1}}
|
||||
<div class="card">
|
||||
@@ -82,60 +76,35 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ============= ШАГ 2: Крипто ============= */}}
|
||||
{{/* ============= ШАГ 2: Крипто (Валидата) ============= */}}
|
||||
{{if eq .Step 2}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Done.Crypto}}ok{{else}}err{{end}}"></span>Шаг 2. Крипто-провайдер (КриптоПро CSP или Рутокен)</h2>
|
||||
<h2><span class="dot {{if .Done.Crypto}}ok{{else}}err{{end}}"></span>Шаг 2. СКЗИ «Валидата Клиент L»</h2>
|
||||
<p>СКЗИ нужен для подписи XMLDSig и проверки квитанций НРД.</p>
|
||||
|
||||
<div class="help-block">
|
||||
<strong>Что это?</strong> КриптоПро CSP — российский криптопровайдер с поддержкой ГОСТ Р 34.10-2012. Рутокен ЭЦП 2.0 — USB-токен для безопасного хранения ключей. Можно использовать оба: CSP — для серверной части, Рутокен — для подписи действий оператора.<br>
|
||||
<strong>Где взять?</strong> Дистрибутив КриптоПро CSP 5.0 R4 — <a href="https://www.cryptopro.ru/products/csp/downloads" target="_blank">cryptopro.ru/products/csp/downloads</a> (нужна регистрация в личном кабинете). Лицензия — там же или у дилера. Демо-лицензия на 3 месяца встроена в дистрибутив.
|
||||
<strong>Что это?</strong> АПК «Валидата Клиент L» — российское СКЗИ с поддержкой ГОСТ Р 34.10-2012, поставляемое НРД для подключения к ЭДО. На Linux работает напрямую через PKCS#11 — отдельной лицензии и регистрационных данных <em>не требует</em>.<br>
|
||||
<strong>Где взять?</strong> Дистрибутив для Astra Linux SE — <a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. Установка — <code>sudo dpkg -i zpki-*.deb zsdk-*.deb</code> (см. <a href="/admin/help/crypto">/admin/help/crypto</a>).
|
||||
</div>
|
||||
|
||||
{{if not .CryptoProInstalled}}
|
||||
<div style="background:var(--bg);border:1px solid var(--accent);border-radius:6px;padding:14px;margin-top:12px">
|
||||
<h3 style="margin:0 0 8px 0;font-size:15px">Шаг 2a — загрузить и установить КриптоПро CSP</h3>
|
||||
<p class="muted" style="margin:0 0 10px 0">Скачайте с <code>cryptopro.ru</code> архив <code>linux-amd64.tgz</code> или <code>linux-amd64.tar</code> (КриптоПро CSP 5.0 R4 для Linux) и загрузите его сюда. Bj-server сам распакует и установит нужные пакеты.</p>
|
||||
<form method="post" action="/admin/setup/crypto/install" enctype="multipart/form-data" style="margin:0">
|
||||
<input type="file" name="dist" accept=".tar,.tgz,.tar.gz,.rpm" required style="margin-right:8px">
|
||||
<button type="submit" class="btn">Загрузить и установить</button>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
<p style="color:var(--ok);margin-top:12px">✓ КриптоПро CSP установлен. Версия: <code>{{.CryptoProVersion}}</code></p>
|
||||
{{end}}
|
||||
|
||||
<details style="margin-top:14px" {{if not .Done.Crypto}}open{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent)">Шаг 2b — указать провайдер и путь к PKCS#11 модулю</summary>
|
||||
<summary style="cursor:pointer;color:var(--accent)">Параметры провайдера</summary>
|
||||
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px">
|
||||
<div>
|
||||
<label>Провайдер <span class="tooltip" title="cryptopro — КриптоПро CSP, rutoken — Рутокен ЭЦП 2.0 через драйверы CSP, stub — без криптографии (демо-режим без подписи)">?</span></label>
|
||||
<label>Провайдер <span class="tooltip" title="validata — АПК «Валидата Клиент L»; stub — без криптографии (демо-режим без подписи)">?</span></label>
|
||||
<select name="provider" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
|
||||
<option value="cryptopro" {{if eq .Settings.Crypto.Provider "cryptopro"}}selected{{end}}>КриптоПро CSP (серверная подпись, ключи на диске)</option>
|
||||
<option value="rutoken" {{if eq .Settings.Crypto.Provider "rutoken"}}selected{{end}}>Рутокен ЭЦП 2.0 (подпись оператора)</option>
|
||||
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата Клиент L</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Путь к модулю PKCS#11 <span class="tooltip" title="Файл libcppkcs11.so входит в пакет lsb-cprocsp-pkcs11-64. После установки КриптоПро CSP он находится в /opt/cprocsp/lib/amd64/">?</span></label>
|
||||
<input type="text" name="jcp_path" value="{{if .Settings.Crypto.JCPPath}}{{.Settings.Crypto.JCPPath}}{{else}}/opt/cprocsp/lib/amd64/libcppkcs11.so{{end}}" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
<label>Путь к модулю PKCS#11 <span class="tooltip" title="После установки пакета zpki модуль находится в /opt/Validata/VDCSP/lib/amd64/libvdpkcs11.so">?</span></label>
|
||||
<input type="text" name="module_path" value="{{if .Settings.Crypto.ModulePath}}{{.Settings.Crypto.ModulePath}}{{else}}/opt/Validata/VDCSP/lib/amd64/libvdpkcs11.so{{end}}" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
</div>
|
||||
<button type="submit" class="btn">Сохранить</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{{if and .Done.Crypto (not .Settings.Crypto.LicenseKey)}}
|
||||
<details open style="margin-top:14px">
|
||||
<summary style="cursor:pointer;color:var(--accent)">Шаг 2c — активировать лицензию (если демо не подходит)</summary>
|
||||
<form method="post" action="/admin/setup/crypto/activate" style="margin-top:12px">
|
||||
<label>Серийный номер лицензии КриптоПро <span class="tooltip" title="Формат XXXXX-XXXXX-XXXXX-XXXXX-XXXXX. Выдаётся при покупке лицензии. Демо-лицензия на 3 месяца встроена в дистрибутив — её активировать не нужно.">?</span></label>
|
||||
<input type="text" name="license" placeholder="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%;margin-top:6px">
|
||||
<button type="submit" class="btn" style="margin-top:8px">Активировать</button>
|
||||
</form>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between">
|
||||
<a href="/admin/wizard?step=1" class="btn" style="background:var(--card);text-decoration:none">← К шагу 1</a>
|
||||
{{if .Done.Crypto}}<a href="/admin/wizard?step=3" class="btn" style="text-decoration:none">К шагу 3 →</a>{{else}}<a href="/admin/wizard?step=3&skip=crypto" class="btn" style="background:var(--card);text-decoration:none">Пропустить →</a>{{end}}
|
||||
@@ -156,72 +125,17 @@
|
||||
<li>В режиме <strong>ИШ НРД</strong>: подписывает <em>сам ИШ</em> — наш ключ настраивается <em>в ИШ</em>, не здесь. Bj-server нужен только для проверки квитанций НРД и (опц.) расшифровки 4BROKER01.</li>
|
||||
<li>В режиме <strong>прямого ONYX без ИШ</strong>: bj-server подписывает сам — нужен наш ключ с приватной частью.</li>
|
||||
</ul>
|
||||
<strong>Что куда загружать (по режиму):</strong>
|
||||
<table style="margin-top:6px;font-size:13px">
|
||||
<thead><tr><th>Что</th><th>Зачем</th><th>Куда</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Корневой сертификат <strong>УЦ МБ</strong> (<a href="https://ca.moex.com/" target="_blank">ca.moex.com</a>)</td><td>проверка цепочки нашей подписи и подписей контрагентов</td><td><code>mroot</code></td></tr>
|
||||
<tr><td>Корневой и подписной <strong>УЦ НРД</strong> (<a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank">nsd.ru/workflow/system/cryptography/</a>)</td><td>проверка квитанций от НРД</td><td><code>mroot</code> + <code>uRoot</code></td></tr>
|
||||
<tr><td>Наш сертификат + ключ <em>(только если без ИШ)</em></td><td>подпись отправляемых пакетов + расшифровка 4BROKER01</td><td><code>uMy</code> — с приватным ключом</td></tr>
|
||||
<tr><td>Сертификаты с Рутокена</td><td>сами появятся в таблице ниже после подключения USB</td><td>не грузить</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<strong>Что куда загружать:</strong>
|
||||
<ul style="margin:6px 0 6px 16px">
|
||||
<li>Корневой сертификат <strong>УЦ МБ</strong> (<a href="https://ca.moex.com/" target="_blank">ca.moex.com</a>) — для проверки цепочки нашей подписи и подписей контрагентов.</li>
|
||||
<li>Корневой и подписной <strong>УЦ НРД</strong> (<a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank">nsd.ru/workflow/system/cryptography/</a>) — для проверки квитанций от НРД.</li>
|
||||
<li>Наш сертификат с приватным ключом <em>(только если без ИШ)</em> — для подписи пакетов и расшифровки 4BROKER01.</li>
|
||||
</ul>
|
||||
<p class="muted" style="margin-top:6px">Полный регламент PKI — в «Правилах ЭДО НРД» и «Руководстве по установке ИШ» (<a href="https://www.nsd.ru/ru/documents/workflow/" target="_blank">nsd.ru/ru/documents/workflow/</a>) — в наших PDF этого не описано.</p>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:18px">Импорт сертификата</h3>
|
||||
<form method="post" action="/admin/setup/crypto/import-cert" enctype="multipart/form-data" style="margin-top:8px;display:grid;gap:8px;grid-template-columns:1fr 1fr 1fr auto;align-items:end">
|
||||
<div>
|
||||
<label class="muted" style="font-size:12px">Файл</label>
|
||||
<input type="file" name="cert" accept=".cer,.crt,.pfx,.p12" required style="width:100%">
|
||||
</div>
|
||||
<div>
|
||||
<label class="muted" style="font-size:12px">Хранилище</label>
|
||||
<select name="store" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
<option value="uMy">uMy — мой (с приватным ключом)</option>
|
||||
<option value="mroot">mroot — корневой УЦ</option>
|
||||
<option value="uRoot">uRoot — промежуточный УЦ</option>
|
||||
<option value="uCA">uCA — сертификаты УЦ НРД</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="muted" style="font-size:12px">PIN (для .pfx)</label>
|
||||
<input type="password" name="pin" placeholder="опц." style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
</div>
|
||||
<button type="submit" class="btn">Импортировать</button>
|
||||
</form>
|
||||
|
||||
<h3 style="margin-top:18px">Контейнеры на подключённых носителях (флешка/Рутокен)</h3>
|
||||
{{if .FlashContainers}}
|
||||
<p class="muted">Найдено {{len .FlashContainers}} контейнер(а) формата <code>name.000</code> на смонтированных USB-носителях. Нажмите «Скопировать в локальное хранилище» — папка будет перенесена в <code>/var/opt/cprocsp/keys/$USER/</code>, после чего контейнер виден как <code>\\.\HDIMAGE\name</code> и работает даже без вставленной флешки.</p>
|
||||
<table style="margin-top:8px">
|
||||
<thead><tr><th>Носитель</th><th>Имя контейнера</th><th>Файлы</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .FlashContainers}}
|
||||
<tr>
|
||||
<td><code style="font-size:12px">{{.Mountpoint}}</code></td>
|
||||
<td><strong>{{.Name}}</strong></td>
|
||||
<td><span class="muted" style="font-size:11px">{{len .Files}} файлов</span></td>
|
||||
<td>{{if .AlreadyImported}}<span style="color:var(--ok)">уже в HDIMAGE</span>{{else}}<span class="muted">только на флешке</span>{{end}}</td>
|
||||
<td>
|
||||
{{if not .AlreadyImported}}
|
||||
<form method="post" action="/admin/setup/crypto/copy-container" style="margin:0">
|
||||
<input type="hidden" name="src" value="{{.Path}}">
|
||||
<button type="submit" class="btn" style="background:var(--ok);padding:6px 12px;font-size:12px">Скопировать в локальное хранилище</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="margin-top:8px">После копирования: импортировать сертификат из контейнера командой <code>certmgr -inst -cont '\\.\HDIMAGE\{имя}' -store uMy</code> — это пропишет сертификат в видимое хранилище. (UI-кнопку для этого добавим следующим шагом.)</p>
|
||||
{{else}}
|
||||
<p class="muted">Подключённые USB-носители с контейнерами КриптоПро формата <code>name.000</code> не обнаружены. Поиск идёт в <code>/run/media/$USER/</code>, <code>/media/$USER/</code>, <code>/media/</code>, <code>/mnt/</code>. Вставьте флешку с контейнером и обновите страницу — контейнер появится в этой таблице автоматически.</p>
|
||||
{{end}}
|
||||
|
||||
<h3 style="margin-top:18px">Авто-загрузка сертификатов УЦ НРД</h3>
|
||||
<p class="muted">Самый простой способ — добавить прямые URL <code>.cer</code>-файлов УЦ НРД (с <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и включить авто-обновление. Раз в сутки система перепроверит и переустановит изменённые сертификаты.</p>
|
||||
<p class="muted">Самый простой способ — добавить прямые URL <code>.cer</code>-файлов УЦ НРД (с <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и включить авто-обновление. Раз в сутки система перепроверит и сохранит изменённые сертификаты в <code>/var/lib/bj/ca-certs/</code>.</p>
|
||||
<form method="post" action="/admin/setup/cacerts" style="margin-top:8px;display:grid;gap:10px">
|
||||
<textarea name="urls" rows="3" placeholder="https://www.nsd.ru/path/to/root-ca.cer https://www.nsd.ru/path/to/sub-ca.cer" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
|
||||
{{end}}</textarea>
|
||||
@@ -292,8 +206,8 @@
|
||||
<input type="text" name="igw_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
</div>
|
||||
<div>
|
||||
<label>Ключевой контейнер НРД <span class="tooltip" title="Имя контейнера КриптоПро с ключами ЭДО НРД (выдаются УЦ НРД). Формат: \\.\HDIMAGE\нрд-имя или нрд-имя.000">?</span></label>
|
||||
<input type="text" name="key_container" value="{{.Settings.NSD.KeyContainer}}" placeholder="\\.\HDIMAGE\nrd-edo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
<label>Ключевой контейнер НРД <span class="tooltip" title="Имя контейнера Валидаты с ключами ЭДО НРД (выдаются УЦ НРД)">?</span></label>
|
||||
<input type="text" name="key_container" value="{{.Settings.NSD.KeyContainer}}" placeholder="nrd-edo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
</div>
|
||||
<button type="submit" class="btn" style="justify-self:start">Сохранить</button>
|
||||
</form>
|
||||
@@ -359,4 +273,5 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -1,72 +1,287 @@
|
||||
{{define "layout"}}<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<html lang="ru" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{.Title}} · lk-gateway</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · Bridge&Join</title>
|
||||
<style>
|
||||
:root { --bg:#0f1115; --card:#1a1d24; --border:#2a2f3a; --text:#e8eaed; --muted:#8b94a3; --accent:#5b9dff; --ok:#3fbf6c; --warn:#e8b13a; --err:#e85a5a; }
|
||||
/* ===================== Дизайн-система ===================== */
|
||||
/* Светлая тема (по умолчанию) */
|
||||
:root {
|
||||
--bg:#f5f6f8; --bg-elev:#ffffff; --card:#ffffff; --card-2:#fafbfc;
|
||||
--border:#e4e7ec; --border-strong:#d0d5dd;
|
||||
--text:#1a1f29; --text-2:#475067; --muted:#7a8499;
|
||||
--accent:#2563eb; --accent-weak:rgba(37,99,235,0.10); --accent-strong:#1d4ed8;
|
||||
--ok:#16a34a; --ok-weak:rgba(22,163,74,0.12);
|
||||
--warn:#d97706; --warn-weak:rgba(217,119,6,0.12);
|
||||
--err:#dc2626; --err-weak:rgba(220,38,38,0.12);
|
||||
--brand:#c5203e; /* MOEX-красный для акцентов бренда */
|
||||
--shadow:0 1px 2px rgba(16,24,40,0.06), 0 1px 3px rgba(16,24,40,0.10);
|
||||
--shadow-lg:0 8px 24px rgba(16,24,40,0.12);
|
||||
--radius:10px; --radius-sm:6px;
|
||||
}
|
||||
/* Тёмная тема */
|
||||
[data-theme="dark"] {
|
||||
--bg:#0f1115; --bg-elev:#161922; --card:#1a1d24; --card-2:#20242e;
|
||||
--border:#2a2f3a; --border-strong:#3a4150;
|
||||
--text:#e8eaed; --text-2:#b4bcc9; --muted:#8b94a3;
|
||||
--accent:#5b9dff; --accent-weak:rgba(91,157,255,0.14); --accent-strong:#7db0ff;
|
||||
--ok:#3fbf6c; --ok-weak:rgba(63,191,108,0.16);
|
||||
--warn:#e8b13a; --warn-weak:rgba(232,177,58,0.16);
|
||||
--err:#e85a5a; --err-weak:rgba(232,90,90,0.16);
|
||||
--brand:#ff5a78;
|
||||
--shadow:0 1px 2px rgba(0,0,0,0.3); --shadow-lg:0 8px 28px rgba(0,0,0,0.5);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); }
|
||||
header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 24px; }
|
||||
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
header nav a { color: var(--muted); text-decoration: none; margin-right: 16px; font-size: 14px; }
|
||||
header nav a:hover, header nav a.active { color: var(--text); }
|
||||
main { padding: 24px; max-width: 1280px; margin: 0 auto; }
|
||||
h2 { font-size: 16px; margin: 0 0 12px; font-weight: 600; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 16px; margin-bottom: 16px; }
|
||||
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
.stat { padding: 12px; background: var(--card); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.stat-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.stat-value { font-size: 22px; font-weight: 600; margin-top: 4px; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
.dot.err { background: var(--err); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
tr:hover td { background: rgba(91,157,255,0.05); }
|
||||
a { color: var(--accent); }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.badge.draft, .badge.validated, .badge.submitted_to_nsd { background: rgba(91,157,255,0.15); color: #5b9dff; }
|
||||
.badge.awaiting_decision { background: rgba(232,177,58,0.15); color: var(--warn); }
|
||||
.badge.confirmed, .badge.awaiting_sub16, .badge.done { background: rgba(63,191,108,0.15); color: var(--ok); }
|
||||
.badge.rejected, .badge.timed_out { background: rgba(232,90,90,0.15); color: var(--err); }
|
||||
.badge.manual_approval { background: rgba(232,177,58,0.15); color: var(--warn); }
|
||||
code { background: var(--border); padding: 2px 6px; border-radius: 3px; font-size: 12px; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
pre { background: #0a0c10; border: 1px solid var(--border); border-radius: 4px; padding: 12px; font-size: 12px; overflow: auto; max-height: 400px; }
|
||||
button, .btn { background: var(--accent); color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||||
button:hover, .btn:hover { opacity: .9; }
|
||||
html, body { margin:0; padding:0; }
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--text); line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ---- Шапка ---- */
|
||||
.topbar {
|
||||
display:flex; align-items:center; gap:20px;
|
||||
padding:0 24px; height:58px;
|
||||
background: var(--bg-elev); border-bottom:1px solid var(--border);
|
||||
position:sticky; top:0; z-index:100;
|
||||
}
|
||||
.brand { display:flex; align-items:center; gap:9px; font-weight:700; font-size:16px; letter-spacing:-0.01em; }
|
||||
.brand .logo { width:26px; height:26px; border-radius:7px; background:linear-gradient(135deg,var(--accent),var(--brand)); display:inline-block; }
|
||||
.nav { display:flex; align-items:center; gap:2px; margin-left:8px; }
|
||||
.nav .group-label { font-size:10px; text-transform:uppercase; letter-spacing:0.06em; color:var(--muted); padding:0 8px 0 14px; border-left:1px solid var(--border); margin-left:6px; }
|
||||
.nav a {
|
||||
color:var(--text-2); text-decoration:none; font-size:13.5px; font-weight:500;
|
||||
padding:7px 11px; border-radius:7px; white-space:nowrap;
|
||||
}
|
||||
.nav a:hover { background:var(--card-2); color:var(--text); }
|
||||
.nav a.active { background:var(--accent-weak); color:var(--accent); }
|
||||
.topbar-right { margin-left:auto; display:flex; align-items:center; gap:14px; }
|
||||
.theme-toggle {
|
||||
background:var(--card-2); border:1px solid var(--border); color:var(--text-2);
|
||||
width:34px; height:34px; border-radius:8px; cursor:pointer; font-size:15px;
|
||||
display:flex; align-items:center; justify-content:center; padding:0;
|
||||
}
|
||||
.theme-toggle:hover { background:var(--border); color:var(--text); }
|
||||
.topbar .clock { font-size:12.5px; color:var(--muted); font-variant-numeric:tabular-nums; }
|
||||
|
||||
/* ---- Контент ---- */
|
||||
main { padding:24px; max-width:1200px; margin:0 auto; }
|
||||
h1 { font-size:22px; font-weight:680; margin:0 0 4px; letter-spacing:-0.01em; }
|
||||
h2 { font-size:15px; font-weight:650; margin:0 0 12px; }
|
||||
h3 { font-size:13.5px; font-weight:600; margin:14px 0 6px; }
|
||||
p { margin:0 0 10px; }
|
||||
a { color:var(--accent); text-decoration:none; }
|
||||
a:hover { text-decoration:underline; }
|
||||
|
||||
/* ---- Карточки ---- */
|
||||
.card {
|
||||
background:var(--card); border:1px solid var(--border); border-radius:var(--radius);
|
||||
padding:18px 20px; margin-bottom:16px; box-shadow:var(--shadow);
|
||||
}
|
||||
.card h2:first-child { margin-top:0; }
|
||||
.grid { display:grid; gap:14px; grid-template-columns:repeat(auto-fit, minmax(220px,1fr)); }
|
||||
|
||||
/* ---- Статы ---- */
|
||||
.stat { padding:16px 18px; background:var(--card); border:1px solid var(--border); border-radius:var(--radius); box-shadow:var(--shadow); }
|
||||
.stat-label { font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; font-weight:600; }
|
||||
.stat-value { font-size:26px; font-weight:700; margin-top:6px; letter-spacing:-0.02em; }
|
||||
|
||||
/* ---- Индикаторы ---- */
|
||||
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:7px; vertical-align:middle; }
|
||||
.dot.ok { background:var(--ok); } .dot.warn { background:var(--warn); } .dot.err { background:var(--err); }
|
||||
|
||||
/* ---- Таблицы ---- */
|
||||
table { width:100%; border-collapse:collapse; font-size:13.5px; }
|
||||
th, td { text-align:left; padding:9px 12px; border-bottom:1px solid var(--border); }
|
||||
th { color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.04em; }
|
||||
tbody tr:hover td { background:var(--accent-weak); }
|
||||
|
||||
/* ---- Бейджи статусов ---- */
|
||||
.badge { display:inline-block; padding:3px 9px; border-radius:20px; font-size:11px; font-weight:600; }
|
||||
.badge.draft, .badge.validated, .badge.submitted_to_nsd { background:var(--accent-weak); color:var(--accent); }
|
||||
.badge.awaiting_decision, .badge.manual_approval { background:var(--warn-weak); color:var(--warn); }
|
||||
.badge.confirmed, .badge.awaiting_sub16, .badge.done { background:var(--ok-weak); color:var(--ok); }
|
||||
.badge.rejected, .badge.timed_out, .badge.err { background:var(--err-weak); color:var(--err); }
|
||||
.badge.ok { background:var(--ok-weak); color:var(--ok); }
|
||||
|
||||
/* ---- Код / preformatted ---- */
|
||||
code { background:var(--card-2); border:1px solid var(--border); padding:1.5px 6px; border-radius:5px; font-size:12px; font-family:ui-monospace,"SF Mono",Menlo,monospace; }
|
||||
pre { background:var(--card-2); border:1px solid var(--border); border-radius:var(--radius-sm); padding:14px; font-size:12px; overflow:auto; max-height:420px; font-family:ui-monospace,"SF Mono",Menlo,monospace; }
|
||||
.muted { color:var(--muted); font-size:13px; }
|
||||
|
||||
/* ---- Кнопки ---- */
|
||||
button, .btn {
|
||||
background:var(--accent); color:#fff; border:1px solid var(--accent);
|
||||
padding:9px 16px; border-radius:8px; cursor:pointer; font-size:13.5px; font-weight:550;
|
||||
font-family:inherit; transition:filter .15s;
|
||||
}
|
||||
button:hover, .btn:hover { filter:brightness(1.07); text-decoration:none; }
|
||||
.btn-secondary { background:var(--card-2); color:var(--text); border-color:var(--border-strong); }
|
||||
.btn-ok { background:var(--ok); border-color:var(--ok); }
|
||||
.btn-warn { background:var(--warn); border-color:var(--warn); color:#fff; }
|
||||
.btn-danger { background:var(--err); border-color:var(--err); }
|
||||
.btn-ghost { background:transparent; color:var(--accent); border-color:transparent; }
|
||||
|
||||
/* ---- Формы ---- */
|
||||
input, select, textarea {
|
||||
padding:9px 11px; background:var(--bg-elev); border:1px solid var(--border-strong);
|
||||
color:var(--text); border-radius:8px; font:inherit; font-size:13.5px;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline:2px solid var(--accent-weak); border-color:var(--accent); }
|
||||
label { font-size:13px; font-weight:500; }
|
||||
|
||||
/* ---- Баннер режима эмуляции ---- */
|
||||
.banner-mock { background:var(--warn-weak); border-bottom:1px solid var(--warn); padding:10px 24px; display:flex; align-items:center; gap:12px; font-size:13px; }
|
||||
|
||||
/* ---- Hero (приветствие + статус) ---- */
|
||||
.hero { padding:8px 0 22px; }
|
||||
.hero-greeting { font-size:28px; font-weight:720; letter-spacing:-0.02em; margin:0 0 6px; }
|
||||
.hero-status { display:inline-flex; align-items:center; gap:9px; font-size:15px; font-weight:550; padding:7px 16px; border-radius:24px; }
|
||||
.hero-status.ok { background:var(--ok-weak); color:var(--ok); }
|
||||
.hero-status.warn { background:var(--warn-weak); color:var(--warn); }
|
||||
.hero-status.err { background:var(--err-weak); color:var(--err); }
|
||||
|
||||
/* ---- Плитки задач (task tiles) ---- */
|
||||
.tiles { display:grid; gap:16px; grid-template-columns:repeat(auto-fit, minmax(210px,1fr)); margin:8px 0 24px; }
|
||||
.tile {
|
||||
display:flex; flex-direction:column; gap:10px;
|
||||
padding:22px; background:var(--card); border:1px solid var(--border);
|
||||
border-radius:16px; box-shadow:var(--shadow); cursor:pointer;
|
||||
text-decoration:none; color:var(--text); transition:transform .14s, box-shadow .14s, border-color .14s;
|
||||
min-height:128px;
|
||||
}
|
||||
.tile:hover { transform:translateY(-3px); box-shadow:var(--shadow-lg); border-color:var(--accent); text-decoration:none; }
|
||||
.tile .ico { width:46px; height:46px; border-radius:12px; display:flex; align-items:center; justify-content:center; font-size:24px; background:var(--accent-weak); }
|
||||
.tile.brand .ico { background:linear-gradient(135deg,var(--accent),var(--brand)); }
|
||||
.tile .t-title { font-size:16px; font-weight:640; letter-spacing:-0.01em; }
|
||||
.tile .t-sub { font-size:12.5px; color:var(--muted); margin-top:-4px; }
|
||||
.tile .t-arrow { margin-top:auto; color:var(--muted); font-size:18px; }
|
||||
.tile:hover .t-arrow { color:var(--accent); }
|
||||
|
||||
/* ---- Секция (заголовок + контент) ---- */
|
||||
.section-head { display:flex; align-items:baseline; justify-content:space-between; margin:24px 0 12px; }
|
||||
.section-head h2 { margin:0; font-size:17px; }
|
||||
.section-head a { font-size:13px; }
|
||||
|
||||
/* ---- Админ-центр: боковые разделы + контент (macOS System Settings) ---- */
|
||||
.settings { display:grid; grid-template-columns:236px 1fr; gap:26px; align-items:start; }
|
||||
.settings-nav { position:sticky; top:78px; display:flex; flex-direction:column; gap:2px; }
|
||||
.settings-nav button {
|
||||
display:flex; align-items:center; gap:11px; justify-content:flex-start;
|
||||
background:transparent; border:1px solid transparent; color:var(--text-2);
|
||||
padding:10px 13px; border-radius:9px; cursor:pointer; font-size:14px; font-weight:520;
|
||||
width:100%; text-align:left; transition:background .12s;
|
||||
}
|
||||
.settings-nav button:hover { background:var(--card-2); color:var(--text); }
|
||||
.settings-nav button.active { background:var(--accent-weak); color:var(--accent); }
|
||||
.settings-nav button .nico { font-size:16px; width:20px; text-align:center; }
|
||||
.settings-nav button .ind { margin-left:auto; width:8px; height:8px; border-radius:50%; flex:none; }
|
||||
.settings-nav button .ind.ok { background:var(--ok); }
|
||||
.settings-nav button .ind.warn { background:var(--warn); }
|
||||
.settings-nav button .ind.err { background:var(--err); }
|
||||
.settings-section { display:none; }
|
||||
.settings-section.active { display:block; }
|
||||
.settings-section > h1 { margin-bottom:18px; }
|
||||
@media (max-width:820px) {
|
||||
.settings { grid-template-columns:1fr; }
|
||||
.settings-nav { flex-direction:row; overflow-x:auto; position:static; padding-bottom:8px; }
|
||||
.settings-nav button { white-space:nowrap; width:auto; }
|
||||
}
|
||||
|
||||
/* ---- Toast ---- */
|
||||
#bj-toast { position:fixed; top:72px; right:24px; z-index:9999; max-width:520px; padding:14px 18px; background:var(--card); border-left:4px solid var(--ok); border-radius:var(--radius-sm); color:var(--text); box-shadow:var(--shadow-lg); font-size:13px; line-height:1.45; opacity:0; transform:translateY(-12px); transition:opacity .25s, transform .25s; }
|
||||
#bj-toast.visible { opacity:1; transform:translateY(0); }
|
||||
#bj-toast .close { position:absolute; top:6px; right:10px; cursor:pointer; color:var(--muted); font-size:15px; }
|
||||
#bj-toast .close:hover { color:var(--text); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>lk-gateway</h1>
|
||||
<nav>
|
||||
<div id="bj-toast"><span class="close" onclick="document.getElementById('bj-toast').classList.remove('visible')">×</span><div id="bj-toast-text"></div></div>
|
||||
|
||||
<header class="topbar">
|
||||
<span class="brand"><span class="logo"></span>Bridge&Join</span>
|
||||
<nav class="nav">
|
||||
<span class="group-label">Оператор</span>
|
||||
<a href="/admin/" class="{{if eq .Active "home"}}active{{end}}">Дашборд</a>
|
||||
<a href="/admin/wizard" class="{{if eq .Active "wizard"}}active{{end}}">Мастер настройки</a>
|
||||
<a href="/admin/claims" class="{{if eq .Active "claims"}}active{{end}}">Переводы</a>
|
||||
<a href="/admin/news" class="{{if eq .Active "news"}}active{{end}}">События</a>
|
||||
<span class="group-label">Администратор</span>
|
||||
<a href="/admin/setup" class="{{if eq .Active "setup"}}active{{end}}">Настройка</a>
|
||||
<a href="/admin/news" class="{{if eq .Active "news"}}active{{end}}">Новости</a>
|
||||
<a href="/admin/claims" class="{{if eq .Active "claims"}}active{{end}}">Заявки</a>
|
||||
<a href="/admin/status" class="{{if eq .Active "status"}}active{{end}}">Статус системы</a>
|
||||
<a href="/admin/help" class="{{if eq .Active "help"}}active{{end}}">Инструкции</a>
|
||||
<a href="/admin/status" class="{{if eq .Active "status"}}active{{end}}">Статус</a>
|
||||
<a href="/admin/help" class="{{if eq .Active "help"}}active{{end}}">Справка</a>
|
||||
</nav>
|
||||
<span class="muted" style="margin-left:auto">{{.Now}}</span>
|
||||
</header>
|
||||
{{if .IsMockMode}}
|
||||
<div style="background:rgba(232,177,58,0.15);border-bottom:2px solid var(--warn);padding:10px 24px;display:flex;align-items:center;gap:12px;font-size:13px">
|
||||
<span style="font-size:18px">🟡</span>
|
||||
<div>
|
||||
<strong style="color:var(--warn)">РЕЖИМ ЭМУЛЯЦИИ</strong> — реального обмена с НРД нет.
|
||||
<span class="muted" style="margin-left:6px">{{.MockReason}}</span>
|
||||
<div class="topbar-right">
|
||||
<span class="clock">{{.Now}}</span>
|
||||
<button class="theme-toggle" id="theme-toggle" title="Светлая/тёмная тема" aria-label="Переключить тему">🌙</button>
|
||||
</div>
|
||||
<a href="/admin/wizard" style="margin-left:auto;font-size:13px">Настроить →</a>
|
||||
</header>
|
||||
|
||||
{{if .IsMockMode}}
|
||||
<div class="banner-mock">
|
||||
<span style="font-size:16px">🟡</span>
|
||||
<div><strong style="color:var(--warn)">Режим эмуляции</strong> — реального обмена с НРД нет. <span class="muted">{{.MockReason}}</span></div>
|
||||
<a href="/admin/wizard" style="margin-left:auto">Открыть мастер настройки →</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<main>
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// --- Тема: light/dark, сохранение в localStorage ---
|
||||
var root = document.documentElement;
|
||||
var toggle = document.getElementById('theme-toggle');
|
||||
function applyTheme(t) {
|
||||
root.setAttribute('data-theme', t);
|
||||
toggle.textContent = (t === 'dark') ? '☀️' : '🌙';
|
||||
try { localStorage.setItem('bj-theme', t); } catch(e) {}
|
||||
}
|
||||
var saved = 'light';
|
||||
try { saved = localStorage.getItem('bj-theme') || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); } catch(e) {}
|
||||
applyTheme(saved);
|
||||
toggle.addEventListener('click', function() {
|
||||
applyTheme(root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
|
||||
// --- Toast из ?flash= ---
|
||||
var toast = document.getElementById('bj-toast');
|
||||
var toastText = document.getElementById('bj-toast-text');
|
||||
function showToast(msg) {
|
||||
toastText.textContent = msg;
|
||||
toast.classList.add('visible');
|
||||
var ms = Math.max(5000, Math.min(20000, msg.length * 60));
|
||||
setTimeout(function() { toast.classList.remove('visible'); }, ms);
|
||||
}
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var flash = params.get('flash');
|
||||
if (flash) {
|
||||
showToast(flash);
|
||||
params.delete('flash');
|
||||
var qs = params.toString();
|
||||
window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : '') + window.location.hash);
|
||||
}
|
||||
|
||||
// --- Сохранение позиции прокрутки через POST-редиректы ---
|
||||
document.addEventListener('submit', function(ev) {
|
||||
var f = ev.target;
|
||||
if (f && f.method && f.method.toLowerCase() === 'post') {
|
||||
try { sessionStorage.setItem('bj-scroll', String(window.scrollY)); } catch(e) {}
|
||||
}
|
||||
}, true);
|
||||
var sc = null;
|
||||
try { sc = sessionStorage.getItem('bj-scroll'); } catch(e) {}
|
||||
if (sc !== null) {
|
||||
window.requestAnimationFrame(function() {
|
||||
window.scrollTo(0, parseInt(sc, 10));
|
||||
try { sessionStorage.removeItem('bj-scroll'); } catch(e) {}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -725,8 +725,12 @@ func (m M2MTransferDecision) Validate() error {
|
||||
// GUID/StatusCode/Response объявлены локально в M2MTransferResponse.xsd
|
||||
// (elementFormDefault="qualified") — namespace элементов response, не
|
||||
// types, хотя их типы из types.
|
||||
// XMLName без namespace: реальный НРД-ответ присылает корневой
|
||||
// M2MTransferResponse БЕЗ namespace на root (дочерние qualified в response
|
||||
// ns через префикс ns3). Матчим root по локальному имени — толерантно к
|
||||
// тому, объявлен ли default namespace на корне.
|
||||
type M2MTransferResponse struct {
|
||||
XMLName xml.Name `xml:"http://nsd.ru/schemas/m2m/response M2MTransferResponse"`
|
||||
XMLName xml.Name `xml:"M2MTransferResponse"`
|
||||
GUID UUID `xml:"http://nsd.ru/schemas/m2m/response GUID"`
|
||||
StatusCode StatusCode `xml:"http://nsd.ru/schemas/m2m/response StatusCode"`
|
||||
Responses []Response `xml:"http://nsd.ru/schemas/m2m/response Response"`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package m2m_test
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -35,6 +36,24 @@ var roundTripCases = []roundTripCase{
|
||||
{filepath.Join("..", "..", "DOC", "Эталонные сообщения", "M2MTransferDecision_эталон.xml"), func() any { return new(m2m.M2MTransferDecision) }},
|
||||
}
|
||||
|
||||
// clearXMLNameSpace обнуляет поле Space у верхнеуровневого xml.Name (если
|
||||
// есть), чтобы round-trip-сравнение не зависело от namespace корня входного
|
||||
// документа.
|
||||
func clearXMLNameSpace(v any) {
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() != reflect.Ptr || rv.IsNil() {
|
||||
return
|
||||
}
|
||||
rv = rv.Elem()
|
||||
if rv.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
f := rv.FieldByName("XMLName")
|
||||
if f.IsValid() && f.CanSet() && f.Type() == reflect.TypeOf(xml.Name{}) {
|
||||
f.Set(reflect.ValueOf(xml.Name{Local: f.Interface().(xml.Name).Local}))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
for _, c := range roundTripCases {
|
||||
c := c
|
||||
@@ -70,6 +89,15 @@ func TestRoundTrip(t *testing.T) {
|
||||
t.Fatalf("unmarshal после Marshal: %v", err)
|
||||
}
|
||||
|
||||
// XMLName.Space — это метаданные пространства имён входного XML,
|
||||
// а не полезная нагрузка. Часть документов НРД присылает с
|
||||
// namespace на корне (официальные примеры), часть — без (реальный
|
||||
// ответ робота МОСТ). Парсер намеренно namespace-agnostic, поэтому
|
||||
// после re-marshal корневой namespace может отличаться. Для
|
||||
// приёмочных (receive-only) документов это несущественно — сравниваем
|
||||
// без учёта XMLName.Space.
|
||||
clearXMLNameSpace(s1)
|
||||
clearXMLNameSpace(s2)
|
||||
if !reflect.DeepEqual(s1, s2) {
|
||||
t.Errorf("round-trip структуры разошлись:\nS1 = %+v\nS2 = %+v", s1, s2)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type Deal struct {
|
||||
SignedClaim []byte
|
||||
Request *m2m.M2MTransferRequest
|
||||
Response *m2m.M2MTransferResponse
|
||||
RawResponse []byte // точные байты ответа МОСТ от НРД (для пересылки в ТП)
|
||||
Decision *m2m.M2MTransferDecision
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
@@ -169,6 +170,71 @@ func (d *Deal) ReceiveDecision(_ context.Context, decision *m2m.M2MTransferDecis
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReceiveServiceResponse принимает M2MTransferResponse — ответ сервиса МОСТ
|
||||
// (НРД), а не контрагента. Это сервисный уровень: подтверждение приёма в
|
||||
// обработку (StatusCode=INFO) либо отказ сервиса (StatusCode=ERROR, код
|
||||
// M2Mxx — напр. M2M14 «код отправителя отсутствует в справочнике M2M»).
|
||||
//
|
||||
// - INFO — заявка принята МОСТ в обработку; состояние не меняем, ждём
|
||||
// M2MTransferDecision контрагента. Сохраняем ответ для отображения.
|
||||
// - ERROR — сервис отклонил заявку до контрагента; Decision уже не придёт,
|
||||
// переводим сделку в Rejected. Сохраняем ответ, чтобы в карточке был
|
||||
// виден код и текст отказа НРД.
|
||||
// raw — точные байты ответа (unpacked doc.xml от НРД); сохраняются как есть
|
||||
// для дословной пересылки в техподдержку НРД. Может быть nil (mock-режим).
|
||||
func (d *Deal) ReceiveServiceResponse(_ context.Context, resp *m2m.M2MTransferResponse, raw []byte) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if resp == nil {
|
||||
return fmt.Errorf("m2mcore: ReceiveServiceResponse: resp=nil")
|
||||
}
|
||||
d.Response = resp
|
||||
if len(raw) > 0 {
|
||||
d.RawResponse = raw
|
||||
}
|
||||
d.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if resp.StatusCode != m2m.StatusError {
|
||||
// INFO/приём в обработку — фиксируем, но не двигаем FSM.
|
||||
d.recordEvent("service_ack", responsePayload(resp), "nsd")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ERROR — отказ сервиса МОСТ. Если сделка уже в терминальном состоянии,
|
||||
// повторный ответ игнорируем (идемпотентность поллера).
|
||||
if IsTerminal(d.State) {
|
||||
d.recordEvent("service_error_ignored_terminal", responsePayload(resp), "nsd")
|
||||
return nil
|
||||
}
|
||||
if err := d.shiftTo(StateRejected, "service_error:"+firstCode(resp)); err != nil {
|
||||
return err
|
||||
}
|
||||
d.recordEvent("service_error", responsePayload(resp), "nsd")
|
||||
return nil
|
||||
}
|
||||
|
||||
// responsePayload сворачивает M2MTransferResponse в компактную запись для
|
||||
// журнала событий (коды + тексты ответов сервиса).
|
||||
func responsePayload(resp *m2m.M2MTransferResponse) map[string]any {
|
||||
codes := make([]map[string]string, 0, len(resp.Responses))
|
||||
for _, r := range resp.Responses {
|
||||
codes = append(codes, map[string]string{"code": r.Code, "text": r.Text})
|
||||
}
|
||||
return map[string]any{
|
||||
"status": string(resp.StatusCode),
|
||||
"guid": string(resp.GUID),
|
||||
"codes": codes,
|
||||
}
|
||||
}
|
||||
|
||||
// firstCode возвращает первый код ответа (для reason перехода).
|
||||
func firstCode(resp *m2m.M2MTransferResponse) string {
|
||||
if len(resp.Responses) > 0 {
|
||||
return resp.Responses[0].Code
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Timeout переводит сделку в TimedOut (когда не дождались Decision).
|
||||
func (d *Deal) Timeout(_ context.Context) error {
|
||||
d.mu.Lock()
|
||||
|
||||
@@ -19,6 +19,11 @@ type ClaimInput struct {
|
||||
CostInfo m2m.CostInfo
|
||||
IIAAgreement *m2m.IIAAgreementDetails
|
||||
Securities []ClaimSecurityInput
|
||||
// InvestorDocument, если задан, переопределяет документ инвестора из
|
||||
// анкеты Fansy. Нужен для тестов с роботом НРД, где код сценария
|
||||
// кодируется в серии ДУЛ (DocumentSeries). Для обычных заявок — nil
|
||||
// (личность берётся из анкеты-источника).
|
||||
InvestorDocument *ClientDocument
|
||||
}
|
||||
|
||||
// ClaimSecurityInput — одна ЦБ в заявке.
|
||||
@@ -76,6 +81,11 @@ func EnrichRequest(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("m2mcore: GetClientByID: %w", err)
|
||||
}
|
||||
// Переопределение документа инвестора (тест с роботом: серия ДУЛ = код
|
||||
// сценария). Меняем только удостоверение личности, ФИО оставляем из анкеты.
|
||||
if claim.InvestorDocument != nil {
|
||||
client.Document = *claim.InvestorDocument
|
||||
}
|
||||
accounts, err := store.GetDepoAccounts(ctx, claim.InvestorClientID, claim.TransferringDepositoryINN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("m2mcore: GetDepoAccounts: %w", err)
|
||||
|
||||
@@ -105,6 +105,57 @@ func TestEnrichRequestHappyPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichRequestDocumentOverride — для теста с роботом НРД серия ДУЛ
|
||||
// кодирует сценарий. Override должен заместить паспорт из анкеты, оставив ФИО.
|
||||
func TestEnrichRequestDocumentOverride(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
client: &m2mcore.Client{
|
||||
ID: "inv-1", LastName: "Иванов", FirstName: "Иван", MiddleName: "Иванович",
|
||||
Document: m2mcore.ClientDocument{
|
||||
DocumentType: m2m.DocCode21, Series: "4513", Number: "654322",
|
||||
},
|
||||
},
|
||||
accounts: []m2mcore.DepoAccount{{
|
||||
ID: "acc-1", ClientID: "inv-1", DeponentCode: "DP789456",
|
||||
AccountID: m2m.AccountID("31MC0021900000F01"), SectionID: "P001",
|
||||
DepositoryINN: m2m.OrganizationINN("7702070139"),
|
||||
}},
|
||||
}
|
||||
whole := uint64(1)
|
||||
isin := m2m.ISIN("RU0007661625")
|
||||
claim := m2mcore.ClaimInput{
|
||||
InvestorClientID: "inv-1",
|
||||
TransferringDepositoryINN: m2m.OrganizationINN("0702345678"),
|
||||
ReceivingDepositoryINN: m2m.OrganizationINN("0710987654"),
|
||||
CostInfo: m2m.CostInfo{No: &m2m.CostInfoNo{}},
|
||||
Securities: []m2mcore.ClaimSecurityInput{{
|
||||
SecurityCode: m2m.SecurityCode("RU0007661625"),
|
||||
Details: m2m.SecurityDetails{ISIN: &isin}, Quantity: m2m.Quantity{Whole: &whole},
|
||||
}},
|
||||
// Сценарий робота 2001 в серии ДУЛ.
|
||||
InvestorDocument: &m2mcore.ClientDocument{
|
||||
DocumentType: m2m.DocCode21, Series: "2001", Number: "111111",
|
||||
},
|
||||
}
|
||||
req, err := m2mcore.EnrichRequest(context.Background(), store, claim, m2mcore.SenderReceiver{
|
||||
SenderCode: m2m.DeponentCode("MC0413600000"), ReceiverCode: m2m.DeponentCode("MC0012500000"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnrichRequest: %v", err)
|
||||
}
|
||||
doc := req.Data.InvestorInformation.IdentityDocument
|
||||
if doc.DocumentSeries == nil || string(*doc.DocumentSeries) != "2001" {
|
||||
t.Errorf("DocumentSeries = %v, ожидалось 2001 (override сценария)", doc.DocumentSeries)
|
||||
}
|
||||
if string(doc.DocumentNumber) != "111111" {
|
||||
t.Errorf("DocumentNumber = %q, ожидалось 111111", doc.DocumentNumber)
|
||||
}
|
||||
// ФИО берётся из анкеты, не из override.
|
||||
if req.Data.InvestorInformation.LastName != "Иванов" {
|
||||
t.Errorf("ФИО должно остаться из анкеты, получено %q", req.Data.InvestorInformation.LastName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichRequestNoAccounts(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
client: &m2mcore.Client{LastName: "X", FirstName: "Y", Document: m2mcore.ClientDocument{DocumentType: m2m.DocCode21, Number: "1"}},
|
||||
|
||||
@@ -54,7 +54,7 @@ func (r *PostgresRepository) Close() {
|
||||
// guid уже есть, возвращает её (без модификации).
|
||||
func (r *PostgresRepository) Create(ctx context.Context, deal *Deal) (*Deal, error) {
|
||||
reqXML, _ := marshalXMLIfPresent(deal.Request)
|
||||
respXML, _ := marshalXMLIfPresent(deal.Response)
|
||||
respXML := responseBytes(deal)
|
||||
decisionXML, _ := marshalXMLIfPresent(deal.Decision)
|
||||
stages, err := json.Marshal(deal.Stages)
|
||||
if err != nil {
|
||||
@@ -97,7 +97,7 @@ func (r *PostgresRepository) GetByID(ctx context.Context, id string) (*Deal, err
|
||||
// Update сохраняет полное состояние сделки (для простоты — без diff).
|
||||
func (r *PostgresRepository) Update(ctx context.Context, deal *Deal) error {
|
||||
reqXML, _ := marshalXMLIfPresent(deal.Request)
|
||||
respXML, _ := marshalXMLIfPresent(deal.Response)
|
||||
respXML := responseBytes(deal)
|
||||
decisionXML, _ := marshalXMLIfPresent(deal.Decision)
|
||||
stages, err := json.Marshal(deal.Stages)
|
||||
if err != nil {
|
||||
@@ -229,6 +229,8 @@ func scanRow(r rowScanner) (*Deal, error) {
|
||||
}
|
||||
}
|
||||
if len(respXML) > 0 {
|
||||
// Сохраняем точные байты для дословного показа/пересылки в ТП НРД.
|
||||
d.RawResponse = respXML
|
||||
var v m2m.M2MTransferResponse
|
||||
if err := nsdxml.Unmarshal(respXML, &v); err == nil {
|
||||
d.Response = &v
|
||||
@@ -254,6 +256,18 @@ func dealsSelectSQL() string {
|
||||
FROM m2m_core.deals`
|
||||
}
|
||||
|
||||
// responseBytes возвращает байты ответа МОСТ для записи в response_xml:
|
||||
// точные байты от НРД (RawResponse), если они есть, иначе пере-сериализация
|
||||
// разобранной структуры. Точные байты предпочтительны — их можно дословно
|
||||
// переслать в техподдержку НРД.
|
||||
func responseBytes(deal *Deal) []byte {
|
||||
if len(deal.RawResponse) > 0 {
|
||||
return deal.RawResponse
|
||||
}
|
||||
b, _ := marshalXMLIfPresent(deal.Response)
|
||||
return b
|
||||
}
|
||||
|
||||
// marshalXMLIfPresent сериализует *T в windows-1251 XML (или возвращает nil).
|
||||
func marshalXMLIfPresent(v any) ([]byte, error) {
|
||||
if v == nil {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package m2mcore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
// makeResponse строит M2MTransferResponse сервиса МОСТ с заданным статусом и
|
||||
// одним кодом/текстом (как в реальном ответе НРД, напр. M2M14).
|
||||
func makeResponse(guid m2m.UUID, status m2m.StatusCode, code, text string) *m2m.M2MTransferResponse {
|
||||
return &m2m.M2MTransferResponse{
|
||||
GUID: guid,
|
||||
StatusCode: status,
|
||||
Responses: []m2m.Response{{Code: code, Text: text}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestReceiveServiceResponseError — ERROR (отказ сервиса, напр. M2M14)
|
||||
// переводит сделку в Rejected и сохраняет ответ для отображения в карточке.
|
||||
func TestReceiveServiceResponseError(t *testing.T) {
|
||||
d := newTestDeal(t)
|
||||
resp := makeResponse(d.GUID, m2m.StatusError, "M2M14",
|
||||
"Код ЭДО НРД отправителя отсутствует в справочнике участников M2M")
|
||||
|
||||
if err := d.ReceiveServiceResponse(context.Background(), resp, nil); err != nil {
|
||||
t.Fatalf("ReceiveServiceResponse(ERROR): %v", err)
|
||||
}
|
||||
if d.State != m2mcore.StateRejected {
|
||||
t.Errorf("состояние %s, ожидалось %s", d.State, m2mcore.StateRejected)
|
||||
}
|
||||
if d.Response == nil || d.Response.StatusCode != m2m.StatusError {
|
||||
t.Errorf("ответ НРД не сохранён в сделке: %+v", d.Response)
|
||||
}
|
||||
if len(d.Response.Responses) == 0 || d.Response.Responses[0].Code != "M2M14" {
|
||||
t.Errorf("код ответа не сохранён: %+v", d.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReceiveServiceResponseInfo — INFO (принято в обработку) сохраняет ответ,
|
||||
// но НЕ меняет состояние: ждём M2MTransferDecision контрагента.
|
||||
func TestReceiveServiceResponseInfo(t *testing.T) {
|
||||
d := newTestDeal(t)
|
||||
before := d.State
|
||||
resp := makeResponse(d.GUID, m2m.StatusInfo, "01", "Запрос на перевод принят в обработку")
|
||||
|
||||
if err := d.ReceiveServiceResponse(context.Background(), resp, nil); err != nil {
|
||||
t.Fatalf("ReceiveServiceResponse(INFO): %v", err)
|
||||
}
|
||||
if d.State != before {
|
||||
t.Errorf("состояние изменилось на %s, ожидалось без изменений (%s)", d.State, before)
|
||||
}
|
||||
if d.Response == nil || d.Response.StatusCode != m2m.StatusInfo {
|
||||
t.Errorf("ответ НРД не сохранён: %+v", d.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReceiveServiceResponseTerminalIdempotent — повторный ERROR на уже
|
||||
// терминальной (Rejected) сделке не валит FSM, ответ обновляется.
|
||||
func TestReceiveServiceResponseTerminalIdempotent(t *testing.T) {
|
||||
d := newTestDeal(t)
|
||||
first := makeResponse(d.GUID, m2m.StatusError, "M2M14", "первый отказ")
|
||||
if err := d.ReceiveServiceResponse(context.Background(), first, nil); err != nil {
|
||||
t.Fatalf("первый ERROR: %v", err)
|
||||
}
|
||||
// Поллер ИШ может прислать тот же пакет повторно — не должно паниковать
|
||||
// и не должно ломать состояние недопустимым переходом Rejected→Rejected.
|
||||
second := makeResponse(d.GUID, m2m.StatusError, "M2M14", "повторный отказ")
|
||||
if err := d.ReceiveServiceResponse(context.Background(), second, nil); err != nil {
|
||||
t.Fatalf("повторный ERROR на терминальной сделке: %v", err)
|
||||
}
|
||||
if d.State != m2mcore.StateRejected {
|
||||
t.Errorf("состояние %s, ожидалось %s", d.State, m2mcore.StateRejected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReceiveServiceResponseNil — защита от nil.
|
||||
func TestReceiveServiceResponseNil(t *testing.T) {
|
||||
d := newTestDeal(t)
|
||||
if err := d.ReceiveServiceResponse(context.Background(), nil, nil); err == nil {
|
||||
t.Error("ожидалась ошибка на nil-ответе")
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -97,20 +98,30 @@ func (c *Client) SendPackage(ctx context.Context, channel, packageType string, z
|
||||
if channel == "" {
|
||||
return "", errors.New("igw: channel пустой")
|
||||
}
|
||||
_ = packageType // не используется в новом API, кладётся внутрь ZIP/config.xml
|
||||
_ = packageType // тип пакета (#M2MTR) кладётся внутрь ZIP/config.xml через pack.go
|
||||
if len(zipBody) == 0 {
|
||||
return "", errors.New("igw: zipBody пустой")
|
||||
}
|
||||
payload := sendBody{
|
||||
Type: "archive",
|
||||
File: base64.StdEncoding.EncodeToString(zipBody),
|
||||
// ИШ REST API ждёт multipart/form-data: поле File (binary ZIP) +
|
||||
// Type (enum InFileType: FILE | ARCHIVE). Для ZIP-пакета — ARCHIVE.
|
||||
// Ответ: {"id": <int64>}. См. Swagger /api/package/{channel}/file.
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
if err := mw.WriteField("Type", "ARCHIVE"); err != nil {
|
||||
return "", fmt.Errorf("igw: multipart Type: %w", err)
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
fw, err := mw.CreateFormFile("File", "package.zip")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("igw: marshal payload: %w", err)
|
||||
return "", fmt.Errorf("igw: multipart File: %w", err)
|
||||
}
|
||||
if _, err := fw.Write(zipBody); err != nil {
|
||||
return "", fmt.Errorf("igw: write zip в multipart: %w", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return "", fmt.Errorf("igw: multipart close: %w", err)
|
||||
}
|
||||
path := fmt.Sprintf("/api/package/%s/file", url.PathEscape(channel))
|
||||
resp, err := c.doRetry(ctx, http.MethodPost, path, bytes.NewReader(raw), "application/json")
|
||||
resp, err := c.doRetry(ctx, http.MethodPost, path, &buf, mw.FormDataContentType())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -20,23 +21,22 @@ func TestSendPackageHappyPath(t *testing.T) {
|
||||
if r.URL.Path != "/api/package/CH1/file" {
|
||||
t.Errorf("неожиданный путь %q", r.URL.Path)
|
||||
}
|
||||
// Проверим что тело — это {Type: "archive", File: base64}.
|
||||
var body struct {
|
||||
Type string `json:"Type"`
|
||||
File string `json:"File"`
|
||||
// ИШ ждёт multipart/form-data: поля Type (FILE|ARCHIVE) + File (binary).
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
t.Fatalf("parse multipart: %v", err)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
if got := r.FormValue("Type"); got != "ARCHIVE" {
|
||||
t.Errorf("Type = %q, ожидалось ARCHIVE", got)
|
||||
}
|
||||
if body.Type != "archive" {
|
||||
t.Errorf("Type = %q, ожидалось archive", body.Type)
|
||||
f, _, err := r.FormFile("File")
|
||||
if err != nil {
|
||||
t.Fatalf("form file: %v", err)
|
||||
}
|
||||
if body.File == "" {
|
||||
defer f.Close()
|
||||
b, _ := io.ReadAll(f)
|
||||
if len(b) == 0 {
|
||||
t.Errorf("File пустой")
|
||||
}
|
||||
if _, err := base64.StdEncoding.DecodeString(body.File); err != nil {
|
||||
t.Errorf("File не base64: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 123})
|
||||
}))
|
||||
|
||||
@@ -40,15 +40,21 @@ func (s *Sender) Send(ctx context.Context, req *m2m.M2MTransferRequest) (*m2m.M2
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("nsdadapter: req.Validate: %w", err)
|
||||
}
|
||||
body, err := nsdxml.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nsdadapter: marshal Request: %w", err)
|
||||
}
|
||||
pkgType, err := RouteToPackageType(KindTransferRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkgID, err := s.client.SendPackage(ctx, s.profile.Channel, string(pkgType), body)
|
||||
// ИШ ждёт ZIP-архив (Type=ARCHIVE): doc.xml + config.xml с типом пакета.
|
||||
// Сырой XML ИШ отвергает ("Bad signature" при распаковке). PackRequest
|
||||
// собирает корректный ZIP с config.xml (#M2MTR).
|
||||
body, err := igw.PackRequest(req, "M2MTransferRequest.xml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nsdadapter: PackRequest: %w", err)
|
||||
}
|
||||
// ИШ резолвит канал по СОСТАВНОМУ коду: <код канала>+<код депонента>
|
||||
// (так его формирует ИШ при создании канала: напр. TEST3+MC0413600000).
|
||||
channel := s.profile.Channel + string(req.Header.SenderCode)
|
||||
pkgID, err := s.client.SendPackage(ctx, channel, string(pkgType), body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nsdadapter: SendPackage: %w", err)
|
||||
}
|
||||
@@ -67,7 +73,7 @@ func (s *Sender) SendDecision(ctx context.Context, decision *m2m.M2MTransferDeci
|
||||
if err := decision.Validate(); err != nil {
|
||||
return fmt.Errorf("nsdadapter: decision.Validate: %w", err)
|
||||
}
|
||||
body, err := nsdxml.Marshal(decision)
|
||||
xmlBytes, err := nsdxml.Marshal(decision)
|
||||
if err != nil {
|
||||
return fmt.Errorf("nsdadapter: marshal Decision: %w", err)
|
||||
}
|
||||
@@ -75,7 +81,12 @@ func (s *Sender) SendDecision(ctx context.Context, decision *m2m.M2MTransferDeci
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.client.SendPackage(ctx, s.profile.Channel, string(pkgType), body); err != nil {
|
||||
body, err := igw.PackXML(xmlBytes, "M2MTransferDecision.xml", string(pkgType))
|
||||
if err != nil {
|
||||
return fmt.Errorf("nsdadapter: PackXML: %w", err)
|
||||
}
|
||||
channel := s.profile.Channel + string(decision.Header.SenderCode)
|
||||
if _, err := s.client.SendPackage(ctx, channel, string(pkgType), body); err != nil {
|
||||
return fmt.Errorf("nsdadapter: SendPackage: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -88,8 +88,11 @@ func TestSenderSend(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if mock.gotChannel != "TEST3" {
|
||||
t.Errorf("channel = %q, ожидалось TEST3", mock.gotChannel)
|
||||
// ИШ резолвит канал по составному коду: код канала + код депонента-
|
||||
// отправителя (channels.code = channelCode+deponentCode). Для профиля
|
||||
// test3-gost и отправителя MC0079200000 это TEST3MC0079200000.
|
||||
if mock.gotChannel != "TEST3MC0079200000" {
|
||||
t.Errorf("channel = %q, ожидалось TEST3MC0079200000", mock.gotChannel)
|
||||
}
|
||||
if mock.gotType != string(nsdadapter.PackageM2MTransferRequest) {
|
||||
t.Errorf("type = %q, ожидалось %s", mock.gotType, nsdadapter.PackageM2MTransferRequest)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package release
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client — клиент артефактории: скачивает подписанный манифест и артефакты,
|
||||
// проверяет подпись зашитым публичным ключом и sha256 каждого файла.
|
||||
type Client struct {
|
||||
BaseURL string // напр. https://updates.example.com
|
||||
Channel string // "stable" | "beta"
|
||||
Pub ed25519.PublicKey // публичный ключ издателя (зашит в bj-server)
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// NewClient собирает клиент. pubB64 — публичный ключ в base64 (зашитый).
|
||||
func NewClient(baseURL, channel, pubB64 string) (*Client, error) {
|
||||
pub, err := ParsePublicKey(pubB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("release: публичный ключ: %w", err)
|
||||
}
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Channel: channel,
|
||||
Pub: pub,
|
||||
HTTP: &http.Client{Timeout: 60 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FetchManifest скачивает /v1/<channel>/manifest.json и проверяет подпись.
|
||||
// Возвращает доверенный Manifest (или ошибку, если подпись не сошлась).
|
||||
func (c *Client) FetchManifest(ctx context.Context) (*Manifest, error) {
|
||||
url := fmt.Sprintf("%s/v1/%s/manifest.json", c.BaseURL, c.Channel)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("release: запрос манифеста: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("release: манифест HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var sm SignedManifest
|
||||
if err := json.NewDecoder(resp.Body).Decode(&sm); err != nil {
|
||||
return nil, fmt.Errorf("release: разбор манифеста: %w", err)
|
||||
}
|
||||
return Verify(&sm, c.Pub) // здесь проверяется подпись
|
||||
}
|
||||
|
||||
// DownloadArtifact скачивает артефакт в destDir, проверяет sha256/размер по
|
||||
// манифесту, выставляет +x при необходимости. Возвращает путь к файлу.
|
||||
// Скачивает во временный файл и переименовывает атомарно.
|
||||
func (c *Client) DownloadArtifact(ctx context.Context, a Artifact, destDir string) (string, error) {
|
||||
url := fmt.Sprintf("%s/v1/%s/files/%s", c.BaseURL, c.Channel, a.File)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("release: скачивание %s: %w", a.Name, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("release: %s HTTP %d", a.Name, resp.StatusCode)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(destDir, "."+a.File+".dl-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // если что-то пойдёт не так — подчистим
|
||||
|
||||
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Проверка целостности по манифесту (подпись манифеста уже проверена).
|
||||
if err := VerifyArtifact(tmpName, a); err != nil {
|
||||
return "", err
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if a.Exec {
|
||||
mode = 0o755
|
||||
}
|
||||
if err := os.Chmod(tmpName, mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
final := filepath.Join(destDir, a.File)
|
||||
if err := os.Rename(tmpName, final); err != nil { // атомарно в пределах destDir
|
||||
return "", err
|
||||
}
|
||||
return final, nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Package release — формат манифеста релиза артефактории и его подпись
|
||||
// Ed25519. Используется тремя сторонами:
|
||||
//
|
||||
// - cmd/bj-release — собирает манифест из артефактов и подписывает;
|
||||
// - cmd/bj-artifactory — отдаёт манифест и файлы по HTTP;
|
||||
// - bj-server (auto-update) — скачивает манифест, проверяет подпись,
|
||||
// сравнивает версии и обновляет компоненты.
|
||||
//
|
||||
// Доверие строится на одном корневом Ed25519-ключе: приватный держит
|
||||
// издатель (мы), публичный зашит в клиента (bj-server) и в установщик.
|
||||
// Манифест подписывается целиком в каноничном виде — клиент проверяет
|
||||
// подпись ДО того, как доверять версиям и хешам артефактов.
|
||||
package release
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CurrentSchema — версия формата манифеста (на случай эволюции).
|
||||
const CurrentSchema = 1
|
||||
|
||||
// Artifact — один распространяемый файл (бинарь/скрипт/архив).
|
||||
type Artifact struct {
|
||||
Name string `json:"name"` // логическое имя: "bj-server", "install-validata.sh"
|
||||
File string `json:"file"` // имя файла в хранилище
|
||||
Version string `json:"version,omitempty"` // версия компонента (если своя)
|
||||
SHA256 string `json:"sha256"` // hex-хеш содержимого
|
||||
Size int64 `json:"size"` // размер в байтах
|
||||
Exec bool `json:"exec,omitempty"` // ставить ли +x при установке
|
||||
}
|
||||
|
||||
// Manifest — описание релиза. Подписывается целиком (каноничный JSON).
|
||||
type Manifest struct {
|
||||
Schema int `json:"schema"`
|
||||
Version string `json:"version"` // версия релиза, напр. "1.2.0"
|
||||
Channel string `json:"channel"` // "stable" | "beta"
|
||||
ReleasedAt time.Time `json:"released_at"` // время сборки релиза
|
||||
Notes string `json:"notes,omitempty"` // что нового (для UI)
|
||||
MinVersion string `json:"min_version,omitempty"` // мин. версия для прямого апгрейда
|
||||
Artifacts []Artifact `json:"artifacts"`
|
||||
}
|
||||
|
||||
// SignedManifest — манифест + отделённая подпись. Именно это лежит в
|
||||
// /v1/manifest.json и скачивается клиентом.
|
||||
//
|
||||
// Payload хранится как base64 от каноничного JSON Manifest — НЕ как
|
||||
// вложенный JSON. Иначе json.MarshalIndent переформатировал бы содержимое
|
||||
// и подписанные байты разошлись бы с прочитанными.
|
||||
type SignedManifest struct {
|
||||
Payload string `json:"payload"` // base64(каноничный JSON Manifest)
|
||||
Signature string `json:"signature"` // base64(ed25519 over каноничным JSON)
|
||||
KeyID string `json:"key_id"` // идентификатор публичного ключа
|
||||
}
|
||||
|
||||
// Canonical сериализует манифест детерминированно (для подписи/проверки).
|
||||
// json.Marshal в Go стабилен по порядку полей структуры — этого достаточно,
|
||||
// т.к. подписываем и проверяем одни и те же байты Payload.
|
||||
func (m *Manifest) Canonical() ([]byte, error) {
|
||||
if m.Schema == 0 {
|
||||
m.Schema = CurrentSchema
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// Sign подписывает манифест приватным ключом и возвращает SignedManifest.
|
||||
func Sign(m *Manifest, priv ed25519.PrivateKey, keyID string) (*SignedManifest, error) {
|
||||
payload, err := m.Canonical()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("release: canonical: %w", err)
|
||||
}
|
||||
sig := ed25519.Sign(priv, payload)
|
||||
return &SignedManifest{
|
||||
Payload: base64.StdEncoding.EncodeToString(payload),
|
||||
Signature: base64.StdEncoding.EncodeToString(sig),
|
||||
KeyID: keyID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify проверяет подпись и возвращает разобранный Manifest. Это
|
||||
// единственный способ получить доверенный Manifest на стороне клиента.
|
||||
func Verify(sm *SignedManifest, pub ed25519.PublicKey) (*Manifest, error) {
|
||||
sig, err := base64.StdEncoding.DecodeString(sm.Signature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("release: decode signature: %w", err)
|
||||
}
|
||||
payload, err := base64.StdEncoding.DecodeString(sm.Payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("release: decode payload: %w", err)
|
||||
}
|
||||
if !ed25519.Verify(pub, payload, sig) {
|
||||
return nil, errors.New("release: подпись манифеста недействительна")
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(payload, &m); err != nil {
|
||||
return nil, fmt.Errorf("release: unmarshal payload: %w", err)
|
||||
}
|
||||
if m.Schema != CurrentSchema {
|
||||
return nil, fmt.Errorf("release: неподдерживаемая схема манифеста %d (ожидается %d)", m.Schema, CurrentSchema)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// HashFile считает sha256 (hex) и размер файла — для заполнения Artifact.
|
||||
func HashFile(path string) (sha string, size int64, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(h, f)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), n, nil
|
||||
}
|
||||
|
||||
// VerifyArtifact проверяет, что файл по пути совпадает с Artifact (хеш+размер).
|
||||
func VerifyArtifact(path string, a Artifact) error {
|
||||
sha, size, err := HashFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if size != a.Size {
|
||||
return fmt.Errorf("release: размер %s не совпал: %d != %d", a.Name, size, a.Size)
|
||||
}
|
||||
if sha != a.SHA256 {
|
||||
return fmt.Errorf("release: sha256 %s не совпал", a.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Ключи ---
|
||||
|
||||
// LoadPrivateKey читает Ed25519 приватный ключ из файла (base64 seed, 32 байта).
|
||||
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seed, err := base64.StdEncoding.DecodeString(stringsTrim(string(b)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("release: decode seed: %w", err)
|
||||
}
|
||||
if len(seed) != ed25519.SeedSize {
|
||||
return nil, fmt.Errorf("release: неверный размер seed %d (ожидается %d)", len(seed), ed25519.SeedSize)
|
||||
}
|
||||
return ed25519.NewKeyFromSeed(seed), nil
|
||||
}
|
||||
|
||||
// LoadPublicKey читает Ed25519 публичный ключ из файла (base64, 32 байта).
|
||||
func LoadPublicKey(path string) (ed25519.PublicKey, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub, err := base64.StdEncoding.DecodeString(stringsTrim(string(b)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("release: decode pubkey: %w", err)
|
||||
}
|
||||
if len(pub) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("release: неверный размер pubkey %d", len(pub))
|
||||
}
|
||||
return ed25519.PublicKey(pub), nil
|
||||
}
|
||||
|
||||
// ParsePublicKey разбирает публичный ключ из base64-строки (для зашитого в клиента).
|
||||
func ParsePublicKey(b64 string) (ed25519.PublicKey, error) {
|
||||
pub, err := base64.StdEncoding.DecodeString(stringsTrim(b64))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pub) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("release: неверный размер pubkey %d", len(pub))
|
||||
}
|
||||
return ed25519.PublicKey(pub), nil
|
||||
}
|
||||
|
||||
func stringsTrim(s string) string {
|
||||
// убираем пробелы/переводы строк вокруг base64
|
||||
start, end := 0, len(s)
|
||||
for start < end && (s[start] == ' ' || s[start] == '\n' || s[start] == '\r' || s[start] == '\t') {
|
||||
start++
|
||||
}
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\n' || s[end-1] == '\r' || s[end-1] == '\t') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package release
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &Manifest{
|
||||
Version: "1.2.3",
|
||||
Channel: "stable",
|
||||
ReleasedAt: time.Now().UTC().Truncate(time.Second),
|
||||
Artifacts: []Artifact{
|
||||
{Name: "bj-server", File: "bj-server", SHA256: "abc", Size: 100, Exec: true},
|
||||
},
|
||||
}
|
||||
sm, err := Sign(m, priv, "main")
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
got, err := Verify(sm, pub)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if got.Version != m.Version || got.Channel != m.Channel || len(got.Artifacts) != 1 {
|
||||
t.Fatalf("manifest mismatch: %+v", got)
|
||||
}
|
||||
if got.Schema != CurrentSchema {
|
||||
t.Fatalf("schema = %d, want %d", got.Schema, CurrentSchema)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsTamper(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
m := &Manifest{Version: "1.0.0", Channel: "stable", Artifacts: []Artifact{{Name: "x"}}}
|
||||
sm, _ := Sign(m, priv, "main")
|
||||
// Подменяем payload на другой манифест — подпись не должна сойтись.
|
||||
other := &Manifest{Version: "9.9.9", Channel: "stable", Artifacts: []Artifact{{Name: "evil"}}}
|
||||
bad, _ := other.Canonical()
|
||||
sm.Payload = base64.StdEncoding.EncodeToString(bad)
|
||||
if _, err := Verify(sm, pub); err == nil {
|
||||
t.Fatal("Verify принял подделанный payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsWrongKey(t *testing.T) {
|
||||
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
other, _, _ := ed25519.GenerateKey(rand.Reader) // чужой публичный ключ
|
||||
m := &Manifest{Version: "1.0.0", Channel: "stable", Artifacts: []Artifact{{Name: "x"}}}
|
||||
sm, _ := Sign(m, priv, "main")
|
||||
if _, err := Verify(sm, other); err == nil {
|
||||
t.Fatal("Verify принял подпись чужим ключом")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package release
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsNewer возвращает true, если версия remote строго новее local.
|
||||
// Понимает semver вида "MAJOR.MINOR.PATCH" (с опциональным префиксом "v"
|
||||
// и суффиксом "-beta" — суффикс игнорируется при сравнении чисел).
|
||||
// Некорректные/пустые версии считаются «старыми» (0.0.0).
|
||||
func IsNewer(remote, local string) bool {
|
||||
rm := parseVer(remote)
|
||||
lc := parseVer(local)
|
||||
for i := 0; i < 3; i++ {
|
||||
if rm[i] != lc[i] {
|
||||
return rm[i] > lc[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseVer(s string) [3]int {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
if i := strings.IndexAny(s, "-+"); i >= 0 {
|
||||
s = s[:i] // отбрасываем -beta, +build
|
||||
}
|
||||
var out [3]int
|
||||
parts := strings.Split(s, ".")
|
||||
for i := 0; i < 3 && i < len(parts); i++ {
|
||||
n, _ := strconv.Atoi(parts[i])
|
||||
out[i] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user