Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce auto update and killswitch options on plugins #5749

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public class NotificationViewModel
public DateTimeOffset Created { get; set; }
public string Body { get; set; }
public string ActionLink { get; set; }
public string ActionText { get; set; } = "Details";
public bool Seen { get; set; }
}
}
104 changes: 99 additions & 5 deletions BTCPayServer/Controllers/UIServerController.Plugins.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Configuration;
using BTCPayServer.HostedServices;
using BTCPayServer.Plugins;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
Expand Down Expand Up @@ -55,7 +56,7 @@ public class ListPluginsViewModel
public IEnumerable<PluginService.AvailablePlugin> Available { get; set; }
public (string command, string plugin)[] Commands { get; set; }
public bool CanShowRestart { get; set; }
public Dictionary<string, Version> Disabled { get; set; }
public Dictionary<string, (Version v, string? reason)> Disabled { get; set; }
public Dictionary<string, AvailablePlugin> DownloadedPluginsByIdentifier { get; set; } = new Dictionary<string, AvailablePlugin>();
}

Expand All @@ -73,9 +74,22 @@ public class ListPluginsViewModel
return RedirectToAction("ListPlugins");
}

[HttpPost("server/plugins/enable")]
public IActionResult EnablePlugin([FromServices] PluginService pluginService, string plugin)
{
pluginService.CancelCommands(plugin);
pluginService.Enable(plugin);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Plugin scheduled to be re-enabled.",
Severity = StatusMessageModel.StatusSeverity.Success
});

return RedirectToAction("ListPlugins");
}

[HttpPost("server/plugins/cancel")]
public IActionResult CancelPluginCommands(
[FromServices] PluginService pluginService, string plugin)
public IActionResult CancelPluginCommands([FromServices] PluginService pluginService, string plugin)
{
pluginService.CancelCommands(plugin);
TempData.SetStatusMessageModel(new StatusMessageModel()
Expand All @@ -86,10 +100,89 @@ public class ListPluginsViewModel

return RedirectToAction("ListPlugins");
}

[HttpPost("server/plugins/autoupdate")]
public async Task<IActionResult> ToggleAutoUpdate( string plugin, bool? autoUpdate)
{
var dh = await _SettingsRepository.GetSettingAsync<PluginVersionCheckerDataHolder>() ??
new PluginVersionCheckerDataHolder();
dh.AutoUpdatePlugins ??= [];
autoUpdate??= !dh.AutoUpdatePlugins.Contains(plugin);
if (autoUpdate is true)
{
dh.AutoUpdatePlugins.Add(plugin);
}
else
{
dh.AutoUpdatePlugins.Remove(plugin);
}

await _SettingsRepository.UpdateSetting(dh);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = $"Auto update {(autoUpdate.Value ? "enabled" : "disabled")} for {plugin}.",
Severity = StatusMessageModel.StatusSeverity.Success
});

return RedirectToAction("ListPlugins");
}

[HttpPost("server/plugins/killswitch")]
public async Task<IActionResult> ToggleKillswitch( string plugin, bool? killswitch)
{
var dh = await _SettingsRepository.GetSettingAsync<PluginVersionCheckerDataHolder>() ??
new PluginVersionCheckerDataHolder();
dh.KillswitchPlugins ??= [];
killswitch??= !dh.KillswitchPlugins.Contains(plugin);
if (killswitch is true)
{
dh.KillswitchPlugins.Add(plugin);
}
else
{
dh.KillswitchPlugins.Remove(plugin);
}

await _SettingsRepository.UpdateSetting(dh);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = $"Killswitch {(killswitch.Value ? "enabled" : "disabled")} for {plugin}.",
Severity = StatusMessageModel.StatusSeverity.Success
});

return RedirectToAction("ListPlugins");
}

private async Task TogglePluginStuff(string plugin, bool killswitch, bool autoUpdate)
{
var dh = await _SettingsRepository.GetSettingAsync<PluginVersionCheckerDataHolder>() ??
new PluginVersionCheckerDataHolder();
dh.KillswitchPlugins ??= new List<string>();
dh.AutoUpdatePlugins ??= new List<string>();

if (killswitch)
{
dh.KillswitchPlugins.Add(plugin);
}
else
{
dh.KillswitchPlugins.Remove(plugin);
}

if (autoUpdate)
{
dh.AutoUpdatePlugins.Add(plugin);
}
else
{
dh.AutoUpdatePlugins.Remove(plugin);
}

await _SettingsRepository.UpdateSetting(dh);
}

[HttpPost("server/plugins/install")]
public async Task<IActionResult> InstallPlugin(
[FromServices] PluginService pluginService, string plugin, bool update = false, string version = null)
public async Task<IActionResult> InstallPlugin([FromServices] PluginService pluginService, string plugin, bool update = false, string version = null)
{
try
{
Expand All @@ -101,6 +194,7 @@ public class ListPluginsViewModel
else
{
pluginService.InstallPlugin(plugin);
await TogglePluginStuff(plugin, true, false);
}
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Expand Down
58 changes: 58 additions & 0 deletions BTCPayServer/HostedServices/PluginKillNotification.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Configuration;
using BTCPayServer.Controllers;
using BTCPayServer.Plugins;
using BTCPayServer.Services.Notifications;
using Microsoft.AspNetCore.Routing;

namespace BTCPayServer.HostedServices;

internal class PluginKillNotification : BaseNotification
{
private const string TYPE = "pluginkill";

internal class Handler(LinkGenerator linkGenerator, BTCPayServerOptions options)
: NotificationHandler<PluginKillNotification>
{
public override string NotificationType => TYPE;

public override (string identifier, string name)[] Meta
{
get
{
return [(TYPE, "Plugin update")];
}
}

protected override void FillViewModel(PluginKillNotification notification, NotificationViewModel vm)
{
vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType;
vm.Body =
$"The plugin {notification.Name} has been disabled through the vulnerability killswitch. Restart the server to apply the changes.";
vm.ActionLink = linkGenerator.GetPathByAction(nameof(UIServerController.Maintenance),
"UIServer",
new {command = "soft-restart"}, options.RootPath);
vm.ActionText = "Restart now";
}
}

public PluginKillNotification()
{
}

public PluginKillNotification(PluginService.AvailablePlugin plugin)
{
Name = plugin.Name;
PluginIdentifier = plugin.Identifier;
Version = plugin.Version.ToString();
}

public string PluginIdentifier { get; set; }

public string Name { get; set; }

public string Version { get; set; }
public override string Identifier => TYPE;
public override string NotificationType => TYPE;
}
97 changes: 38 additions & 59 deletions BTCPayServer/HostedServices/PluginUpdateFetcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,79 +5,32 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Configuration;
using BTCPayServer.Controllers;
using BTCPayServer.Plugins;
using BTCPayServer.Services;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;

namespace BTCPayServer.HostedServices
{
internal class PluginUpdateNotification : BaseNotification
{
private const string TYPE = "pluginupdate";

internal class Handler(LinkGenerator linkGenerator, BTCPayServerOptions options) : NotificationHandler<PluginUpdateNotification>
{
public override string NotificationType => TYPE;

public override (string identifier, string name)[] Meta
{
get
{
return new (string identifier, string name)[] {(TYPE, "Plugin update")};
}
}

protected override void FillViewModel(PluginUpdateNotification notification, NotificationViewModel vm)
{
vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType;
vm.Body = $"New {notification.Name} plugin version {notification.Version} released!";
vm.ActionLink = linkGenerator.GetPathByAction(nameof(UIServerController.ListPlugins),
"UIServer",
new {plugin = notification.PluginIdentifier}, options.RootPath);
}
}

public PluginUpdateNotification()
{
}

public PluginUpdateNotification(PluginService.AvailablePlugin plugin)
{
Name = plugin.Name;
PluginIdentifier = plugin.Identifier;
Version = plugin.Version.ToString();
}

public string PluginIdentifier { get; set; }

public string Name { get; set; }

public string Version { get; set; }
public override string Identifier => TYPE;
public override string NotificationType => TYPE;
}

public class PluginVersionCheckerDataHolder
{
public Dictionary<string, Version> LastVersions { get; set; }
public List<string> AutoUpdatePlugins { get; set; }
public List<string> KillswitchPlugins { get; set; }
}

public class PluginUpdateFetcher(SettingsRepository settingsRepository, NotificationSender notificationSender, PluginService pluginService)
public class PluginUpdateFetcher(SettingsRepository settingsRepository, NotificationSender notificationSender, PluginService pluginService, DataDirectories dataDirectories)
: IPeriodicTask
{
public async Task Do(CancellationToken cancellationToken)
{
var dh = await settingsRepository.GetSettingAsync<PluginVersionCheckerDataHolder>() ??
new PluginVersionCheckerDataHolder();
dh.LastVersions ??= new Dictionary<string, Version>();
dh.AutoUpdatePlugins ??= new List<string>();
var disabledPlugins = pluginService.GetDisabledPlugins();

var installedPlugins =
Expand All @@ -89,27 +42,53 @@ public async Task Do(CancellationToken cancellationToken)
.Select(group => group.OrderByDescending(plugin => plugin.Version).First())
.Where(pair => installedPlugins.ContainsKey(pair.Identifier) || disabledPlugins.ContainsKey(pair.Name))
.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version);
var notify = new HashSet<string>();
var notify = new Dictionary<string, string>();

foreach (var pair in remotePluginsList)
{
if (dh.LastVersions.TryGetValue(pair.Key, out var lastVersion) && lastVersion >= pair.Value)
continue;
if (installedPlugins.TryGetValue(pair.Key, out var installedVersion) && installedVersion < pair.Value)
{
notify.Add(pair.Key);
notify.TryAdd(pair.Key, "update");
}
else if (disabledPlugins.TryGetValue(pair.Key, out var disabledVersion) && disabledVersion < pair.Value)
else if (disabledPlugins.TryGetValue(pair.Key, out var disabledVersion) && disabledVersion.Item1 < pair.Value)
{
notify.Add(pair.Key);
notify.TryAdd(pair.Key, "update");
}
}

dh.LastVersions = remotePluginsList;

foreach (string pluginUpdate in notify)
//check if any loaded plugin is in the remote list with exact version and is marked with Kill. If so, check if there is the plugin listed under AutoKillSwitch and if so, kill the plugin
foreach (var plugin in pluginService.LoadedPlugins)
{
var matched = remotePlugins.FirstOrDefault(p => p.Identifier == plugin.Identifier && p.Version == plugin.Version);
if(matched is {Kill: true} && dh.KillswitchPlugins.Contains(plugin.Identifier))
{
PluginManager.DisablePlugin(dataDirectories.PluginDir, plugin.Identifier, "Killswitch activated");

notify.TryAdd(plugin.Identifier, "kill");
}
}
foreach (var pluginUpdate in notify)
{
var plugin = remotePlugins.First(p => p.Identifier == pluginUpdate);
await notificationSender.SendNotification(new AdminScope(), new PluginUpdateNotification(plugin));
var plugin = remotePlugins.First(p => p.Identifier == pluginUpdate.Key);

if (pluginUpdate.Value == "kill")
{
await notificationSender.SendNotification(new AdminScope(), new PluginKillNotification(plugin));
}
else
{
var update = false;
if (dh.AutoUpdatePlugins.Contains(plugin.Identifier))
{
update = true;
await pluginService.DownloadRemotePlugin(plugin.Identifier, plugin.Version.ToString());
pluginService.UpdatePlugin(plugin.Identifier);
}
await notificationSender.SendNotification(new AdminScope(), new PluginUpdateNotification(plugin, update));
}
}

await settingsRepository.UpdateSetting(dh);
Expand Down