diff --git a/src/PepperDash.Essentials.Core/Extensions/IpAddressExtensions.cs b/src/PepperDash.Essentials.Core/Extensions/IpAddressExtensions.cs new file mode 100644 index 00000000..35ec1b8a --- /dev/null +++ b/src/PepperDash.Essentials.Core/Extensions/IpAddressExtensions.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +namespace PepperDash.Essentials.Core +{ + /// + /// Extensions for IPAddress to provide additional functionality such as getting broadcast address, network address, and checking if two addresses are in the same subnet. + /// + public static class IPAddressExtensions + { + /// + /// Get the broadcast address for a given IP address and subnet mask. + /// + /// Address to check + /// Subnet mask in a.b.c.d format + /// Broadcast address + /// + /// If the input IP address is 192.168.1.100 and the subnet mask is 255.255.255.0, the broadcast address will be 192.168.1.255 + /// + /// + public static IPAddress GetBroadcastAddress(this IPAddress address, IPAddress subnetMask) + { + byte[] ipAdressBytes = address.GetAddressBytes(); + byte[] subnetMaskBytes = subnetMask.GetAddressBytes(); + + if (ipAdressBytes.Length != subnetMaskBytes.Length) + throw new ArgumentException("Lengths of IP address and subnet mask do not match."); + + byte[] broadcastAddress = new byte[ipAdressBytes.Length]; + for (int i = 0; i < broadcastAddress.Length; i++) + { + broadcastAddress[i] = (byte)(ipAdressBytes[i] | (subnetMaskBytes[i] ^ 255)); + } + return new IPAddress(broadcastAddress); + } + + /// + /// Get the network address for a given IP address and subnet mask. + /// + /// Address to check + /// Subnet mask in a.b.c.d + /// Network Address + /// /// + /// If the input IP address is 192.168.1.100 and the subnet mask is 255.255.255.0, the network address will be 192.168.1.0 + /// + /// + public static IPAddress GetNetworkAddress(this IPAddress address, IPAddress subnetMask) + { + byte[] ipAdressBytes = address.GetAddressBytes(); + byte[] subnetMaskBytes = subnetMask.GetAddressBytes(); + + if (ipAdressBytes.Length != subnetMaskBytes.Length) + throw new ArgumentException("Lengths of IP address and subnet mask do not match."); + + byte[] broadcastAddress = new byte[ipAdressBytes.Length]; + for (int i = 0; i < broadcastAddress.Length; i++) + { + broadcastAddress[i] = (byte)(ipAdressBytes[i] & (subnetMaskBytes[i])); + } + return new IPAddress(broadcastAddress); + } + + /// + /// Determine if two IP addresses are in the same subnet. + /// + /// Address to check + /// Second address to check + /// Subnet mask to use to compare the 2 IP Address + /// True if addresses are in the same subnet + /// + /// If the input IP addresses are 192.168.1.100 and 192.168.1.200, and the subnet mask is 255.255.255.0, this will return true. + /// If the input IP addresses are 10.1.1.100 and 192.168.1.100, and the subnet mask is 255.255.255.0, this will return false. + /// + public static bool IsInSameSubnet(this IPAddress address2, IPAddress address, IPAddress subnetMask) + { + IPAddress network1 = address.GetNetworkAddress(subnetMask); + IPAddress network2 = address2.GetNetworkAddress(subnetMask); + + return network1.Equals(network2); + } + } +} diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs index cd75dcb7..859a7c7b 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs @@ -1,1381 +1,1281 @@ -using Crestron.SimplSharp; -using Crestron.SimplSharp.WebScripting; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using PepperDash.Core; -using PepperDash.Core.Logging; -using PepperDash.Essentials.AppServer.Messengers; -using PepperDash.Essentials.Core; -using PepperDash.Essentials.Core.DeviceTypeInterfaces; -using PepperDash.Essentials.Core.Web; -using PepperDash.Essentials.RoomBridges; -using PepperDash.Essentials.WebApiHandlers; -using Serilog.Events; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Text.RegularExpressions; -using WebSocketSharp; -using WebSocketSharp.Net; -using WebSocketSharp.Server; -using ErrorEventArgs = WebSocketSharp.ErrorEventArgs; - - -namespace PepperDash.Essentials.WebSocketServer -{ - /// - /// Represents the behaviour to associate with a UiClient for WebSocket communication - /// - public class UiClient : WebSocketBehavior - { - public MobileControlSystemController Controller { get; set; } - - public string RoomKey { get; set; } - - private string _clientId; - - private DateTime _connectionTime; - - public TimeSpan ConnectedDuration - { - get - { - if (Context.WebSocket.IsAlive) - { - return DateTime.Now - _connectionTime; - } - else - { - return new TimeSpan(0); - } - } - } - - public UiClient() - { - - } - - protected override void OnOpen() - { - base.OnOpen(); - - var url = Context.WebSocket.Url; - Debug.LogMessage(LogEventLevel.Verbose, "New WebSocket Connection from: {0}", null, url); - - var match = Regex.Match(url.AbsoluteUri, "(?:ws|wss):\\/\\/.*(?:\\/mc\\/api\\/ui\\/join\\/)(.*)"); - - if (!match.Success) - { - _connectionTime = DateTime.Now; - return; - } - - var clientId = match.Groups[1].Value; - _clientId = clientId; - - if (Controller == null) - { - Debug.LogMessage(LogEventLevel.Verbose, "WebSocket UiClient Controller is null"); - _connectionTime = DateTime.Now; - } - - var clientJoinedMessage = new MobileControlMessage - { - Type = "/system/clientJoined", - Content = JToken.FromObject(new - { - clientId, - roomKey = RoomKey, - }) - }; - - Controller.HandleClientMessage(JsonConvert.SerializeObject(clientJoinedMessage)); - - var bridge = Controller.GetRoomBridge(RoomKey); - - if (bridge == null) return; - - SendUserCodeToClient(bridge, clientId); - - bridge.UserCodeChanged -= Bridge_UserCodeChanged; - bridge.UserCodeChanged += Bridge_UserCodeChanged; - - // TODO: Future: Check token to see if there's already an open session using that token and reject/close the session - } - - private void Bridge_UserCodeChanged(object sender, EventArgs e) - { - SendUserCodeToClient((MobileControlEssentialsRoomBridge)sender, _clientId); - } - - private void SendUserCodeToClient(MobileControlBridgeBase bridge, string clientId) - { - var content = new - { - userCode = bridge.UserCode, - qrUrl = bridge.QrCodeUrl, - }; - - var message = new MobileControlMessage - { - Type = "/system/userCodeChanged", - ClientId = clientId, - Content = JToken.FromObject(content) - }; - - Controller.SendMessageObjectToDirectClient(message); - } - - protected override void OnMessage(MessageEventArgs e) - { - base.OnMessage(e); - - if (e.IsText && e.Data.Length > 0 && Controller != null) - { - // Forward the message to the controller to be put on the receive queue - Controller.HandleClientMessage(e.Data); - } - } - - protected override void OnClose(CloseEventArgs e) - { - base.OnClose(e); - - Debug.LogMessage(LogEventLevel.Verbose, "WebSocket UiClient Closing: {0} reason: {1}", null, e.Code, e.Reason); - } - - protected override void OnError(ErrorEventArgs e) - { - base.OnError(e); - - Debug.LogMessage(LogEventLevel.Verbose, "WebSocket UiClient Error: {exception} message: {message}", e.Exception, e.Message); - } - } - - public class MobileControlWebsocketServer : EssentialsDevice - { - private readonly string userAppPath = Global.FilePathPrefix + "mcUserApp" + Global.DirectorySeparator; - - private readonly string localConfigFolderName = "_local-config"; - - private readonly string appConfigFileName = "_config.local.json"; - - /// - /// Where the key is the join token and the value is the room key - /// - //private Dictionary _joinTokens; - - private HttpServer _server; - - public HttpServer Server => _server; - - public Dictionary UiClients { get; private set; } - - private readonly MobileControlSystemController _parent; - - private WebSocketServerSecretProvider _secretProvider; - - private ServerTokenSecrets _secret; - - private static readonly HttpClient LogClient = new HttpClient(); - - private string SecretProviderKey - { - get - { - return string.Format("{0}:{1}-tokens", Global.ControlSystem.ProgramNumber, Key); - } - } - - /// - /// The path for the WebSocket messaging - /// - private readonly string _wsPath = "/mc/api/ui/join/"; - - public string WsPath => _wsPath; - - /// - /// The path to the location of the files for the user app (single page Angular app) - /// - private readonly string _appPath = string.Format("{0}mcUserApp", Global.FilePathPrefix); - - /// - /// The base HREF that the user app uses - /// - private string _userAppBaseHref = "/mc/app"; - - /// - /// The prot the server will run on - /// - public int Port { get; private set; } - - public string UserAppUrlPrefix - { - get - { - return string.Format("http://{0}:{1}{2}?token=", - CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), - Port, - _userAppBaseHref); - - } - } - - public int ConnectedUiClientsCount - { - get - { - var count = 0; - - foreach (var client in UiClients) - { - if (client.Value.Client != null && client.Value.Client.Context.WebSocket.IsAlive) - { - count++; - } - } - - return count; - } - } - - public MobileControlWebsocketServer(string key, int customPort, MobileControlSystemController parent) - : base(key) - { - _parent = parent; - - // Set the default port to be 50000 plus the slot number of the program - Port = 50000 + (int)Global.ControlSystem.ProgramNumber; - - if (customPort != 0) - { - Port = customPort; - } - - if (parent.Config.DirectServer.AutomaticallyForwardPortToCSLAN == true) - { - try - { - CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetCSAdapter); - - - Debug.LogMessage(LogEventLevel.Information, "Automatically forwarding port {0} to CS LAN", Port); - - var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetCSAdapter); - var csIp = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId); - - var result = CrestronEthernetHelper.AddPortForwarding((ushort)Port, (ushort)Port, csIp, CrestronEthernetHelper.ePortMapTransport.TCP); - - if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr) - { - Debug.LogMessage(LogEventLevel.Error, "Error adding port forwarding: {0}", result); - } - } - catch (ArgumentException) - { - Debug.LogMessage(LogEventLevel.Information, "This processor does not have a CS LAN", this); - } - catch (Exception ex) - { - Debug.LogMessage(ex, "Error automatically forwarding port to CS LAN"); - } - } - - - UiClients = new Dictionary(); - - //_joinTokens = new Dictionary(); - - if (Global.Platform == eDevicePlatform.Appliance) - { - AddConsoleCommands(); - } - - AddPreActivationAction(() => AddWebApiPaths()); - } - - private void AddWebApiPaths() - { - var apiServer = DeviceManager.AllDevices.OfType().FirstOrDefault(); - - if (apiServer == null) - { - this.LogInformation("No API Server available"); - return; - } - - var routes = new List - { - new HttpCwsRoute($"devices/{Key}/client") - { - Name = "ClientHandler", - RouteHandler = new UiClientHandler(this) - }, - }; - - apiServer.AddRoute(routes); - } - - private void AddConsoleCommands() - { - CrestronConsole.AddNewConsoleCommand(GenerateClientTokenFromConsole, "MobileAddUiClient", "Adds a client and generates a token. ? for more help", ConsoleAccessLevelEnum.AccessOperator); - CrestronConsole.AddNewConsoleCommand(RemoveToken, "MobileRemoveUiClient", "Removes a client. ? for more help", ConsoleAccessLevelEnum.AccessOperator); - CrestronConsole.AddNewConsoleCommand((s) => PrintClientInfo(), "MobileGetClientInfo", "Displays the current client info", ConsoleAccessLevelEnum.AccessOperator); - CrestronConsole.AddNewConsoleCommand(RemoveAllTokens, "MobileRemoveAllClients", "Removes all clients", ConsoleAccessLevelEnum.AccessOperator); - } - - - public override void Initialize() - { - try - { - base.Initialize(); - - _server = new HttpServer(Port, false); - - _server.OnGet += Server_OnGet; - - _server.OnOptions += Server_OnOptions; - - if (_parent.Config.DirectServer.Logging.EnableRemoteLogging) - { - _server.OnPost += Server_OnPost; - } - - CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; - - _server.Start(); - - if (_server.IsListening) - { - Debug.LogMessage(LogEventLevel.Information, "Mobile Control WebSocket Server listening on port {port}", this, _server.Port); - } - - CrestronEnvironment.ProgramStatusEventHandler += OnProgramStop; - - RetrieveSecret(); - - CreateFolderStructure(); - - AddClientsForTouchpanels(); - } - catch (Exception ex) - { - Debug.LogMessage(ex, "Exception intializing websocket server", this); - } - } - - private void AddClientsForTouchpanels() - { - var touchpanels = DeviceManager.AllDevices - .OfType().Where(tp => tp.UseDirectServer); - - - var touchpanelsToAdd = new List(); - - if (_secret != null) - { - var newTouchpanels = touchpanels.Where(tp => !_secret.Tokens.Any(t => t.Value.TouchpanelKey != null && t.Value.TouchpanelKey.Equals(tp.Key, StringComparison.InvariantCultureIgnoreCase))); - - touchpanelsToAdd.AddRange(newTouchpanels); - } - else - { - touchpanelsToAdd.AddRange(touchpanels); - } - - foreach (var client in touchpanelsToAdd) - { - var bridge = _parent.GetRoomBridge(client.DefaultRoomKey); - - if (bridge == null) - { - this.LogWarning("Unable to find room with key: {defaultRoomKey}", client.DefaultRoomKey); - return; - } - - var (key, path) = GenerateClientToken(bridge, client.Key); - - if (key == null) - { - this.LogWarning("Unable to generate a client for {clientKey}", client.Key); - continue; - } - } - - var lanAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetLANAdapter); - - var processorIp = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, lanAdapterId); - - this.LogVerbose("Processor IP: {processorIp}", processorIp); - - foreach (var touchpanel in touchpanels.Select(tp => - { - var token = _secret.Tokens.FirstOrDefault((t) => t.Value.TouchpanelKey.Equals(tp.Key, StringComparison.InvariantCultureIgnoreCase)); - - var messenger = _parent.GetRoomBridge(tp.DefaultRoomKey); - - return new { token.Key, Touchpanel = tp, Messenger = messenger }; - })) - { - if (touchpanel.Key == null) - { - this.LogWarning("Token for touchpanel {touchpanelKey} not found", touchpanel.Touchpanel.Key); - continue; - } - - if (touchpanel.Messenger == null) - { - this.LogWarning("Unable to find room messenger for {defaultRoomKey}", touchpanel.Touchpanel.DefaultRoomKey); - continue; - } - - var appUrl = $"http://{processorIp}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"; - - this.LogVerbose("Sending URL {appUrl}", appUrl); - - touchpanel.Messenger.UpdateAppUrl($"http://{processorIp}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"); - } - } - - private void OnProgramStop(eProgramStatusEventType programEventType) - { - switch (programEventType) - { - case eProgramStatusEventType.Stopping: - _server.Stop(); - break; - } - } - - private void CreateFolderStructure() - { - if (!Directory.Exists(userAppPath)) - { - Directory.CreateDirectory(userAppPath); - } - - if (!Directory.Exists($"{userAppPath}{localConfigFolderName}")) - { - Directory.CreateDirectory($"{userAppPath}{localConfigFolderName}"); - } - - using (var sw = new StreamWriter(File.Open($"{userAppPath}{localConfigFolderName}{Global.DirectorySeparator}{appConfigFileName}", FileMode.Create, FileAccess.ReadWrite))) - { - var config = GetApplicationConfig(); - - var contents = JsonConvert.SerializeObject(config, Formatting.Indented); - - sw.Write(contents); - } - } - - private MobileControlApplicationConfig GetApplicationConfig() - { - MobileControlApplicationConfig config = null; - - var lanAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetLANAdapter); - - var processorIp = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, lanAdapterId); - - try - { - if (_parent.Config.ApplicationConfig == null) - { - config = new MobileControlApplicationConfig - { - ApiPath = string.Format("http://{0}:{1}/mc/api", processorIp, _parent.Config.DirectServer.Port), - GatewayAppPath = "", - LogoPath = "logo/logo.png", - EnableDev = false, - IconSet = MCIconSet.GOOGLE, - LoginMode = "room-list", - Modes = new Dictionary - { - { - "room-list", - new McMode{ - ListPageText= "Please select your room", - LoginHelpText = "Please select your room from the list, then enter the code shown on the display.", - PasscodePageText = "Please enter the code shown on this room's display" - } - } - }, - Logging = _parent.Config.DirectServer.Logging.EnableRemoteLogging, - }; - } - else - { - config = new MobileControlApplicationConfig - { - ApiPath = string.Format("http://{0}:{1}/mc/api", processorIp, _parent.Config.DirectServer.Port), - GatewayAppPath = "", - LogoPath = _parent.Config.ApplicationConfig.LogoPath ?? "logo/logo.png", - EnableDev = _parent.Config.ApplicationConfig.EnableDev ?? false, - IconSet = _parent.Config.ApplicationConfig.IconSet ?? MCIconSet.GOOGLE, - LoginMode = _parent.Config.ApplicationConfig.LoginMode ?? "room-list", - Modes = _parent.Config.ApplicationConfig.Modes ?? new Dictionary - { - { - "room-list", - new McMode { - ListPageText = "Please select your room", - LoginHelpText = "Please select your room from the list, then enter the code shown on the display.", - PasscodePageText = "Please enter the code shown on this room's display" - } - } - }, - Logging = _parent.Config.ApplicationConfig.Logging, - PartnerMetadata = _parent.Config.ApplicationConfig.PartnerMetadata ?? new List() - }; - } - } - catch (Exception ex) - { - Debug.LogMessage(ex, "Error getting application configuration", this); - - Debug.LogMessage(LogEventLevel.Verbose, "Config Object: {config} from {parentConfig}", this, config, _parent.Config); - } - - return config; - } - - /// - /// Attempts to retrieve secrets previously stored in memory - /// - private void RetrieveSecret() - { - try - { - // Add secret provider - _secretProvider = new WebSocketServerSecretProvider(SecretProviderKey); - - // Check for existing secrets - var secret = _secretProvider.GetSecret(SecretProviderKey); - - if (secret != null) - { - Debug.LogMessage(LogEventLevel.Information, "Secret successfully retrieved", this); - - Debug.LogMessage(LogEventLevel.Debug, "Secret: {0}", this, secret.Value.ToString()); - - - // populate the local secrets object - _secret = JsonConvert.DeserializeObject(secret.Value.ToString()); - - if (_secret != null && _secret.Tokens != null) - { - // populate the _uiClient collection - foreach (var token in _secret.Tokens) - { - if (token.Value == null) - { - Debug.LogMessage(LogEventLevel.Warning, "Token value is null", this); - continue; - } - - Debug.LogMessage(LogEventLevel.Information, "Adding token: {0} for room: {1}", this, token.Key, token.Value.RoomKey); - - if (UiClients == null) - { - Debug.LogMessage(LogEventLevel.Warning, "UiClients is null", this); - UiClients = new Dictionary(); - } - - UiClients.Add(token.Key, new UiClientContext(token.Value)); - } - } - - if (UiClients.Count > 0) - { - Debug.LogMessage(LogEventLevel.Information, "Restored {uiClientCount} UiClients from secrets data", this, UiClients.Count); - - foreach (var client in UiClients) - { - var key = client.Key; - var path = _wsPath + key; - var roomKey = client.Value.Token.RoomKey; - - _server.AddWebSocketService(path, () => - { - var c = new UiClient(); - Debug.LogMessage(LogEventLevel.Debug, "Constructing UiClient with id: {key}", this, key); - - c.Controller = _parent; - c.RoomKey = roomKey; - UiClients[key].SetClient(c); - return c; - }); - - - //_server.WebSocketServices.AddService(path, (c) => - //{ - // Debug.Console(2, this, "Constructing UiClient with id: {0}", key); - // c.Controller = _parent; - // c.RoomKey = roomKey; - // UiClients[key].SetClient(c); - //}); - } - } - } - else - { - Debug.LogMessage(LogEventLevel.Warning, "No secret found"); - } - - Debug.LogMessage(LogEventLevel.Debug, "{uiClientCount} UiClients restored from secrets data", this, UiClients.Count); - } - catch (Exception ex) - { - Debug.LogMessage(ex, "Exception retrieving secret", this); - } - } - - /// - /// Stores secrets to memory to persist through reboot - /// - public void UpdateSecret() - { - try - { - if (_secret == null) - { - Debug.LogMessage(LogEventLevel.Error, "Secret is null", this); - - _secret = new ServerTokenSecrets(string.Empty); - } - - _secret.Tokens.Clear(); - - foreach (var uiClientContext in UiClients) - { - _secret.Tokens.Add(uiClientContext.Key, uiClientContext.Value.Token); - } - - var serializedSecret = JsonConvert.SerializeObject(_secret); - - _secretProvider.SetSecret(SecretProviderKey, serializedSecret); - } - catch (Exception ex) - { - Debug.LogMessage(ex, "Exception updating secret", this); - } - } - - /// - /// Generates a new token based on validating a room key and grant code passed in. If valid, returns a token and adds a service to the server for that token's path - /// - /// - private void GenerateClientTokenFromConsole(string s) - { - if (s == "?" || string.IsNullOrEmpty(s)) - { - CrestronConsole.ConsoleCommandResponse(@"[RoomKey] [GrantCode] Validates the room key against the grant code and returns a token for use in a UI client"); - return; - } - - var values = s.Split(' '); - - if(values.Length < 2) - { - CrestronConsole.ConsoleCommandResponse("Invalid number of arguments. Please provide a room key and a grant code"); - return; - } - - - var roomKey = values[0]; - var grantCode = values[1]; - - var bridge = _parent.GetRoomBridge(roomKey); - - if (bridge == null) - { - CrestronConsole.ConsoleCommandResponse(string.Format("Unable to find room with key: {0}", roomKey)); - return; - } - - var (token, path) = ValidateGrantCode(grantCode, bridge); - - if (token == null) - { - CrestronConsole.ConsoleCommandResponse("Grant Code is not valid"); - return; - } - - CrestronConsole.ConsoleCommandResponse($"Added new WebSocket UiClient service at path: {path}"); - CrestronConsole.ConsoleCommandResponse($"Token: {token}"); - } - - public (string, string) ValidateGrantCode(string grantCode, string roomKey) - { - var bridge = _parent.GetRoomBridge(roomKey); - - if (bridge == null) - { - this.LogWarning("Unable to find room with key: {roomKey}", roomKey); - return (null, null); - } - - return ValidateGrantCode(grantCode, bridge); - } - - public (string, string) ValidateGrantCode(string grantCode, MobileControlBridgeBase bridge) - { - // TODO: Authenticate grant code passed in - // For now, we just generate a random guid as the token and use it as the ClientId as well - var grantCodeIsValid = true; - - if (grantCodeIsValid) - { - if (_secret == null) - { - _secret = new ServerTokenSecrets(grantCode); - } - - return GenerateClientToken(bridge, ""); - } - else - { - return (null, null); - } - } - - public (string, string) GenerateClientToken(MobileControlBridgeBase bridge, string touchPanelKey = "") - { - var key = Guid.NewGuid().ToString(); - - var token = new JoinToken { Code = bridge.UserCode, RoomKey = bridge.RoomKey, Uuid = _parent.SystemUuid, TouchpanelKey = touchPanelKey }; - - UiClients.Add(key, new UiClientContext(token)); - - var path = _wsPath + key; - - _server.AddWebSocketService(path, () => - { - var c = new UiClient(); - Debug.LogMessage(LogEventLevel.Verbose, "Constructing UiClient with id: {0}", this, key); - c.Controller = _parent; - c.RoomKey = bridge.RoomKey; - UiClients[key].SetClient(c); - return c; - }); - - Debug.LogMessage(LogEventLevel.Information, "Added new WebSocket UiClient service at path: {path}", this, path); - Debug.LogMessage(LogEventLevel.Information, "Token: {@token}", this, token); - - Debug.LogMessage(LogEventLevel.Verbose, "{serviceCount} websocket services present", this, _server.WebSocketServices.Count); - - UpdateSecret(); - - return (key, path); - } - - /// - /// Removes all clients from the server - /// - private void RemoveAllTokens(string s) - { - if (s == "?" || string.IsNullOrEmpty(s)) - { - CrestronConsole.ConsoleCommandResponse(@"Removes all clients from the server. To execute add 'confirm' to command"); - return; - } - - if (s != "confirm") - { - CrestronConsole.ConsoleCommandResponse(@"To remove all clients, add 'confirm' to the command"); - return; - } - - foreach (var client in UiClients) - { - if (client.Value.Client != null && client.Value.Client.Context.WebSocket.IsAlive) - { - client.Value.Client.Context.WebSocket.Close(CloseStatusCode.Normal, "Server Shutting Down"); - } - - var path = _wsPath + client.Key; - if (_server.RemoveWebSocketService(path)) - { - CrestronConsole.ConsoleCommandResponse(string.Format("Client removed with token: {0}", client.Key)); - } - else - { - CrestronConsole.ConsoleCommandResponse(string.Format("Unable to remove client with token : {0}", client.Key)); - } - } - - UiClients.Clear(); - - UpdateSecret(); - } - - /// - /// Removes a client with the specified token value - /// - /// - private void RemoveToken(string s) - { - if (s == "?" || string.IsNullOrEmpty(s)) - { - CrestronConsole.ConsoleCommandResponse(@"[token] Removes the client with the specified token value"); - return; - } - - var key = s; - - if (UiClients.ContainsKey(key)) - { - var uiClientContext = UiClients[key]; - - if (uiClientContext.Client != null && uiClientContext.Client.Context.WebSocket.IsAlive) - { - uiClientContext.Client.Context.WebSocket.Close(CloseStatusCode.Normal, "Token removed from server"); - } - - var path = _wsPath + key; - if (_server.RemoveWebSocketService(path)) - { - UiClients.Remove(key); - - UpdateSecret(); - - CrestronConsole.ConsoleCommandResponse(string.Format("Client removed with token: {0}", key)); - } - else - { - CrestronConsole.ConsoleCommandResponse(string.Format("Unable to remove client with token : {0}", key)); - } - } - else - { - CrestronConsole.ConsoleCommandResponse(string.Format("Unable to find client with token: {0}", key)); - } - } - - /// - /// Prints out info about current client IDs - /// - private void PrintClientInfo() - { - CrestronConsole.ConsoleCommandResponse("Mobile Control UI Client Info:\r"); - - CrestronConsole.ConsoleCommandResponse(string.Format("{0} clients found:\r", UiClients.Count)); - - foreach (var client in UiClients) - { - CrestronConsole.ConsoleCommandResponse(string.Format("RoomKey: {0} Token: {1}\r", client.Value.Token.RoomKey, client.Key)); - } - } - - private void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) - { - if (programEventType == eProgramStatusEventType.Stopping) - { - foreach (var client in UiClients.Values) - { - if (client.Client != null && client.Client.Context.WebSocket.IsAlive) - { - client.Client.Context.WebSocket.Close(CloseStatusCode.Normal, "Server Shutting Down"); - } - } - - StopServer(); - } - } - - /// - /// Handler for GET requests to server - /// - /// - /// - private void Server_OnGet(object sender, HttpRequestEventArgs e) - { - try - { - var req = e.Request; - var res = e.Response; - res.ContentEncoding = Encoding.UTF8; - - res.AddHeader("Access-Control-Allow-Origin", "*"); - - var path = req.RawUrl; - - this.LogVerbose("GET Request received at path: {path}", path); - - // Call for user app to join the room with a token - if (path.StartsWith("/mc/api/ui/joinroom")) - { - HandleJoinRequest(req, res); - } - // Call to get the server version - else if (path.StartsWith("/mc/api/version")) - { - HandleVersionRequest(res); - } - else if (path.StartsWith("/mc/app/logo")) - { - HandleImageRequest(req, res); - } - // Call to serve the user app - else if (path.StartsWith(_userAppBaseHref)) - { - HandleUserAppRequest(req, res, path); - } - else - { - // All other paths - res.StatusCode = 404; - res.Close(); - } - } - catch (Exception ex) - { - Debug.LogMessage(ex, "Caught an exception in the OnGet handler", this); - } - } - - private async void Server_OnPost(object sender, HttpRequestEventArgs e) - { - try - { - var req = e.Request; - var res = e.Response; - - res.AddHeader("Access-Control-Allow-Origin", "*"); - - var path = req.RawUrl; - var ip = req.RemoteEndPoint.Address.ToString(); - - this.LogVerbose("POST Request received at path: {path} from host {host}", path, ip); - - var body = new StreamReader(req.InputStream).ReadToEnd(); - - if (path.StartsWith("/mc/api/log")) - { - res.StatusCode = 200; - res.Close(); - - var logRequest = new HttpRequestMessage(HttpMethod.Post, $"http://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs") - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }; - - logRequest.Headers.Add("x-pepperdash-host", ip); - - await LogClient.SendAsync(logRequest); - - this.LogVerbose("Log data sent to {host}:{port}", _parent.Config.DirectServer.Logging.Host, _parent.Config.DirectServer.Logging.Port); - } - else - { - res.StatusCode = 404; - res.Close(); - } - } - catch (Exception ex) - { - this.LogException(ex, "Caught an exception in the OnPost handler"); - } - } - - private void Server_OnOptions(object sender, HttpRequestEventArgs e) - { - try - { - var res = e.Response; - - res.AddHeader("Access-Control-Allow-Origin", "*"); - res.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.AddHeader("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With, remember-me"); - - res.StatusCode = 200; - res.Close(); - } - catch (Exception ex) - { - Debug.LogMessage(ex, "Caught an exception in the OnPost handler", this); - } - } - - /// - /// Handle the request to join the room with a token - /// - /// - /// - private void HandleJoinRequest(HttpListenerRequest req, HttpListenerResponse res) - { - var qp = req.QueryString; - var token = qp["token"]; - - this.LogVerbose("Join Room Request with token: {token}", token); - - - if (UiClients.TryGetValue(token, out UiClientContext clientContext)) - { - var bridge = _parent.GetRoomBridge(clientContext.Token.RoomKey); - - if (bridge != null) - { - res.StatusCode = 200; - res.ContentType = "application/json"; - - // Construct the response object - JoinResponse jRes = new JoinResponse - { - ClientId = token, - RoomKey = bridge.RoomKey, - SystemUuid = _parent.SystemUuid, - RoomUuid = _parent.SystemUuid, - Config = _parent.GetConfigWithPluginVersion(), - CodeExpires = new DateTime().AddYears(1), - UserCode = bridge.UserCode, - UserAppUrl = string.Format("http://{0}:{1}/mc/app", - CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), - Port), - EnableDebug = false - }; - - // Serialize to JSON and convert to Byte[] - var json = JsonConvert.SerializeObject(jRes); - var body = Encoding.UTF8.GetBytes(json); - res.ContentLength64 = body.LongLength; - - // Send the response - res.Close(body, true); - } - else - { - var message = string.Format("Unable to find bridge with key: {0}", clientContext.Token.RoomKey); - res.StatusCode = 404; - res.ContentType = "application/json"; - this.LogVerbose("{message}", message); - var body = Encoding.UTF8.GetBytes(message); - res.ContentLength64 = body.LongLength; - res.Close(body, true); - - } - } - else - { - var message = "Token invalid or has expired"; - res.StatusCode = 401; - res.ContentType = "application/json"; - this.LogVerbose("{message}", message); - var body = Encoding.UTF8.GetBytes(message); - res.ContentLength64 = body.LongLength; - res.Close(body, true); - } - } - - /// - /// Handles a server version request - /// - /// - private void HandleVersionRequest(HttpListenerResponse res) - { - res.StatusCode = 200; - res.ContentType = "application/json"; - var version = new Version() { ServerVersion = _parent.GetConfigWithPluginVersion().RuntimeInfo.PluginVersion }; - var message = JsonConvert.SerializeObject(version); - this.LogVerbose("{message}", message); - - var body = Encoding.UTF8.GetBytes(message); - res.ContentLength64 = body.LongLength; - res.Close(body, true); - } - - /// - /// Handler to return images requested by the user app - /// - /// - /// - private void HandleImageRequest(HttpListenerRequest req, HttpListenerResponse res) - { - var path = req.RawUrl; - - Debug.LogMessage(LogEventLevel.Verbose, "Requesting Image: {0}", this, path); - - var imageBasePath = Global.DirectorySeparator + "html" + Global.DirectorySeparator + "logo" + Global.DirectorySeparator; - - var image = path.Split('/').Last(); - - var filePath = imageBasePath + image; - - Debug.LogMessage(LogEventLevel.Verbose, "Retrieving Image: {0}", this, filePath); - - if (File.Exists(filePath)) - { - if (filePath.EndsWith(".png")) - { - res.ContentType = "image/png"; - } - else if (filePath.EndsWith(".jpg")) - { - res.ContentType = "image/jpeg"; - } - else if (filePath.EndsWith(".gif")) - { - res.ContentType = "image/gif"; - } - else if (filePath.EndsWith(".svg")) - { - res.ContentType = "image/svg+xml"; - } - byte[] contents = File.ReadAllBytes(filePath); - res.ContentLength64 = contents.LongLength; - res.Close(contents, true); - } - else - { - res.StatusCode = (int)HttpStatusCode.NotFound; - res.Close(); - } - } - - /// - /// Handles requests to serve files for the Angular single page app - /// - /// - /// - /// - private void HandleUserAppRequest(HttpListenerRequest req, HttpListenerResponse res, string path) - { - this.LogVerbose("Requesting User app file"); - - string filePath = path.Split('?')[0]; - - // remove the token from the path if found - //string filePath = path.Replace(string.Format("?token={0}", token), ""); - - // if there's no file suffix strip any extra path data after the base href - if (filePath != _userAppBaseHref && !filePath.Contains(".") && (!filePath.EndsWith(_userAppBaseHref) || !filePath.EndsWith(_userAppBaseHref += "/"))) - { - var suffix = filePath.Substring(_userAppBaseHref.Length, filePath.Length - _userAppBaseHref.Length); - if (suffix != "/") - { - //Debug.Console(2, this, "Suffix: {0}", suffix); - filePath = filePath.Replace(suffix, ""); - } - } - - // swap the base href prefix for the file path prefix - filePath = filePath.Replace(_userAppBaseHref, _appPath); - - this.LogVerbose("filepath: {filePath}", filePath); - - - // append index.html if no specific file is specified - if (!filePath.Contains(".")) - { - if (filePath.EndsWith("/")) - { - filePath += "index.html"; - } - else - { - filePath += "/index.html"; - } - } - - // Set ContentType based on file type - if (filePath.EndsWith(".html")) - { - this.LogVerbose("Client requesting User App"); - - res.ContentType = "text/html"; - } - else - { - if (path.EndsWith(".js")) - { - res.ContentType = "application/javascript"; - } - else if (path.EndsWith(".css")) - { - res.ContentType = "text/css"; - } - else if (path.EndsWith(".json")) - { - res.ContentType = "application/json"; - } - } - - this.LogVerbose("Attempting to serve file: {filePath}", filePath); - - byte[] contents; - if (File.Exists(filePath)) - { - this.LogVerbose("File found: {filePath}", filePath); - contents = File.ReadAllBytes(filePath); - } - else - { - this.LogVerbose("File not found: {filePath}", filePath); - res.StatusCode = (int)HttpStatusCode.NotFound; - res.Close(); - return; - } - - res.ContentLength64 = contents.LongLength; - res.Close(contents, true); - } - - public void StopServer() - { - this.LogVerbose("Stopping WebSocket Server"); - _server.Stop(CloseStatusCode.Normal, "Server Shutting Down"); - } - - /// - /// Sends a message to all connectd clients - /// - /// - public void SendMessageToAllClients(string message) - { - foreach (var clientContext in UiClients.Values) - { - if (clientContext.Client != null && clientContext.Client.Context.WebSocket.IsAlive) - { - clientContext.Client.Context.WebSocket.Send(message); - } - } - } - - /// - /// Sends a message to a specific client - /// - /// - /// - public void SendMessageToClient(object clientId, string message) - { - if (clientId == null) - { - return; - } - - if (UiClients.TryGetValue((string)clientId, out UiClientContext clientContext)) - { - if (clientContext.Client != null) - { - var socket = clientContext.Client.Context.WebSocket; - - if (socket.IsAlive) - { - socket.Send(message); - } - } - } - else - { - this.LogWarning("Unable to find client with ID: {clientId}", clientId); - } - } - } - - /// - /// Class to describe the server version info - /// - public class Version - { - [JsonProperty("serverVersion")] - public string ServerVersion { get; set; } - - [JsonProperty("serverIsRunningOnProcessorHardware")] - public bool ServerIsRunningOnProcessorHardware { get; private set; } - - public Version() - { - ServerIsRunningOnProcessorHardware = true; - } - } - - /// - /// Represents an instance of a UiClient and the associated Token - /// - public class UiClientContext - { - public UiClient Client { get; private set; } - public JoinToken Token { get; private set; } - - public UiClientContext(JoinToken token) - { - Token = token; - } - - public void SetClient(UiClient client) - { - Client = client; - } - - } - - /// - /// Represents the data structure for the grant code and UiClient tokens to be stored in the secrets manager - /// - public class ServerTokenSecrets - { - public string GrantCode { get; set; } - - public Dictionary Tokens { get; set; } - - public ServerTokenSecrets(string grantCode) - { - GrantCode = grantCode; - Tokens = new Dictionary(); - } - } - - /// - /// Represents a join token with the associated properties - /// - public class JoinToken - { - public string Code { get; set; } - - public string RoomKey { get; set; } - - public string Uuid { get; set; } - - public string TouchpanelKey { get; set; } = ""; - - public string Token { get; set; } = null; - } - - /// - /// Represents the structure of the join response - /// - public class JoinResponse - { - [JsonProperty("clientId")] - public string ClientId { get; set; } - - [JsonProperty("roomKey")] - public string RoomKey { get; set; } - - [JsonProperty("systemUUid")] - public string SystemUuid { get; set; } - - [JsonProperty("roomUUid")] - public string RoomUuid { get; set; } - - [JsonProperty("config")] - public object Config { get; set; } - - [JsonProperty("codeExpires")] - public DateTime CodeExpires { get; set; } - - [JsonProperty("userCode")] - public string UserCode { get; set; } - - [JsonProperty("userAppUrl")] - public string UserAppUrl { get; set; } - - [JsonProperty("enableDebug")] - public bool EnableDebug { get; set; } - } -} +using Crestron.SimplSharp; +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using PepperDash.Core; +using PepperDash.Core.Logging; +using PepperDash.Essentials.Core; +using PepperDash.Essentials.Core.DeviceTypeInterfaces; +using PepperDash.Essentials.Core.Web; +using PepperDash.Essentials.RoomBridges; +using PepperDash.Essentials.WebApiHandlers; +using Serilog.Events; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using WebSocketSharp; +using WebSocketSharp.Net; +using WebSocketSharp.Server; + + +namespace PepperDash.Essentials.WebSocketServer +{ + public class MobileControlWebsocketServer : EssentialsDevice + { + private readonly string userAppPath = Global.FilePathPrefix + "mcUserApp" + Global.DirectorySeparator; + + private readonly string localConfigFolderName = "_local-config"; + + private readonly string appConfigFileName = "_config.local.json"; + private readonly string appConfigCsFileName = "_config.cs.json"; + + /// + /// Where the key is the join token and the value is the room key + /// + //private Dictionary _joinTokens; + + private HttpServer _server; + + public HttpServer Server => _server; + + public Dictionary UiClients { get; private set; } + + private readonly MobileControlSystemController _parent; + + private WebSocketServerSecretProvider _secretProvider; + + private ServerTokenSecrets _secret; + + private static readonly HttpClient LogClient = new HttpClient(); + + private string SecretProviderKey + { + get + { + return string.Format("{0}:{1}-tokens", Global.ControlSystem.ProgramNumber, Key); + } + } + + private string lanIpAddress => CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetLANAdapter)); + + private System.Net.IPAddress csIpAddress; + + private System.Net.IPAddress csSubnetMask; + + /// + /// The path for the WebSocket messaging + /// + private readonly string _wsPath = "/mc/api/ui/join/"; + + public string WsPath => _wsPath; + + /// + /// The path to the location of the files for the user app (single page Angular app) + /// + private readonly string _appPath = string.Format("{0}mcUserApp", Global.FilePathPrefix); + + /// + /// The base HREF that the user app uses + /// + private string _userAppBaseHref = "/mc/app"; + + /// + /// The port the server will run on + /// + public int Port { get; private set; } + + public string UserAppUrlPrefix + { + get + { + return string.Format("http://{0}:{1}{2}?token=", + CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), + Port, + _userAppBaseHref); + + } + } + + public int ConnectedUiClientsCount + { + get + { + var count = 0; + + foreach (var client in UiClients) + { + if (client.Value.Client != null && client.Value.Client.Context.WebSocket.IsAlive) + { + count++; + } + } + + return count; + } + } + + public MobileControlWebsocketServer(string key, int customPort, MobileControlSystemController parent) + : base(key) + { + _parent = parent; + + // Set the default port to be 50000 plus the slot number of the program + Port = 50000 + (int)Global.ControlSystem.ProgramNumber; + + if (customPort != 0) + { + Port = customPort; + } + + if (parent.Config.DirectServer.AutomaticallyForwardPortToCSLAN == true) + { + try + { + Debug.LogMessage(LogEventLevel.Information, "Automatically forwarding port {0} to CS LAN", Port); + + var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetCSAdapter); + var csIp = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId); + + var result = CrestronEthernetHelper.AddPortForwarding((ushort)Port, (ushort)Port, csIp, CrestronEthernetHelper.ePortMapTransport.TCP); + + if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr) + { + Debug.LogMessage(LogEventLevel.Error, "Error adding port forwarding: {0}", result); + } + } + catch (ArgumentException) + { + Debug.LogMessage(LogEventLevel.Information, "This processor does not have a CS LAN", this); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Error automatically forwarding port to CS LAN"); + } + } + + try + { + var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetCSAdapter); + var csSubnetMask = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_MASK, csAdapterId); + var csIpAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId); + + this.csSubnetMask = System.Net.IPAddress.Parse(csSubnetMask); + this.csIpAddress = System.Net.IPAddress.Parse(csIpAddress); + } + catch (ArgumentException) + { + if (parent.Config.DirectServer.AutomaticallyForwardPortToCSLAN == false) + { + Debug.LogMessage(LogEventLevel.Information, "This processor does not have a CS LAN", this); + } + } + + + UiClients = new Dictionary(); + + //_joinTokens = new Dictionary(); + + if (Global.Platform == eDevicePlatform.Appliance) + { + AddConsoleCommands(); + } + + AddPreActivationAction(() => AddWebApiPaths()); + } + + private void AddWebApiPaths() + { + var apiServer = DeviceManager.AllDevices.OfType().FirstOrDefault(); + + if (apiServer == null) + { + this.LogInformation("No API Server available"); + return; + } + + var routes = new List + { + new HttpCwsRoute($"devices/{Key}/client") + { + Name = "ClientHandler", + RouteHandler = new UiClientHandler(this) + }, + }; + + apiServer.AddRoute(routes); + } + + private void AddConsoleCommands() + { + CrestronConsole.AddNewConsoleCommand(GenerateClientTokenFromConsole, "MobileAddUiClient", "Adds a client and generates a token. ? for more help", ConsoleAccessLevelEnum.AccessOperator); + CrestronConsole.AddNewConsoleCommand(RemoveToken, "MobileRemoveUiClient", "Removes a client. ? for more help", ConsoleAccessLevelEnum.AccessOperator); + CrestronConsole.AddNewConsoleCommand((s) => PrintClientInfo(), "MobileGetClientInfo", "Displays the current client info", ConsoleAccessLevelEnum.AccessOperator); + CrestronConsole.AddNewConsoleCommand(RemoveAllTokens, "MobileRemoveAllClients", "Removes all clients", ConsoleAccessLevelEnum.AccessOperator); + } + + + public override void Initialize() + { + try + { + base.Initialize(); + + _server = new HttpServer(Port, false); + + _server.OnGet += Server_OnGet; + + _server.OnOptions += Server_OnOptions; + + if (_parent.Config.DirectServer.Logging.EnableRemoteLogging) + { + _server.OnPost += Server_OnPost; + } + + CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; + + _server.Start(); + + if (_server.IsListening) + { + Debug.LogMessage(LogEventLevel.Information, "Mobile Control WebSocket Server listening on port {port}", this, _server.Port); + } + + CrestronEnvironment.ProgramStatusEventHandler += OnProgramStop; + + RetrieveSecret(); + + CreateFolderStructure(); + + AddClientsForTouchpanels(); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Exception intializing websocket server", this); + } + } + + private void AddClientsForTouchpanels() + { + var touchpanels = DeviceManager.AllDevices + .OfType().Where(tp => tp.UseDirectServer); + + + var touchpanelsToAdd = new List(); + + if (_secret != null) + { + var newTouchpanels = touchpanels.Where(tp => !_secret.Tokens.Any(t => t.Value.TouchpanelKey != null && t.Value.TouchpanelKey.Equals(tp.Key, StringComparison.InvariantCultureIgnoreCase))); + + touchpanelsToAdd.AddRange(newTouchpanels); + } + else + { + touchpanelsToAdd.AddRange(touchpanels); + } + + foreach (var client in touchpanelsToAdd) + { + var bridge = _parent.GetRoomBridge(client.DefaultRoomKey); + + if (bridge == null) + { + this.LogWarning("Unable to find room with key: {defaultRoomKey}", client.DefaultRoomKey); + return; + } + + var (key, path) = GenerateClientToken(bridge, client.Key); + + if (key == null) + { + this.LogWarning("Unable to generate a client for {clientKey}", client.Key); + continue; + } + } + + var lanAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetLANAdapter); + + var processorIp = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, lanAdapterId); + + this.LogVerbose("Processor IP: {processorIp}", processorIp); + + foreach (var touchpanel in touchpanels.Select(tp => + { + var token = _secret.Tokens.FirstOrDefault((t) => t.Value.TouchpanelKey.Equals(tp.Key, StringComparison.InvariantCultureIgnoreCase)); + + var messenger = _parent.GetRoomBridge(tp.DefaultRoomKey); + + return new { token.Key, Touchpanel = tp, Messenger = messenger }; + })) + { + if (touchpanel.Key == null) + { + this.LogWarning("Token for touchpanel {touchpanelKey} not found", touchpanel.Touchpanel.Key); + continue; + } + + if (touchpanel.Messenger == null) + { + this.LogWarning("Unable to find room messenger for {defaultRoomKey}", touchpanel.Touchpanel.DefaultRoomKey); + continue; + } + + var appUrl = $"http://{processorIp}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"; + + this.LogVerbose("Sending URL {appUrl}", appUrl); + + touchpanel.Messenger.UpdateAppUrl($"http://{processorIp}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"); + } + } + + private void OnProgramStop(eProgramStatusEventType programEventType) + { + switch (programEventType) + { + case eProgramStatusEventType.Stopping: + _server.Stop(); + break; + } + } + + private void CreateFolderStructure() + { + if (!Directory.Exists(userAppPath)) + { + Directory.CreateDirectory(userAppPath); + } + + if (!Directory.Exists($"{userAppPath}{localConfigFolderName}")) + { + Directory.CreateDirectory($"{userAppPath}{localConfigFolderName}"); + } + + using (var sw = new StreamWriter(File.Open($"{userAppPath}{localConfigFolderName}{Global.DirectorySeparator}{appConfigFileName}", FileMode.Create, FileAccess.ReadWrite))) + { + // Write the LAN application configuration file. Used when a request comes in for the application config from the LAN + var lanAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetLANAdapter); + + this.LogDebug("LAN Adapter ID: {lanAdapterId}", lanAdapterId); + + var processorIp = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, lanAdapterId); + + var config = GetApplicationConfig(processorIp); + + var contents = JsonConvert.SerializeObject(config, Formatting.Indented); + + sw.Write(contents); + } + + short csAdapterId; + try + { + csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetCSAdapter); + } + catch (ArgumentException) + { + this.LogDebug("This processor does not have a CS LAN"); + return; + } + + if(csAdapterId == -1) + { + this.LogDebug("CS LAN Adapter not found"); + return; + } + + this.LogDebug("CS LAN Adapter ID: {csAdapterId}. Adding CS Config", csAdapterId); + + using (var sw = new StreamWriter(File.Open($"{userAppPath}{localConfigFolderName}{Global.DirectorySeparator}{appConfigCsFileName}", FileMode.Create, FileAccess.ReadWrite))) + { + // Write the CS application configuration file. Used when a request comes in for the application config from the CS + var processorIp = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId); + + var config = GetApplicationConfig(processorIp); + + var contents = JsonConvert.SerializeObject(config, Formatting.Indented); + + sw.Write(contents); + } + } + + private MobileControlApplicationConfig GetApplicationConfig(string processorIp) + { + try + { + var config = new MobileControlApplicationConfig + { + ApiPath = string.Format("http://{0}:{1}/mc/api", processorIp, _parent.Config.DirectServer.Port), + GatewayAppPath = "", + LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png", + EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false, + IconSet = _parent.Config.ApplicationConfig?.IconSet ?? MCIconSet.GOOGLE, + LoginMode = _parent.Config.ApplicationConfig?.LoginMode ?? "room-list", + Modes = _parent.Config.ApplicationConfig?.Modes ?? new Dictionary + { + { + "room-list", + new McMode { + ListPageText = "Please select your room", + LoginHelpText = "Please select your room from the list, then enter the code shown on the display.", + PasscodePageText = "Please enter the code shown on this room's display" + } + } + }, + Logging = _parent.Config.ApplicationConfig?.Logging ?? false, + PartnerMetadata = _parent.Config.ApplicationConfig?.PartnerMetadata ?? new List() + }; + + return config; + } + catch (Exception ex) + { + this.LogError(ex, "Error getting application configuration"); + + return null; + } + } + + /// + /// Attempts to retrieve secrets previously stored in memory + /// + private void RetrieveSecret() + { + try + { + // Add secret provider + _secretProvider = new WebSocketServerSecretProvider(SecretProviderKey); + + // Check for existing secrets + var secret = _secretProvider.GetSecret(SecretProviderKey); + + if (secret != null) + { + Debug.LogMessage(LogEventLevel.Information, "Secret successfully retrieved", this); + + Debug.LogMessage(LogEventLevel.Debug, "Secret: {0}", this, secret.Value.ToString()); + + + // populate the local secrets object + _secret = JsonConvert.DeserializeObject(secret.Value.ToString()); + + if (_secret != null && _secret.Tokens != null) + { + // populate the _uiClient collection + foreach (var token in _secret.Tokens) + { + if (token.Value == null) + { + Debug.LogMessage(LogEventLevel.Warning, "Token value is null", this); + continue; + } + + Debug.LogMessage(LogEventLevel.Information, "Adding token: {0} for room: {1}", this, token.Key, token.Value.RoomKey); + + if (UiClients == null) + { + Debug.LogMessage(LogEventLevel.Warning, "UiClients is null", this); + UiClients = new Dictionary(); + } + + UiClients.Add(token.Key, new UiClientContext(token.Value)); + } + } + + if (UiClients.Count > 0) + { + Debug.LogMessage(LogEventLevel.Information, "Restored {uiClientCount} UiClients from secrets data", this, UiClients.Count); + + foreach (var client in UiClients) + { + var key = client.Key; + var path = _wsPath + key; + var roomKey = client.Value.Token.RoomKey; + + _server.AddWebSocketService(path, () => + { + var c = new UiClient(); + Debug.LogMessage(LogEventLevel.Debug, "Constructing UiClient with id: {key}", this, key); + + c.Controller = _parent; + c.RoomKey = roomKey; + UiClients[key].SetClient(c); + return c; + }); + + + //_server.WebSocketServices.AddService(path, (c) => + //{ + // Debug.Console(2, this, "Constructing UiClient with id: {0}", key); + // c.Controller = _parent; + // c.RoomKey = roomKey; + // UiClients[key].SetClient(c); + //}); + } + } + } + else + { + Debug.LogMessage(LogEventLevel.Warning, "No secret found"); + } + + Debug.LogMessage(LogEventLevel.Debug, "{uiClientCount} UiClients restored from secrets data", this, UiClients.Count); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Exception retrieving secret", this); + } + } + + /// + /// Stores secrets to memory to persist through reboot + /// + public void UpdateSecret() + { + try + { + if (_secret == null) + { + Debug.LogMessage(LogEventLevel.Error, "Secret is null", this); + + _secret = new ServerTokenSecrets(string.Empty); + } + + _secret.Tokens.Clear(); + + foreach (var uiClientContext in UiClients) + { + _secret.Tokens.Add(uiClientContext.Key, uiClientContext.Value.Token); + } + + var serializedSecret = JsonConvert.SerializeObject(_secret); + + _secretProvider.SetSecret(SecretProviderKey, serializedSecret); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Exception updating secret", this); + } + } + + /// + /// Generates a new token based on validating a room key and grant code passed in. If valid, returns a token and adds a service to the server for that token's path + /// + /// + private void GenerateClientTokenFromConsole(string s) + { + if (s == "?" || string.IsNullOrEmpty(s)) + { + CrestronConsole.ConsoleCommandResponse(@"[RoomKey] [GrantCode] Validates the room key against the grant code and returns a token for use in a UI client"); + return; + } + + var values = s.Split(' '); + + if(values.Length < 2) + { + CrestronConsole.ConsoleCommandResponse("Invalid number of arguments. Please provide a room key and a grant code"); + return; + } + + + var roomKey = values[0]; + var grantCode = values[1]; + + var bridge = _parent.GetRoomBridge(roomKey); + + if (bridge == null) + { + CrestronConsole.ConsoleCommandResponse(string.Format("Unable to find room with key: {0}", roomKey)); + return; + } + + var (token, path) = ValidateGrantCode(grantCode, bridge); + + if (token == null) + { + CrestronConsole.ConsoleCommandResponse("Grant Code is not valid"); + return; + } + + CrestronConsole.ConsoleCommandResponse($"Added new WebSocket UiClient service at path: {path}"); + CrestronConsole.ConsoleCommandResponse($"Token: {token}"); + } + + public (string, string) ValidateGrantCode(string grantCode, string roomKey) + { + var bridge = _parent.GetRoomBridge(roomKey); + + if (bridge == null) + { + this.LogWarning("Unable to find room with key: {roomKey}", roomKey); + return (null, null); + } + + return ValidateGrantCode(grantCode, bridge); + } + + public (string, string) ValidateGrantCode(string grantCode, MobileControlBridgeBase bridge) + { + // TODO: Authenticate grant code passed in + // For now, we just generate a random guid as the token and use it as the ClientId as well + var grantCodeIsValid = true; + + if (grantCodeIsValid) + { + if (_secret == null) + { + _secret = new ServerTokenSecrets(grantCode); + } + + return GenerateClientToken(bridge, ""); + } + else + { + return (null, null); + } + } + + public (string, string) GenerateClientToken(MobileControlBridgeBase bridge, string touchPanelKey = "") + { + var key = Guid.NewGuid().ToString(); + + var token = new JoinToken { Code = bridge.UserCode, RoomKey = bridge.RoomKey, Uuid = _parent.SystemUuid, TouchpanelKey = touchPanelKey }; + + UiClients.Add(key, new UiClientContext(token)); + + var path = _wsPath + key; + + _server.AddWebSocketService(path, () => + { + var c = new UiClient(); + Debug.LogMessage(LogEventLevel.Verbose, "Constructing UiClient with id: {0}", this, key); + c.Controller = _parent; + c.RoomKey = bridge.RoomKey; + UiClients[key].SetClient(c); + return c; + }); + + Debug.LogMessage(LogEventLevel.Information, "Added new WebSocket UiClient service at path: {path}", this, path); + Debug.LogMessage(LogEventLevel.Information, "Token: {@token}", this, token); + + Debug.LogMessage(LogEventLevel.Verbose, "{serviceCount} websocket services present", this, _server.WebSocketServices.Count); + + UpdateSecret(); + + return (key, path); + } + + /// + /// Removes all clients from the server + /// + private void RemoveAllTokens(string s) + { + if (s == "?" || string.IsNullOrEmpty(s)) + { + CrestronConsole.ConsoleCommandResponse(@"Removes all clients from the server. To execute add 'confirm' to command"); + return; + } + + if (s != "confirm") + { + CrestronConsole.ConsoleCommandResponse(@"To remove all clients, add 'confirm' to the command"); + return; + } + + foreach (var client in UiClients) + { + if (client.Value.Client != null && client.Value.Client.Context.WebSocket.IsAlive) + { + client.Value.Client.Context.WebSocket.Close(CloseStatusCode.Normal, "Server Shutting Down"); + } + + var path = _wsPath + client.Key; + if (_server.RemoveWebSocketService(path)) + { + CrestronConsole.ConsoleCommandResponse(string.Format("Client removed with token: {0}", client.Key)); + } + else + { + CrestronConsole.ConsoleCommandResponse(string.Format("Unable to remove client with token : {0}", client.Key)); + } + } + + UiClients.Clear(); + + UpdateSecret(); + } + + /// + /// Removes a client with the specified token value + /// + /// + private void RemoveToken(string s) + { + if (s == "?" || string.IsNullOrEmpty(s)) + { + CrestronConsole.ConsoleCommandResponse(@"[token] Removes the client with the specified token value"); + return; + } + + var key = s; + + if (UiClients.ContainsKey(key)) + { + var uiClientContext = UiClients[key]; + + if (uiClientContext.Client != null && uiClientContext.Client.Context.WebSocket.IsAlive) + { + uiClientContext.Client.Context.WebSocket.Close(CloseStatusCode.Normal, "Token removed from server"); + } + + var path = _wsPath + key; + if (_server.RemoveWebSocketService(path)) + { + UiClients.Remove(key); + + UpdateSecret(); + + CrestronConsole.ConsoleCommandResponse(string.Format("Client removed with token: {0}", key)); + } + else + { + CrestronConsole.ConsoleCommandResponse(string.Format("Unable to remove client with token : {0}", key)); + } + } + else + { + CrestronConsole.ConsoleCommandResponse(string.Format("Unable to find client with token: {0}", key)); + } + } + + /// + /// Prints out info about current client IDs + /// + private void PrintClientInfo() + { + CrestronConsole.ConsoleCommandResponse("Mobile Control UI Client Info:\r"); + + CrestronConsole.ConsoleCommandResponse(string.Format("{0} clients found:\r", UiClients.Count)); + + foreach (var client in UiClients) + { + CrestronConsole.ConsoleCommandResponse(string.Format("RoomKey: {0} Token: {1}\r", client.Value.Token.RoomKey, client.Key)); + } + } + + private void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType == eProgramStatusEventType.Stopping) + { + foreach (var client in UiClients.Values) + { + if (client.Client != null && client.Client.Context.WebSocket.IsAlive) + { + client.Client.Context.WebSocket.Close(CloseStatusCode.Normal, "Server Shutting Down"); + } + } + + StopServer(); + } + } + + /// + /// Handler for GET requests to server + /// + /// + /// + private void Server_OnGet(object sender, HttpRequestEventArgs e) + { + try + { + var req = e.Request; + var res = e.Response; + res.ContentEncoding = Encoding.UTF8; + + res.AddHeader("Access-Control-Allow-Origin", "*"); + + var path = req.RawUrl; + + this.LogVerbose("GET Request received at path: {path}", path); + + // Call for user app to join the room with a token + if (path.StartsWith("/mc/api/ui/joinroom")) + { + HandleJoinRequest(req, res); + } + // Call to get the server version + else if (path.StartsWith("/mc/api/version")) + { + HandleVersionRequest(res); + } + else if (path.StartsWith("/mc/app/logo")) + { + HandleImageRequest(req, res); + } + // Call to serve the user app + else if (path.StartsWith(_userAppBaseHref)) + { + HandleUserAppRequest(req, res, path); + } + else + { + // All other paths + res.StatusCode = 404; + res.Close(); + } + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Caught an exception in the OnGet handler", this); + } + } + + private async void Server_OnPost(object sender, HttpRequestEventArgs e) + { + try + { + var req = e.Request; + var res = e.Response; + + res.AddHeader("Access-Control-Allow-Origin", "*"); + + var path = req.RawUrl; + var ip = req.RemoteEndPoint.Address.ToString(); + + this.LogVerbose("POST Request received at path: {path} from host {host}", path, ip); + + var body = new StreamReader(req.InputStream).ReadToEnd(); + + if (path.StartsWith("/mc/api/log")) + { + res.StatusCode = 200; + res.Close(); + + var logRequest = new HttpRequestMessage(HttpMethod.Post, $"http://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs") + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + logRequest.Headers.Add("x-pepperdash-host", ip); + + await LogClient.SendAsync(logRequest); + + this.LogVerbose("Log data sent to {host}:{port}", _parent.Config.DirectServer.Logging.Host, _parent.Config.DirectServer.Logging.Port); + } + else + { + res.StatusCode = 404; + res.Close(); + } + } + catch (Exception ex) + { + this.LogException(ex, "Caught an exception in the OnPost handler"); + } + } + + private void Server_OnOptions(object sender, HttpRequestEventArgs e) + { + try + { + var res = e.Response; + + res.AddHeader("Access-Control-Allow-Origin", "*"); + res.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.AddHeader("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With, remember-me"); + + res.StatusCode = 200; + res.Close(); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Caught an exception in the OnPost handler", this); + } + } + + /// + /// Handle the request to join the room with a token + /// + /// + /// + private void HandleJoinRequest(HttpListenerRequest req, HttpListenerResponse res) + { + var qp = req.QueryString; + var token = qp["token"]; + + this.LogVerbose("Join Room Request with token: {token}", token); + + + if (UiClients.TryGetValue(token, out UiClientContext clientContext)) + { + var bridge = _parent.GetRoomBridge(clientContext.Token.RoomKey); + + if (bridge != null) + { + res.StatusCode = 200; + res.ContentType = "application/json"; + + // Construct the response object + JoinResponse jRes = new JoinResponse + { + ClientId = token, + RoomKey = bridge.RoomKey, + SystemUuid = _parent.SystemUuid, + RoomUuid = _parent.SystemUuid, + Config = _parent.GetConfigWithPluginVersion(), + CodeExpires = new DateTime().AddYears(1), + UserCode = bridge.UserCode, + UserAppUrl = string.Format("http://{0}:{1}/mc/app", + CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), + Port), + EnableDebug = false + }; + + // Serialize to JSON and convert to Byte[] + var json = JsonConvert.SerializeObject(jRes); + var body = Encoding.UTF8.GetBytes(json); + res.ContentLength64 = body.LongLength; + + // Send the response + res.Close(body, true); + } + else + { + var message = string.Format("Unable to find bridge with key: {0}", clientContext.Token.RoomKey); + res.StatusCode = 404; + res.ContentType = "application/json"; + this.LogVerbose("{message}", message); + var body = Encoding.UTF8.GetBytes(message); + res.ContentLength64 = body.LongLength; + res.Close(body, true); + + } + } + else + { + var message = "Token invalid or has expired"; + res.StatusCode = 401; + res.ContentType = "application/json"; + this.LogVerbose("{message}", message); + var body = Encoding.UTF8.GetBytes(message); + res.ContentLength64 = body.LongLength; + res.Close(body, true); + } + } + + /// + /// Handles a server version request + /// + /// + private void HandleVersionRequest(HttpListenerResponse res) + { + res.StatusCode = 200; + res.ContentType = "application/json"; + var version = new Version() { ServerVersion = _parent.GetConfigWithPluginVersion().RuntimeInfo.PluginVersion }; + var message = JsonConvert.SerializeObject(version); + this.LogVerbose("{message}", message); + + var body = Encoding.UTF8.GetBytes(message); + res.ContentLength64 = body.LongLength; + res.Close(body, true); + } + + /// + /// Handler to return images requested by the user app + /// + /// + /// + private void HandleImageRequest(HttpListenerRequest req, HttpListenerResponse res) + { + var path = req.RawUrl; + + Debug.LogMessage(LogEventLevel.Verbose, "Requesting Image: {0}", this, path); + + var imageBasePath = Global.DirectorySeparator + "html" + Global.DirectorySeparator + "logo" + Global.DirectorySeparator; + + var image = path.Split('/').Last(); + + var filePath = imageBasePath + image; + + Debug.LogMessage(LogEventLevel.Verbose, "Retrieving Image: {0}", this, filePath); + + if (File.Exists(filePath)) + { + if (filePath.EndsWith(".png")) + { + res.ContentType = "image/png"; + } + else if (filePath.EndsWith(".jpg")) + { + res.ContentType = "image/jpeg"; + } + else if (filePath.EndsWith(".gif")) + { + res.ContentType = "image/gif"; + } + else if (filePath.EndsWith(".svg")) + { + res.ContentType = "image/svg+xml"; + } + byte[] contents = File.ReadAllBytes(filePath); + res.ContentLength64 = contents.LongLength; + res.Close(contents, true); + } + else + { + res.StatusCode = (int)HttpStatusCode.NotFound; + res.Close(); + } + } + + /// + /// Handles requests to serve files for the Angular single page app + /// + /// + /// + /// + private void HandleUserAppRequest(HttpListenerRequest req, HttpListenerResponse res, string path) + { + this.LogVerbose("Requesting User app file"); + + string filePath = path.Split('?')[0]; + + // remove the token from the path if found + //string filePath = path.Replace(string.Format("?token={0}", token), ""); + + // if there's no file suffix strip any extra path data after the base href + if (filePath != _userAppBaseHref && !filePath.Contains(".") && (!filePath.EndsWith(_userAppBaseHref) || !filePath.EndsWith(_userAppBaseHref += "/"))) + { + var suffix = filePath.Substring(_userAppBaseHref.Length, filePath.Length - _userAppBaseHref.Length); + if (suffix != "/") + { + //Debug.Console(2, this, "Suffix: {0}", suffix); + filePath = filePath.Replace(suffix, ""); + } + } + + // swap the base href prefix for the file path prefix + filePath = filePath.Replace(_userAppBaseHref, _appPath); + + this.LogVerbose("filepath: {filePath}", filePath); + + + // append index.html if no specific file is specified + if (!filePath.Contains(".")) + { + if (filePath.EndsWith("/")) + { + filePath += "index.html"; + } + else + { + filePath += "/index.html"; + } + } + + // Set ContentType based on file type + if (filePath.EndsWith(".html")) + { + this.LogVerbose("Client requesting User App"); + + res.ContentType = "text/html"; + } + else + { + if (path.EndsWith(".js")) + { + res.ContentType = "application/javascript"; + } + else if (path.EndsWith(".css")) + { + res.ContentType = "text/css"; + } + else if (path.EndsWith(".json")) + { + res.ContentType = "application/json"; + } + } + + this.LogVerbose("Attempting to serve file: {filePath}", filePath); + + var remoteIp = req.RemoteEndPoint.Address; + + // Check if the request is coming from the CS LAN and if so, send the CS config instead of the LAN config + if (csSubnetMask != null && csIpAddress != null && remoteIp.IsInSameSubnet(csIpAddress, csSubnetMask) && filePath.Contains(appConfigFileName)) + { + filePath = filePath.Replace(appConfigFileName, appConfigCsFileName); + } + + byte[] contents; + if (File.Exists(filePath)) + { + this.LogVerbose("File found: {filePath}", filePath); + contents = File.ReadAllBytes(filePath); + } + else + { + this.LogVerbose("File not found: {filePath}", filePath); + res.StatusCode = (int)HttpStatusCode.NotFound; + res.Close(); + return; + } + + res.ContentLength64 = contents.LongLength; + res.Close(contents, true); + } + + public void StopServer() + { + this.LogVerbose("Stopping WebSocket Server"); + _server.Stop(CloseStatusCode.Normal, "Server Shutting Down"); + } + + /// + /// Sends a message to all connectd clients + /// + /// + public void SendMessageToAllClients(string message) + { + foreach (var clientContext in UiClients.Values) + { + if (clientContext.Client != null && clientContext.Client.Context.WebSocket.IsAlive) + { + clientContext.Client.Context.WebSocket.Send(message); + } + } + } + + /// + /// Sends a message to a specific client + /// + /// + /// + public void SendMessageToClient(object clientId, string message) + { + if (clientId == null) + { + return; + } + + if (UiClients.TryGetValue((string)clientId, out UiClientContext clientContext)) + { + if (clientContext.Client != null) + { + var socket = clientContext.Client.Context.WebSocket; + + if (socket.IsAlive) + { + socket.Send(message); + } + } + } + else + { + this.LogWarning("Unable to find client with ID: {clientId}", clientId); + } + } + } + + /// + /// Class to describe the server version info + /// + public class Version + { + [JsonProperty("serverVersion")] + public string ServerVersion { get; set; } + + [JsonProperty("serverIsRunningOnProcessorHardware")] + public bool ServerIsRunningOnProcessorHardware { get; private set; } + + public Version() + { + ServerIsRunningOnProcessorHardware = true; + } + } + + /// + /// Represents an instance of a UiClient and the associated Token + /// + public class UiClientContext + { + public UiClient Client { get; private set; } + public JoinToken Token { get; private set; } + + public UiClientContext(JoinToken token) + { + Token = token; + } + + public void SetClient(UiClient client) + { + Client = client; + } + + } + + /// + /// Represents the data structure for the grant code and UiClient tokens to be stored in the secrets manager + /// + public class ServerTokenSecrets + { + public string GrantCode { get; set; } + + public Dictionary Tokens { get; set; } + + public ServerTokenSecrets(string grantCode) + { + GrantCode = grantCode; + Tokens = new Dictionary(); + } + } + + /// + /// Represents a join token with the associated properties + /// + public class JoinToken + { + public string Code { get; set; } + + public string RoomKey { get; set; } + + public string Uuid { get; set; } + + public string TouchpanelKey { get; set; } = ""; + + public string Token { get; set; } = null; + } + + /// + /// Represents the structure of the join response + /// + public class JoinResponse + { + [JsonProperty("clientId")] + public string ClientId { get; set; } + + [JsonProperty("roomKey")] + public string RoomKey { get; set; } + + [JsonProperty("systemUUid")] + public string SystemUuid { get; set; } + + [JsonProperty("roomUUid")] + public string RoomUuid { get; set; } + + [JsonProperty("config")] + public object Config { get; set; } + + [JsonProperty("codeExpires")] + public DateTime CodeExpires { get; set; } + + [JsonProperty("userCode")] + public string UserCode { get; set; } + + [JsonProperty("userAppUrl")] + public string UserAppUrl { get; set; } + + [JsonProperty("enableDebug")] + public bool EnableDebug { get; set; } + } +} diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/UiClient.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/UiClient.cs new file mode 100644 index 00000000..eb1cf7a1 --- /dev/null +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/UiClient.cs @@ -0,0 +1,145 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using PepperDash.Core; +using PepperDash.Essentials.AppServer.Messengers; +using PepperDash.Essentials.RoomBridges; +using Serilog.Events; +using System; +using System.Text.RegularExpressions; +using WebSocketSharp; +using WebSocketSharp.Server; +using ErrorEventArgs = WebSocketSharp.ErrorEventArgs; + + +namespace PepperDash.Essentials.WebSocketServer +{ + /// + /// Represents the behaviour to associate with a UiClient for WebSocket communication + /// + public class UiClient : WebSocketBehavior + { + public MobileControlSystemController Controller { get; set; } + + public string RoomKey { get; set; } + + private string _clientId; + + private DateTime _connectionTime; + + public TimeSpan ConnectedDuration + { + get + { + if (Context.WebSocket.IsAlive) + { + return DateTime.Now - _connectionTime; + } + else + { + return new TimeSpan(0); + } + } + } + + public UiClient() + { + + } + + protected override void OnOpen() + { + base.OnOpen(); + + var url = Context.WebSocket.Url; + Debug.LogMessage(LogEventLevel.Verbose, "New WebSocket Connection from: {0}", null, url); + + var match = Regex.Match(url.AbsoluteUri, "(?:ws|wss):\\/\\/.*(?:\\/mc\\/api\\/ui\\/join\\/)(.*)"); + + if (!match.Success) + { + _connectionTime = DateTime.Now; + return; + } + + var clientId = match.Groups[1].Value; + _clientId = clientId; + + if (Controller == null) + { + Debug.LogMessage(LogEventLevel.Verbose, "WebSocket UiClient Controller is null"); + _connectionTime = DateTime.Now; + } + + var clientJoinedMessage = new MobileControlMessage + { + Type = "/system/clientJoined", + Content = JToken.FromObject(new + { + clientId, + roomKey = RoomKey, + }) + }; + + Controller.HandleClientMessage(JsonConvert.SerializeObject(clientJoinedMessage)); + + var bridge = Controller.GetRoomBridge(RoomKey); + + if (bridge == null) return; + + SendUserCodeToClient(bridge, clientId); + + bridge.UserCodeChanged -= Bridge_UserCodeChanged; + bridge.UserCodeChanged += Bridge_UserCodeChanged; + + // TODO: Future: Check token to see if there's already an open session using that token and reject/close the session + } + + private void Bridge_UserCodeChanged(object sender, EventArgs e) + { + SendUserCodeToClient((MobileControlEssentialsRoomBridge)sender, _clientId); + } + + private void SendUserCodeToClient(MobileControlBridgeBase bridge, string clientId) + { + var content = new + { + userCode = bridge.UserCode, + qrUrl = bridge.QrCodeUrl, + }; + + var message = new MobileControlMessage + { + Type = "/system/userCodeChanged", + ClientId = clientId, + Content = JToken.FromObject(content) + }; + + Controller.SendMessageObjectToDirectClient(message); + } + + protected override void OnMessage(MessageEventArgs e) + { + base.OnMessage(e); + + if (e.IsText && e.Data.Length > 0 && Controller != null) + { + // Forward the message to the controller to be put on the receive queue + Controller.HandleClientMessage(e.Data); + } + } + + protected override void OnClose(CloseEventArgs e) + { + base.OnClose(e); + + Debug.LogMessage(LogEventLevel.Verbose, "WebSocket UiClient Closing: {0} reason: {1}", null, e.Code, e.Reason); + } + + protected override void OnError(ErrorEventArgs e) + { + base.OnError(e); + + Debug.LogMessage(LogEventLevel.Verbose, "WebSocket UiClient Error: {exception} message: {message}", e.Exception, e.Message); + } + } +}