From b26271a4f993aa4298b56396a9d739304bd6fd61 Mon Sep 17 00:00:00 2001 From: monster1025 Date: Thu, 17 Sep 2026 15:13:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B5=20=D1=80=D0=B2=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20=D1=82=D1=83=D0=BD=D0=BD=D0=B5=D0=BB=D1=8C=20=D0=B8=D0=B7-?= =?UTF-8?q?=D0=B7=D0=B0=20=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20heartbeat=20=D0=BF=D0=BE=D0=B4=20=D0=BD=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heartbeat (exec-канал `while :; do echo; sleep 1; done`) мультиплексирован в том же SSH-соединении, что и полезный трафик проброса. На большой выгрузке строка heartbeat опаздывает за данными, MonitorLoop считает туннель мёртвым и зовёт Reconnect(), который Stop()/Dispose()-ит ForwardedPortRemote — обрывая ту самую передачу, которая и вызвала задержку. По логам sshd: 1670 переподключений за месяц, все с формулировкой "Connection terminated by the client"; распределение интервалов двумодальное — 82% короче 30 с (шторм) против 16% длиннее 10 минут (покой), а в час активной передачи данных разрывов в 4-8 раз больше фонового уровня. Что сделано: 1. Порог молчания вынесен в HeartbeatSilenceSec (60 с). Раньше считался как Math.Max(5, MaxPingFailures) при дефолте MaxPingFailures=3 — то есть жёстко 5 секунд, а настройка была ниже пола и не влияла ни на что. В UI предел тоже был 10. MaxPingFailures сохранён для чтения старых settings.ini. 2. Решение о переподключении принимает e2e-проверка проброса, а не тишина heartbeat. Поднимается ForwardedPortLocal на серверный RemotePort, и TCP-подключение к нему проходит весь маршрут по кругу: процесс -> SSH -> слушатель обратного проброса на сервере -> SSH обратно -> LocalPort. Это ловит и обратный случай, когда проброс завис, а heartbeat бодро отвечает. Рвём только после ProbeFailuresBeforeReconnect (2) неудач подряд. Если порт проверки поднять не удалось, проверка считается пройденной — иначе неисправная диагностика начала бы рвать рабочий туннель. Co-Authored-By: Claude Opus 5 (1M context) --- src/MonsterMonitor/Models/AppSettings.cs | 22 +++ .../Services/SshTunnelService.cs | 184 +++++++++++++++++- src/MonsterMonitor/UI/SettingsForm.cs | 11 +- 3 files changed, 202 insertions(+), 15 deletions(-) diff --git a/src/MonsterMonitor/Models/AppSettings.cs b/src/MonsterMonitor/Models/AppSettings.cs index 1a9875e..c0a21bd 100644 --- a/src/MonsterMonitor/Models/AppSettings.cs +++ b/src/MonsterMonitor/Models/AppSettings.cs @@ -19,8 +19,22 @@ namespace MonsterMonitor.Models public bool SavePassword { get; set; } = false; public int RemotePort { get; set; } = 3328; public int LocalPort { get; set; } = 7829; + // Устарело: раньше использовалось как порог молчания heartbeat в секундах, + // но код брал Math.Max(5, MaxPingFailures), поэтому значение <5 ничего не меняло. + // Оставлено для совместимости со старыми settings.ini, см. HeartbeatSilenceSec. public int MaxPingFailures { get; set; } = 3; public int ReconnectTimeoutSec { get; set; } = 45; + + // Сколько секунд молчания heartbeat считать подозрительным. Само по себе + // молчание больше НЕ рвёт соединение - оно лишь повод сделать e2e-проверку. + // Большая выгрузка легко забивает канал на десятки секунд. + public int HeartbeatSilenceSec { get; set; } = 60; + + // e2e-проверка проброса: раз в сколько секунд, таймаут и сколько подряд + // неудач требуется, чтобы переподключаться. + public int ProbeIntervalSec { get; set; } = 30; + public int ProbeTimeoutSec { get; set; } = 10; + public int ProbeFailuresBeforeReconnect { get; set; } = 2; public string Proxy { get; set; } = string.Empty; public string SsProcessPath { get; set; } = Path.Combine("App_Data", "ss", "ss.exe"); public string SsArguments { get; set; } = string.Empty; @@ -164,6 +178,10 @@ namespace MonsterMonitor.Models result.LocalPort = GetInt(map, nameof(LocalPort), result.LocalPort); result.MaxPingFailures = GetInt(map, nameof(MaxPingFailures), result.MaxPingFailures); result.ReconnectTimeoutSec = GetInt(map, nameof(ReconnectTimeoutSec), result.ReconnectTimeoutSec); + result.HeartbeatSilenceSec = GetInt(map, nameof(HeartbeatSilenceSec), result.HeartbeatSilenceSec); + result.ProbeIntervalSec = GetInt(map, nameof(ProbeIntervalSec), result.ProbeIntervalSec); + result.ProbeTimeoutSec = GetInt(map, nameof(ProbeTimeoutSec), result.ProbeTimeoutSec); + result.ProbeFailuresBeforeReconnect = GetInt(map, nameof(ProbeFailuresBeforeReconnect), result.ProbeFailuresBeforeReconnect); result.Proxy = Get(map, nameof(Proxy), result.Proxy); result.SsProcessPath = Get(map, nameof(SsProcessPath), result.SsProcessPath); result.SsArguments = Get(map, nameof(SsArguments), result.SsArguments); @@ -193,6 +211,10 @@ namespace MonsterMonitor.Models $"{nameof(LocalPort)}={LocalPort}", $"{nameof(MaxPingFailures)}={MaxPingFailures}", $"{nameof(ReconnectTimeoutSec)}={ReconnectTimeoutSec}", + $"{nameof(HeartbeatSilenceSec)}={HeartbeatSilenceSec}", + $"{nameof(ProbeIntervalSec)}={ProbeIntervalSec}", + $"{nameof(ProbeTimeoutSec)}={ProbeTimeoutSec}", + $"{nameof(ProbeFailuresBeforeReconnect)}={ProbeFailuresBeforeReconnect}", $"{nameof(Proxy)}={Proxy}", $"{nameof(SsProcessPath)}={SsProcessPath}", $"{nameof(SsArguments)}={SsArguments}" diff --git a/src/MonsterMonitor/Services/SshTunnelService.cs b/src/MonsterMonitor/Services/SshTunnelService.cs index fcf3678..a1ddce5 100644 --- a/src/MonsterMonitor/Services/SshTunnelService.cs +++ b/src/MonsterMonitor/Services/SshTunnelService.cs @@ -1,5 +1,7 @@ using System; using System.IO; +using System.Net; +using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using MonsterMonitor.Models; @@ -14,6 +16,13 @@ namespace MonsterMonitor.Services private readonly object _sync = new object(); private SshClient _client; private ForwardedPortRemote _forwardedPort; + // Локальный проброс на серверный RemotePort: даёт замкнутый e2e-маршрут + // local -> SSH -> сервер:RemotePort -> обратный проброс -> наш LocalPort. + private ForwardedPortLocal _probePort; + private int _probeLocalPort; + private int _probeFailures; + private DateTime _lastProbeUtc = DateTime.MinValue; + private DateTime _lastGoodProbeUtc = DateTime.MinValue; private SshCommand _heartbeatCommand; private CancellationTokenSource _heartbeatReadTokenSource; private Task _heartbeatReadTask; @@ -63,7 +72,11 @@ namespace MonsterMonitor.Services _forwardedPort.Exception += ForwardedPortOnException; _client.AddForwardedPort(_forwardedPort); _forwardedPort.Start(); + StartProbePortNoLock(); StartRemoteHeartbeatNoLock(); + _probeFailures = 0; + _lastProbeUtc = DateTime.UtcNow; + _lastGoodProbeUtc = DateTime.UtcNow; _log.Info( $"SSH подключен. Туннель remote:{_settings.RemotePort} -> local:{_settings.LocalPort}"); @@ -94,19 +107,62 @@ namespace MonsterMonitor.Services { try { - var silenceThresholdSec = Math.Max( - 5, - _settings.MaxPingFailures); - - var lastHeartbeat = _lastHeartbeatUtc; - var isHeartbeatAlive = lastHeartbeat != DateTime.MinValue && - (DateTime.UtcNow - lastHeartbeat).TotalSeconds <= silenceThresholdSec; - - if (!IsConnected() || !isHeartbeatAlive) + if (!IsConnected()) { - _log.Warn("Нет живого вывода heartbeat-команды на удаленном сервере. Переподключаю SSH."); + _log.Warn("SSH-сессия не подключена. Переподключаю."); await Reconnect().ConfigureAwait(false); } + else + { + var now = DateTime.UtcNow; + var silenceThresholdSec = Math.Max(5, _settings.HeartbeatSilenceSec); + var probeIntervalSec = Math.Max(5, _settings.ProbeIntervalSec); + + var lastHeartbeat = _lastHeartbeatUtc; + var heartbeatSilentSec = lastHeartbeat == DateTime.MinValue + ? double.MaxValue + : (now - lastHeartbeat).TotalSeconds; + var heartbeatSuspicious = heartbeatSilentSec > silenceThresholdSec; + + // Heartbeat живёт в том же SSH-соединении, что и полезный трафик, + // поэтому под большой выгрузкой он опаздывает - это НЕ повод рвать + // туннель. Решение принимает только e2e-проверка проброса. + var needProbe = heartbeatSuspicious || + (now - _lastProbeUtc).TotalSeconds >= probeIntervalSec; + + if (needProbe) + { + _lastProbeUtc = now; + var ok = await ProbeForwardAsync().ConfigureAwait(false); + if (ok) + { + if (_probeFailures > 0) + { + _log.Info("e2e-проверка проброса снова проходит."); + } + + _probeFailures = 0; + _lastGoodProbeUtc = DateTime.UtcNow; + } + else + { + _probeFailures++; + _log.Warn($"e2e-проверка проброса не прошла ({_probeFailures} подряд)."); + } + } + + if (heartbeatSuspicious && _probeFailures == 0) + { + _log.Debug( + $"Heartbeat молчит {heartbeatSilentSec:F0}с, но проброс работает - вероятно идёт передача. Не переподключаю."); + } + + if (_probeFailures >= Math.Max(1, _settings.ProbeFailuresBeforeReconnect)) + { + _log.Warn("Проброс портов не пропускает трафик. Переподключаю SSH."); + await Reconnect().ConfigureAwait(false); + } + } } catch (Exception ex) { @@ -183,6 +239,7 @@ namespace MonsterMonitor.Services private void DisconnectCore() { StopRemoteHeartbeatNoLock(); + StopProbePortNoLock(); try { @@ -219,6 +276,113 @@ namespace MonsterMonitor.Services } } + /// + /// Поднимает локальный проброс на серверный RemotePort. Подключение к нему + /// проходит весь маршрут целиком: наш процесс -> SSH -> слушатель обратного + /// проброса на сервере -> SSH обратно -> наш LocalPort (ss.exe). + /// Успешный TCP-connect означает, что проброс реально пропускает трафик, + /// а не просто "сессия жива". + /// + private void StartProbePortNoLock() + { + StopProbePortNoLock(); + + try + { + _probeLocalPort = FindFreeLocalPort(); + _probePort = new ForwardedPortLocal( + "127.0.0.1", + (uint)_probeLocalPort, + "127.0.0.1", + (uint)_settings.RemotePort); + _client.AddForwardedPort(_probePort); + _probePort.Start(); + _log.Info($"e2e-проверка проброса включена (127.0.0.1:{_probeLocalPort})."); + } + catch (Exception ex) + { + _probePort = null; + _log.Warn("Не удалось поднять порт e2e-проверки: " + ex.Message); + } + } + + private void StopProbePortNoLock() + { + try + { + if (_probePort != null) + { + if (_probePort.IsStarted) + { + _probePort.Stop(); + } + + _probePort.Dispose(); + _probePort = null; + } + } + catch + { + // Ignore errors on shutdown. + } + } + + private static int FindFreeLocalPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private async Task ProbeForwardAsync() + { + ForwardedPortLocal probe; + int port; + lock (_sync) + { + probe = _probePort; + port = _probeLocalPort; + } + + if (probe == null || !probe.IsStarted || port <= 0) + { + // Порт проверки поднять не удалось - не выдумываем отказ проброса, + // иначе будем рвать рабочий туннель из-за собственной диагностики. + return true; + } + + var timeoutMs = Math.Max(1, _settings.ProbeTimeoutSec) * 1000; + + try + { + using (var tcp = new TcpClient()) + { + var connectTask = tcp.ConnectAsync(IPAddress.Loopback, port); + var completed = await Task.WhenAny(connectTask, Task.Delay(timeoutMs)).ConfigureAwait(false); + if (completed != connectTask) + { + _log.Debug("e2e-проверка: таймаут подключения через проброс."); + return false; + } + + await connectTask.ConfigureAwait(false); + return tcp.Connected; + } + } + catch (Exception ex) + { + _log.Debug("e2e-проверка: " + ex.Message); + return false; + } + } + private void StartRemoteHeartbeatNoLock() { if (_client == null || !_client.IsConnected) diff --git a/src/MonsterMonitor/UI/SettingsForm.cs b/src/MonsterMonitor/UI/SettingsForm.cs index 57d8d70..3fdaef6 100644 --- a/src/MonsterMonitor/UI/SettingsForm.cs +++ b/src/MonsterMonitor/UI/SettingsForm.cs @@ -1,3 +1,4 @@ +using System; using System.Drawing; using System.Security.Cryptography; using System.Windows.Forms; @@ -64,7 +65,7 @@ namespace MonsterMonitor.UI AddRow(panel, string.Empty, _chkSavePassword, 4); AddRow(panel, "Удаленный порт:", _numRemotePort, 5); AddRow(panel, "Локальный порт:", _numLocalPort, 6); - AddRow(panel, "Макс. потерь:", _numMaxFailures, 7); + AddRow(panel, "Молчание heartbeat (сек):", _numMaxFailures, 7); AddRow(panel, "Таймаут reconnect (сек):", _numReconnectTimeout, 8); AddRow(panel, "Прокси (http://host:port):", _txtProxy, 9); AddRow(panel, "Путь к ss:", _txtSsPath, 10); @@ -78,8 +79,8 @@ namespace MonsterMonitor.UI num.Maximum = 65535; } - _numMaxFailures.Minimum = 1; - _numMaxFailures.Maximum = 10; + _numMaxFailures.Minimum = 10; + _numMaxFailures.Maximum = 600; _numReconnectTimeout.Minimum = 5; _numReconnectTimeout.Maximum = 60; @@ -200,7 +201,7 @@ namespace MonsterMonitor.UI _chkSavePassword.Checked = _settings.SavePassword; _numRemotePort.Value = _settings.RemotePort; _numLocalPort.Value = _settings.LocalPort; - _numMaxFailures.Value = _settings.MaxPingFailures; + _numMaxFailures.Value = Math.Min(600, Math.Max(10, _settings.HeartbeatSilenceSec)); _numReconnectTimeout.Value = _settings.ReconnectTimeoutSec; _txtProxy.Text = _settings.Proxy; _txtSsPath.Text = _settings.SsProcessPath; @@ -224,7 +225,7 @@ namespace MonsterMonitor.UI _settings.SetPassword(_txtPassword.Text); _settings.RemotePort = (int)_numRemotePort.Value; _settings.LocalPort = (int)_numLocalPort.Value; - _settings.MaxPingFailures = (int)_numMaxFailures.Value; + _settings.HeartbeatSilenceSec = (int)_numMaxFailures.Value; _settings.ReconnectTimeoutSec = (int)_numReconnectTimeout.Value; _settings.Proxy = _txtProxy.Text.Trim(); _settings.SsProcessPath = string.IsNullOrWhiteSpace(_txtSsPath.Text)