diff --git a/cmd/bj-component-install/main.go b/cmd/bj-component-install/main.go index e342a38..6dc4500 100644 --- a/cmd/bj-component-install/main.go +++ b/cmd/bj-component-install/main.go @@ -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)) - } - } -} diff --git a/cmd/bj-installer/main.go b/cmd/bj-installer/main.go index a4af4a2..6095202 100644 --- a/cmd/bj-installer/main.go +++ b/cmd/bj-installer/main.go @@ -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() } diff --git a/cmd/bj-installer/precheck.go b/cmd/bj-installer/precheck.go deleted file mode 100644 index bacd592..0000000 --- a/cmd/bj-installer/precheck.go +++ /dev/null @@ -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 -} diff --git a/cmd/bj-installer/server.go b/cmd/bj-installer/server.go deleted file mode 100644 index 463cca8..0000000 --- a/cmd/bj-installer/server.go +++ /dev/null @@ -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) -} diff --git a/cmd/bj-installer/sse.go b/cmd/bj-installer/sse.go deleted file mode 100644 index 8bf7df5..0000000 --- a/cmd/bj-installer/sse.go +++ /dev/null @@ -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: \ndata: \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() - } - } -} diff --git a/cmd/bj-installer/state.go b/cmd/bj-installer/state.go deleted file mode 100644 index 8d7cc1a..0000000 --- a/cmd/bj-installer/state.go +++ /dev/null @@ -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) -} diff --git a/cmd/bj-installer/steps.go b/cmd/bj-installer/steps.go deleted file mode 100644 index dd0a6ec..0000000 --- a/cmd/bj-installer/steps.go +++ /dev/null @@ -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 -} diff --git a/cmd/bj-installer/steps_impl.go b/cmd/bj-installer/steps_impl.go deleted file mode 100644 index 486ef79..0000000 --- a/cmd/bj-installer/steps_impl.go +++ /dev/null @@ -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/ с владельцем 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) -} diff --git a/cmd/bj-installer/web/app.js b/cmd/bj-installer/web/app.js deleted file mode 100644 index 43222cd..0000000 --- a/cmd/bj-installer/web/app.js +++ /dev/null @@ -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 = ` - ${r.ok ? "✓" : "✗"} -
-
${escapeHTML(r.title)}
- ${r.message ? `
${escapeHTML(r.message)}
` : ""} -
`; - 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 = ` - ${STEP_ICONS[s.status] || "○"} -
-
${escapeHTML(s.title)}
- ${s.message ? `
${escapeHTML(s.message)}
` : ""} -
`; - 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(); diff --git a/cmd/bj-installer/web/index.html b/cmd/bj-installer/web/index.html deleted file mode 100644 index aaab131..0000000 --- a/cmd/bj-installer/web/index.html +++ /dev/null @@ -1,110 +0,0 @@ - - - - - -bj-installer — мастер установки Bridge-and-Join-s - - - -
- -
мастер установки
-
- -
- - - - -
-

Добро пожаловать

-

Этот мастер установит на сервер СКЗИ «Валидата Клиент L», - bj-server, bj-crypto и ИШ НРД, настроит - systemd-сервисы и подготовит окружение для подписи документов - по ГОСТ 34.10-2012.

-

После установки откроется /admin/setup в bj-server, где можно - загрузить тестовый профиль от MOEX (.7z) и активировать подпись.

-
- -
-
- - -
-

Проверка системы

-
-
- - -
-
- - -
-

Настройка

-
- - - - -
- - -
-
-
- - -
-

Установка

-
    -
    -
    - - -
    -

    ✓ Готово

    -

    bj-server и все сервисы запущены. Откройте панель администратора и - импортируйте профиль:

    - -

    Что дальше:

    -
      -
    1. Подключите USB с .vdk → он автоматически смонтируется в /var/lib/bj/usb/
    2. -
    3. На /admin/setup загрузите .7z с профилем от MOEX и введите пароль
    4. -
    5. Нажмите «Активировать» — bj-crypto подтянет ключ и подтвердит готовность
    6. -
    -
    - - -
    -

    ✗ Установка прервана

    -

    Произошла ошибка:

    -
    
    -    

    Логи: journalctl -u bj-installer и journalctl -u bj-crypto

    -
    - -
    -
    -
    - - - - diff --git a/cmd/bj-installer/web/style.css b/cmd/bj-installer/web/style.css deleted file mode 100644 index 6730da9..0000000 --- a/cmd/bj-installer/web/style.css +++ /dev/null @@ -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; -} diff --git a/deploy/embed.go b/deploy/embed.go new file mode 100644 index 0000000..030f608 --- /dev/null +++ b/deploy/embed.go @@ -0,0 +1,10 @@ +// Package deploy — файлы развёртывания bj, которые ставит установщик (cmd/bj-installer): +// root-скрипты для /usr/local/sbin и unit-файлы служб systemd. +package deploy + +import "embed" + +// Files — linux/sbin/* (в /usr/local/sbin) и systemd/*.service (в /etc/systemd/system). +// +//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service +var Files embed.FS diff --git a/deploy/systemd/bj-server.service b/deploy/systemd/bj-server.service index 47e5142..f7715cc 100644 --- a/deploy/systemd/bj-server.service +++ b/deploy/systemd/bj-server.service @@ -15,7 +15,7 @@ RestartSec=5 # Конфигурация — через ENV или ~/.bj/setup.json (UI /admin/setup). Environment=BJ_HTTP_ADDR=:8080 -Environment=BJ_SETUP_PATH=/var/lib/bj/setup.json +Environment=BJ_SETUP_PATH=/var/lib/bj/.bj/setup.json Environment=BJ_M2M_SENDER=MC0079200000 Environment=BJ_M2M_RECEIVER=MC0010300000 @@ -32,6 +32,8 @@ ProtectSystem=strict ProtectHome=true PrivateTmp=true ReadWritePaths=/var/lib/bj /var/log/bj /run/bj +StandardOutput=append:/var/log/bj/bj-server.log +StandardError=append:/var/log/bj/bj-server.err ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true diff --git a/internal/license/keys.go b/internal/license/keys.go new file mode 100644 index 0000000..2b7e6cd --- /dev/null +++ b/internal/license/keys.go @@ -0,0 +1,5 @@ +package license + +// DefaultPublicKey — открытый ключ проверки лицензий Bridge&Join (закрытый — на сервере лицензий, +// /etc/bj-license/license.priv). Общий для bj-server и установщика. +const DefaultPublicKey = "UuvEYFxxzOFk59FjCQTdxKuzkNAJpJyWTuUdlCYfsdI=" diff --git a/internal/license/license.go b/internal/license/license.go index 13820b2..a412df6 100644 --- a/internal/license/license.go +++ b/internal/license/license.go @@ -38,14 +38,14 @@ const ( // License — содержимое лицензии (подписывается целиком). type License struct { Schema int `json:"schema"` - ID string `json:"id"` // UUID лицензии - Tenant string `json:"tenant"` // организация-клиент - Product string `json:"product"` // "bj-server" - Plan Plan `json:"plan"` // free|pro|enterprise - IssuedAt time.Time `json:"issued_at"` // дата выпуска - ExpiresAt time.Time `json:"expires_at"` // дата окончания (годовой ключ) - Features []string `json:"features,omitempty"` // "updates","web-cabinet",... - MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита) + ID string `json:"id"` // UUID лицензии + Tenant string `json:"tenant"` // организация-клиент + Product string `json:"product"` // "bj-server" + Plan Plan `json:"plan"` // free|pro|enterprise + IssuedAt time.Time `json:"issued_at"` // дата выпуска + ExpiresAt time.Time `json:"expires_at"` // дата окончания (годовой ключ) + Features []string `json:"features,omitempty"` // "updates","web-cabinet",... + MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита) Note string `json:"note,omitempty"` // Онлайн-лицензирование (lic.zetit.ru, docs/license-server-api.md): токен выдаётся diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go index 4d4fd9e..52556c8 100644 --- a/internal/lkgateway/auth.go +++ b/internal/lkgateway/auth.go @@ -490,7 +490,7 @@ type AuthSettings struct { // Перепроверка групп доменных пользователей (adrecheck.go): поиск от имени // сервисной учётной записи только на чтение. Пусто — роль меняется только при входе. - ADBindUser string `json:"ad_bind_user"` // svc-bj@corp.example.ru, CORP\svc-bj или DN + ADBindUser string `json:"ad_bind_user"` // svc-bj@corp.example.ru, CORP\svc-bj или DN ADBindPassword string `json:"ad_bind_password"` ADRecheckMinutes int `json:"ad_recheck_minutes"` // 0 = 15 } @@ -805,7 +805,7 @@ func currentUser(w http.ResponseWriter) *User { // authExempt — пути без входа: API модулей (свой ключ/токен), health, статика. func authExempt(p string) bool { switch { - case p == "/login" || p == "/logout" || p == "/healthz": + case p == "/login" || p == "/logout" || p == "/healthz" || p == "/first-run": return true case strings.HasPrefix(p, "/api/"): return true @@ -857,6 +857,14 @@ func authMiddleware(rc *RuntimeConfig, next http.Handler) http.Handler { next.ServeHTTP(w, r) return } + if firstRunPending() { // свежая установка: сначала администратор по одноразовому коду + if isAjax(r) { + http.Error(w, "требуется первичная настройка", http.StatusUnauthorized) + return + } + http.Redirect(w, r, "/first-run", http.StatusSeeOther) + return + } if authOpen() { next.ServeHTTP(&authRW{ResponseWriter: w, user: nil}, r) return @@ -924,9 +932,14 @@ type loginData struct { var loginTpl = template.Must(template.New("login").Parse(loginHTML)) func registerAuth(mux *http.ServeMux, rc *RuntimeConfig) { + registerFirstRun(mux) mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { a := rc.Snapshot().Auth next := safeReturnPath(r.FormValue("next")) + if firstRunPending() { + http.Redirect(w, r, "/first-run", http.StatusSeeOther) + return + } if authOpen() { http.Redirect(w, r, "/admin/setup#users", http.StatusSeeOther) return @@ -978,10 +991,10 @@ func registerAuth(mux *http.ServeMux, rc *RuntimeConfig) { // ---------- обработчики настроек ---------- type UsersView struct { - Users []*User - Roles []roleDef // доступные при текущей лицензии - Hidden []struct{ Title, Missing string } // роли, недоступные без модулей - Modules []struct{ Key, Title string } // подключённые модули + Users []*User + Roles []roleDef // доступные при текущей лицензии + Hidden []struct{ Title, Missing string } // роли, недоступные без модулей + Modules []struct{ Key, Title string } // подключённые модули Sessions []*Session Open bool Me *User diff --git a/internal/lkgateway/firstrun.go b/internal/lkgateway/firstrun.go new file mode 100644 index 0000000..4dc6d4a --- /dev/null +++ b/internal/lkgateway/firstrun.go @@ -0,0 +1,130 @@ +package lkgateway + +// Первичная настройка после установки. Пока в bj нет ни одного пользователя, установщик +// (cmd/bj-installer) оставляет файл с хешем одноразового кода, который показан на экране сервера. +// Все страницы, кроме этой, перенаправляются сюда: по коду создаётся первый администратор, файл +// удаляется, дальше обычный вход и раздел «Настройка». Установки без файла работают как раньше +// (открытый режим до создания первого пользователя). + +import ( + "encoding/json" + "html/template" + "log" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const firstRunFile = "/var/lib/bj/.bj/first-run.json" + +type firstRunState struct { + CodeHash string `json:"code_hash"` + CreatedAt time.Time `json:"created_at"` +} + +// firstRunPending — ждём первичной настройки: пользователей нет, установщик оставил код. +func firstRunPending() bool { + if globalUsers.Count() > 0 { + return false + } + _, err := os.Stat(firstRunFile) + return err == nil +} + +type firstRunData struct { + Error, Login, Name string +} + +var firstRunTpl = template.Must(template.New("first-run").Parse(firstRunHTML)) + +func registerFirstRun(mux *http.ServeMux) { + mux.HandleFunc("/first-run", func(w http.ResponseWriter, r *http.Request) { + if !firstRunPending() { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + var d firstRunData + w.Header().Set("Cache-Control", "no-store") + if r.Method == http.MethodPost { + ip := clientIP(r) + key := "first-run|" + ip + d.Login, d.Name = strings.TrimSpace(r.FormValue("login")), strings.TrimSpace(r.FormValue("name")) + pw, pw2 := r.FormValue("password"), r.FormValue("password2") + code := strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(r.FormValue("code"))) + var st firstRunState + raw, readErr := os.ReadFile(firstRunFile) + if readErr == nil { + _ = json.Unmarshal(raw, &st) + } + switch { + case loginFailCount(key) >= 8: + d.Error = "Слишком много неудачных попыток. Подождите 15 минут." + case readErr != nil || st.CodeHash == "" || bcrypt.CompareHashAndPassword([]byte(st.CodeHash), []byte(code)) != nil: + loginFailAdd(key) + time.Sleep(700 * time.Millisecond) + log.Printf("auth: первичная настройка: неверный код с %s", ip) + d.Error = "Неверный одноразовый код" + case d.Login == "" || strings.ContainsAny(d.Login, " /\\@\"'<>"): + d.Error = "Логин пустой или содержит недопустимые символы" + case len(pw) < 10 || pw != pw2: + d.Error = "Пароль не короче 10 символов и должен совпадать с повтором" + default: + hash, err := hashPassword(pw) + if err != nil { + d.Error = err.Error() + break + } + now := time.Now() + u := &User{Login: d.Login, Name: d.Name, Role: "admin", Source: "local", PassHash: hash, Active: true, CreatedAt: now, LastLogin: now} + if err := globalUsers.Put(u); err != nil { + d.Error = "Не удалось сохранить пользователя: " + err.Error() + break + } + _ = os.Remove(firstRunFile) + loginFailReset(key) + s := globalSessions.New(u.Login, ip, r.UserAgent()) + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: s.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, MaxAge: int(sessionMaxLife.Seconds())}) + log.Printf("auth: первичная настройка: создан администратор %s с %s", u.Login, ip) + http.Redirect(w, r, "/admin/setup", http.StatusSeeOther) + return + } + w.WriteHeader(http.StatusUnauthorized) + } + _ = firstRunTpl.Execute(w, d) + }) +} + +const firstRunHTML = `Первичная настройка · Bridge&Join +
    +
    Bridge&Join
    +
    Первичная настройка
    +

    Введите одноразовый код, который установщик показал на экране сервера, и создайте учётную запись администратора. Остальные настройки откроются сразу после этого.

    +
    + + + + + + +{{if .Error}}
    {{.Error}}
    {{end}} +
    Код потерян или истёк: на сервере выполните sudo bj-installer first-run-code.
    +
    +
    ` diff --git a/internal/lkgateway/licensecheck.go b/internal/lkgateway/licensecheck.go index b1ddbbe..d99a78a 100644 --- a/internal/lkgateway/licensecheck.go +++ b/internal/lkgateway/licensecheck.go @@ -10,7 +10,7 @@ import ( // DefaultLicensePublicKey — открытый ключ проверки лицензий: пара к ключу подписи сервера // лицензий lic.zetit.ru. Зашит в bj и в настройках не меняется, поэтому ключ, подписанный // чем-то другим, не принимается. var — только для подмены в тестах. -var DefaultLicensePublicKey = "UuvEYFxxzOFk59FjCQTdxKuzkNAJpJyWTuUdlCYfsdI=" +var DefaultLicensePublicKey = license.DefaultPublicKey // LicenseStatus — сводка состояния лицензии для UI и гейтов. type LicenseStatus struct { diff --git a/internal/lkgateway/updater.go b/internal/lkgateway/updater.go index 5022094..3e600eb 100644 --- a/internal/lkgateway/updater.go +++ b/internal/lkgateway/updater.go @@ -30,7 +30,7 @@ var BuildVersion = "0.1.0" // Открытая часть ключа подписи релизов. Если задан в // настройках (UpdateSettings.PublicKey) — приоритет у настроек (для проверки // в bj; root-установщик верит только /etc/bj/update.pub). -var DefaultUpdatePublicKey = "GHC/gJdQOAEVuBmq8pRpfG+CoIwtcjpNlSf6bPFtr/Y=" // ~/keys/release-signing.pub (закрытый ключ хранится вне сервера лицензий) +var DefaultUpdatePublicKey = release.DefaultPublicKey const updateStagingDir = "/var/lib/bj/update-staging" diff --git a/internal/release/components.go b/internal/release/components.go index 159ff34..2127412 100644 --- a/internal/release/components.go +++ b/internal/release/components.go @@ -28,6 +28,7 @@ type Component struct { // Components — все компоненты bj. Порядок — порядок показа и установки (ядро перезапускается последним). var Components = []Component{ {Name: "bj-server", Title: "Ядро bj: переводы M2M, DEPO, QUIK МНП, ЛК ЕСИА", Path: "/opt/bj/bj-server", Unit: "bj-server", Exec: true, Core: true, Feature: "updates"}, + {Name: "bj-component-install", Title: "Установщик обновлений", Path: "/usr/local/sbin/bj-component-install", Exec: true, Core: true, Feature: "updates"}, {Name: "crypto-service.jar", Title: "Криптосервис", Path: "/opt/bj/crypto-service.jar", Unit: "bj-crypto", Feature: "updates"}, {Name: "bj-lk", Title: "Личный кабинет клиента", Path: "/opt/bj/bj-lk", Unit: "bj-lk", Exec: true, Feature: "web-cabinet"}, {Name: "bj-npr", Title: "Риск-монитор НПР / УДС", Path: "/opt/bj/bj-npr", Unit: "bj-npr", Exec: true, Feature: "npr"}, diff --git a/internal/release/install.go b/internal/release/install.go new file mode 100644 index 0000000..4e0a8a8 --- /dev/null +++ b/internal/release/install.go @@ -0,0 +1,243 @@ +package release + +// Установка компонентов из каталога подготовки. Общая для root-установщика обновлений +// (cmd/bj-component-install) и установщика bj (cmd/bj-installer). Ничему из каталога не доверяет: +// проверяет подпись манифеста и sha256 файлов, ставит только известные компоненты по их путям, +// не откатывает версию, сохраняет прежний файл (<путь>.bak-ДАТА, пять последних). + +import ( + "crypto/ed25519" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "syscall" + "time" +) + +// StagingBase — каталог подготовки обновлений на сервере bj. +const StagingBase = "/var/lib/bj/update-staging" + +// InstallOptions — как ставить. +type InstallOptions struct { + Root string // корень установки ("" — настоящий; иначе проверка в песочнице) + PubKey ed25519.PublicKey // открытый ключ подписи релизов + Restart bool // перезапустить службы установленных компонентов (ядро последним) + AllowNew bool // ставить модули, которых ещё нет на сервере (первичная установка) +} + +// InstallResult — итог установки (JSON для bj-server). +type InstallResult 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"` +} + +// InstallStaged — установить компоненты из каталога подготовки: manifest.json (подписанный), +// request.json {"names": [...]} и файлы компонентов. +func InstallStaged(staging string, opt InstallOptions) (res InstallResult) { + fail := func(format string, a ...any) InstallResult { + res.OK, res.Error = false, fmt.Sprintf(format, a...) + return res + } + if staging == "" { + return fail("не указан каталог подготовки") + } + staging = filepath.Clean(staging) + if opt.Root == "" && !strings.HasPrefix(staging, StagingBase+"/") { + return fail("каталог подготовки должен быть внутри %s", StagingBase) + } + if len(opt.PubKey) != ed25519.PublicKeySize { + return fail("не задан открытый ключ подписи релизов") + } + var sm SignedManifest + if err := readStagedJSON(staging, "manifest.json", &sm); err != nil { + return fail("манифест: %v", err) + } + m, err := Verify(&sm, opt.PubKey) + if err != nil { + return fail("подпись манифеста не прошла проверку: %v", err) + } + res.Release = m.Version + var req struct { + Names []string `json:"names"` + } + if err := readStagedJSON(staging, "request.json", &req); err != nil { + return fail("запрос: %v", err) + } + if len(req.Names) == 0 { + return fail("не выбраны компоненты") + } + installedPath := InstalledFile + if opt.Root != "" { + installedPath = filepath.Join(opt.Root, installedPath) + } + installed := LoadInstalled(installedPath) + + // Все проверки до любой установки: всё или ничего. + type job struct { + c Component + a Artifact + file string + } + var jobs []job + for _, name := range req.Names { + c, ok := ComponentByName(name) + if !ok { + return fail("неизвестный компонент %q", name) + } + var art *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) + } + if art.Version == "" { + art.Version = m.Version + } + if _, err := os.Stat(c.PathAt(opt.Root)); err != nil && !c.Core && !opt.AllowNew { + return fail("%s не установлен на этом сервере: модуль ставится установщиком, а не обновлением", name) + } + if prev, ok := installed[name]; ok && prev.Version != "" && IsNewer(prev.Version, art.Version) { + return fail("%s: установлена %s, в релизе более старая %s, откат не выполняется", name, prev.Version, art.Version) + } + 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 := VerifyArtifact(file, *art); err != nil { + return fail("%s: %v", name, err) + } + jobs = append(jobs, job{c: c, a: *art, file: file}) + } + sort.SliceStable(jobs, func(i, j int) bool { return componentOrder(jobs[i].c.Name) < componentOrder(jobs[j].c.Name) }) + + ts := time.Now().Format("20060102-150405") + var units []string + for _, j := range jobs { + if err := installFile(j.file, j.c.PathAt(opt.Root), j.c.Exec, ts); err != nil { + return fail("установка %s: %v", j.c.Name, err) + } + installed[j.c.Name] = 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 := SaveInstalled(installedPath, installed); err != nil { + return fail("запись установленных версий: %v", err) + } + chownLikeParent(installedPath) + + if opt.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 componentOrder(name string) int { + for i, c := range Components { + if c.Name == name { + return i + } + } + return len(Components) +} + +func readStagedJSON(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) +} + +// installFile — копия прежнего файла, запись нового рядом и атомарная замена. +func installFile(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 := copyFileMode(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 := copyFileMode(src, tmp, mode); err != nil { + return err + } + return os.Rename(tmp, dst) +} + +func copyFileMode(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)) + } + } +} diff --git a/internal/release/keys.go b/internal/release/keys.go new file mode 100644 index 0000000..4b0cf14 --- /dev/null +++ b/internal/release/keys.go @@ -0,0 +1,6 @@ +package release + +// DefaultPublicKey — открытый ключ подписи релизов bj. Закрытый хранится вне сервера лицензий +// (~/keys/release-signing.priv на рабочей машине издателя). Общий для bj-server, установщика и +// root-установщика компонентов (на серверах клиента копия лежит в /etc/bj/update.pub). +const DefaultPublicKey = "GHC/gJdQOAEVuBmq8pRpfG+CoIwtcjpNlSf6bPFtr/Y=" diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..80ad311 --- /dev/null +++ b/migrations/embed.go @@ -0,0 +1,9 @@ +// Package migrations — SQL-миграции bj; встраиваются в установщик (cmd/bj-installer). +package migrations + +import "embed" + +// M2MCore — схема m2m_core (сделки, этапы). Применяются по порядку имён файлов, повторно безопасны. +// +//go:embed m2m-core/*.sql +var M2MCore embed.FS diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..aab937d --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Сборка и подпись релиза bj для сервера обновлений (lic.zetit.ru). +# +# scripts/build-release.sh 2026.10.1 [канал] [опубликовать] +# 2026.10.1 версия релиза +# канал stable (по умолчанию) | beta | … +# опубликовать publish — сразу загрузить на сервер (нужен репозиторий lic_Bridge-and-Join-s рядом) +# +# Версии компонентов: переменные BJ_SERVER_VERSION, INSTALLER_VERSION, COMPONENT_INSTALL_VERSION, +# CRYPTO_VERSION (по умолчанию — версия релиза). Криптосервис берётся из CRYPTO_JAR +# (по умолчанию /opt/bj/crypto-service.jar). Ключ подписи: RELEASE_KEY (~/keys/release-signing.priv). +set -eu +VERSION=${1:?версия релиза, например 2026.10.1} +CHANNEL=${2:-stable} +PUBLISH=${3:-} +ROOT=$(cd "$(dirname "$0")/.." && pwd) +DIST=${DIST:-$ROOT/dist/release-$VERSION} +KEY=${RELEASE_KEY:-$HOME/keys/release-signing.priv} +MOD=git.zetit.ru/zuevav/Bridge-and-Join-s +rm -rf "$DIST" && mkdir -p "$DIST" +cd "$ROOT" +ver() { eval "v=\${$1:-}"; [ -n "$v" ] && echo "$v" || echo "$VERSION"; } +echo "Сборка релиза $VERSION (канал $CHANNEL) в $DIST" +go build -trimpath -ldflags "-s -w -X $MOD/internal/lkgateway.BuildVersion=$(ver BJ_SERVER_VERSION)" -o "$DIST/bj-server" ./cmd/bj-server +CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.installerVersion=$(ver INSTALLER_VERSION)" -o "$DIST/bj-installer" ./cmd/bj-installer +CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$DIST/bj-component-install" ./cmd/bj-component-install +ver BJ_SERVER_VERSION > "$DIST/bj-server.version" +ver INSTALLER_VERSION > "$DIST/bj-installer.version" +ver COMPONENT_INSTALL_VERSION > "$DIST/bj-component-install.version" +JAR=${CRYPTO_JAR:-/opt/bj/crypto-service.jar} +if [ -f "$JAR" ]; then + cp "$JAR" "$DIST/crypto-service.jar" + ver CRYPTO_VERSION > "$DIST/crypto-service.jar.version" +fi +go run ./cmd/bj-release build -dir "$DIST" -version "$VERSION" -channel "$CHANNEL" -key "$KEY" -keyid release -out "$DIST/manifest.json" +if [ "$PUBLISH" = "publish" ]; then + LIC=${LIC_REPO:-$ROOT/../lic_Bridge-and-Join-s} + [ -x "$LIC/deploy/publish-release.sh" ] || LIC=$HOME/lic_Bridge-and-Join-s + rm -f "$DIST"/*.version + "$LIC/deploy/publish-release.sh" "$DIST" "$CHANNEL" +fi