diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs
index 9c3df14e..12b3d90a 100644
--- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs
+++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs
@@ -1,8 +1,4 @@
using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
using Serilog;
using Serilog.Core;
using Serilog.Events;
@@ -12,11 +8,11 @@ using Crestron.SimplSharp;
using WebSocketSharp;
using System.Security.Authentication;
using WebSocketSharp.Net;
-using X509Certificate2 = System.Security.Cryptography.X509Certificates.X509Certificate2;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
using System.IO;
using Org.BouncyCastle.Asn1.X509;
using Serilog.Formatting;
-using Newtonsoft.Json.Linq;
using Serilog.Formatting.Json;
namespace PepperDash.Core
@@ -27,18 +23,23 @@ namespace PepperDash.Core
public class DebugWebsocketSink : ILogEventSink
{
private HttpServer _httpsServer;
-
+
private string _path = "/debug/join/";
private const string _certificateName = "selfCres";
private const string _certificatePassword = "cres12345";
- public int Port
- { get
- {
-
- if(_httpsServer == null) return 0;
+ private static string CertPath =>
+ $"{Path.DirectorySeparatorChar}user{Path.DirectorySeparatorChar}{_certificateName}.pfx";
+
+
+ public int Port
+ {
+ get
+ {
+
+ if (_httpsServer == null) return 0;
return _httpsServer.Port;
- }
+ }
}
public string Url
@@ -54,60 +55,105 @@ namespace PepperDash.Core
/// Gets or sets the IsRunning
///
public bool IsRunning { get => _httpsServer?.IsListening ?? false; }
-
+
private readonly ITextFormatter _textFormatter;
+ ///
+ /// Initializes a new instance of the class with the specified text formatter.
+ ///
+ /// 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.
+ /// The text formatter used to format log messages. If null, a default JSON formatter is used.
public DebugWebsocketSink(ITextFormatter formatProvider)
{
_textFormatter = formatProvider ?? new JsonFormatter();
- if (!File.Exists($"\\user\\{_certificateName}.pfx"))
- CreateCert(null);
+ if (!File.Exists(CertPath))
+ CreateCert();
- CrestronEnvironment.ProgramStatusEventHandler += type =>
- {
- if (type == eProgramStatusEventType.Stopping)
- {
- StopServer();
- }
- };
- }
-
- private void CreateCert(string[] args)
- {
try
{
- //Debug.Console(0,"CreateCert Creating Utility");
- CrestronConsole.PrintLine("CreateCert Creating Utility");
- //var utility = new CertificateUtility();
- var utility = new BouncyCertificate();
- //Debug.Console(0, "CreateCert Calling CreateCert");
- CrestronConsole.PrintLine("CreateCert Calling CreateCert");
- //utility.CreateCert();
+ CrestronEnvironment.ProgramStatusEventHandler += type =>
+ {
+ if (type == eProgramStatusEventType.Stopping)
+ StopServer();
+ };
+ }
+ 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 hostName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, 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("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));
- 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 });
- //Crestron fails to let us do this...perhaps it should be done through their Dll's but haven't tested
- //Debug.Print($"CreateCert Storing Certificate To My.LocalMachine");
- //utility.AddCertToStore(certificate, StoreName.My, StoreLocation.LocalMachine);
- //Debug.Console(0, "CreateCert Saving Cert to \\user\\");
- CrestronConsole.PrintLine("CreateCert Saving Cert to \\user\\");
- utility.CertificatePassword = _certificatePassword;
- utility.WriteCertificate(certificate, @"\user\", _certificateName);
- //Debug.Console(0, "CreateCert Ending CreateCert");
- CrestronConsole.PrintLine("CreateCert Ending CreateCert");
+ var subjectName = string.Format("CN={0}.{1}", hostName, domainName);
+ var fqdn = string.Format("{0}.{1}", hostName, domainName);
+
+ using (var rsa = RSA.Create(2048))
+ {
+
+ var request = new CertificateRequest(
+ subjectName,
+ rsa,
+ 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)
{
- //Debug.Console(0, "WSS CreateCert Failed\r\n{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));
+ CrestronConsole.PrintLine(string.Format("WSS CreateCert Failed: {0}\r\n{1}", ex.Message, ex.StackTrace));
}
}
@@ -126,14 +172,37 @@ namespace PepperDash.Core
}
///
- /// StartServerAndSetPort method
+ /// Starts the WebSocket server on the specified port and configures it with the appropriate certificate.
///
+ /// 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.
+ /// The port number on which the WebSocket server will listen. Must be a valid, non-negative port number.
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 = "")
@@ -142,66 +211,37 @@ namespace PepperDash.Core
{
_httpsServer = new HttpServer(port, true);
-
if (!string.IsNullOrWhiteSpace(certPath))
{
- Debug.Console(0, "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(_path);
- Debug.Console(0, "Assigning Log Info");
- _httpsServer.Log.Level = LogLevel.Trace;
- _httpsServer.Log.Output = (d, s) =>
- {
- uint level;
+ Debug.LogInformation("Assigning SSL Configuration");
- switch(d.Level)
- {
- case WebSocketSharp.LogLevel.Fatal:
- level = 3;
- break;
- case WebSocketSharp.LogLevel.Error:
- level = 2;
- break;
- case WebSocketSharp.LogLevel.Warn:
- level = 1;
- break;
- case WebSocketSharp.LogLevel.Info:
- level = 0;
- break;
- case WebSocketSharp.LogLevel.Debug:
- level = 4;
- break;
- 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.SslConfiguration.ServerCertificate = LoadOrRecreateCert(certPath, certPassword);
+ _httpsServer.SslConfiguration.ClientCertificateRequired = false;
+ _httpsServer.SslConfiguration.CheckCertificateRevocation = false;
+ _httpsServer.SslConfiguration.EnabledSslProtocols = SslProtocols.Tls12;
+ //this is just to test, you might want to actually validate
+ _httpsServer.SslConfiguration.ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) =>
+ {
+ Debug.LogInformation("HTTPS ClientCerticateValidation Callback triggered");
+ return true;
+ };
+ }
+ Debug.LogInformation("Adding Debug Client Service");
+ _httpsServer.AddWebSocketService(_path);
+ Debug.LogInformation("Assigning Log Info");
+ _httpsServer.Log.Level = LogLevel.Trace;
+ _httpsServer.Log.Output = WriteWebSocketInternalLog;
+ Debug.LogInformation("Starting");
_httpsServer.Start();
- Debug.Console(0, "Ready");
+ Debug.LogInformation("Ready");
}
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
///
public void StopServer()
{
- Debug.Console(0, "Stopping Websocket Server");
- _httpsServer?.Stop();
+ Debug.LogInformation("Stopping Websocket Server");
- _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) ? "" : 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.
+ }
}
}
diff --git a/src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs b/src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs
new file mode 100644
index 00000000..c08f9af1
--- /dev/null
+++ b/src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs
@@ -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
+{
+ ///
+ /// Represents a DeleteAllUiClientsHandler
+ ///
+ public class DeleteAllUiClientsHandler : WebApiBaseRequestHandler
+ {
+ private readonly MobileControlWebsocketServer server;
+
+ ///
+ /// Essentials CWS API handler for the MC Direct Server
+ ///
+ /// Direct Server instance
+ public DeleteAllUiClientsHandler(MobileControlWebsocketServer directServer) : base(true)
+ {
+ server = directServer;
+ }
+
+ ///
+ /// Deletes all clients from the Direct Server
+ ///
+ /// HTTP Context for this request
+ 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();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs
index 3b55120f..1c9ed37a 100644
--- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs
+++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs
@@ -238,11 +238,17 @@ namespace PepperDash.Essentials.WebSocketServer
var routes = new List
{
- new HttpCwsRoute($"devices/{Key}/client")
+ new HttpCwsRoute($"device/{Key}/client")
{
Name = "ClientHandler",
RouteHandler = new UiClientHandler(this)
},
+
+ new HttpCwsRoute($"device/{Key}/deleteAllUiClients")
+ {
+ Name = "DeleteAllClientsHandler",
+ RouteHandler = new DeleteAllUiClientsHandler(this)
+ },
};
apiServer.AddRoute(routes);
@@ -908,7 +914,7 @@ namespace PepperDash.Essentials.WebSocketServer
///
/// Removes all clients from the server
///
- private void RemoveAllTokens(string s)
+ public void RemoveAllTokens(string s)
{
if (s == "?" || string.IsNullOrEmpty(s))
{