diff --git a/deploy/linux/sbin/bj-keymedia-worker b/deploy/linux/sbin/bj-keymedia-worker index 54bfc71..ade7ee9 100755 --- a/deploy/linux/sbin/bj-keymedia-worker +++ b/deploy/linux/sbin/bj-keymedia-worker @@ -15,7 +15,7 @@ process_one(){ # (и сам этот демон) отсюда тронуть нельзя. unit-restart) case "$extra" in - bj-server|bj-npr|bj-quikexport|bj-lk|bj-crypto) + bj-server|bj-npr|bj-quikexport|bj-lk|bj-crypto|igate) systemctl reset-failed "$extra" >/dev/null 2>&1 if err=$(systemctl restart "$extra" 2>&1); then out="{\"ok\":true,\"output\":\"перезапущено: $extra\"}" diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 5946d93..5f10dec 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -356,6 +356,8 @@ type page struct { IsMockMode bool // true если ИШ или СКЗИ не настроены — обмена с НРД нет MockReason string // короткое описание причины (у клиента — без «mock») NPR bool // модуль «НПР / УДС» включён и разрешён лицензией + IGWDown string // ИШ НРД не отвечает: «N мин» (пусто — отвечает или не проверялся) + IGWDetail string DevMode bool // режим разработки (сервер лицензий): видны служебные разделы } @@ -720,6 +722,9 @@ func nowPage(title, active string) page { if globalRC != nil { p.NPR = nprAvailable(globalRC) p.DevMode = devMode(globalRC) + if down, d, det := igwStatus(); down { + p.IGWDown, p.IGWDetail = humanDur(d), det + } s := globalRC.Snapshot() switch { case s.NSD.IGWBaseURL == "": diff --git a/internal/lkgateway/igwwatch.go b/internal/lkgateway/igwwatch.go new file mode 100644 index 0000000..f27cdd2 --- /dev/null +++ b/internal/lkgateway/igwwatch.go @@ -0,0 +1,226 @@ +package lkgateway + +// igwwatch.go — сторож интеграционного шлюза НРД (ИШ). ИШ может «жить» как процесс +// (systemd считает службу active), но не слушать порт: тогда bj не получает входящие +// и не может отправить ни одного пакета. Сторож каждые 30 с проверяет, отвечает ли ИШ +// по HTTP, и: +// - после 2 мин недоступности — оповещение (лента «Новости» + e-mail/Zabbix) и +// попытка перезапустить службу igate (если ИШ на этом сервере; через root-демон +// bj-keymedia, список служб закрытый); +// - перезапуск не помог — оповещение с числом попыток; повтор раз в 20 мин, до 3 +// попыток за эпизод, дальше напоминание раз в 2 ч; +// - ИШ снова отвечает — оповещение «восстановлен» с длительностью простоя. +// Состояние видно в шапке (индикатор «НРД на связи» → «ИШ НРД недоступен»). + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const ( + igwCheckEvery = 30 * time.Second + igwAlertAfter = 2 * time.Minute + igwRestartEvery = 20 * time.Minute + igwRestartMax = 3 + igwRemindEvery = 2 * time.Hour + igwRestartSettle = 4 * time.Minute + igwServiceUnit = "igate" +) + +type igwWatchState struct { + mu sync.Mutex + Checked bool + Up bool + DownSince time.Time + LastOK time.Time + LastErr string + Alerted bool + Restarts int + LastRestart time.Time + LastRemind time.Time + RestartMsg string +} + +var igwWatch igwWatchState + +// igwStatus — для шапки и страницы статуса: недоступен ли ИШ и сколько. +func igwStatus() (down bool, since time.Duration, detail string) { + igwWatch.mu.Lock() + defer igwWatch.mu.Unlock() + if !igwWatch.Checked || igwWatch.Up { + return false, 0, "" + } + d := fmt.Sprintf("нет ответа с %s: %s", igwWatch.DownSince.Format("02.01 15:04"), igwWatch.LastErr) + if igwWatch.Restarts > 0 { + d += fmt.Sprintf("; попыток перезапуска: %d (%s)", igwWatch.Restarts, igwWatch.RestartMsg) + } + return true, time.Since(igwWatch.DownSince).Round(time.Minute), d +} + +// igwProbe — отвечает ли ИШ по HTTP (любой HTTP-ответ = жив; отказ соединения/таймаут = нет). +func igwProbe(ctx context.Context, base string) error { + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(base, "/")+"/", nil) + if err != nil { + return err + } + resp, err := (&http.Client{Transport: &http.Transport{Proxy: nil}}).Do(req) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +func igwLocal(base string) bool { + u, err := url.Parse(base) + if err != nil { + return false + } + h := u.Hostname() + return h == "localhost" || h == "127.0.0.1" || h == "::1" +} + +func humanDur(d time.Duration) string { + d = d.Round(time.Minute) + if d < time.Hour { + return fmt.Sprintf("%d мин", int(d.Minutes())) + } + if d < 48*time.Hour { + return fmt.Sprintf("%d ч %d мин", int(d.Hours()), int(d.Minutes())%60) + } + return fmt.Sprintf("%d сут %d ч", int(d.Hours())/24, int(d.Hours())%24) +} + +// igwWatchLoop — фоновый сторож (запускается через superviseLoop). +func (s *Server) igwWatchLoop(ctx context.Context) { + healthRegister("igw-watch", 2*igwCheckEvery) + t := time.NewTicker(igwCheckEvery) + defer t.Stop() + for { + igwWatchTick(ctx, s.rc) + healthBeat("igw-watch") + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +func igwWatchTick(ctx context.Context, rc *RuntimeConfig) { + base := strings.TrimSpace(rc.Snapshot().NSD.IGWBaseURL) + if base == "" { + igwWatch.mu.Lock() + igwWatch.Checked = false + igwWatch.mu.Unlock() + return + } + err := igwProbe(ctx, base) + now := time.Now() + w := &igwWatch + w.mu.Lock() + w.Checked = true + if err == nil { + wasDown, since, alerted, restarts := !w.Up && !w.DownSince.IsZero(), w.DownSince, w.Alerted, w.Restarts + w.Up, w.LastOK, w.LastErr, w.DownSince, w.Alerted, w.Restarts, w.RestartMsg = true, now, "", time.Time{}, false, 0, "" + w.mu.Unlock() + if wasDown && alerted { + how := "сам" + if restarts > 0 { + how = fmt.Sprintf("после перезапуска bj (попыток: %d)", restarts) + } + log.Printf("igw-watch: ИШ снова отвечает (%s), простой %s", how, humanDur(now.Sub(since))) + raiseSystemAlert(rc, "ИШ НРД снова доступен", fmt.Sprintf("Интеграционный шлюз НРД (%s) отвечает — восстановился %s. Простой %s (с %s). Обмен с НРД возобновлён: входящие переводы будут получены, ожидающие отправки уйдут.", + base, how, humanDur(now.Sub(since)), since.Format("02.01 15:04"))) + } + return + } + if w.Up || w.DownSince.IsZero() { + w.Up, w.DownSince = false, now + log.Printf("igw-watch: ИШ НРД не отвечает (%s): %v", base, err) + } + w.LastErr = shortErr(err) + down := now.Sub(w.DownSince) + needAlert := !w.Alerted && down >= igwAlertAfter + canRestart := igwLocal(base) && down >= igwAlertAfter && w.Restarts < igwRestartMax && + (w.LastRestart.IsZero() || now.Sub(w.LastRestart) >= igwRestartEvery) + remind := w.Alerted && !canRestart && w.Restarts >= igwRestartMax && now.Sub(w.LastRemind) >= igwRemindEvery + if needAlert { + w.Alerted, w.LastRemind = true, now + } + if remind { + w.LastRemind = now + } + if canRestart { + w.Restarts++ + w.LastRestart = now + } + attempt, since, lastErr := w.Restarts, w.DownSince, w.LastErr + w.mu.Unlock() + + if needAlert { + plan := "ИШ на другом сервере — перезапустите службу ИШ там." + if igwLocal(base) { + plan = "bj пробует перезапустить службу " + igwServiceUnit + " автоматически и сообщит результат." + } + raiseSystemAlert(rc, "ИШ НРД недоступен", fmt.Sprintf("Интеграционный шлюз НРД (%s) не отвечает с %s (%s): %s. Пока ИШ недоступен, входящие переводы не принимаются, отправка в НРД невозможна. %s", + base, since.Format("02.01 15:04"), humanDur(down), lastErr, plan)) + } + if remind { + raiseSystemAlert(rc, "ИШ НРД всё ещё недоступен", fmt.Sprintf("ИШ (%s) не отвечает %s (с %s); автоматические перезапуски (%d) не помогли: %s. Нужна проверка администратором: journalctl -u %s, igate-cli.", + base, humanDur(down), since.Format("02.01 15:04"), igwRestartMax, lastErr, igwServiceUnit)) + } + if canRestart { + go igwTryRestart(rc, base, attempt) + } +} + +// igwTryRestart — перезапуск службы ИШ и проверка, поднялся ли он. +func igwTryRestart(rc *RuntimeConfig, base string, attempt int) { + log.Printf("igw-watch: перезапуск службы %s (попытка %d из %d)", igwServiceUnit, attempt, igwRestartMax) + out, err := restartUnit(context.Background(), igwServiceUnit) + msg := out + if err != nil { + msg = "служба не перезапущена: " + err.Error() + } else { + deadline := time.Now().Add(igwRestartSettle) + for time.Now().Before(deadline) { + time.Sleep(15 * time.Second) + if igwProbe(context.Background(), base) == nil { + msg = "перезапуск выполнен, ИШ ответил" + break + } + msg = fmt.Sprintf("служба перезапущена, но ИШ не отвечает %s после перезапуска", humanDur(igwRestartSettle)) + } + } + igwWatch.mu.Lock() + igwWatch.RestartMsg = msg + up := igwWatch.Up + igwWatch.mu.Unlock() + log.Printf("igw-watch: попытка %d: %s", attempt, msg) + if strings.HasPrefix(msg, "перезапуск выполнен") || up { + return // «восстановлен» сообщит igwWatchTick + } + next := "следующая попытка через 20 мин" + if attempt >= igwRestartMax { + next = "попытки исчерпаны — нужна проверка администратором (journalctl -u " + igwServiceUnit + ")" + } + raiseSystemAlert(rc, fmt.Sprintf("Перезапуск ИШ НРД не помог (попытка %d из %d)", attempt, igwRestartMax), + fmt.Sprintf("bj перезапустил службу %s, но шлюз %s не отвечает: %s. %s.", igwServiceUnit, base, msg, next)) +} + +func shortErr(err error) string { + s := err.Error() + if i := strings.LastIndex(s, ": "); i > 0 && len(s) > 120 { + s = s[i+2:] + } + return s +} diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 933daed..6c18688 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -279,6 +279,7 @@ func (s *Server) Run(ctx context.Context) error { // Все фоновые горутины — под supervise: паника ловится recover(), пишется // в лог и в ленту, горутина перезапускается. Один сбой не роняет сервис. go superviseLoop(ctx, "consume-decisions", s.rc, s.consumeDecisions) + go superviseLoop(ctx, "igw-watch", s.rc, s.igwWatchLoop) // сторож ИШ НРД: оповещения и автоперезапуск // Поллер входящих от НРД (только в реальном режиме ИШ): забирает // ответы робота/контрагента и применяет их через ApplyDecision. diff --git a/internal/lkgateway/web/templates/layout.html b/internal/lkgateway/web/templates/layout.html index e6595ef..f76f417 100644 --- a/internal/lkgateway/web/templates/layout.html +++ b/internal/lkgateway/web/templates/layout.html @@ -343,8 +343,9 @@ label { font-size:13px; font-weight:500; }
- - {{if .IsMockMode}}{{if devOn}}Эмуляция{{else}}Нет связи с НРД{{end}}{{else}}НРД на связи{{end}} + {{if and .IGWDown (not .IsMockMode)}} + ИШ НРД недоступен · {{.IGWDown}}{{else}} + {{if .IsMockMode}}{{if devOn}}Эмуляция{{else}}Нет связи с НРД{{end}}{{else}}НРД на связи{{end}}{{end}} {{.Now}}