Files
btcpayserver/BTCPayServer/Plugins/PluginManager.cs
Nicolas Dorier 894643c5a3 Fix: When running more than one test in a test run, only the first would load plugins correctly (#6985)
Reported by @napoly.

## Actual behavior

Two tests were created. When we would run the tests through the runner,
ASP.NET wouldn't find the registered view of the plugin.

## Expected behavior

The second test should works find, ASP.NET should properly find the
views of the plugin when there are more than one test in the same test
run.

## Cause

If we detected that a plugin assemly was already in the AppDomain, then
we were not loading the ApplicationParts of such assembly.
This wasn't the case for the first test run, but would be after.

The reason for initially doing this was that long time ago, we would
test plugins by referencing them from BTCPaySevrer project. But since
this is not how we are doing things anymore, I think it is safe to
remove this "Feature".
This feature was broken anyway since we started loading plugins in their
own context, but this wouldn't happen with the old way of referencing
plugins from BTCPayServer. (#6851)
2025-11-12 00:15:31 +09:00

520 lines
23 KiB
C#

#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<Assembly> _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<PreloadedPlugin>
{
List<PreloadedPlugin> _plugins = new();
readonly Dictionary<string, PreloadedPlugin> _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<PreloadedPlugin> 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<PreloadedPlugin>(_plugins.Count);
var topological = _plugins.TopologicalSort(
p => p.Instance.Dependencies.Select(d => d.Identifier),
p => p.Instance.Identifier,
p=> p, Comparer<PreloadedPlugin>.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<KestrelServerOptions>(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 "<PLUGIN_IDENTIFIER>::<PathToDll>" or "<PathToDll>"
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<string>();
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;
});
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<PreloadedPlugin>();
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<PreloadedPlugin> loaded)
{
var missing = new List<IBTCPayServerPlugin.PluginDependency>();
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<Assembly>();
foreach (var extension in applicationBuilder.ApplicationServices
.GetServices<IBTCPayServerPlugin>())
{
extension.Execute(applicationBuilder,
applicationBuilder.ApplicationServices);
assemblies.Add(extension.GetType().Assembly);
}
var webHostEnvironment = applicationBuilder.ApplicationServices.GetRequiredService<IWebHostEnvironment>();
var providers = new List<IFileProvider>() { webHostEnvironment.WebRootFileProvider };
providers.AddRange(assemblies.Select(a => new EmbeddedFileProvider(a)));
webHostEnvironment.WebRootFileProvider = new CompositeFileProvider(providers);
}
private static IEnumerable<IBTCPayServerPlugin> 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<object>()) as IBTCPayServerPlugin)
.Where(t => t is not null)!;
}
private static IEnumerable<Type> 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<string, (Version?, IBTCPayServerPlugin.PluginDependency[]? Dependencies, bool Disabled)> TryGetInstalledInfo(
string pluginsFolder)
{
var disabled = GetDisabledPluginIdentifiers(pluginsFolder);
var installed = new Dictionary<string, (Version?, IBTCPayServerPlugin.PluginDependency[]? Dependencies, bool Disabled)>();
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<PluginService.AvailablePlugin>();
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<string, Version> 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<PluginService.AvailablePlugin>();
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<string> 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<string, Version?> 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<string, Version> 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<IBTCPayServerPlugin.PluginDependency> dependencies,
Dictionary<string, Version> installed) => dependencies.All(dependency => DependencyMet(dependency, installed));
}
}