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,114 @@
|
||||
// Command bj-artifactory — простой сервер раздачи релизов и обновлений.
|
||||
//
|
||||
// Раскладка хранилища (--root), один подкаталог на канал:
|
||||
//
|
||||
// <root>/stable/manifest.json — подписанный SignedManifest
|
||||
// <root>/stable/bj-server — артефакты, перечисленные в манифесте
|
||||
// <root>/stable/crypto-service.jar
|
||||
// <root>/beta/manifest.json
|
||||
// ...
|
||||
//
|
||||
// HTTP API (потребляет bj-server auto-update и install.sh):
|
||||
//
|
||||
// GET /v1/<channel>/manifest.json — манифест канала
|
||||
// GET /v1/<channel>/files/<name> — артефакт по имени
|
||||
// GET /healthz — проверка живости
|
||||
//
|
||||
// Подпись манифеста делает bj-release; здесь только статическая раздача.
|
||||
// Перед прод-выкаткой ставится за TLS-reverse-proxy (nginx, см.
|
||||
// deploy/artifactory/nginx.conf).
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", ":8090", "адрес прослушивания")
|
||||
root := flag.String("root", "./releases", "корень хранилища релизов")
|
||||
flag.Parse()
|
||||
|
||||
abs, err := filepath.Abs(*root)
|
||||
if err != nil {
|
||||
log.Fatalf("bj-artifactory: root: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(abs); err != nil {
|
||||
log.Fatalf("bj-artifactory: каталог релизов %s недоступен: %v", abs, err)
|
||||
}
|
||||
srv := &server{root: abs}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
|
||||
mux.HandleFunc("/v1/", srv.handleV1)
|
||||
|
||||
log.Printf("bj-artifactory: раздаю %s на %s", abs, *addr)
|
||||
httpSrv := &http.Server{Addr: *addr, Handler: logging(mux), ReadHeaderTimeout: 10 * time.Second}
|
||||
log.Fatal(httpSrv.ListenAndServe())
|
||||
}
|
||||
|
||||
type server struct{ root string }
|
||||
|
||||
// handleV1 разбирает /v1/<channel>/manifest.json и /v1/<channel>/files/<name>.
|
||||
func (s *server) handleV1(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/v1/")
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 2 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
channel := parts[0]
|
||||
if !safeName(channel) {
|
||||
http.Error(w, "bad channel", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case len(parts) == 2 && parts[1] == "manifest.json":
|
||||
s.serveFile(w, r, filepath.Join(s.root, channel, "manifest.json"), "application/json")
|
||||
case len(parts) == 3 && parts[1] == "files":
|
||||
name := parts[2]
|
||||
if !safeName(name) {
|
||||
http.Error(w, "bad name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.serveFile(w, r, filepath.Join(s.root, channel, name), "application/octet-stream")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) serveFile(w http.ResponseWriter, r *http.Request, path, ctype string) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
fi, err := f.Stat()
|
||||
if err != nil || fi.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f)
|
||||
}
|
||||
|
||||
// safeName запрещает обход каталогов (.., /, пустые).
|
||||
func safeName(s string) bool {
|
||||
if s == "" || s == "." || s == ".." {
|
||||
return false
|
||||
}
|
||||
return !strings.ContainsAny(s, "/\\") && !strings.Contains(s, "..")
|
||||
}
|
||||
|
||||
func logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.RemoteAddr, r.Method, r.URL.Path)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package main — bj-installer.
|
||||
//
|
||||
// Web-инсталлятор для bj-server: на машине клиента после установки
|
||||
// Debian/Astra поднимает локальный HTTP на 127.0.0.1:8181, проводит
|
||||
// через 5-страничный wizard (welcome → precheck → config → install → done)
|
||||
// и за кадром выполняет 20+ шагов установки Валидаты + bj-server + ИШ.
|
||||
//
|
||||
// Прогресс шагов прилетает в UI через Server-Sent Events. Каждый шаг
|
||||
// идемпотентен — можно повторно запускать инсталлятор на уже настроенной
|
||||
// машине, он пропустит то, что сделано.
|
||||
//
|
||||
// Запуск: sudo ./bj-installer [--addr 127.0.0.1:8181] [--no-browser]
|
||||
// Артефакты ожидаются рядом с бинарём в каталоге ./artifacts/:
|
||||
//
|
||||
// artifacts/ClientL_Other/zpki-*.deb
|
||||
// artifacts/ClientL_Other/zsdk-*.deb
|
||||
// artifacts/bj-server (Go-бинарь)
|
||||
// artifacts/crypto-service.jar (Java-сайдкар)
|
||||
// artifacts/ish/igate_*.deb (ИШ НРД)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const banner = `
|
||||
======================================================================
|
||||
bj-installer — мастер установки Bridge-and-Join-s
|
||||
======================================================================
|
||||
`
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "127.0.0.1:8181", "адрес web-инсталлятора")
|
||||
noBrowser := flag.Bool("no-browser", false, "не пытаться открыть браузер автоматически")
|
||||
artifactsDir := flag.String("artifacts", "./artifacts", "каталог с дистрибутивами (Validata deb, bj-server, ish)")
|
||||
flag.Parse()
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Fprintln(os.Stderr, "Установщик должен быть запущен от root (sudo).")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Print(banner)
|
||||
fmt.Printf(" адрес: http://%s\n", *addr)
|
||||
fmt.Printf(" артефакты: %s\n", *artifactsDir)
|
||||
fmt.Println("======================================================================")
|
||||
|
||||
st := newState(*artifactsDir)
|
||||
|
||||
srv := newServer(st)
|
||||
httpSrv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: srv,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// SIGINT/SIGTERM → корректный shutdown
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("HTTP-сервер упал: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
url := "http://" + *addr
|
||||
log.Printf("Откройте в браузере: %s", url)
|
||||
if !*noBrowser {
|
||||
tryOpenBrowser(url)
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("Завершаем работу...")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = httpSrv.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
// tryOpenBrowser — без фанатизма. Если xdg-open/sensible-browser есть и
|
||||
// $DISPLAY поднят (xrdp, Fly DE) — откроем. Иначе пользователь увидит URL
|
||||
// в выводе и перейдёт сам с другого компа (типичный сценарий headless).
|
||||
func tryOpenBrowser(url string) {
|
||||
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||
return
|
||||
}
|
||||
var bin string
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
for _, cand := range []string{"xdg-open", "sensible-browser", "x-www-browser"} {
|
||||
if p, err := exec.LookPath(cand); err == nil {
|
||||
bin = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bin == "" {
|
||||
return
|
||||
}
|
||||
_ = exec.Command(bin, url).Start()
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// runPrechecks — все системные проверки на стадии "Проверка системы".
|
||||
// Возвращает срез результатов, по каждому видно ✓/✗ + объяснение.
|
||||
//
|
||||
// Ничего не модифицирует — просто читает /etc/os-release, проверяет
|
||||
// наличие нужных бинарей, права root, свободное место, артефакты в
|
||||
// artifactsDir и т.п. UI отрисовывает таблицей.
|
||||
func runPrechecks(artifactsDir string) []PrecheckResult {
|
||||
var out []PrecheckResult
|
||||
|
||||
out = append(out, checkRoot())
|
||||
out = append(out, checkArch())
|
||||
out = append(out, checkDistro())
|
||||
out = append(out, checkAptAvailable())
|
||||
out = append(out, checkSystemd())
|
||||
out = append(out, checkDiskSpace())
|
||||
out = append(out, checkArtifacts(artifactsDir))
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func checkRoot() PrecheckResult {
|
||||
if os.Geteuid() == 0 {
|
||||
return PrecheckResult{ID: "root", Title: "Запуск от root", OK: true}
|
||||
}
|
||||
return PrecheckResult{ID: "root", Title: "Запуск от root", OK: false, Message: "Требуется sudo"}
|
||||
}
|
||||
|
||||
func checkArch() PrecheckResult {
|
||||
if runtime.GOARCH == "amd64" {
|
||||
return PrecheckResult{ID: "arch", Title: "Архитектура amd64", OK: true, Message: runtime.GOARCH}
|
||||
}
|
||||
return PrecheckResult{ID: "arch", Title: "Архитектура amd64", OK: false, Message: "Валидата собрана только под amd64, у вас " + runtime.GOARCH}
|
||||
}
|
||||
|
||||
func checkDistro() PrecheckResult {
|
||||
id, pretty := readOSRelease()
|
||||
switch id {
|
||||
case "debian", "astra":
|
||||
return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: true, Message: pretty}
|
||||
case "ubuntu":
|
||||
return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: true, Message: pretty + " (поддерживается на свой страх)"}
|
||||
default:
|
||||
return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: false, Message: "ОС не в списке поддерживаемых: " + pretty}
|
||||
}
|
||||
}
|
||||
|
||||
func checkAptAvailable() PrecheckResult {
|
||||
if _, err := exec.LookPath("apt-get"); err != nil {
|
||||
return PrecheckResult{ID: "apt", Title: "Доступен apt-get", OK: false, Message: "apt-get не найден — это не Debian-семейство"}
|
||||
}
|
||||
return PrecheckResult{ID: "apt", Title: "Доступен apt-get", OK: true}
|
||||
}
|
||||
|
||||
func checkSystemd() PrecheckResult {
|
||||
if _, err := os.Stat("/run/systemd/system"); err != nil {
|
||||
return PrecheckResult{ID: "systemd", Title: "systemd работает", OK: false, Message: "/run/systemd/system нет"}
|
||||
}
|
||||
return PrecheckResult{ID: "systemd", Title: "systemd работает", OK: true}
|
||||
}
|
||||
|
||||
func checkDiskSpace() PrecheckResult {
|
||||
var fs syscall.Statfs_t
|
||||
if err := syscall.Statfs("/var", &fs); err != nil {
|
||||
return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: false, Message: err.Error()}
|
||||
}
|
||||
freeBytes := fs.Bavail * uint64(fs.Bsize)
|
||||
freeGiB := freeBytes / (1 << 30)
|
||||
if freeGiB < 2 {
|
||||
return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: false, Message: fmt.Sprintf("Свободно %d GiB, нужно ≥ 2", freeGiB)}
|
||||
}
|
||||
return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: true, Message: fmt.Sprintf("%d GiB свободно", freeGiB)}
|
||||
}
|
||||
|
||||
func checkArtifacts(dir string) PrecheckResult {
|
||||
required := []struct {
|
||||
Glob string
|
||||
Name string
|
||||
}{
|
||||
{filepath.Join(dir, "ClientL_Other", "zpki-*.deb"), "zpki (Валидата)"},
|
||||
{filepath.Join(dir, "bj-server"), "bj-server (Go-бинарь)"},
|
||||
{filepath.Join(dir, "crypto-service.jar"), "crypto-service.jar"},
|
||||
}
|
||||
var missing []string
|
||||
for _, r := range required {
|
||||
matches, _ := filepath.Glob(r.Glob)
|
||||
if len(matches) == 0 {
|
||||
missing = append(missing, r.Name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return PrecheckResult{
|
||||
ID: "artifacts",
|
||||
Title: "Артефакты дистрибутива",
|
||||
OK: false,
|
||||
Message: "Отсутствуют: " + strings.Join(missing, ", ") + " (положите в " + dir + ")",
|
||||
}
|
||||
}
|
||||
return PrecheckResult{ID: "artifacts", Title: "Артефакты дистрибутива", OK: true, Message: "Все на месте в " + dir}
|
||||
}
|
||||
|
||||
func readOSRelease() (id, pretty string) {
|
||||
b, err := os.ReadFile("/etc/os-release")
|
||||
if err != nil {
|
||||
return "", "неизвестно"
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v = strings.Trim(v, `"`)
|
||||
switch k {
|
||||
case "ID":
|
||||
id = v
|
||||
case "PRETTY_NAME":
|
||||
pretty = v
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed web
|
||||
var webFS embed.FS
|
||||
|
||||
type server struct {
|
||||
state *State
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func newServer(st *State) *server {
|
||||
s := &server{state: st, mux: http.NewServeMux()}
|
||||
|
||||
// Статика (HTML/CSS/JS из embed)
|
||||
sub, _ := fs.Sub(webFS, "web")
|
||||
s.mux.Handle("/", http.FileServer(http.FS(sub)))
|
||||
|
||||
// API
|
||||
s.mux.HandleFunc("/api/state", s.handleState)
|
||||
s.mux.HandleFunc("/api/precheck", s.handlePrecheck)
|
||||
s.mux.HandleFunc("/api/config", s.handleConfig)
|
||||
s.mux.HandleFunc("/api/install", s.handleInstall)
|
||||
s.mux.HandleFunc("/api/events", s.handleSSE)
|
||||
s.mux.HandleFunc("/api/reset", s.handleReset)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Защита: только localhost (даже если addr 0.0.0.0 поставят)
|
||||
host := r.RemoteAddr
|
||||
if i := strings.LastIndex(host, ":"); i != -1 {
|
||||
host = host[:i]
|
||||
}
|
||||
switch host {
|
||||
case "127.0.0.1", "::1", "[::1]", "localhost":
|
||||
// ok
|
||||
default:
|
||||
http.Error(w, "installer is local-only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GET /api/state — полный snapshot для холодного открытия страницы.
|
||||
func (s *server) handleState(w http.ResponseWriter, r *http.Request) {
|
||||
snap := s.state.Snapshot()
|
||||
writeJSON(w, snap)
|
||||
}
|
||||
|
||||
// POST /api/precheck — запускает все pre-check проверки и возвращает результат.
|
||||
// Wizard переходит на стадию precheck.
|
||||
func (s *server) handlePrecheck(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s.state.setStage(StagePrecheck)
|
||||
results := runPrechecks(s.state.artifactsDir)
|
||||
s.state.setPrecheck(results)
|
||||
writeJSON(w, results)
|
||||
}
|
||||
|
||||
// POST /api/config — сохраняет org INN, email, license. Переход на стадию config.
|
||||
func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var c Config
|
||||
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.state.setConfig(c)
|
||||
s.state.setStage(StageConfig)
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/install — стартует установку (в горутине), переход на стадию installing.
|
||||
// UI слушает /api/events для прогресса.
|
||||
func (s *server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s.state.setStage(StageInstalling)
|
||||
go func() {
|
||||
if err := runInstallation(s.state); err != nil {
|
||||
log.Printf("install error: %v", err)
|
||||
s.state.setError(err.Error())
|
||||
return
|
||||
}
|
||||
s.state.setStage(StageDone)
|
||||
}()
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/reset — сброс wizard'а на welcome (после ошибки).
|
||||
func (s *server) handleReset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s.state.mu.Lock()
|
||||
s.state.Stage = StageWelcome
|
||||
s.state.ErrorMsg = ""
|
||||
s.state.Precheck = nil
|
||||
s.state.Steps = buildStepList()
|
||||
s.state.mu.Unlock()
|
||||
s.state.bus.publish(event{Type: "reset", Data: "{}"})
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// event — одно событие, отдаваемое подписчикам через SSE.
|
||||
// Type становится `event:` строкой, Data — `data:`.
|
||||
type event struct {
|
||||
Type string
|
||||
Data string
|
||||
}
|
||||
|
||||
// eventBus — простой fan-out для SSE. Подписчик создаётся в момент
|
||||
// открытия GET /api/events и живёт до закрытия соединения.
|
||||
type eventBus struct {
|
||||
mu sync.Mutex
|
||||
subscribers map[chan event]struct{}
|
||||
}
|
||||
|
||||
func newEventBus() *eventBus {
|
||||
return &eventBus{subscribers: make(map[chan event]struct{})}
|
||||
}
|
||||
|
||||
func (b *eventBus) subscribe() chan event {
|
||||
ch := make(chan event, 64)
|
||||
b.mu.Lock()
|
||||
b.subscribers[ch] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (b *eventBus) unsubscribe(ch chan event) {
|
||||
b.mu.Lock()
|
||||
delete(b.subscribers, ch)
|
||||
close(ch)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *eventBus) publish(e event) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for ch := range b.subscribers {
|
||||
select {
|
||||
case ch <- e:
|
||||
default:
|
||||
// Подписчик отстаёт — пропускаем (UI догонится снапшотом по GET /api/state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleSSE — GET /api/events. Держит соединение, в каждом событии
|
||||
// отдаёт event: <Type>\ndata: <Data>\n\n.
|
||||
func (s *server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
ch := s.state.bus.subscribe()
|
||||
defer s.state.bus.unsubscribe(ch)
|
||||
|
||||
// сразу шлём snapshot, чтобы UI догнал состояние
|
||||
snap := s.state.Snapshot()
|
||||
fmt.Fprintf(w, "event: snapshot\ndata: %s\n\n", mustJSON(snap))
|
||||
flusher.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case e := <-ch:
|
||||
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", e.Type, e.Data); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WizardStage — какой странице wizard'а соответствует текущее состояние.
|
||||
// Переходы: welcome → precheck → config → installing → done.
|
||||
// Из любого можно вернуться в welcome (полный reset).
|
||||
type WizardStage string
|
||||
|
||||
const (
|
||||
StageWelcome WizardStage = "welcome"
|
||||
StagePrecheck WizardStage = "precheck"
|
||||
StageConfig WizardStage = "config"
|
||||
StageInstalling WizardStage = "installing"
|
||||
StageDone WizardStage = "done"
|
||||
StageError WizardStage = "error"
|
||||
)
|
||||
|
||||
// Config — данные, которые wizard собирает на стадии config.
|
||||
type Config struct {
|
||||
OrgINN string `json:"orgInn"` // ИНН организации
|
||||
OrgName string `json:"orgName"` // отображаемое имя
|
||||
AdminEmail string `json:"adminEmail"` // куда писать алерты
|
||||
LicenseKey string `json:"licenseKey"` // годовой ключ (опционально, можно пропустить)
|
||||
}
|
||||
|
||||
// StepStatus — текущее состояние конкретного шага установки.
|
||||
type StepStatus string
|
||||
|
||||
const (
|
||||
StepPending StepStatus = "pending"
|
||||
StepRunning StepStatus = "running"
|
||||
StepDone StepStatus = "done"
|
||||
StepSkipped StepStatus = "skipped"
|
||||
StepFailed StepStatus = "failed"
|
||||
)
|
||||
|
||||
// StepState — снимок одного шага для отдачи в UI.
|
||||
type StepState struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status StepStatus `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Started *time.Time `json:"started,omitempty"`
|
||||
Finished *time.Time `json:"finished,omitempty"`
|
||||
}
|
||||
|
||||
// PrecheckResult — результат одной системной проверки на стадии precheck.
|
||||
type PrecheckResult struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// State — потокобезопасное состояние wizard'а. Хранит всё что нужно
|
||||
// отрисовать на любой из страниц + текущий прогресс установки.
|
||||
type State struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
artifactsDir string
|
||||
|
||||
Stage WizardStage `json:"stage"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Precheck []PrecheckResult `json:"precheck"`
|
||||
Config Config `json:"config"`
|
||||
Steps []StepState `json:"steps"`
|
||||
|
||||
bus *eventBus
|
||||
}
|
||||
|
||||
func newState(artifactsDir string) *State {
|
||||
return &State{
|
||||
artifactsDir: artifactsDir,
|
||||
Stage: StageWelcome,
|
||||
Steps: buildStepList(),
|
||||
bus: newEventBus(),
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot — потокобезопасная копия для GET /api/state.
|
||||
func (s *State) Snapshot() State {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
cp := *s
|
||||
cp.Precheck = append([]PrecheckResult(nil), s.Precheck...)
|
||||
cp.Steps = append([]StepState(nil), s.Steps...)
|
||||
return cp
|
||||
}
|
||||
|
||||
func (s *State) setStage(st WizardStage) {
|
||||
s.mu.Lock()
|
||||
s.Stage = st
|
||||
s.mu.Unlock()
|
||||
s.bus.publish(event{Type: "stage", Data: mustJSON(map[string]string{"stage": string(st)})})
|
||||
}
|
||||
|
||||
func (s *State) setError(msg string) {
|
||||
s.mu.Lock()
|
||||
s.Stage = StageError
|
||||
s.ErrorMsg = msg
|
||||
s.mu.Unlock()
|
||||
s.bus.publish(event{Type: "error", Data: mustJSON(map[string]string{"message": msg})})
|
||||
}
|
||||
|
||||
func (s *State) setPrecheck(items []PrecheckResult) {
|
||||
s.mu.Lock()
|
||||
s.Precheck = items
|
||||
s.mu.Unlock()
|
||||
s.bus.publish(event{Type: "precheck", Data: mustJSON(items)})
|
||||
}
|
||||
|
||||
func (s *State) setConfig(c Config) {
|
||||
s.mu.Lock()
|
||||
s.Config = c
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *State) updateStep(id string, fn func(*StepState)) {
|
||||
s.mu.Lock()
|
||||
var snap StepState
|
||||
for i := range s.Steps {
|
||||
if s.Steps[i].ID == id {
|
||||
fn(&s.Steps[i])
|
||||
snap = s.Steps[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.bus.publish(event{Type: "step", Data: mustJSON(snap)})
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Step — описание одного шага установки. Run выполняет шаг, может
|
||||
// проверить idempotency и вернуть Skipped. Логи прокидываются через
|
||||
// log-функцию, которая публикует event в SSE.
|
||||
type Step struct {
|
||||
ID string
|
||||
Title string
|
||||
Run func(s *State, log func(string)) (StepStatus, error)
|
||||
}
|
||||
|
||||
// buildStepList — фиксированный порядок шагов установки. Соответствует
|
||||
// install-validata.sh + установка bj-server/crypto-service/ИШ. Меняется
|
||||
// атомарно (если что-то добавляется — добавляем сюда).
|
||||
func buildStepList() []StepState {
|
||||
steps := allSteps()
|
||||
out := make([]StepState, len(steps))
|
||||
for i, s := range steps {
|
||||
out[i] = StepState{ID: s.ID, Title: s.Title, Status: StepPending}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func allSteps() []Step {
|
||||
return []Step{
|
||||
{ID: "deps", Title: "Установка системных зависимостей", Run: stepInstallDeps},
|
||||
{ID: "validata-deb", Title: "Установка пакетов Валидаты (zpki + zsdk)", Run: stepInstallValidataDebs},
|
||||
{ID: "execstack", Title: "execstack -c libvdcsp.so", Run: stepExecstack},
|
||||
{ID: "bj-user", Title: "Создание пользователя bj и каталогов", Run: stepCreateBJUser},
|
||||
{ID: "pcscd-dropin", Title: "Настройка pcscd (always-on)", Run: stepPcscdDropin},
|
||||
{ID: "bj-crypto-dropins", Title: "Drop-ins для bj-crypto sandbox", Run: stepBJCryptoDropins},
|
||||
{ID: "bj-server-dropin", Title: "Drop-in для bj-server", Run: stepBJServerDropin},
|
||||
{ID: "spki-ini", Title: "Создание spki.ini", Run: stepSPKIIni},
|
||||
{ID: "pki1-prep", Title: "Подготовка pki1.conf для bj", Run: stepPKI1Prep},
|
||||
{ID: "usb-mount", Title: "Авто-mount USB через udev + systemd", Run: stepUSBMount},
|
||||
{ID: "bj-server-binary", Title: "Установка bj-server бинаря в /opt/bj/", Run: stepInstallBJServer},
|
||||
{ID: "crypto-jar", Title: "Установка crypto-service.jar", Run: stepInstallCryptoJar},
|
||||
{ID: "systemd-units", Title: "systemd unit bj-crypto.service + bj-server.service", Run: stepSystemdUnits},
|
||||
{ID: "ish-install", Title: "Установка ИШ НРД (если есть .deb)", Run: stepInstallISH},
|
||||
{ID: "save-config", Title: "Сохранение setup.json", Run: stepSaveConfig},
|
||||
{ID: "systemd-start", Title: "Запуск сервисов (pcscd, bj-crypto, bj-server)", Run: stepStartServices},
|
||||
{ID: "health", Title: "Финальный health-check", Run: stepHealthCheck},
|
||||
}
|
||||
}
|
||||
|
||||
// runInstallation — основной цикл установки. Перебирает шаги, обновляет
|
||||
// статусы через State, прокидывает логи в SSE. Останавливается при первой
|
||||
// ошибке (UI покажет какой шаг + сообщение).
|
||||
func runInstallation(s *State) error {
|
||||
steps := allSteps()
|
||||
for _, step := range steps {
|
||||
now := time.Now()
|
||||
s.updateStep(step.ID, func(ss *StepState) {
|
||||
ss.Status = StepRunning
|
||||
ss.Started = &now
|
||||
ss.Message = ""
|
||||
})
|
||||
|
||||
logFn := func(line string) {
|
||||
s.updateStep(step.ID, func(ss *StepState) {
|
||||
ss.Message = line
|
||||
})
|
||||
}
|
||||
|
||||
status, err := step.Run(s, logFn)
|
||||
finished := time.Now()
|
||||
s.updateStep(step.ID, func(ss *StepState) {
|
||||
ss.Status = status
|
||||
ss.Finished = &finished
|
||||
if err != nil {
|
||||
ss.Message = err.Error()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("шаг %q: %w", step.Title, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------- //
|
||||
// Хелперы
|
||||
// --------------------------------------------------------------------- //
|
||||
|
||||
// runCmd — запускает команду, прокидывает stdout/stderr построчно в log.
|
||||
// Возвращает ошибку с последними строками stderr для удобства отображения.
|
||||
func runCmd(logFn func(string), name string, args ...string) error {
|
||||
logFn(fmt.Sprintf("$ %s %s", name, strings.Join(args, " ")))
|
||||
cmd := exec.Command(name, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
|
||||
if line != "" {
|
||||
logFn(line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeFileIfChanged — пишет файл только если содержимое отличается. Возвращает
|
||||
// true если файл был создан/изменён (для решения «нужен ли daemon-reload»).
|
||||
func writeFileIfChanged(path string, content string, mode os.FileMode) (bool, error) {
|
||||
existing, err := os.ReadFile(path)
|
||||
if err == nil && string(existing) == content {
|
||||
return false, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- //
|
||||
// Шаги
|
||||
// --------------------------------------------------------------------- //
|
||||
|
||||
func stepInstallDeps(s *State, log func(string)) (StepStatus, error) {
|
||||
log("Обновляю apt-кеш...")
|
||||
if err := runCmd(log, "apt-get", "update", "-qq"); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
|
||||
deps := []string{
|
||||
"libgtk-3-0", "libpcsclite1", "libccid", "pcscd",
|
||||
"libcurl4", "libkrb5-3", "libgssapi-krb5-2",
|
||||
"libsasl2-modules", "libsasl2-modules-gssapi-mit",
|
||||
"execstack", "p7zip-full",
|
||||
}
|
||||
if hasAPTPackage("libldap-2.4-2") {
|
||||
deps = append(deps, "libldap-2.4-2")
|
||||
} else {
|
||||
deps = append(deps, "libldap-2.5-0")
|
||||
log("libldap-2.4-2 не найден → ставлю 2.5-0, для zpki будет --force-depends")
|
||||
}
|
||||
|
||||
args := append([]string{"install", "-y", "--no-install-recommends"}, deps...)
|
||||
if err := runCmd(log, "apt-get", args...); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepInstallValidataDebs(s *State, log func(string)) (StepStatus, error) {
|
||||
zpki, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ClientL_Other", "zpki-*.amd64.deb"))
|
||||
zsdk, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ClientL_Other", "zsdk-*.amd64.deb"))
|
||||
if len(zpki) == 0 {
|
||||
return StepFailed, fmt.Errorf("zpki-*.amd64.deb не найден в %s/ClientL_Other/", s.artifactsDir)
|
||||
}
|
||||
useForce := !hasAPTPackage("libldap-2.4-2")
|
||||
for _, deb := range append(zpki, zsdk...) {
|
||||
args := []string{"-i", deb}
|
||||
if useForce {
|
||||
args = append([]string{"--force-depends"}, args...)
|
||||
}
|
||||
if err := runCmd(log, "dpkg", args...); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("/opt/Validata/VDCSP/lib/amd64"); err != nil {
|
||||
return StepFailed, fmt.Errorf("/opt/Validata не появился после установки")
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepExecstack(s *State, log func(string)) (StepStatus, error) {
|
||||
target := "/opt/Validata/VDCSP/lib/amd64/libvdcsp.so"
|
||||
// Проверка состояния
|
||||
out, err := exec.Command("execstack", "-q", target).Output()
|
||||
if err == nil && strings.HasPrefix(strings.TrimSpace(string(out)), "-") {
|
||||
log("executable-stack уже снят")
|
||||
return StepSkipped, nil
|
||||
}
|
||||
return StepDone, runCmd(log, "execstack", "-c", target)
|
||||
}
|
||||
|
||||
func stepCreateBJUser(s *State, log func(string)) (StepStatus, error) {
|
||||
if _, err := exec.LookPath("id"); err == nil {
|
||||
if exec.Command("id", "bj").Run() == nil {
|
||||
log("Пользователь bj уже существует")
|
||||
} else {
|
||||
if err := runCmd(log, "useradd", "--system", "--create-home",
|
||||
"--home-dir", "/var/lib/bj", "--shell", "/bin/bash", "bj"); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs := []struct {
|
||||
Path string
|
||||
Mode os.FileMode
|
||||
}{
|
||||
{"/var/lib/bj/usb", 0o755},
|
||||
{"/var/lib/bj/.Validata", 0o700},
|
||||
{"/var/lib/bj/.Validata/vdkeys", 0o700},
|
||||
{"/var/lib/bj/profiles", 0o755},
|
||||
{"/var/log/bj", 0o755},
|
||||
{"/var/lib/bj/.bj", 0o700},
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d.Path, d.Mode); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
return StepDone, runCmd(log, "chown", "-R", "bj:bj", "/var/lib/bj", "/var/log/bj")
|
||||
}
|
||||
|
||||
func stepPcscdDropin(s *State, log func(string)) (StepStatus, error) {
|
||||
const dropin = `[Unit]
|
||||
Requires=
|
||||
After=
|
||||
Sockets=
|
||||
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/sbin/pcscd --foreground
|
||||
`
|
||||
changed, err := writeFileIfChanged("/etc/systemd/system/pcscd.service.d/no-autoexit.conf", dropin, 0o644)
|
||||
if err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if !changed {
|
||||
log("Drop-in уже актуален")
|
||||
return StepSkipped, nil
|
||||
}
|
||||
log("Создан /etc/systemd/system/pcscd.service.d/no-autoexit.conf")
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepBJCryptoDropins(s *State, log func(string)) (StepStatus, error) {
|
||||
files := map[string]string{
|
||||
"/etc/systemd/system/bj-crypto.service.d/validata-paths.conf": `[Service]
|
||||
WorkingDirectory=/opt/Validata/VDCSP/etc
|
||||
ReadWritePaths=/opt/Validata/VDCSP/etc
|
||||
ReadWritePaths=/var/lib/bj
|
||||
`,
|
||||
"/etc/systemd/system/bj-crypto.service.d/usb-access.conf": `[Service]
|
||||
ReadOnlyPaths=/media
|
||||
ReadOnlyPaths=/var/lib/bj/usb
|
||||
`,
|
||||
"/etc/systemd/system/bj-crypto.service.d/share-crysvc.conf": `[Service]
|
||||
PrivateTmp=true
|
||||
BindPaths=/tmp/.crysvc.sock:/tmp/.crysvc.sock
|
||||
`,
|
||||
}
|
||||
for path, content := range files {
|
||||
if _, err := writeFileIfChanged(path, content, 0o644); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepBJServerDropin(s *State, log func(string)) (StepStatus, error) {
|
||||
const dropin = `[Service]
|
||||
ReadWritePaths=/opt/Validata/VDCSP/etc
|
||||
`
|
||||
_, err := writeFileIfChanged("/etc/systemd/system/bj-server.service.d/pki1conf.conf", dropin, 0o644)
|
||||
if err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepSPKIIni(s *State, log func(string)) (StepStatus, error) {
|
||||
const path = "/opt/Validata/VDCSP/etc/spki.ini"
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
log("Файл уже существует")
|
||||
return StepSkipped, nil
|
||||
}
|
||||
const content = `[store]
|
||||
count = 0
|
||||
|
||||
[Parameters]
|
||||
PkiLdapTimeout = 10
|
||||
PkiHttpTimeout = 60
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepPKI1Prep(s *State, log func(string)) (StepStatus, error) {
|
||||
const path = "/opt/Validata/VDCSP/etc/pki1.conf"
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
log("Файл pki1.conf отсутствует — Валидата создаст при первом запуске")
|
||||
return StepSkipped, nil
|
||||
}
|
||||
if err := runCmd(log, "chgrp", "bj", path); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if err := runCmd(log, "chmod", "g+w", path); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
|
||||
existing, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(existing), "# --- bj-server: BEGIN ---") {
|
||||
appended := string(existing) + "\n# --- bj-server: BEGIN ---\n# Секции профилей дописываются bj-server при импорте через /admin/setup.\n# --- bj-server: END ---\n"
|
||||
if err := os.WriteFile(path, []byte(appended), 0o664); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepUSBMount(s *State, log func(string)) (StepStatus, error) {
|
||||
files := map[string]string{
|
||||
"/etc/udev/rules.d/99-bj-usb.rules": `# Авто-mount USB-флешек в /var/lib/bj/usb/<UUID> с владельцем bj.
|
||||
ACTION=="add", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
|
||||
ENV{ID_FS_TYPE}!="", \
|
||||
ENV{SYSTEMD_WANTS}="bj-usb-mount@$env{ID_FS_UUID}.service"
|
||||
ACTION=="remove", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
|
||||
ENV{ID_FS_TYPE}!="", \
|
||||
ENV{SYSTEMD_WANTS}="bj-usb-umount@$env{ID_FS_UUID}.service"
|
||||
`,
|
||||
"/etc/systemd/system/bj-usb-mount@.service": `[Unit]
|
||||
Description=Mount USB %i to /var/lib/bj/usb/%i for bj
|
||||
DefaultDependencies=no
|
||||
After=local-fs.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/bin/bash -c 'mkdir -p /var/lib/bj/usb/%i && /usr/bin/mount -o uid=$(id -u bj),gid=$(id -g bj),fmask=0133,dmask=0022 UUID=%i /var/lib/bj/usb/%i'
|
||||
ExecStop=/usr/bin/umount /var/lib/bj/usb/%i || true
|
||||
`,
|
||||
"/etc/systemd/system/bj-usb-umount@.service": `[Unit]
|
||||
Description=Umount USB %i from /var/lib/bj/usb/%i
|
||||
DefaultDependencies=no
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/bash -c '/usr/bin/umount /var/lib/bj/usb/%i 2>/dev/null; /usr/bin/rmdir /var/lib/bj/usb/%i 2>/dev/null; true'
|
||||
`,
|
||||
}
|
||||
anyChanged := false
|
||||
for path, content := range files {
|
||||
ch, err := writeFileIfChanged(path, content, 0o644)
|
||||
if err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
anyChanged = anyChanged || ch
|
||||
}
|
||||
if anyChanged {
|
||||
_ = runCmd(log, "udevadm", "control", "--reload-rules")
|
||||
_ = runCmd(log, "udevadm", "trigger")
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepInstallBJServer(s *State, log func(string)) (StepStatus, error) {
|
||||
src := filepath.Join(s.artifactsDir, "bj-server")
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
return StepSkipped, nil // нет бинаря — может ставится через rpm/deb
|
||||
}
|
||||
if err := os.MkdirAll("/opt/bj", 0o755); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if err := runCmd(log, "install", "-o", "bj", "-g", "bj", "-m", "0755", src, "/opt/bj/bj-server"); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepInstallCryptoJar(s *State, log func(string)) (StepStatus, error) {
|
||||
src := filepath.Join(s.artifactsDir, "crypto-service.jar")
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
return StepSkipped, nil
|
||||
}
|
||||
if err := os.MkdirAll("/opt/bj", 0o755); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if err := runCmd(log, "install", "-o", "bj", "-g", "bj", "-m", "0644", src, "/opt/bj/crypto-service.jar"); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepSystemdUnits(s *State, log func(string)) (StepStatus, error) {
|
||||
units := map[string]string{
|
||||
"/etc/systemd/system/bj-crypto.service": `[Unit]
|
||||
Description=Bridge-and-Join-s — Crypto sidecar (Java + Валидата Клиент L)
|
||||
Before=bj-server.service
|
||||
After=network-online.target pcscd.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj
|
||||
Group=bj
|
||||
RuntimeDirectory=bj
|
||||
RuntimeDirectoryMode=0750
|
||||
Environment=BJ_CRYPTO_SOCKET=/run/bj/crypto.sock
|
||||
Environment=BJ_CRYPTO_PROVIDER=validata
|
||||
Environment=LD_LIBRARY_PATH=/opt/Validata/VDCSP/lib/amd64
|
||||
ExecStart=/usr/bin/java -Djava.library.path=/opt/Validata/VDCSP/lib/amd64 -jar /opt/bj/crypto-service.jar
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=append:/var/log/bj/crypto-service.log
|
||||
StandardError=append:/var/log/bj/crypto-service.err
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/run/bj /var/log/bj
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`,
|
||||
"/etc/systemd/system/bj-server.service": `[Unit]
|
||||
Description=Bridge-and-Join-s — единый сервис M2M-переводов
|
||||
After=network-online.target bj-crypto.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj
|
||||
Group=bj
|
||||
WorkingDirectory=/var/lib/bj
|
||||
ExecStart=/opt/bj/bj-server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=BJ_HTTP_ADDR=:8080
|
||||
Environment=BJ_SETUP_PATH=/var/lib/bj/.bj/setup.json
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/bj /var/log/bj
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`,
|
||||
}
|
||||
for path, content := range units {
|
||||
if _, err := writeFileIfChanged(path, content, 0o644); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
return StepDone, runCmd(log, "systemctl", "daemon-reload")
|
||||
}
|
||||
|
||||
func stepInstallISH(s *State, log func(string)) (StepStatus, error) {
|
||||
matches, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ish", "igate_*.deb"))
|
||||
if len(matches) == 0 {
|
||||
log("Дистрибутив ИШ не найден — пропускаю (можно установить позже)")
|
||||
return StepSkipped, nil
|
||||
}
|
||||
if err := runCmd(log, "dpkg", "-i", matches[0]); err != nil {
|
||||
// допустим, что зависимости подтянутся
|
||||
_ = runCmd(log, "apt-get", "-f", "install", "-y")
|
||||
if err := runCmd(log, "dpkg", "-i", matches[0]); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepSaveConfig(s *State, log func(string)) (StepStatus, error) {
|
||||
cfg := s.Snapshot().Config
|
||||
if cfg.OrgINN == "" && cfg.AdminEmail == "" && cfg.LicenseKey == "" {
|
||||
return StepSkipped, nil
|
||||
}
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if err := os.MkdirAll("/var/lib/bj/.bj", 0o700); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if err := os.WriteFile("/var/lib/bj/.bj/setup.json", b, 0o600); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
return StepDone, runCmd(log, "chown", "-R", "bj:bj", "/var/lib/bj/.bj")
|
||||
}
|
||||
|
||||
func stepStartServices(s *State, log func(string)) (StepStatus, error) {
|
||||
// disable+stop socket activation для pcscd
|
||||
_ = runCmd(log, "systemctl", "stop", "pcscd.socket")
|
||||
_ = runCmd(log, "systemctl", "disable", "pcscd.socket")
|
||||
|
||||
for _, svc := range []string{"pcscd", "bj-crypto", "bj-server"} {
|
||||
if err := runCmd(log, "systemctl", "enable", svc); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
if err := runCmd(log, "systemctl", "restart", svc); err != nil {
|
||||
return StepFailed, err
|
||||
}
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
func stepHealthCheck(s *State, log func(string)) (StepStatus, error) {
|
||||
var bad []string
|
||||
for _, svc := range []string{"pcscd", "vdcrysvc", "bj-crypto", "bj-server"} {
|
||||
if err := exec.Command("systemctl", "is-active", "--quiet", svc).Run(); err != nil {
|
||||
bad = append(bad, svc)
|
||||
} else {
|
||||
log(svc + ": active")
|
||||
}
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
return StepFailed, fmt.Errorf("сервисы не запустились: %s", strings.Join(bad, ", "))
|
||||
}
|
||||
return StepDone, nil
|
||||
}
|
||||
|
||||
// hasAPTPackage — проверяет наличие пакета в apt-cache (доступен ли для установки).
|
||||
func hasAPTPackage(name string) bool {
|
||||
out, err := exec.Command("apt-cache", "show", name).CombinedOutput()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(out), "Package: "+name)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Минимальный клиент wizard'а: рендерит страницы, ловит события из SSE,
|
||||
// отправляет POST'ы на backend для перехода между стадиями.
|
||||
|
||||
let state = {
|
||||
stage: "welcome",
|
||||
precheck: [],
|
||||
config: {},
|
||||
steps: [],
|
||||
errorMsg: "",
|
||||
};
|
||||
|
||||
const STAGE_ORDER = ["welcome", "precheck", "config", "installing", "done"];
|
||||
const STEP_ICONS = {
|
||||
pending: "○",
|
||||
running: "◐",
|
||||
done: "✓",
|
||||
skipped: "—",
|
||||
failed: "✗",
|
||||
};
|
||||
|
||||
function $(sel) { return document.querySelector(sel); }
|
||||
function $$(sel) { return [...document.querySelectorAll(sel)]; }
|
||||
|
||||
function render() {
|
||||
// stepper
|
||||
$$("#stepper span").forEach(el => {
|
||||
el.classList.remove("active", "done");
|
||||
const stage = el.dataset.stage;
|
||||
if (stage === state.stage) el.classList.add("active");
|
||||
if (STAGE_ORDER.indexOf(stage) < STAGE_ORDER.indexOf(state.stage)) el.classList.add("done");
|
||||
});
|
||||
// pages
|
||||
$$(".page").forEach(p => p.classList.toggle("active", p.dataset.stage === state.stage));
|
||||
|
||||
if (state.stage === "precheck") renderPrecheck();
|
||||
if (state.stage === "installing" || state.stage === "done") renderSteps();
|
||||
if (state.stage === "error") $("#error-message").textContent = state.errorMsg || "(нет деталей)";
|
||||
if (state.stage === "done") {
|
||||
// подставляем хост машины в админскую ссылку
|
||||
const adminURL = window.location.protocol + "//" + window.location.hostname + ":8080/admin/setup";
|
||||
$("#adminLink").href = adminURL;
|
||||
$("#adminLink").textContent = "Перейти в " + adminURL + " →";
|
||||
}
|
||||
}
|
||||
|
||||
function renderPrecheck() {
|
||||
const root = $("#precheck-results");
|
||||
root.innerHTML = "";
|
||||
let allOK = true;
|
||||
for (const r of state.precheck || []) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "check " + (r.ok ? "ok" : "bad");
|
||||
div.innerHTML = `
|
||||
<span class="check-icon">${r.ok ? "✓" : "✗"}</span>
|
||||
<div>
|
||||
<div class="check-title">${escapeHTML(r.title)}</div>
|
||||
${r.message ? `<div class="check-msg">${escapeHTML(r.message)}</div>` : ""}
|
||||
</div>`;
|
||||
root.appendChild(div);
|
||||
if (!r.ok) allOK = false;
|
||||
}
|
||||
$("#goConfigBtn").disabled = !allOK;
|
||||
}
|
||||
|
||||
function renderSteps() {
|
||||
const root = $("#step-list");
|
||||
root.innerHTML = "";
|
||||
let done = 0;
|
||||
for (const s of state.steps || []) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "step-" + s.status;
|
||||
li.innerHTML = `
|
||||
<span class="step-icon">${STEP_ICONS[s.status] || "○"}</span>
|
||||
<div>
|
||||
<div class="step-title">${escapeHTML(s.title)}</div>
|
||||
${s.message ? `<div class="step-msg">${escapeHTML(s.message)}</div>` : ""}
|
||||
</div>`;
|
||||
root.appendChild(li);
|
||||
if (s.status === "done" || s.status === "skipped") done++;
|
||||
}
|
||||
const total = state.steps.length;
|
||||
const pct = total ? Math.round(100 * done / total) : 0;
|
||||
$("#progress-bar").style.width = pct + "%";
|
||||
}
|
||||
|
||||
function escapeHTML(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({
|
||||
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'"
|
||||
}[c]));
|
||||
}
|
||||
|
||||
// ------------- transitions -------------
|
||||
|
||||
async function startPrecheck() {
|
||||
await fetch("/api/precheck", { method: "POST" });
|
||||
}
|
||||
|
||||
function goWelcome() {
|
||||
state.stage = "welcome";
|
||||
render();
|
||||
}
|
||||
|
||||
function goPrecheck() {
|
||||
state.stage = "precheck";
|
||||
render();
|
||||
}
|
||||
|
||||
async function goConfig() {
|
||||
state.stage = "config";
|
||||
render();
|
||||
}
|
||||
|
||||
async function startInstall() {
|
||||
const form = $("#config-form");
|
||||
const data = Object.fromEntries(new FormData(form).entries());
|
||||
await fetch("/api/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
await fetch("/api/install", { method: "POST" });
|
||||
}
|
||||
|
||||
async function resetWizard() {
|
||||
await fetch("/api/reset", { method: "POST" });
|
||||
}
|
||||
|
||||
// ------------- SSE -------------
|
||||
|
||||
function connectSSE() {
|
||||
const es = new EventSource("/api/events");
|
||||
es.addEventListener("snapshot", e => {
|
||||
const snap = JSON.parse(e.data);
|
||||
state.stage = snap.stage;
|
||||
state.precheck = snap.precheck || [];
|
||||
state.config = snap.config || {};
|
||||
state.steps = snap.steps || [];
|
||||
state.errorMsg = snap.errorMsg || "";
|
||||
render();
|
||||
});
|
||||
es.addEventListener("stage", e => {
|
||||
state.stage = JSON.parse(e.data).stage;
|
||||
render();
|
||||
});
|
||||
es.addEventListener("precheck", e => {
|
||||
state.precheck = JSON.parse(e.data);
|
||||
render();
|
||||
});
|
||||
es.addEventListener("step", e => {
|
||||
const s = JSON.parse(e.data);
|
||||
const idx = state.steps.findIndex(x => x.id === s.id);
|
||||
if (idx >= 0) state.steps[idx] = s;
|
||||
render();
|
||||
});
|
||||
es.addEventListener("error", e => {
|
||||
state.errorMsg = JSON.parse(e.data).message;
|
||||
state.stage = "error";
|
||||
render();
|
||||
});
|
||||
es.addEventListener("reset", () => {
|
||||
location.reload();
|
||||
});
|
||||
es.onerror = () => {
|
||||
// авто-реконнект делает EventSource сам, ничего не делаем
|
||||
};
|
||||
}
|
||||
|
||||
connectSSE();
|
||||
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>bj-installer — мастер установки Bridge-and-Join-s</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="logo">Bridge-and-Join-s</div>
|
||||
<div class="subtitle">мастер установки</div>
|
||||
</header>
|
||||
|
||||
<main id="app">
|
||||
<!-- ===== Stepper ===== -->
|
||||
<nav class="stepper" id="stepper">
|
||||
<span data-stage="welcome">1. Старт</span>
|
||||
<span data-stage="precheck">2. Проверка</span>
|
||||
<span data-stage="config">3. Настройка</span>
|
||||
<span data-stage="installing">4. Установка</span>
|
||||
<span data-stage="done">5. Готово</span>
|
||||
</nav>
|
||||
|
||||
<!-- ===== Welcome ===== -->
|
||||
<section class="page" data-stage="welcome">
|
||||
<h1>Добро пожаловать</h1>
|
||||
<p>Этот мастер установит на сервер <b>СКЗИ «Валидата Клиент L»</b>,
|
||||
<b>bj-server</b>, <b>bj-crypto</b> и <b>ИШ НРД</b>, настроит
|
||||
systemd-сервисы и подготовит окружение для подписи документов
|
||||
по ГОСТ 34.10-2012.</p>
|
||||
<p class="muted">После установки откроется <code>/admin/setup</code> в bj-server, где можно
|
||||
загрузить тестовый профиль от MOEX (.7z) и активировать подпись.</p>
|
||||
<div class="buttons">
|
||||
<button class="primary" onclick="startPrecheck()">Начать →</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Precheck ===== -->
|
||||
<section class="page" data-stage="precheck">
|
||||
<h1>Проверка системы</h1>
|
||||
<div id="precheck-results" class="checks"></div>
|
||||
<div class="buttons">
|
||||
<button onclick="goWelcome()">← Назад</button>
|
||||
<button class="primary" id="goConfigBtn" onclick="goConfig()">Дальше →</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Config ===== -->
|
||||
<section class="page" data-stage="config">
|
||||
<h1>Настройка</h1>
|
||||
<form id="config-form" onsubmit="event.preventDefault(); startInstall();">
|
||||
<label>ИНН организации
|
||||
<input type="text" name="orgInn" placeholder="7702077840" pattern="\d{10}|\d{12}">
|
||||
</label>
|
||||
<label>Название организации (для отображения)
|
||||
<input type="text" name="orgName" placeholder="ПАО Московская Биржа">
|
||||
</label>
|
||||
<label>Email администратора
|
||||
<input type="email" name="adminEmail" placeholder="admin@example.com">
|
||||
</label>
|
||||
<label>Лицензионный ключ (опционально)
|
||||
<input type="text" name="licenseKey" placeholder="BJ-XXXX-XXXX-XXXX">
|
||||
<span class="muted">Без ключа сервис работает, но обновления заблокированы. Получить можно в личном кабинете.</span>
|
||||
</label>
|
||||
<div class="buttons">
|
||||
<button type="button" onclick="goPrecheck()">← Назад</button>
|
||||
<button class="primary" type="submit">Установить →</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- ===== Installing ===== -->
|
||||
<section class="page" data-stage="installing">
|
||||
<h1>Установка</h1>
|
||||
<ol id="step-list" class="steps"></ol>
|
||||
<div class="progress"><div id="progress-bar" class="progress-bar"></div></div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Done ===== -->
|
||||
<section class="page" data-stage="done">
|
||||
<h1>✓ Готово</h1>
|
||||
<p>bj-server и все сервисы запущены. Откройте панель администратора и
|
||||
импортируйте профиль:</p>
|
||||
<div class="next-link">
|
||||
<a href="" id="adminLink" class="primary-link">Перейти в /admin/setup →</a>
|
||||
</div>
|
||||
<p class="muted">Что дальше:</p>
|
||||
<ol>
|
||||
<li>Подключите USB с .vdk → он автоматически смонтируется в <code>/var/lib/bj/usb/</code></li>
|
||||
<li>На <code>/admin/setup</code> загрузите .7z с профилем от MOEX и введите пароль</li>
|
||||
<li>Нажмите «Активировать» — bj-crypto подтянет ключ и подтвердит готовность</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- ===== Error ===== -->
|
||||
<section class="page" data-stage="error">
|
||||
<h1>✗ Установка прервана</h1>
|
||||
<p>Произошла ошибка:</p>
|
||||
<pre id="error-message" class="error"></pre>
|
||||
<p class="muted">Логи: <code>journalctl -u bj-installer</code> и <code>journalctl -u bj-crypto</code></p>
|
||||
<div class="buttons">
|
||||
<button onclick="resetWizard()">Начать заново</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
:root {
|
||||
--bg: #f6f7fb;
|
||||
--card: #ffffff;
|
||||
--text: #1d2330;
|
||||
--muted: #6b7280;
|
||||
--accent: #2563eb;
|
||||
--accent-dark: #1d4ed8;
|
||||
--ok: #16a34a;
|
||||
--err: #dc2626;
|
||||
--border: #e5e7eb;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.topbar {
|
||||
display: flex; align-items: baseline; gap: 16px;
|
||||
padding: 18px 32px;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
}
|
||||
.logo { font-weight: 700; font-size: 18px; letter-spacing: 0.3px; }
|
||||
.subtitle { color: #94a3b8; font-size: 14px; }
|
||||
main {
|
||||
max-width: 760px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.stepper {
|
||||
display: flex; gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
padding: 12px 14px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.stepper span {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stepper span.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stepper span.done {
|
||||
color: var(--ok);
|
||||
}
|
||||
.page {
|
||||
display: none;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
.page.active { display: block; }
|
||||
h1 { margin: 0 0 16px; font-size: 22px; }
|
||||
p, label { font-size: 15px; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
code {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.buttons {
|
||||
display: flex; gap: 12px;
|
||||
margin-top: 24px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
button, .primary-link {
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
button.primary, .primary-link {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
button.primary:hover, .primary-link:hover { background: var(--accent-dark); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Precheck */
|
||||
.checks { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
|
||||
.check {
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.check.ok { border-color: var(--ok); }
|
||||
.check.bad { border-color: var(--err); }
|
||||
.check-icon { font-size: 18px; }
|
||||
.check.ok .check-icon { color: var(--ok); }
|
||||
.check.bad .check-icon { color: var(--err); }
|
||||
.check-title { font-weight: 500; }
|
||||
.check-msg { font-size: 13px; color: var(--muted); }
|
||||
|
||||
/* Config form */
|
||||
#config-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
#config-form label {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
#config-form input {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
#config-form input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
|
||||
|
||||
/* Installing steps */
|
||||
.steps { list-style: none; padding: 0; margin: 16px 0; }
|
||||
.steps li {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.steps li:last-child { border-bottom: none; }
|
||||
.step-icon { font-size: 16px; min-width: 24px; line-height: 1.5; }
|
||||
.step-title { font-weight: 500; }
|
||||
.step-msg { font-size: 12px; color: var(--muted); margin-top: 2px; word-break: break-word; }
|
||||
.step-pending .step-icon { color: var(--muted); }
|
||||
.step-running .step-icon { color: var(--accent); animation: spin 1.2s linear infinite; }
|
||||
.step-done .step-icon { color: var(--ok); }
|
||||
.step-skipped .step-icon { color: var(--muted); }
|
||||
.step-failed .step-icon { color: var(--err); }
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.progress {
|
||||
margin-top: 16px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Done */
|
||||
.next-link { margin: 20px 0; }
|
||||
|
||||
/* Error */
|
||||
.error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid var(--err);
|
||||
color: var(--err);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Command bj-license-server — онлайн-сервис учёта и отзыва лицензий.
|
||||
//
|
||||
// Базовая модель лицензирования офлайновая: bj-server проверяет подпись и
|
||||
// срок сам. Этот сервер нужен для:
|
||||
// - реестра выданных лицензий (учёт);
|
||||
// - ОТЗЫВА (revocation) до окончания срока;
|
||||
// - проверки клиентом «не отозвана ли» (опциональный online-чек).
|
||||
//
|
||||
// Хранилище — JSON-файл со списком отозванных ID (для каркаса; в проде —
|
||||
// PostgreSQL). API:
|
||||
//
|
||||
// GET /v1/check?id=<license-id> → {"revoked":bool}
|
||||
// GET /healthz
|
||||
//
|
||||
// Управление отзывом — правкой файла revoked.json (или будущим admin API).
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
revoked map[string]bool
|
||||
}
|
||||
|
||||
func newStore(path string) *store {
|
||||
s := &store{path: path, revoked: map[string]bool{}}
|
||||
s.load()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *store) load() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
b, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return // файла нет — пустой список
|
||||
}
|
||||
var ids []string
|
||||
if err := json.Unmarshal(b, &ids); err != nil {
|
||||
log.Printf("license-server: разбор %s: %v", s.path, err)
|
||||
return
|
||||
}
|
||||
s.revoked = map[string]bool{}
|
||||
for _, id := range ids {
|
||||
s.revoked[id] = true
|
||||
}
|
||||
log.Printf("license-server: загружено отозванных лицензий: %d", len(s.revoked))
|
||||
}
|
||||
|
||||
func (s *store) isRevoked(id string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.revoked[id]
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", ":8091", "адрес прослушивания")
|
||||
file := flag.String("revoked", "./revoked.json", "JSON-файл со списком отозванных license ID")
|
||||
flag.Parse()
|
||||
|
||||
st := newStore(*file)
|
||||
|
||||
// Перечитываем файл отзывов раз в минуту (горячее применение).
|
||||
go func() {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
st.load()
|
||||
}
|
||||
}()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
|
||||
mux.HandleFunc("/v1/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
http.Error(w, "id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"revoked": st.isRevoked(id)})
|
||||
})
|
||||
|
||||
log.Printf("license-server: слушаю %s, отзывы из %s", *addr, *file)
|
||||
srv := &http.Server{Addr: *addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Command bj-release — инструмент издателя: генерация ключей подписи,
|
||||
// сборка манифеста релиза из каталога артефактов и его подпись Ed25519.
|
||||
//
|
||||
// Использование:
|
||||
//
|
||||
// bj-release keygen -out ./keys/signing
|
||||
// → создаёт signing.priv (base64 seed) и signing.pub (base64 pubkey)
|
||||
//
|
||||
// bj-release build -dir ./dist -version 1.2.0 -channel stable \
|
||||
// -key ./keys/signing.priv -keyid main -out ./dist/manifest.json
|
||||
// → хеширует все файлы в ./dist, собирает Manifest, подписывает,
|
||||
// пишет SignedManifest в manifest.json
|
||||
//
|
||||
// Манифест подписывается целиком; клиент (bj-server auto-update) проверяет
|
||||
// подпись зашитым публичным ключом ДО доверия версиям/хешам.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "keygen":
|
||||
keygen(os.Args[2:])
|
||||
case "build":
|
||||
build(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "bj-release keygen -out <prefix>")
|
||||
fmt.Fprintln(os.Stderr, "bj-release build -dir <artifacts> -version <v> -channel <c> -key <priv> -keyid <id> -out <manifest.json> [-notes <txt>]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func keygen(args []string) {
|
||||
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
|
||||
out := fs.String("out", "signing", "префикс файлов ключей (создаст <out>.priv и <out>.pub)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
fatal("keygen: %v", err)
|
||||
}
|
||||
seed := priv.Seed()
|
||||
if err := os.WriteFile(*out+".priv", []byte(base64.StdEncoding.EncodeToString(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("Публичный ключ: %s.pub\n", *out)
|
||||
fmt.Printf("Публичный ключ (зашить в bj-server):\n %s\n", base64.StdEncoding.EncodeToString(pub))
|
||||
}
|
||||
|
||||
func build(args []string) {
|
||||
fs := flag.NewFlagSet("build", flag.ExitOnError)
|
||||
dir := fs.String("dir", "./dist", "каталог с артефактами")
|
||||
version := fs.String("version", "", "версия релиза, напр. 1.2.0")
|
||||
channel := fs.String("channel", "stable", "канал: stable|beta")
|
||||
keyPath := fs.String("key", "", "путь к приватному ключу (base64 seed)")
|
||||
keyID := fs.String("keyid", "main", "идентификатор ключа")
|
||||
out := fs.String("out", "", "путь для записи manifest.json (по умолчанию <dir>/manifest.json)")
|
||||
notes := fs.String("notes", "", "заметки к релизу")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if *version == "" || *keyPath == "" {
|
||||
fatal("build: требуются -version и -key")
|
||||
}
|
||||
if *out == "" {
|
||||
*out = filepath.Join(*dir, "manifest.json")
|
||||
}
|
||||
|
||||
priv, err := release.LoadPrivateKey(*keyPath)
|
||||
if err != nil {
|
||||
fatal("load key: %v", err)
|
||||
}
|
||||
|
||||
// Имена артефактов, которые издаём (логическое имя → ставить +x).
|
||||
known := map[string]bool{
|
||||
"bj-server": true, // Go-бинарь
|
||||
"crypto-service.jar": false, // Java сайдкар
|
||||
"install-validata.sh": true,
|
||||
"install.sh": true,
|
||||
"configure-ish.sql": false,
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(*dir)
|
||||
if err != nil {
|
||||
fatal("read dir: %v", err)
|
||||
}
|
||||
var arts []release.Artifact
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || e.Name() == "manifest.json" {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(*dir, e.Name())
|
||||
sha, size, err := release.HashFile(full)
|
||||
if err != nil {
|
||||
fatal("hash %s: %v", e.Name(), err)
|
||||
}
|
||||
exec, ok := known[e.Name()]
|
||||
if !ok {
|
||||
// неизвестный файл — включаем, +x по расширению
|
||||
exec = strings.HasSuffix(e.Name(), ".sh")
|
||||
}
|
||||
arts = append(arts, release.Artifact{
|
||||
Name: e.Name(),
|
||||
File: e.Name(),
|
||||
Version: *version,
|
||||
SHA256: sha,
|
||||
Size: size,
|
||||
Exec: exec,
|
||||
})
|
||||
}
|
||||
sort.Slice(arts, func(i, j int) bool { return arts[i].Name < arts[j].Name })
|
||||
if len(arts) == 0 {
|
||||
fatal("build: в каталоге %s нет артефактов", *dir)
|
||||
}
|
||||
|
||||
m := &release.Manifest{
|
||||
Schema: release.CurrentSchema,
|
||||
Version: *version,
|
||||
Channel: *channel,
|
||||
ReleasedAt: time.Now().UTC(),
|
||||
Notes: *notes,
|
||||
Artifacts: arts,
|
||||
}
|
||||
sm, err := release.Sign(m, priv, *keyID)
|
||||
if err != nil {
|
||||
fatal("sign: %v", err)
|
||||
}
|
||||
b, err := json.MarshalIndent(sm, "", " ")
|
||||
if err != nil {
|
||||
fatal("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*out, b, 0o644); err != nil {
|
||||
fatal("write manifest: %v", err)
|
||||
}
|
||||
fmt.Printf("Манифест %s: версия %s, канал %s, артефактов %d, подписан ключом %s\n",
|
||||
*out, *version, *channel, len(arts), *keyID)
|
||||
for _, a := range arts {
|
||||
fmt.Printf(" %-22s %10d B %s\n", a.Name, a.Size, a.SHA256[:16])
|
||||
}
|
||||
}
|
||||
|
||||
func fatal(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, "bj-release: "+format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
+4
-11
@@ -40,17 +40,10 @@ func main() {
|
||||
DefaultSender: defaultSender,
|
||||
DefaultReceiver: defaultReceiver,
|
||||
SetupPath: setupPath,
|
||||
CheckOptions: func() lkgateway.CheckOptions {
|
||||
return lkgateway.CheckOptions{
|
||||
PostgresDSN: os.Getenv("BJ_DSN"),
|
||||
CryptoSocket: getenv("BJ_CRYPTO_SOCKET", "/run/bj/crypto.sock"),
|
||||
NSDAdapterURL: os.Getenv("BJ_NSD_ADAPTER_URL"),
|
||||
LKCallbackURL: os.Getenv("BJ_LK_CALLBACK_URL"),
|
||||
Profile: getenv("BJ_NSD_PROFILE", "demo (mock NSD)"),
|
||||
CryptoProvider: getenv("BJ_CRYPTO_PROVIDER", "stub"),
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
},
|
||||
// CheckOptions не задаём — server.go использует свой снапшот-based
|
||||
// вариант, который читает актуальные значения из setup.json
|
||||
// (DSN, crypto-сокет, URL ИШ, профиль), а не из ENV. Так проверки
|
||||
// статуса совпадают с тем, что реально настроено в UI.
|
||||
}
|
||||
|
||||
srv, err := lkgateway.NewServer(cfg)
|
||||
|
||||
Reference in New Issue
Block a user