Модуль, снятый с лицензии, отключается один раз: служба остановлена (bj-keymedia unit-disable), одно оповещение, в списках не показывается; вернули в лицензию — служба включается

Также: версия файла, совпадающего с выпуском, записывается (нет ложного «новее»); признак неудачной проверки отдельно от результатов установки; окно обновления само перезагружает страницу.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-09-24 22:43:34 +03:00
parent e450887688
commit 2edc83e614
7 changed files with 192 additions and 12 deletions
+9
View File
@@ -25,6 +25,15 @@ process_one(){
fi ;; fi ;;
*) out="{\"ok\":false,\"error\":\"недопустимая служба\"}" ;; *) out="{\"ok\":false,\"error\":\"недопустимая служба\"}" ;;
esac ;; 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) ;; *) out=$(BJ_TARGET_DEV="$tdev" BJ_TARGET_MNT="$tmnt" BJ_EXTRA="$extra" /usr/local/sbin/bj-keymedia-install "$action" "$staging" "$profile" 2>&1) ;;
esac esac
printf '%s' "$out" > "$res.tmp"; chown bj:bj "$res.tmp" 2>/dev/null printf '%s' "$out" > "$res.tmp"; chown bj:bj "$res.tmp" 2>/dev/null
+5 -1
View File
@@ -168,7 +168,11 @@ func LicenseRenew(ctx context.Context, rc *RuntimeConfig) error {
} }
return err return err
} }
return licenseAccept(rc, r.Token, "продлена") if err := licenseAccept(rc, r.Token, "продлена"); err != nil {
return err
}
enforceModuleLicenseAsync(rc) // состав лицензии мог измениться — службы модулей в соответствие
return nil
} }
// licenseNeedsRenew — онлайн-ключ и 15 минут без продления: так режим разработки, включённый или // licenseNeedsRenew — онлайн-ключ и 15 минут без продления: так режим разработки, включённый или
+87
View File
@@ -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)
}()
}
+6
View File
@@ -92,6 +92,12 @@ type UpdateSettings struct {
AutoCheck bool `json:"auto_check"` // проверять автоматически AutoCheck bool `json:"auto_check"` // проверять автоматически
LastCheck time.Time `json:"last_check"` // когда последний раз проверяли LastCheck time.Time `json:"last_check"` // когда последний раз проверяли
LastResult string `json:"last_result"` // текст результата проверки 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"` // доступная версия (если новее) Available string `json:"available_version"` // доступная версия (если новее)
Notes string `json:"notes,omitempty"` // заметки доступного релиза Notes string `json:"notes,omitempty"` // заметки доступного релиза
// Components — версии компонентов в последнем проверенном релизе (имя → версия). // Components — версии компонентов в последнем проверенном релизе (имя → версия).
+3
View File
@@ -79,6 +79,9 @@ func unitViews(rc *RuntimeConfig) []UnitView {
if !present && !c.Core { if !present && !c.Core {
continue // модуля на сервере нет — и службы быть не должно continue // модуля на сервере нет — и службы быть не должно
} }
if moduleOff(rc, c.Name) {
continue // снят с лицензии: служба отключена намеренно
}
v := unitState(c.Unit) v := unitState(c.Unit)
v.Title, v.Core, v.Present = c.Title, c.Core, true v.Title, v.Core, v.Present = c.Title, c.Core, true
out = append(out, v) out = append(out, v)
+63 -3
View File
@@ -10,9 +10,12 @@ package lkgateway
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@@ -95,6 +98,7 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
return st, err return st, err
} }
st.Available, st.Notes = m.Version, m.Notes st.Available, st.Notes = m.Version, m.Notes
syncSameFileVersions(m)
st.Components = release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion) st.Components = release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
release.MarkSplitModules(st.Components, u.allowed) release.MarkSplitModules(st.Components, u.allowed)
off := markUnlicensed(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 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 — модули, установленные на сервере, но снятые с лицензии: не обновляются (сервер // markUnlicensed — модули, установленные на сервере, но снятые с лицензии: не обновляются (сервер
// обновлений их файлы не отдаёт), в списке — с пояснением. Возвращает названия таких модулей. // обновлений их файлы не отдаёт), в списке — с пояснением. Возвращает названия таких модулей.
func markUnlicensed(states []release.ComponentState, allowed func(string) bool) []string { 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 var off []string
for i := range states { for i := range states {
c := &states[i] c := &states[i]
@@ -131,7 +185,7 @@ func markUnlicensed(states []release.ComponentState, allowed func(string) bool)
continue continue
} }
c.Update, c.New = false, false c.Update, c.New = false, false
if c.Present { if _, already := disabled[c.Name]; c.Present && !already { // об отключённом уже сказано один раз
c.Blocked = "не входит в лицензию — модуль отключён и не обновляется; чтобы подключить, свяжитесь с ZETIT" c.Blocked = "не входит в лицензию — модуль отключён и не обновляется; чтобы подключить, свяжитесь с ZETIT"
off = append(off, "«"+c.Title+"»") 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 := u.rc.Snapshot().Update
cfg.LastCheck = time.Now().UTC() cfg.LastCheck = time.Now().UTC()
cfg.LastResult = st.Message cfg.LastResult = st.Message
cfg.CheckFailed = avail == nil // nil — манифест получить не удалось
cfg.Available = st.Available cfg.Available = st.Available
cfg.Notes = st.Notes cfg.Notes = st.Notes
if avail != nil { if avail != nil {
@@ -202,6 +257,7 @@ func (u *Updater) applyUpdate(ctx context.Context, names []string) (string, erro
if err != nil { if err != nil {
return "", fmt.Errorf("манифест: %w", err) return "", fmt.Errorf("манифест: %w", err)
} }
syncSameFileVersions(m)
plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion) plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
release.MarkSplitModules(plan, u.allowed) release.MarkSplitModules(plan, u.allowed)
off := markUnlicensed(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)) 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) 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) }) markUnlicensed(out, func(f string) bool { return moduleAllowed(rc, f) })
// Последняя проверка не удалась — предлагать установку по старым данным нельзя: // Последняя проверка не удалась — предлагать установку по старым данным нельзя:
// кнопка ушла бы в тот же отказ. Показываем причину и просим проверить заново. // кнопка ушла бы в тот же отказ. Показываем причину и просим проверить заново.
if updateResultFailed(set.LastResult) { if set.CheckFailed {
for i := range out { for i := range out {
if out[i].Update && out[i].Available != "" { if out[i].Update && out[i].Available != "" {
out[i].Blocked = "проверка обновлений не удалась — нажмите «Проверить обновления»" out[i].Blocked = "проверка обновлений не удалась — нажмите «Проверить обновления»"
@@ -476,6 +535,7 @@ func (u *Updater) updateLoop(ctx context.Context) {
return return
case <-time.After(time.Minute): case <-time.After(time.Minute):
cleanupStaging(time.Hour) cleanupStaging(time.Hour)
enforceModuleLicense(ctx, u.rc)
u.ensureSplitModules(ctx) u.ensureSplitModules(ctx)
check() check()
} }
@@ -593,12 +593,22 @@ label { font-size:13px; font-weight:500; }
detail=document.getElementById('upd-detail'), doneEl=document.getElementById('upd-done'); detail=document.getElementById('upd-detail'), doneEl=document.getElementById('upd-done');
function mb(n){ return (n/1048576).toFixed(n<10485760?1:0)+' МБ'; } function mb(n){ return (n/1048576).toFixed(n<10485760?1:0)+' МБ'; }
function show(){ modal.style.display='flex'; } function show(){ modal.style.display='flex'; }
var finished=false;
function finish(text, ok){ function finish(text, ok){
finished=true;
stageEl.textContent=text; bar.style.width='100%'; stageEl.textContent=text; bar.style.width='100%';
bar.style.background= ok ? 'var(--ok,#2e8b57)' : 'var(--err,#c0392b)'; bar.style.background= ok ? 'var(--ok,#2e8b57)' : 'var(--err,#c0392b)';
doneEl.style.display='block'; 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){ 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; 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=''; show(); stageEl.textContent='Отправка запроса…'; bar.style.width='3%'; detail.textContent='';
// номер последнего отчёта до отправки: отчёт прошлой попытки (например, с ошибкой) не показываем // номер последнего отчёта до отправки: отчёт прошлой попытки (например, с ошибкой) не показываем
@@ -620,6 +630,7 @@ label { font-size:13px; font-weight:500; }
function poll(){ 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){ 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; } 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.target) target=p.target;
if(p.note) note=p.note; if(p.note) note=p.note;
if(lost){ // bj вернулся после перезапуска if(lost){ // bj вернулся после перезапуска