fix: add config option to turn echo off for SSH

In addition, removed CTimer in favor of System.Threading.Timers Timer in the SSH class, and modified the class to be thread-safe.
This commit is contained in:
Andrew Welker
2025-10-31 09:01:54 -05:00
parent 666be5ae59
commit ff609dfecd
3 changed files with 373 additions and 261 deletions

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using Crestron.SimplSharp;
@@ -11,11 +12,12 @@ using Renci.SshNet.Common;
namespace PepperDash.Core
{
/// <summary>
///
/// SSH Client
/// </summary>
public class GenericSshClient : Device, ISocketStatusWithStreamDebugging, IAutoReconnect
{
private const string SPlusKey = "Uninitialized SshClient";
/// <summary>
/// Object to enable stream debugging
/// </summary>
@@ -36,11 +38,6 @@ namespace PepperDash.Core
/// </summary>
public event EventHandler<GenericSocketStatusChageEventArgs> ConnectionChange;
/// <summary>
/////
///// </summary>
//public event GenericSocketStatusChangeEventDelegate SocketStatusChange;
/// <summary>
/// Gets or sets the Hostname
/// </summary>
@@ -67,7 +64,7 @@ namespace PepperDash.Core
public bool IsConnected
{
// returns false if no client or not connected
get { return Client != null && ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; }
get { return client != null && ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; }
}
/// <summary>
@@ -83,16 +80,26 @@ namespace PepperDash.Core
/// </summary>
public SocketStatus ClientStatus
{
get { return _ClientStatus; }
get { lock (_stateLock) { return _ClientStatus; } }
private set
{
if (_ClientStatus == value)
return;
bool shouldFireEvent = false;
lock (_stateLock)
{
if (_ClientStatus != value)
{
_ClientStatus = value;
shouldFireEvent = true;
}
}
// Fire event outside lock to avoid deadlock
if (shouldFireEvent)
OnConnectionChange();
}
}
SocketStatus _ClientStatus;
private SocketStatus _ClientStatus;
private bool _ConnectEnabled;
/// <summary>
/// Contains the familiar Simpl analog status values. This drives the ConnectionChange event
@@ -100,7 +107,7 @@ namespace PepperDash.Core
/// </summary>
public ushort UStatus
{
get { return (ushort)_ClientStatus; }
get { lock (_stateLock) { return (ushort)_ClientStatus; } }
}
/// <summary>
@@ -111,7 +118,11 @@ namespace PepperDash.Core
/// <summary>
/// Will be set and unset by connect and disconnect only
/// </summary>
public bool ConnectEnabled { get; private set; }
public bool ConnectEnabled
{
get { lock (_stateLock) { return _ConnectEnabled; } }
private set { lock (_stateLock) { _ConnectEnabled = value; } }
}
/// <summary>
/// S+ helper for AutoReconnect
@@ -127,17 +138,25 @@ namespace PepperDash.Core
/// </summary>
public int AutoReconnectIntervalMs { get; set; }
SshClient Client;
private SshClient client;
ShellStream TheStream;
private ShellStream shellStream;
CTimer ReconnectTimer;
private readonly Timer reconnectTimer;
//Lock object to prevent simulatneous connect/disconnect operations
//private CCriticalSection connectLock = new CCriticalSection();
private SemaphoreSlim connectLock = new SemaphoreSlim(1);
private readonly SemaphoreSlim connectLock = new SemaphoreSlim(1);
private bool DisconnectLogged = false;
// Thread-safety lock for state changes
private readonly object _stateLock = new object();
private bool disconnectLogged = false;
/// <summary>
/// When true, turns off echo for the SSH session
/// </summary>
public bool DisableEcho { get; set; }
/// <summary>
/// Typical constructor.
@@ -154,13 +173,13 @@ namespace PepperDash.Core
Password = password;
AutoReconnectIntervalMs = 5000;
ReconnectTimer = new CTimer(o =>
reconnectTimer = new Timer(o =>
{
if (ConnectEnabled)
if (ConnectEnabled) // Now thread-safe property access
{
Connect();
}
}, System.Threading.Timeout.Infinite);
}, null, System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
}
/// <summary>
@@ -172,23 +191,23 @@ namespace PepperDash.Core
CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler);
AutoReconnectIntervalMs = 5000;
ReconnectTimer = new CTimer(o =>
reconnectTimer = new Timer(o =>
{
if (ConnectEnabled)
if (ConnectEnabled) // Now thread-safe property access
{
Connect();
}
}, System.Threading.Timeout.Infinite);
}, null, System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
}
/// <summary>
/// Handles closing this up when the program shuts down
/// </summary>
void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType)
private void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType)
{
if (programEventType == eProgramStatusEventType.Stopping)
{
if (Client != null)
if (client != null)
{
this.LogDebug("Program stopping. Closing connection");
Disconnect();
@@ -223,10 +242,10 @@ namespace PepperDash.Core
this.LogDebug("Attempting connect");
// Cancel reconnect if running.
ReconnectTimer?.Stop();
StopReconnectTimer();
// Cleanup the old client if it already exists
if (Client != null)
if (client != null)
{
this.LogDebug("Cleaning up disconnected client");
KillClient(SocketStatus.SOCKET_STATUS_BROKEN_LOCALLY);
@@ -239,29 +258,37 @@ namespace PepperDash.Core
this.LogDebug("Creating new SshClient");
ConnectionInfo connectionInfo = new ConnectionInfo(Hostname, Port, Username, pauth, kauth);
Client = new SshClient(connectionInfo);
Client.ErrorOccurred += Client_ErrorOccurred;
client = new SshClient(connectionInfo);
client.ErrorOccurred += Client_ErrorOccurred;
//Attempt to connect
ClientStatus = SocketStatus.SOCKET_STATUS_WAITING;
try
{
Client.Connect();
TheStream = Client.CreateShellStream("PDTShell", 0, 0, 0, 0, 65534);
if (TheStream.DataAvailable)
client.Connect();
var modes = new Dictionary<TerminalModes, uint>();
if (DisableEcho)
{
modes.Add(TerminalModes.ECHO, 0);
}
shellStream = client.CreateShellStream("PDTShell", 0, 0, 0, 0, 65534, modes);
if (shellStream.DataAvailable)
{
// empty the buffer if there is data
string str = TheStream.Read();
string str = shellStream.Read();
}
TheStream.DataReceived += Stream_DataReceived;
shellStream.DataReceived += Stream_DataReceived;
this.LogInformation("Connected");
ClientStatus = SocketStatus.SOCKET_STATUS_CONNECTED;
DisconnectLogged = false;
disconnectLogged = false;
}
catch (SshConnectionException e)
{
var ie = e.InnerException; // The details are inside!!
var errorLogLevel = DisconnectLogged == true ? Debug.ErrorLogLevel.None : Debug.ErrorLogLevel.Error;
var errorLogLevel = disconnectLogged == true ? Debug.ErrorLogLevel.None : Debug.ErrorLogLevel.Error;
if (ie is SocketException)
{
@@ -286,37 +313,37 @@ namespace PepperDash.Core
this.LogVerbose(ie, "Exception details: ");
}
DisconnectLogged = true;
disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect)
{
this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs);
ReconnectTimer.Reset(AutoReconnectIntervalMs);
StartReconnectTimer();
}
}
catch (SshOperationTimeoutException ex)
{
this.LogWarning("Connection attempt timed out: {message}", ex.Message);
DisconnectLogged = true;
disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect)
{
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
ReconnectTimer.Reset(AutoReconnectIntervalMs);
StartReconnectTimer();
}
}
catch (Exception e)
{
var errorLogLevel = DisconnectLogged == true ? Debug.ErrorLogLevel.None : Debug.ErrorLogLevel.Error;
var errorLogLevel = disconnectLogged == true ? Debug.ErrorLogLevel.None : Debug.ErrorLogLevel.Error;
this.LogError("Unhandled exception on connect: {error}", e.Message);
this.LogVerbose(e, "Exception details: ");
DisconnectLogged = true;
disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect)
{
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
ReconnectTimer.Reset(AutoReconnectIntervalMs);
StartReconnectTimer();
}
}
}
@@ -334,11 +361,7 @@ namespace PepperDash.Core
{
ConnectEnabled = false;
// Stop trying reconnects, if we are
if (ReconnectTimer != null)
{
ReconnectTimer.Stop();
// ReconnectTimer = null;
}
StopReconnectTimer();
KillClient(SocketStatus.SOCKET_STATUS_BROKEN_LOCALLY);
}
@@ -352,12 +375,12 @@ namespace PepperDash.Core
try
{
if (Client != null)
if (client != null)
{
Client.ErrorOccurred -= Client_ErrorOccurred;
Client.Disconnect();
Client.Dispose();
Client = null;
client.ErrorOccurred -= Client_ErrorOccurred;
client.Disconnect();
client.Dispose();
client = null;
ClientStatus = status;
this.LogDebug("Disconnected");
}
@@ -371,16 +394,16 @@ namespace PepperDash.Core
/// <summary>
/// Kills the stream
/// </summary>
void KillStream()
private void KillStream()
{
try
{
if (TheStream != null)
if (shellStream != null)
{
TheStream.DataReceived -= Stream_DataReceived;
TheStream.Close();
TheStream.Dispose();
TheStream = null;
shellStream.DataReceived -= Stream_DataReceived;
shellStream.Close();
shellStream.Dispose();
shellStream = null;
this.LogDebug("Disconnected stream");
}
}
@@ -393,7 +416,7 @@ namespace PepperDash.Core
/// <summary>
/// Handles the keyboard interactive authentication, should it be required.
/// </summary>
void kauth_AuthenticationPrompt(object sender, AuthenticationPromptEventArgs e)
private void kauth_AuthenticationPrompt(object sender, AuthenticationPromptEventArgs e)
{
foreach (AuthenticationPrompt prompt in e.Prompts)
if (prompt.Request.IndexOf("Password:", StringComparison.InvariantCultureIgnoreCase) != -1)
@@ -403,7 +426,7 @@ namespace PepperDash.Core
/// <summary>
/// Handler for data receive on ShellStream. Passes data across to queue for line parsing.
/// </summary>
void Stream_DataReceived(object sender, ShellDataEventArgs e)
private void Stream_DataReceived(object sender, ShellDataEventArgs e)
{
if (((ShellStream)sender).Length <= 0L)
{
@@ -439,7 +462,7 @@ namespace PepperDash.Core
/// Error event handler for client events - disconnect, etc. Will forward those events via ConnectionChange
/// event
/// </summary>
void Client_ErrorOccurred(object sender, ExceptionEventArgs e)
private void Client_ErrorOccurred(object sender, ExceptionEventArgs e)
{
CrestronInvoke.BeginInvoke(o =>
{
@@ -459,7 +482,7 @@ namespace PepperDash.Core
if (AutoReconnect && ConnectEnabled)
{
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
ReconnectTimer.Reset(AutoReconnectIntervalMs);
StartReconnectTimer();
}
});
}
@@ -467,7 +490,7 @@ namespace PepperDash.Core
/// <summary>
/// Helper for ConnectionChange event
/// </summary>
void OnConnectionChange()
private void OnConnectionChange()
{
ConnectionChange?.Invoke(this, new GenericSocketStatusChageEventArgs(this));
}
@@ -482,7 +505,7 @@ namespace PepperDash.Core
{
try
{
if (Client != null && TheStream != null && IsConnected)
if (client != null && shellStream != null && IsConnected)
{
if (StreamDebugging.TxStreamDebuggingIsEnabled)
this.LogInformation(
@@ -490,8 +513,8 @@ namespace PepperDash.Core
text.Length,
ComTextHelper.GetDebugText(text));
TheStream.Write(text);
TheStream.Flush();
shellStream.Write(text);
shellStream.Flush();
}
else
{
@@ -503,7 +526,7 @@ namespace PepperDash.Core
this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim());
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
ReconnectTimer.Reset();
StopReconnectTimer();
}
catch (Exception ex)
{
@@ -519,13 +542,13 @@ namespace PepperDash.Core
{
try
{
if (Client != null && TheStream != null && IsConnected)
if (client != null && shellStream != null && IsConnected)
{
if (StreamDebugging.TxStreamDebuggingIsEnabled)
this.LogInformation("Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes));
TheStream.Write(bytes, 0, bytes.Length);
TheStream.Flush();
shellStream.Write(bytes, 0, bytes.Length);
shellStream.Flush();
}
else
{
@@ -537,7 +560,7 @@ namespace PepperDash.Core
this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes));
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
ReconnectTimer.Reset();
StopReconnectTimer();
}
catch (Exception ex)
{
@@ -546,6 +569,83 @@ namespace PepperDash.Core
}
#endregion
/// <summary>
/// Safely starts the reconnect timer with exception handling
/// </summary>
private void StartReconnectTimer()
{
try
{
reconnectTimer?.Change(AutoReconnectIntervalMs, System.Threading.Timeout.Infinite);
}
catch (ObjectDisposedException)
{
// Timer was disposed, ignore
this.LogDebug("Attempted to start timer but it was already disposed");
}
}
/// <summary>
/// Safely stops the reconnect timer with exception handling
/// </summary>
private void StopReconnectTimer()
{
try
{
reconnectTimer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
}
catch (ObjectDisposedException)
{
// Timer was disposed, ignore
this.LogDebug("Attempted to stop timer but it was already disposed");
}
}
/// <summary>
/// Deactivate method - properly dispose of resources
/// </summary>
public override bool Deactivate()
{
try
{
this.LogDebug("Deactivating SSH client - disposing resources");
// Stop trying reconnects
ConnectEnabled = false;
StopReconnectTimer();
// Disconnect and cleanup client
KillClient(SocketStatus.SOCKET_STATUS_BROKEN_LOCALLY);
// Dispose timer
try
{
reconnectTimer?.Dispose();
}
catch (ObjectDisposedException)
{
// Already disposed, ignore
}
// Dispose semaphore
try
{
connectLock?.Dispose();
}
catch (ObjectDisposedException)
{
// Already disposed, ignore
}
return base.Deactivate();
}
catch (Exception ex)
{
this.LogException(ex, "Error during SSH client deactivation");
return false;
}
}
}
//*****************************************************************************************************

View File

@@ -555,6 +555,12 @@ namespace PepperDash.Core
/// </summary>
public int AutoReconnectIntervalMs { get; set; }
/// <summary>
/// When true, turns off echo for the SSH session
/// </summary>
[JsonProperty("disableSshEcho")]
public bool DisableSshEcho { get; set; }
/// <summary>
/// Default constructor
/// </summary>
@@ -565,8 +571,7 @@ namespace PepperDash.Core
AutoReconnectIntervalMs = 5000;
Username = "";
Password = "";
DisableSshEcho = false;
}
}
}

View File

@@ -64,8 +64,11 @@ namespace PepperDash.Essentials.Core
break;
case eControlMethod.Ssh:
{
var ssh = new GenericSshClient(deviceConfig.Key + "-ssh", c.Address, c.Port, c.Username, c.Password);
ssh.AutoReconnect = c.AutoReconnect;
var ssh = new GenericSshClient(deviceConfig.Key + "-ssh", c.Address, c.Port, c.Username, c.Password)
{
AutoReconnect = c.AutoReconnect,
DisableEcho = c.DisableSshEcho
};
if (ssh.AutoReconnect)
ssh.AutoReconnectIntervalMs = c.AutoReconnectIntervalMs;
comm = ssh;
@@ -73,8 +76,10 @@ namespace PepperDash.Essentials.Core
}
case eControlMethod.Tcpip:
{
var tcp = new GenericTcpIpClient(deviceConfig.Key + "-tcp", c.Address, c.Port, c.BufferSize);
tcp.AutoReconnect = c.AutoReconnect;
var tcp = new GenericTcpIpClient(deviceConfig.Key + "-tcp", c.Address, c.Port, c.BufferSize)
{
AutoReconnect = c.AutoReconnect
};
if (tcp.AutoReconnect)
tcp.AutoReconnectIntervalMs = c.AutoReconnectIntervalMs;
comm = tcp;
@@ -90,8 +95,10 @@ namespace PepperDash.Essentials.Core
break;
case eControlMethod.SecureTcpIp:
{
var secureTcp = new GenericSecureTcpIpClient(deviceConfig.Key + "-secureTcp", c.Address, c.Port, c.BufferSize);
secureTcp.AutoReconnect = c.AutoReconnect;
var secureTcp = new GenericSecureTcpIpClient(deviceConfig.Key + "-secureTcp", c.Address, c.Port, c.BufferSize)
{
AutoReconnect = c.AutoReconnect
};
if (secureTcp.AutoReconnect)
secureTcp.AutoReconnectIntervalMs = c.AutoReconnectIntervalMs;
comm = secureTcp;