From 492e226e541ecdc0fc3174283193f9090a9208de Mon Sep 17 00:00:00 2001 From: zuevav Date: Wed, 23 Sep 2026 08:53:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0:=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=8D=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=20(bj?= =?UTF-8?q?-welcome:=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=20=D0=B2=D0=B5=D0=B1?= =?UTF-8?q?-=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20=D0=B8=20=D1=81=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5,=20=D0=B2=D1=81=D0=B5?= =?UTF-8?q?=D0=B3=D0=B4=D0=B0),=20=D1=8F=D0=B4=D1=80=D0=BE=20=D1=81=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D0=B0=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BA=D1=82=D0=B0=20(--bundle),=20=D0=BA=D0=BB=D1=8E=D1=87=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B4=D1=83=D0=BA=D1=82=D0=B0=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B9=20=D0=B2=D0=B5=D0=B1-?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B5,=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2=D1=82=D0=BE=D1=80=20=D0=B2=D0=BE=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=BA=D0=B0=D0=B6=D0=B4?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL --- cmd/bj-installer/main.go | 100 ++++++++++++++++++++++++++++-- deploy/embed.go | 2 +- deploy/iso/bj-firstboot | 53 +++++++++++----- deploy/iso/build-iso.sh | 16 ++++- deploy/iso/preseed.cfg | 5 +- deploy/linux/sbin/bj-welcome | 66 ++++++++++++++++++++ deploy/systemd/bj-welcome.service | 12 ++++ deploy/systemd/bj-welcome.timer | 10 +++ internal/lkgateway/firstrun.go | 24 +++++++ 9 files changed, 263 insertions(+), 25 deletions(-) create mode 100755 deploy/linux/sbin/bj-welcome create mode 100644 deploy/systemd/bj-welcome.service create mode 100644 deploy/systemd/bj-welcome.timer diff --git a/cmd/bj-installer/main.go b/cmd/bj-installer/main.go index 7d3f6cf..3c09575 100644 --- a/cmd/bj-installer/main.go +++ b/cmd/bj-installer/main.go @@ -69,6 +69,7 @@ type installer struct { token string lic *license.License setupCode string // одноразовый код первичной настройки (итоговый экран) + bundle string // локальный релиз (образ ISO): ядро без ключа продукта shown bool } @@ -99,6 +100,7 @@ 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, "только проверить систему") @@ -136,7 +138,7 @@ 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) == "" { + 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{ @@ -356,6 +358,9 @@ func machineHash() string { } 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) @@ -516,6 +521,9 @@ func unitTemplate(unit string) bool { } func (in *installer) stepComponents() (string, error) { + if in.bundle != "" { + return in.componentsFromBundle() + } if err := in.ensureToken(); err != nil { return "", err } @@ -584,6 +592,77 @@ func (in *installer) stepComponents() (string, error) { 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 { + return "", fmt.Errorf("в релизе нет файла %s", art.File) + } + 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) + } + if err := os.WriteFile(in.P("/etc/bj/update.pub"), []byte(release.DefaultPublicKey+"\n"), 0o644); err != nil { + return "", err + } + var list []string + for _, x := range res.Installed { + list = append(list, x["name"]+" "+x["version"]) + } + detail := "релиз " + m.Version + " с образа: " + strings.Join(list, ", ") + if len(skipped) > 0 { + detail += "; модули " + strings.Join(skipped, ", ") + " установятся после активации ключа продукта" + } + return detail, nil +} + func (in *installer) stepServices() (string, error) { var files []string err := fs.WalkDir(deploy.Files, ".", func(p string, d fs.DirEntry, err error) error { @@ -609,7 +688,7 @@ func (in *installer) stepServices() (string, error) { if err != nil { return "", err } - enable := []string{"enable", "bj-keymedia.service", "bj-server.service"} + 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) { @@ -678,12 +757,20 @@ func (in *installer) newFirstRunCode() (string, error) { } in.chownBJ(in.P(firstRunFile)) code := digits[:4] + "-" + digits[4:] - url := setupURLs() - issue := fmt.Sprintf("Bridge&Join, первичная настройка: откройте в браузере %s/first-run и введите код %s\n\n", firstOr(url, "http://<адрес сервера>:8080"), code) - _ = writeFile(in.P("/etc/issue.d/bj-setup.issue"), []byte(issue), 0o644) + 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 { @@ -860,6 +947,9 @@ func (c *console) done(code string, l *license.License) { } 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)) } diff --git a/deploy/embed.go b/deploy/embed.go index 46163cc..9f1b7a9 100644 --- a/deploy/embed.go +++ b/deploy/embed.go @@ -6,5 +6,5 @@ import "embed" // Files — linux/sbin/* (в /usr/local/sbin) и systemd/*.service (в /etc/systemd/system). // -//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service systemd/bj-npr.service systemd/bj-quikexport.service systemd/bj-lk.service +//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service systemd/bj-npr.service systemd/bj-quikexport.service systemd/bj-lk.service systemd/bj-welcome.service systemd/bj-welcome.timer var Files embed.FS diff --git a/deploy/iso/bj-firstboot b/deploy/iso/bj-firstboot index 1ca4872..4b13531 100755 --- a/deploy/iso/bj-firstboot +++ b/deploy/iso/bj-firstboot @@ -1,41 +1,60 @@ -#!/bin/bash -# Первая загрузка сервера, установленного с образа Bridge&Join: на экране сервера спрашивает ключ -# продукта и запускает bj-installer. После успешной установки служба выключается; адрес первичной -# настройки и одноразовый код остаются на экране входа (/etc/issue.d/bj-setup.issue). +#!/bin/sh +# Первая загрузка сервера, установленного с образа Bridge&Join. Спрашивает ключ продукта на консоли; +# без ключа ставит ядро bj с образа (/var/lib/bj/bundle), а ключ вводится в браузере на первой +# странице настройки. Пока bj не установлен, вопрос повторяется при каждой загрузке (с таймаутом), +# адрес и подсказки всегда есть на экране входа (bj-welcome). set -u MARK=/var/lib/bj/.bj/installed-from-iso SERVER=https://lic.zetit.ru CHANNEL=stable +BUNDLE=/var/lib/bj/bundle [ -s /etc/bj/channel ] && CHANNEL=$(tr -d ' \n' < /etc/bj/channel) - finish() { mkdir -p "$(dirname "$MARK")" && date -Iseconds > "$MARK" systemctl disable bj-firstboot.service >/dev/null 2>&1 + [ -x /usr/local/sbin/bj-welcome ] && /usr/local/sbin/bj-welcome } - +urls() { ip -4 -o addr show scope global 2>/dev/null | awk '{split($4,a,"/"); printf " http://%s:8080%s\n", a[1], "'"$1"'"}'; } clear -printf '\n Bridge&Join: установка на сервер\n ────────────────────────────────────────────────\n\n' -printf ' Debian установлен. Осталось установить bj.\n' -printf ' Нужен ключ продукта (BJ-XXXX-XXXX-XXXX): он есть в портале клиента %s/portal/\n\n' "$SERVER" - -for i in $(seq 1 60); do +printf '\n Bridge&Join: установка на сервер\n ────────────────────────────────────────────────────────────\n' +printf ' Debian установлен. Осталось установить bj.\n\n' +if [ -f "$BUNDLE/manifest.json" ]; then + printf ' Ключ продукта (BJ-XXXX-XXXX-XXXX) есть в портале клиента %s/portal/.\n' "$SERVER" + printf ' Вводить его здесь необязательно: нажмите Enter — ядро bj установится с образа,\n а ключ вы введёте в браузере на первой странице настройки (там его удобно вставить).\n\n' +else + printf ' Нужен ключ продукта (BJ-XXXX-XXXX-XXXX): он есть в портале клиента %s/portal/\n\n' "$SERVER" +fi +for i in $(seq 1 30); do curl -fsS -m 5 -o /dev/null "$SERVER/healthz" && break [ "$i" = 1 ] && printf ' Жду сеть и связь с %s…\n' "$SERVER" sleep 2 done - while true; do - read -r -p " Ключ продукта (пусто — установить позже): " CODE - CODE=$(printf '%s' "$CODE" | tr -d ' ' | tr '[:lower:]' '[:upper:]') + CODE="" + if [ -f "$BUNDLE/manifest.json" ]; then + read -t 120 -r -p " Ключ продукта (Enter — установить с образа, ключ ввести в браузере): " CODE || true + else + read -t 120 -r -p " Ключ продукта (Enter — установить позже): " CODE || true + fi + CODE=$(printf '%s' "${CODE:-}" | tr -d ' ' | tr '[:lower:]' '[:upper:]') if [ -z "$CODE" ]; then - printf '\n Установка bj отложена. Позже выполните: sudo bj-installer install --code КЛЮЧ-ПРОДУКТА\n\n' - finish + if [ -f "$BUNDLE/manifest.json" ]; then + if /usr/local/sbin/bj-installer install --bundle "$BUNDLE" --server "$SERVER" --channel "$CHANNEL"; then + finish + printf '\n Ключ продукта спросит первая страница настройки в браузере:\n'; urls /first-run; printf '\n' + read -t 60 -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ || true + exit 0 + fi + printf '\n Установка с образа не завершилась. Подробности выше; повторить:\n sudo bj-installer install --bundle %s\n\n' "$BUNDLE" + sleep 5; exit 0 + fi + printf '\n Установка bj отложена. Вопрос повторится при следующей загрузке; вручную:\n sudo bj-installer install --code КЛЮЧ-ПРОДУКТА\n\n' sleep 3 exit 0 fi if /usr/local/sbin/bj-installer install --code "$CODE" --server "$SERVER" --channel "$CHANNEL"; then finish - read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ + read -t 60 -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ || true exit 0 fi printf '\n Установка не завершилась. Проверьте ключ продукта и сеть и попробуйте ещё раз.\n\n' diff --git a/deploy/iso/build-iso.sh b/deploy/iso/build-iso.sh index fc27fc9..6d69a26 100755 --- a/deploy/iso/build-iso.sh +++ b/deploy/iso/build-iso.sh @@ -1,7 +1,8 @@ #!/bin/sh # Сборка образа ISO Bridge&Join на основе официального Debian 13 netinst. # -# deploy/iso/build-iso.sh <версия> [канал] [выходной.iso] +# deploy/iso/build-iso.sh <версия> [канал] [выходной.iso] [каталог релиза] +# Каталог релиза (dist с manifest.json) — ядро bj кладётся в образ: установка без ключа продукта, ключ вводится в браузере. # # В образ добавляются: файл ответов установщику (preseed.cfg), bj-installer и служба первой загрузки. # Загрузочное меню BIOS (isolinux) и UEFI (grub) по умолчанию запускает установку с этими ответами. @@ -13,6 +14,7 @@ INSTALLER=${2:?файл bj-installer} VERSION=${3:?версия образа, например 2026.10.1} CHANNEL=${4:-stable} OUT=${5:-bj-debian13-$VERSION.iso} +BUNDLE=${6:-} HERE=$(cd "$(dirname "$0")" && pwd) command -v xorriso >/dev/null || { echo "нужен xorriso: apt-get install xorriso"; exit 1; } WORK=$(mktemp -d) @@ -25,6 +27,18 @@ install -m 0755 "$HERE/bj-firstboot" "$WORK/bj/bj-firstboot" cp "$HERE/bj-firstboot.service" "$WORK/bj/bj-firstboot.service" echo "$CHANNEL" > "$WORK/bj/channel" echo "$VERSION" > "$WORK/bj/version" +# приветственный экран консоли (адрес веб-входа) — ставится ещё до установки bj +install -m 0755 "$HERE/../linux/sbin/bj-welcome" "$WORK/bj/bj-welcome" +cp "$HERE/../systemd/bj-welcome.service" "$HERE/../systemd/bj-welcome.timer" "$WORK/bj/" +# ядро bj с образа: подписанный манифест и файлы компонентов ядра (bj-server, bj-component-install) +if [ -n "$BUNDLE" ]; then + [ -s "$BUNDLE/manifest.json" ] || { echo "в $BUNDLE нет manifest.json"; exit 1; } + mkdir -p "$WORK/bj/bundle"; cp "$BUNDLE/manifest.json" "$WORK/bj/bundle/" + for f in $(python3 -c 'import json,sys;m=json.load(open(sys.argv[1]));m=m.get("manifest",m);print(" ".join(a["file"] for a in m["artifacts"] if a["name"] in ("bj-server","bj-component-install")))' "$BUNDLE/manifest.json"); do + cp "$BUNDLE/$f" "$WORK/bj/bundle/" + done + echo "в образ добавлено ядро bj: $(ls "$WORK/bj/bundle" | tr '\n' ' ')" +fi BOOT="auto=true priority=high preseed/file=/cdrom/preseed/bj.cfg language=ru country=RU locale=ru_RU.UTF-8 keyboard-configuration/xkb-keymap=ru" diff --git a/deploy/iso/preseed.cfg b/deploy/iso/preseed.cfg index 17f2ae0..eec1a3e 100644 --- a/deploy/iso/preseed.cfg +++ b/deploy/iso/preseed.cfg @@ -1,7 +1,8 @@ #_preseed_V1 # Ответы установщику Debian 13 для образа Bridge&Join (deploy/iso/build-iso.sh). # Спрашиваются только пароли, диск (если их несколько) и подтверждение стирания; остальное задано здесь. -# После установки служба bj-firstboot на экране сервера спросит ключ продукта и установит bj. +# После установки служба bj-firstboot на экране сервера спросит ключ продукта (или установит ядро с образа — +# ключ тогда вводится в браузере); bj-welcome показывает адрес веб-входа на экране входа в консоль. # Язык, клавиатура, время d-i debian-installer/language string ru @@ -69,6 +70,8 @@ d-i preseed/late_command string \ install -m 0644 /cdrom/bj/bj-firstboot.service /target/etc/systemd/system/bj-firstboot.service; \ mkdir -p /target/etc/bj; \ if [ -f /cdrom/bj/channel ]; then install -m 0644 /cdrom/bj/channel /target/etc/bj/channel; fi; \ + if [ -d /cdrom/bj/bundle ]; then mkdir -p /target/var/lib/bj/bundle && cp -r /cdrom/bj/bundle/. /target/var/lib/bj/bundle/; fi; \ + if [ -f /cdrom/bj/bj-welcome ]; then install -m 0755 /cdrom/bj/bj-welcome /target/usr/local/sbin/bj-welcome; install -m 0644 /cdrom/bj/bj-welcome.service /target/etc/systemd/system/; install -m 0644 /cdrom/bj/bj-welcome.timer /target/etc/systemd/system/; in-target systemctl enable bj-welcome.service bj-welcome.timer; fi; \ in-target systemctl enable bj-firstboot.service d-i finish-install/reboot_in_progress note diff --git a/deploy/linux/sbin/bj-welcome b/deploy/linux/sbin/bj-welcome new file mode 100755 index 0000000..affab2b --- /dev/null +++ b/deploy/linux/sbin/bj-welcome @@ -0,0 +1,66 @@ +#!/bin/sh +# bj-welcome — приветственный экран на консоли до входа (/etc/issue.d/10-bj.issue): +# адреса веб-входа и состояние bj. Вызывается службой bj-welcome при загрузке и по таймеру, +# а также установщиком после шагов, меняющих состояние. Читает только локальные файлы. +set -u +ISSUE=/etc/issue.d/10-bj.issue +BJ=/opt/bj/bj-server +SETUP=/var/lib/bj/.bj/setup.json +USERS=/var/lib/bj/.bj/users.json +FIRSTRUN=/var/lib/bj/.bj/first-run.json +PORT=8080 +HOST=$(hostname 2>/dev/null || echo bj) +ADDRS=$(ip -4 -o addr show scope global 2>/dev/null | awk '{split($4,a,"/"); print a[1]}' | tr '\n' ' ') +[ -z "$ADDRS" ] && ADDRS="<адрес не получен: проверьте сеть>" +url() { for a in $ADDRS; do case "$a" in \<*) printf ' http://%s:%s%s\n' "" "$PORT" "$1";; *) printf ' http://%s:%s%s\n' "$a" "$PORT" "$1";; esac; done; } +lic="" +if [ -r "$SETUP" ]; then + lic=$(python3 - "$SETUP" 2>/dev/null <<'PY' +import json,sys +try: + d=json.load(open(sys.argv[1])); l=d.get("license",{}) + print("да" if l.get("key") else "нет", l.get("last_msg","")[:60]) +except Exception: print("") +PY +) +fi +{ + printf '\n ┌────────────────────────────────────────────────────────────────┐\n' + printf ' │ Bridge&Join (bj) — шлюз переводов ценных бумаг M2M / НРД │\n' + printf ' └────────────────────────────────────────────────────────────────┘\n' + printf ' Сервер: %s Адреса: %s\n\n' "$HOST" "$ADDRS" + if [ ! -x "$BJ" ]; then + printf ' Состояние: bj НЕ УСТАНОВЛЕН.\n' + if [ -f /var/lib/bj/bundle/manifest.json ]; then + printf ' Установка с образа (ключ продукта — потом, в браузере):\n sudo bj-installer install --bundle /var/lib/bj/bundle\n' + printf ' Или с ключом продукта: sudo bj-installer install --code BJ-XXXX-XXXX-XXXX\n' + else + printf ' Установка: sudo bj-installer install --code BJ-XXXX-XXXX-XXXX (ключ — в портале клиента)\n' + fi + elif ! grep -q '"login"' "$USERS" 2>/dev/null; then + printf ' Состояние: bj установлен, ПЕРВИЧНАЯ НАСТРОЙКА НЕ ВЫПОЛНЕНА.\n' + printf ' Откройте в браузере на своём компьютере:\n'; url /first-run + if [ -f "$FIRSTRUN" ]; then + printf ' Введите одноразовый код, показанный установщиком. Код потерян или истёк —\n новый код: sudo bj-installer first-run-code\n' + else + printf ' Нужен одноразовый код: sudo bj-installer first-run-code\n' + fi + case "$lic" in да*) ;; *) printf ' Ключ продукта ещё не введён — его спросит первая страница.\n';; esac + else + printf ' Состояние: bj работает. Вход в веб-интерфейс:\n'; url /admin + case "$lic" in + да*) printf ' Лицензия: %s\n' "${lic#да }";; + *) printf ' Лицензия НЕ АКТИВИРОВАНА: введите ключ продукта в «Настройка → Лицензия».\n';; + esac + fi + printf '\n Вход в консоль сервера — учётной записью ОС (для обслуживания). Веб-вход — по адресу выше.\n\n' +} > "$ISSUE.tmp" +if ! cmp -s "$ISSUE.tmp" "$ISSUE" 2>/dev/null; then + mv -f "$ISSUE.tmp" "$ISSUE" + # обновить приглашение на tty1, если там никто не вошёл + if command -v systemctl >/dev/null && ! who 2>/dev/null | grep -qE '^\S+\s+tty1\b'; then + systemctl try-restart getty@tty1.service 2>/dev/null || true + fi +else + rm -f "$ISSUE.tmp" +fi diff --git a/deploy/systemd/bj-welcome.service b/deploy/systemd/bj-welcome.service new file mode 100644 index 0000000..7a78a4d --- /dev/null +++ b/deploy/systemd/bj-welcome.service @@ -0,0 +1,12 @@ +[Unit] +Description=Bridge&Join: приветственный экран на консоли (адрес веб-входа и состояние) +After=network-online.target +Wants=network-online.target +Before=getty@tty1.service + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/bj-welcome + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/bj-welcome.timer b/deploy/systemd/bj-welcome.timer new file mode 100644 index 0000000..a92403d --- /dev/null +++ b/deploy/systemd/bj-welcome.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Bridge&Join: обновление приветственного экрана (адреса, состояние) + +[Timer] +OnBootSec=1min +OnUnitActiveSec=2min +AccuracySec=30s + +[Install] +WantedBy=timers.target diff --git a/internal/lkgateway/firstrun.go b/internal/lkgateway/firstrun.go index 4dc6d4a..7c605b6 100644 --- a/internal/lkgateway/firstrun.go +++ b/internal/lkgateway/firstrun.go @@ -7,10 +7,12 @@ package lkgateway // (открытый режим до создания первого пользователя). import ( + "context" "encoding/json" "html/template" "log" "net/http" + "net/url" "os" "strings" "time" @@ -36,6 +38,8 @@ func firstRunPending() bool { type firstRunData struct { Error, Login, Name string + NeedKey bool // лицензия ещё не активирована — предложить ввести ключ продукта здесь + ProductKey string } var firstRunTpl = template.Must(template.New("first-run").Parse(firstRunHTML)) @@ -47,11 +51,13 @@ func registerFirstRun(mux *http.ServeMux) { return } var d firstRunData + d.NeedKey = globalRC != nil && !licenseStatus(globalRC).Valid w.Header().Set("Cache-Control", "no-store") if r.Method == http.MethodPost { ip := clientIP(r) key := "first-run|" + ip d.Login, d.Name = strings.TrimSpace(r.FormValue("login")), strings.TrimSpace(r.FormValue("name")) + d.ProductKey = strings.ToUpper(strings.NewReplacer(" ", "").Replace(strings.TrimSpace(r.FormValue("product_key")))) pw, pw2 := r.FormValue("password"), r.FormValue("password2") code := strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(r.FormValue("code"))) var st firstRunState @@ -88,6 +94,23 @@ func registerFirstRun(mux *http.ServeMux) { s := globalSessions.New(u.Login, ip, r.UserAgent()) http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: s.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, MaxAge: int(sessionMaxLife.Seconds())}) log.Printf("auth: первичная настройка: создан администратор %s с %s", u.Login, ip) + // ключ продукта, если его не вводили на сервере: активируем сразу, чтобы модули и обновления заработали + if d.NeedKey && d.ProductKey != "" && globalRC != nil { + actx, acancel := context.WithTimeout(r.Context(), 30*time.Second) + err := LicenseActivate(actx, globalRC, d.ProductKey) + acancel() + if err != nil { + log.Printf("auth: первичная настройка: ключ продукта не принят: %v", err) + http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape("Лицензия: ключ продукта не принят — "+err.Error()+". Введите его в разделе «Лицензия».")+"#sec-license", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape("Лицензия активирована. Дальше — подключение к базе DEPO, затем реквизиты НРД (кнопка «Определить из DEPO»)."), http.StatusSeeOther) + return + } + if d.NeedKey { + http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape("Администратор создан. Лицензия ещё не активирована — введите ключ продукта в разделе «Лицензия», иначе модули и обновления недоступны.")+"#sec-license", http.StatusSeeOther) + return + } http.Redirect(w, r, "/admin/setup", http.StatusSeeOther) return } @@ -123,6 +146,7 @@ code{font-size:12px} +{{if .NeedKey}}{{end}} {{if .Error}}
{{.Error}}
{{end}}
Код потерян или истёк: на сервере выполните sudo bj-installer first-run-code.