// Command bj-installer — установка bj на Ubuntu 22.04 LTS или Astra Linux Special Edition // (ОС, на которых официально работает СКЗИ «Валидата Клиент L»). // // 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 // // Установка идёт в консоли, без графической среды: проверка системы, пакеты, пользователь и каталоги, // активация лицензии, база PostgreSQL, компоненты bj по лицензии (подпись релиза и контрольные суммы // проверяются), root-скрипты и службы. В конце на экране — адрес первичной настройки и одноразовый код: // дальше настройка в браузере, в самом bj. // // Проверка без чистого сервера: --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" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/hwid" "io" "io/fs" "math/big" "net" "net/http" "os" "os/exec" "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" ) // installerVersion — версия установщика (go build -ldflags "-X main.installerVersion=…"). var installerVersion = "0.1.0" 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 ) type installer struct { server, channel, code string root string // префикс файловой системы (песочница) dryRun bool steps map[string]bool out *console token string lic *license.License setupCode string // одноразовый код первичной настройки (итоговый экран) bundle string // локальный релиз (образ ISO): ядро без ключа продукта shown bool } func main() { if len(os.Args) < 2 { usage() } switch os.Args[1] { case "install": cmdInstall(os.Args[2:]) case "refresh-system": os.Exit(refreshSystem()) 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 refresh-system (переписать службы и скрипты из этой версии установщика)") 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.bundle, "bundle", "", "каталог с релизом (manifest.json и файлы) — установка ядра без ключа продукта, ключ вводится в браузере") 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) == "" && in.bundle == "" { return errors.New("не указан ключ продукта: bj-installer install --code BJ-XXXX-XXXX-XXXX (он есть в портале клиента https://lic.zetit.ru/portal/)") } all := []step{ {"precheck", "Проверка системы", in.stepPrecheck}, {"packages", "Системные пакеты", in.stepPackages}, {"validata", "СКЗИ «Валидата Клиент L»", in.stepValidata}, {"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 s.id == "license" && strings.TrimSpace(in.code) == "" && in.bundle != "": // ключ продукта вводится в браузере на первой странице — шаг здесь не нужен 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 == "ubuntu" && strings.HasPrefix(ver, "22.04"): case id == "astra" || strings.Contains(like, "astra") || strings.Contains(strings.ToLower(name), "astra"): default: return "", fmt.Errorf("поддерживаются Ubuntu 22.04 LTS и Astra Linux Special Edition — на них официально работает СКЗИ «Валидата Клиент L»; на сервере %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 } } } return 0 } func (in *installer) stepPackages() (string, error) { pkgs := []string{"ca-certificates", "curl", "postgresql", "postgresql-client", "default-jre-headless", "pcscd", "python3", "smbclient"} env := append(os.Environ(), "DEBIAN_FRONTEND=noninteractive") // Образ кладёт пакеты со всеми зависимостями в /var/lib/bj/debs — ставим с диска, без сети. // Не вышло (другая система, не хватает зависимости) — обычная установка из репозитория. if debs, _ := filepath.Glob(in.P(debsDir) + "/*.deb"); len(debs) > 0 && in.root == "" { args := append([]string{"install", "-y", "-qq", "--no-install-recommends"}, debs...) if _, err := runEnv(env, "apt-get", append(args, pkgs...)...); err == nil { _ = os.RemoveAll(in.P(debsDir)) return strings.Join(pkgs, ", ") + " (с образа, без загрузки)", nil } } 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 } 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) { if strings.TrimSpace(in.code) == "" && in.bundle != "" { return "пропущена: ключ продукта вводится в браузере на первой странице настройки", nil } 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(), "board": hwid.Board(), "mac": hwid.MAC()}) 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 in.bundle != "" { return in.componentsFromBundle() } 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) } _ = os.MkdirAll(in.P("/etc/bj"), 0o755) 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 } // componentsFromBundle — установка из локального релиза (образ ISO): подписанный manifest.json и файлы // рядом. Без лицензии ставится только ядро; модули — после активации ключа, из раздела «Обновления». func (in *installer) componentsFromBundle() (string, error) { pub, err := release.ParsePublicKey(release.DefaultPublicKey) if err != nil { return "", err } _ = in.ensureToken() // лицензии может не быть — тогда только ядро sm, m, err := release.ReadStagedManifest(in.bundle, pub) if err != nil { return "", fmt.Errorf("релиз в %s: %v", in.bundle, 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.lic == nil || !in.licensed(c.Feature)): skipped = append(skipped, c.Title) case !unitTemplate(c.Unit): skipped = append(skipped, c.Title) default: src := filepath.Join(in.bundle, art.File) if _, err := os.Stat(src); err != nil { if c.Name == "bj-server" { return "", fmt.Errorf("в релизе нет файла %s", art.File) } // в образ кладётся не всё: недостающее поставит первое обновление после ввода ключа skipped = append(skipped, c.Title+" (поставится обновлением)") continue } body, err := os.ReadFile(src) if err != nil { return "", err } if err := os.WriteFile(filepath.Join(staging, art.File), body, 0o640); err != nil { return "", 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) } _ = os.MkdirAll(in.P("/etc/bj"), 0o755) 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 { if in.lic == nil { // без ключа не знаем, что входит в лицензию, — не перечисляем модули, которых у клиента может не быть detail += "; криптосервис и модули по лицензии установятся обновлением после ввода ключа продукта" } else { detail += "; установятся обновлением: " + strings.Join(skipped, ", ") } } return detail, nil } // debsDir — системные пакеты для bj (PostgreSQL, Java, pcscd…), положенные образом ISO. const debsDir = "/var/lib/bj/debs" // validataDir — пакеты «Валидата Клиент L», положенные образом ISO (zpki, zsdk и совместимая libldap-2.4-2). const validataDir = "/var/lib/bj/validata" // stepValidata — ставит СКЗИ «Валидата Клиент L» из комплекта образа, если её ещё нет. func (in *installer) stepValidata() (string, error) { if out, err := run("dpkg-query", "-W", "-f", "${Version}", "zpki"); err == nil && strings.TrimSpace(out) != "" { return "уже установлена, версия " + strings.TrimSpace(out), nil } debs, _ := filepath.Glob(in.P(validataDir) + "/*.deb") if len(debs) == 0 { return "не входит в комплект этой установки — поставьте по инструкции «Справка → Криптография»", nil } if in.root != "" { return "проверка в песочнице: пакеты " + strings.Join(debs, ", "), nil } ld, _ := run("sh", "-c", "ldconfig -p 2>/dev/null | grep -c 'libldap_r-2.4.so.2' || true") var args []string for _, d := range debs { if strings.Contains(filepath.Base(d), "libldap") && strings.TrimSpace(ld) != "0" && strings.TrimSpace(ld) != "" { continue // в системе уже есть libldap 2.4 } args = append(args, d) } env := append(os.Environ(), "DEBIAN_FRONTEND=noninteractive") if out, err := runEnv(env, "apt-get", append([]string{"install", "-y", "-qq", "--no-install-recommends"}, args...)...); err != nil { return "", fmt.Errorf("установка Валидаты: %v\n%s", err, tail(out, 12)) } ver, _ := run("dpkg-query", "-W", "-f", "${Version}", "zpki") return "установлена с образа, версия " + strings.TrimSpace(ver), nil } func (in *installer) stepServices() (string, error) { _ = hwid.WriteCache(in.root) // хэш UUID платформы для bj (читается только root) 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 } enable := []string{"enable", "bj-keymedia.service", "bj-server.service", "bj-welcome.service", "bj-welcome.timer"} // службы установленных модулей (bj-npr и т. п.), для которых есть unit-файл for name := range release.LoadInstalled(in.P(release.InstalledFile)) { if c, ok := release.ComponentByName(name); ok && !c.Core && c.Unit != "" && unitTemplate(c.Unit) { enable = append(enable, c.Unit+".service") } } if in.root == "" { for _, c := range [][]string{{"daemon-reload"}, enable} { 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 } // refreshSystem — «bj-installer refresh-system»: переписать службы systemd и служебные скрипты // из этой версии установщика (вызывается root-установщиком обновлений после обновления самого // bj-installer). Работающие службы не перезапускаются: ядро перезапустит обновление, а служба // root-операций bj-keymedia — через минуту, когда обновление, которое она выполняет, закончится. func refreshSystem() int { if os.Geteuid() != 0 { fmt.Fprintln(os.Stderr, "refresh-system: нужны права root") return 1 } in := &installer{} msg, err := in.stepServices() if err != nil { fmt.Fprintln(os.Stderr, "refresh-system:", err) return 1 } _, _ = run("systemd-run", "--on-active=60", "--unit=bj-keymedia-refresh", "systemctl", "try-restart", "bj-keymedia.service") fmt.Println("системные файлы обновлены:", msg) return 0 } func (in *installer) stepFirstRun() (string, error) { code, err := in.newFirstRunCode() if err != nil { return "", err } if in.root == "" { units := []string{"bj-keymedia", "bj-server"} for name := range release.LoadInstalled(in.P(release.InstalledFile)) { if c, ok := release.ComponentByName(name); ok && !c.Core && c.Unit != "" && unitTemplate(c.Unit) { units = append(units, c.Unit) } } for _, unit := range units { if out, err := run("systemctl", "restart", unit); err != nil { st, _ := run("systemctl", "status", unit, "--no-pager", "-n", "15") return "", fmt.Errorf("запуск %s: %v %s\n%s", unit, err, out, st) } } 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:] in.refreshWelcome() return code, nil } // refreshWelcome — обновить приветственный экран консоли (адреса и состояние bj). func (in *installer) refreshWelcome() { if in.root != "" { return } if _, err := os.Stat("/usr/local/sbin/bj-welcome"); err == nil { _, _ = run("/usr/local/sbin/bj-welcome") } } 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 Ключ продукта ещё не введён — его спросит первая страница в браузере\n (или позже: «Настройка → Лицензия»). Он есть в портале клиента.\n") } 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() }