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>
746 lines
26 KiB
Go
746 lines
26 KiB
Go
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)
|
|
}
|