package lkgateway // Сторож связи ИШ ↔ НРД. ИШ может отвечать bj, но сам не достукиваться до НРД (контур НРД отдаёт // 404/500/502): тогда пакеты получают ERROR, входящие не приходят. В REST ИШ такого признака нет, // поэтому смотрим журнал ИШ (если он на этом сервере): итог последних опросов НРД // «GetPackageListFull (GET) -> OK | #код». НРД не отвечает nsdAlertAfter — оповещение; снова // отвечает — оповещение с простоем и числом недоставленных переводов (их нужно отправить повторно). import ( "fmt" "io" "log" "os" "regexp" "strings" "sync" "time" ) const ( nsdAlertAfter = 10 * time.Minute nsdRemindEvery = 2 * time.Hour nsdLogTail = 512 << 10 ) var igateLogFile = "/var/lib/igate/.local/share/NSD Integration Gateway/logs-svc/log.txt" // PID_363248 2026-09-25 15:42:59.1873 T16 DEBUG [WSL] WSL TEST3 GOST: GetPackageListFull (GET) -> #404 (HTTP Error 404: NotFound) // PID_36882 2026-10-07 00:02:13.4942 T16 … — ИШ выравнивает номер процесса пробелами: с коротким номером (после // перезапуска сервера) пробелов больше одного, и прежний шаблон с одним пробелом не находил ни одного опроса // («НРД: нет данных опроса», серый индикатор при работающем обмене). var nsdPollRe = regexp.MustCompile(`^PID_\d+\s+(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\.\d+ .*WSL ([^:]+): GetPackageListFull \(GET\) -> (OK|#\d+[^\r\n]*)`) type nsdPoll struct { At time.Time Contour string OK bool Result string } // nsdLastPolls — итоги опросов НРД из хвоста журнала ИШ: последний опрос и первый неудачный после // последнего успешного (начало текущего сбоя; nil — сбоя нет). func nsdLastPolls(tail []byte) (last, firstFail *nsdPoll) { return nsdLastPollsFor(tail, "") } // nsdLastPollsFor — то же для канала одного контура: при двух каналах в ИШ (TEST3 и боевой) опросы чужого // контура не учитываются. prefix — начало имени канала в журнале («TEST3», «PROD»); пусто — все. func nsdLastPollsFor(tail []byte, prefix string) (last, firstFail *nsdPoll) { for _, ln := range strings.Split(string(tail), "\n") { m := nsdPollRe.FindStringSubmatch(strings.TrimRight(ln, "\r")) if m == nil || (prefix != "" && !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(m[2])), prefix)) { continue } at, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local) if err != nil { continue } p := &nsdPoll{At: at, Contour: m[2], OK: m[3] == "OK", Result: m[3]} last = p if p.OK { firstFail = nil } else if firstFail == nil { firstFail = p } } return last, firstFail } func readTail(path string, n int64) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() st, err := f.Stat() if err != nil { return nil, err } off := st.Size() - n if off < 0 { off = 0 } if _, err := f.Seek(off, io.SeekStart); err != nil { return nil, err } return io.ReadAll(f) } var nsdWatch struct { sync.Mutex DownSince time.Time // первый неудачный опрос после последнего успешного LastErr string Contour string Alerted bool LastRemind time.Time LastPollAt time.Time // последний опрос НРД шлюзом (по журналу) LastPollOK time.Time // последний успешный } // nsdLinkStatus — для страницы статуса: не отвечает ли НРД шлюзу. func nsdLinkStatus() (down bool, detail string) { nsdWatch.Lock() defer nsdWatch.Unlock() if nsdWatch.DownSince.IsZero() { return false, "" } return true, fmt.Sprintf("НРД (%s) не отвечает шлюзу с %s: %s", nsdWatch.Contour, nsdWatch.DownSince.Format("02.01 15:04"), nsdWatch.LastErr) } // nsdLinkTick — вызывается сторожем ИШ, когда сам ИШ отвечает и он на этом сервере. func nsdLinkTick(rc *RuntimeConfig, now time.Time) { tail, err := readTail(igateLogFile, nsdLogTail) if err != nil { return } last, firstFail := nsdLastPollsFor(tail, contourChannel(contourOf(rc.Snapshot().NSD.Profile))) if last == nil { return } w := &nsdWatch w.Lock() w.LastPollAt = last.At if last.OK { w.LastPollOK = last.At } else if firstFail != nil && w.LastPollOK.IsZero() { // в хвосте журнала нет успешного опроса после сбоя — ищем последний успешный до него for _, ln := range strings.Split(string(tail), "\n") { if m := nsdPollRe.FindStringSubmatch(strings.TrimRight(ln, "\r")); m != nil && m[3] == "OK" { if at, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local); err == nil { w.LastPollOK = at } } } } if last.OK { since, alerted := w.DownSince, w.Alerted w.DownSince, w.LastErr, w.Alerted = time.Time{}, "", false w.Unlock() if !since.IsZero() { log.Printf("nsd-watch: НРД (%s) снова отвечает шлюзу, простой %s", last.Contour, humanDur(last.At.Sub(since))) nsdProbeNow() // сквозную проверку — сразу, не дожидаясь планового срока } if alerted { body := fmt.Sprintf("Контур НРД %s снова отвечает интеграционному шлюзу (простой %s, с %s).", last.Contour, humanDur(last.At.Sub(since)), since.Format("02.01 15:04")) if n := sendFailCount(); n > 0 { body += fmt.Sprintf(" Недоставленных переводов: %d — отправьте их повторно из карточки заявки («Отправить повторно»).", n) } raiseSystemAlert(rc, "НРД снова на связи", body) } return } if w.DownSince.IsZero() { w.DownSince = firstFail.At log.Printf("nsd-watch: НРД (%s) не отвечает шлюзу: %s", last.Contour, last.Result) } w.LastErr, w.Contour = last.Result, last.Contour down := now.Sub(w.DownSince) alert := !w.Alerted && down >= nsdAlertAfter remind := w.Alerted && now.Sub(w.LastRemind) >= nsdRemindEvery if alert || remind { w.Alerted, w.LastRemind = true, now } since, lastErr := w.DownSince, w.LastErr w.Unlock() if !alert && !remind { return } title := "НРД не отвечает шлюзу" if remind { title = "НРД всё ещё не отвечает шлюзу" } raiseSystemAlert(rc, title, fmt.Sprintf("Интеграционный шлюз работает, но контур НРД %s не отвечает %s (с %s): %s. Пока так, отправки в НРД не доходят (заявка остаётся «Не отправлена»), входящие не поступают. Это сторона НРД — при затяжном сбое обратитесь в поддержку НРД. Когда связь восстановится, bj сообщит.", last.Contour, humanDur(down), since.Format("02.01 15:04"), lastErr)) } func sendFailCount() int { sendFails.Lock() defer sendFails.Unlock() sendFailLoad() return len(sendFails.m) }