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)