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:
zuevav
2026-06-19 00:03:21 +03:00
parent 6e503433d4
commit 9737c787f9
110 changed files with 10771 additions and 1690 deletions
+192
View File
@@ -0,0 +1,192 @@
// Command bj-license — инструмент издателя: генерация ключей подписи,
// выпуск годовых лицензий и проверка.
//
// bj-license keygen -out ./keys/license
// bj-license issue -tenant "ООО Ромашка" -plan pro -days 365 \
// -features updates,web-cabinet -key ./keys/license.priv -keyid main
// bj-license verify -key-file license.key -pub ./keys/license.pub
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
// newUUID — UUID v4 без внешних зависимостей.
func newUUID() string {
var b [16]byte
_, _ = rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40 // версия 4
b[8] = (b[8] & 0x3f) | 0x80 // вариант
h := hex.EncodeToString(b[:])
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
}
func main() {
if len(os.Args) < 2 {
usage()
}
switch os.Args[1] {
case "keygen":
keygen(os.Args[2:])
case "issue":
issue(os.Args[2:])
case "verify":
verify(os.Args[2:])
default:
usage()
}
}
func usage() {
fmt.Fprintln(os.Stderr, "bj-license keygen -out <prefix>")
fmt.Fprintln(os.Stderr, "bj-license issue -tenant <name> -plan free|pro|enterprise -days <n> -features a,b -key <priv> [-keyid id] [-max-nodes n] [-note txt]")
fmt.Fprintln(os.Stderr, "bj-license verify -key-file <license.key> -pub <pubkey.pub>")
os.Exit(2)
}
func keygen(args []string) {
out := "license"
for i := 0; i < len(args)-1; i++ {
if args[i] == "-out" {
out = args[i+1]
}
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
fatal("keygen: %v", err)
}
if err := os.WriteFile(out+".priv", []byte(base64.StdEncoding.EncodeToString(priv.Seed())+"\n"), 0o600); err != nil {
fatal("write priv: %v", err)
}
if err := os.WriteFile(out+".pub", []byte(base64.StdEncoding.EncodeToString(pub)+"\n"), 0o644); err != nil {
fatal("write pub: %v", err)
}
fmt.Printf("Приватный ключ лицензий: %s.priv (СЕКРЕТ)\n", out)
fmt.Printf("Публичный ключ (зашить в bj-server):\n %s\n", base64.StdEncoding.EncodeToString(pub))
}
func issue(args []string) {
a := parseArgs(args)
tenant := a["tenant"]
keyPath := a["key"]
if tenant == "" || keyPath == "" {
fatal("issue: требуются -tenant и -key")
}
plan := license.Plan(orDefault(a["plan"], "pro"))
days := atoiDefault(a["days"], 365)
keyID := orDefault(a["keyid"], "main")
priv, err := license.LoadPrivateKey(keyPath)
if err != nil {
fatal("load key: %v", err)
}
now := time.Now().UTC()
var feats []string
if a["features"] != "" {
feats = strings.Split(a["features"], ",")
}
l := &license.License{
Schema: license.CurrentSchema,
ID: newUUID(),
Tenant: tenant,
Product: "bj-server",
Plan: plan,
IssuedAt: now,
ExpiresAt: now.AddDate(0, 0, days),
Features: feats,
MaxNodes: atoiDefault(a["max-nodes"], 0),
Note: a["note"],
}
tok, err := license.Sign(l, priv, keyID)
if err != nil {
fatal("sign: %v", err)
}
fmt.Printf("Лицензия выпущена: tenant=%q plan=%s до %s (%d дней)\n",
tenant, plan, l.ExpiresAt.Format("02.01.2006"), days)
fmt.Printf("ID: %s\n", l.ID)
fmt.Println("Ключ для клиента (вставить в bj-server → Лицензия):")
fmt.Println(tok.Encode())
}
func verify(args []string) {
a := parseArgs(args)
if a["key-file"] == "" || a["pub"] == "" {
fatal("verify: требуются -key-file и -pub")
}
raw, err := os.ReadFile(a["key-file"])
if err != nil {
fatal("read key-file: %v", err)
}
pubB, err := os.ReadFile(a["pub"])
if err != nil {
fatal("read pub: %v", err)
}
pub, err := license.ParsePublicKey(strings.TrimSpace(string(pubB)))
if err != nil {
fatal("pub: %v", err)
}
tok, err := license.DecodeToken(string(raw))
if err != nil {
fatal("decode: %v", err)
}
l, err := license.Verify(tok, pub)
if err != nil {
fatal("verify: %v", err)
}
now := time.Now().UTC()
fmt.Printf("Подпись валидна. tenant=%q plan=%s\n", l.Tenant, l.Plan)
fmt.Printf("Действует: %s — %s (осталось %d дней)\n",
l.IssuedAt.Format("02.01.2006"), l.ExpiresAt.Format("02.01.2006"), l.DaysLeft(now))
if err := l.Valid(now); err != nil {
fmt.Printf("СТАТУС: %v\n", err)
} else {
fmt.Printf("СТАТУС: активна, обновления %v\n", l.AllowsUpdates())
}
}
// --- helpers ---
func parseArgs(args []string) map[string]string {
m := map[string]string{}
for i := 0; i < len(args); i++ {
if strings.HasPrefix(args[i], "-") && i+1 < len(args) {
m[strings.TrimPrefix(args[i], "-")] = args[i+1]
i++
}
}
return m
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
var n int
_, err := fmt.Sscanf(s, "%d", &n)
if err != nil {
return def
}
return n
}
func fatal(format string, a ...any) {
fmt.Fprintf(os.Stderr, "bj-license: "+format+"\n", a...)
os.Exit(1)
}