feat(install): консольный установщик bj и первичная настройка по одноразовому коду

- cmd/bj-installer (вместо веб-мастера): проверка Debian 13 / Astra, пакеты, пользователь и
  каталоги, активация лицензии ключом продукта, PostgreSQL и миграции m2m_core, компоненты
  по лицензии с проверкой подписи, root-скрипты и службы, одноразовый код и итоговый экран
  с адресом http://<сервер>:8080/first-run; проверка в песочнице (--steps, --root)
- bj: /first-run — пока нет пользователей и установщик оставил код, по коду создаётся
  первый администратор (прежние установки без файла работают как раньше)
- internal/release/install.go — общая установка компонентов (bj-component-install и
  установщик); bj-component-install стал компонентом ядра
- открытые ключи вынесены в internal/license и internal/release; миграции и файлы
  развёртывания встроены (migrations/embed.go, deploy/embed.go); unit bj-server исправлен
- scripts/build-release.sh: сборка с версиями, подпись, публикация на сервер обновлений

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-15 23:56:44 +03:00
parent 8099e50ab2
commit 4135435b51
24 changed files with 1313 additions and 1807 deletions
+10 -232
View File
@@ -2,261 +2,39 @@
//
// bj-server работает без прав root: он скачивает подписанный манифест релиза и выбранные файлы в
// каталог подготовки и просит root-воркер (bj-keymedia-worker, действие component-install) вызвать
// этот инструмент. Установщик ничему из каталога не доверяет: сам проверяет подпись манифеста
// публичным ключом из /etc/bj/update.pub (принадлежит root) и sha256 каждого файла, ставит только
// известные компоненты (release.Components) по их путям, не откатывает на более старую версию,
// сохраняет прежний файл (<путь>.bak-ДАТА, хранятся 5 последних), перезапускает службы (ядро
// bj-server последним) и записывает установленные версии в /var/lib/bj/.bj/components.json.
// этот инструмент. Проверка и установка — release.InstallStaged: подпись манифеста ключом из
// /etc/bj/update.pub (принадлежит root), sha256 файлов, только известные компоненты, без отката,
// копия прежнего файла, перезапуск служб (ядро последним), записи в /var/lib/bj/.bj/components.json.
// Вывод — JSON для bj-server.
//
// bj-component-install <каталог подготовки>
// в каталоге: manifest.json (подписанный), request.json {"names": [...]}, файлы компонентов
// bj-component-install -root /tmp/песочница -pub test.pub -no-restart <каталог> (проверка на копии)
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
)
const stagingBase = "/var/lib/bj/update-staging"
type result struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Release string `json:"release,omitempty"`
Installed []map[string]string `json:"installed,omitempty"`
Restarted []string `json:"restarted,omitempty"`
}
func main() {
fs := flag.NewFlagSet("bj-component-install", flag.ExitOnError)
root := fs.String("root", "", "корень установки (для проверки в песочнице; по умолчанию /)")
pubPath := fs.String("pub", "/etc/bj/update.pub", "публичный ключ издателя (base64 Ed25519), файл root")
pubPath := fs.String("pub", "/etc/bj/update.pub", "открытый ключ подписи релизов (base64 Ed25519), файл root")
noRestart := fs.Bool("no-restart", false, "не перезапускать службы")
_ = fs.Parse(os.Args[1:])
res := run(*root, *pubPath, !*noRestart, fs.Arg(0))
var res release.InstallResult
if pub, err := release.LoadPublicKey(*pubPath); err != nil {
res.Error = fmt.Sprintf("открытый ключ обновлений (%s): %v", *pubPath, err)
} else {
res = release.InstallStaged(fs.Arg(0), release.InstallOptions{Root: *root, PubKey: pub, Restart: !*noRestart})
}
b, _ := json.Marshal(res)
fmt.Println(string(b))
if !res.OK {
os.Exit(1)
}
}
func run(root, pubPath string, restart bool, staging string) (res result) {
fail := func(format string, a ...any) result {
res.OK, res.Error = false, fmt.Sprintf(format, a...)
return res
}
if staging == "" {
return fail("не указан каталог подготовки")
}
staging = filepath.Clean(staging)
if root == "" && !strings.HasPrefix(staging, stagingBase+"/") {
return fail("каталог подготовки должен быть внутри %s", stagingBase)
}
pub, err := release.LoadPublicKey(pubPath)
if err != nil {
return fail("публичный ключ обновлений (%s): %v", pubPath, err)
}
var sm release.SignedManifest
if err := readJSON(staging, "manifest.json", &sm); err != nil {
return fail("манифест: %v", err)
}
m, err := release.Verify(&sm, pub)
if err != nil {
return fail("подпись манифеста не прошла проверку: %v", err)
}
res.Release = m.Version
var req struct {
Names []string `json:"names"`
}
if err := readJSON(staging, "request.json", &req); err != nil {
return fail("запрос: %v", err)
}
if len(req.Names) == 0 {
return fail("не выбраны компоненты")
}
installedPath := release.InstalledFile
if root != "" {
installedPath = filepath.Join(root, installedPath)
}
installed := release.LoadInstalled(installedPath)
// Проверки до любой установки: всё или ничего.
type job struct {
c release.Component
a release.Artifact
file string
}
var jobs []job
for _, name := range req.Names {
c, ok := release.ComponentByName(name)
if !ok {
return fail("неизвестный компонент %q", name)
}
var art *release.Artifact
for i := range m.Artifacts {
if m.Artifacts[i].Name == name {
art = &m.Artifacts[i]
}
}
if art == nil {
return fail("компонента %s нет в релизе %s", name, m.Version)
}
ver := art.Version
if ver == "" {
ver = m.Version
}
art.Version = ver
if _, err := os.Stat(c.PathAt(root)); err != nil && !c.Core {
return fail("%s не установлен на этом сервере — модуль ставится установщиком, а не обновлением", name)
}
if prev, ok := installed[name]; ok && prev.Version != "" && release.IsNewer(prev.Version, ver) {
return fail("%s: установлена %s, в релизе более старая %s — откат не выполняется", name, prev.Version, ver)
}
file := filepath.Join(staging, filepath.Base(art.File))
fi, err := os.Lstat(file)
if err != nil || !fi.Mode().IsRegular() {
return fail("%s: файл %s не найден в каталоге подготовки", name, art.File)
}
if err := release.VerifyArtifact(file, *art); err != nil {
return fail("%s: %v", name, err)
}
jobs = append(jobs, job{c: c, a: *art, file: file})
}
// Порядок как в release.Components: ядро первым в списке ставится, но перезапускается последним.
sort.SliceStable(jobs, func(i, j int) bool { return order(jobs[i].c.Name) < order(jobs[j].c.Name) })
ts := time.Now().Format("20060102-150405")
var units []string
for _, j := range jobs {
dst := j.c.PathAt(root)
if err := install(j.file, dst, j.c.Exec, ts); err != nil {
return fail("установка %s: %v", j.c.Name, err)
}
installed[j.c.Name] = release.Installed{Version: j.a.Version, SHA256: j.a.SHA256, InstalledAt: time.Now().UTC()}
res.Installed = append(res.Installed, map[string]string{"name": j.c.Name, "version": j.a.Version})
if j.c.Unit != "" {
units = append(units, j.c.Unit)
}
}
if err := release.SaveInstalled(installedPath, installed); err != nil {
return fail("запись установленных версий: %v", err)
}
chownLikeParent(installedPath)
if restart {
sort.SliceStable(units, func(i, j int) bool { return units[j] == "bj-server" && units[i] != "bj-server" })
for _, u := range units {
if out, err := exec.Command("systemctl", "restart", u).CombinedOutput(); err != nil {
return fail("перезапуск %s: %v: %s", u, err, strings.TrimSpace(string(out)))
}
res.Restarted = append(res.Restarted, u)
}
}
res.OK = true
return res
}
func order(name string) int {
for i, c := range release.Components {
if c.Name == name {
return i
}
}
return len(release.Components)
}
func readJSON(dir, name string, v any) error {
p := filepath.Join(dir, name)
fi, err := os.Lstat(p)
if err != nil {
return err
}
if !fi.Mode().IsRegular() || fi.Size() > 1<<20 {
return errors.New(name + ": не обычный файл или слишком большой")
}
b, err := os.ReadFile(p)
if err != nil {
return err
}
return json.Unmarshal(b, v)
}
// install — копия прежнего файла, запись нового рядом и атомарная замена.
func install(src, dst string, execBit bool, ts string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if _, err := os.Stat(dst); err == nil {
if err := copyFile(dst, dst+".bak-"+ts, 0o755); err != nil {
return fmt.Errorf("копия прежнего файла: %w", err)
}
pruneBackups(dst, 5)
}
mode := os.FileMode(0o644)
if execBit {
mode = 0o755
}
tmp := dst + ".new"
if err := copyFile(src, tmp, mode); err != nil {
return err
}
return os.Rename(tmp, dst)
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
if err := out.Sync(); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
return os.Chmod(dst, mode)
}
// pruneBackups — хранить keep последних копий <путь>.bak-ДАТА-ВРЕМЯ.
func pruneBackups(dst string, keep int) {
files, _ := filepath.Glob(dst + ".bak-2*")
sort.Strings(files)
for len(files) > keep {
_ = os.Remove(files[0])
files = files[1:]
}
}
func chownLikeParent(path string) {
if fi, err := os.Stat(filepath.Dir(path)); err == nil {
if s, ok := fi.Sys().(*syscall.Stat_t); ok {
_ = os.Chown(path, int(s.Uid), int(s.Gid))
}
}
}
+826 -82
View File
@@ -1,111 +1,855 @@
// Package main — bj-installer.
// Command bj-installer — установка bj на Debian 13 или Astra Linux.
//
// Web-инсталлятор для bj-server: на машине клиента после установки
// Debian/Astra поднимает локальный HTTP на 127.0.0.1:8181, проводит
// через 5-страничный wizard (welcome → precheck → config → install → done)
// и за кадром выполняет 20+ шагов установки Валидаты + bj-server + ИШ.
// curl -fsSL https://lic.zetit.ru/install.sh | sudo bash -s -- КЛЮЧ-ПРОДУКТА
// sudo bj-installer install --code BJ-XXXX-XXXX-XXXX [--server https://lic.zetit.ru] [--channel stable]
// sudo bj-installer install --dry-run только проверка системы, без изменений
// sudo bj-installer first-run-code новый одноразовый код первичной настройки
// bj-installer version
//
// Прогресс шагов прилетает в UI через Server-Sent Events. Каждый шаг
// идемпотентен — можно повторно запускать инсталлятор на уже настроенной
// машине, он пропустит то, что сделано.
// Установка идёт в консоли, без графической среды: проверка системы, пакеты, пользователь и каталоги,
// активация лицензии, база PostgreSQL, компоненты bj по лицензии (подпись релиза и контрольные суммы
// проверяются), root-скрипты и службы. В конце на экране — адрес первичной настройки и одноразовый код:
// дальше настройка в браузере, в самом bj.
//
// Запуск: 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 (ИШ НРД)
// Проверка без чистого сервера: --steps license,components --root /tmp/песочница --server http://127.0.0.1:18091
package main
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"io"
"io/fs"
"math/big"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"os/user"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"syscall"
"time"
"golang.org/x/crypto/bcrypt"
"git.zetit.ru/zuevav/Bridge-and-Join-s/deploy"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
"git.zetit.ru/zuevav/Bridge-and-Join-s/migrations"
)
const banner = `
======================================================================
bj-installer — мастер установки Bridge-and-Join-s
======================================================================
`
// installerVersion — версия установщика (go build -ldflags "-X main.installerVersion=…").
var installerVersion = "0.1.0"
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()
const (
setupFile = "/var/lib/bj/.bj/setup.json"
usersFile = "/var/lib/bj/.bj/users.json"
firstRunFile = "/var/lib/bj/.bj/first-run.json"
bjPort = 8080
)
if os.Geteuid() != 0 {
fmt.Fprintln(os.Stderr, "Установщик должен быть запущен от root (sudo).")
os.Exit(1)
}
type installer struct {
server, channel, code string
root string // префикс файловой системы (песочница)
dryRun bool
steps map[string]bool
out *console
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)
token string
lic *license.License
setupCode string // одноразовый код первичной настройки (итоговый экран)
shown bool
}
// tryOpenBrowser — без фанатизма. Если xdg-open/sensible-browser есть и
// $DISPLAY поднят (xrdp, Fly DE) — откроем. Иначе пользователь увидит URL
// в выводе и перейдёт сам с другого компа (типичный сценарий headless).
func tryOpenBrowser(url string) {
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
return
func main() {
if len(os.Args) < 2 {
usage()
}
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
switch os.Args[1] {
case "install":
cmdInstall(os.Args[2:])
case "first-run-code":
cmdFirstRunCode(os.Args[2:])
case "version":
fmt.Println("bj-installer", installerVersion)
default:
usage()
}
}
func usage() {
fmt.Fprintln(os.Stderr, "bj-installer install --code BJ-XXXX-XXXX-XXXX [--server https://lic.zetit.ru] [--channel stable] [--dry-run]")
fmt.Fprintln(os.Stderr, "bj-installer first-run-code")
fmt.Fprintln(os.Stderr, "bj-installer version")
os.Exit(2)
}
func cmdInstall(args []string) {
fl := flag.NewFlagSet("install", flag.ExitOnError)
in := &installer{out: newConsole()}
fl.StringVar(&in.code, "code", "", "ключ продукта (код активации)")
fl.StringVar(&in.server, "server", "https://lic.zetit.ru", "сервер лицензий и обновлений")
fl.StringVar(&in.channel, "channel", "stable", "канал обновлений")
fl.BoolVar(&in.dryRun, "dry-run", false, "только проверить систему")
stepsFlag := fl.String("steps", "", "только эти шаги через запятую (для проверки): precheck,packages,system,license,database,components,services,firstrun")
fl.StringVar(&in.root, "root", "", "корень файловой системы для проверки в песочнице")
_ = fl.Parse(args)
in.server = strings.TrimRight(in.server, "/")
if *stepsFlag != "" {
in.steps = map[string]bool{}
for _, s := range strings.Split(*stepsFlag, ",") {
in.steps[strings.TrimSpace(s)] = true
}
}
in.out.banner()
if err := in.run(); err != nil {
in.out.failure(err)
os.Exit(1)
}
}
// P — путь с учётом песочницы.
func (in *installer) P(path string) string {
if in.root == "" {
return path
}
return filepath.Join(in.root, path)
}
type step struct {
id, title string
run func() (string, error)
}
func (in *installer) run() error {
if in.root == "" && !in.dryRun && os.Geteuid() != 0 {
return errors.New("установщик запускается от root: sudo bj-installer install …")
}
if !in.dryRun && in.steps == nil && strings.TrimSpace(in.code) == "" {
return errors.New("не указан ключ продукта: bj-installer install --code BJ-XXXX-XXXX-XXXX (он есть в портале клиента https://lic.zetit.ru/portal/)")
}
all := []step{
{"precheck", "Проверка системы", in.stepPrecheck},
{"packages", "Системные пакеты", in.stepPackages},
{"system", "Пользователь bj и каталоги", in.stepSystem},
{"license", "Активация лицензии", in.stepLicense},
{"database", "База данных PostgreSQL", in.stepDatabase},
{"components", "Компоненты bj по лицензии", in.stepComponents},
{"services", "Скрипты и службы", in.stepServices},
{"firstrun", "Запуск и код первичной настройки", in.stepFirstRun},
}
var todo []step
for _, s := range all {
switch {
case in.dryRun && s.id != "precheck":
case in.steps != nil && !in.steps[s.id]:
default:
todo = append(todo, s)
}
}
for i, s := range todo {
in.out.step(i+1, len(todo), s.title)
detail, err := s.run()
if err != nil {
in.out.stepFail()
return fmt.Errorf("%s: %w", s.title, err)
}
in.out.stepOK(detail)
}
if in.dryRun {
in.out.note("Проверка пройдена. Для установки запустите без --dry-run и с ключом продукта.")
}
if in.shown {
in.out.done(in.setupCode, in.lic)
}
return nil
}
// ---- шаги ----
func osRelease() map[string]string {
m := map[string]string{}
b, err := os.ReadFile("/etc/os-release")
if err != nil {
return m
}
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(line, "=")
if ok {
m[k] = strings.Trim(v, `"`)
}
}
return m
}
func (in *installer) stepPrecheck() (string, error) {
osr := osRelease()
id, ver, like := osr["ID"], osr["VERSION_ID"], osr["ID_LIKE"]
name := pick(osr["PRETTY_NAME"], id+" "+ver)
switch {
case id == "debian" && strings.HasPrefix(ver, "13"):
case id == "astra" || strings.Contains(like, "astra") || strings.Contains(strings.ToLower(name), "astra"):
default:
return "", fmt.Errorf("поддерживаются Debian 13 и Astra Linux, а на сервере %s", name)
}
if runtime.GOARCH != "amd64" {
return "", fmt.Errorf("нужна архитектура x86_64, а на сервере %s", runtime.GOARCH)
}
if _, err := os.Stat("/run/systemd/system"); err != nil {
return "", errors.New("systemd не запущен")
}
var st syscall.Statfs_t
if err := syscall.Statfs("/", &st); err == nil {
if free := st.Bavail * uint64(st.Bsize); free < 10<<30 {
return "", fmt.Errorf("на диске свободно %.1f ГБ, нужно не меньше 10 ГБ", float64(free)/(1<<30))
}
}
mem := memTotal()
if mem > 0 && mem < 1800<<20 {
return "", fmt.Errorf("памяти %.1f ГБ, нужно не меньше 2 ГБ", float64(mem)/(1<<30))
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, in.server+"/healthz", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("нет связи с %s: %v", in.server, err)
}
resp.Body.Close()
return fmt.Sprintf("%s, %.1f ГБ памяти, связь с сервером лицензий есть", name, float64(mem)/(1<<30)), nil
}
func memTotal() uint64 {
b, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0
}
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(line, "MemTotal:") {
f := strings.Fields(line)
if len(f) >= 2 {
kb, _ := strconv.ParseUint(f[1], 10, 64)
return kb << 10
}
}
}
if bin == "" {
return 0
}
func (in *installer) stepPackages() (string, error) {
pkgs := []string{"ca-certificates", "curl", "postgresql", "postgresql-client", "default-jre-headless", "pcscd", "python3"}
env := append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
if out, err := runEnv(env, "apt-get", "update", "-qq"); err != nil {
return "", fmt.Errorf("apt-get update: %v\n%s", err, tail(out, 8))
}
args := append([]string{"install", "-y", "-qq", "--no-install-recommends"}, pkgs...)
if out, err := runEnv(env, "apt-get", args...); err != nil {
return "", fmt.Errorf("apt-get install: %v\n%s", err, tail(out, 12))
}
return strings.Join(pkgs, ", "), nil
}
func (in *installer) stepSystem() (string, error) {
if in.root == "" {
if _, err := user.Lookup("bj"); err != nil {
if out, err := run("useradd", "--system", "--home-dir", "/var/lib/bj", "--no-create-home", "--shell", "/usr/sbin/nologin", "bj"); err != nil {
return "", fmt.Errorf("useradd: %v %s", err, out)
}
}
}
dirs := []struct {
path string
mode os.FileMode
bjOwn bool
}{
{"/opt/bj", 0o755, false}, {"/etc/bj", 0o755, false},
{"/var/lib/bj", 0o750, true}, {"/var/lib/bj/.bj", 0o700, true}, {"/var/log/bj", 0o750, true},
{"/var/lib/bj/keymedia-requests", 0o770, true}, {"/var/lib/bj/update-staging", 0o750, true},
}
for _, d := range dirs {
if err := os.MkdirAll(in.P(d.path), d.mode); err != nil {
return "", err
}
_ = os.Chmod(in.P(d.path), d.mode)
if d.bjOwn {
in.chownBJ(in.P(d.path))
}
}
return "пользователь bj, /opt/bj, /var/lib/bj, /var/log/bj", nil
}
func (in *installer) chownBJ(path string) {
if in.root != "" {
return
}
_ = exec.Command(bin, url).Start()
u, err := user.Lookup("bj")
if err != nil {
return
}
uid, _ := strconv.Atoi(u.Uid)
gid, _ := strconv.Atoi(u.Gid)
_ = os.Chown(path, uid, gid)
}
// ---- настройки bj (setup.json) ----
func (in *installer) loadSetup() map[string]any {
m := map[string]any{}
if b, err := os.ReadFile(in.P(setupFile)); err == nil {
_ = json.Unmarshal(b, &m)
}
return m
}
func (in *installer) saveSetup(m map[string]any) error {
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
p := in.P(setupFile)
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
if err := os.WriteFile(p+".tmp", b, 0o600); err != nil {
return err
}
in.chownBJ(p + ".tmp")
return os.Rename(p+".tmp", p)
}
func section(m map[string]any, key string) map[string]any {
if s, ok := m[key].(map[string]any); ok {
return s
}
s := map[string]any{}
m[key] = s
return s
}
func newUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = b[6]&0x0f | 0x40
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:]
}
func machineHash() string {
b, err := os.ReadFile("/etc/machine-id")
if err != nil || strings.TrimSpace(string(b)) == "" {
return ""
}
sum := sha256.Sum256([]byte(strings.TrimSpace(string(b))))
return hex.EncodeToString(sum[:])
}
func (in *installer) stepLicense() (string, error) {
setup := in.loadSetup()
lic := section(setup, "license")
installID, _ := lic["install_id"].(string)
if len(installID) != 36 {
installID = newUUID()
}
host, _ := os.Hostname()
body, _ := json.Marshal(map[string]string{"code": strings.TrimSpace(in.code), "install_id": installID,
"hostname": host, "version": "bj-installer " + installerVersion, "machine": machineHash()})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, in.server+"/v1/activate", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("сервер лицензий недоступен: %v", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var reply struct {
Token string `json:"token"`
Tenant string `json:"tenant"`
Message string `json:"message"`
}
_ = json.Unmarshal(raw, &reply)
if resp.StatusCode != http.StatusOK || reply.Token == "" {
return "", fmt.Errorf("ключ продукта не принят: %s", pick(reply.Message, fmt.Sprintf("HTTP %d", resp.StatusCode)))
}
pub, err := license.ParsePublicKey(license.DefaultPublicKey)
if err != nil {
return "", err
}
tok, err := license.DecodeToken(reply.Token)
if err != nil {
return "", err
}
l, err := license.Verify(tok, pub)
if err != nil {
return "", fmt.Errorf("сервер вернул недействительный ключ: %v", err)
}
if !strings.EqualFold(l.InstallID, installID) {
return "", errors.New("сервер вернул ключ для другой установки")
}
in.token, in.lic = reply.Token, l
now := time.Now().UTC()
lic["key"], lic["install_id"] = reply.Token, installID
lic["last_sync"], lic["last_attempt"], lic["last_ok"] = now, now, true
lic["last_msg"] = "активирована установщиком: ключ до " + l.ExpiresAt.Local().Format("02.01.2006")
if err := in.saveSetup(setup); err != nil {
return "", err
}
return fmt.Sprintf("%s, план %s, модули: %s", pick(reply.Tenant, l.Tenant), l.Plan, featuresText(l)), nil
}
func featuresText(l *license.License) string {
if l.Plan == license.PlanEnterprise {
return "все"
}
if len(l.Features) == 0 {
return "только ядро"
}
return strings.Join(l.Features, ", ")
}
// ensureToken — ключ из настроек, если шаг активации в этом запуске не выполнялся.
func (in *installer) ensureToken() error {
if in.token != "" {
return nil
}
key, _ := section(in.loadSetup(), "license")["key"].(string)
if key == "" {
return errors.New("лицензия не активирована: запустите установку с ключом продукта")
}
pub, _ := license.ParsePublicKey(license.DefaultPublicKey)
tok, err := license.DecodeToken(key)
if err != nil {
return err
}
l, err := license.Verify(tok, pub)
if err != nil {
return err
}
in.token, in.lic = key, l
return nil
}
func (in *installer) stepDatabase() (string, error) {
setup := in.loadSetup()
pg := section(setup, "postgres")
if dsn, _ := pg["dsn"].(string); dsn != "" {
if err := applyMigrations(dsn); err == nil {
return "база уже настроена, схема обновлена", nil
}
}
if out, err := run("systemctl", "enable", "--now", "postgresql"); err != nil {
return "", fmt.Errorf("запуск PostgreSQL: %v %s", err, out)
}
if err := waitFor(60*time.Second, func() bool { _, err := psqlAdmin("SELECT 1"); return err == nil }); err != nil {
return "", errors.New("PostgreSQL не отвечает")
}
pw := randomString(28)
exists, _ := psqlAdmin("SELECT 1 FROM pg_roles WHERE rolname='bj'")
verb := "CREATE ROLE"
if strings.TrimSpace(exists) == "1" {
verb = "ALTER ROLE"
}
if _, err := psqlAdmin(verb + " bj WITH LOGIN PASSWORD '" + pw + "'"); err != nil {
return "", fmt.Errorf("роль bj: %v", err)
}
if db, _ := psqlAdmin("SELECT 1 FROM pg_database WHERE datname='bj'"); strings.TrimSpace(db) != "1" {
if _, err := psqlAdmin("CREATE DATABASE bj OWNER bj"); err != nil {
return "", fmt.Errorf("база bj: %v", err)
}
}
dsn := "postgres://bj:" + pw + "@127.0.0.1:5432/bj?sslmode=disable"
if err := applyMigrations(dsn); err != nil {
return "", err
}
pg["dsn"] = dsn
if err := in.saveSetup(setup); err != nil {
return "", err
}
return "база bj, пользователь bj, схема m2m_core", nil
}
func psqlAdmin(sql string) (string, error) {
return run("runuser", "-u", "postgres", "--", "psql", "-X", "-tAq", "-v", "ON_ERROR_STOP=1", "-c", sql)
}
func applyMigrations(dsn string) error {
names, err := fs.Glob(migrations.M2MCore, "m2m-core/*.sql")
if err != nil {
return err
}
sort.Strings(names)
for _, n := range names {
sqlText, _ := migrations.M2MCore.ReadFile(n)
cmd := exec.Command("psql", "-X", "-q", "-v", "ON_ERROR_STOP=1", dsn)
cmd.Stdin = bytes.NewReader(sqlText)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("миграция %s: %v\n%s", filepath.Base(n), err, tail(string(out), 6))
}
}
return nil
}
func (in *installer) licensed(feature string) bool {
return feature == "" || feature == "updates" || in.lic.HasFeature(feature)
}
// unitTemplate — есть ли в установщике unit-файл для службы компонента.
func unitTemplate(unit string) bool {
if unit == "" {
return true
}
_, err := fs.Stat(deploy.Files, "systemd/"+unit+".service")
return err == nil
}
func (in *installer) stepComponents() (string, error) {
if err := in.ensureToken(); err != nil {
return "", err
}
pub, err := release.ParsePublicKey(release.DefaultPublicKey)
if err != nil {
return "", err
}
cl, err := release.NewClient(in.server+"/updates", in.channel, release.DefaultPublicKey)
if err != nil {
return "", err
}
cl.Token = in.token
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
sm, m, err := cl.FetchSigned(ctx)
if err != nil {
return "", fmt.Errorf("релиз %s: %v", in.channel, err)
}
staging := filepath.Join(in.P(release.StagingBase), fmt.Sprintf("install-%d", time.Now().UnixNano()))
if err := os.MkdirAll(staging, 0o750); err != nil {
return "", err
}
defer os.RemoveAll(staging)
var names, skipped []string
for _, c := range release.Components {
var art *release.Artifact
for i := range m.Artifacts {
if m.Artifacts[i].Name == c.Name {
art = &m.Artifacts[i]
}
}
switch {
case art == nil:
case !c.Core && !in.licensed(c.Feature):
case !unitTemplate(c.Unit):
skipped = append(skipped, c.Title)
default:
if _, err := cl.DownloadArtifact(ctx, *art, staging); err != nil {
return "", fmt.Errorf("скачивание %s: %v", c.Name, err)
}
names = append(names, c.Name)
}
}
if len(names) == 0 {
return "", fmt.Errorf("в релизе %s нет компонентов для этой лицензии", m.Version)
}
b, _ := json.Marshal(sm)
_ = os.WriteFile(filepath.Join(staging, "manifest.json"), b, 0o640)
rb, _ := json.Marshal(map[string][]string{"names": names})
_ = os.WriteFile(filepath.Join(staging, "request.json"), rb, 0o640)
res := release.InstallStaged(staging, release.InstallOptions{Root: in.root, PubKey: pub, AllowNew: true})
if !res.OK {
return "", errors.New(res.Error)
}
if err := os.WriteFile(in.P("/etc/bj/update.pub"), []byte(release.DefaultPublicKey+"\n"), 0o644); err != nil {
return "", err
}
var list []string
for _, x := range res.Installed {
list = append(list, x["name"]+" "+x["version"])
}
detail := "релиз " + m.Version + ": " + strings.Join(list, ", ")
if len(skipped) > 0 {
detail += "; модули " + strings.Join(skipped, ", ") + " установятся позже, из раздела «Обновления»"
}
return detail, nil
}
func (in *installer) stepServices() (string, error) {
var files []string
err := fs.WalkDir(deploy.Files, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
body, _ := deploy.Files.ReadFile(p)
switch {
case strings.HasPrefix(p, "linux/sbin/"):
dst := in.P("/usr/local/sbin/" + filepath.Base(p))
if err := writeFile(dst, body, 0o755); err != nil {
return err
}
files = append(files, filepath.Base(p))
case strings.HasPrefix(p, "systemd/"):
if err := writeFile(in.P("/etc/systemd/system/"+filepath.Base(p)), body, 0o644); err != nil {
return err
}
files = append(files, filepath.Base(p))
}
return nil
})
if err != nil {
return "", err
}
if in.root == "" {
for _, c := range [][]string{{"daemon-reload"}, {"enable", "bj-keymedia.service", "bj-server.service"}} {
if out, err := run("systemctl", c...); err != nil {
return "", fmt.Errorf("systemctl %s: %v %s", strings.Join(c, " "), err, out)
}
}
}
return strings.Join(files, ", ") + "; криптосервис включается после настройки ключей в bj", nil
}
func (in *installer) stepFirstRun() (string, error) {
code, err := in.newFirstRunCode()
if err != nil {
return "", err
}
if in.root == "" {
for _, unit := range []string{"bj-keymedia", "bj-server"} {
if out, err := run("systemctl", "restart", unit); err != nil {
return "", fmt.Errorf("запуск %s: %v %s", unit, err, out)
}
}
if err := waitFor(90*time.Second, func() bool {
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/healthz", bjPort))
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == http.StatusOK
}); err != nil {
return "", errors.New("bj-server не ответил за 90 секунд: journalctl -u bj-server")
}
}
in.setupCode, in.shown = code, true
if in.root != "" {
return "проверка в песочнице: службы не запускались", nil
}
return "bj-server работает", nil
}
// newFirstRunCode — одноразовый код первичной настройки (если пользователей ещё нет).
func (in *installer) newFirstRunCode() (string, error) {
if b, err := os.ReadFile(in.P(usersFile)); err == nil && bytes.Contains(b, []byte(`"login"`)) {
return "", nil // bj уже настроен: вход по учётным записям
}
n, _ := rand.Int(rand.Reader, big.NewInt(100000000))
digits := fmt.Sprintf("%08d", n.Int64())
hash, err := bcrypt.GenerateFromPassword([]byte(digits), 12)
if err != nil {
return "", err
}
b, _ := json.Marshal(map[string]any{"code_hash": string(hash), "created_at": time.Now().UTC()})
if err := writeFile(in.P(firstRunFile), b, 0o600); err != nil {
return "", err
}
in.chownBJ(in.P(firstRunFile))
code := digits[:4] + "-" + digits[4:]
url := setupURLs()
issue := fmt.Sprintf("Bridge&Join, первичная настройка: откройте в браузере %s/first-run и введите код %s\n\n", firstOr(url, "http://<адрес сервера>:8080"), code)
_ = writeFile(in.P("/etc/issue.d/bj-setup.issue"), []byte(issue), 0o644)
return code, nil
}
func cmdFirstRunCode(args []string) {
in := &installer{out: newConsole()}
if os.Geteuid() != 0 {
in.out.failure(errors.New("запустите от root: sudo bj-installer first-run-code"))
os.Exit(1)
}
code, err := in.newFirstRunCode()
if err != nil {
in.out.failure(err)
os.Exit(1)
}
if code == "" {
in.out.note("В bj уже есть пользователи: первичная настройка выполнена, входите своей учётной записью.")
return
}
_ = in.ensureToken()
in.out.done(code, in.lic)
}
// ---- вспомогательное ----
func setupURLs() []string {
var out []string
ifaces, _ := net.Interfaces()
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
continue
}
addrs, _ := ifc.Addrs()
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok && ipn.IP.To4() != nil {
out = append(out, fmt.Sprintf("http://%s:%d", ipn.IP.String(), bjPort))
}
}
}
return out
}
func firstOr(list []string, def string) string {
if len(list) > 0 {
return list[0]
}
return def
}
func run(name string, args ...string) (string, error) {
return runEnv(nil, name, args...)
}
func runEnv(env []string, name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
if env != nil {
cmd.Env = env
}
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
func tail(s string, n int) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
func waitFor(d time.Duration, ok func() bool) error {
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if ok() {
return nil
}
time.Sleep(time.Second)
}
return errors.New("время ожидания истекло")
}
func writeFile(path string, body []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
if err := os.WriteFile(path+".tmp", body, mode); err != nil {
return err
}
return os.Rename(path+".tmp", path)
}
func randomString(n int) string {
const alphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, n)
for i := range b {
x, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
b[i] = alphabet[x.Int64()]
}
return string(b)
}
func pick(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
// ---- вывод в консоль ----
type console struct {
color bool
w *bufio.Writer
}
func newConsole() *console {
fi, err := os.Stdout.Stat()
return &console{color: err == nil && fi.Mode()&os.ModeCharDevice != 0 && os.Getenv("NO_COLOR") == "", w: bufio.NewWriter(os.Stdout)}
}
func (c *console) paint(code, s string) string {
if !c.color {
return s
}
return "\033[" + code + "m" + s + "\033[0m"
}
func (c *console) flush() { _ = c.w.Flush() }
func (c *console) banner() {
fmt.Fprintf(c.w, "\n %s установка на сервер\n %s\n\n", c.paint("1", "Bridge&Join"), c.paint("2", "bj-installer "+installerVersion))
c.flush()
}
func (c *console) step(i, n int, title string) {
fmt.Fprintf(c.w, " %s %s ", c.paint("36", fmt.Sprintf("[%d/%d]", i, n)), title)
c.flush()
}
func (c *console) stepOK(detail string) {
fmt.Fprintf(c.w, "%s\n", c.paint("32", "готово"))
if detail != "" {
fmt.Fprintf(c.w, " %s\n", c.paint("2", detail))
}
c.flush()
}
func (c *console) stepFail() {
fmt.Fprintf(c.w, "%s\n", c.paint("31", "ошибка"))
c.flush()
}
func (c *console) failure(err error) {
fmt.Fprintf(c.w, "\n %s %v\n\n Исправьте причину и запустите установку ещё раз: пройденные шаги повторяются безопасно.\n\n", c.paint("31;1", "Установка не завершена."), err)
c.flush()
}
func (c *console) note(s string) {
fmt.Fprintf(c.w, "\n %s\n\n", s)
c.flush()
}
// done — итоговый экран: адрес первичной настройки и одноразовый код.
func (c *console) done(code string, l *license.License) {
urls := setupURLs()
line := c.paint("2", strings.Repeat("─", 64))
fmt.Fprintf(c.w, "\n %s\n %s\n\n", line, c.paint("32;1", "bj установлен"))
if code != "" {
fmt.Fprintf(c.w, " Откройте в браузере на своём компьютере:\n")
if len(urls) == 0 {
urls = []string{fmt.Sprintf("http://<адрес сервера>:%d", bjPort)}
}
for _, u := range urls {
fmt.Fprintf(c.w, " %s\n", c.paint("1", u+"/first-run"))
}
fmt.Fprintf(c.w, "\n Одноразовый код первичной настройки: %s\n\n", c.paint("1;33", code))
fmt.Fprintf(c.w, " По коду создаётся администратор, дальше в разделе «Настройка»:\n организация и код участника НРД, ключи и интеграционный шлюз, учётные системы, пользователи.\n")
} else {
fmt.Fprintf(c.w, " Адрес bj: %s\n", c.paint("1", firstOr(urls, fmt.Sprintf("http://<адрес сервера>:%d", bjPort))))
}
if l != nil {
fmt.Fprintf(c.w, "\n Лицензия: %s, план %s, ключ до %s, модули: %s\n", l.Tenant, l.Plan, l.ExpiresAt.Local().Format("02.01.2006"), featuresText(l))
}
fmt.Fprintf(c.w, " Код потерян: sudo bj-installer first-run-code\n %s\n\n", line)
c.flush()
}
-132
View File
@@ -1,132 +0,0 @@
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
}
-129
View File
@@ -1,129 +0,0 @@
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)
}
-86
View File
@@ -1,86 +0,0 @@
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()
}
}
}
-140
View File
@@ -1,140 +0,0 @@
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)
}
-84
View File
@@ -1,84 +0,0 @@
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
}
-448
View File
@@ -1,448 +0,0 @@
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)
}
-168
View File
@@ -1,168 +0,0 @@
// Минимальный клиент 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 => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;"
}[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();
-110
View File
@@ -1,110 +0,0 @@
<!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>
-179
View File
@@ -1,179 +0,0 @@
: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;
}