package lkgateway import ( "context" "crypto/sha256" "crypto/x509" "encoding/asn1" "encoding/hex" "encoding/pem" "errors" "fmt" "os" "os/exec" "os/user" "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"` } // 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{ 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// через 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 [-p] — рекурсивное извлечение. 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 } // UnmountISO удаляет всё, что относится к загруженному образу: // - распакованную директорию /var/lib/bj/media/iso//; // - .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//. // Возвращает целевой путь. 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// 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//, генерирует секцию для 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: `, не // пишем повторно. 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//. 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//, и связанную секцию из pki1.conf // (между маркерами «# --- bj-server: профиль ---» и следующим // «# --- 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: профиль ---» и кончается // перед следующим таким маркером или до конца файла. Если блок не // найден — успех (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]) } 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) }