package lkgateway // Обновления bj по компонентам (internal/release/components.go). У каждого компонента своя версия в // манифесте артефактории: ядро, криптосервис, ЛК клиента, НПР, экспорт QUIK обновляются независимо. // // bj-server работает без прав root, поэтому сам только проверяет манифест, скачивает выбранные // файлы в /var/lib/bj/update-staging и передаёт установку root-воркеру (действие // component-install → /usr/local/sbin/bj-component-install). Установщик заново проверяет подпись и // sha256, ставит файлы и перезапускает службы; при обновлении ядра bj-server перезапустится сам. import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "os" "path/filepath" "strings" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" ) // BuildVersion — версия bj-server. Переопределяется при сборке: // // go build -ldflags "-X .../lkgateway.BuildVersion=1.2.0" var BuildVersion = "0.1.0" // DefaultUpdatePublicKey — публичный ключ артефактории, зашитый в релиз. // Открытая часть ключа подписи релизов. Если задан в // настройках (UpdateSettings.PublicKey) — приоритет у настроек (для проверки // в bj; root-установщик верит только /etc/bj/update.pub). var DefaultUpdatePublicKey = release.DefaultPublicKey const updateStagingDir = "/var/lib/bj/update-staging" // DefaultUpdateURL — сервер обновлений по умолчанию (встроен в сервер лицензий). const DefaultUpdateURL = "https://lic.zetit.ru/updates" type Updater struct{ rc *RuntimeConfig } func NewUpdater(rc *RuntimeConfig) *Updater { return &Updater{rc: rc} } type UpdateStatus struct { Configured bool CurrentVersion string Available string // версия релиза HasUpdate bool // есть что обновить хотя бы в одном компоненте Channel string Notes string Message string Components []release.ComponentState } func (u *Updater) updateClient() (*release.Client, error) { cfg := u.rc.Snapshot().Update pub := cfg.PublicKey if pub == "" { pub = DefaultUpdatePublicKey } base := cfg.BaseURL if base == "" { base = DefaultUpdateURL } if pub == "" { return nil, fmt.Errorf("обновления не настроены: в этой сборке нет открытого ключа подписи релизов") } channel := cfg.Channel if channel == "" { channel = "stable" } cl, err := release.NewClient(base, channel, pub) if err != nil { return nil, err } cl.Token = strings.TrimSpace(u.rc.Snapshot().License.Key) // лицензионный токен установки return cl, nil } // CheckForUpdate — манифест и состояние каждого компонента. func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) { st := UpdateStatus{CurrentVersion: BuildVersion, Channel: u.rc.Snapshot().Update.Channel} cl, err := u.updateClient() if err != nil { st.Message = err.Error() return st, nil // не настроено — не ошибка } st.Configured = true m, err := cl.FetchManifest(ctx) if err != nil { st.Message = "проверка не удалась: " + err.Error() u.saveCheckResult(st, nil) return st, err } st.Available, st.Notes = m.Version, m.Notes syncSameFileVersions(m) st.Components = release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion) release.MarkSplitModules(st.Components, u.allowed) off := markUnlicensed(st.Components, u.allowed) var upd []string avail := map[string]string{} for _, c := range st.Components { if c.Available != "" { avail[c.Name] = c.Available } if c.New { upd = append(upd, c.Name+" "+c.Available+" (установка модуля)") } else if c.Update { upd = append(upd, c.Name+" "+c.Available) } } st.HasUpdate = len(upd) > 0 if st.HasUpdate { st.Message = "доступны обновления: " + strings.Join(upd, ", ") } else { st.Message = "все установленные компоненты актуальны (релиз " + m.Version + ")" } st.Message += unlicensedNote(off) u.saveCheckResult(st, avail) return st, nil } // syncSameFileVersions — если файл компонента на сервере побайтно совпадает с файлом выпуска, а // записана другая версия (компонент не менялся, а номер выпуска вырос, или файл выложен вручную), // записываем версию выпуска: иначе список предлагает «обновить» то, что установщик отклонит как // «обновление не требуется». func syncSameFileVersions(m *release.Manifest) { installed := release.LoadInstalled(release.InstalledFile) changed := false for _, a := range m.Artifacts { c, ok := release.ComponentByName(a.Name) if !ok || a.SHA256 == "" { continue } v := a.Version if v == "" { v = m.Version } if cur, ok := installed[a.Name]; ok && cur.Version == v { continue } sum, err := fileSHA256(c.Path) if err != nil || !strings.EqualFold(sum, a.SHA256) { continue } installed[a.Name] = release.Installed{Version: v, SHA256: sum, InstalledAt: time.Now().UTC()} changed = true } if changed { if err := release.SaveInstalled(release.InstalledFile, installed); err != nil { log.Printf("lk-gateway: запись версий совпадающих компонентов: %v", err) } } } func fileSHA256(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } // markUnlicensed — модули, установленные на сервере, но снятые с лицензии: не обновляются (сервер // обновлений их файлы не отдаёт), в списке — с пояснением. Возвращает названия таких модулей. func markUnlicensed(states []release.ComponentState, allowed func(string) bool) []string { var disabled map[string]string if globalRC != nil { disabled = globalRC.Snapshot().Update.DisabledModules } var off []string for i := range states { c := &states[i] if c.Core || c.Feature == "" || c.Feature == "updates" || allowed(c.Feature) { continue } c.Update, c.New = false, false if _, already := disabled[c.Name]; c.Present && !already { // об отключённом уже сказано один раз c.Blocked = "не входит в лицензию — модуль отключён и не обновляется; чтобы подключить, свяжитесь с ZETIT" off = append(off, "«"+c.Title+"»") } } return off } func unlicensedNote(off []string) string { if len(off) == 0 { return "" } if len(off) == 1 { return "; модуль " + off[0] + " не входит в лицензию — отключён и не обновлялся; чтобы подключить, свяжитесь с ZETIT" } return "; модули " + strings.Join(off, ", ") + " не входят в лицензию — отключены и не обновлялись; чтобы подключить, свяжитесь с ZETIT" } // updateChannel — канал обновлений установки (пусто = stable). func updateChannel(s UpdateSettings) string { if c := strings.TrimSpace(s.Channel); c != "" { return c } return "stable" } func (u *Updater) channel() string { return updateChannel(u.rc.Snapshot().Update) } func (u *Updater) saveCheckResult(st UpdateStatus, avail map[string]string) { cfg := u.rc.Snapshot().Update cfg.LastCheck = time.Now().UTC() cfg.LastResult = st.Message cfg.CheckFailed = avail == nil // nil — манифест получить не удалось cfg.Available = st.Available cfg.Notes = st.Notes if avail != nil { cfg.Components = avail cfg.ComponentsChannel = u.channel() } if err := u.rc.SaveUpdateSettings(cfg); err != nil { log.Printf("lk-gateway: сохранение результата проверки обновления: %v", err) } } // ApplyUpdate — скачать выбранные компоненты (names пусто — все, для которых есть обновление) и // передать установку root-установщику. Если среди них ядро, bj-server перезапустится до ответа. func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, error) { progressBegin(4) progressStage(1, "Проверка выпуска на сервере лицензий") msg, err := u.applyUpdate(ctx, names) progressEnd(msg, err) return msg, err } func (u *Updater) applyUpdate(ctx context.Context, names []string) (string, error) { if licensingEnabled(u.rc) { ls := licenseStatus(u.rc) if !ls.Valid { return "", fmt.Errorf("обновления заблокированы — лицензия: %s", ls.Message) } if !ls.AllowsUpdates { return "", fmt.Errorf("обновления не входят в план %q", ls.Plan) } } cl, err := u.updateClient() if err != nil { return "", err } sm, m, err := cl.FetchSigned(ctx) if err != nil { return "", fmt.Errorf("манифест: %w", err) } syncSameFileVersions(m) plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion) release.MarkSplitModules(plan, u.allowed) off := markUnlicensed(plan, u.allowed) want := map[string]bool{} for _, n := range names { want[n] = true } var selected []release.ComponentState for _, c := range plan { switch { case len(want) > 0 && !want[c.Name]: case c.Update: selected = append(selected, c) case want[c.Name] && c.Blocked != "": return "", fmt.Errorf("%s: %s", c.Title, c.Blocked) case want[c.Name]: return "", fmt.Errorf("%s: обновление не требуется (установлена %s, в релизе %s)", c.Title, pickVersion(c.Installed), pickVersion(c.Available)) } } if len(selected) == 0 { if len(off) > 0 { return "обновлять нечего: компоненты лицензии актуальны (релиз " + m.Version + ")" + unlicensedNote(off), nil } return "", fmt.Errorf("нечего обновлять: все установленные компоненты актуальны (релиз %s)", m.Version) } // Прежний root-установщик не умеет впервые ставить выделенные из ядра модули: пока он сам не // обновлён, такие модули откладываются — их поставит ensureSplitModules после перезапуска. installerOutdated := false for _, c := range plan { if c.Name == "bj-component-install" && c.Update { installerOutdated = true } } var deferred []string if installerOutdated { kept := selected[:0] for _, c := range selected { if c.New || c.Name == "bj-installer" { // bj-installer прежний установщик обновлений не знает deferred = append(deferred, c.Name) continue } kept = append(kept, c) } selected = kept if len(selected) == 0 { return "", fmt.Errorf("сначала обновите установщик обновлений (bj-component-install): текущий не ставит модули %s", strings.Join(deferred, ", ")) } } cleanupStaging(24 * time.Hour) staging := filepath.Join(updateStagingDir, fmt.Sprintf("%d", time.Now().UnixNano())) if err := os.MkdirAll(staging, 0o750); err != nil { return "", fmt.Errorf("каталог подготовки: %w", err) } b, _ := json.Marshal(sm) if err := os.WriteFile(filepath.Join(staging, "manifest.json"), b, 0o640); err != nil { return "", err } var list []string var req struct { Names []string `json:"names"` New []string `json:"new,omitempty"` } var total int64 for _, c := range selected { for _, a := range m.Artifacts { if a.Name == c.Name { total += a.Size } } if c.Name == "bj-server" { progressTarget(c.Available) } } progressNote(strings.TrimPrefix(unlicensedNote(off), "; ")) progressStage(2, "Скачивание файлов") stopWatch := progressWatchDir(staging, total) refused := false for _, c := range selected { skip := false for _, a := range m.Artifacts { if a.Name == c.Name { if _, err := cl.DownloadArtifact(ctx, a, staging); err != nil { // Модуль сняли с лицензии, а bj ещё помнит прежний её состав (узнаёт при продлении): // сервер обновлений файл не отдаёт — пропускаем модуль, остальное ставим. if !c.Core && c.Feature != "updates" && strings.Contains(err.Error(), "HTTP 403") { off = append(off, "«"+c.Title+"»") refused, skip = true, true _ = os.Remove(filepath.Join(staging, filepath.Base(a.File))) continue } stopWatch() return "", fmt.Errorf("скачивание %s: %w", c.Name, err) } } } if skip { continue } req.Names = append(req.Names, c.Name) if c.New { req.New = append(req.New, c.Name) } list = append(list, c.Name+" "+c.Available) } stopWatch() if refused { // состав лицензии устарел — обновляем сразу, чтобы разделы снятых модулей пропали lctx, lcancel := context.WithTimeout(ctx, 30*time.Second) if err := LicenseRenew(lctx, u.rc); err != nil { log.Printf("lk-gateway: продление лицензии после отказа в модуле: %v", err) } lcancel() progressNote(strings.TrimPrefix(unlicensedNote(off), "; ")) if len(req.Names) == 0 { _ = os.RemoveAll(staging) return "обновлять нечего: компоненты лицензии актуальны (релиз " + m.Version + ")" + unlicensedNote(off), nil } } rb, _ := json.Marshal(req) if err := os.WriteFile(filepath.Join(staging, "request.json"), rb, 0o640); err != nil { return "", err } progressStage(3, "Проверка подписи и установка, затем перезапуск служб") log.Printf("lk-gateway: обновление компонентов: %s — передано root-установщику", strings.Join(list, ", ")) wctx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() res, err := runKeymediaOp(wctx, "component-install", map[string]string{"staging": staging}) _ = os.RemoveAll(staging) if err != nil { return "", fmt.Errorf("установка: %w", err) } msg := "установлено: " + strings.Join(list, ", ") if r, ok := res["restarted"].([]any); ok && len(r) > 0 { var units []string for _, x := range r { units = append(units, fmt.Sprint(x)) } msg += "; перезапущены службы: " + strings.Join(units, ", ") } if len(deferred) > 0 { msg += "; модули " + strings.Join(deferred, ", ") + " будут установлены после перезапуска bj-server новым установщиком" } msg += unlicensedNote(off) return msg, nil } // allowed — разрешён ли лицензией признак компонента. func (u *Updater) allowed(feature string) bool { return moduleAllowed(u.rc, feature) } // ensureSplitModules — выделенные из ядра модули, которые входят в лицензию, но ещё не стоят на этом // сервере (например, ядро обновилось прежним установщиком), ставятся сами: без своих служб разделы // этих модулей в bj не работают. Раз за запуск bj-server; ошибки — только в журнал и «Последнюю проверку». func (u *Updater) ensureSplitModules(ctx context.Context) { var names []string installed := release.LoadInstalled(release.InstalledFile) for _, c := range release.Plan("", nil, installed, BuildVersion) { if c.FromCore && !c.Present && u.allowed(c.Feature) { names = append(names, c.Name) } } // bj-installer обновлением ещё не ставился (сервер с образа или обновлён прежним установщиком // обновлений): ставим сразу — с ним приезжают свежие службы и скрипты. Только если на сервере // обновлений он новее, иначе ApplyUpdate вернёт «обновление не требуется» — это не ошибка. if _, ok := installed["bj-installer"]; !ok && licenseStatus(u.rc).AllowsUpdates { if st, err := u.CheckForUpdate(ctx); err == nil { for _, c := range st.Components { if c.Name == "bj-installer" && c.Update { names = append(names, c.Name) } } } } if len(names) == 0 { return } cctx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() msg, err := u.ApplyUpdate(cctx, names) if err != nil { msg = "Установка модулей " + strings.Join(names, ", ") + " не выполнена: " + err.Error() } else { msg = "Установка модулей: " + msg } log.Printf("lk-gateway: %s", msg) cfg := u.rc.Snapshot().Update cfg.LastResult, cfg.LastCheck = msg, time.Now().UTC() _ = u.rc.SaveUpdateSettings(cfg) } // updateComponentRows — компоненты для раздела «Обновления» без обращения к артефактории: // установленное на этом сервере и версии из последней проверки. func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) { rows := release.Plan("", nil, release.LoadInstalled(release.InstalledFile), BuildVersion) set := rc.Snapshot().Update avail := set.Components // Версии из проверки в другом канале к текущему каналу не относятся: по ним // «Обновить» уходило бы в пустоту («релизов в канале нет»). if set.ComponentsChannel != "" && set.ComponentsChannel != updateChannel(set) { avail = nil } var out []release.ComponentState any := false vers := systemVersions(rc) for _, r := range rows { if r.Present && r.Installed == "" { r.Installed = vers.Get(r.Name).Version // выложен вручную — версия со слов самого компонента } r.Available = avail[r.Name] r.Update = r.Available != "" && (r.Present || r.Core) && (r.Installed == "" || release.IsNewer(r.Available, r.Installed)) // Модуль, которого нет на сервере и нет в лицензии, не показываем: поставить его нельзя, // а в списке он выглядит так, будто входит в поставку. // Модуль вне лицензии не показываем вовсе (и установленный: его служба отключена), пока // лицензия действует; без действующей лицензии список как есть. unlicensed := !r.Core && r.Feature != "" && r.Feature != "updates" && licenseStatus(rc).Valid && !moduleAllowed(rc, r.Feature) if !unlicensed && (r.Present || r.Core || (r.Available != "" && moduleAllowed(rc, r.Feature))) { out = append(out, r) } } release.MarkSplitModules(out, func(f string) bool { return moduleAllowed(rc, f) }) markUnlicensed(out, func(f string) bool { return moduleAllowed(rc, f) }) // Последняя проверка не удалась — предлагать установку по старым данным нельзя: // кнопка ушла бы в тот же отказ. Показываем причину и просим проверить заново. if set.CheckFailed { for i := range out { if out[i].Update && out[i].Available != "" { out[i].Blocked = "проверка обновлений не удалась — нажмите «Проверить обновления»" } out[i].Update = false } } for _, r := range out { any = any || r.Update } return out, any } func pickVersion(v string) string { if v == "" { return "неизвестна" } return v } // cleanupStaging — удалить брошенные каталоги подготовки (например, если bj перезапустился при обновлении ядра). func cleanupStaging(olderThan time.Duration) { entries, err := os.ReadDir(updateStagingDir) if err != nil { return } for _, e := range entries { if info, err := e.Info(); err == nil && e.IsDir() && time.Since(info.ModTime()) > olderThan { _ = os.RemoveAll(filepath.Join(updateStagingDir, e.Name())) } } } // updateLoop — фоновая авто-проверка обновлений (если включена). func (u *Updater) updateLoop(ctx context.Context) { ticker := time.NewTicker(6 * time.Hour) defer ticker.Stop() check := func() { if !u.rc.Snapshot().Update.AutoCheck { return } cctx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() if st, err := u.CheckForUpdate(cctx); err == nil && st.HasUpdate { log.Printf("lk-gateway: %s", st.Message) } } // первая проверка через минуту после старта select { case <-ctx.Done(): return case <-time.After(time.Minute): cleanupStaging(time.Hour) enforceModuleLicense(ctx, u.rc) u.ensureSplitModules(ctx) check() } for { select { case <-ctx.Done(): return case <-ticker.C: check() } } } // ApplyBundle — обновление из файла для закрытого контура: пакет с портала клиента // (подписанный манифест + файлы компонентов). Что ставить, решает тот же план, что и при // обновлении по сети: всё, что новее установленного, в порядке компонентов — ядро первым, // модули за ним. Модуль, которому нужно более новое ядро, чем будет после установки, // пропускается с объяснением. func (u *Updater) ApplyBundle(ctx context.Context, zipPath string) (string, error) { progressBegin(4) progressStage(1, "Распаковка и проверка пакета") msg, err := u.applyBundle(ctx, zipPath) progressEnd(msg, err) return msg, err } func (u *Updater) applyBundle(ctx context.Context, zipPath string) (string, error) { defer os.Remove(zipPath) if licensingEnabled(u.rc) { ls := licenseStatus(u.rc) if !ls.Valid { return "", fmt.Errorf("обновления заблокированы — лицензия: %s", ls.Message) } if !ls.AllowsUpdates { return "", fmt.Errorf("обновления не входят в план %q", ls.Plan) } } pubB64 := u.rc.Snapshot().Update.PublicKey if pubB64 == "" { pubB64 = DefaultUpdatePublicKey } pub, err := release.ParsePublicKey(pubB64) if err != nil { return "", fmt.Errorf("открытый ключ подписи релизов: %w", err) } cleanupStaging(24 * time.Hour) staging := filepath.Join(updateStagingDir, fmt.Sprintf("%d", time.Now().UnixNano())) files, err := release.ExtractBundle(zipPath, staging) if err != nil { _ = os.RemoveAll(staging) return "", err } sm, m, err := release.ReadStagedManifest(staging, pub) if err != nil { _ = os.RemoveAll(staging) return "", err } _ = sm have := map[string]bool{} for _, f := range files { have[f] = true } plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion) release.MarkSplitModules(plan, u.allowed) markUnlicensed(plan, u.allowed) var req struct { Names []string `json:"names"` New []string `json:"new,omitempty"` } var list, skipped []string for _, c := range plan { if c.Blocked != "" { skipped = append(skipped, c.Title+": "+c.Blocked) continue } if !c.Update { continue } if !have[c.Name] { // в пакете может не быть файла модуля (не входит в лицензию клиента) continue } if !u.allowed(c.Feature) && c.Feature != "updates" { skipped = append(skipped, c.Title+": не входит в лицензию") continue } req.Names = append(req.Names, c.Name) if c.New { req.New = append(req.New, c.Name) } list = append(list, c.Name+" "+c.Available) } if len(req.Names) == 0 { _ = os.RemoveAll(staging) msg := fmt.Sprintf("в пакете релиз %s: нечего обновлять, все компоненты актуальны", m.Version) if len(skipped) > 0 { msg += "; пропущено: " + strings.Join(skipped, "; ") } return "", errors.New(msg) } rb, _ := json.Marshal(req) if err := os.WriteFile(filepath.Join(staging, "request.json"), rb, 0o640); err != nil { return "", err } for _, n := range req.Names { if n == "bj-server" { for _, c := range plan { if c.Name == n { progressTarget(c.Available) } } } } progressStage(3, "Проверка подписи и установка, затем перезапуск служб") log.Printf("lk-gateway: обновление из файла (релиз %s): %s — передано root-установщику", m.Version, strings.Join(list, ", ")) wctx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() res, err := runKeymediaOp(wctx, "component-install", map[string]string{"staging": staging}) _ = os.RemoveAll(staging) if err != nil { return "", fmt.Errorf("установка: %w", err) } msg := fmt.Sprintf("из файла (релиз %s) установлено: %s", m.Version, strings.Join(list, ", ")) if r, ok := res["restarted"].([]any); ok && len(r) > 0 { var units []string for _, x := range r { units = append(units, fmt.Sprint(x)) } msg += "; перезапущены службы: " + strings.Join(units, ", ") } if len(skipped) > 0 { msg += "; пропущено: " + strings.Join(skipped, "; ") } return msg, nil } // updateTouchesCore — затронет ли обновление ядро (тогда ответа установщика не дождаться). func updateTouchesCore(rc *RuntimeConfig, names []string) bool { if len(names) == 0 { // «Обновить всё»: ядро попадёт в набор, если для него есть новая версия rows, _ := updateComponentRows(rc) for _, r := range rows { if r.Core && r.Update { return true } } return false } for _, n := range names { if c, ok := release.ComponentByName(n); ok && c.Core { return true } } return false } // updateResultFailed — последняя строка результата говорит об отказе. func updateResultFailed(result string) bool { r := strings.ToLower(result) return strings.Contains(r, "не применено") || strings.Contains(r, "ошибка") || strings.Contains(r, "не удалось") || strings.Contains(r, "недоступ") }