(_path);
- _httpsServer.OnGet += HandleHttpGet;
- Debug.LogInformation("Assigning Log Info");
- _httpsServer.Log.Level = LogLevel.Trace;
- _httpsServer.Log.Output = WriteWebSocketInternalLog;
- Debug.LogInformation("Starting");
-
- _httpsServer.Start();
- Debug.LogInformation("Ready");
}
- catch (Exception ex)
+
+ public string Url
{
- 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;
+ get
+ {
+ if (_httpsServer == null || !_httpsServer.IsListening) return "";
+ var service = _httpsServer.WebSocketServices[_path];
+ if (service == null) return "";
+
+ // Use CSLAN IP if available, otherwise fallback to primary IP. This ensures we provide a reachable URL in dual-stack environments.
+ var cslanIp = CrestronEthernetHelper.GetEthernetParameter(
+ CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1);
+ if (!string.IsNullOrEmpty(cslanIp) && cslanIp != "Invalid Value")
+ return $"wss://{cslanIp}:{_httpsServer.Port}{service.Path}";
+ else
+ return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{service.Path}";
+ }
}
- }
- private void HandleHttpGet(object sender, HttpRequestEventArgs e)
- {
- var res = e.Response;
- var body = System.Text.Encoding.UTF8.GetBytes(
- "Certificate accepted.
You can close this tab and return to the application.
");
- res.ContentType = "text/html";
- res.ContentLength64 = body.Length;
- res.Close(body, true);
- }
+ ///
+ /// Gets or sets the IsRunning
+ ///
+ public bool IsRunning { get => _httpsServer?.IsListening ?? false; }
- ///
- /// Stops the WebSocket server if it is currently running.
- ///
- /// This method halts the WebSocket server and releases any associated resources. After
- /// calling this method, the server will no longer accept or process incoming connections.
- public void StopServer()
- {
- Debug.LogInformation("Stopping Websocket Server");
-
- try
+ ///
+ /// Gets a value indicating whether there are active WebSocket connections.
+ ///
+ public bool HasActiveConnections
{
- if (_httpsServer == null || !_httpsServer.IsListening)
+ get
{
- return;
+ if (_httpsServer == null || !_httpsServer.IsListening) return false;
+ var service = _httpsServer.WebSocketServices[_path];
+ if (service == null) return false;
+ return service.Sessions.Count > 0;
}
+ }
- // Prevent close-sequence internal websocket logs from re-entering the logging pipeline.
- _httpsServer.Log.Output = (d, s) => { };
- var serviceHost = _httpsServer.WebSocketServices[_path];
+ private readonly ITextFormatter _textFormatter;
- if (serviceHost == null)
+ ///
+ /// 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(CertPath))
+ CreateCert();
+
+ try
{
- _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)
+ CrestronEnvironment.ProgramStatusEventHandler += type =>
{
- session.Context.WebSocket.Close(1001, "Server is stopping");
+ 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);
+
+ CrestronConsole.PrintLine(string.Format("CreateCert: DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress));
+
+ var subjectName = string.Format("CN={0}.{1}", hostName, domainName);
+ var fqdn = string.Format("{0}.{1}", hostName, domainName);
+
+ var random = new SecureRandom();
+
+ // Generate RSA 2048 key pair
+ var keyPairGenerator = new RsaKeyPairGenerator();
+ keyPairGenerator.Init(new KeyGenerationParameters(random, 2048));
+ var keyPair = keyPairGenerator.GenerateKeyPair();
+
+ // Build certificate
+ var certGenerator = new X509V3CertificateGenerator();
+ certGenerator.SetSerialNumber(BigInteger.ValueOf(Math.Abs(DateTime.UtcNow.Ticks)));
+ certGenerator.SetIssuerDN(new X509Name(subjectName));
+ certGenerator.SetSubjectDN(new X509Name(subjectName));
+ certGenerator.SetNotBefore(DateTime.UtcNow);
+ certGenerator.SetNotAfter(DateTime.UtcNow.AddYears(2));
+ certGenerator.SetPublicKey(keyPair.Public);
+
+ // Extended Key Usage: server + client auth
+ certGenerator.AddExtension(X509Extensions.ExtendedKeyUsage, false,
+ new ExtendedKeyUsage(new[] { KeyPurposeID.id_kp_serverAuth, KeyPurposeID.id_kp_clientAuth }));
+
+ // Subject Alternative Names: DNS + IP
+ System.Net.IPAddress parsedIp;
+ if (System.Net.IPAddress.TryParse(ipAddress, out parsedIp))
+ {
+ certGenerator.AddExtension(X509Extensions.SubjectAlternativeName, false,
+ new GeneralNames(new GeneralName[] {
+ new GeneralName(GeneralName.DnsName, fqdn),
+ new GeneralName(GeneralName.IPAddress, ipAddress)
+ }));
+ }
+ else
+ {
+ certGenerator.AddExtension(X509Extensions.SubjectAlternativeName, false,
+ new GeneralNames(new GeneralName(GeneralName.DnsName, fqdn)));
+ }
+
+ // Sign with SHA256withRSA
+ var signatureFactory = new Asn1SignatureFactory("SHA256WITHRSA", keyPair.Private, random);
+ var certificate = certGenerator.Generate(signatureFactory);
+
+ // Export as PKCS12/PFX
+ var pkcs12Store = new Pkcs12StoreBuilder().Build();
+ var certEntry = new X509CertificateEntry(certificate);
+ pkcs12Store.SetCertificateEntry(_certificateName, certEntry);
+ pkcs12Store.SetKeyEntry(_certificateName, new AsymmetricKeyEntry(keyPair.Private), new[] { certEntry });
+
+ var separator = Path.DirectorySeparatorChar;
+ var outputPath = string.Format("{0}user{1}{2}.pfx", separator, separator, _certificateName);
+
+ using (var ms = new MemoryStream())
+ {
+ var passwordChars = _certificatePassword.ToCharArray();
+ try
+ {
+ pkcs12Store.Save(ms, passwordChars, random);
+ }
+ finally
+ {
+ Array.Clear(passwordChars, 0, passwordChars.Length);
+ }
+ File.WriteAllBytes(outputPath, ms.ToArray());
+ }
+
+ CrestronConsole.PrintLine(string.Format("CreateCert: Certificate written to {0}", outputPath));
+ }
+ catch (Exception ex)
+ {
+ CrestronConsole.PrintLine(string.Format("WSS CreateCert Failed: {0}\r\n{1}", ex.Message, ex.StackTrace));
+ }
+ }
+
+ ///
+ /// Emit method
+ ///
+ public void Emit(LogEvent logEvent)
+ {
+ if (_httpsServer == null || !_httpsServer.IsListening) return;
+
+ var sw = new StringWriter();
+ _textFormatter.Format(logEvent, sw);
+
+ _httpsServer.WebSocketServices.Broadcast(sw.ToString());
+
+ }
+
+ ///
+ /// 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.LogInformation("Starting Websocket Server on port: {0}", port);
+
+
+
+
+ Start(port, CertPath, _certificatePassword);
+ }
+
+ private static X509Certificate2 LoadOrRecreateCert(string certPath, string certPassword)
+ {
+ if (!File.Exists(certPath))
+ CreateCert();
+
+ try
+ {
+ return LoadCertFromBouncyCastle(certPath, certPassword);
+ }
+ catch (Exception ex)
+ {
+ // Cert is corrupt or was written by an incompatible tool — delete and regenerate once.
+ CrestronConsole.PrintLine(string.Format("SSL cert load failed ({0}); regenerating...", ex.Message));
+ try { File.Delete(certPath); } catch { }
+ CreateCert();
+ return LoadCertFromBouncyCastle(certPath, certPassword);
+ }
+ }
+
+ ///
+ /// Loads a PKCS#12 file written by BouncyCastle and returns an with
+ /// private key attached.
+ /// The PFX is parsed and re-encoded by BouncyCastle (ensuring format compatibility), then passed as
+ /// raw bytes to so neither RSACryptoServiceProvider nor the
+ /// EphemeralKeySet flag (unsupported on the Crestron/Mono runtime) is needed.
+ ///
+ private static X509Certificate2 LoadCertFromBouncyCastle(string certPath, string certPassword)
+ {
+ var passwordChars = certPassword.ToCharArray();
+ try
+ {
+ using (var stream = File.OpenRead(certPath))
+ {
+ var store = new Pkcs12StoreBuilder().Build();
+ store.Load(stream, passwordChars);
+
+ // Re-encode through BouncyCastle to guarantee PKCS#12 format compatibility,
+ // then hand raw bytes to X509Certificate2 — no RSACryptoServiceProvider needed.
+ using (var ms = new MemoryStream())
+ {
+ store.Save(ms, passwordChars, new SecureRandom());
+ var cert = new X509Certificate2(ms.ToArray(), certPassword);
+
+ if (!cert.HasPrivateKey)
+ throw new InvalidOperationException(
+ string.Format("Certificate loaded from '{0}' does not contain a private key and cannot be used as a server certificate.", certPath));
+
+ return cert;
+ }
}
}
-
- _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)
+ finally
{
- return;
+ Array.Clear(passwordChars, 0, passwordChars.Length);
}
-
- 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
+
+ private void Start(int port, string certPath = "", string certPassword = "")
{
- // Never throw from websocket log callback.
- }
- }
-
-}
-
-///
-/// Configures the logger to write log events to a debug WebSocket sink.
-///
-/// This extension method allows you to direct log events to a WebSocket sink for debugging
-/// purposes.
-public static class DebugWebsocketSinkExtensions
-{
- ///
- /// Configures a logger to write log events to a debug WebSocket sink.
- ///
- /// This method adds a sink that writes log events to a WebSocket for debugging purposes.
- /// It is typically used during development to stream log events in real-time.
- /// The logger sink configuration to apply the WebSocket sink to.
- /// An optional text formatter to format the log events. If not provided, a default formatter will be used.
- /// A object that can be used to further configure the logger.
- public static LoggerConfiguration DebugWebsocketSink(
- this LoggerSinkConfiguration loggerConfiguration,
- ITextFormatter formatProvider = null)
- {
- return loggerConfiguration.Sink(new DebugWebsocketSink(formatProvider));
- }
-}
-
-///
-/// Represents a WebSocket client for debugging purposes, providing connection lifecycle management and message
-/// handling functionality.
-///
-/// The class extends to handle
-/// WebSocket connections, including events for opening, closing, receiving messages, and errors. It tracks the
-/// duration of the connection and logs relevant events for debugging.
-public class DebugClient : WebSocketBehavior
-{
- private DateTime _connectionTime;
-
- ///
- /// Gets the duration of time the WebSocket connection has been active.
- ///
- public TimeSpan ConnectedDuration
- {
- get
- {
- if (Context.WebSocket.IsAlive)
+ try
{
- return DateTime.Now - _connectionTime;
+ _httpsServer = new HttpServer(port, true);
+
+ if (!string.IsNullOrWhiteSpace(certPath))
+ {
+ Debug.LogInformation("Assigning SSL Configuration");
+
+ _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.LogInformation("Ready");
}
- else
+ catch (Exception ex)
{
- return new TimeSpan(0);
+ 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;
+ }
+ }
+
+ ///
+ /// StopServer method
+ ///
+ public void StopServer()
+ {
+ Debug.LogInformation("Stopping Websocket Server");
+
+ 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.
}
}
}
+ public static class DebugWebsocketSinkExtensions
+ {
+ ///
+ /// DebugWebsocketSink method
+ ///
+ public static LoggerConfiguration DebugWebsocketSink(
+ this LoggerSinkConfiguration loggerConfiguration,
+ ITextFormatter formatProvider = null)
+ {
+ return loggerConfiguration.Sink(new DebugWebsocketSink(formatProvider));
+ }
+ }
+
///
- /// Initializes a new instance of the class.
+ /// Represents a DebugClient
///
- public DebugClient()
+ public class DebugClient : WebSocketBehavior
{
- Debug.LogInformation("DebugClient Created");
- }
+ private DateTime _connectionTime;
- ///
- protected override void OnOpen()
- {
- base.OnOpen();
+ public TimeSpan ConnectedDuration
+ {
+ get
+ {
+ if (Context.WebSocket.IsAlive)
+ {
+ return DateTime.Now - _connectionTime;
+ }
+ else
+ {
+ return new TimeSpan(0);
+ }
+ }
+ }
- var url = Context.WebSocket.Url;
- Debug.LogInformation("New WebSocket Connection from: {0}", url);
+ public DebugClient()
+ {
+ Debug.Console(0, "DebugClient Created");
+ }
- _connectionTime = DateTime.Now;
- }
+ protected override void OnOpen()
+ {
+ base.OnOpen();
- ///
- protected override void OnMessage(MessageEventArgs e)
- {
- base.OnMessage(e);
+ var url = Context.WebSocket.Url;
+ Debug.Console(0, Debug.ErrorLogLevel.Notice, "New WebSocket Connection from: {0}", url);
- Debug.LogVerbose("WebSocket UiClient Message: {0}", e.Data);
- }
+ _connectionTime = DateTime.Now;
+ }
- ///
- protected override void OnClose(CloseEventArgs e)
- {
- base.OnClose(e);
+ protected override void OnMessage(MessageEventArgs e)
+ {
+ base.OnMessage(e);
- Debug.LogDebug("WebSocket UiClient Closing: {0} reason: {1}", e.Code, e.Reason);
- }
+ Debug.Console(0, "WebSocket UiClient Message: {0}", e.Data);
+ }
- ///
- protected override void OnError(WebSocketSharp.ErrorEventArgs e)
- {
- base.OnError(e);
+ protected override void OnClose(CloseEventArgs e)
+ {
+ base.OnClose(e);
- Debug.LogError(e.Exception, "WebSocket UiClient Error: {0} message: {1}", e.Exception, e.Message);
- Debug.LogVerbose("Stack Trace:\r{0}", e.Exception.StackTrace);
+ Debug.Console(0, Debug.ErrorLogLevel.Notice, "WebSocket UiClient Closing: {0} reason: {1}", e.Code, e.Reason);
+
+ }
+
+ protected override void OnError(WebSocketSharp.ErrorEventArgs e)
+ {
+ base.OnError(e);
+
+ Debug.Console(2, Debug.ErrorLogLevel.Notice, "WebSocket UiClient Error: {0} message: {1}", e.Exception, e.Message);
+ }
}
}
diff --git a/src/PepperDash.Core/Network/DiscoveryThings.cs b/src/PepperDash.Core/Network/DiscoveryThings.cs
index c01613b7..973c03a4 100644
--- a/src/PepperDash.Core/Network/DiscoveryThings.cs
+++ b/src/PepperDash.Core/Network/DiscoveryThings.cs
@@ -4,17 +4,19 @@ using System.Linq;
using System.Text;
using Crestron.SimplSharp;
-namespace PepperDash.Core;
-
-///
-/// Not in use
-///
- public static class NetworkComm
- {
+namespace PepperDash.Core
+{
///
/// Not in use
///
+ public static class NetworkComm
+ {
+ ///
+ /// Not in use
+ ///
static NetworkComm()
{
}
- }
\ No newline at end of file
+ }
+
+}
\ No newline at end of file
diff --git a/src/PepperDash.Core/PasswordManagement/Config.cs b/src/PepperDash.Core/PasswordManagement/Config.cs
index a5f071a4..22aa4881 100644
--- a/src/PepperDash.Core/PasswordManagement/Config.cs
+++ b/src/PepperDash.Core/PasswordManagement/Config.cs
@@ -4,8 +4,8 @@ using System.Linq;
using System.Text;
using Crestron.SimplSharp;
-namespace PepperDash.Core.PasswordManagement;
-
+namespace PepperDash.Core.PasswordManagement
+{
///
/// JSON password configuration
///
@@ -22,4 +22,5 @@ namespace PepperDash.Core.PasswordManagement;
{
}
- }
\ No newline at end of file
+ }
+}
\ No newline at end of file
diff --git a/src/PepperDash.Core/PasswordManagement/Constants.cs b/src/PepperDash.Core/PasswordManagement/Constants.cs
index d4cf1e0b..65a1bf45 100644
--- a/src/PepperDash.Core/PasswordManagement/Constants.cs
+++ b/src/PepperDash.Core/PasswordManagement/Constants.cs
@@ -4,8 +4,8 @@ using System.Linq;
using System.Text;
using Crestron.SimplSharp;
-namespace PepperDash.Core.PasswordManagement;
-
+namespace PepperDash.Core.PasswordManagement
+{
///
/// Constants
///
@@ -53,4 +53,5 @@ namespace PepperDash.Core.PasswordManagement;
/// Generic string value change constant
///