feat: живой цикл M2M с НРД + мастер установки ключа на флешку
Инфраструктура 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>
This commit is contained in:
@@ -0,0 +1,745 @@
|
||||
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/<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
|
||||
}
|
||||
|
||||
// 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])
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user