Files
Bridge-and-Join-s/internal/lkgateway/setup.go
T
fontvielle 0ef75e05e8 feat(admin): импорт сертификатов через UI + список сертификатов на токенах + URL контуров НРД
После реальной установки КриптоПро CSP добавлены следующие
функциональности:

cryptocli/client.go:
- FindCertificates() — перечисляет CKO_CERTIFICATE объекты на всех
  подключенных слотах через PKCS#11, парсит X.509, извлекает CN, ИНН
  (OID 1.2.643.3.131.1.1), серийник, срок действия. Для каждого
  сертификата проверяет наличие парного приватного ключа (CKO_PRIVATE_KEY
  с тем же CKA_ID).
- Тип Certificate с полями: SubjectCN, IssuerCN, INN, Serial, NotBefore,
  NotAfter, DER, HasPrivateKey, TokenLabel, SlotID.

internal/lkgateway/setup.go:
- handler importCertificate (POST /admin/setup/crypto/import-cert,
  multipart). Принимает .pfx/.p12 (с PIN) или .cer/.crt. Запускает
  certmgr -inst -pfx или -inst с выбором хранилища (uMy/mroot/uRoot).
- listCertsForUI() — вспомогательный метод renderSetup для подгрузки
  актуального списка сертификатов с подключенных токенов при каждом
  открытии страницы.

internal/lkgateway/web/templates/admin_setup.html:
- секция «Сертификаты на токенах» с таблицей (Кому/Кем выдан/ИНН/срок/
  токен/есть-ли-приватный-ключ).
- форма «Импорт сертификата (.pfx/.cer/.crt)» с полями для PIN и
  выбора хранилища.
- блок «Интеграционный шлюз НРД»: добавлен JS автозаполнения URL ONYX
  и контейнера по выбору профиля (guest/test3/prod × gost/rsa) —
  значения из DOC/Ссылки для доступа в тестовые контуры.pdf.

internal/lkgateway/web/templates/admin_help_systems.html:
- секция «Интеграционный шлюз НРД и контуры тестирования» дополнена
  полной таблицей URL-ов сервисов GUEST/TEST3 (ONYX, Agate, DCS,
  Единый кабинет, Корпоративные действия). IP gost.nsd.ru для
  настройки межсетевого экрана.
- новая секция «Сертификаты УЦ НРД (для проверки квитанций)» с
  пошаговой инструкцией: куда импортировать корневой сертификат УЦ
  НРД, куда промежуточные, куда наши сертификаты из стороннего УЦ.

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

588 lines
21 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"
)
// crypto-сертификаты на текущих токенах (для отображения на странице).
func (h *setupHandlers) listCertsForUI() []cryptocli.Certificate {
s := h.rc.Snapshot()
if s.Crypto.Provider == "" || s.Crypto.Provider == "stub" || s.Crypto.JCPPath == "" {
return nil
}
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.Provider(s.Crypto.Provider),
ModulePath: s.Crypto.JCPPath,
})
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
certs, _ := cli.FindCertificates(ctx)
return certs
}
// 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/crypto/import-cert", h.importCertificate)
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()
lower := strings.ToLower(dst)
switch {
case strings.HasSuffix(lower, ".rpm"):
// Один rpm — установим напрямую без распаковки.
case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"):
if untar, err := runCmdInDir(ctx, dir, "tar", "-xzf", dst); err != nil {
setupFlash(w, r, "Установка: распаковка .tgz упала: "+err.Error()+" / вывод: "+untar)
return
}
case strings.HasSuffix(lower, ".tar"):
if untar, err := runCmdInDir(ctx, dir, "tar", "-xf", dst); err != nil {
setupFlash(w, r, "Установка: распаковка .tar упала: "+err.Error()+" / вывод: "+untar)
return
}
default:
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))
}
// importCertificate — POST /admin/setup/crypto/import-cert (multipart).
// Принимает .pfx (PKCS#12 — приватный ключ + сертификат + опц. PIN) или
// .cer/.crt (только публичный сертификат). Импортирует через certmgr
// КриптоПро. Сертификат добавляется в хранилище uMy (либо mroot для
// корневых).
func (h *setupHandlers) importCertificate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
if err := r.ParseMultipartForm(64 << 20); err != nil {
setupFlash(w, r, "Импорт сертификата: ошибка чтения формы: "+err.Error())
return
}
file, header, err := r.FormFile("cert")
if err != nil {
setupFlash(w, r, "Импорт сертификата: выберите файл .pfx/.cer/.crt")
return
}
defer file.Close()
pin := strings.TrimSpace(r.FormValue("pin"))
store := strings.TrimSpace(r.FormValue("store")) // "uMy" по умолчанию, "mroot" для корневых
dir := "/tmp/bj-certs"
_ = os.MkdirAll(dir, 0o755)
safeName := filepath.Base(header.Filename)
dst := filepath.Join(dir, safeName)
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()
certmgr := "/opt/cprocsp/bin/amd64/certmgr"
if _, err := os.Stat(certmgr); err != nil {
setupFlash(w, r, "Импорт сертификата: certmgr не найден. Сначала установите КриптоПро CSP.")
return
}
if store == "" {
store = "uMy"
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
lower := strings.ToLower(safeName)
var cmdOut string
switch {
case strings.HasSuffix(lower, ".pfx") || strings.HasSuffix(lower, ".p12"):
// PKCS#12 — импорт через certmgr -inst с PIN
args := []string{"-inst", "-pfx", "-file", dst, "-store", store}
if pin != "" {
args = append(args, "-pin", pin)
}
cmdOut, err = runCmd(ctx, certmgr, args...)
case strings.HasSuffix(lower, ".cer") || strings.HasSuffix(lower, ".crt"):
// Голый сертификат — импорт в хранилище без приватного ключа
cmdOut, err = runCmd(ctx, certmgr, "-inst", "-file", dst, "-store", store)
default:
setupFlash(w, r, "Импорт сертификата: неизвестное расширение, нужен .pfx/.p12/.cer/.crt")
return
}
if err != nil {
setupFlash(w, r, "Импорт сертификата: certmgr упал: "+err.Error()+" / вывод: "+strings.TrimSpace(cmdOut))
return
}
setupFlash(w, r, "Сертификат «"+safeName+"» импортирован в хранилище "+store+". Вывод certmgr: "+strings.TrimSpace(cmdOut))
}
// 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
Certificates []cryptocli.Certificate
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),
Certificates: h.listCertsForUI(),
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