diff --git a/Application/API/Master/IMasterApi.cs b/Application/API/Master/IMasterApi.cs index afac522c5..f6a46bf21 100644 --- a/Application/API/Master/IMasterApi.cs +++ b/Application/API/Master/IMasterApi.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Threading.Tasks; +using IW4MAdmin.Application.Helpers; using Newtonsoft.Json; using RestEase; using SharedLibraryCore.Helpers; @@ -35,6 +37,13 @@ namespace IW4MAdmin.Application.API.Master public string Message { get; set; } } + public class PluginSubscriptionContent + { + public string Content { get; set; } + public PluginType Type { get; set; } + } + + /// /// Defines the capabilities of the master API /// @@ -63,5 +72,8 @@ namespace IW4MAdmin.Application.API.Master [Get("localization/{languageTag}")] Task GetLocalization([Path("languageTag")] string languageTag); + + [Get("plugin_subscriptions")] + Task> GetPluginSubscription([Query("instance_id")] Guid instanceId, [Query("subscription_id")] string subscription_id); } } diff --git a/Application/Main.cs b/Application/Main.cs index 9cdce81e5..86732d3cd 100644 --- a/Application/Main.cs +++ b/Application/Main.cs @@ -135,7 +135,7 @@ namespace IW4MAdmin.Application await ApplicationTask; } - catch (Exception e) + catch (Exception e) { string failMessage = translationLookup == null ? "Failed to initalize IW4MAdmin" : translationLookup["MANAGER_INIT_FAIL"]; Console.WriteLine($"{failMessage}: {e.GetExceptionInfo()}"); @@ -226,12 +226,17 @@ namespace IW4MAdmin.Application /// private static IServiceCollection ConfigureServices(string[] args) { + var appConfigHandler = new BaseConfigurationHandler("IW4MAdminSettings"); + var appConfig = appConfigHandler.Configuration(); var defaultLogger = new Logger("IW4MAdmin-Manager"); - var pluginImporter = new PluginImporter(defaultLogger); + + var masterUri = Utilities.IsDevelopment ? new Uri("http://127.0.0.1:8080") : appConfig?.MasterUrl ?? new ApplicationConfiguration().MasterUrl; + var apiClient = RestClient.For(masterUri); + var pluginImporter = new PluginImporter(defaultLogger, appConfig, apiClient, new RemoteAssemblyHandler(defaultLogger, appConfig)); var serviceCollection = new ServiceCollection(); serviceCollection.AddSingleton(_serviceProvider => serviceCollection) - .AddSingleton(new BaseConfigurationHandler("IW4MAdminSettings") as IConfigurationHandler) + .AddSingleton(appConfigHandler as IConfigurationHandler) .AddSingleton(new BaseConfigurationHandler("CommandConfiguration") as IConfigurationHandler) .AddSingleton(_serviceProvider => _serviceProvider.GetRequiredService>().Configuration() ?? new ApplicationConfiguration()) .AddSingleton(_serviceProvider => _serviceProvider.GetRequiredService>().Configuration() ?? new CommandConfiguration()) @@ -255,19 +260,17 @@ namespace IW4MAdmin.Application .AddSingleton, UpdatedAliasResourceQueryHelper>() .AddSingleton, ChatResourceQueryHelper>() .AddTransient() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(apiClient) .AddSingleton(_serviceProvider => { var config = _serviceProvider.GetRequiredService>().Configuration(); return Localization.Configure.Initialize(useLocalTranslation: config?.UseLocalTranslations ?? false, apiInstance: _serviceProvider.GetRequiredService(), customLocale: config?.EnableCustomLocale ?? false ? (config.CustomLocale ?? "en-US") : "en-US"); - }) - .AddSingleton() - .AddSingleton(_serviceProvider => RestClient - .For(Utilities.IsDevelopment ? new Uri("http://127.0.0.1:8080") : _serviceProvider - .GetRequiredService>().Configuration()?.MasterUrl ?? - new ApplicationConfiguration().MasterUrl)) - .AddSingleton(); + }); if (args.Contains("serialevents")) { diff --git a/Application/Misc/PluginImporter.cs b/Application/Misc/PluginImporter.cs index 828333976..29723eba4 100644 --- a/Application/Misc/PluginImporter.cs +++ b/Application/Misc/PluginImporter.cs @@ -6,6 +6,8 @@ using SharedLibraryCore.Interfaces; using System.Linq; using SharedLibraryCore; using IW4MAdmin.Application.Misc; +using IW4MAdmin.Application.API.Master; +using SharedLibraryCore.Configuration; namespace IW4MAdmin.Application.Helpers { @@ -15,12 +17,19 @@ namespace IW4MAdmin.Application.Helpers /// public class PluginImporter : IPluginImporter { + private IEnumerable _pluginSubscription; private static readonly string PLUGIN_DIR = "Plugins"; private readonly ILogger _logger; + private readonly IRemoteAssemblyHandler _remoteAssemblyHandler; + private readonly IMasterApi _masterApi; + private readonly ApplicationConfiguration _appConfig; - public PluginImporter(ILogger logger) + public PluginImporter(ILogger logger, ApplicationConfiguration appConfig, IMasterApi masterApi, IRemoteAssemblyHandler remoteAssemblyHandler) { _logger = logger; + _masterApi = masterApi; + _remoteAssemblyHandler = remoteAssemblyHandler; + _appConfig = appConfig; } /// @@ -33,11 +42,11 @@ namespace IW4MAdmin.Application.Helpers if (Directory.Exists(pluginDir)) { - string[] scriptPluginFiles = Directory.GetFiles(pluginDir, "*.js"); + var scriptPluginFiles = Directory.GetFiles(pluginDir, "*.js").AsEnumerable().Union(GetRemoteScripts()); - _logger.WriteInfo($"Discovered {scriptPluginFiles.Length} potential script plugins"); + _logger.WriteInfo($"Discovered {scriptPluginFiles.Count()} potential script plugins"); - if (scriptPluginFiles.Length > 0) + if (scriptPluginFiles.Count() > 0) { foreach (string fileName in scriptPluginFiles) { @@ -66,7 +75,10 @@ namespace IW4MAdmin.Application.Helpers if (dllFileNames.Length > 0) { - var assemblies = dllFileNames.Select(_name => Assembly.LoadFrom(_name)); + // we only want to load the most recent assembly in case of duplicates + var assemblies = dllFileNames.Select(_name => Assembly.LoadFrom(_name)) + .Union(GetRemoteAssemblies()) + .GroupBy(_assembly => _assembly.FullName).Select(_assembly => _assembly.OrderByDescending(_assembly => _assembly.GetName().Version).First()); pluginTypes = assemblies .SelectMany(_asm => _asm.GetTypes()) @@ -84,5 +96,47 @@ namespace IW4MAdmin.Application.Helpers return (pluginTypes, commandTypes); } + + private IEnumerable GetRemoteAssemblies() + { + try + { + if (_pluginSubscription == null) + _pluginSubscription = _masterApi.GetPluginSubscription(Guid.Parse(_appConfig.Id), _appConfig.SubscriptionId).Result; + + return _remoteAssemblyHandler.DecryptAssemblies(_pluginSubscription.Where(sub => sub.Type == PluginType.Binary).Select(sub => sub.Content).ToArray()); + } + + catch (Exception ex) + { + _logger.WriteWarning("Could not load remote assemblies"); + _logger.WriteDebug(ex.GetExceptionInfo()); + return Enumerable.Empty(); + } + } + + private IEnumerable GetRemoteScripts() + { + try + { + if (_pluginSubscription == null) + _pluginSubscription = _masterApi.GetPluginSubscription(Guid.Parse(_appConfig.Id), _appConfig.SubscriptionId).Result; + + return _remoteAssemblyHandler.DecryptScripts(_pluginSubscription.Where(sub => sub.Type == PluginType.Script).Select(sub => sub.Content).ToArray()); + } + + catch (Exception ex) + { + _logger.WriteWarning("Could not load remote assemblies"); + _logger.WriteDebug(ex.GetExceptionInfo()); + return Enumerable.Empty(); + } + } + } + + public enum PluginType + { + Binary, + Script } } diff --git a/Application/Misc/RemoteAssemblyHandler.cs b/Application/Misc/RemoteAssemblyHandler.cs new file mode 100644 index 000000000..1733495c1 --- /dev/null +++ b/Application/Misc/RemoteAssemblyHandler.cs @@ -0,0 +1,76 @@ +using SharedLibraryCore; +using SharedLibraryCore.Configuration; +using SharedLibraryCore.Interfaces; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; + +namespace IW4MAdmin.Application.Misc +{ + public class RemoteAssemblyHandler : IRemoteAssemblyHandler + { + private const int keyLength = 32; + private const int tagLength = 16; + private const int nonceLength = 12; + private const int iterationCount = 10000; + + private readonly ApplicationConfiguration _appconfig; + private readonly ILogger _logger; + + public RemoteAssemblyHandler(ILogger logger, ApplicationConfiguration appconfig) + { + _appconfig = appconfig; + _logger = logger; + } + + public IEnumerable DecryptAssemblies(string[] encryptedAssemblies) + { + return DecryptContent(encryptedAssemblies) + .Select(decryptedAssembly => Assembly.Load(decryptedAssembly)); + } + + public IEnumerable DecryptScripts(string[] encryptedScripts) + { + return DecryptContent(encryptedScripts).Select(decryptedScript => Encoding.UTF8.GetString(decryptedScript)); + } + + private byte[][] DecryptContent(string[] content) + { + if (string.IsNullOrEmpty(_appconfig.Id) || string.IsNullOrWhiteSpace(_appconfig.SubscriptionId)) + { + _logger.WriteWarning($"{nameof(_appconfig.Id)} and {nameof(_appconfig.SubscriptionId)} must be provided to attempt loading remote assemblies/scripts"); + return new byte[0][]; + } + + var assemblies = content.Select(piece => + { + byte[] byteContent = Convert.FromBase64String(piece); + byte[] encryptedContent = byteContent.Take(byteContent.Length - (tagLength + nonceLength)).ToArray(); + byte[] tag = byteContent.Skip(byteContent.Length - (tagLength + nonceLength)).Take(tagLength).ToArray(); + byte[] nonce = byteContent.Skip(byteContent.Length - nonceLength).Take(nonceLength).ToArray(); + byte[] decryptedContent = new byte[encryptedContent.Length]; + + var keyGen = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(_appconfig.SubscriptionId), Encoding.UTF8.GetBytes(_appconfig.Id.ToString()), iterationCount, HashAlgorithmName.SHA512); + var encryption = new AesGcm(keyGen.GetBytes(keyLength)); + + try + { + encryption.Decrypt(nonce, encryptedContent, tag, decryptedContent); + } + + catch (CryptographicException ex) + { + _logger.WriteError("Could not obtain remote plugin assemblies"); + _logger.WriteDebug(ex.GetExceptionInfo()); + } + + return decryptedContent; + }); + + return assemblies.ToArray(); + } + } +} diff --git a/SharedLibraryCore/Configuration/ApplicationConfiguration.cs b/SharedLibraryCore/Configuration/ApplicationConfiguration.cs index c2cf6491e..4764366f7 100644 --- a/SharedLibraryCore/Configuration/ApplicationConfiguration.cs +++ b/SharedLibraryCore/Configuration/ApplicationConfiguration.cs @@ -99,6 +99,8 @@ namespace SharedLibraryCore.Configuration [ConfigurationIgnore] public string Id { get; set; } [ConfigurationIgnore] + public string SubscriptionId { get; set; } + [ConfigurationIgnore] public MapConfiguration[] Maps { get; set; } [ConfigurationIgnore] public QuickMessageConfiguration[] QuickMessages { get; set; } diff --git a/SharedLibraryCore/Interfaces/IRemoteAssemblyHandler.cs b/SharedLibraryCore/Interfaces/IRemoteAssemblyHandler.cs new file mode 100644 index 000000000..278d93d96 --- /dev/null +++ b/SharedLibraryCore/Interfaces/IRemoteAssemblyHandler.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Reflection; + +namespace SharedLibraryCore.Interfaces +{ + public interface IRemoteAssemblyHandler + { + IEnumerable DecryptAssemblies(string[] encryptedAssemblies); + IEnumerable DecryptScripts(string[] encryptedScripts); + } +}