Files
Bridge-and-Join-s/internal/lkgateway/setup.go
T
fontvielle 82b3186b95 feat(admin): загрузка дистрибутива КриптоПро через UI + активация лицензии
В карточке «СКЗИ» страницы /admin/setup добавлены два новых блока:

1. «Установка КриптоПро CSP» — multipart-форма с input type=file.
   Принимает .tar/.tgz/.tar.gz/.rpm (формат с cryptopro.ru). После
   загрузки на сервер (лимит 256 МБ):
   - сохраняет архив в /tmp/bj-cryptopro/
   - распаковывает (tar -xzf или tar -xf)
   - находит все .rpm в распакованной директории
   - выполняет sudo rpm -Uvh --replacepkgs --nosignature на найденные пакеты
   - возвращает результат с количеством установленных пакетов и выводом rpm

2. «Активация лицензии» — поле для ввода серийника и кнопка.
   Вызывает /opt/cprocsp/sbin/amd64/cpconfig -license -set <серийник>.
   Если cpconfig не найден — показывает подсказку про /admin/help/cryptopro.
   После успеха сохраняет серийник в runtime-конфиге.

internal/lkgateway/setup.go:
- handler installCryptoPro (multipart form, parse, untar, find rpms, sudo rpm)
- handler activateLicense (cpconfig -license -set, сохранение в RuntimeConfig)
- общие хелперы runCmd / runCmdInDir для exec через context

internal/lkgateway/web/templates/admin_setup.html:
- секция «Установка КриптоПро CSP» с формой загрузки
- секция «Активация лицензии» с полем + кнопкой
- ссылки на /admin/help/cryptopro и cryptopro.ru/products/csp/downloads

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:09:20 +03:00

492 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
)
// setupHandlers — обработчики /admin/setup/*.
type setupHandlers struct {
rc *RuntimeConfig
tpl *adminTemplates
svc *Service
}
// adminTemplates — обёртка, чтобы передать набор шаблонов в setup.
type adminTemplates struct {
a *admin
}
// registerSetup вешает /admin/setup и /admin/setup/* (POST) на mux.
func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service) {
h := &setupHandlers{rc: rc, tpl: &adminTemplates{a: a}, svc: svc}
mux.HandleFunc("/admin/setup", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
h.renderSetup(w, r, "")
})
mux.HandleFunc("/admin/setup/postgres", h.savePostgres)
mux.HandleFunc("/admin/setup/crypto", h.saveCrypto)
mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto)
mux.HandleFunc("/admin/setup/crypto/activate", h.activateLicense)
mux.HandleFunc("/admin/setup/crypto/install", h.installCryptoPro)
mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
mux.HandleFunc("/admin/setup/lk", h.saveLK)
mux.HandleFunc("/admin/setup/test-run", h.testRun)
}
// installCryptoPro — POST /admin/setup/crypto/install (multipart).
// Принимает tar или tar.gz архив с дистрибутивом КриптоПро CSP (как
// linux-amd64.tgz с cryptopro.ru), распаковывает в /tmp/bj-cryptopro,
// находит все .rpm файлы и устанавливает через sudo rpm -i.
// На РЕД ОС / ALT / ROSA это даёт рабочий /opt/cprocsp/.
func (h *setupHandlers) installCryptoPro(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
// Архив КриптоПро ~50-100 МБ — поднимем лимит до 256 МБ.
if err := r.ParseMultipartForm(256 << 20); err != nil {
setupFlash(w, r, "Установка: ошибка чтения формы: "+err.Error())
return
}
file, header, err := r.FormFile("dist")
if err != nil {
setupFlash(w, r, "Установка: выберите файл архива дистрибутива (.tar/.tgz/.tar.gz/.rpm)")
return
}
defer file.Close()
dir := "/tmp/bj-cryptopro"
_ = os.RemoveAll(dir)
if err := os.MkdirAll(dir, 0o755); err != nil {
setupFlash(w, r, "Установка: не получилось создать "+dir+": "+err.Error())
return
}
dst := filepath.Join(dir, filepath.Base(header.Filename))
out, err := os.Create(dst)
if err != nil {
setupFlash(w, r, "Установка: не получилось создать "+dst+": "+err.Error())
return
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
setupFlash(w, r, "Установка: ошибка записи файла: "+err.Error())
return
}
out.Close()
// Распаковка (если .tar/.tgz/.tar.gz).
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
if strings.HasSuffix(strings.ToLower(dst), ".rpm") {
// Один rpm — установим напрямую.
} else if strings.HasSuffix(strings.ToLower(dst), ".tar.gz") || strings.HasSuffix(strings.ToLower(dst), ".tgz") {
if untar, err := runCmdInDir(ctx, dir, "tar", "-xzf", dst); err != nil {
setupFlash(w, r, "Установка: распаковка .tgz упала: "+err.Error()+" / вывод: "+untar)
return
}
} else if strings.HasSuffix(strings.ToLower(dst), ".tar") {
if untar, err := runCmdInDir(ctx, dir, "tar", "-xf", dst); err != nil {
setupFlash(w, r, "Установка: распаковка .tar упала: "+err.Error()+" / вывод: "+untar)
return
}
} else {
setupFlash(w, r, "Установка: неизвестный формат файла, нужен .tar/.tgz/.tar.gz/.rpm")
return
}
// Найти все .rpm в директории.
var rpms []string
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() && strings.HasSuffix(p, ".rpm") {
rpms = append(rpms, p)
}
return nil
})
if len(rpms) == 0 {
setupFlash(w, r, "Установка: после распаковки .rpm файлы не найдены в "+dir)
return
}
// sudo rpm -i <все rpm>. На РЕД ОС иногда нужен --nosignature --nodeps.
args := append([]string{"rpm", "-Uvh", "--replacepkgs", "--nosignature"}, rpms...)
output, err := runCmd(ctx, "sudo", args...)
if err != nil {
setupFlash(w, r, "Установка: rpm -i упал: "+err.Error()+" / вывод: "+strings.TrimSpace(output))
return
}
setupFlash(w, r, "КриптоПро CSP установлен. Файлов rpm: "+fmt.Sprint(len(rpms))+". Теперь введите серийник и нажмите «Активировать лицензию». Вывод rpm: "+strings.TrimSpace(output))
}
// runCmdInDir выполняет команду в указанной рабочей директории.
func runCmdInDir(ctx context.Context, dir, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
return string(out), err
}
// runCmd выполняет команду и возвращает stdout+stderr строкой.
func runCmd(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
out, err := cmd.CombinedOutput()
return string(out), err
}
// activateLicense — POST /admin/setup/crypto/activate. Принимает серийный
// номер из формы, вызывает cpconfig -license -set, возвращает результат
// во flash. Если КриптоПро CSP не установлен — даёт ссылку на инструкцию.
func (h *setupHandlers) activateLicense(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
serial := strings.TrimSpace(r.FormValue("license_key"))
if serial == "" {
setupFlash(w, r, "Активация лицензии: введите серийный номер в поле выше")
return
}
cpconfig := "/opt/cprocsp/sbin/amd64/cpconfig"
if _, err := os.Stat(cpconfig); err != nil {
setupFlash(w, r, "КриптоПро CSP не установлен ("+cpconfig+" не найден). Раздел /admin/help/cryptopro — команды установки и копирования дистрибутива на ВМ.")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
out, err := runCmd(ctx, cpconfig, "-license", "-set", serial)
if err != nil {
setupFlash(w, r, "Активация лицензии не прошла: "+err.Error()+" / вывод: "+strings.TrimSpace(out))
return
}
cur := h.rc.Snapshot().Crypto
cur.LicenseKey = serial
if err := h.rc.UpdateCrypto(cur); err != nil {
log.Printf("activateLicense: UpdateCrypto: %v", err)
}
setupFlash(w, r, "Лицензия КриптоПро активирована. Вывод cpconfig: "+strings.TrimSpace(out))
}
// checkCrypto — POST /admin/setup/crypto/check. Запускает Health()
// текущего провайдера PKCS#11 без изменения настроек.
func (h *setupHandlers) checkCrypto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot()
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.Provider(s.Crypto.Provider),
ModulePath: s.Crypto.JCPPath, // унаследовано — теперь путь к PKCS#11 .so
})
defer cli.Close()
info, err := cli.Health(r.Context())
if err != nil {
setupFlash(w, r, "СКЗИ: проверка не прошла — "+err.Error())
return
}
msg := fmt.Sprintf("СКЗИ %s: %s", info.Provider, info.Message)
if info.CryptokiVersion != "" {
msg += fmt.Sprintf(" (PKCS#11 v%s, %s)", info.CryptokiVersion, info.ManufacturerID)
}
if len(info.Tokens) > 0 {
msg += ". Токены:"
for _, t := range info.Tokens {
msg += fmt.Sprintf(" «%s» (%s);", t.Label, t.Model)
}
}
setupFlash(w, r, msg)
}
// SetupData — данные для шаблона admin_setup.html.
type SetupData struct {
page
Settings Settings
Readiness []Readiness
ReadyCount int
TotalCount int
Flash string
Error string
}
func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flash string) {
s := h.rc.Snapshot()
r := h.rc.ReadinessSummary()
ready := 0
for _, x := range r {
if x.Configured {
ready++
}
}
data := SetupData{
page: nowPage("Настройка", "setup"),
Settings: s,
Readiness: r,
ReadyCount: ready,
TotalCount: len(r),
Flash: flash,
}
if errVal := errMsgFromQuery(_q(w)); errVal != "" {
data.Error = errVal
}
render(w, h.tpl.a.setup, data)
}
func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
dsn := strings.TrimSpace(r.FormValue("dsn"))
if dsn != "" {
// Лёгкая проверка: попытка sql.Open и Ping (без драйвера дальше
// просто ошибка — это нормально, важно показать что DSN сохранён).
if err := tryPingPostgres(dsn); err != nil {
setupFlash(w, r, "postgres: тест соединения упал: "+err.Error())
return
}
}
if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil {
setupFlash(w, r, "postgres: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "PostgreSQL настройки сохранены")
}
func (h *setupHandlers) saveCrypto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := CryptoSettings{
Provider: strings.TrimSpace(r.FormValue("provider")),
SocketPath: strings.TrimSpace(r.FormValue("socket_path")),
JCPPath: strings.TrimSpace(r.FormValue("jcp_path")),
LicenseKey: strings.TrimSpace(r.FormValue("license_key")),
}
if s.Provider == "" {
s.Provider = "stub"
}
if s.SocketPath == "" {
s.SocketPath = "/run/bj/crypto.sock"
}
// Если указан JCP-путь — проверим что файл существует.
if s.JCPPath != "" {
if _, err := os.Stat(s.JCPPath); err != nil {
setupFlash(w, r, "crypto: jcp_path не найден: "+err.Error())
return
}
}
if err := h.rc.UpdateCrypto(s); err != nil {
setupFlash(w, r, "crypto: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "Криптография: настройки сохранены ("+s.Provider+")")
}
func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := NSDSettings{
Profile: strings.TrimSpace(r.FormValue("profile")),
IGWBaseURL: strings.TrimSpace(r.FormValue("igw_base_url")),
KeyContainer: strings.TrimSpace(r.FormValue("key_container")),
}
if s.IGWBaseURL != "" {
if _, err := url.ParseRequestURI(s.IGWBaseURL); err != nil {
setupFlash(w, r, "nsd: невалидный URL: "+err.Error())
return
}
if err := tryHTTPHealth(s.IGWBaseURL + "/healthz"); err != nil {
setupFlash(w, r, "nsd: ИШ не отвечает на /healthz: "+err.Error())
return
}
}
if err := h.rc.UpdateNSD(s); err != nil {
setupFlash(w, r, "nsd: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "nsd-adapter: настройки сохранены")
}
func (h *setupHandlers) saveLK(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := LKSettings{CallbackURL: strings.TrimSpace(r.FormValue("callback_url"))}
if s.CallbackURL != "" {
if _, err := url.ParseRequestURI(s.CallbackURL); err != nil {
setupFlash(w, r, "lk: невалидный URL: "+err.Error())
return
}
if err := tryHTTPHealth(s.CallbackURL + "/healthz"); err != nil {
setupFlash(w, r, "lk: callback URL не отвечает на /healthz: "+err.Error())
return
}
}
if err := h.rc.UpdateLK(s); err != nil {
setupFlash(w, r, "lk: ошибка сохранения: "+err.Error())
return
}
if s.CallbackURL != "" {
h.svc.callbackURL = s.CallbackURL
}
setupFlash(w, r, "Callback в ЛК сохранён и применён")
}
// testRun запускает тестовую заявку с предустановленными данными,
// ждёт изменения статуса до confirmed/rejected/timed_out и сохраняет
// результат в RuntimeConfig.LastTest.
func (h *setupHandlers) testRun(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
go h.runTestClaim()
setupFlash(w, r, "Тестовая заявка запущена, обновите страницу через 5 секунд")
}
// runTestClaim делает CreateClaim + ждёт финального состояния через GetClaim.
func (h *setupHandlers) runTestClaim() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
whole := uint64(1500)
req := CreateClaimRequest{
Investor: Investor{
ID: "11111111-1111-1111-1111-111111111111",
LastName: "Иванов", FirstName: "Иван", MiddleName: "Иванович",
Document: Document{DocumentType: "21", Series: "4512", Number: "654321"},
},
TransferringDepositoryINN: "0702345678",
ReceivingDepositoryINN: "0710987654",
CostInfo: CostInfo{No: &struct{}{}},
IIAAgreement: &IIAAgreement{
AgreementType: "T03", AgreementNumber: "ИИС78/2024",
AgreementDate: "2026-01-15", BrokerINN: "0707083893",
},
Securities: []ClaimSec{
{
SecurityCode: "MM0766162534",
SecurityDetails: SecurityDetails{ISIN: "RU0007661625"},
Quantity: Quantity{Whole: whole},
SettlementAccounts: []SettlementAccount{
{
SettlementRequisitesINN: "7702070139",
SettlementLocation: SettlementLocation{
DeponentCode: "DP789456", AccountID: "31MC0021900000F01", SectionID: "P001",
},
},
},
},
},
SignedDocument: "dGVzdC1zaWduYXR1cmU=",
SignatureFormat: "XMLDSig-GOST",
}
res := TestRunResult{StartedAt: time.Now().UTC()}
created, err := h.svc.CreateClaim(ctx, req)
if err != nil {
res.FinishedAt = time.Now().UTC()
res.OK = false
res.Message = "CreateClaim упал: " + err.Error()
_ = h.rc.RecordTestRun(res)
return
}
res.ClaimID = created.ID
// Опрашиваем статус каждые 200ms до перехода в финал.
deadline := time.Now().Add(25 * time.Second)
for time.Now().Before(deadline) {
view, err := h.svc.GetClaim(ctx, created.ID)
if err != nil {
res.FinishedAt = time.Now().UTC()
res.OK = false
res.FinalStatus = "lookup_failed"
res.Message = err.Error()
_ = h.rc.RecordTestRun(res)
return
}
switch view.Status {
case "confirmed", "awaiting_sub16", "done":
res.FinishedAt = time.Now().UTC()
res.OK = true
res.FinalStatus = view.Status
res.Message = "Заявка подтверждена принимающей стороной (mock или реальный НРД)."
_ = h.rc.RecordTestRun(res)
return
case "rejected", "timed_out":
res.FinishedAt = time.Now().UTC()
res.OK = false
res.FinalStatus = view.Status
res.Message = "Заявка не прошла: статус " + view.Status
_ = h.rc.RecordTestRun(res)
return
}
time.Sleep(200 * time.Millisecond)
}
res.FinishedAt = time.Now().UTC()
res.OK = false
res.FinalStatus = "timeout_waiting"
res.Message = "Не дождались финального статуса за 25 сек (mock-задержка обычно 3 сек; проверьте лог lk-gateway)"
_ = h.rc.RecordTestRun(res)
}
// tryPingPostgres делает короткое подключение через pgx и Ping.
func tryPingPostgres(dsn string) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return err
}
defer conn.Close(ctx)
return conn.Ping(ctx)
}
// tryHTTPHealth делает GET и ждёт 2xx.
func tryHTTPHealth(u string) error {
c := &http.Client{Timeout: 3 * time.Second}
resp, err := c.Get(u)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil
}
// setupFlash шлёт 303 на /admin/setup с flash-сообщением в query.
func setupFlash(w http.ResponseWriter, r *http.Request, msg string) {
http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
// _q извлекает Request из ResponseWriter trick — здесь не нужно
// (всегда работаем через chain).
func _q(_ http.ResponseWriter) string { return "" }
func errMsgFromQuery(_ string) string { return "" }
// guard — заглушка для совместимости с возможным расширением.
var _ = errors.New