#nullable enable using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Configuration; using BTCPayServer.Plugins.Dotnet; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; namespace BTCPayServer.Plugins { public static class PluginManager { public const string BTCPayPluginSuffix = ".btcpay"; private static readonly List _pluginAssemblies = new (); public static bool IsExceptionByPlugin(Exception exception, [MaybeNullWhen(false)] out string pluginName) { var fromAssembly = exception is TypeLoadException ? Regex.Match(exception.Message, "from assembly '(.*?),").Groups[1].Value : null; foreach (var assembly in _pluginAssemblies) { var assemblyName = assembly.GetName().Name; if (assemblyName is null) continue; // Comparison is case sensitive as it is theoretically possible to have a different plugin // with same name but different casing. if (exception.Source is not null && assemblyName.Equals(exception.Source, StringComparison.Ordinal)) { pluginName = assemblyName; return true; } if (exception.Message.Contains(assemblyName, StringComparison.Ordinal)) { pluginName = assemblyName; return true; } // For TypeLoadException, check if it might come from areferenced assembly if (!string.IsNullOrEmpty(fromAssembly) && assembly.GetReferencedAssemblies().Select(a => a.Name).Contains(fromAssembly)) { pluginName = assemblyName; return true; } } pluginName = null; return false; } record PreloadedPlugin(IBTCPayServerPlugin Instance, PluginLoader? Loader, Assembly Assembly); class PreloadedPlugins : IEnumerable { List _plugins = new(); readonly Dictionary _preloadedPluginsByIdentifier = new(StringComparer.OrdinalIgnoreCase); public bool Contains(string identifier) => _preloadedPluginsByIdentifier.ContainsKey(identifier); public void Add(PreloadedPlugin plugin) { if (!_preloadedPluginsByIdentifier.TryAdd(plugin.Instance.Identifier, plugin)) return; _plugins.Add(plugin); } public IEnumerator GetEnumerator() => _plugins.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => _plugins.GetEnumerator(); public void Clear() { _plugins.Clear(); _preloadedPluginsByIdentifier.Clear(); } public void TopologicalSort() { // We want to run all the system plugins first. // Then the rest topologically sorted. var ordered = new List(_plugins.Count); var topological = _plugins.TopologicalSort( p => p.Instance.Dependencies.Select(d => d.Identifier), p => p.Instance.Identifier, p=> p, Comparer.Create((a, b) => string.Compare(a.Instance.Identifier, b.Instance.Identifier, StringComparison.Ordinal))).ToList(); foreach (var p in topological.Where(t => t.Instance.SystemPlugin)) ordered.Add(p); foreach (var p in topological.Where(t => !t.Instance.SystemPlugin)) ordered.Add(p); _plugins = ordered; } public PreloadedPlugin? TryGet(string identifier) { _preloadedPluginsByIdentifier.TryGetValue(identifier, out var p); return p; } } public static IMvcBuilder AddPlugins(this IMvcBuilder mvcBuilder, IServiceCollection serviceCollection, IConfiguration config, ILoggerFactory loggerFactory, ServiceProvider bootstrapServiceProvider) { var logger = loggerFactory.CreateLogger(typeof(PluginManager)); var pluginsFolder = new DataDirectories().Configure(config).PluginDir; var preloadedPlugins = new PreloadedPlugins(); serviceCollection.Configure(options => { options.Limits.MaxRequestBodySize = int.MaxValue; // if don't set default value is: 30 MB }); logger.LogInformation($"Loading plugins from {pluginsFolder}"); Directory.CreateDirectory(pluginsFolder); ExecuteCommands(pluginsFolder); var disabledPluginIdentifiers = GetDisabledPluginIdentifiers(pluginsFolder); var systemAssembly = typeof(Program).Assembly; foreach (var plugin in GetPluginInstancesFromAssembly(systemAssembly, true)) { preloadedPlugins.Add(new PreloadedPlugin(plugin, null, systemAssembly)); plugin.SystemPlugin = true; } var pluginsToPreload = new List<(string PluginIdentifier, string PluginFilePath)>(); #if DEBUG // Load from DEBUG_PLUGINS, in an optional appsettings.dev.json var debugPlugins = config["DEBUG_PLUGINS"] ?? ""; foreach (var plugin in debugPlugins.Split(';', StringSplitOptions.RemoveEmptyEntries)) { // Formatted either as "::" or "" var idx = plugin.IndexOf("::", StringComparison.Ordinal); var filePath = plugin; if (idx != -1) { filePath = plugin[(idx + 1)..]; filePath = Path.GetFullPath(filePath); pluginsToPreload.Add((plugin[0..idx], filePath)); } else { filePath = Path.GetFullPath(filePath); pluginsToPreload.Add((Path.GetFileNameWithoutExtension(plugin), filePath)); } } #endif // Load from the plugins folder foreach (var directory in Directory.GetDirectories(pluginsFolder)) { var pluginIdentifier = Path.GetFileName(directory); var pluginFilePath = Path.Combine(directory, pluginIdentifier + ".dll"); if (!File.Exists(pluginFilePath)) continue; if (disabledPluginIdentifiers.Contains(pluginIdentifier)) continue; pluginsToPreload.Add((pluginIdentifier, pluginFilePath)); } var toDisable = new List(); foreach (var toLoad in pluginsToPreload) { if (preloadedPlugins.Contains(toLoad.PluginIdentifier)) continue; try { var loader = PluginLoader.CreateFromAssemblyFile( toLoad.PluginFilePath, // create a plugin from for the .dll file c => { // this ensures that the version of MVC is shared between this app and the plugin c.PreferSharedTypes = true; c.IsUnloadable = false; c.LoadAssembliesInDefaultLoadContext = config.GetOrDefault("TEST_RUNNER_ENABLED", false); }); var pluginAssembly = loader.LoadDefaultAssembly(); var p = GetPluginInstanceFromAssembly(toLoad.PluginIdentifier, pluginAssembly, silentlyFails: true); if (p == null) { logger.LogError($"The plugin assembly doesn't contain the plugin {toLoad.PluginIdentifier}"); toDisable.Add(toLoad.PluginIdentifier); } else { p.SystemPlugin = false; preloadedPlugins.Add(new(p, loader, pluginAssembly)); } } catch (Exception e) { logger.LogError(e, $"Error when loading plugin {toLoad.PluginIdentifier}."); toDisable.Add(toLoad.PluginIdentifier); } } preloadedPlugins.TopologicalSort(); var loadedPlugins = new List(); foreach (var preloadedPlugin in preloadedPlugins) { var plugin = preloadedPlugin.Instance; try { AssertDependencies(plugin, loadedPlugins); if (preloadedPlugin.Loader is { } loader) loader.AddAssemblyLoadContexts( plugin.Dependencies .Select(d => preloadedPlugins.TryGet(d.Identifier)?.Loader) .Where(d => d is not null) .ToArray()!); // silentlyFails is false, because we want this to throw if there is any missing assembly. GetPluginInstanceFromAssembly(plugin.Identifier, preloadedPlugin.Assembly, silentlyFails: false); if (preloadedPlugin.Loader is not null) mvcBuilder.AddPluginLoader(preloadedPlugin.Loader); _pluginAssemblies.Add(preloadedPlugin.Assembly); logger.LogInformation( $"Adding and executing plugin {plugin.Identifier} - {plugin.Version}"); var pluginServiceCollection = new PluginServiceCollection(serviceCollection, bootstrapServiceProvider); plugin.Execute(pluginServiceCollection); serviceCollection.AddSingleton(plugin); loadedPlugins.Add(preloadedPlugin); } catch (MissingDependenciesException e) { // The difference is that we don't print the stacktrace and we do not disable it logger.LogError($"Error when executing plugin {plugin.Identifier} - {plugin.Version}: {e.Message}"); } catch (Exception e) { logger.LogError(e, $"Error when executing plugin {plugin.Identifier} - {plugin.Version}."); if (!plugin.SystemPlugin) toDisable.Add(plugin.Identifier); } } if (toDisable.Count > 0) { foreach (var plugin in toDisable) DisablePlugin(pluginsFolder, plugin); var crashedPluginsStr = string.Join(", ", toDisable); throw new ConfigException($"The following plugin(s) crashed at startup, they will be disabled and the server will restart: {crashedPluginsStr}"); } return mvcBuilder; } class MissingDependenciesException(string message) : Exception(message); private static void AssertDependencies(IBTCPayServerPlugin plugin, List loaded) { var missing = new List(); var installed = loaded.ToDictionary(l => l.Instance.Identifier, l => l.Instance.Version); foreach (var d in plugin.Dependencies) { if (!DependencyMet(d, installed)) { missing.Add(d); } } if (missing.Any()) { throw new MissingDependenciesException( $"Plugin {plugin.Identifier} is missing dependencies: {string.Join(", ", missing.Select(d => d.ToString()))}"); } } public static void UsePlugins(this IApplicationBuilder applicationBuilder) { var assemblies = new HashSet(); foreach (var extension in applicationBuilder.ApplicationServices .GetServices()) { extension.Execute(applicationBuilder, applicationBuilder.ApplicationServices); assemblies.Add(extension.GetType().Assembly); } var webHostEnvironment = applicationBuilder.ApplicationServices.GetRequiredService(); var providers = new List() { webHostEnvironment.WebRootFileProvider }; providers.AddRange(assemblies.Select(a => new EmbeddedFileProvider(a))); webHostEnvironment.WebRootFileProvider = new CompositeFileProvider(providers); } private static IEnumerable GetPluginInstancesFromAssembly(Assembly assembly, bool silentlyFails) { return GetTypes(assembly, silentlyFails).Where(type => typeof(IBTCPayServerPlugin).IsAssignableFrom(type) && type != typeof(PluginService.AvailablePlugin) && !type.IsAbstract). Select(type => Activator.CreateInstance(type, Array.Empty()) as IBTCPayServerPlugin) .Where(t => t is not null)!; } private static IEnumerable GetTypes(Assembly assembly, bool silentlyFails) { try { return assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) when (silentlyFails) { return ex.Types.Where(t => t is not null)!; } } private static IBTCPayServerPlugin? GetPluginInstanceFromAssembly(string pluginIdentifier, Assembly assembly, bool silentlyFails) { return GetPluginInstancesFromAssembly(assembly, silentlyFails).FirstOrDefault(plugin => plugin.Identifier == pluginIdentifier); } private static bool ExecuteCommands(string pluginsFolder) { var pendingCommands = GetPendingCommands(pluginsFolder); if (!pendingCommands.Any()) { return false; } var remainingCommands = (from command in pendingCommands where !ExecuteCommand(command, pluginsFolder) select $"{command.command}:{command.plugin}").ToList(); if (remainingCommands.Any()) { File.WriteAllLines(Path.Combine(pluginsFolder, "commands"), remainingCommands); } else { File.Delete(Path.Combine(pluginsFolder, "commands")); } return remainingCommands.Count != pendingCommands.Length; } private static Dictionary TryGetInstalledInfo( string pluginsFolder) { var disabled = GetDisabledPluginIdentifiers(pluginsFolder); var installed = new Dictionary(); foreach (var pluginDir in Directory.EnumerateDirectories(pluginsFolder)) { var plugin = Path.GetFileName(pluginDir); var dirName = Path.Combine(pluginsFolder, plugin); var isDisabled = disabled.Contains(plugin); var manifestFileName = Path.Combine(dirName, plugin + ".json"); if (File.Exists(manifestFileName)) { var pluginManifest = JObject.Parse(File.ReadAllText(manifestFileName)).ToObject(); if (pluginManifest is not null) installed.TryAdd(pluginManifest.Identifier, (pluginManifest.Version, pluginManifest.Dependencies, isDisabled)); } else if (isDisabled) { // Disabled plugin might not have a manifest, but we still need to include // it in the list, so that it can be shown on the Manage Plugins page installed.TryAdd(plugin, (null, null, true)); } } return installed; } private static bool DependenciesMet(string pluginsFolder, string plugin, Dictionary installed) { var dirName = Path.Combine(pluginsFolder, plugin); var manifestFileName = dirName + ".json"; if (!File.Exists(manifestFileName)) return true; var pluginManifest = JObject.Parse(File.ReadAllText(manifestFileName)).ToObject(); if (pluginManifest is not null) return DependenciesMet(pluginManifest.Dependencies, installed); return true; } private static bool ExecuteCommand((string command, string extension) command, string pluginsFolder) { var dirName = Path.Combine(pluginsFolder, command.extension); switch (command.command) { case "delete": ExecuteCommand(("enable", command.extension), pluginsFolder); if (File.Exists(dirName)) { File.Delete(dirName); } if (Directory.Exists(dirName)) { Directory.Delete(dirName, true); } break; case "install": var fileName = dirName + BTCPayPluginSuffix; var manifestFileName = dirName + ".json"; ExecuteCommand(("enable", command.extension), pluginsFolder); if (File.Exists(fileName)) { if (File.Exists(dirName) || Directory.Exists(dirName)) ExecuteCommand(("delete", dirName), pluginsFolder); ZipFile.ExtractToDirectory(fileName, dirName, true); File.Delete(fileName); if (File.Exists(manifestFileName)) { File.Move(manifestFileName, Path.Combine(dirName, Path.GetFileName(manifestFileName))); } } break; case "disable": if (Directory.Exists(dirName)) { if (File.Exists(Path.Combine(pluginsFolder, "disabled"))) { var disabled = File.ReadAllLines(Path.Combine(pluginsFolder, "disabled")); if (!disabled.Contains(command.extension)) { File.AppendAllLines(Path.Combine(pluginsFolder, "disabled"), new[] { command.extension }); } } else { File.AppendAllLines(Path.Combine(pluginsFolder, "disabled"), new[] { command.extension }); } } break; case "enable": if (File.Exists(Path.Combine(pluginsFolder, "disabled"))) { var disabled = File.ReadAllLines(Path.Combine(pluginsFolder, "disabled")); if (disabled.Contains(command.extension)) { File.WriteAllLines(Path.Combine(pluginsFolder, "disabled"), disabled.Where(s => s != command.extension)); } } break; } return true; } public static (string command, string plugin)[] GetPendingCommands(string pluginsFolder) { if (!File.Exists(Path.Combine(pluginsFolder, "commands"))) return Array.Empty<(string command, string plugin)>(); var commands = File.ReadAllLines(Path.Combine(pluginsFolder, "commands")); return commands.Select(s => { var split = s.Split(':'); return (split[0].ToLower(CultureInfo.InvariantCulture), split[1]); }).ToArray(); } public static void QueueCommands(string pluginsFolder, params (string action, string plugin)[] commands) { File.AppendAllLines(Path.Combine(pluginsFolder, "commands"), commands.Select((tuple) => $"{tuple.action}:{tuple.plugin}")); } public static void CancelCommands(string pluginDir, string plugin) { var cmds = GetPendingCommands(pluginDir).Where(tuple => !tuple.plugin.Equals(plugin, StringComparison.InvariantCultureIgnoreCase)).ToArray(); if (File.Exists(Path.Combine(pluginDir, plugin, BTCPayPluginSuffix))) { File.Delete(Path.Combine(pluginDir, plugin, BTCPayPluginSuffix)); } if (File.Exists(Path.Combine(pluginDir, plugin, ".json"))) { File.Delete(Path.Combine(pluginDir, plugin, ".json")); } File.Delete(Path.Combine(pluginDir, "commands")); QueueCommands(pluginDir, cmds); } public static void DisablePlugin(string pluginDir, string plugin) { QueueCommands(pluginDir, ("disable", plugin)); } // Loads the list of disabled plugins from the file private static HashSet GetDisabledPluginIdentifiers(string pluginsFolder) { var disabledPath = Path.Combine(pluginsFolder, "disabled"); return File.Exists(disabledPath) ? File.ReadAllLines(disabledPath).ToHashSet() : []; } // List of disabled plugins with additional info, like the disabled version and its dependencies public static Dictionary GetDisabledPlugins(string pluginsFolder) { return TryGetInstalledInfo(pluginsFolder).Where(pair => pair.Value.Disabled) .ToDictionary(pair => pair.Key, pair => pair.Value.Item1); } public static bool DependencyMet(IBTCPayServerPlugin.PluginDependency dependency, Dictionary installed) { var condition = dependency.ParseCondition(); if (!installed.TryGetValue(dependency.Identifier, out var v)) return condition is VersionCondition.Not; return condition.IsFulfilled(v); } public static bool DependenciesMet(IEnumerable dependencies, Dictionary installed) => dependencies.All(dependency => DependencyMet(dependency, installed)); } }