9737c787f9
Инфраструктура 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>
526 lines
19 KiB
Go
526 lines
19 KiB
Go
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()
|
|
}
|