938a859cd4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
1082 lines
40 KiB
Go
1082 lines
40 KiB
Go
// 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 := runEnv(append(os.Environ(), "LC_ALL=C"), "apt-cache", "policy", "openjdk-17-jre-headless") // «Candidate:» — без перевода
|
||
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()
|
||
}
|