#nullable enable
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
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.Abstractions.Extensions;
using BTCPayServer.Configuration;
using BTCPayServer.Plugins.Dotnet;
using BTCPayServer.Services;
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";
///
/// In case of tests, this is shared the plugins that are already their assembly loaded.
/// This avoid loading the same plugin twice.
///
private static PreloadedPlugins _preloadedPlugins = new();
public static bool IsExceptionByPlugin(Exception exception, [MaybeNullWhen(false)] out PreloadedPlugin preloadedPlugin)
{
if (ExtractPluginFromStackTrace(exception, out preloadedPlugin)) return true;
var fromAssembly = exception is TypeLoadException
? Regex.Match(exception.Message, "from assembly '(.*?),").Groups[1].Value
: null;
foreach (var plugin in _preloadedPlugins)
{
var assembly = plugin.Assembly;
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 the same name but different casing.
if (exception.Source is not null &&
assemblyName.Equals(exception.Source, StringComparison.Ordinal))
{
preloadedPlugin = plugin;
return true;
}
if (exception.Message.Contains(assemblyName, StringComparison.Ordinal))
{
preloadedPlugin = plugin;
return true;
}
// For TypeLoadException, check if it might come from a referenced assembly
if (!string.IsNullOrEmpty(fromAssembly) && assembly.GetReferencedAssemblies().Select(a => a.Name).Contains(fromAssembly))
{
preloadedPlugin = plugin;
return true;
}
}
preloadedPlugin = null;
return false;
}
private static bool ExtractPluginFromStackTrace(Exception exception, [MaybeNullWhen(false)] out PreloadedPlugin preloadedPlugin)
{
Dictionary pluginsByName = new();
foreach (var preloaded in _preloadedPlugins.Where(p => p.Loader is not null && !string.IsNullOrEmpty(p.Assembly.FullName)))
{
pluginsByName.TryAdd(preloaded.Assembly.FullName!, preloaded);
foreach (var assembly in preloaded.Loader!.LoadContext.Assemblies)
{
pluginsByName.TryAdd(assembly.FullName!, preloaded);
}
}
return ExtractPluginFromStackTrace(exception, out preloadedPlugin, pluginsByName);
}
private static bool ExtractPluginFromStackTrace(Exception exception, out PreloadedPlugin? preloadedPlugin,
Dictionary pluginsByName)
{
var trace = new StackTrace(exception, true);
foreach (var frame in trace.GetFrames().Reverse())
{
var m = frame.GetMethod();
if (m is null)
continue;
if (pluginsByName.TryGetValue(m.Module.Assembly.FullName ?? "", out var plugin))
{
preloadedPlugin = plugin;
return true;
}
}
preloadedPlugin = null;
if (exception is AggregateException aggregateException)
{
foreach (var ex in aggregateException.InnerExceptions)
{
if (ExtractPluginFromStackTrace(ex, out preloadedPlugin, pluginsByName))
return true;
}
return false;
}
else if (exception.InnerException is not null)
return ExtractPluginFromStackTrace(exception.InnerException, out preloadedPlugin, pluginsByName);
else
return false;
}
public 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 preloadedPlugins = new PreloadedPlugins();
var logger = loggerFactory.CreateLogger(typeof(PluginManager));
var pluginsFolder = new DataDirectories().Configure(config).PluginDir;
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))
{
var contentRoot = config["contentRoot"] as string ?? ".";
// Formatted either as "::" or ""
var idx = plugin.IndexOf("::", StringComparison.Ordinal);
var filePath = plugin;
if (idx != -1)
{
filePath = plugin[(idx + 1)..];
filePath = Path.GetFullPath(Path.Combine(contentRoot, filePath));
pluginsToPreload.Add((plugin[0..idx], filePath));
}
else
{
filePath = Path.GetFullPath(Path.Combine(contentRoot, 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.Join(directory, pluginIdentifier + ".dll");
if (!File.Exists(pluginFilePath))
continue;
if (disabledPluginIdentifiers.Contains(pluginIdentifier))
{
logger.LogInformation($"Skipping disabled plugin {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);
var (logLevel, message) = plugin switch
{
{ Identifier: "BTCPayServer" } => (LogLevel.Information, $"Running {plugin.Identifier} - {BTCPayServerEnvironment.GetInformationalVersion()}"),
{ SystemPlugin: true } => (LogLevel.Debug, $"Running system plugin {plugin.Identifier} - {plugin.Version}"),
_ => (LogLevel.Information, $"Running plugin {plugin.Identifier} - {plugin.Version}")
};
logger.Log(logLevel, message);
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);
}
}
_preloadedPlugins = preloadedPlugins;
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