feat: add DeleteAllUiClientsHandler and route for deleting all clients. Resolve issues with client closing websocket connection to DebugWebsocketSink

This commit is contained in:
Neil Dorin 2026-04-14 22:12:39 -06:00
parent a610e127de
commit 6e9480f503
3 changed files with 255 additions and 108 deletions

View file

@ -1,8 +1,4 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Serilog; using Serilog;
using Serilog.Core; using Serilog.Core;
using Serilog.Events; using Serilog.Events;
@ -12,11 +8,11 @@ using Crestron.SimplSharp;
using WebSocketSharp; using WebSocketSharp;
using System.Security.Authentication; using System.Security.Authentication;
using WebSocketSharp.Net; using WebSocketSharp.Net;
using X509Certificate2 = System.Security.Cryptography.X509Certificates.X509Certificate2; using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.IO; using System.IO;
using Org.BouncyCastle.Asn1.X509; using Org.BouncyCastle.Asn1.X509;
using Serilog.Formatting; using Serilog.Formatting;
using Newtonsoft.Json.Linq;
using Serilog.Formatting.Json; using Serilog.Formatting.Json;
namespace PepperDash.Core namespace PepperDash.Core
@ -27,18 +23,23 @@ namespace PepperDash.Core
public class DebugWebsocketSink : ILogEventSink public class DebugWebsocketSink : ILogEventSink
{ {
private HttpServer _httpsServer; private HttpServer _httpsServer;
private string _path = "/debug/join/"; private string _path = "/debug/join/";
private const string _certificateName = "selfCres"; private const string _certificateName = "selfCres";
private const string _certificatePassword = "cres12345"; private const string _certificatePassword = "cres12345";
public int Port private static string CertPath =>
{ get $"{Path.DirectorySeparatorChar}user{Path.DirectorySeparatorChar}{_certificateName}.pfx";
{
if(_httpsServer == null) return 0; public int Port
{
get
{
if (_httpsServer == null) return 0;
return _httpsServer.Port; return _httpsServer.Port;
} }
} }
public string Url public string Url
@ -54,60 +55,105 @@ namespace PepperDash.Core
/// Gets or sets the IsRunning /// Gets or sets the IsRunning
/// </summary> /// </summary>
public bool IsRunning { get => _httpsServer?.IsListening ?? false; } public bool IsRunning { get => _httpsServer?.IsListening ?? false; }
private readonly ITextFormatter _textFormatter; private readonly ITextFormatter _textFormatter;
/// <summary>
/// Initializes a new instance of the <see cref="DebugWebsocketSink"/> class with the specified text formatter.
/// </summary>
/// <remarks>This constructor initializes the WebSocket sink and ensures that a certificate is
/// available for secure communication. If the required certificate does not exist, it will be created
/// automatically. Additionally, the sink is configured to stop the server when the program is
/// stopping.</remarks>
/// <param name="formatProvider">The text formatter used to format log messages. If null, a default JSON formatter is used.</param>
public DebugWebsocketSink(ITextFormatter formatProvider) public DebugWebsocketSink(ITextFormatter formatProvider)
{ {
_textFormatter = formatProvider ?? new JsonFormatter(); _textFormatter = formatProvider ?? new JsonFormatter();
if (!File.Exists($"\\user\\{_certificateName}.pfx")) if (!File.Exists(CertPath))
CreateCert(null); CreateCert();
CrestronEnvironment.ProgramStatusEventHandler += type =>
{
if (type == eProgramStatusEventType.Stopping)
{
StopServer();
}
};
}
private void CreateCert(string[] args)
{
try try
{ {
//Debug.Console(0,"CreateCert Creating Utility"); CrestronEnvironment.ProgramStatusEventHandler += type =>
CrestronConsole.PrintLine("CreateCert Creating Utility"); {
//var utility = new CertificateUtility(); if (type == eProgramStatusEventType.Stopping)
var utility = new BouncyCertificate(); StopServer();
//Debug.Console(0, "CreateCert Calling CreateCert"); };
CrestronConsole.PrintLine("CreateCert Calling CreateCert"); }
//utility.CreateCert(); catch
{
// CrestronEnvironment is not available in test / dev environments — safe to skip.
}
}
private static void CreateCert()
{
// NOTE: This method is called from the constructor, which is itself called during Debug's static
// constructor before _logger is assigned. Do NOT call any Debug.Log* methods here — use
// CrestronConsole.PrintLine only, to avoid a NullReferenceException that would poison the Debug type.
try
{
var ipAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); var ipAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0);
var hostName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, 0); var hostName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, 0);
var domainName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_DOMAIN_NAME, 0); var domainName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_DOMAIN_NAME, 0);
//Debug.Console(0, "DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress); CrestronConsole.PrintLine(string.Format("CreateCert: DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress));
CrestronConsole.PrintLine(string.Format("DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress));
var certificate = utility.CreateSelfSignedCertificate(string.Format("CN={0}.{1}", hostName, domainName), new[] { string.Format("{0}.{1}", hostName, domainName), ipAddress }, new[] { KeyPurposeID.id_kp_serverAuth, KeyPurposeID.id_kp_clientAuth }); var subjectName = string.Format("CN={0}.{1}", hostName, domainName);
//Crestron fails to let us do this...perhaps it should be done through their Dll's but haven't tested var fqdn = string.Format("{0}.{1}", hostName, domainName);
//Debug.Print($"CreateCert Storing Certificate To My.LocalMachine");
//utility.AddCertToStore(certificate, StoreName.My, StoreLocation.LocalMachine); using (var rsa = RSA.Create(2048))
//Debug.Console(0, "CreateCert Saving Cert to \\user\\"); {
CrestronConsole.PrintLine("CreateCert Saving Cert to \\user\\");
utility.CertificatePassword = _certificatePassword; var request = new CertificateRequest(
utility.WriteCertificate(certificate, @"\user\", _certificateName); subjectName,
//Debug.Console(0, "CreateCert Ending CreateCert"); rsa,
CrestronConsole.PrintLine("CreateCert Ending CreateCert"); HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
// Subject Key Identifier
request.CertificateExtensions.Add(
new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
// Extended Key Usage: server + client auth
request.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection
{
new Oid("1.3.6.1.5.5.7.3.1"), // id-kp-serverAuth
new Oid("1.3.6.1.5.5.7.3.2") // id-kp-clientAuth
},
false));
// Subject Alternative Names: DNS + IP
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName(fqdn);
if (System.Net.IPAddress.TryParse(ipAddress, out var ip))
sanBuilder.AddIpAddress(ip);
request.CertificateExtensions.Add(sanBuilder.Build());
var notBefore = DateTimeOffset.UtcNow;
var notAfter = notBefore.AddYears(2);
using (var cert = request.CreateSelfSigned(notBefore, notAfter))
{
var separator = Path.DirectorySeparatorChar;
var outputPath = string.Format("{0}user{1}{2}.pfx", separator, separator, _certificateName);
var pfxBytes = cert.Export(X509ContentType.Pfx, _certificatePassword);
File.WriteAllBytes(outputPath, pfxBytes);
CrestronConsole.PrintLine(string.Format("CreateCert: Certificate written to {0}", outputPath));
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
//Debug.Console(0, "WSS CreateCert Failed\r\n{0}\r\n{1}", ex.Message, ex.StackTrace); CrestronConsole.PrintLine(string.Format("WSS CreateCert Failed: {0}\r\n{1}", ex.Message, ex.StackTrace));
CrestronConsole.PrintLine(string.Format("WSS CreateCert Failed\r\n{0}\r\n{1}", ex.Message, ex.StackTrace));
} }
} }
@ -126,14 +172,37 @@ namespace PepperDash.Core
} }
/// <summary> /// <summary>
/// StartServerAndSetPort method /// Starts the WebSocket server on the specified port and configures it with the appropriate certificate.
/// </summary> /// </summary>
/// <remarks>This method initializes the WebSocket server and binds it to the specified port. It
/// also applies the server's certificate for secure communication. Ensure that the port is not already in use
/// and that the certificate file is accessible.</remarks>
/// <param name="port">The port number on which the WebSocket server will listen. Must be a valid, non-negative port number.</param>
public void StartServerAndSetPort(int port) public void StartServerAndSetPort(int port)
{ {
Debug.Console(0, "Starting Websocket Server on port: {0}", port); Debug.LogInformation("Starting Websocket Server on port: {0}", port);
Start(port, $"\\user\\{_certificateName}.pfx", _certificatePassword); Start(port, CertPath, _certificatePassword);
}
private static X509Certificate2 LoadOrRecreateCert(string certPath, string certPassword)
{
try
{
// EphemeralKeySet is required on Linux/OpenSSL (Crestron 4-series) to avoid
// key-container persistence failures, and avoids the private key export restriction.
return new X509Certificate2(certPath, certPassword, X509KeyStorageFlags.EphemeralKeySet);
}
catch (Exception ex)
{
// Cert is stale or was generated by an incompatible library (e.g. old BouncyCastle output).
// Delete it, regenerate with the BCL path, and retry once.
CrestronConsole.PrintLine(string.Format("SSL cert load failed ({0}); regenerating...", ex.Message));
try { File.Delete(certPath); } catch { }
CreateCert();
return new X509Certificate2(certPath, certPassword, X509KeyStorageFlags.EphemeralKeySet);
}
} }
private void Start(int port, string certPath = "", string certPassword = "") private void Start(int port, string certPath = "", string certPassword = "")
@ -142,66 +211,37 @@ namespace PepperDash.Core
{ {
_httpsServer = new HttpServer(port, true); _httpsServer = new HttpServer(port, true);
if (!string.IsNullOrWhiteSpace(certPath)) if (!string.IsNullOrWhiteSpace(certPath))
{ {
Debug.Console(0, "Assigning SSL Configuration"); Debug.LogInformation("Assigning SSL Configuration");
_httpsServer.SslConfiguration = new ServerSslConfiguration(new X509Certificate2(certPath, certPassword))
{
ClientCertificateRequired = false,
CheckCertificateRevocation = false,
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11 | SslProtocols.Tls,
//this is just to test, you might want to actually validate
ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) =>
{
Debug.Console(0, "HTTPS ClientCerticateValidation Callback triggered");
return true;
}
};
}
Debug.Console(0, "Adding Debug Client Service");
_httpsServer.AddWebSocketService<DebugClient>(_path);
Debug.Console(0, "Assigning Log Info");
_httpsServer.Log.Level = LogLevel.Trace;
_httpsServer.Log.Output = (d, s) =>
{
uint level;
switch(d.Level) _httpsServer.SslConfiguration.ServerCertificate = LoadOrRecreateCert(certPath, certPassword);
{ _httpsServer.SslConfiguration.ClientCertificateRequired = false;
case WebSocketSharp.LogLevel.Fatal: _httpsServer.SslConfiguration.CheckCertificateRevocation = false;
level = 3; _httpsServer.SslConfiguration.EnabledSslProtocols = SslProtocols.Tls12;
break; //this is just to test, you might want to actually validate
case WebSocketSharp.LogLevel.Error: _httpsServer.SslConfiguration.ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) =>
level = 2; {
break; Debug.LogInformation("HTTPS ClientCerticateValidation Callback triggered");
case WebSocketSharp.LogLevel.Warn: return true;
level = 1; };
break; }
case WebSocketSharp.LogLevel.Info: Debug.LogInformation("Adding Debug Client Service");
level = 0; _httpsServer.AddWebSocketService<DebugClient>(_path);
break; Debug.LogInformation("Assigning Log Info");
case WebSocketSharp.LogLevel.Debug: _httpsServer.Log.Level = LogLevel.Trace;
level = 4; _httpsServer.Log.Output = WriteWebSocketInternalLog;
break; Debug.LogInformation("Starting");
case WebSocketSharp.LogLevel.Trace:
level = 5;
break;
default:
level = 4;
break;
}
Debug.Console(level, "{1} {0}\rCaller:{2}\rMessage:{3}\rs:{4}", d.Level.ToString(), d.Date.ToString(), d.Caller.ToString(), d.Message, s);
};
Debug.Console(0, "Starting");
_httpsServer.Start(); _httpsServer.Start();
Debug.Console(0, "Ready"); Debug.LogInformation("Ready");
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.Console(0, "WebSocket Failed to start {0}", ex.Message); Debug.LogError(ex, "WebSocket Failed to start {0}", ex.Message);
Debug.LogVerbose("Stack Trace:\r{0}", ex.StackTrace);
// Null out the server so callers can detect failure via IsRunning / Url null guards.
_httpsServer = null;
} }
} }
@ -210,10 +250,68 @@ namespace PepperDash.Core
/// </summary> /// </summary>
public void StopServer() public void StopServer()
{ {
Debug.Console(0, "Stopping Websocket Server"); Debug.LogInformation("Stopping Websocket Server");
_httpsServer?.Stop();
_httpsServer = null; try
{
if (_httpsServer == null || !_httpsServer.IsListening)
{
return;
}
// Prevent close-sequence internal websocket logs from re-entering the logging pipeline.
_httpsServer.Log.Output = (d, s) => { };
var serviceHost = _httpsServer.WebSocketServices[_path];
if (serviceHost == null)
{
_httpsServer.Stop();
_httpsServer = null;
return;
}
serviceHost.Sessions.Broadcast("Server is stopping");
foreach (var session in serviceHost.Sessions.Sessions)
{
if (session?.Context?.WebSocket != null && session.Context.WebSocket.IsAlive)
{
session.Context.WebSocket.Close(1001, "Server is stopping");
}
}
_httpsServer.Stop();
_httpsServer = null;
}
catch (Exception ex)
{
Debug.LogError(ex, "WebSocket Failed to stop gracefully {0}", ex.Message);
Debug.LogVerbose("Stack Trace\r\n{0}", ex.StackTrace);
}
}
private static void WriteWebSocketInternalLog(LogData data, string supplemental)
{
try
{
if (data == null)
{
return;
}
var message = string.IsNullOrWhiteSpace(data.Message) ? "<none>" : data.Message;
var details = string.IsNullOrWhiteSpace(supplemental) ? string.Empty : string.Format(" | details: {0}", supplemental);
// Use direct console output to avoid recursive log sink calls.
CrestronConsole.PrintLine(string.Format("WS[{0}] {1} | message: {2}{3}", data.Level, data.Date, message, details));
}
catch
{
// Never throw from websocket log callback.
}
} }
} }

View file

@ -0,0 +1,43 @@
using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json;
using PepperDash.Core;
using PepperDash.Core.Web.RequestHandlers;
using PepperDash.Essentials.Core.Web;
using PepperDash.Essentials.WebSocketServer;
using Serilog.Events;
namespace PepperDash.Essentials.WebApiHandlers
{
/// <summary>
/// Represents a DeleteAllUiClientsHandler
/// </summary>
public class DeleteAllUiClientsHandler : WebApiBaseRequestHandler
{
private readonly MobileControlWebsocketServer server;
/// <summary>
/// Essentials CWS API handler for the MC Direct Server
/// </summary>
/// <param name="directServer">Direct Server instance</param>
public DeleteAllUiClientsHandler(MobileControlWebsocketServer directServer) : base(true)
{
server = directServer;
}
/// <summary>
/// Deletes all clients from the Direct Server
/// </summary>
/// <param name="context">HTTP Context for this request</param>
protected override void HandleDelete(HttpCwsContext context)
{
server.RemoveAllTokens("confirm");
var res = context.Response;
res.StatusCode = 200;
res.ContentType = "application/json";
res.Headers.Add("Content-Type", "application/json");
res.Write(JsonConvert.SerializeObject(new { success = true }), false);
res.End();
}
}
}

View file

@ -238,11 +238,17 @@ namespace PepperDash.Essentials.WebSocketServer
var routes = new List<HttpCwsRoute> var routes = new List<HttpCwsRoute>
{ {
new HttpCwsRoute($"devices/{Key}/client") new HttpCwsRoute($"device/{Key}/client")
{ {
Name = "ClientHandler", Name = "ClientHandler",
RouteHandler = new UiClientHandler(this) RouteHandler = new UiClientHandler(this)
}, },
new HttpCwsRoute($"device/{Key}/deleteAllUiClients")
{
Name = "DeleteAllClientsHandler",
RouteHandler = new DeleteAllUiClientsHandler(this)
},
}; };
apiServer.AddRoute(routes); apiServer.AddRoute(routes);
@ -908,7 +914,7 @@ namespace PepperDash.Essentials.WebSocketServer
/// <summary> /// <summary>
/// Removes all clients from the server /// Removes all clients from the server
/// </summary> /// </summary>
private void RemoveAllTokens(string s) public void RemoveAllTokens(string s)
{ {
if (s == "?" || string.IsNullOrEmpty(s)) if (s == "?" || string.IsNullOrEmpty(s))
{ {