mirror of
https://github.com/PepperDash/Essentials.git
synced 2026-01-25 02:14:56 +00:00
1294 lines
48 KiB
C#
1294 lines
48 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Text;
|
|
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 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";
|
|
|
|
/// <summary>
|
|
/// Where the key is the join token and the value is the room key
|
|
/// </summary>
|
|
//private Dictionary<string, JoinToken> _joinTokens;
|
|
|
|
private HttpServer _server;
|
|
|
|
public HttpServer Server => _server;
|
|
|
|
public Dictionary<string, UiClientContext> 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;
|
|
|
|
/// <summary>
|
|
/// The path for the WebSocket messaging
|
|
/// </summary>
|
|
private readonly string _wsPath = "/mc/api/ui/join/";
|
|
|
|
public string WsPath => _wsPath;
|
|
|
|
/// <summary>
|
|
/// The path to the location of the files for the user app (single page Angular app)
|
|
/// </summary>
|
|
private readonly string _appPath = string.Format("{0}mcUserApp", Global.FilePathPrefix);
|
|
|
|
/// <summary>
|
|
/// The base HREF that the user app uses
|
|
/// </summary>
|
|
private string _userAppBaseHref = "/mc/app";
|
|
|
|
/// <summary>
|
|
/// The port the server will run on
|
|
/// </summary>
|
|
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<string, UiClientContext>();
|
|
|
|
//_joinTokens = new Dictionary<string, JoinToken>();
|
|
|
|
if (Global.Platform == eDevicePlatform.Appliance)
|
|
{
|
|
AddConsoleCommands();
|
|
}
|
|
|
|
AddPreActivationAction(() => AddWebApiPaths());
|
|
}
|
|
|
|
private void AddWebApiPaths()
|
|
{
|
|
var apiServer = DeviceManager.AllDevices.OfType<EssentialsWebApi>().FirstOrDefault();
|
|
|
|
if (apiServer == null)
|
|
{
|
|
this.LogInformation("No API Server available");
|
|
return;
|
|
}
|
|
|
|
var routes = new List<HttpCwsRoute>
|
|
{
|
|
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<IMobileControlTouchpanelController>().Where(tp => tp.UseDirectServer);
|
|
|
|
|
|
var touchpanelsToAdd = new List<IMobileControlTouchpanelController>();
|
|
|
|
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);
|
|
|
|
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;
|
|
}
|
|
|
|
string ip = processorIp;
|
|
if (touchpanel.Touchpanel is IMobileControlCrestronTouchpanelController crestronTouchpanel)
|
|
{
|
|
ip = crestronTouchpanel.ConnectedIps.Any(ipInfo =>
|
|
{
|
|
if (System.Net.IPAddress.TryParse(ipInfo.DeviceIpAddress, out var parsedIp))
|
|
{
|
|
return csIpAddress.IsInSameSubnet(parsedIp, csSubnetMask);
|
|
}
|
|
this.LogWarning("Invalid IP address: {deviceIpAddress}", ipInfo.DeviceIpAddress);
|
|
return false;
|
|
}) ? csIpAddress.ToString() : processorIp;
|
|
}
|
|
|
|
var appUrl = $"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}";
|
|
|
|
this.LogVerbose("Sending URL {appUrl}", appUrl);
|
|
|
|
touchpanel.Messenger.UpdateAppUrl($"http://{ip}:{_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<string, McMode>
|
|
{
|
|
{
|
|
"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<MobileControlPartnerMetadata>()
|
|
};
|
|
|
|
return config;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
this.LogError(ex, "Error getting application configuration");
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to retrieve secrets previously stored in memory
|
|
/// </summary>
|
|
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<ServerTokenSecrets>(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<string, UiClientContext>();
|
|
}
|
|
|
|
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<UiClient>(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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores secrets to memory to persist through reboot
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
/// <param name="s"></param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes all clients from the server
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a client with the specified token value
|
|
/// </summary>
|
|
/// <param name="s"></param>
|
|
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));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prints out info about current client IDs
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handler for GET requests to server
|
|
/// </summary>
|
|
/// <param name="sender"></param>
|
|
/// <param name="e"></param>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle the request to join the room with a token
|
|
/// </summary>
|
|
/// <param name="req"></param>
|
|
/// <param name="res"></param>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a server version request
|
|
/// </summary>
|
|
/// <param name="res"></param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handler to return images requested by the user app
|
|
/// </summary>
|
|
/// <param name="req"></param>
|
|
/// <param name="res"></param>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles requests to serve files for the Angular single page app
|
|
/// </summary>
|
|
/// <param name="req"></param>
|
|
/// <param name="res"></param>
|
|
/// <param name="path"></param>
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a message to all connectd clients
|
|
/// </summary>
|
|
/// <param name="message"></param>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a message to a specific client
|
|
/// </summary>
|
|
/// <param name="clientId"></param>
|
|
/// <param name="message"></param>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Class to describe the server version info
|
|
/// </summary>
|
|
public class Version
|
|
{
|
|
[JsonProperty("serverVersion")]
|
|
public string ServerVersion { get; set; }
|
|
|
|
[JsonProperty("serverIsRunningOnProcessorHardware")]
|
|
public bool ServerIsRunningOnProcessorHardware { get; private set; }
|
|
|
|
public Version()
|
|
{
|
|
ServerIsRunningOnProcessorHardware = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents an instance of a UiClient and the associated Token
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents the data structure for the grant code and UiClient tokens to be stored in the secrets manager
|
|
/// </summary>
|
|
public class ServerTokenSecrets
|
|
{
|
|
public string GrantCode { get; set; }
|
|
|
|
public Dictionary<string, JoinToken> Tokens { get; set; }
|
|
|
|
public ServerTokenSecrets(string grantCode)
|
|
{
|
|
GrantCode = grantCode;
|
|
Tokens = new Dictionary<string, JoinToken>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a join token with the associated properties
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents the structure of the join response
|
|
/// </summary>
|
|
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; }
|
|
}
|
|
}
|