From 2edc83e61427aca9ab0c27dfbf4bcda4d29d7a47 Mon Sep 17 00:00:00 2001 From: zuevav Date: Thu, 24 Sep 2026 22:43:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BE=D0=B4=D1=83=D0=BB=D1=8C,=20=D1=81?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8B=D0=B9=20=D1=81=20=D0=BB=D0=B8=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D0=B7=D0=B8=D0=B8,=20=D0=BE=D1=82=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=20=D1=80=D0=B0=D0=B7:=20=D1=81=D0=BB=D1=83=D0=B6=D0=B1?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20(bj-keymedia=20unit-disable),=20=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BE=D0=BF=D0=BE=D0=B2=D0=B5=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5,=20=D0=B2=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D1=85=20=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F;=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=83=D0=BB=D0=B8=20=D0=B2=20=D0=BB=D0=B8=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D0=B7=D0=B8=D1=8E=20=E2=80=94=20=D1=81=D0=BB=D1=83=D0=B6?= =?UTF-8?q?=D0=B1=D0=B0=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Также: версия файла, совпадающего с выпуском, записывается (нет ложного «новее»); признак неудачной проверки отдельно от результатов установки; окно обновления само перезагружает страницу. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi --- deploy/linux/sbin/bj-keymedia-worker | 9 ++ internal/lkgateway/licenseclient.go | 6 +- internal/lkgateway/moduleslicense.go | 87 ++++++++++++++++++++ internal/lkgateway/runtimeconfig.go | 22 +++-- internal/lkgateway/units.go | 3 + internal/lkgateway/updater.go | 66 ++++++++++++++- internal/lkgateway/web/templates/layout.html | 11 +++ 7 files changed, 192 insertions(+), 12 deletions(-) create mode 100644 internal/lkgateway/moduleslicense.go diff --git a/deploy/linux/sbin/bj-keymedia-worker b/deploy/linux/sbin/bj-keymedia-worker index ade7ee9..75dc773 100755 --- a/deploy/linux/sbin/bj-keymedia-worker +++ b/deploy/linux/sbin/bj-keymedia-worker @@ -25,6 +25,15 @@ process_one(){ fi ;; *) out="{\"ok\":false,\"error\":\"недопустимая служба\"}" ;; esac ;; + unit-disable|unit-enable) + # службы модулей по лицензии: модуль сняли с лицензии — остановить и отключить, вернули — включить + case "$extra" in + bj-npr|bj-quikexport|bj-lk) + if [ "$action" = unit-disable ]; then err=$(systemctl disable --now "$extra" 2>&1); else systemctl reset-failed "$extra" >/dev/null 2>&1; err=$(systemctl enable --now "$extra" 2>&1); fi + if [ $? -eq 0 ]; then out="{\"ok\":true,\"output\":\"$action: $extra\"}" + else err=$(printf '%s' "$err" | tr -d '"\\' | tr '\n' ' '); out="{\"ok\":false,\"error\":\"$extra: $err\"}"; fi ;; + *) out="{\"ok\":false,\"error\":\"недопустимая служба\"}" ;; + esac ;; *) out=$(BJ_TARGET_DEV="$tdev" BJ_TARGET_MNT="$tmnt" BJ_EXTRA="$extra" /usr/local/sbin/bj-keymedia-install "$action" "$staging" "$profile" 2>&1) ;; esac printf '%s' "$out" > "$res.tmp"; chown bj:bj "$res.tmp" 2>/dev/null diff --git a/internal/lkgateway/licenseclient.go b/internal/lkgateway/licenseclient.go index 16e2591..cea0b81 100644 --- a/internal/lkgateway/licenseclient.go +++ b/internal/lkgateway/licenseclient.go @@ -168,7 +168,11 @@ func LicenseRenew(ctx context.Context, rc *RuntimeConfig) error { } return err } - return licenseAccept(rc, r.Token, "продлена") + if err := licenseAccept(rc, r.Token, "продлена"); err != nil { + return err + } + enforceModuleLicenseAsync(rc) // состав лицензии мог измениться — службы модулей в соответствие + return nil } // licenseNeedsRenew — онлайн-ключ и 15 минут без продления: так режим разработки, включённый или diff --git a/internal/lkgateway/moduleslicense.go b/internal/lkgateway/moduleslicense.go new file mode 100644 index 0000000..5323cdc --- /dev/null +++ b/internal/lkgateway/moduleslicense.go @@ -0,0 +1,87 @@ +package lkgateway + +// Модули, снятые с лицензии: служба модуля останавливается и отключается один раз, с одним +// оповещением; дальше модуль не показывается и не предлагается к обновлению. Вернули в лицензию — +// служба включается обратно. Работает только при действующей лицензии: истёкший или непроверенный +// ключ модули не трогает (временная ошибка не должна гасить работающие службы). + +import ( + "context" + "log" + "os" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" +) + +var moduleEnforceMu sync.Mutex + +// moduleOff — модуль отключён как снятый с лицензии (по записи в настройках). +func moduleOff(rc *RuntimeConfig, name string) bool { + _, ok := rc.Snapshot().Update.DisabledModules[name] + return ok +} + +func enforceModuleLicense(ctx context.Context, rc *RuntimeConfig) { + moduleEnforceMu.Lock() + defer moduleEnforceMu.Unlock() + if _, err := os.Stat(keymediaReqDir); err != nil { + return // нет root-службы (разработка, тесты) + } + st := licenseStatus(rc) + if !st.Valid { + return + } + cfg := rc.Snapshot().Update + changed := false + for _, c := range release.Components { + if c.Core || c.Feature == "" || c.Feature == "updates" || c.Unit == "" { + continue + } + if _, err := os.Stat(c.Path); err != nil { + continue // модуль не установлен + } + allowed := moduleAllowed(rc, c.Feature) + _, off := cfg.DisabledModules[c.Name] + switch { + case !allowed && !off: + if _, err := runKeymediaOp(ctx, "unit-disable", map[string]string{"extra": c.Unit}); err != nil { + log.Printf("modules: отключение %s: %v", c.Unit, err) + continue + } + if cfg.DisabledModules == nil { + cfg.DisabledModules = map[string]string{} + } + cfg.DisabledModules[c.Name] = time.Now().Format("02.01.2006 15:04") + changed = true + log.Printf("modules: %s снят с лицензии — служба %s остановлена и отключена", c.Name, c.Unit) + raiseSystemAlert(rc, "Модуль «"+c.Title+"» отключён: не входит в лицензию", + "Служба "+c.Unit+" остановлена, раздел скрыт, обновления для модуля не выдаются. Чтобы подключить модуль, свяжитесь с ZETIT — после добавления в лицензию он включится сам.") + case allowed && off: + if _, err := runKeymediaOp(ctx, "unit-enable", map[string]string{"extra": c.Unit}); err != nil { + log.Printf("modules: включение %s: %v", c.Unit, err) + continue + } + delete(cfg.DisabledModules, c.Name) + changed = true + log.Printf("modules: %s снова в лицензии — служба %s включена", c.Name, c.Unit) + raiseSystemAlert(rc, "Модуль «"+c.Title+"» снова включён", "Модуль добавлен в лицензию, служба "+c.Unit+" запущена.") + } + } + if changed { + cur := rc.Snapshot().Update + cur.DisabledModules = cfg.DisabledModules + if err := rc.SaveUpdateSettings(cur); err != nil { + log.Printf("modules: сохранение: %v", err) + } + } +} + +func enforceModuleLicenseAsync(rc *RuntimeConfig) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + enforceModuleLicense(ctx, rc) + }() +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index bff4358..e018869 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -86,14 +86,20 @@ type LicenseSettings struct { // UpdateSettings — авто-обновления из артефактории (#18/#20). type UpdateSettings struct { - BaseURL string `json:"base_url"` // https://updates.example.com - Channel string `json:"channel"` // "stable" | "beta" - PublicKey string `json:"public_key"` // base64 Ed25519 (если не зашит в бинарь) - AutoCheck bool `json:"auto_check"` // проверять автоматически - LastCheck time.Time `json:"last_check"` // когда последний раз проверяли - LastResult string `json:"last_result"` // текст результата проверки - Available string `json:"available_version"` // доступная версия (если новее) - Notes string `json:"notes,omitempty"` // заметки доступного релиза + BaseURL string `json:"base_url"` // https://updates.example.com + Channel string `json:"channel"` // "stable" | "beta" + PublicKey string `json:"public_key"` // base64 Ed25519 (если не зашит в бинарь) + AutoCheck bool `json:"auto_check"` // проверять автоматически + LastCheck time.Time `json:"last_check"` // когда последний раз проверяли + LastResult string `json:"last_result"` // текст результата проверки + // CheckFailed — последняя ПРОВЕРКА обновлений не удалась (сервер недоступен, нет лицензии…): + // тогда версии из прошлой проверки не предлагаются. Неудачная установка сюда не относится. + CheckFailed bool `json:"check_failed,omitempty"` + // DisabledModules — модули, отключённые как снятые с лицензии (имя → когда): служба остановлена, + // в списке компонентов не показываются; при возврате в лицензию включаются обратно. + DisabledModules map[string]string `json:"disabled_modules,omitempty"` + Available string `json:"available_version"` // доступная версия (если новее) + Notes string `json:"notes,omitempty"` // заметки доступного релиза // Components — версии компонентов в последнем проверенном релизе (имя → версия). Components map[string]string `json:"components,omitempty"` // ComponentsChannel — канал, в котором эти версии были найдены. Если канал diff --git a/internal/lkgateway/units.go b/internal/lkgateway/units.go index 47749d0..16516c8 100644 --- a/internal/lkgateway/units.go +++ b/internal/lkgateway/units.go @@ -79,6 +79,9 @@ func unitViews(rc *RuntimeConfig) []UnitView { if !present && !c.Core { continue // модуля на сервере нет — и службы быть не должно } + if moduleOff(rc, c.Name) { + continue // снят с лицензии: служба отключена намеренно + } v := unitState(c.Unit) v.Title, v.Core, v.Present = c.Title, c.Core, true out = append(out, v) diff --git a/internal/lkgateway/updater.go b/internal/lkgateway/updater.go index af16a59..681df49 100644 --- a/internal/lkgateway/updater.go +++ b/internal/lkgateway/updater.go @@ -10,9 +10,12 @@ package lkgateway import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" + "io" "log" "os" "path/filepath" @@ -95,6 +98,7 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) { 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) @@ -121,9 +125,59 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) { 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] @@ -131,7 +185,7 @@ func markUnlicensed(states []release.ComponentState, allowed func(string) bool) continue } c.Update, c.New = false, false - if c.Present { + if _, already := disabled[c.Name]; c.Present && !already { // об отключённом уже сказано один раз c.Blocked = "не входит в лицензию — модуль отключён и не обновляется; чтобы подключить, свяжитесь с ZETIT" off = append(off, "«"+c.Title+"»") } @@ -163,6 +217,7 @@ 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 { @@ -202,6 +257,7 @@ func (u *Updater) applyUpdate(ctx context.Context, names []string) (string, erro 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) @@ -414,7 +470,10 @@ func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) { r.Update = r.Available != "" && (r.Present || r.Core) && (r.Installed == "" || release.IsNewer(r.Available, r.Installed)) // Модуль, которого нет на сервере и нет в лицензии, не показываем: поставить его нельзя, // а в списке он выглядит так, будто входит в поставку. - if r.Present || r.Core || (r.Available != "" && moduleAllowed(rc, r.Feature)) { + // Модуль вне лицензии не показываем вовсе (и установленный: его служба отключена), пока + // лицензия действует; без действующей лицензии список как есть. + 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) } } @@ -422,7 +481,7 @@ func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) { markUnlicensed(out, func(f string) bool { return moduleAllowed(rc, f) }) // Последняя проверка не удалась — предлагать установку по старым данным нельзя: // кнопка ушла бы в тот же отказ. Показываем причину и просим проверить заново. - if updateResultFailed(set.LastResult) { + if set.CheckFailed { for i := range out { if out[i].Update && out[i].Available != "" { out[i].Blocked = "проверка обновлений не удалась — нажмите «Проверить обновления»" @@ -476,6 +535,7 @@ func (u *Updater) updateLoop(ctx context.Context) { return case <-time.After(time.Minute): cleanupStaging(time.Hour) + enforceModuleLicense(ctx, u.rc) u.ensureSplitModules(ctx) check() } diff --git a/internal/lkgateway/web/templates/layout.html b/internal/lkgateway/web/templates/layout.html index 25e95b3..48e8232 100644 --- a/internal/lkgateway/web/templates/layout.html +++ b/internal/lkgateway/web/templates/layout.html @@ -593,12 +593,22 @@ label { font-size:13px; font-weight:500; } detail=document.getElementById('upd-detail'), doneEl=document.getElementById('upd-done'); function mb(n){ return (n/1048576).toFixed(n<10485760?1:0)+' МБ'; } function show(){ modal.style.display='flex'; } + var finished=false; function finish(text, ok){ + finished=true; stageEl.textContent=text; bar.style.width='100%'; bar.style.background= ok ? 'var(--ok,#2e8b57)' : 'var(--err,#c0392b)'; doneEl.style.display='block'; + if(ok){ // после успешного обновления страница перезагружается сама — с новыми версиями + var left=5, tick=function(){ if(left<=0){ location.reload(); return; } + doneEl.querySelector('button').textContent='Готово (страница обновится через '+left+' с)'; left--; setTimeout(tick,1000); }; + tick(); + } } function run(form){ + var lastChange=Date.now(), lastKey=''; + // сторож: если отчёт не меняется 2 минуты, перезагружаем страницу — не оставляем «зависшее» окно + (function watch(){ if(finished) return; if(Date.now()-lastChange>120000){ location.reload(); return; } setTimeout(watch,5000); })(); var note='', startVer=null, target='', seenActive=false, postDone=false, lost=false, t0=Date.now(), seq0=null; show(); stageEl.textContent='Отправка запроса…'; bar.style.width='3%'; detail.textContent=''; // номер последнего отчёта до отправки: отчёт прошлой попытки (например, с ошибкой) не показываем @@ -620,6 +630,7 @@ label { font-size:13px; font-weight:500; } function poll(){ fetch('/admin/setup/update/progress',{cache:'no-store',credentials:'same-origin'}).then(function(r){ if(!r.ok) throw 0; return r.json(); }).then(function(p){ if(startVer===null){ startVer=p.version; } + var key=[p.seq,p.step,p.done,p.finished,p.version].join('/'); if(key!==lastKey){ lastKey=key; lastChange=Date.now(); } if(p.target) target=p.target; if(p.note) note=p.note; if(lost){ // bj вернулся после перезапуска