Files
Bridge-and-Join-s/cmd/bj-installer/main.go
T

1082 lines
40 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
bjHTTPSPort = 8443 // веб-вход по HTTPS (самоподписанный сертификат сервера; см. lkgateway/tlsserve.go)
)
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)
}
}
if os.Getenv("BJ_SCREEN") != "" { // экран установки (bj-firstboot-screen): весь план шагов заранее
var titles []string
for _, s := range todo {
titles = append(titles, s.title)
}
fmt.Fprintf(in.out.w, "@@plan %s\n", strings.Join(titles, "|"))
in.out.flush()
}
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 {
if in.bundle != "" {
// установка с образа — всё нужное на диске; ключ продукта и обновления — позже, когда будет связь
return fmt.Sprintf("%s, %.1f ГБ памяти; нет связи с %s — ставим с образа, ключ продукта введёте в браузере, когда появится связь", name, float64(mem)/(1<<30), in.server), 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
}
// javaPkg — Java для службы подписи (crypto-service собран под Java 17): где Java по умолчанию старше
// не бывает (Ubuntu 22.04, Astra 1.7 — Java 11), ставится openjdk-17; где по умолчанию 17 и новее
// (Debian 13 — Java 21), — пакет по умолчанию.
func javaPkg(debs []string) string {
if len(debs) > 0 { // пакеты с образа: какая Java положена в образ
for _, d := range debs {
if strings.HasPrefix(filepath.Base(d), "openjdk-17-jre-headless_") {
return "openjdk-17-jre-headless"
}
}
return "default-jre-headless"
}
out, err := run("apt-cache", "policy", "openjdk-17-jre-headless")
if err == nil && strings.Contains(out, "Candidate:") && !strings.Contains(out, "Candidate: (none)") {
return "openjdk-17-jre-headless"
}
return "default-jre-headless"
}
func (in *installer) stepPackages() (string, error) {
pkgs := []string{"ca-certificates", "curl", "postgresql", "postgresql-client", "pcscd", "python3", "smbclient",
// для интеграционного шлюза НРД без графики (ИШ 107: пароли в gnome-keyring, движок под виртуальным дисплеем)
"gnome-keyring", "dbus", "xvfb", "xauth", "libsecret-tools", "unzip"}
env := append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
// Образ кладёт пакеты со всеми зависимостями в /var/lib/bj/debs — ставим с диска, без сети.
// Не вышло (другая система, не хватает зависимости) — обычная установка из репозитория.
if debs, _ := filepath.Glob(in.P(debsDir) + "/*.deb"); len(debs) > 0 && in.root == "" {
withJava := append(pkgs, javaPkg(debs))
args := append([]string{"install", "-y", "-qq", "--no-install-recommends"}, debs...)
if _, err := runEnv(env, "apt-get", append(args, withJava...)...); err == nil {
_ = os.RemoveAll(in.P(debsDir))
return strings.Join(withJava, ", ") + " (с образа, без загрузки)", 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))
}
pkgs = append(pkgs, javaPkg(nil))
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("https://%s:%d", ipn.IP.String(), bjHTTPSPort))
}
}
}
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("https://<адрес сервера>:%d", bjHTTPSPort)}
}
for _, u := range urls {
fmt.Fprintf(c.w, " %s\n", c.paint("1", u+"/first-run"))
}
fmt.Fprintf(c.w, " Браузер предупредит о сертификате: это сертификат самого сервера bj — подтвердите исключение.\n Сертификат организации можно загрузить позже: «Настройка → Сервер».\n")
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("https://<адрес сервера>:%d", bjHTTPSPort))))
}
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()
}