feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk) - трансферные таблицы FTV_M2M: создание при установке, события, справочник участников - сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO - боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК - онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML) - модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+6
-402
@@ -11,7 +11,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -50,11 +49,11 @@ type Medium struct {
|
||||
// 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/
|
||||
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.
|
||||
@@ -69,7 +68,7 @@ type KeyContainer struct {
|
||||
type CertFile struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Format string `json:"format"` // "cer" | "pem" | "pfx"
|
||||
Format string `json:"format"` // "cer" | "pem" | "pfx"
|
||||
SubjectCN string `json:"subject_cn"`
|
||||
IssuerCN string `json:"issuer_cn"`
|
||||
Serial string `json:"serial"`
|
||||
@@ -80,75 +79,6 @@ type CertFile struct {
|
||||
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{
|
||||
@@ -383,332 +313,6 @@ func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) {
|
||||
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])
|
||||
|
||||
Reference in New Issue
Block a user