init
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
|
||||
</startup>
|
||||
</configuration>
|
||||
@@ -0,0 +1,2 @@
|
||||
auth none
|
||||
socks -i127.0.0.1 -p7683
|
||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<Costura />
|
||||
</Weavers>
|
||||
@@ -0,0 +1,176 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
|
||||
<xs:element name="Weavers">
|
||||
<xs:complexType>
|
||||
<xs:all>
|
||||
<xs:element name="Costura" minOccurs="0" maxOccurs="1">
|
||||
<xs:complexType>
|
||||
<xs:all>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="IncludeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeRuntimeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="IncludeRuntimeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged32Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX86Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with line breaks.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged64Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX64Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinArm64Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="PreloadOrder" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The order of preloaded assemblies, delimited with line breaks.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
</xs:all>
|
||||
<xs:attribute name="CreateTemporaryAssemblies" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="IncludeDebugSymbols" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Controls if .pdbs for reference assemblies are also embedded.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="IncludeRuntimeReferences" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Controls if runtime assemblies are also embedded.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="UseRuntimeReferencePaths" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Controls whether the runtime assemblies are embedded with their full path or only with their assembly name.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="DisableCompression" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="DisableCleanup" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="DisableEventSubscription" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The attach method no longer subscribes to the `AppDomain.AssemblyResolve` (.NET 4.x) and `AssemblyLoadContext.Resolving` (.NET 6.0+) events.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="LoadAtModuleInit" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="IgnoreSatelliteAssemblies" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ExcludeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="IncludeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ExcludeRuntimeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="IncludeRuntimeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Unmanaged32Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="UnmanagedWinX86Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with |.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Unmanaged64Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="UnmanagedWinX64Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="UnmanagedWinArm64Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="PreloadOrder" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The order of preloaded assemblies, delimited with |.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:all>
|
||||
<xs:attribute name="VerifyAssembly" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="GenerateXsd" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SshTunnelMonitor.Models
|
||||
{
|
||||
public sealed class AppSettings
|
||||
{
|
||||
private const string ProtectedPrefix = "dpapi:";
|
||||
|
||||
public string SshHost { get; set; } = "127.0.0.1";
|
||||
public int SshPort { get; set; } = 22;
|
||||
public string SshUsername { get; set; } = "user";
|
||||
public string SshPasswordProtected { get; set; } = string.Empty;
|
||||
public string SystemPasswordProtected { get; set; } = string.Empty;
|
||||
public string ThreeProxyPasswordProtected { get; set; } = string.Empty;
|
||||
public bool SavePassword { get; set; } = false;
|
||||
public int RemotePort { get; set; } = 3328;
|
||||
public int LocalPort { get; set; } = 7829;
|
||||
public int MaxPingFailures { get; set; } = 3;
|
||||
public int ReconnectTimeoutSec { get; set; } = 45;
|
||||
public string SsProcessPath { get; set; } = Path.Combine("App_Data", "ss", "ss.exe");
|
||||
public string SsArguments { get; set; } = string.Empty;
|
||||
|
||||
public string GetPassword()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SshPasswordProtected))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (!SshPasswordProtected.StartsWith(ProtectedPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return SshPasswordProtected;
|
||||
}
|
||||
|
||||
var protectedData = Convert.FromBase64String(SshPasswordProtected.Substring(ProtectedPrefix.Length));
|
||||
var unprotected = ProtectedData.Unprotect(protectedData, null, DataProtectionScope.CurrentUser);
|
||||
return Encoding.UTF8.GetString(unprotected);
|
||||
}
|
||||
|
||||
public void SetPassword(string password)
|
||||
{
|
||||
if (!SavePassword || string.IsNullOrEmpty(password))
|
||||
{
|
||||
SshPasswordProtected = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(password);
|
||||
var protectedData = ProtectedData.Protect(bytes, null, DataProtectionScope.CurrentUser);
|
||||
SshPasswordProtected = ProtectedPrefix + Convert.ToBase64String(protectedData);
|
||||
}
|
||||
|
||||
public string GetSystemPassword()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SystemPasswordProtected))
|
||||
{
|
||||
return "STerra";
|
||||
}
|
||||
|
||||
if (!SystemPasswordProtected.StartsWith(ProtectedPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return SystemPasswordProtected;
|
||||
}
|
||||
|
||||
var protectedData = Convert.FromBase64String(SystemPasswordProtected.Substring(ProtectedPrefix.Length));
|
||||
var unprotected = ProtectedData.Unprotect(protectedData, null, DataProtectionScope.CurrentUser);
|
||||
return Encoding.UTF8.GetString(unprotected);
|
||||
}
|
||||
|
||||
public void SetSystemPassword(string password)
|
||||
{
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
SystemPasswordProtected = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(password);
|
||||
var protectedData = ProtectedData.Protect(bytes, null, DataProtectionScope.CurrentUser);
|
||||
SystemPasswordProtected = ProtectedPrefix + Convert.ToBase64String(protectedData);
|
||||
}
|
||||
|
||||
public string GetThreeProxyPassword()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ThreeProxyPasswordProtected))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (!ThreeProxyPasswordProtected.StartsWith(ProtectedPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return ThreeProxyPasswordProtected;
|
||||
}
|
||||
|
||||
var protectedData = Convert.FromBase64String(ThreeProxyPasswordProtected.Substring(ProtectedPrefix.Length));
|
||||
var unprotected = ProtectedData.Unprotect(protectedData, null, DataProtectionScope.CurrentUser);
|
||||
return Encoding.UTF8.GetString(unprotected);
|
||||
}
|
||||
|
||||
public void SetThreeProxyPassword(string password)
|
||||
{
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
ThreeProxyPasswordProtected = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(password);
|
||||
var protectedData = ProtectedData.Protect(bytes, null, DataProtectionScope.CurrentUser);
|
||||
ThreeProxyPasswordProtected = ProtectedPrefix + Convert.ToBase64String(protectedData);
|
||||
}
|
||||
|
||||
public static string GetSettingsPath()
|
||||
{
|
||||
var directory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"MonsterMonitor");
|
||||
Directory.CreateDirectory(directory);
|
||||
return Path.Combine(directory, "settings.ini");
|
||||
}
|
||||
|
||||
public static AppSettings Load()
|
||||
{
|
||||
var path = GetSettingsPath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new AppSettings();
|
||||
}
|
||||
|
||||
var result = new AppSettings();
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var line in File.ReadAllLines(path))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separator = line.IndexOf('=');
|
||||
if (separator <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = line.Substring(0, separator).Trim();
|
||||
var value = line.Substring(separator + 1).Trim();
|
||||
map[key] = value;
|
||||
}
|
||||
|
||||
result.SshHost = Get(map, nameof(SshHost), result.SshHost);
|
||||
result.SshPort = GetInt(map, nameof(SshPort), result.SshPort);
|
||||
result.SshUsername = Get(map, nameof(SshUsername), result.SshUsername);
|
||||
result.SshPasswordProtected = Get(map, nameof(SshPasswordProtected), result.SshPasswordProtected);
|
||||
result.SystemPasswordProtected = Get(map, nameof(SystemPasswordProtected), result.SystemPasswordProtected);
|
||||
result.ThreeProxyPasswordProtected = Get(map, nameof(ThreeProxyPasswordProtected), result.ThreeProxyPasswordProtected);
|
||||
result.SavePassword = GetBool(map, nameof(SavePassword), result.SavePassword);
|
||||
result.RemotePort = GetInt(map, nameof(RemotePort), result.RemotePort);
|
||||
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.SsProcessPath = Get(map, nameof(SsProcessPath), result.SsProcessPath);
|
||||
result.SsArguments = Get(map, nameof(SsArguments), result.SsArguments);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(result.SystemPasswordProtected))
|
||||
{
|
||||
result.SetSystemPassword("STerra");
|
||||
result.Save();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
var path = GetSettingsPath();
|
||||
var lines = new[]
|
||||
{
|
||||
$"{nameof(SshHost)}={SshHost}",
|
||||
$"{nameof(SshPort)}={SshPort}",
|
||||
$"{nameof(SshUsername)}={SshUsername}",
|
||||
$"{nameof(SshPasswordProtected)}={SshPasswordProtected}",
|
||||
$"{nameof(SystemPasswordProtected)}={SystemPasswordProtected}",
|
||||
$"{nameof(ThreeProxyPasswordProtected)}={ThreeProxyPasswordProtected}",
|
||||
$"{nameof(SavePassword)}={SavePassword}",
|
||||
$"{nameof(RemotePort)}={RemotePort}",
|
||||
$"{nameof(LocalPort)}={LocalPort}",
|
||||
$"{nameof(MaxPingFailures)}={MaxPingFailures}",
|
||||
$"{nameof(ReconnectTimeoutSec)}={ReconnectTimeoutSec}",
|
||||
$"{nameof(SsProcessPath)}={SsProcessPath}",
|
||||
$"{nameof(SsArguments)}={SsArguments}"
|
||||
};
|
||||
File.WriteAllLines(path, lines);
|
||||
}
|
||||
|
||||
private static string Get(IDictionary<string, string> map, string key, string defaultValue)
|
||||
{
|
||||
return map.TryGetValue(key, out var value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
private static int GetInt(IDictionary<string, string> map, string key, int defaultValue)
|
||||
{
|
||||
if (map.TryGetValue(key, out var value) && int.TryParse(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private static bool GetBool(IDictionary<string, string> map, string key, bool defaultValue)
|
||||
{
|
||||
if (map.TryGetValue(key, out var value) && bool.TryParse(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net472</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<LangVersion>7.3</LangVersion>
|
||||
<RootNamespace>SshTunnelMonitor</RootNamespace>
|
||||
<AssemblyName>MonsterMonitor</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Costura.Fody" Version="6.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Fody" Version="6.9.3">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<Reference Include="System.Security" />
|
||||
<PackageReference Include="SSH.NET" Version="2025.1.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="App_Data\ss\ss.cfg">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="App_Data\ss\ss.exe">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="logistics_tunnel_icon_148216.ico">
|
||||
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
using SshTunnelMonitor.UI;
|
||||
|
||||
namespace SshTunnelMonitor
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static void Main()
|
||||
{
|
||||
Application.ThreadException += (sender, args) =>
|
||||
{
|
||||
MessageBox.Show(
|
||||
args.Exception.ToString(),
|
||||
"Необработанное исключение UI-потока",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
};
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += (sender, args) =>
|
||||
{
|
||||
MessageBox.Show(
|
||||
args.ExceptionObject.ToString(),
|
||||
"Критическое исключение",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
};
|
||||
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new MainForm());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using SshTunnelMonitor.Models;
|
||||
|
||||
namespace SshTunnelMonitor.Services
|
||||
{
|
||||
public sealed class AppController : IDisposable
|
||||
{
|
||||
private readonly LogService _log;
|
||||
private readonly PowerManagementService _power;
|
||||
private readonly SsConfigService _ssConfig;
|
||||
private ProcessMonitorService _processMonitor;
|
||||
private SshTunnelService _sshTunnel;
|
||||
|
||||
public AppController(LogService log, PowerManagementService power)
|
||||
{
|
||||
_log = log;
|
||||
_power = power;
|
||||
_ssConfig = new SsConfigService(_log);
|
||||
}
|
||||
|
||||
public void Start(AppSettings settings)
|
||||
{
|
||||
Stop();
|
||||
|
||||
_power.PreventSleep();
|
||||
_processMonitor = new ProcessMonitorService(_log);
|
||||
_sshTunnel = new SshTunnelService(_log);
|
||||
|
||||
_ssConfig.EnsureConfig(settings);
|
||||
_processMonitor.Start(settings.SsProcessPath, settings.SsArguments);
|
||||
_sshTunnel.Start(settings);
|
||||
|
||||
_log.Info("Сервисы приложения запущены.");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_sshTunnel?.Dispose();
|
||||
_sshTunnel = null;
|
||||
|
||||
_processMonitor?.Dispose();
|
||||
_processMonitor = null;
|
||||
|
||||
_power.RestoreDefault();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using SshTunnelMonitor.Models;
|
||||
|
||||
namespace SshTunnelMonitor.Services
|
||||
{
|
||||
public class AuthMonitor: IAuthMonitor
|
||||
{
|
||||
[DllImport("user32.dll")]
|
||||
static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
|
||||
|
||||
private readonly AppSettings _settings;
|
||||
|
||||
public AuthMonitor(AppSettings settings)
|
||||
{
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
private string GetActiveWindowTitle()
|
||||
{
|
||||
const int nChars = 256;
|
||||
var buff = new StringBuilder(nChars);
|
||||
var handle = GetForegroundWindow();
|
||||
return GetWindowText(handle, buff, nChars) > 0 ? buff.ToString() : null;
|
||||
}
|
||||
|
||||
public async Task StartMonitor()
|
||||
{
|
||||
_ = Task.Run(async () => await CheckProcess());
|
||||
}
|
||||
|
||||
private async Task CheckProcess()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process[] processlist = Process.GetProcesses();
|
||||
foreach (Process process in processlist)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(process.MainWindowTitle) && process.MainWindowTitle.Contains("XAuth request"))
|
||||
{
|
||||
SetForegroundWindow(process.MainWindowHandle);
|
||||
}
|
||||
}
|
||||
|
||||
var activeWindowTitle = GetActiveWindowTitle();
|
||||
if (activeWindowTitle?.Contains("XAuth request") == true)
|
||||
{
|
||||
SendKeys.SendWait(_settings.GetSystemPassword());
|
||||
SendKeys.SendWait("{ENTER}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(ex.Message + ex.StackTrace);
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface IAuthMonitor
|
||||
{
|
||||
Task StartMonitor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
|
||||
namespace SshTunnelMonitor.Services
|
||||
{
|
||||
public enum LogLevel
|
||||
{
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error
|
||||
}
|
||||
|
||||
public sealed class LogEntry
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
public LogLevel Level { get; set; }
|
||||
public string Message { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LogService
|
||||
{
|
||||
public event Action<LogEntry> LogReceived;
|
||||
|
||||
public void Log(LogLevel level, string message)
|
||||
{
|
||||
LogReceived?.Invoke(new LogEntry
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
Level = level,
|
||||
Message = message ?? string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
public void Info(string message) => Log(LogLevel.Info, message);
|
||||
public void Warn(string message) => Log(LogLevel.Warn, message);
|
||||
public void Error(string message) => Log(LogLevel.Error, message);
|
||||
public void Debug(string message) => Log(LogLevel.Debug, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace SshTunnelMonitor.Services
|
||||
{
|
||||
public sealed class PowerManagementService
|
||||
{
|
||||
[Flags]
|
||||
private enum ExecutionState : uint
|
||||
{
|
||||
ES_SYSTEM_REQUIRED = 0x00000001,
|
||||
ES_DISPLAY_REQUIRED = 0x00000002,
|
||||
ES_CONTINUOUS = 0x80000000
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern ExecutionState SetThreadExecutionState(ExecutionState flags);
|
||||
|
||||
public void PreventSleep()
|
||||
{
|
||||
SetThreadExecutionState(
|
||||
ExecutionState.ES_CONTINUOUS |
|
||||
ExecutionState.ES_SYSTEM_REQUIRED |
|
||||
ExecutionState.ES_DISPLAY_REQUIRED);
|
||||
}
|
||||
|
||||
public void RestoreDefault()
|
||||
{
|
||||
SetThreadExecutionState(ExecutionState.ES_CONTINUOUS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
|
||||
namespace SshTunnelMonitor.Services
|
||||
{
|
||||
public sealed class ProcessMonitorService : IDisposable
|
||||
{
|
||||
private readonly LogService _log;
|
||||
private readonly object _sync = new object();
|
||||
private readonly Timer _watchdogTimer;
|
||||
private Process _process;
|
||||
private string _path;
|
||||
private string _arguments;
|
||||
private bool _stopping;
|
||||
|
||||
public ProcessMonitorService(LogService log)
|
||||
{
|
||||
_log = log;
|
||||
_watchdogTimer = new Timer(WatchdogTick, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
public void Start(string path, string arguments)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_path = path;
|
||||
_arguments = arguments ?? string.Empty;
|
||||
_stopping = false;
|
||||
EnsureStarted();
|
||||
_watchdogTimer.Change(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureStarted()
|
||||
{
|
||||
if (IsProcessRunning(_process))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_process != null)
|
||||
{
|
||||
_process.Exited -= ProcessOnExited;
|
||||
_process.Dispose();
|
||||
}
|
||||
_process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = _path,
|
||||
Arguments = _arguments,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
},
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
//_process.Exited += ProcessOnExited;
|
||||
_process.Start();
|
||||
_log.Info("Процесс ss запущен.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error("Ошибка запуска ss: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessOnExited(object sender, EventArgs e)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_stopping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Warn("Процесс ss завершился, запускаю заново.");
|
||||
EnsureStarted();
|
||||
}
|
||||
}
|
||||
|
||||
private void WatchdogTick(object state)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_stopping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureStarted();
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_stopping = true;
|
||||
_watchdogTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
|
||||
try
|
||||
{
|
||||
if (IsProcessRunning(_process))
|
||||
{
|
||||
_process.Kill();
|
||||
_process.WaitForExit(5000);
|
||||
_log.Info("Процесс ss остановлен.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warn("Не удалось корректно завершить ss: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_process != null)
|
||||
{
|
||||
_process.Exited -= ProcessOnExited;
|
||||
_process.Dispose();
|
||||
}
|
||||
_process = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsProcessRunning(Process process)
|
||||
{
|
||||
if (process == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_watchdogTimer.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using SshTunnelMonitor.Models;
|
||||
|
||||
namespace SshTunnelMonitor.Services
|
||||
{
|
||||
public sealed class SsConfigService
|
||||
{
|
||||
private readonly LogService _log;
|
||||
|
||||
public SsConfigService(LogService log)
|
||||
{
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void EnsureConfig(AppSettings settings)
|
||||
{
|
||||
var password = settings.GetThreeProxyPassword();
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
password = GeneratePassword();
|
||||
settings.SetThreeProxyPassword(password);
|
||||
settings.Save();
|
||||
_log.Info("Сгенерирован пароль прокси и сохранен в настройках.");
|
||||
}
|
||||
var port = settings.LocalPort;
|
||||
|
||||
var config = "users admin:CL:" + password + "\r\n" +
|
||||
"auth strong\r\n" +
|
||||
$"socks -i127.0.0.1 -p{port}";
|
||||
|
||||
var configPath = GetConfigPath();
|
||||
var directory = Path.GetDirectoryName(configPath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(configPath, config);
|
||||
_log.Info("Сконфигурирован ss.cfg: " + configPath);
|
||||
}
|
||||
|
||||
private static string GetConfigPath()
|
||||
{
|
||||
return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "App_Data", "ss", "ss.cfg");
|
||||
}
|
||||
|
||||
private static string GeneratePassword()
|
||||
{
|
||||
const string alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
const int length = 20;
|
||||
var bytes = new byte[length];
|
||||
using (var rng = RandomNumberGenerator.Create())
|
||||
{
|
||||
rng.GetBytes(bytes);
|
||||
}
|
||||
|
||||
var chars = new char[length];
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
chars[i] = alphabet[bytes[i] % alphabet.Length];
|
||||
}
|
||||
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Renci.SshNet;
|
||||
using Renci.SshNet.Common;
|
||||
using SshTunnelMonitor.Models;
|
||||
|
||||
namespace SshTunnelMonitor.Services
|
||||
{
|
||||
public sealed class SshTunnelService : IDisposable
|
||||
{
|
||||
private readonly LogService _log;
|
||||
private readonly object _sync = new object();
|
||||
private SshClient _client;
|
||||
private ForwardedPortRemote _forwardedPort;
|
||||
private SshCommand _heartbeatCommand;
|
||||
private CancellationTokenSource _heartbeatReadTokenSource;
|
||||
private Task _heartbeatReadTask;
|
||||
private CancellationTokenSource _monitorTokenSource;
|
||||
private AppSettings _settings;
|
||||
private bool _disposed;
|
||||
private bool _reconnectInProgress;
|
||||
private DateTime _lastHeartbeatUtc = DateTime.MinValue;
|
||||
private bool _remoteIsWindows;
|
||||
|
||||
public SshTunnelService(LogService log)
|
||||
{
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void Start(AppSettings settings)
|
||||
{
|
||||
_settings = settings;
|
||||
Connect();
|
||||
StartMonitor();
|
||||
}
|
||||
|
||||
private void Connect()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
DisconnectCore();
|
||||
|
||||
var password = _settings.GetPassword();
|
||||
var authMethod = new PasswordAuthenticationMethod(_settings.SshUsername, password);
|
||||
var connectionInfo = new ConnectionInfo(_settings.SshHost, _settings.SshPort, _settings.SshUsername, authMethod);
|
||||
|
||||
_client = new SshClient(connectionInfo);
|
||||
_client.ErrorOccurred += ClientOnErrorOccurred;
|
||||
_client.Connect();
|
||||
|
||||
_forwardedPort = new ForwardedPortRemote((uint)_settings.RemotePort, "127.0.0.1", (uint)_settings.LocalPort);
|
||||
_forwardedPort.Exception += ForwardedPortOnException;
|
||||
_client.AddForwardedPort(_forwardedPort);
|
||||
_forwardedPort.Start();
|
||||
StartRemoteHeartbeatNoLock();
|
||||
|
||||
_log.Info(
|
||||
$"SSH подключен. Туннель remote:{_settings.RemotePort} -> local:{_settings.LocalPort}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ClientOnErrorOccurred(object sender, ExceptionEventArgs e)
|
||||
{
|
||||
_log.Warn("Ошибка SSH: " + e.Exception.Message);
|
||||
ReconnectSoon();
|
||||
}
|
||||
|
||||
private void ForwardedPortOnException(object sender, ExceptionEventArgs e)
|
||||
{
|
||||
_log.Warn("Ошибка перенаправления порта: " + e.Exception.Message);
|
||||
ReconnectSoon();
|
||||
}
|
||||
|
||||
private void StartMonitor()
|
||||
{
|
||||
_monitorTokenSource = new CancellationTokenSource();
|
||||
Task.Run(() => MonitorLoop(_monitorTokenSource.Token));
|
||||
}
|
||||
|
||||
private async Task MonitorLoop(CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var silenceThresholdSec = Math.Max(
|
||||
5,
|
||||
_settings.MaxPingFailures);
|
||||
|
||||
var lastHeartbeat = _lastHeartbeatUtc;
|
||||
var isHeartbeatAlive = lastHeartbeat != DateTime.MinValue &&
|
||||
(DateTime.UtcNow - lastHeartbeat).TotalSeconds <= silenceThresholdSec;
|
||||
|
||||
if (!IsConnected() || !isHeartbeatAlive)
|
||||
{
|
||||
_log.Warn("Нет живого вывода heartbeat-команды на удаленном сервере. Переподключаю SSH.");
|
||||
await Reconnect().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warn("Исключение мониторинга heartbeat: " + ex.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), token).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsConnected()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _client != null && _client.IsConnected;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReconnectSoon()
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(1000).ConfigureAwait(false);
|
||||
await Reconnect().ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task Reconnect()
|
||||
{
|
||||
if (_reconnectInProgress || _disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_reconnectInProgress = true;
|
||||
try
|
||||
{
|
||||
var timeout = Math.Min(60, Math.Max(5, _settings.ReconnectTimeoutSec));
|
||||
_log.Warn("Перезапуск SSH-соединения...");
|
||||
|
||||
var reconnectTask = Task.Run(() =>
|
||||
{
|
||||
DisconnectCore();
|
||||
Connect();
|
||||
});
|
||||
|
||||
var completed = await Task.WhenAny(reconnectTask, Task.Delay(TimeSpan.FromSeconds(timeout))).ConfigureAwait(false);
|
||||
if (completed != reconnectTask)
|
||||
{
|
||||
_log.Error("Переподключение превысило таймаут " + timeout + "с.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Info("SSH-соединение восстановлено.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error("Ошибка переподключения SSH: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_reconnectInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void DisconnectCore()
|
||||
{
|
||||
StopRemoteHeartbeatNoLock();
|
||||
|
||||
try
|
||||
{
|
||||
if (_forwardedPort != null)
|
||||
{
|
||||
if (_forwardedPort.IsStarted)
|
||||
{
|
||||
_forwardedPort.Stop();
|
||||
}
|
||||
_forwardedPort.Dispose();
|
||||
_forwardedPort = null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors on shutdown.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_client != null)
|
||||
{
|
||||
if (_client.IsConnected)
|
||||
{
|
||||
_client.Disconnect();
|
||||
}
|
||||
_client.Dispose();
|
||||
_client = null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors on shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
private void StartRemoteHeartbeatNoLock()
|
||||
{
|
||||
if (_client == null || !_client.IsConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StopRemoteHeartbeatNoLock();
|
||||
_remoteIsWindows = DetectRemoteWindowsNoLock();
|
||||
|
||||
var heartbeatCommand = _remoteIsWindows
|
||||
? "ping ya.ru -t"
|
||||
: "while :; do echo 'some type of ping response '$C; C=$((C+1)); sleep 1; done";
|
||||
|
||||
_heartbeatCommand = _client.CreateCommand(heartbeatCommand);
|
||||
_heartbeatReadTokenSource = new CancellationTokenSource();
|
||||
_lastHeartbeatUtc = DateTime.UtcNow;
|
||||
_heartbeatCommand.BeginExecute();
|
||||
|
||||
_heartbeatReadTask = Task.Run(() =>
|
||||
ReadHeartbeatOutputLoop(_heartbeatCommand, _heartbeatReadTokenSource.Token));
|
||||
_log.Info("Запущен удаленный heartbeat через SSH (" + (_remoteIsWindows ? "Windows" : "Linux/Unix") + ").");
|
||||
}
|
||||
|
||||
private void StopRemoteHeartbeatNoLock()
|
||||
{
|
||||
try
|
||||
{
|
||||
_heartbeatReadTokenSource?.Cancel();
|
||||
_heartbeatReadTask?.Wait(1000);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors on shutdown.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_heartbeatReadTokenSource?.Dispose();
|
||||
_heartbeatReadTokenSource = null;
|
||||
_heartbeatReadTask = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_heartbeatCommand?.Dispose();
|
||||
_heartbeatCommand = null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors on shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
private void ReadHeartbeatOutputLoop(SshCommand command, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var reader = new StreamReader(command.OutputStream))
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var line = reader.ReadLine();
|
||||
if (line == null)
|
||||
{
|
||||
Thread.Sleep(200);
|
||||
continue;
|
||||
}
|
||||
|
||||
_lastHeartbeatUtc = DateTime.UtcNow;
|
||||
_log.Debug("HB: " + line);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
_log.Warn("Ошибка чтения heartbeat-вывода: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool DetectRemoteWindowsNoLock()
|
||||
{
|
||||
try
|
||||
{
|
||||
var command = _client.CreateCommand("cmd /c ver");
|
||||
var output = command.Execute() ?? string.Empty;
|
||||
return output.IndexOf("windows", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_monitorTokenSource?.Cancel();
|
||||
_monitorTokenSource?.Dispose();
|
||||
_monitorTokenSource = null;
|
||||
lock (_sync)
|
||||
{
|
||||
DisconnectCore();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using SshTunnelMonitor.Models;
|
||||
using SshTunnelMonitor.Services;
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace SshTunnelMonitor.UI
|
||||
{
|
||||
public sealed class MainForm : Form
|
||||
{
|
||||
private readonly RichTextBox _console = new RichTextBox();
|
||||
private readonly Button _btnSettings = new Button();
|
||||
private readonly Button _btnExit = new Button();
|
||||
private readonly NotifyIcon _notifyIcon = new NotifyIcon();
|
||||
private readonly Icon _trayIcon;
|
||||
private readonly LogService _log = new LogService();
|
||||
private readonly PowerManagementService _power = new PowerManagementService();
|
||||
private AppController _controller;
|
||||
private AppSettings _settings;
|
||||
private bool _allowClose;
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Text = "Monster Monitor";
|
||||
Width = 980;
|
||||
Height = 620;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
_trayIcon = LoadTrayIcon();
|
||||
|
||||
BuildUi();
|
||||
BindEvents();
|
||||
}
|
||||
|
||||
protected override void OnLoad(EventArgs e)
|
||||
{
|
||||
base.OnLoad(e);
|
||||
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(MainForm));
|
||||
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));
|
||||
|
||||
_settings = AppSettings.Load();
|
||||
_controller = new AppController(_log, _power);
|
||||
RestartServices();
|
||||
}
|
||||
|
||||
private void BuildUi()
|
||||
{
|
||||
var topPanel = new Panel { Dock = DockStyle.Top, Height = 42 };
|
||||
_btnSettings.Text = "Настройки";
|
||||
_btnSettings.Width = 110;
|
||||
_btnSettings.Location = new Point(10, 8);
|
||||
|
||||
_btnExit.Text = "Выход";
|
||||
_btnExit.Width = 110;
|
||||
_btnExit.Location = new Point(130, 8);
|
||||
|
||||
topPanel.Controls.Add(_btnSettings);
|
||||
topPanel.Controls.Add(_btnExit);
|
||||
|
||||
_console.Dock = DockStyle.Fill;
|
||||
_console.ReadOnly = true;
|
||||
_console.BackColor = Color.Black;
|
||||
_console.ForeColor = Color.White;
|
||||
_console.Font = new Font("Consolas", 10f);
|
||||
|
||||
Controls.Add(_console);
|
||||
Controls.Add(topPanel);
|
||||
|
||||
_notifyIcon.Text = "Monster Monitor";
|
||||
_notifyIcon.Icon = _trayIcon;
|
||||
_notifyIcon.Visible = true;
|
||||
var trayMenu = new ContextMenuStrip();
|
||||
trayMenu.Items.Add("Открыть", null, (_, __) => RestoreFromTray());
|
||||
trayMenu.Items.Add("Выход", null, (_, __) => ExitApplication());
|
||||
_notifyIcon.ContextMenuStrip = trayMenu;
|
||||
}
|
||||
|
||||
private void BindEvents()
|
||||
{
|
||||
Resize += MainFormOnResize;
|
||||
FormClosing += MainFormOnFormClosing;
|
||||
_notifyIcon.DoubleClick += (_, __) => RestoreFromTray();
|
||||
_btnSettings.Click += (_, __) => OpenSettings();
|
||||
_btnExit.Click += (_, __) => ExitApplication();
|
||||
_log.LogReceived += AppendLog;
|
||||
}
|
||||
|
||||
private void OpenSettings()
|
||||
{
|
||||
using (var form = new SettingsForm(_settings))
|
||||
{
|
||||
if (form.ShowDialog(this) == DialogResult.OK)
|
||||
{
|
||||
_log.Info("Настройки обновлены. Перезапускаю сервисы.");
|
||||
RestartServices();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RestartServices()
|
||||
{
|
||||
try
|
||||
{
|
||||
_controller.Start(_settings);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error("Ошибка запуска сервисов: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void MainFormOnResize(object sender, EventArgs e)
|
||||
{
|
||||
if (WindowState != FormWindowState.Minimized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Hide();
|
||||
_notifyIcon.BalloonTipTitle = "Monster Monitor";
|
||||
_notifyIcon.BalloonTipText = "Приложение свернуто в трей.";
|
||||
_notifyIcon.ShowBalloonTip(1000);
|
||||
}
|
||||
|
||||
private void RestoreFromTray()
|
||||
{
|
||||
Show();
|
||||
WindowState = FormWindowState.Normal;
|
||||
Activate();
|
||||
}
|
||||
|
||||
private void ExitApplication()
|
||||
{
|
||||
_allowClose = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void MainFormOnFormClosing(object sender, FormClosingEventArgs e)
|
||||
{
|
||||
if (!_allowClose && e.CloseReason == CloseReason.UserClosing)
|
||||
{
|
||||
e.Cancel = true;
|
||||
WindowState = FormWindowState.Minimized;
|
||||
return;
|
||||
}
|
||||
|
||||
_notifyIcon.Visible = false;
|
||||
_controller?.Dispose();
|
||||
_trayIcon?.Dispose();
|
||||
}
|
||||
|
||||
private void AppendLog(LogEntry entry)
|
||||
{
|
||||
if (InvokeRequired)
|
||||
{
|
||||
BeginInvoke(new Action<LogEntry>(AppendLog), entry);
|
||||
return;
|
||||
}
|
||||
|
||||
_console.SelectionStart = _console.TextLength;
|
||||
_console.SelectionLength = 0;
|
||||
_console.SelectionColor = GetColor(entry.Level);
|
||||
_console.AppendText($"[{entry.Timestamp:HH:mm:ss}] [{entry.Level}] {entry.Message}{Environment.NewLine}");
|
||||
_console.SelectionColor = _console.ForeColor;
|
||||
_console.ScrollToCaret();
|
||||
}
|
||||
|
||||
private static Color GetColor(LogLevel level)
|
||||
{
|
||||
switch (level)
|
||||
{
|
||||
case LogLevel.Debug:
|
||||
return Color.Silver;
|
||||
case LogLevel.Warn:
|
||||
return Color.Orange;
|
||||
case LogLevel.Error:
|
||||
return Color.Red;
|
||||
default:
|
||||
return Color.White;
|
||||
}
|
||||
}
|
||||
|
||||
private static Icon LoadTrayIcon()
|
||||
{
|
||||
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(MainForm));
|
||||
return ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(MainForm));
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// MainForm
|
||||
//
|
||||
this.ClientSize = new System.Drawing.Size(284, 261);
|
||||
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));
|
||||
this.Name = "MainForm";
|
||||
this.ResumeLayout(false);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Security.Cryptography;
|
||||
using System.Windows.Forms;
|
||||
using SshTunnelMonitor.Models;
|
||||
|
||||
namespace SshTunnelMonitor.UI
|
||||
{
|
||||
public sealed class SettingsForm : Form
|
||||
{
|
||||
private readonly AppSettings _settings;
|
||||
private readonly TextBox _txtHost = new TextBox();
|
||||
private readonly NumericUpDown _numSshPort = new NumericUpDown();
|
||||
private readonly TextBox _txtUser = new TextBox();
|
||||
private readonly TextBox _txtPassword = new TextBox();
|
||||
private readonly CheckBox _chkSavePassword = new CheckBox();
|
||||
private readonly NumericUpDown _numRemotePort = new NumericUpDown();
|
||||
private readonly NumericUpDown _numLocalPort = new NumericUpDown();
|
||||
private readonly NumericUpDown _numMaxFailures = new NumericUpDown();
|
||||
private readonly NumericUpDown _numReconnectTimeout = new NumericUpDown();
|
||||
private readonly TextBox _txtSsPath = new TextBox();
|
||||
private readonly TextBox _txtSsArgs = new TextBox();
|
||||
private readonly TextBox _txtSystemPassword = new TextBox();
|
||||
private readonly CheckBox _chkShowSystemPassword = new CheckBox();
|
||||
private readonly TextBox _txtThreeProxyPassword = new TextBox();
|
||||
private readonly CheckBox _chkShowThreeProxyPassword = new CheckBox();
|
||||
private readonly Button _btnGenerateThreeProxyPassword = new Button();
|
||||
|
||||
public SettingsForm(AppSettings settings)
|
||||
{
|
||||
_settings = settings;
|
||||
Text = "Настройки";
|
||||
Width = 560;
|
||||
Height = 520;
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
|
||||
BuildLayout();
|
||||
LoadValues();
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
var panel = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(10),
|
||||
ColumnCount = 2,
|
||||
RowCount = 14
|
||||
};
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 44f));
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 56f));
|
||||
|
||||
Controls.Add(panel);
|
||||
|
||||
AddRow(panel, "Сервер:", _txtHost, 0);
|
||||
AddRow(panel, "Порт:", _numSshPort, 1);
|
||||
AddRow(panel, "Логин:", _txtUser, 2);
|
||||
_txtPassword.PasswordChar = '*';
|
||||
AddRow(panel, "Пароль:", _txtPassword, 3);
|
||||
_chkSavePassword.Text = "Сохранить пароль";
|
||||
AddRow(panel, string.Empty, _chkSavePassword, 4);
|
||||
AddRow(panel, "Удаленный порт:", _numRemotePort, 5);
|
||||
AddRow(panel, "Локальный порт:", _numLocalPort, 6);
|
||||
AddRow(panel, "Макс. потерь:", _numMaxFailures, 7);
|
||||
AddRow(panel, "Таймаут reconnect (сек):", _numReconnectTimeout, 8);
|
||||
AddRow(panel, "Путь к ss:", _txtSsPath, 9);
|
||||
AddRow(panel, "Аргументы ss:", _txtSsArgs, 10);
|
||||
BuildSystemPasswordRow(panel, 11);
|
||||
BuildThreeProxyPasswordRow(panel, 12);
|
||||
|
||||
foreach (var num in new[] { _numSshPort, _numRemotePort, _numLocalPort })
|
||||
{
|
||||
num.Minimum = 1;
|
||||
num.Maximum = 65535;
|
||||
}
|
||||
|
||||
_numMaxFailures.Minimum = 1;
|
||||
_numMaxFailures.Maximum = 10;
|
||||
_numReconnectTimeout.Minimum = 5;
|
||||
_numReconnectTimeout.Maximum = 60;
|
||||
|
||||
var buttonsPanel = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
FlowDirection = FlowDirection.RightToLeft
|
||||
};
|
||||
var btnSave = new Button { Text = "Сохранить", Width = 120, Height = 30 };
|
||||
var btnCancel = new Button { Text = "Отмена", Width = 120, Height = 30 };
|
||||
btnSave.Click += (_, __) => SaveAndClose();
|
||||
btnCancel.Click += (_, __) => DialogResult = DialogResult.Cancel;
|
||||
|
||||
buttonsPanel.Controls.Add(btnSave);
|
||||
buttonsPanel.Controls.Add(btnCancel);
|
||||
panel.Controls.Add(buttonsPanel, 0, 13);
|
||||
panel.SetColumnSpan(buttonsPanel, 2);
|
||||
}
|
||||
|
||||
private void BuildSystemPasswordRow(TableLayoutPanel panel, int row)
|
||||
{
|
||||
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 34f));
|
||||
panel.Controls.Add(new Label
|
||||
{
|
||||
Text = "Пароль Sterra:",
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
Dock = DockStyle.Fill
|
||||
}, 0, row);
|
||||
|
||||
var passPanel = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
WrapContents = false,
|
||||
AutoSize = false
|
||||
};
|
||||
|
||||
_txtSystemPassword.Width = 170;
|
||||
_txtSystemPassword.PasswordChar = '*';
|
||||
|
||||
_chkShowSystemPassword.Text = "Показать";
|
||||
_chkShowSystemPassword.AutoSize = true;
|
||||
_chkShowSystemPassword.CheckedChanged += (_, __) =>
|
||||
{
|
||||
_txtSystemPassword.PasswordChar = _chkShowSystemPassword.Checked ? '\0' : '*';
|
||||
};
|
||||
|
||||
passPanel.Controls.Add(_txtSystemPassword);
|
||||
passPanel.Controls.Add(_chkShowSystemPassword);
|
||||
panel.Controls.Add(passPanel, 1, row);
|
||||
}
|
||||
|
||||
private void BuildThreeProxyPasswordRow(TableLayoutPanel panel, int row)
|
||||
{
|
||||
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 34f));
|
||||
panel.Controls.Add(new Label
|
||||
{
|
||||
Text = "Пароль прокси:",
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
Dock = DockStyle.Fill
|
||||
}, 0, row);
|
||||
|
||||
var passPanel = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
WrapContents = false,
|
||||
AutoSize = false
|
||||
};
|
||||
|
||||
_txtThreeProxyPassword.Width = 170;
|
||||
_txtThreeProxyPassword.PasswordChar = '*';
|
||||
|
||||
_chkShowThreeProxyPassword.Text = "Показать";
|
||||
_chkShowThreeProxyPassword.AutoSize = true;
|
||||
_chkShowThreeProxyPassword.CheckedChanged += (_, __) =>
|
||||
{
|
||||
_txtThreeProxyPassword.PasswordChar = _chkShowThreeProxyPassword.Checked ? '\0' : '*';
|
||||
};
|
||||
|
||||
_btnGenerateThreeProxyPassword.Text = "Сгенерировать";
|
||||
_btnGenerateThreeProxyPassword.AutoSize = true;
|
||||
_btnGenerateThreeProxyPassword.Click += (_, __) =>
|
||||
{
|
||||
_txtThreeProxyPassword.Text = GeneratePassword();
|
||||
};
|
||||
|
||||
passPanel.Controls.Add(_txtThreeProxyPassword);
|
||||
passPanel.Controls.Add(_chkShowThreeProxyPassword);
|
||||
passPanel.Controls.Add(_btnGenerateThreeProxyPassword);
|
||||
panel.Controls.Add(passPanel, 1, row);
|
||||
}
|
||||
|
||||
private static void AddRow(TableLayoutPanel panel, string label, Control control, int row)
|
||||
{
|
||||
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 34f));
|
||||
|
||||
if (!string.IsNullOrEmpty(label))
|
||||
{
|
||||
panel.Controls.Add(new Label
|
||||
{
|
||||
Text = label,
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
Dock = DockStyle.Fill
|
||||
}, 0, row);
|
||||
}
|
||||
|
||||
control.Dock = DockStyle.Fill;
|
||||
panel.Controls.Add(control, 1, row);
|
||||
}
|
||||
|
||||
private void LoadValues()
|
||||
{
|
||||
_txtHost.Text = _settings.SshHost;
|
||||
_numSshPort.Value = _settings.SshPort;
|
||||
_txtUser.Text = _settings.SshUsername;
|
||||
_txtPassword.Text = _settings.GetPassword();
|
||||
_chkSavePassword.Checked = _settings.SavePassword;
|
||||
_numRemotePort.Value = _settings.RemotePort;
|
||||
_numLocalPort.Value = _settings.LocalPort;
|
||||
_numMaxFailures.Value = _settings.MaxPingFailures;
|
||||
_numReconnectTimeout.Value = _settings.ReconnectTimeoutSec;
|
||||
_txtSsPath.Text = _settings.SsProcessPath;
|
||||
_txtSsArgs.Text = _settings.SsArguments;
|
||||
_txtSystemPassword.Text = _settings.GetSystemPassword();
|
||||
_txtThreeProxyPassword.Text = _settings.GetThreeProxyPassword();
|
||||
}
|
||||
|
||||
private void SaveAndClose()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_txtHost.Text))
|
||||
{
|
||||
MessageBox.Show(this, "Поле сервера обязательно.", "Валидация", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.SshHost = _txtHost.Text.Trim();
|
||||
_settings.SshPort = (int)_numSshPort.Value;
|
||||
_settings.SshUsername = _txtUser.Text.Trim();
|
||||
_settings.SavePassword = _chkSavePassword.Checked;
|
||||
_settings.SetPassword(_txtPassword.Text);
|
||||
_settings.RemotePort = (int)_numRemotePort.Value;
|
||||
_settings.LocalPort = (int)_numLocalPort.Value;
|
||||
_settings.MaxPingFailures = (int)_numMaxFailures.Value;
|
||||
_settings.ReconnectTimeoutSec = (int)_numReconnectTimeout.Value;
|
||||
_settings.SsProcessPath = string.IsNullOrWhiteSpace(_txtSsPath.Text)
|
||||
? System.IO.Path.Combine("App_Data", "ss", "ss.exe")
|
||||
: _txtSsPath.Text.Trim();
|
||||
_settings.SsArguments = string.IsNullOrWhiteSpace(_txtSsArgs.Text)
|
||||
? System.IO.Path.Combine("App_Data", "ss", "ss.cfg")
|
||||
: _txtSsArgs.Text.Trim();
|
||||
_settings.SetSystemPassword(_txtSystemPassword.Text.Trim());
|
||||
_settings.SetThreeProxyPassword(_txtThreeProxyPassword.Text.Trim());
|
||||
_settings.Save();
|
||||
|
||||
DialogResult = DialogResult.OK;
|
||||
}
|
||||
|
||||
private static string GeneratePassword()
|
||||
{
|
||||
const string alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
const int length = 20;
|
||||
var bytes = new byte[length];
|
||||
using (var rng = RandomNumberGenerator.Create())
|
||||
{
|
||||
rng.GetBytes(bytes);
|
||||
}
|
||||
|
||||
var chars = new char[length];
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
chars[i] = alphabet[bytes[i] % alphabet.Length];
|
||||
}
|
||||
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Reference in New Issue
Block a user