da12a9ec1c
- 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>
350 lines
12 KiB
Go
350 lines
12 KiB
Go
package lkgateway
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/asn1"
|
|
"encoding/hex"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// MediaRoot — где bj-server хранит свои носители: распакованные ISO,
|
|
// импортированные ключевые контейнеры. На прод-машине пользователь bj
|
|
// должен быть владельцем этой директории (создаётся install.sh).
|
|
const (
|
|
mediaRoot = "/var/lib/bj/media"
|
|
mediaISODir = "/var/lib/bj/media/iso"
|
|
containersDir = "/var/lib/bj/containers"
|
|
profilesDir = "/var/lib/bj/profiles"
|
|
keyFileMinPerDir = 2 // считаем директорию контейнером, если в ней >= 2 *.key файлов
|
|
)
|
|
|
|
// Medium — один носитель: USB-флешка или распакованная ISO.
|
|
type Medium struct {
|
|
// ID — стабильный идентификатор (для USB — sha1 от пути монтирования,
|
|
// для ISO — sha256-prefix от исходного файла).
|
|
ID string `json:"id"`
|
|
// Kind — "usb" или "iso".
|
|
Kind string `json:"kind"`
|
|
// Mountpoint — корень, по которому сейчас доступен носитель.
|
|
Mountpoint string `json:"mountpoint"`
|
|
// Source — для ISO: путь до исходного .iso на сервере.
|
|
Source string `json:"source,omitempty"`
|
|
// Profile — полный профиль Валидаты (pse + gdbm + vdkeys), если найден.
|
|
Profile *ValidataProfile `json:"profile,omitempty"`
|
|
// Containers — найденные ключевые контейнеры (директории с *.key/*.vdk).
|
|
Containers []KeyContainer `json:"containers"`
|
|
// Certificates — отдельно лежащие сертификаты (.cer/.crt/.pem/.pfx/.p12).
|
|
Certificates []CertFile `json:"certificates"`
|
|
}
|
|
|
|
// 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/
|
|
}
|
|
|
|
// KeyContainer — ключевой контейнер: директория с *.key или *.vdk.
|
|
type KeyContainer struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"` // имя последней компоненты пути
|
|
Files []string `json:"files"` // имена файлов в контейнере
|
|
Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/containers/
|
|
}
|
|
|
|
// CertFile — публичный или PKCS#12 сертификат.
|
|
type CertFile struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Format string `json:"format"` // "cer" | "pem" | "pfx"
|
|
SubjectCN string `json:"subject_cn"`
|
|
IssuerCN string `json:"issuer_cn"`
|
|
Serial string `json:"serial"`
|
|
NotBefore time.Time `json:"not_before"`
|
|
NotAfter time.Time `json:"not_after"`
|
|
INN string `json:"inn,omitempty"`
|
|
HasPrivateKey bool `json:"has_private_key"` // true для .pfx/.p12
|
|
ParseError string `json:"parse_error,omitempty"`
|
|
}
|
|
|
|
// scanMountpoint сканирует точку монтирования на 3 уровня вглубь.
|
|
func scanMountpoint(kind, mountpoint, source string) Medium {
|
|
m := Medium{
|
|
ID: sha1Path(mountpoint),
|
|
Kind: kind,
|
|
Mountpoint: mountpoint,
|
|
Source: source,
|
|
}
|
|
containers, certs, profile := walkForArtifacts(mountpoint)
|
|
m.Containers = containers
|
|
m.Certificates = certs
|
|
m.Profile = profile
|
|
// Отмечаем контейнеры, уже импортированные в /var/lib/bj/containers/.
|
|
for i := range m.Containers {
|
|
if _, err := os.Stat(filepath.Join(containersDir, m.Containers[i].Name)); err == nil {
|
|
m.Containers[i].Imported = true
|
|
}
|
|
}
|
|
// Профиль помечается импортированным, если в /var/lib/bj/profiles/
|
|
// есть директория с тем же именем (имя берётся от носителя).
|
|
if m.Profile != nil {
|
|
name := filepath.Base(mountpoint)
|
|
if _, err := os.Stat(filepath.Join(profilesDir, name)); err == nil {
|
|
m.Profile.Imported = true
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// walkForArtifacts проходит дерево mountpoint (до 3 уровней) и собирает:
|
|
// - директории-контейнеры (>=2 *.key или >=1 *.vdk файла);
|
|
// - отдельные сертификаты (.cer/.pfx/...);
|
|
// - полный профиль Валидаты (наличие *.pse + *.gdbm + *.vdk в дереве).
|
|
func walkForArtifacts(root string) ([]KeyContainer, []CertFile, *ValidataProfile) {
|
|
var containers []KeyContainer
|
|
var certs []CertFile
|
|
prof := &ValidataProfile{Root: root}
|
|
|
|
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(root, p)
|
|
depth := strings.Count(rel, string(filepath.Separator))
|
|
if depth > 4 {
|
|
return filepath.SkipDir
|
|
}
|
|
if info.IsDir() {
|
|
if p != root {
|
|
if c, ok := classifyContainer(p); ok {
|
|
containers = append(containers, c)
|
|
// НЕ делаем SkipDir: внутри vdkeys/ нужно собрать
|
|
// .vdk-файлы для определения профиля Валидаты.
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
lower := strings.ToLower(info.Name())
|
|
switch {
|
|
case strings.HasSuffix(lower, ".pse"):
|
|
prof.PSEFiles = append(prof.PSEFiles, rel)
|
|
case strings.HasSuffix(lower, ".gdbm"):
|
|
prof.GDBMFiles = append(prof.GDBMFiles, rel)
|
|
case strings.HasSuffix(lower, ".vdk"):
|
|
prof.KeyFiles = append(prof.KeyFiles, rel)
|
|
default:
|
|
if cert := classifyCertFile(p); cert != nil {
|
|
certs = append(certs, *cert)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// Профилем считаем носитель если есть и pse, и vdk (gdbm
|
|
// опционален — но обычно тоже присутствует).
|
|
if len(prof.PSEFiles) == 0 || len(prof.KeyFiles) == 0 {
|
|
prof = nil
|
|
}
|
|
return containers, certs, prof
|
|
}
|
|
|
|
// classifyContainer — директория является ключевым контейнером, если:
|
|
// - в ней >=2 файлов *.key (старый формат КриптоПро/Валидата); или
|
|
// - в ней >=1 файл *.vdk (Валидата Linux).
|
|
func classifyContainer(dir string) (KeyContainer, bool) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return KeyContainer{}, false
|
|
}
|
|
var keyFiles, vdkFiles, allFiles []string
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
allFiles = append(allFiles, name)
|
|
lower := strings.ToLower(name)
|
|
switch {
|
|
case strings.HasSuffix(lower, ".vdk"):
|
|
vdkFiles = append(vdkFiles, name)
|
|
case strings.HasSuffix(lower, ".key"):
|
|
keyFiles = append(keyFiles, name)
|
|
}
|
|
}
|
|
if len(vdkFiles) == 0 && len(keyFiles) < keyFileMinPerDir {
|
|
return KeyContainer{}, false
|
|
}
|
|
return KeyContainer{
|
|
Path: dir,
|
|
Name: filepath.Base(dir),
|
|
Files: allFiles,
|
|
}, true
|
|
}
|
|
|
|
// classifyCertFile парсит один файл — возвращает CertFile если это
|
|
// похоже на сертификат.
|
|
func classifyCertFile(path string) *CertFile {
|
|
lower := strings.ToLower(path)
|
|
var format string
|
|
switch {
|
|
case strings.HasSuffix(lower, ".cer"), strings.HasSuffix(lower, ".crt"):
|
|
format = "cer"
|
|
case strings.HasSuffix(lower, ".pem"):
|
|
format = "pem"
|
|
case strings.HasSuffix(lower, ".pfx"), strings.HasSuffix(lower, ".p12"):
|
|
format = "pfx"
|
|
default:
|
|
return nil
|
|
}
|
|
cf := &CertFile{
|
|
Path: path,
|
|
Name: filepath.Base(path),
|
|
Format: format,
|
|
}
|
|
if format == "pfx" {
|
|
// PKCS#12 шифрован PIN'ом — мета без него не вытащить.
|
|
cf.HasPrivateKey = true
|
|
return cf
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
cf.ParseError = "read: " + err.Error()
|
|
return cf
|
|
}
|
|
if len(data) > 32*1024 {
|
|
// Странно большой файл для сертификата — режем.
|
|
data = data[:32*1024]
|
|
}
|
|
der := data
|
|
if block, _ := pem.Decode(data); block != nil && block.Type == "CERTIFICATE" {
|
|
der = block.Bytes
|
|
}
|
|
cert, err := x509.ParseCertificate(der)
|
|
if err != nil {
|
|
cf.ParseError = "x509: " + err.Error()
|
|
return cf
|
|
}
|
|
cf.SubjectCN = cert.Subject.CommonName
|
|
cf.IssuerCN = cert.Issuer.CommonName
|
|
cf.Serial = cert.SerialNumber.Text(16)
|
|
cf.NotBefore = cert.NotBefore
|
|
cf.NotAfter = cert.NotAfter
|
|
cf.INN = extractCertINN(cert)
|
|
return cf
|
|
}
|
|
|
|
// extractCertINN — ИНН из OID 1.2.643.3.131.1.1 в Subject.
|
|
func extractCertINN(c *x509.Certificate) string {
|
|
innOID := asn1.ObjectIdentifier{1, 2, 643, 3, 131, 1, 1}
|
|
for _, name := range c.Subject.Names {
|
|
if name.Type.Equal(innOID) {
|
|
if s, ok := name.Value.(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ExtractISO распаковывает образ диска (.iso/.img/.zip и т.п.) в
|
|
// /var/lib/bj/media/iso/<id>/ через 7z. password — опциональный пароль
|
|
// архива (пустая строка = без пароля). id — sha256-prefix от исходного
|
|
// пути. Возвращает Medium или ошибку.
|
|
func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) {
|
|
abs, err := filepath.Abs(isoPath)
|
|
if err != nil {
|
|
return Medium{}, fmt.Errorf("ISO путь: %w", err)
|
|
}
|
|
info, err := os.Stat(abs)
|
|
if err != nil {
|
|
return Medium{}, fmt.Errorf("ISO не найден: %w", err)
|
|
}
|
|
if info.IsDir() {
|
|
return Medium{}, errors.New("ISO путь — это директория, нужен файл")
|
|
}
|
|
|
|
id := isoID(abs)
|
|
dst := filepath.Join(mediaISODir, id)
|
|
if err := os.MkdirAll(dst, 0o755); err != nil {
|
|
return Medium{}, fmt.Errorf("создать %s: %w", dst, err)
|
|
}
|
|
if isEmpty, _ := dirEmpty(dst); !isEmpty {
|
|
// Уже распакован раньше — просто пересканируем.
|
|
writeISOSource(id, abs)
|
|
m := scanMountpoint("iso", dst, abs)
|
|
m.ID = id
|
|
return m, nil
|
|
}
|
|
|
|
cctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
|
defer cancel()
|
|
// 7z x -y -o<dst> [-p<pass>] <archive> — рекурсивное извлечение.
|
|
args := []string{"x", "-y", "-o" + dst}
|
|
if password != "" {
|
|
// 7z требует пароль через -p без пробела.
|
|
args = append(args, "-p"+password)
|
|
} else {
|
|
// -p- запрещает интерактивный запрос пароля (нам нечего вводить).
|
|
args = append(args, "-p-")
|
|
}
|
|
args = append(args, abs)
|
|
cmd := exec.CommandContext(cctx, "7z", args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
_ = os.RemoveAll(dst)
|
|
return Medium{}, fmt.Errorf("7z x: %w / %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
writeISOSource(id, abs)
|
|
|
|
m := scanMountpoint("iso", dst, abs)
|
|
m.ID = id
|
|
return m, nil
|
|
}
|
|
|
|
func isoID(absPath string) string {
|
|
h := sha256.Sum256([]byte(absPath))
|
|
return hex.EncodeToString(h[:8])
|
|
}
|
|
|
|
func sha1Path(s string) string {
|
|
h := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(h[:6])
|
|
}
|
|
|
|
func dirEmpty(path string) (bool, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer f.Close()
|
|
names, err := f.Readdirnames(1)
|
|
if errors.Is(err, os.ErrInvalid) || err != nil {
|
|
return len(names) == 0, nil
|
|
}
|
|
return len(names) == 0, nil
|
|
}
|
|
|
|
func readISOSource(id string) string {
|
|
data, err := os.ReadFile(filepath.Join(mediaISODir, id+".src"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
|
|
func writeISOSource(id, src string) {
|
|
_ = os.WriteFile(filepath.Join(mediaISODir, id+".src"), []byte(src), 0o644)
|
|
}
|