Compare commits

...
8 Commits
Author SHA1 Message Date
kislovdm 1faa327f56 нагрузка
.NET Core Desktop / build (Debug) (push) Canceled after 0s
2026-09-17 15:18:01 +03:00
monster 65c8452f3c Merge pull request #1 from monster1025/fix/tunnel-self-reconnect-on-load
Не рвать туннель из-за молчания heartbeat под нагрузкой
2026-09-17 15:16:07 +03:00
monster1025andClaude Opus 5 b26271a4f9 Не рвать туннель из-за молчания heartbeat под нагрузкой
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) <noreply@anthropic.com>
2026-09-17 15:13:09 +03:00
kislovdm 24f8aefefa откат
.NET Core Desktop / build (Debug) (push) Has been cancelled
2026-07-23 16:52:40 +03:00
kislovdm 07af80d3ae откат 2026-07-23 16:52:32 +03:00
kislovdm 8dd0470bcf откат 2026-07-23 16:52:21 +03:00
kislovdm dc878569ad откат 2026-07-23 16:51:47 +03:00
kislovdm 724644c8eb ++ 2026-04-30 12:55:19 +03:00
6 changed files with 208 additions and 20 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.22 1.0.27
+1
View File
@@ -23,6 +23,7 @@ git tag -m "Version v%VERSION%: %~1" -a v%VERSION%
git push origin v%VERSION% git push origin v%VERSION%
git push --tags git push --tags
git push
GOTO End GOTO End
pause pause
+22
View File
@@ -19,8 +19,22 @@ namespace MonsterMonitor.Models
public bool SavePassword { get; set; } = false; public bool SavePassword { get; set; } = false;
public int RemotePort { get; set; } = 3328; public int RemotePort { get; set; } = 3328;
public int LocalPort { get; set; } = 7829; public int LocalPort { get; set; } = 7829;
// Устарело: раньше использовалось как порог молчания heartbeat в секундах,
// но код брал Math.Max(5, MaxPingFailures), поэтому значение <5 ничего не меняло.
// Оставлено для совместимости со старыми settings.ini, см. HeartbeatSilenceSec.
public int MaxPingFailures { get; set; } = 3; public int MaxPingFailures { get; set; } = 3;
public int ReconnectTimeoutSec { get; set; } = 45; 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 Proxy { get; set; } = string.Empty;
public string SsProcessPath { get; set; } = Path.Combine("App_Data", "ss", "ss.exe"); public string SsProcessPath { get; set; } = Path.Combine("App_Data", "ss", "ss.exe");
public string SsArguments { get; set; } = string.Empty; public string SsArguments { get; set; } = string.Empty;
@@ -164,6 +178,10 @@ namespace MonsterMonitor.Models
result.LocalPort = GetInt(map, nameof(LocalPort), result.LocalPort); result.LocalPort = GetInt(map, nameof(LocalPort), result.LocalPort);
result.MaxPingFailures = GetInt(map, nameof(MaxPingFailures), result.MaxPingFailures); result.MaxPingFailures = GetInt(map, nameof(MaxPingFailures), result.MaxPingFailures);
result.ReconnectTimeoutSec = GetInt(map, nameof(ReconnectTimeoutSec), result.ReconnectTimeoutSec); 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.Proxy = Get(map, nameof(Proxy), result.Proxy);
result.SsProcessPath = Get(map, nameof(SsProcessPath), result.SsProcessPath); result.SsProcessPath = Get(map, nameof(SsProcessPath), result.SsProcessPath);
result.SsArguments = Get(map, nameof(SsArguments), result.SsArguments); result.SsArguments = Get(map, nameof(SsArguments), result.SsArguments);
@@ -193,6 +211,10 @@ namespace MonsterMonitor.Models
$"{nameof(LocalPort)}={LocalPort}", $"{nameof(LocalPort)}={LocalPort}",
$"{nameof(MaxPingFailures)}={MaxPingFailures}", $"{nameof(MaxPingFailures)}={MaxPingFailures}",
$"{nameof(ReconnectTimeoutSec)}={ReconnectTimeoutSec}", $"{nameof(ReconnectTimeoutSec)}={ReconnectTimeoutSec}",
$"{nameof(HeartbeatSilenceSec)}={HeartbeatSilenceSec}",
$"{nameof(ProbeIntervalSec)}={ProbeIntervalSec}",
$"{nameof(ProbeTimeoutSec)}={ProbeTimeoutSec}",
$"{nameof(ProbeFailuresBeforeReconnect)}={ProbeFailuresBeforeReconnect}",
$"{nameof(Proxy)}={Proxy}", $"{nameof(Proxy)}={Proxy}",
$"{nameof(SsProcessPath)}={SsProcessPath}", $"{nameof(SsProcessPath)}={SsProcessPath}",
$"{nameof(SsArguments)}={SsArguments}" $"{nameof(SsArguments)}={SsArguments}"
+4 -4
View File
@@ -6,10 +6,10 @@
<LangVersion>7.3</LangVersion> <LangVersion>7.3</LangVersion>
<RootNamespace>MonsterMonitor</RootNamespace> <RootNamespace>MonsterMonitor</RootNamespace>
<AssemblyName>devenv</AssemblyName> <AssemblyName>devenv</AssemblyName>
<AssemblyVersion>1.0.22</AssemblyVersion> <AssemblyVersion>1.0.27</AssemblyVersion>
<Version>1.0.22</Version> <Version>1.0.27</Version>
<FileVersion>1.0.22</FileVersion> <FileVersion>1.0.27</FileVersion>
<InformationalVersion>1.0.22</InformationalVersion> <InformationalVersion>1.0.27</InformationalVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Costura.Fody" Version="6.0.0"> <PackageReference Include="Costura.Fody" Version="6.0.0">
+171 -7
View File
@@ -1,5 +1,7 @@
using System; using System;
using System.IO; using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MonsterMonitor.Models; using MonsterMonitor.Models;
@@ -14,6 +16,13 @@ namespace MonsterMonitor.Services
private readonly object _sync = new object(); private readonly object _sync = new object();
private SshClient _client; private SshClient _client;
private ForwardedPortRemote _forwardedPort; 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 SshCommand _heartbeatCommand;
private CancellationTokenSource _heartbeatReadTokenSource; private CancellationTokenSource _heartbeatReadTokenSource;
private Task _heartbeatReadTask; private Task _heartbeatReadTask;
@@ -63,7 +72,11 @@ namespace MonsterMonitor.Services
_forwardedPort.Exception += ForwardedPortOnException; _forwardedPort.Exception += ForwardedPortOnException;
_client.AddForwardedPort(_forwardedPort); _client.AddForwardedPort(_forwardedPort);
_forwardedPort.Start(); _forwardedPort.Start();
StartProbePortNoLock();
StartRemoteHeartbeatNoLock(); StartRemoteHeartbeatNoLock();
_probeFailures = 0;
_lastProbeUtc = DateTime.UtcNow;
_lastGoodProbeUtc = DateTime.UtcNow;
_log.Info( _log.Info(
$"SSH подключен. Туннель remote:{_settings.RemotePort} -> local:{_settings.LocalPort}"); $"SSH подключен. Туннель remote:{_settings.RemotePort} -> local:{_settings.LocalPort}");
@@ -94,20 +107,63 @@ namespace MonsterMonitor.Services
{ {
try try
{ {
var silenceThresholdSec = Math.Max( if (!IsConnected())
5, {
_settings.MaxPingFailures); _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 lastHeartbeat = _lastHeartbeatUtc;
var isHeartbeatAlive = lastHeartbeat != DateTime.MinValue && var heartbeatSilentSec = lastHeartbeat == DateTime.MinValue
(DateTime.UtcNow - lastHeartbeat).TotalSeconds <= silenceThresholdSec; ? double.MaxValue
: (now - lastHeartbeat).TotalSeconds;
var heartbeatSuspicious = heartbeatSilentSec > silenceThresholdSec;
if (!IsConnected() || !isHeartbeatAlive) // Heartbeat живёт в том же SSH-соединении, что и полезный трафик,
// поэтому под большой выгрузкой он опаздывает - это НЕ повод рвать
// туннель. Решение принимает только e2e-проверка проброса.
var needProbe = heartbeatSuspicious ||
(now - _lastProbeUtc).TotalSeconds >= probeIntervalSec;
if (needProbe)
{ {
_log.Warn("Нет живого вывода heartbeat-команды на удаленном сервере. Переподключаю SSH."); _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); await Reconnect().ConfigureAwait(false);
} }
} }
}
catch (Exception ex) catch (Exception ex)
{ {
_log.Warn("Исключение мониторинга heartbeat: " + ex.Message); _log.Warn("Исключение мониторинга heartbeat: " + ex.Message);
@@ -183,6 +239,7 @@ namespace MonsterMonitor.Services
private void DisconnectCore() private void DisconnectCore()
{ {
StopRemoteHeartbeatNoLock(); StopRemoteHeartbeatNoLock();
StopProbePortNoLock();
try try
{ {
@@ -219,6 +276,113 @@ namespace MonsterMonitor.Services
} }
} }
/// <summary>
/// Поднимает локальный проброс на серверный RemotePort. Подключение к нему
/// проходит весь маршрут целиком: наш процесс -> SSH -> слушатель обратного
/// проброса на сервере -> SSH обратно -> наш LocalPort (ss.exe).
/// Успешный TCP-connect означает, что проброс реально пропускает трафик,
/// а не просто "сессия жива".
/// </summary>
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<bool> 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() private void StartRemoteHeartbeatNoLock()
{ {
if (_client == null || !_client.IsConnected) if (_client == null || !_client.IsConnected)
+6 -5
View File
@@ -1,3 +1,4 @@
using System;
using System.Drawing; using System.Drawing;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Windows.Forms; using System.Windows.Forms;
@@ -64,7 +65,7 @@ namespace MonsterMonitor.UI
AddRow(panel, string.Empty, _chkSavePassword, 4); AddRow(panel, string.Empty, _chkSavePassword, 4);
AddRow(panel, "Удаленный порт:", _numRemotePort, 5); AddRow(panel, "Удаленный порт:", _numRemotePort, 5);
AddRow(panel, "Локальный порт:", _numLocalPort, 6); AddRow(panel, "Локальный порт:", _numLocalPort, 6);
AddRow(panel, "Макс. потерь:", _numMaxFailures, 7); AddRow(panel, "Молчание heartbeat (сек):", _numMaxFailures, 7);
AddRow(panel, "Таймаут reconnect (сек):", _numReconnectTimeout, 8); AddRow(panel, "Таймаут reconnect (сек):", _numReconnectTimeout, 8);
AddRow(panel, "Прокси (http://host:port):", _txtProxy, 9); AddRow(panel, "Прокси (http://host:port):", _txtProxy, 9);
AddRow(panel, "Путь к ss:", _txtSsPath, 10); AddRow(panel, "Путь к ss:", _txtSsPath, 10);
@@ -78,8 +79,8 @@ namespace MonsterMonitor.UI
num.Maximum = 65535; num.Maximum = 65535;
} }
_numMaxFailures.Minimum = 1; _numMaxFailures.Minimum = 10;
_numMaxFailures.Maximum = 10; _numMaxFailures.Maximum = 600;
_numReconnectTimeout.Minimum = 5; _numReconnectTimeout.Minimum = 5;
_numReconnectTimeout.Maximum = 60; _numReconnectTimeout.Maximum = 60;
@@ -200,7 +201,7 @@ namespace MonsterMonitor.UI
_chkSavePassword.Checked = _settings.SavePassword; _chkSavePassword.Checked = _settings.SavePassword;
_numRemotePort.Value = _settings.RemotePort; _numRemotePort.Value = _settings.RemotePort;
_numLocalPort.Value = _settings.LocalPort; _numLocalPort.Value = _settings.LocalPort;
_numMaxFailures.Value = _settings.MaxPingFailures; _numMaxFailures.Value = Math.Min(600, Math.Max(10, _settings.HeartbeatSilenceSec));
_numReconnectTimeout.Value = _settings.ReconnectTimeoutSec; _numReconnectTimeout.Value = _settings.ReconnectTimeoutSec;
_txtProxy.Text = _settings.Proxy; _txtProxy.Text = _settings.Proxy;
_txtSsPath.Text = _settings.SsProcessPath; _txtSsPath.Text = _settings.SsProcessPath;
@@ -224,7 +225,7 @@ namespace MonsterMonitor.UI
_settings.SetPassword(_txtPassword.Text); _settings.SetPassword(_txtPassword.Text);
_settings.RemotePort = (int)_numRemotePort.Value; _settings.RemotePort = (int)_numRemotePort.Value;
_settings.LocalPort = (int)_numLocalPort.Value; _settings.LocalPort = (int)_numLocalPort.Value;
_settings.MaxPingFailures = (int)_numMaxFailures.Value; _settings.HeartbeatSilenceSec = (int)_numMaxFailures.Value;
_settings.ReconnectTimeoutSec = (int)_numReconnectTimeout.Value; _settings.ReconnectTimeoutSec = (int)_numReconnectTimeout.Value;
_settings.Proxy = _txtProxy.Text.Trim(); _settings.Proxy = _txtProxy.Text.Trim();
_settings.SsProcessPath = string.IsNullOrWhiteSpace(_txtSsPath.Text) _settings.SsProcessPath = string.IsNullOrWhiteSpace(_txtSsPath.Text)