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>
This commit is contained in:
fontvielle
2026-05-14 14:09:20 +03:00
parent 660d71e21a
commit 82b3186b95
2 changed files with 154 additions and 0 deletions
+138
View File
@@ -4,9 +4,13 @@ import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
@@ -40,11 +44,145 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
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) {
@@ -80,6 +80,22 @@
<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>
<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>