feat(install): консольный установщик bj и первичная настройка по одноразовому коду
- cmd/bj-installer (вместо веб-мастера): проверка Debian 13 / Astra, пакеты, пользователь и каталоги, активация лицензии ключом продукта, PostgreSQL и миграции m2m_core, компоненты по лицензии с проверкой подписи, root-скрипты и службы, одноразовый код и итоговый экран с адресом http://<сервер>:8080/first-run; проверка в песочнице (--steps, --root) - bj: /first-run — пока нет пользователей и установщик оставил код, по коду создаётся первый администратор (прежние установки без файла работают как раньше) - internal/release/install.go — общая установка компонентов (bj-component-install и установщик); bj-component-install стал компонентом ядра - открытые ключи вынесены в internal/license и internal/release; миграции и файлы развёртывания встроены (migrations/embed.go, deploy/embed.go); unit bj-server исправлен - scripts/build-release.sh: сборка с версиями, подпись, публикация на сервер обновлений Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
package license
|
||||
|
||||
// DefaultPublicKey — открытый ключ проверки лицензий Bridge&Join (закрытый — на сервере лицензий,
|
||||
// /etc/bj-license/license.priv). Общий для bj-server и установщика.
|
||||
const DefaultPublicKey = "UuvEYFxxzOFk59FjCQTdxKuzkNAJpJyWTuUdlCYfsdI="
|
||||
@@ -38,14 +38,14 @@ const (
|
||||
// License — содержимое лицензии (подписывается целиком).
|
||||
type License struct {
|
||||
Schema int `json:"schema"`
|
||||
ID string `json:"id"` // UUID лицензии
|
||||
Tenant string `json:"tenant"` // организация-клиент
|
||||
Product string `json:"product"` // "bj-server"
|
||||
Plan Plan `json:"plan"` // free|pro|enterprise
|
||||
IssuedAt time.Time `json:"issued_at"` // дата выпуска
|
||||
ExpiresAt time.Time `json:"expires_at"` // дата окончания (годовой ключ)
|
||||
Features []string `json:"features,omitempty"` // "updates","web-cabinet",...
|
||||
MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита)
|
||||
ID string `json:"id"` // UUID лицензии
|
||||
Tenant string `json:"tenant"` // организация-клиент
|
||||
Product string `json:"product"` // "bj-server"
|
||||
Plan Plan `json:"plan"` // free|pro|enterprise
|
||||
IssuedAt time.Time `json:"issued_at"` // дата выпуска
|
||||
ExpiresAt time.Time `json:"expires_at"` // дата окончания (годовой ключ)
|
||||
Features []string `json:"features,omitempty"` // "updates","web-cabinet",...
|
||||
MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита)
|
||||
Note string `json:"note,omitempty"`
|
||||
|
||||
// Онлайн-лицензирование (lic.zetit.ru, docs/license-server-api.md): токен выдаётся
|
||||
|
||||
@@ -490,7 +490,7 @@ type AuthSettings struct {
|
||||
|
||||
// Перепроверка групп доменных пользователей (adrecheck.go): поиск от имени
|
||||
// сервисной учётной записи только на чтение. Пусто — роль меняется только при входе.
|
||||
ADBindUser string `json:"ad_bind_user"` // svc-bj@corp.example.ru, CORP\svc-bj или DN
|
||||
ADBindUser string `json:"ad_bind_user"` // svc-bj@corp.example.ru, CORP\svc-bj или DN
|
||||
ADBindPassword string `json:"ad_bind_password"`
|
||||
ADRecheckMinutes int `json:"ad_recheck_minutes"` // 0 = 15
|
||||
}
|
||||
@@ -805,7 +805,7 @@ func currentUser(w http.ResponseWriter) *User {
|
||||
// authExempt — пути без входа: API модулей (свой ключ/токен), health, статика.
|
||||
func authExempt(p string) bool {
|
||||
switch {
|
||||
case p == "/login" || p == "/logout" || p == "/healthz":
|
||||
case p == "/login" || p == "/logout" || p == "/healthz" || p == "/first-run":
|
||||
return true
|
||||
case strings.HasPrefix(p, "/api/"):
|
||||
return true
|
||||
@@ -857,6 +857,14 @@ func authMiddleware(rc *RuntimeConfig, next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if firstRunPending() { // свежая установка: сначала администратор по одноразовому коду
|
||||
if isAjax(r) {
|
||||
http.Error(w, "требуется первичная настройка", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/first-run", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if authOpen() {
|
||||
next.ServeHTTP(&authRW{ResponseWriter: w, user: nil}, r)
|
||||
return
|
||||
@@ -924,9 +932,14 @@ type loginData struct {
|
||||
var loginTpl = template.Must(template.New("login").Parse(loginHTML))
|
||||
|
||||
func registerAuth(mux *http.ServeMux, rc *RuntimeConfig) {
|
||||
registerFirstRun(mux)
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
a := rc.Snapshot().Auth
|
||||
next := safeReturnPath(r.FormValue("next"))
|
||||
if firstRunPending() {
|
||||
http.Redirect(w, r, "/first-run", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if authOpen() {
|
||||
http.Redirect(w, r, "/admin/setup#users", http.StatusSeeOther)
|
||||
return
|
||||
@@ -978,10 +991,10 @@ func registerAuth(mux *http.ServeMux, rc *RuntimeConfig) {
|
||||
// ---------- обработчики настроек ----------
|
||||
|
||||
type UsersView struct {
|
||||
Users []*User
|
||||
Roles []roleDef // доступные при текущей лицензии
|
||||
Hidden []struct{ Title, Missing string } // роли, недоступные без модулей
|
||||
Modules []struct{ Key, Title string } // подключённые модули
|
||||
Users []*User
|
||||
Roles []roleDef // доступные при текущей лицензии
|
||||
Hidden []struct{ Title, Missing string } // роли, недоступные без модулей
|
||||
Modules []struct{ Key, Title string } // подключённые модули
|
||||
Sessions []*Session
|
||||
Open bool
|
||||
Me *User
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package lkgateway
|
||||
|
||||
// Первичная настройка после установки. Пока в bj нет ни одного пользователя, установщик
|
||||
// (cmd/bj-installer) оставляет файл с хешем одноразового кода, который показан на экране сервера.
|
||||
// Все страницы, кроме этой, перенаправляются сюда: по коду создаётся первый администратор, файл
|
||||
// удаляется, дальше обычный вход и раздел «Настройка». Установки без файла работают как раньше
|
||||
// (открытый режим до создания первого пользователя).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const firstRunFile = "/var/lib/bj/.bj/first-run.json"
|
||||
|
||||
type firstRunState struct {
|
||||
CodeHash string `json:"code_hash"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// firstRunPending — ждём первичной настройки: пользователей нет, установщик оставил код.
|
||||
func firstRunPending() bool {
|
||||
if globalUsers.Count() > 0 {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(firstRunFile)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
type firstRunData struct {
|
||||
Error, Login, Name string
|
||||
}
|
||||
|
||||
var firstRunTpl = template.Must(template.New("first-run").Parse(firstRunHTML))
|
||||
|
||||
func registerFirstRun(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/first-run", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !firstRunPending() {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var d firstRunData
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method == http.MethodPost {
|
||||
ip := clientIP(r)
|
||||
key := "first-run|" + ip
|
||||
d.Login, d.Name = strings.TrimSpace(r.FormValue("login")), strings.TrimSpace(r.FormValue("name"))
|
||||
pw, pw2 := r.FormValue("password"), r.FormValue("password2")
|
||||
code := strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(r.FormValue("code")))
|
||||
var st firstRunState
|
||||
raw, readErr := os.ReadFile(firstRunFile)
|
||||
if readErr == nil {
|
||||
_ = json.Unmarshal(raw, &st)
|
||||
}
|
||||
switch {
|
||||
case loginFailCount(key) >= 8:
|
||||
d.Error = "Слишком много неудачных попыток. Подождите 15 минут."
|
||||
case readErr != nil || st.CodeHash == "" || bcrypt.CompareHashAndPassword([]byte(st.CodeHash), []byte(code)) != nil:
|
||||
loginFailAdd(key)
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
log.Printf("auth: первичная настройка: неверный код с %s", ip)
|
||||
d.Error = "Неверный одноразовый код"
|
||||
case d.Login == "" || strings.ContainsAny(d.Login, " /\\@\"'<>"):
|
||||
d.Error = "Логин пустой или содержит недопустимые символы"
|
||||
case len(pw) < 10 || pw != pw2:
|
||||
d.Error = "Пароль не короче 10 символов и должен совпадать с повтором"
|
||||
default:
|
||||
hash, err := hashPassword(pw)
|
||||
if err != nil {
|
||||
d.Error = err.Error()
|
||||
break
|
||||
}
|
||||
now := time.Now()
|
||||
u := &User{Login: d.Login, Name: d.Name, Role: "admin", Source: "local", PassHash: hash, Active: true, CreatedAt: now, LastLogin: now}
|
||||
if err := globalUsers.Put(u); err != nil {
|
||||
d.Error = "Не удалось сохранить пользователя: " + err.Error()
|
||||
break
|
||||
}
|
||||
_ = os.Remove(firstRunFile)
|
||||
loginFailReset(key)
|
||||
s := globalSessions.New(u.Login, ip, r.UserAgent())
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: s.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, MaxAge: int(sessionMaxLife.Seconds())})
|
||||
log.Printf("auth: первичная настройка: создан администратор %s с %s", u.Login, ip)
|
||||
http.Redirect(w, r, "/admin/setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
_ = firstRunTpl.Execute(w, d)
|
||||
})
|
||||
}
|
||||
|
||||
const firstRunHTML = `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Первичная настройка · Bridge&Join</title>
|
||||
<style>
|
||||
:root{--bg:#f4f5f9;--card:#fff;--text:#1a1d26;--muted:#6b7280;--accent:#3b4fd8;--err:#c62828;--border:#e3e5ee}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#141721;--card:#1d2130;--text:#e6e8f0;--muted:#9aa0b3;--accent:#7c8cff;--border:#2b3044}}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px}
|
||||
.box{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:28px 30px;width:100%;max-width:420px;box-shadow:0 12px 40px rgba(0,0,0,.08)}
|
||||
.brand{display:flex;align-items:center;gap:10px;font-size:20px;font-weight:700;margin-bottom:4px}.brand em{font-style:normal;color:var(--accent)}
|
||||
.sub{color:var(--muted);font-size:12.5px;margin-bottom:14px}
|
||||
label{display:block;font-size:12px;color:var(--muted);margin:10px 0 4px}
|
||||
input{width:100%;box-sizing:border-box;padding:9px 11px;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--text);font-size:14px}
|
||||
input:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent}
|
||||
input.code{font-size:20px;letter-spacing:.12em;text-align:center}
|
||||
button{margin-top:16px;width:100%;padding:10px;border:0;border-radius:8px;background:var(--accent);color:#fff;font-weight:600;font-size:14px;cursor:pointer}
|
||||
.err{background:rgba(198,40,40,.1);color:var(--err);padding:8px 10px;border-radius:8px;font-size:13px;margin-top:12px}
|
||||
.hint{color:var(--muted);font-size:12px;margin-top:12px}
|
||||
code{font-size:12px}
|
||||
</style></head><body><div class="box">
|
||||
<div class="brand"><svg width="26" height="26" viewBox="0 0 26 26" fill="none"><path d="M5.5 16 C 9 8.5, 17 8.5, 20.5 16" stroke="#3b4fd8" stroke-width="1.9" stroke-linecap="round"/><circle cx="5.5" cy="16" r="3.1" fill="#3b4fd8"/><circle cx="20.5" cy="16" r="3.1" fill="#e8562a"/></svg><span>Bridge<em>&Join</em></span></div>
|
||||
<div class="sub">Первичная настройка</div>
|
||||
<p class="hint" style="margin-top:0">Введите одноразовый код, который установщик показал на экране сервера, и создайте учётную запись администратора. Остальные настройки откроются сразу после этого.</p>
|
||||
<form method="post" action="/first-run">
|
||||
<label>Одноразовый код</label><input class="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="0000-0000" autofocus required>
|
||||
<label>Логин администратора</label><input type="text" name="login" value="{{.Login}}" autocomplete="username" required>
|
||||
<label>Имя</label><input type="text" name="name" value="{{.Name}}" autocomplete="name">
|
||||
<label>Пароль, не короче 10 символов</label><input type="password" name="password" minlength="10" autocomplete="new-password" required>
|
||||
<label>Повтор пароля</label><input type="password" name="password2" minlength="10" autocomplete="new-password" required>
|
||||
<button type="submit">Создать администратора</button>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
<div class="hint">Код потерян или истёк: на сервере выполните <code>sudo bj-installer first-run-code</code>.</div>
|
||||
</form>
|
||||
</div></body></html>`
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// DefaultLicensePublicKey — открытый ключ проверки лицензий: пара к ключу подписи сервера
|
||||
// лицензий lic.zetit.ru. Зашит в bj и в настройках не меняется, поэтому ключ, подписанный
|
||||
// чем-то другим, не принимается. var — только для подмены в тестах.
|
||||
var DefaultLicensePublicKey = "UuvEYFxxzOFk59FjCQTdxKuzkNAJpJyWTuUdlCYfsdI="
|
||||
var DefaultLicensePublicKey = license.DefaultPublicKey
|
||||
|
||||
// LicenseStatus — сводка состояния лицензии для UI и гейтов.
|
||||
type LicenseStatus struct {
|
||||
|
||||
@@ -30,7 +30,7 @@ var BuildVersion = "0.1.0"
|
||||
// Открытая часть ключа подписи релизов. Если задан в
|
||||
// настройках (UpdateSettings.PublicKey) — приоритет у настроек (для проверки
|
||||
// в bj; root-установщик верит только /etc/bj/update.pub).
|
||||
var DefaultUpdatePublicKey = "GHC/gJdQOAEVuBmq8pRpfG+CoIwtcjpNlSf6bPFtr/Y=" // ~/keys/release-signing.pub (закрытый ключ хранится вне сервера лицензий)
|
||||
var DefaultUpdatePublicKey = release.DefaultPublicKey
|
||||
|
||||
const updateStagingDir = "/var/lib/bj/update-staging"
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ type Component struct {
|
||||
// Components — все компоненты bj. Порядок — порядок показа и установки (ядро перезапускается последним).
|
||||
var Components = []Component{
|
||||
{Name: "bj-server", Title: "Ядро bj: переводы M2M, DEPO, QUIK МНП, ЛК ЕСИА", Path: "/opt/bj/bj-server", Unit: "bj-server", Exec: true, Core: true, Feature: "updates"},
|
||||
{Name: "bj-component-install", Title: "Установщик обновлений", Path: "/usr/local/sbin/bj-component-install", Exec: true, Core: true, Feature: "updates"},
|
||||
{Name: "crypto-service.jar", Title: "Криптосервис", Path: "/opt/bj/crypto-service.jar", Unit: "bj-crypto", Feature: "updates"},
|
||||
{Name: "bj-lk", Title: "Личный кабинет клиента", Path: "/opt/bj/bj-lk", Unit: "bj-lk", Exec: true, Feature: "web-cabinet"},
|
||||
{Name: "bj-npr", Title: "Риск-монитор НПР / УДС", Path: "/opt/bj/bj-npr", Unit: "bj-npr", Exec: true, Feature: "npr"},
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
package release
|
||||
|
||||
// Установка компонентов из каталога подготовки. Общая для root-установщика обновлений
|
||||
// (cmd/bj-component-install) и установщика bj (cmd/bj-installer). Ничему из каталога не доверяет:
|
||||
// проверяет подпись манифеста и sha256 файлов, ставит только известные компоненты по их путям,
|
||||
// не откатывает версию, сохраняет прежний файл (<путь>.bak-ДАТА, пять последних).
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StagingBase — каталог подготовки обновлений на сервере bj.
|
||||
const StagingBase = "/var/lib/bj/update-staging"
|
||||
|
||||
// InstallOptions — как ставить.
|
||||
type InstallOptions struct {
|
||||
Root string // корень установки ("" — настоящий; иначе проверка в песочнице)
|
||||
PubKey ed25519.PublicKey // открытый ключ подписи релизов
|
||||
Restart bool // перезапустить службы установленных компонентов (ядро последним)
|
||||
AllowNew bool // ставить модули, которых ещё нет на сервере (первичная установка)
|
||||
}
|
||||
|
||||
// InstallResult — итог установки (JSON для bj-server).
|
||||
type InstallResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Release string `json:"release,omitempty"`
|
||||
Installed []map[string]string `json:"installed,omitempty"`
|
||||
Restarted []string `json:"restarted,omitempty"`
|
||||
}
|
||||
|
||||
// InstallStaged — установить компоненты из каталога подготовки: manifest.json (подписанный),
|
||||
// request.json {"names": [...]} и файлы компонентов.
|
||||
func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
|
||||
fail := func(format string, a ...any) InstallResult {
|
||||
res.OK, res.Error = false, fmt.Sprintf(format, a...)
|
||||
return res
|
||||
}
|
||||
if staging == "" {
|
||||
return fail("не указан каталог подготовки")
|
||||
}
|
||||
staging = filepath.Clean(staging)
|
||||
if opt.Root == "" && !strings.HasPrefix(staging, StagingBase+"/") {
|
||||
return fail("каталог подготовки должен быть внутри %s", StagingBase)
|
||||
}
|
||||
if len(opt.PubKey) != ed25519.PublicKeySize {
|
||||
return fail("не задан открытый ключ подписи релизов")
|
||||
}
|
||||
var sm SignedManifest
|
||||
if err := readStagedJSON(staging, "manifest.json", &sm); err != nil {
|
||||
return fail("манифест: %v", err)
|
||||
}
|
||||
m, err := Verify(&sm, opt.PubKey)
|
||||
if err != nil {
|
||||
return fail("подпись манифеста не прошла проверку: %v", err)
|
||||
}
|
||||
res.Release = m.Version
|
||||
var req struct {
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
if err := readStagedJSON(staging, "request.json", &req); err != nil {
|
||||
return fail("запрос: %v", err)
|
||||
}
|
||||
if len(req.Names) == 0 {
|
||||
return fail("не выбраны компоненты")
|
||||
}
|
||||
installedPath := InstalledFile
|
||||
if opt.Root != "" {
|
||||
installedPath = filepath.Join(opt.Root, installedPath)
|
||||
}
|
||||
installed := LoadInstalled(installedPath)
|
||||
|
||||
// Все проверки до любой установки: всё или ничего.
|
||||
type job struct {
|
||||
c Component
|
||||
a Artifact
|
||||
file string
|
||||
}
|
||||
var jobs []job
|
||||
for _, name := range req.Names {
|
||||
c, ok := ComponentByName(name)
|
||||
if !ok {
|
||||
return fail("неизвестный компонент %q", name)
|
||||
}
|
||||
var art *Artifact
|
||||
for i := range m.Artifacts {
|
||||
if m.Artifacts[i].Name == name {
|
||||
art = &m.Artifacts[i]
|
||||
}
|
||||
}
|
||||
if art == nil {
|
||||
return fail("компонента %s нет в релизе %s", name, m.Version)
|
||||
}
|
||||
if art.Version == "" {
|
||||
art.Version = m.Version
|
||||
}
|
||||
if _, err := os.Stat(c.PathAt(opt.Root)); err != nil && !c.Core && !opt.AllowNew {
|
||||
return fail("%s не установлен на этом сервере: модуль ставится установщиком, а не обновлением", name)
|
||||
}
|
||||
if prev, ok := installed[name]; ok && prev.Version != "" && IsNewer(prev.Version, art.Version) {
|
||||
return fail("%s: установлена %s, в релизе более старая %s, откат не выполняется", name, prev.Version, art.Version)
|
||||
}
|
||||
file := filepath.Join(staging, filepath.Base(art.File))
|
||||
fi, err := os.Lstat(file)
|
||||
if err != nil || !fi.Mode().IsRegular() {
|
||||
return fail("%s: файл %s не найден в каталоге подготовки", name, art.File)
|
||||
}
|
||||
if err := VerifyArtifact(file, *art); err != nil {
|
||||
return fail("%s: %v", name, err)
|
||||
}
|
||||
jobs = append(jobs, job{c: c, a: *art, file: file})
|
||||
}
|
||||
sort.SliceStable(jobs, func(i, j int) bool { return componentOrder(jobs[i].c.Name) < componentOrder(jobs[j].c.Name) })
|
||||
|
||||
ts := time.Now().Format("20060102-150405")
|
||||
var units []string
|
||||
for _, j := range jobs {
|
||||
if err := installFile(j.file, j.c.PathAt(opt.Root), j.c.Exec, ts); err != nil {
|
||||
return fail("установка %s: %v", j.c.Name, err)
|
||||
}
|
||||
installed[j.c.Name] = Installed{Version: j.a.Version, SHA256: j.a.SHA256, InstalledAt: time.Now().UTC()}
|
||||
res.Installed = append(res.Installed, map[string]string{"name": j.c.Name, "version": j.a.Version})
|
||||
if j.c.Unit != "" {
|
||||
units = append(units, j.c.Unit)
|
||||
}
|
||||
}
|
||||
if err := SaveInstalled(installedPath, installed); err != nil {
|
||||
return fail("запись установленных версий: %v", err)
|
||||
}
|
||||
chownLikeParent(installedPath)
|
||||
|
||||
if opt.Restart {
|
||||
sort.SliceStable(units, func(i, j int) bool { return units[j] == "bj-server" && units[i] != "bj-server" })
|
||||
for _, u := range units {
|
||||
if out, err := exec.Command("systemctl", "restart", u).CombinedOutput(); err != nil {
|
||||
return fail("перезапуск %s: %v: %s", u, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
res.Restarted = append(res.Restarted, u)
|
||||
}
|
||||
}
|
||||
res.OK = true
|
||||
return res
|
||||
}
|
||||
|
||||
func componentOrder(name string) int {
|
||||
for i, c := range Components {
|
||||
if c.Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(Components)
|
||||
}
|
||||
|
||||
func readStagedJSON(dir, name string, v any) error {
|
||||
p := filepath.Join(dir, name)
|
||||
fi, err := os.Lstat(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.Mode().IsRegular() || fi.Size() > 1<<20 {
|
||||
return errors.New(name + ": не обычный файл или слишком большой")
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, v)
|
||||
}
|
||||
|
||||
// installFile — копия прежнего файла, запись нового рядом и атомарная замена.
|
||||
func installFile(src, dst string, execBit bool, ts string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
if err := copyFileMode(dst, dst+".bak-"+ts, 0o755); err != nil {
|
||||
return fmt.Errorf("копия прежнего файла: %w", err)
|
||||
}
|
||||
pruneBackups(dst, 5)
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if execBit {
|
||||
mode = 0o755
|
||||
}
|
||||
tmp := dst + ".new"
|
||||
if err := copyFileMode(src, tmp, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, dst)
|
||||
}
|
||||
|
||||
func copyFileMode(src, dst string, mode os.FileMode) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(dst, mode)
|
||||
}
|
||||
|
||||
// pruneBackups — хранить keep последних копий <путь>.bak-ДАТА-ВРЕМЯ.
|
||||
func pruneBackups(dst string, keep int) {
|
||||
files, _ := filepath.Glob(dst + ".bak-2*")
|
||||
sort.Strings(files)
|
||||
for len(files) > keep {
|
||||
_ = os.Remove(files[0])
|
||||
files = files[1:]
|
||||
}
|
||||
}
|
||||
|
||||
func chownLikeParent(path string) {
|
||||
if fi, err := os.Stat(filepath.Dir(path)); err == nil {
|
||||
if s, ok := fi.Sys().(*syscall.Stat_t); ok {
|
||||
_ = os.Chown(path, int(s.Uid), int(s.Gid))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package release
|
||||
|
||||
// DefaultPublicKey — открытый ключ подписи релизов bj. Закрытый хранится вне сервера лицензий
|
||||
// (~/keys/release-signing.priv на рабочей машине издателя). Общий для bj-server, установщика и
|
||||
// root-установщика компонентов (на серверах клиента копия лежит в /etc/bj/update.pub).
|
||||
const DefaultPublicKey = "GHC/gJdQOAEVuBmq8pRpfG+CoIwtcjpNlSf6bPFtr/Y="
|
||||
Reference in New Issue
Block a user