diff --git a/Obsidian.API/Plugins/IPluginContainer.cs b/Obsidian.API/Plugins/IPluginContainer.cs new file mode 100644 index 000000000..390534e29 --- /dev/null +++ b/Obsidian.API/Plugins/IPluginContainer.cs @@ -0,0 +1,10 @@ +namespace Obsidian.API.Plugins; +public interface IPluginContainer +{ + /// + /// Searches for the specified file that was packed alongside your plugin. + /// + /// The name of the file you're searching for. + /// Null if the file is not found or the byte array of the file. + public byte[]? GetFileData(string fileName); +} diff --git a/Obsidian.API/Plugins/IPluginInfo.cs b/Obsidian.API/Plugins/IPluginInfo.cs index aae5d2259..4df0f666d 100644 --- a/Obsidian.API/Plugins/IPluginInfo.cs +++ b/Obsidian.API/Plugins/IPluginInfo.cs @@ -7,4 +7,15 @@ public interface IPluginInfo public string Description { get; } public string[] Authors { get; } public Uri ProjectUrl { get; } + + public PluginDependency[] Dependencies { get; } +} + +public readonly struct PluginDependency +{ + public required string Id { get; init; } + + public required string Version { get; init; } + + public bool Required { get; init; } } diff --git a/Obsidian.API/Plugins/PluginBase.cs b/Obsidian.API/Plugins/PluginBase.cs index d48d4560c..e2ba948c3 100644 --- a/Obsidian.API/Plugins/PluginBase.cs +++ b/Obsidian.API/Plugins/PluginBase.cs @@ -7,25 +7,16 @@ namespace Obsidian.API.Plugins; /// public abstract class PluginBase : IDisposable, IAsyncDisposable { -#nullable disable - public IPluginInfo Info { get; internal set; } + public IPluginInfo Info { get; internal set; } = default!; - internal Action unload; -#nullable restore - - private Type typeCache; - - public PluginBase() - { - typeCache ??= GetType(); - } + public IPluginContainer Container { get; internal set; } = default!; /// /// Used for registering services. /// /// /// Only services from the Server will be injected when this method is called. e.x (ILogger, IServerConfiguration). - /// Services registered through this method will be availiable/injected when is called. + /// Services registered through this method will be availiable/injected when is called. /// public virtual void ConfigureServices(IServiceCollection services) { } @@ -35,7 +26,7 @@ public virtual void ConfigureServices(IServiceCollection services) { } /// /// /// Services from the Server will be injected when this method is called. e.x (ILogger, IServerConfiguration). - /// Services registered through this method will be availiable/injected when is called. + /// Services registered through this method will be availiable/injected when is called. /// public virtual void ConfigureRegistry(IPluginRegistry pluginRegistry) { } @@ -43,16 +34,17 @@ public virtual void ConfigureRegistry(IPluginRegistry pluginRegistry) { } /// /// Called when the world has loaded and the server is joinable. /// - public virtual ValueTask OnLoadAsync(IServer server) => ValueTask.CompletedTask; + public virtual ValueTask OnServerReadyAsync(IServer server) => ValueTask.CompletedTask; + /// + /// Called when the plugin has fully loaded. + /// + public virtual ValueTask OnLoadedAsync(IServer server) => ValueTask.CompletedTask; /// - /// Causes this plugin to be unloaded. + /// Called when the plugin is being unloaded. /// - protected void Unload() - { - unload(); - } + public virtual ValueTask OnUnloadingAsync() => ValueTask.CompletedTask; public override sealed bool Equals(object? obj) => base.Equals(obj); public override sealed int GetHashCode() => base.GetHashCode(); diff --git a/Obsidian.API/_Interfaces/IServerConfiguration.cs b/Obsidian.API/_Interfaces/IServerConfiguration.cs index 1595c041e..944523c7d 100644 --- a/Obsidian.API/_Interfaces/IServerConfiguration.cs +++ b/Obsidian.API/_Interfaces/IServerConfiguration.cs @@ -11,6 +11,11 @@ public interface IServerConfiguration /// public bool CanThrottle => this.ConnectionThrottle > 0; + /// + /// Determines where or not the server should load plugins that don't have a valid signature. + /// + public bool AllowUntrustedPlugins { get; set; } + /// /// Allows the server to advertise itself as a LAN server to devices on your network. /// diff --git a/Obsidian.ConsoleApp/Obsidian.ConsoleApp.csproj b/Obsidian.ConsoleApp/Obsidian.ConsoleApp.csproj index b3ccf86ef..42dd32019 100644 --- a/Obsidian.ConsoleApp/Obsidian.ConsoleApp.csproj +++ b/Obsidian.ConsoleApp/Obsidian.ConsoleApp.csproj @@ -9,6 +9,12 @@ en + + + + + + @@ -18,7 +24,9 @@ - + + PreserveNewest + diff --git a/Obsidian.ConsoleApp/accepted_keys/obsidian.pub.xml b/Obsidian.ConsoleApp/accepted_keys/obsidian.pub.xml new file mode 100644 index 000000000..d0cf80a0b --- /dev/null +++ b/Obsidian.ConsoleApp/accepted_keys/obsidian.pub.xml @@ -0,0 +1,4 @@ + + AMYF4iNy8WYQbwlNFxKiEwkjx4TobuMjIgIXgQ4FOBKYVKC77DKOlQ8DKaGn3jOufOZ+Q/+Wgvs28WQjwCOFA9hZJwlYpAGKjnaVGAIcCIU1aCNS6whfP3y/oBB94c+qbLtXXaUdo9qszGTXuYFnyb+GnGCxkdK0N3K6NiTs57xii1VqunwQUlod8+ULo6JbJFRmmlnqzBdYuQNDMpFoLnCq3NZvRJxNe4PP89M3bS2UjS5H1ZM86nTVg9oO4yKMLX4MORlVLWEvP2lvbg1Mrg4fveuPLhMkGZDnvmWaatXxlMoUDv8wQ4+PGrcrhtXS4OqkPl7qFQe9eS4K859kv+NyLDYrb1dtfV7wBZHF5oPnbwyUWgDfT3SWxixY1FqGNEDSRtpo9mUzJvRnvG3V/hNt2YtThpZrxRpZYPoLqiVrKT87vARbFWNjX2QO14XAk/fSqwtzCvF5p/EQl6VuoQ9ylC+2KmAk3U5exydaMw6jksGvVkR7c6lj9J6AZ4nWvw== + AQAB + \ No newline at end of file diff --git a/Obsidian/Obsidian.csproj b/Obsidian/Obsidian.csproj index d9f707bad..aca9ae955 100644 --- a/Obsidian/Obsidian.csproj +++ b/Obsidian/Obsidian.csproj @@ -53,8 +53,8 @@ - - + + diff --git a/Obsidian/Plugins/DirectoryWatcher.cs b/Obsidian/Plugins/DirectoryWatcher.cs index 5148a835e..df87fdc5e 100644 --- a/Obsidian/Plugins/DirectoryWatcher.cs +++ b/Obsidian/Plugins/DirectoryWatcher.cs @@ -7,9 +7,9 @@ public sealed class DirectoryWatcher : IDisposable private string[] _filters = []; public string[] Filters { get => _filters; set => _filters = value ?? []; } - public event Action FileChanged; - public event Action FileRenamed; - public event Action FileDeleted; + public event Action FileChanged = default!; + public event Action FileRenamed = default!; + public event Action FileDeleted = default!; private readonly Dictionary watchers = new(); private readonly Dictionary updateTimestamps = new(); @@ -21,12 +21,6 @@ public void Watch(string path) if (!Directory.Exists(path)) throw new DirectoryNotFoundException(path); - foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) - { - if (TestFilter(file)) - FileChanged?.Invoke(file); - } - lock (watchers) { if (watchers.ContainsKey(path)) @@ -56,7 +50,7 @@ public void Unwatch(string path) { lock (watchers) { - if (!watchers.TryGetValue(path, out FileSystemWatcher watcher)) + if (!watchers.TryGetValue(path, out FileSystemWatcher? watcher)) throw new KeyNotFoundException(); watcher.Created -= OnFileUpdated; diff --git a/Obsidian/Plugins/PluginContainer.cs b/Obsidian/Plugins/PluginContainer.cs index 9dfbba14b..6fd97aebe 100644 --- a/Obsidian/Plugins/PluginContainer.cs +++ b/Obsidian/Plugins/PluginContainer.cs @@ -1,59 +1,84 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Obsidian.API.Plugins; +using Obsidian.Plugins.PluginProviders; +using System.Collections.Frozen; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Reflection; -using System.Runtime.Loader; namespace Obsidian.Plugins; -public sealed class PluginContainer : IDisposable +public sealed class PluginContainer : IDisposable, IPluginContainer { - private Type? pluginType; + private bool initialized; + + private Type? PluginType => this.Plugin?.GetType(); public IServiceScope ServiceScope { get; internal set; } = default!; + public PluginInfo Info { get; private set; } = default!; + + public PluginBase? Plugin { get; internal set; } + + [AllowNull] + public PluginLoadContext LoadContext { get; internal set; } = default!; [AllowNull] - public PluginBase Plugin { get; private set; } - public PluginInfo Info { get; } + public Assembly PluginAssembly { get; internal set; } = default!; [AllowNull] - public AssemblyLoadContext LoadContext { get; private set; } - public Assembly PluginAssembly { get; } = default!; + public FrozenDictionary FileEntries { get; internal set; } = default!; - public string Source { get; internal set; } = default!; - public string ClassName { get; } = default!; + public required string Source { get; set; } + public required bool ValidSignature { get; init; } public bool HasDependencies { get; private set; } = true; public bool IsReady => HasDependencies; public bool Loaded { get; internal set; } - public PluginContainer(PluginInfo info, string source) + ~PluginContainer() { - Info = info; - Source = source; + this.Dispose(false); } - public PluginContainer(PluginBase plugin, PluginInfo info, Assembly assembly, AssemblyLoadContext loadContext, string source) + internal void Initialize() { + if (!this.initialized) + { + var pluginJsonData = this.GetFileData("plugin.json") ?? throw new InvalidOperationException("Failed to find plugin.json"); + + this.Info = pluginJsonData.FromJson() ?? throw new NullReferenceException("Failed to deserialize plugin.json"); - Plugin = plugin; - Info = info; - LoadContext = loadContext; - Source = source; - PluginAssembly = assembly; + this.initialized = true; - pluginType = plugin.GetType(); - ClassName = pluginType.Name; - Plugin.Info = Info; + return; + } + + this.Plugin!.Container = this; + this.Plugin!.Info = this.Info; + } + + //TODO PLUGINS SHOULD USE VERSION CLASS TO SPECIFY VERSION + internal bool IsDependency(string pluginId) => + this.Info.Dependencies.Any(x => x.Id == pluginId); + + internal bool AddDependency(PluginLoadContext pluginLoadContext) + { + ArgumentNullException.ThrowIfNull(pluginLoadContext); + + if (this.LoadContext == null) + return false; + + this.LoadContext.AddDependency(pluginLoadContext); + return true; } /// /// Inject the scoped services into /// - public void InjectServices(ILogger? logger, object? target = null) + internal void InjectServices(ILogger? logger, object? target = null) { - var properties = target is null ? this.pluginType!.WithInjectAttribute() : target.GetType().WithInjectAttribute(); + var properties = target is null ? this.PluginType!.WithInjectAttribute() : target.GetType().WithInjectAttribute(); target ??= this.Plugin; @@ -70,15 +95,33 @@ public void InjectServices(ILogger? logger, object? target = null) logger?.LogError(ex, "Failed to inject service."); } } + } + /// + public byte[]? GetFileData(string fileName) + { + var fileEntry = this.FileEntries?.GetValueOrDefault(fileName); + + return fileEntry?.GetData(); } public void Dispose() { - Plugin = null; - LoadContext = null; - pluginType = null; + this.Dispose(true); - this.ServiceScope.Dispose(); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + this.ServiceScope?.Dispose(); + + if (disposing) + { + this.PluginAssembly = null; + this.LoadContext = null; + this.Plugin = null; + this.FileEntries = null; + } } } diff --git a/Obsidian/Plugins/PluginFileEntry.cs b/Obsidian/Plugins/PluginFileEntry.cs new file mode 100644 index 000000000..4aed19f6a --- /dev/null +++ b/Obsidian/Plugins/PluginFileEntry.cs @@ -0,0 +1,33 @@ +using Org.BouncyCastle.Crypto; +using System.IO; +using System.IO.Compression; + +namespace Obsidian.Plugins; +public sealed class PluginFileEntry +{ + internal byte[] rawData = default!; + + public required string Name { get; init; } + + public required int Length { get; init; } + + public required int CompressedLength { get; init; } + + public required int Offset { get; set; } + + public bool IsCompressed => Length != CompressedLength; + + internal byte[] GetData() + { + if (!this.IsCompressed) + return this.rawData; + + using var ms = new MemoryStream(this.rawData, false); + using var ds = new DeflateStream(ms, CompressionMode.Decompress); + using var outStream = new MemoryStream(); + + ds.CopyTo(outStream); + + return outStream.Length != this.Length ? throw new DataLengthException() : outStream.ToArray(); + } +} diff --git a/Obsidian/Plugins/PluginInfo.cs b/Obsidian/Plugins/PluginInfo.cs index a46066887..bfbee6411 100644 --- a/Obsidian/Plugins/PluginInfo.cs +++ b/Obsidian/Plugins/PluginInfo.cs @@ -26,10 +26,3 @@ internal PluginInfo(string name) Authors = ["Unknown"]; } } - -public readonly struct PluginDependency -{ - public required string Id { get; init; } - - public required Version Version { get; init; } -} diff --git a/Obsidian/Plugins/PluginManager.cs b/Obsidian/Plugins/PluginManager.cs index 4b9b0cafa..2e7cc9b30 100644 --- a/Obsidian/Plugins/PluginManager.cs +++ b/Obsidian/Plugins/PluginManager.cs @@ -8,24 +8,32 @@ using Obsidian.Plugins.ServiceProviders; using Obsidian.Registries; using Obsidian.Services; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; using System.Reflection; +using System.Security.Cryptography; namespace Obsidian.Plugins; public sealed class PluginManager { - private const string loadEvent = "OnLoad"; - internal readonly ILogger logger; + internal readonly IServer server; + + private static PackedPluginProvider packedPluginProvider = default!; + + private readonly List plugins = []; + private readonly List stagedPlugins = []; + private readonly List acceptedKeys = []; - private readonly List plugins = new(); - private readonly List stagedPlugins = new(); private readonly IServiceProvider serverProvider; - private readonly IServer server; private readonly CommandHandler commandHandler; private readonly IPluginRegistry pluginRegistry; private readonly IServiceCollection pluginServiceDescriptors = new ServiceCollection(); + public ImmutableArray AcceptedKeys => acceptedKeys.ToImmutableArray(); + /// /// List of all loaded plugins. ///
Important note: keeping references to plugin containers outside this class will make them unloadable. @@ -56,12 +64,11 @@ public PluginManager(IServiceProvider serverProvider, IServer server, this.serverProvider = serverProvider; this.pluginRegistry = new PluginRegistry(this, eventDispatcher, commandHandler, logger); - PluginProviderSelector.RemotePluginProvider = new RemotePluginProvider(logger); - PluginProviderSelector.UncompiledPluginProvider = new UncompiledPluginProvider(logger); - PluginProviderSelector.CompiledPluginProvider = new CompiledPluginProvider(logger); + packedPluginProvider = new(this, logger); ConfigureInitialServices(env); + DirectoryWatcher.Filters = [".obby"]; DirectoryWatcher.FileChanged += async (path) => { var old = plugins.FirstOrDefault(plugin => plugin.Source == path) ?? @@ -76,36 +83,61 @@ public PluginManager(IServiceProvider serverProvider, IServer server, DirectoryWatcher.FileDeleted += OnPluginSourceDeleted; } - private void ConfigureInitialServices(IServerEnvironment env) + public async Task LoadPluginsAsync() { - this.pluginServiceDescriptors.AddLogging((builder) => + //TODO talk about what format we should support + var acceptedKeyFiles = Directory.GetFiles("accepted_keys"); + + using var rsa = RSA.Create(); + foreach (var certFile in acceptedKeyFiles) { - builder.ClearProviders(); - builder.AddProvider(new LoggerProvider(env.Configuration.LogLevel)); - builder.SetMinimumLevel(env.Configuration.LogLevel); - }); - this.pluginServiceDescriptors.AddSingleton(x => env.Configuration); + var xml = await File.ReadAllTextAsync(certFile); + rsa.FromXmlString(xml); + + this.acceptedKeys.Add(rsa.ExportParameters(false)); + } + + var files = Directory.GetFiles("plugins", "*.obby", SearchOption.AllDirectories); + + var waitingForDepend = new List(); + foreach (var file in files) + { + var pluginContainer = await this.LoadPluginAsync(file); + + if (pluginContainer is null) + continue; + + foreach (var canLoad in waitingForDepend.Where(x => x.IsDependency(pluginContainer.Info.Id)).ToList()) + { + packedPluginProvider.InitializePlugin(canLoad); + + //Add dependency to plugin + canLoad.AddDependency(pluginContainer.LoadContext); + + await this.HandlePluginAsync(canLoad); + + waitingForDepend.Remove(canLoad); + } + + if (pluginContainer.Plugin is null) + waitingForDepend.Add(pluginContainer); + } + + DirectoryWatcher.Watch("plugins"); } /// /// Loads a plugin from selected path asynchronously. /// - /// Path to load the plugin from. Can point either to local DLL, C# code file or a GitHub project url. + /// Path to load the plugin from. Can point either to local OBBY or DLL. /// Loaded plugin. If loading failed, property will be null. public async Task LoadPluginAsync(string path) { - var provider = PluginProviderSelector.GetPluginProvider(path); - if (provider is null) - { - logger.LogError("Couldn't load plugin from path '{path}'", path); - return null; - } - try { - PluginContainer plugin = await provider.GetPluginAsync(path).ConfigureAwait(false); + var plugin = await packedPluginProvider.GetPluginAsync(path).ConfigureAwait(false); - return HandlePlugin(plugin); + return plugin is null ? null : await HandlePluginAsync(plugin); } catch (Exception ex) { @@ -115,57 +147,13 @@ private void ConfigureInitialServices(IServerEnvironment env) } } - private PluginContainer? HandlePlugin(PluginContainer pluginContainer) - { - if (pluginContainer?.Plugin is null) - { - return pluginContainer; - } - - //Inject first wave of services (services initialized by obsidian e.x IServerConfiguration) - PluginServiceHandler.InjectServices(this.serverProvider, pluginContainer, this.logger); - - pluginContainer.Plugin.unload = async () => await UnloadPluginAsync(pluginContainer); - if (pluginContainer.IsReady) - { - lock (plugins) - { - plugins.Add(pluginContainer); - } - - pluginContainer.Plugin.ConfigureServices(this.pluginServiceDescriptors); - pluginContainer.Plugin.ConfigureRegistry(this.pluginRegistry); - - pluginContainer.Loaded = true; - } - else - { - lock (stagedPlugins) - { - stagedPlugins.Add(pluginContainer); - } - - if (logger != null) - { - var stageMessage = new System.Text.StringBuilder(50); - stageMessage.Append($"Plugin {pluginContainer.Info.Name} staged"); - if (!pluginContainer.HasDependencies) - stageMessage.Append(", missing dependencies"); - - logger.LogWarning("{}", stageMessage.ToString()); - } - } - - logger?.LogInformation("Loading finished!"); - - return pluginContainer; - } - /// /// Will cause selected plugin to be unloaded asynchronously. /// public async Task UnloadPluginAsync(PluginContainer pluginContainer) { + this.logger.LogInformation("Unloading plugin..."); + bool removed = false; lock (plugins) { @@ -182,6 +170,10 @@ public async Task UnloadPluginAsync(PluginContainer pluginContainer) this.commandHandler.UnregisterPluginCommands(pluginContainer); + var stopwatch = Stopwatch.StartNew(); + + await pluginContainer.Plugin.OnUnloadingAsync(); + try { await pluginContainer.Plugin.DisposeAsync(); @@ -191,13 +183,18 @@ public async Task UnloadPluginAsync(PluginContainer pluginContainer) logger.LogError(ex, "Unhandled exception occured when disposing {pluginName}", pluginContainer.Info.Name); } - pluginContainer.LoadContext.Unload(); - pluginContainer.LoadContext.Unloading += _ => logger.LogInformation("Finished unloading {pluginName} plugin", pluginContainer.Info.Name); + var loadContext = pluginContainer.LoadContext; + //Dispose has to be called before the LoadContext can unload. pluginContainer.Dispose(); + + stopwatch.Stop(); + + loadContext.Unloading += _ => logger.LogInformation("Finished unloading {pluginName} plugin in {timer}ms", pluginContainer.Info.Name, stopwatch.ElapsedMilliseconds); + loadContext.Unload(); } - public void ServerReady() + public async ValueTask OnServerReadyAsync() { PluginServiceProvider ??= this.pluginServiceDescriptors.BuildServiceProvider(true); foreach (var pluginContainer in this.plugins) @@ -209,7 +206,7 @@ public void ServerReady() pluginContainer.InjectServices(this.logger); - InvokeOnLoad(pluginContainer); + await pluginContainer.Plugin.OnServerReadyAsync(this.server); } //THis only needs to be called once 😭😭 @@ -223,16 +220,61 @@ public void ServerReady() public PluginContainer GetPluginContainerByAssembly(Assembly? assembly = null) => this.Plugins.First(x => x.PluginAssembly == (assembly ?? Assembly.GetCallingAssembly())); - private void OnPluginStateChanged(PluginContainer plugin) + private void ConfigureInitialServices(IServerEnvironment env) { - if (plugin.IsReady) + this.pluginServiceDescriptors.AddLogging((builder) => + { + builder.ClearProviders(); + builder.AddProvider(new LoggerProvider(env.Configuration.LogLevel)); + builder.SetMinimumLevel(env.Configuration.LogLevel); + }); + this.pluginServiceDescriptors.AddSingleton(x => env.Configuration); + } + + private async ValueTask HandlePluginAsync(PluginContainer pluginContainer) + { + //The plugin still hasn't fully loaded. Probably due to it having a hard dependency + if (pluginContainer.Plugin is null) + return pluginContainer; + + //Inject first wave of services (services initialized by obsidian e.x IServerConfiguration) + PluginServiceHandler.InjectServices(this.serverProvider, pluginContainer, this.logger); + + if (pluginContainer.IsReady) { - RunStaged(plugin); + lock (plugins) + { + plugins.Add(pluginContainer); + } + + pluginContainer.Plugin.ConfigureServices(this.pluginServiceDescriptors); + pluginContainer.Plugin.ConfigureRegistry(this.pluginRegistry); + + pluginContainer.Loaded = true; + + await pluginContainer.Plugin.OnLoadedAsync(this.server); } else { - StageRunning(plugin); + lock (stagedPlugins) + { + stagedPlugins.Add(pluginContainer); + } + + if (logger != null) + { + var stageMessage = new System.Text.StringBuilder(50); + stageMessage.Append($"Plugin {pluginContainer.Info.Name} staged"); + if (!pluginContainer.HasDependencies) + stageMessage.Append(", missing dependencies"); + + logger.LogWarning("{}", stageMessage.ToString()); + } } + + logger?.LogInformation("Loading finished!"); + + return pluginContainer; } private void OnPluginSourceRenamed(string oldSource, string newSource) @@ -248,53 +290,6 @@ private async void OnPluginSourceDeleted(string path) if (deletedPlugin != null) await UnloadPluginAsync(deletedPlugin); } - - private void StageRunning(PluginContainer plugin) - { - lock (plugins) - { - if (!plugins.Remove(plugin)) - return; - } - - lock (stagedPlugins) - { - stagedPlugins.Add(plugin); - } - } - - private void RunStaged(PluginContainer plugin) - { - lock (stagedPlugins) - { - if (!stagedPlugins.Remove(plugin)) - return; - } - - lock (plugins) - { - plugins.Add(plugin); - } - - if (!plugin.Loaded) - { - InvokeOnLoad(plugin); - plugin.Loaded = true; - } - } - - private void InvokeOnLoad(PluginContainer plugin) - { - var task = plugin.Plugin.OnLoadAsync(this.server).AsTask(); - if (task.Status == TaskStatus.Created) - { - task.RunSynchronously(); - } - if (task.Status == TaskStatus.Faulted) - { - logger?.LogError(task.Exception?.InnerException, "Invoking {pluginName}.{loadEvent} faulted.", plugin.Info.Name, loadEvent); - } - } } // thank you Roxxel && DorrianD3V for the invasion <3 diff --git a/Obsidian/Plugins/PluginProviders/CompiledPluginProvider.cs b/Obsidian/Plugins/PluginProviders/CompiledPluginProvider.cs deleted file mode 100644 index 4e2ad8039..000000000 --- a/Obsidian/Plugins/PluginProviders/CompiledPluginProvider.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.Extensions.Logging; -using Obsidian.API.Plugins; -using System.IO; -using System.Reflection; -using System.Text.Json; - -namespace Obsidian.Plugins.PluginProviders; - -public sealed class CompiledPluginProvider(ILogger logger) : IPluginProvider -{ - private readonly ILogger logger = logger; - - public async Task GetPluginAsync(string path) - { - var loadContext = new PluginLoadContext(Path.GetFileNameWithoutExtension(path) + "LoadContext", path); - using var pluginStream = new FileStream(path, FileMode.Open); - - var assembly = loadContext.LoadFromStream(pluginStream); - - return await HandlePluginAsync(loadContext, assembly, path); - } - - internal async Task HandlePluginAsync(PluginLoadContext loadContext, Assembly assembly, string path) - { - Type? pluginType = assembly.GetTypes().FirstOrDefault(type => type.IsSubclassOf(typeof(PluginBase))); - - PluginBase? plugin; - if (pluginType == null || pluginType.GetConstructor([]) == null) - { - plugin = default; - logger.LogError("Loaded assembly contains no type implementing PluginBase with public parameterless constructor."); - return new PluginContainer(new PluginInfo(Path.GetFileNameWithoutExtension(path)), path); - } - else - { - logger.LogInformation("Creating plugin instance..."); - plugin = (PluginBase)Activator.CreateInstance(pluginType)!; - } - - string name = assembly.GetName().Name!; - using var pluginInfoStream = assembly.GetManifestResourceStream($"{name}.plugin.json") - ?? throw new InvalidOperationException($"Failed to find embedded plugin.json file for {name}"); - - var info = await pluginInfoStream.FromJsonAsync() ?? throw new JsonException($"Couldn't deserialize plugin.json from {name}"); - - return new PluginContainer(plugin, info, assembly, loadContext, path); - } -} diff --git a/Obsidian/Plugins/PluginProviders/IPluginProvider.cs b/Obsidian/Plugins/PluginProviders/IPluginProvider.cs deleted file mode 100644 index 54896474f..000000000 --- a/Obsidian/Plugins/PluginProviders/IPluginProvider.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace Obsidian.Plugins.PluginProviders; - -public interface IPluginProvider -{ - public Task GetPluginAsync(string path); -} diff --git a/Obsidian/Plugins/PluginProviders/PackedPluginProvider.cs b/Obsidian/Plugins/PluginProviders/PackedPluginProvider.cs new file mode 100644 index 000000000..568c3de5b --- /dev/null +++ b/Obsidian/Plugins/PluginProviders/PackedPluginProvider.cs @@ -0,0 +1,249 @@ +using Microsoft.Extensions.Logging; +using Obsidian.API.Plugins; +using Org.BouncyCastle.Crypto; +using System.Collections.Frozen; +using System.IO; +using System.Reflection; +using System.Security.Cryptography; + +namespace Obsidian.Plugins.PluginProviders; +public sealed class PackedPluginProvider(PluginManager pluginManager, ILogger logger) +{ + private readonly PluginManager pluginManager = pluginManager; + private readonly ILogger logger = logger; + + public async Task GetPluginAsync(string path) + { + await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new BinaryReader(fs); + + var header = reader.ReadBytes(4); + if (!"OBBY"u8.SequenceEqual(header)) + throw new InvalidOperationException("Plugin file does not begin with the proper header."); + + //TODO save api version somewhere + var apiVersion = reader.ReadString(); + + var hash = reader.ReadBytes(SHA384.HashSizeInBytes); + var isSigned = reader.ReadBoolean(); + + byte[]? signature = isSigned ? reader.ReadBytes(SHA384.HashSizeInBits) : null; + + var dataLength = reader.ReadInt32(); + + var curPos = fs.Position; + + //Don't load untrusted plugins + var isSigValid = await this.TryValidatePluginAsync(fs, hash, path, isSigned, signature); + if (!isSigValid) + return null; + + fs.Position = curPos; + + var pluginAssembly = reader.ReadString(); + var pluginVersion = reader.ReadString(); + + var loadContext = new PluginLoadContext(pluginAssembly); + + var entries = await this.InitializeEntriesAsync(reader, fs); + + var partialContainer = this.BuildPartialContainer(loadContext, path, entries, isSigValid); + + //Can't load until those plugins are loaded + if (partialContainer.Info.Dependencies.Any(x => x.Required && !this.pluginManager.Plugins.Any(d => d.Info.Id == x.Id))) + { + var str = partialContainer.Info.Dependencies.Length > 1 ? "has multiple hard dependencies." : + $"has a hard dependency on {partialContainer.Info.Dependencies.First().Id}."; + this.logger.LogWarning("{name} {message}. Will Attempt to load after.", partialContainer.Info.Name, str); + return partialContainer; + } + + var mainAssembly = this.InitializePlugin(partialContainer); + + return HandlePlugin(partialContainer, mainAssembly); + } + + internal Assembly InitializePlugin(PluginContainer pluginContainer) + { + var pluginAssembly = pluginContainer.LoadContext.Name; + + var libsWithSymbols = this.ProcessEntries(pluginContainer); + foreach (var lib in libsWithSymbols) + { + var mainLib = pluginContainer.GetFileData($"{lib}.dll")!; + var libSymbols = pluginContainer.GetFileData($"{lib}.pdb")!; + + pluginContainer.LoadContext.LoadAssembly(mainLib, libSymbols); + } + + var mainPluginEntry = pluginContainer.GetFileData($"{pluginAssembly}.dll")!; + var mainPluginPbdEntry = pluginContainer.GetFileData($"{pluginAssembly}.pdb")!; + + var mainAssembly = pluginContainer.LoadContext.LoadAssembly(mainPluginEntry, mainPluginPbdEntry!) + ?? throw new InvalidOperationException("Failed to find main assembly"); + + pluginContainer.PluginAssembly = mainAssembly; + + return mainAssembly; + } + + internal PluginContainer HandlePlugin(PluginContainer pluginContainer, Assembly assembly) + { + Type? pluginType = assembly.GetTypes().FirstOrDefault(type => type.IsSubclassOf(typeof(PluginBase))); + + PluginBase? plugin; + if (pluginType == null || pluginType.GetConstructor([]) == null) + { + plugin = default; + logger.LogError("Loaded assembly contains no type implementing PluginBase with public parameterless constructor."); + + throw new InvalidOperationException("Loaded assembly contains no type implementing PluginBase with public parameterless constructor."); + } + + logger.LogDebug("Creating plugin instance..."); + plugin = (PluginBase)Activator.CreateInstance(pluginType)!; + + pluginContainer.PluginAssembly = assembly; + pluginContainer.Plugin = plugin; + + pluginContainer.Initialize(); + + return pluginContainer; + } + + /// + /// Verifies the file hash and tries to validate the signature + /// + /// True if the provided plugin was successfully validated. Otherwise false. + private async Task TryValidatePluginAsync(FileStream fs, byte[] hash, string path, bool isSigned, byte[]? signature = null) + { + using (var sha384 = SHA384.Create()) + { + var verifyHash = await sha384.ComputeHashAsync(fs); + + if (!verifyHash.SequenceEqual(hash)) + { + this.logger.LogWarning("File {filePath} integrity does not match specified hash.", path); + return false; + } + } + + var isSigValid = true; + if (!this.pluginManager.server.Configuration.AllowUntrustedPlugins) + { + if (!isSigned) + return false; + + var deformatter = new RSAPKCS1SignatureDeformatter(); + deformatter.SetHashAlgorithm("SHA384"); + + using var rsa = RSA.Create(); + foreach (var rsaParameter in this.pluginManager.AcceptedKeys) + { + rsa.ImportParameters(rsaParameter); + deformatter.SetKey(rsa); + + isSigValid = deformatter.VerifySignature(hash, signature!); + + if (isSigValid) + break; + } + } + + return isSigValid; + } + + /// + /// Steps through the plugin file stream and initializes each file entry found. + /// + /// A dictionary that contains file entries with the key as the FileName and value as . + private async Task> InitializeEntriesAsync(BinaryReader reader, FileStream fs) + { + var entryCount = reader.ReadInt32(); + var entries = new Dictionary(entryCount); + + var offset = 0; + for (int i = 0; i < entryCount; i++) + { + var entry = new PluginFileEntry() + { + Name = reader.ReadString(), + Length = reader.ReadInt32(), + CompressedLength = reader.ReadInt32(), + Offset = offset, + }; + + entries.Add(entry.Name, entry); + + offset += entry.CompressedLength; + } + + var startPos = (int)fs.Position; + foreach (var (_, entry) in entries) + { + entry.Offset += startPos; + + var data = new byte[entry.CompressedLength]; + + var bytesRead = await fs.ReadAsync(data); + + if (bytesRead != entry.CompressedLength) + throw new DataLengthException(); + + entry.rawData = data; + } + + return entries; + } + + private PluginContainer BuildPartialContainer(PluginLoadContext loadContext, string path, + Dictionary entries, bool validSignature) + { + var pluginContainer = new PluginContainer + { + LoadContext = loadContext, + Source = path, + FileEntries = entries.ToFrozenDictionary(), + ValidSignature = validSignature + }; + + pluginContainer.Initialize(); + + return pluginContainer; + } + + + /// + /// Goes and loads any assemblies found into the . + /// + private List ProcessEntries(PluginContainer pluginContainer) + { + var pluginAssembly = pluginContainer.LoadContext.Name; + + var libsWithSymbols = new List(); + foreach (var (_, entry) in pluginContainer.FileEntries) + { + var actualBytes = entry.GetData(); + + var name = Path.GetFileNameWithoutExtension(entry.Name); + //Don't load this assembly wait + if (name == pluginAssembly) + continue; + + //TODO LOAD OTHER FILES SOMEWHERE + if (entry.Name.EndsWith(".dll")) + { + if (pluginContainer.FileEntries.ContainsKey(entry.Name.Replace(".dll", ".pdb"))) + { + //Library has debug symbols load in last + libsWithSymbols.Add(entry.Name.Replace(".dll", ".pdb")); + continue; + } + + pluginContainer.LoadContext.LoadAssembly(actualBytes); + } + } + + return libsWithSymbols; + } +} diff --git a/Obsidian/Plugins/PluginProviders/PluginLoadContext.cs b/Obsidian/Plugins/PluginProviders/PluginLoadContext.cs index e0afe50e2..928e24f6c 100644 --- a/Obsidian/Plugins/PluginProviders/PluginLoadContext.cs +++ b/Obsidian/Plugins/PluginProviders/PluginLoadContext.cs @@ -1,34 +1,29 @@ -using System.Reflection; +using System.IO; +using System.Reflection; using System.Runtime.Loader; namespace Obsidian.Plugins.PluginProviders; -internal sealed class PluginLoadContext : AssemblyLoadContext +public sealed class PluginLoadContext(string name) : AssemblyLoadContext(name: name, isCollectible: true) { - private readonly AssemblyDependencyResolver resolver; + public List Dependencies { get; } = []; - public PluginLoadContext(string name) : base(name: name, isCollectible: true) + public Assembly? LoadAssembly(byte[] mainBytes, byte[]? pbdBytes = null) { - } + using var mainStream = new MemoryStream(mainBytes, false); + using var pbdStream = pbdBytes != null ? new MemoryStream(pbdBytes, false) : null; - public PluginLoadContext(string name, string path) : base(name: name, isCollectible: true) - { - resolver = new AssemblyDependencyResolver(path); + var asm = this.LoadFromStream(mainStream, pbdStream); + + return asm; } + public void AddDependency(PluginLoadContext context) => this.Dependencies.Add(context); + protected override Assembly? Load(AssemblyName assemblyName) { - string assemblyPath = resolver?.ResolveAssemblyToPath(assemblyName); - if (assemblyPath != null) - { - return LoadFromAssemblyPath(assemblyPath); - } + var assembly = this.Assemblies.FirstOrDefault(x => x.GetName() == assemblyName); - return null; - } - - protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) - { - return IntPtr.Zero; + return assembly ?? this.Dependencies.Select(x => x.Load(assemblyName)).FirstOrDefault(x => x != null); } } diff --git a/Obsidian/Plugins/PluginProviders/PluginProviderSelector.cs b/Obsidian/Plugins/PluginProviders/PluginProviderSelector.cs deleted file mode 100644 index 52bbe6f38..000000000 --- a/Obsidian/Plugins/PluginProviders/PluginProviderSelector.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.IO; - -namespace Obsidian.Plugins.PluginProviders; - -public static class PluginProviderSelector -{ - public static RemotePluginProvider RemotePluginProvider { get; internal set; } = default!; - public static UncompiledPluginProvider UncompiledPluginProvider { get; internal set; } = default!; - public static CompiledPluginProvider CompiledPluginProvider { get; internal set; } = default!; - - - public static IPluginProvider? GetPluginProvider(string path) - { - ArgumentException.ThrowIfNullOrEmpty(path); - - if (IsUrl(path)) - { - return RemotePluginProvider; - } - else - { - var fileExtension = Path.GetExtension(path); - return fileExtension switch - { - ".cs" => UncompiledPluginProvider, - ".dll" => CompiledPluginProvider, - _ => null - }; - } - } - - private static bool IsUrl(string path) => - Uri.TryCreate(path, UriKind.Absolute, out var url) && (url.Scheme == Uri.UriSchemeHttp || url.Scheme == Uri.UriSchemeHttps); -} diff --git a/Obsidian/Plugins/PluginProviders/RemotePluginProvider.cs b/Obsidian/Plugins/PluginProviders/RemotePluginProvider.cs deleted file mode 100644 index d4ba25f40..000000000 --- a/Obsidian/Plugins/PluginProviders/RemotePluginProvider.cs +++ /dev/null @@ -1,238 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Emit; -using Microsoft.CodeAnalysis.Text; -using Microsoft.Extensions.Logging; -using System.IO; -using System.Net.Http; -using System.Text.Json; -using System.Xml; - -namespace Obsidian.Plugins.PluginProviders; - -public class RemotePluginProvider(ILogger logger) : IPluginProvider -{ - private static HttpClient client; - - static RemotePluginProvider() - { - client = Globals.HttpClient; - client.DefaultRequestHeaders.Add("User-Agent", "ObsidianServer"); - } - - public async Task GetPluginAsync(string path) - { - if (!Uri.TryCreate(path, UriKind.Absolute, out Uri url)) - return Failed("'unknown'", path, logger, "Provided path is not a valid url"); - - var repository = ParseUrl(url.AbsolutePath); - - return url.Host switch - { - "www.github.com" => await LoadGithubPluginAsync(path, repository), - "gist.github.com" => await LoadGistPluginAsync(path, repository), - _ => Failed("'unknown'", path, logger, $"Remote source {url.Host} is not supported") - }; - } - - private async Task LoadGithubPluginAsync(string path, (string owner, string name) repository) - { - string branch = "main"; - JsonDocument scan = await ScanDirectoryAsync(repository.owner, repository.name, branch); - if (scan is null || scan.RootElement.TryGetProperty("message", out _)) - { - scan?.Dispose(); - branch = "master"; - scan = await ScanDirectoryAsync(repository.owner, repository.name, branch); - if (scan is null) - { - return Failed(repository.name, path, logger, "GitHub servers are not accessible"); - } - else if (scan.RootElement.TryGetProperty("message", out _)) - { - scan.Dispose(); - return Failed(repository.name, path, logger, "Repository is missing master/main tree"); - } - } - - JsonDocument obsidianFile = default; - JsonElement tree = scan.RootElement.GetProperty("tree"); - foreach (JsonElement item in tree.EnumerateArray()) - { - string itemPath = item.GetProperty("path").GetString(); - if (itemPath.EndsWith(".obsidian")) - { - if (obsidianFile != default) - return Failed(repository.name, path, logger, "Repository contains multiple obsidian files"); - - obsidianFile = await GetJsonFileAsync(repository.owner, repository.name, branch, itemPath); - } - } - - (XmlDocument csproj, string csprojPath) = await GetCsprojAsync(obsidianFile, tree, repository, branch); - - if (csproj == null) - return Failed(repository.name, path, logger, "Project not found inside plugin repository"); - - string projectPath = Path.GetDirectoryName(csprojPath).Replace('\\', '/'); - - var fileStreams = new List(); - foreach (JsonElement item in tree.EnumerateArray()) - { - string itemPath = item.GetProperty("path").GetString(); - string itemType = item.GetProperty("type").GetString(); - if (itemPath.StartsWith(projectPath) && itemPath.EndsWith(".cs") && itemType == "blob") - { - fileStreams.Add(await GetFileStreamAsync(repository.owner, repository.name, branch, itemPath)); - } - } - - scan.Dispose(); - obsidianFile?.Dispose(); - - return await CompilePluginFilesAsync(repository, path, fileStreams); - } - - private async Task LoadGistPluginAsync(string path, (string owner, string name) repository) - { - Stream stream = await GetFileStreamAsync(repository.owner, repository.name); - return await CompilePluginFilesAsync(repository, path, new[] { stream }); - } - - private async Task CompilePluginFilesAsync((string owner, string name) repository, string path, IEnumerable fileStreams) - { - var syntaxTrees = fileStreams.Select(fileStream => CSharpSyntaxTree.ParseText(SourceText.From(fileStream))); - var compilation = CSharpCompilation.Create(repository.name, - syntaxTrees, - UncompiledPluginProvider.MetadataReferences, - UncompiledPluginProvider.CompilationOptions); - using var memoryStream = new MemoryStream(); - EmitResult compilationResult = compilation.Emit(memoryStream); - if (!compilationResult.Success) - { - if (logger != null) - { - foreach (var diagnostic in compilationResult.Diagnostics) - { - if (diagnostic.Severity != DiagnosticSeverity.Error || diagnostic.IsWarningAsError) - continue; - - logger.LogError("Compilation failed: {Location} {Message}", diagnostic.Location, diagnostic.GetMessage()); - } - } - - return Failed(repository.name, path); - } - else - { - memoryStream.Seek(0, SeekOrigin.Begin); - - var loadContext = new PluginLoadContext(repository.name + "LoadContext"); - var assembly = loadContext.LoadFromStream(memoryStream); - memoryStream.Dispose(); - return await PluginProviderSelector.CompiledPluginProvider.HandlePluginAsync(loadContext, assembly, path); - } - } - - private async Task<(XmlDocument xml, string path)> GetCsprojAsync(JsonDocument obsidianFile, JsonElement tree, (string owner, string name) repository, string treeName) - { - JsonElement? csproj = null; - string target = null; - JsonElement targetElement = default; - if (obsidianFile?.RootElement.TryGetProperty("target", out targetElement) != false) - target = targetElement.GetString() + ".csproj"; - if (target != null) - { - csproj = tree.EnumerateArray().FirstOrDefault(item => - { - string str = item.GetProperty("path").GetString(); - int slashIndex = str.LastIndexOf('/'); - return slashIndex != -1 ? str[(slashIndex + 1)..] == target : str == target; - }); - } - else - { - foreach (var item in tree.EnumerateArray()) - { - string str = item.GetProperty("path").GetString(); - int slashIndex = str.LastIndexOf('/'); - if (slashIndex != -1 ? str[(slashIndex + 1)..] == target : str == target) - { - csproj = item; - break; - } - } - } - - if (!csproj.HasValue) - return default; - - string csprojPath = csproj.Value.GetProperty("path").GetString(); - return (await GetXmlFileAsync(repository.owner, repository.name, treeName, csprojPath), csprojPath); - } - - private async Task ScanDirectoryAsync(string owner, string name, string tree) - { - await using var stream = await GetFileStreamAsync($"https://api.github.com/repos/{owner}/{name}/git/trees/{tree}?recursive=1"); - if (stream == null) - return null; - return await JsonDocument.ParseAsync(stream); - } - - private async Task GetJsonFileAsync(string owner, string name, string tree, string path) - { - await using var stream = await GetFileStreamAsync($"https://www.github.com/{owner}/{name}/raw/{tree}/{path}"); - if (stream == null) - return null; - return await JsonDocument.ParseAsync(stream); - } - - private async Task GetXmlFileAsync(string owner, string name, string tree, string path) - { - await using var stream = await GetFileStreamAsync($"https://www.github.com/{owner}/{name}/raw/{tree}/{path}"); - if (stream == null) - return null; - - XmlDocument xml = new XmlDocument(); - xml.Load(stream); - return xml; - } - - private async Task GetFileStreamAsync(string owner, string name, string tree, string path) - { - var response = await client.GetAsync($"https://www.github.com/{owner}/{name}/raw/{tree}/{path}"); - if (!response.IsSuccessStatusCode) - return null; - return await response.Content.ReadAsStreamAsync(); - } - - private async Task GetFileStreamAsync(string owner, string hash) - { - var response = await client.GetAsync($"https://gist.githubusercontent.com/{owner}/{hash}/raw"); - if (!response.IsSuccessStatusCode) - return null; - return await response.Content.ReadAsStreamAsync(); - } - - private async Task GetFileStreamAsync(string url) - { - var response = await client.GetAsync(url); - if (!response.IsSuccessStatusCode) - return null; - - return await response.Content.ReadAsStreamAsync(); - } - - private (string owner, string name) ParseUrl(string url) - { - int nameIndex = url.LastIndexOf('/'); - int ownerIndex = url.LastIndexOf('/', nameIndex - 1); - return (url.Substring(ownerIndex + 1, nameIndex - ownerIndex - 1), url[(nameIndex + 1)..]); - } - - private static PluginContainer Failed(string name, string path, ILogger logger = default, string reason = null) - { - logger?.LogError($"Loading plugin {name} failed with reason: {reason}"); - return new PluginContainer(new PluginInfo(name), path); - } -} diff --git a/Obsidian/Plugins/PluginProviders/UncompiledPluginProvider.cs b/Obsidian/Plugins/PluginProviders/UncompiledPluginProvider.cs deleted file mode 100644 index f75279331..000000000 --- a/Obsidian/Plugins/PluginProviders/UncompiledPluginProvider.cs +++ /dev/null @@ -1,89 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Emit; -using Microsoft.CodeAnalysis.Text; -using Microsoft.Extensions.Logging; -using System.IO; - -namespace Obsidian.Plugins.PluginProviders; - -public class UncompiledPluginProvider(ILogger logger) : IPluginProvider -{ - private static WeakReference _metadataReferences = new WeakReference(null); - internal static PortableExecutableReference[] MetadataReferences - { - get - { - if (_metadataReferences.TryGetTarget(out var value) && value != null) - return value; - var references = GetPortableExecutableReferences(); - _metadataReferences.SetTarget(references); - return references; - } - } - internal static CSharpCompilationOptions CompilationOptions { get; set; } - - static UncompiledPluginProvider() - { - CompilationOptions = new CSharpCompilationOptions(outputKind: OutputKind.DynamicallyLinkedLibrary, -#if RELEASE - optimizationLevel: OptimizationLevel.Release); -#elif DEBUG - optimizationLevel: OptimizationLevel.Debug); -#endif - } - - public async Task GetPluginAsync(string path) - { - string name = Path.GetFileNameWithoutExtension(path); - - FileStream fileStream; - try - { - fileStream = File.OpenRead(path); - } - catch - { - logger.LogError("Reloading '{filePath}' failed, file is not accessible.", Path.GetFileName(path)); - return new PluginContainer(new PluginInfo(name), name); - } - SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(SourceText.From(fileStream)); - fileStream.Dispose(); - var compilation = CSharpCompilation.Create(name, - new[] { syntaxTree }, - MetadataReferences, - CompilationOptions); - using var memoryStream = new MemoryStream(); - EmitResult emitResult = compilation.Emit(memoryStream); - - if (!emitResult.Success) - { - if (logger != null) - { - foreach (var diagnostic in emitResult.Diagnostics) - { - if (diagnostic.Severity != DiagnosticSeverity.Error || diagnostic.IsWarningAsError) - continue; - - logger.LogError("Compilation failed: {diagnosticLocation} {diagnosticMessage}", diagnostic.Location, diagnostic.GetMessage()); - } - } - - return new PluginContainer(new PluginInfo(name), name); - } - else - { - memoryStream.Seek(0, SeekOrigin.Begin); - - var loadContext = new PluginLoadContext(name + "LoadContext", path); - var assembly = loadContext.LoadFromStream(memoryStream); - return await PluginProviderSelector.CompiledPluginProvider.HandlePluginAsync(loadContext, assembly, path); - } - } - - private static PortableExecutableReference[] GetPortableExecutableReferences() - { - var trustedAssembliesPaths = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")).Split(Path.PathSeparator); - return trustedAssembliesPaths.Select(reference => MetadataReference.CreateFromFile(reference)).ToArray(); - } -} diff --git a/Obsidian/Server.cs b/Obsidian/Server.cs index 409308781..78c30fa45 100644 --- a/Obsidian/Server.cs +++ b/Obsidian/Server.cs @@ -125,7 +125,7 @@ public Server( CommandsHandler = commandHandler; - PluginManager = new PluginManager(this.serviceProvider, this, eventDispatcher, CommandsHandler, _logger); + PluginManager = new PluginManager(this.serviceProvider, this, eventDispatcher, CommandsHandler, loggerFactory.CreateLogger()); _logger.LogDebug("Registering commands..."); CommandsHandler.RegisterCommandClass(null); @@ -256,10 +256,9 @@ public async Task RunAsync() Directory.CreateDirectory("plugins"); - PluginManager.DirectoryWatcher.Filters = new[] { ".cs", ".dll" }; - PluginManager.DirectoryWatcher.Watch("plugins"); + await PluginManager.LoadPluginsAsync(); - await Task.WhenAll(Configuration.DownloadPlugins.Select(path => PluginManager.LoadPluginAsync(path))); + //await Task.WhenAll(Configuration.DownloadPlugins.Select(path => PluginManager.LoadPluginAsync(path))); if (!Configuration.OnlineMode) _logger.LogInformation("Starting in offline mode..."); @@ -283,7 +282,7 @@ public async Task RunAsync() while (!this.WorldManager.ReadyToJoin && !this._cancelTokenSource.IsCancellationRequested) continue; - this.PluginManager.ServerReady(); + await this.PluginManager.OnServerReadyAsync(); _logger.LogInformation("Listening for new clients..."); diff --git a/Obsidian/Utilities/Extensions.cs b/Obsidian/Utilities/Extensions.cs index e7186a09c..d55e429cd 100644 --- a/Obsidian/Utilities/Extensions.cs +++ b/Obsidian/Utilities/Extensions.cs @@ -109,7 +109,7 @@ public static bool IsConnected(this ConnectionContext context) { return !context.ConnectionClosed.IsCancellationRequested; } - + // Derived from https://gist.github.com/ammaraskar/7b4a3f73bee9dc4136539644a0f27e63 [SuppressMessage("Roslyn", "CA5350", Justification = "SHA1 is required by the Minecraft protocol.")] public static string MinecraftShaDigest(this IEnumerable data) @@ -134,13 +134,14 @@ public static string MinecraftShaDigest(this IEnumerable data) } public static string ToJson(this object? value, JsonSerializerOptions? options = null) => JsonSerializer.Serialize(value, options ?? Globals.JsonOptions); - public static T? FromJson(this string value) => JsonSerializer.Deserialize(value, Globals.JsonOptions); + public static T? FromJson(this string value, JsonSerializerOptions? options = null) => JsonSerializer.Deserialize(value, options ?? Globals.JsonOptions); + public static TValue? FromJson(this byte[] jsonData, JsonSerializerOptions? options = null) => + JsonSerializer.Deserialize(jsonData, options ?? Globals.JsonOptions); public static ValueTask FromJsonAsync(this Stream stream, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default) => JsonSerializer.DeserializeAsync(stream, options ?? Globals.JsonOptions, cancellationToken); public static Task ToJsonAsync(this object? value, Stream stream, CancellationToken cancellationToken = default) => JsonSerializer.SerializeAsync(stream, value, Globals.JsonOptions, cancellationToken); - public static bool Contains(this ReadOnlySpan span, T value) where T : unmanaged { for (int i = 0; i < span.Length; i++) diff --git a/Obsidian/Utilities/ServerConfiguration.cs b/Obsidian/Utilities/ServerConfiguration.cs index 5c0e5dfc2..ed476b0ca 100644 --- a/Obsidian/Utilities/ServerConfiguration.cs +++ b/Obsidian/Utilities/ServerConfiguration.cs @@ -19,6 +19,8 @@ public sealed class ServerConfiguration : IServerConfiguration public bool AllowOperatorRequests { get; set; } = true; + public bool AllowUntrustedPlugins { get; set; } = true; + /// /// If true, each login/client gets a random username where multiple connections from the same host will be allowed. /// diff --git a/SamplePlugin/SamplePlugin.cs b/SamplePlugin/SamplePlugin.cs index 27f73bb32..52c85e1bf 100644 --- a/SamplePlugin/SamplePlugin.cs +++ b/SamplePlugin/SamplePlugin.cs @@ -6,74 +6,87 @@ using Obsidian.API.Plugins; using System.Threading.Tasks; -namespace SamplePlugin +namespace SamplePlugin; + public sealed class SamplePlugin : PluginBase { - public class SamplePlugin : PluginBase - { - // Dependencies will be injected automatically, if dependency class and field/property names match - // Plugins won't load until all their required dependencies are added - // Optional dependencies may be injected at any time, if at all - [Inject] - public ILogger Logger { get; set; } - - //You can register services, commands and events here if you'd like - public override void ConfigureServices(IServiceCollection services) { } + // Dependencies will be injected automatically, if dependency class and field/property names match + // Plugins won't load until all their required dependencies are added + // Optional dependencies may be injected at any time, if at all + [Inject] + public ILogger Logger { get; set; } - //You can register commands, events and soon to be items, blocks and entities - public override void ConfigureRegistry(IPluginRegistry registry) - { - //Will scan for command classes and register them for you - registry.MapCommands(); + //You can register services, commands and events here if you'd like + public override void ConfigureServices(IServiceCollection services) { } - //Will scan for classes that inherit from MinecraftEventHandler - registry.MapEvents(); + //You can register commands, events and soon to be items, blocks and entities + public override void ConfigureRegistry(IPluginRegistry registry) + { + //Will scan for command classes and register them for you + registry.MapCommands(); - //For those coming from the web side of .net these will seem familiar to you. - //You're able to register commands through a "minimal api" like approach - registry.MapCommand("test", - [CommandInfo("test command")] - async (CommandContext ctx, int number, int otherNumber) => - { - await ctx.Player.SendMessageAsync($"Test #{number} and #{otherNumber}. This command was executed from the MinimalAPI."); - }); + //Will scan for classes that inherit from MinecraftEventHandler + registry.MapEvents(); - //As above so below :)) - registry.MapEvent((IncomingChatMessageEventArgs chat) => + //For those coming from the web side of .net these will seem familiar to you. + //You're able to register commands through a "minimal api" like approach + registry.MapCommand("test", + [CommandInfo("test command")] + async (CommandContext ctx, int number, int otherNumber) => { - this.Logger.LogDebug("Got a chat message! From MinimalAPI event."); + await ctx.Player.SendMessageAsync($"Test #{number} and #{otherNumber}. This command was executed from the MinimalAPI."); }); - } - //Called when the world has loaded and the server is ready for connections - public override ValueTask OnLoadAsync(IServer server) + //As above so below :)) + registry.MapEvent((IncomingChatMessageEventArgs chat) => { - Logger.LogInformation("§a{pluginName} §floaded! Hello §aEveryone§f!", Info.Name); - return ValueTask.CompletedTask; - } + this.Logger.LogDebug("Got a chat message! From MinimalAPI event."); + }); } - //All event handlers are created with a scoped lifetime - public class MyEventHandler : MinecraftEventHandler + //Called when the plugin has fully loaded + public override ValueTask OnLoadedAsync(IServer server) { - [EventPriority(Priority = Priority.Critical)] - public async ValueTask ChatEvent(IncomingChatMessageEventArgs args) - { - await args.Player.SendMessageAsync("I got your chat message through event handler class!"); - } + Logger.LogInformation("§a{pluginName} §floaded! Hello §aEveryone§f!", Info.Name); + + return ValueTask.CompletedTask; } - //All command modules are created with a scoped lifetime - public class MyCommands : CommandModuleBase + //Called when the world has loaded and the server is ready for connections + public override ValueTask OnServerReadyAsync(IServer server) { - [Inject] - public ILogger Logger { get; set; } + Logger.LogInformation("Wow you can join the server!!"); + return ValueTask.CompletedTask; + } - [Command("mycommand")] - [CommandInfo("woop dee doo this command is from a plugin")] - public async Task MyCommandAsync() - { - Logger.LogInformation("Testing Services as injected dependency"); - await this.Player.SendMessageAsync("Hello from plugin command!"); - } + //This is self explanatory (called when the plugin is being unloaded) + public override ValueTask OnUnloadingAsync() + { + Logger.LogInformation("I'm unloading now :("); + return ValueTask.CompletedTask; + } +} + +//All event handlers are created with a scoped lifetime +public class MyEventHandler : MinecraftEventHandler +{ + [EventPriority(Priority = Priority.Critical)] + public async ValueTask ChatEvent(IncomingChatMessageEventArgs args) + { + await args.Player.SendMessageAsync("I got your chat message through event handler class!"); + } +} + +//All command modules are created with a scoped lifetime +public class MyCommands : CommandModuleBase +{ + [Inject] + public ILogger Logger { get; set; } + + [Command("mycommand")] + [CommandInfo("woop dee doo this command is from a plugin")] + public async Task MyCommandAsync() + { + Logger.LogInformation("Testing Services as injected dependency"); + await this.Player.SendMessageAsync("Hello from plugin command!"); } } diff --git a/SamplePlugin/SamplePlugin.csproj b/SamplePlugin/SamplePlugin.csproj index 759fe5a96..be1bab1ca 100644 --- a/SamplePlugin/SamplePlugin.csproj +++ b/SamplePlugin/SamplePlugin.csproj @@ -8,27 +8,20 @@ true true false - - - ..\Obsidian.ConsoleApp\bin\Debug\$(TargetFramework)\plugins\ - none - false - Off + SamplePlugin + 1.0.0.0 + 1.0.0 + bin/$(Configuration)/ + signing_key.xml - - - - - - - - + runtime + @@ -38,4 +31,13 @@ + + + PreserveNewest + + + + diff --git a/SamplePlugin/plugin.json b/SamplePlugin/plugin.json index 9602ed78c..8ff5b75dd 100644 --- a/SamplePlugin/plugin.json +++ b/SamplePlugin/plugin.json @@ -6,5 +6,12 @@ "Obsidian Team" ], "description": "My sample plugin.", - "projectUrl": "https://github.com/ObsidianMC/Obsidian" + "projectUrl": "https://github.com/ObsidianMC/Obsidian", + "dependencies": [ + { + "id": "test_plugin", + "version": "1.0.0-beta", + "required": true + } + ] } \ No newline at end of file diff --git a/SamplePlugin/signing_key.xml b/SamplePlugin/signing_key.xml new file mode 100644 index 000000000..7cb09cbb8 --- /dev/null +++ b/SamplePlugin/signing_key.xml @@ -0,0 +1,16 @@ + + + xgXiI3LxZhBvCU0XEqITCSPHhOhu4yMiAheBDgU4EphUoLvsMo6VDwMpoafeM6585n5D/5aC+zbxZCPAI4UD2FknCVikAYqOdpUYAhwIhTVoI1LrCF8/fL+gEH3hz6psu1ddpR2j2qzMZNe5gWfJv4acYLGR0rQ3cro2JOznvGKLVWq6fBBSWh3z5QujolskVGaaWerMF1i5A0MykWgucKrc1m9EnE17g8/z0zdtLZSNLkfVkzzqdNWD2g7jIowtfgw5GVUtYS8/aW9uDUyuDh+9648uEyQZkOe+ZZpq1fGUyhQO/zBDj48atyuG1dLg6qQ+XuoVB715Lgrzn2S/43IsNitvV219XvAFkcXmg+dvDJRaAN9PdJbGLFjUWoY0QNJG2mj2ZTMm9Ge8bdX+E23Zi1OGlmvFGllg+guqJWspPzu8BFsVY2NfZA7XhcCT99KrC3MK8Xmn8RCXpW6hD3KUL7YqYCTdTl7HJ1ozDqOSwa9WRHtzqWP0noBnida/ + + AQAB +

+ 6VTDu0JIlGyj0MEqnpcNggTaQuywn8KPAmLQYtfPQM4tDH6fJS/2Zz9WUnWAwdg5rJ1vPDHHfHExl2XeUJm/ORYiVYxWCbUmgUbRxsFxvyuGceFaGSCetMiJAUxgaNYVGBwPROPJs5G0PkdyjTMU9L3TQK+HkZM37ZcHkhtkOAVz/ybvUjd35Q/pIqxiwfeXj6h95VoY0C9dproh3NNsaZeigjVXAL19nAp4AeBT+z8/8qRzHuFKCA+986wIGpuz +

+ + 2UL2K6tTw3niPUINE1RhoMJ+pzenrvTdv2DZUeqYXju9xDsDP2tggsR04J0bNQmtie5pVAsuKQGyKJS9Bq2ytFIwpA33JS1/GO7wgAUuUyTJqtcrcy8SsX4T67HsrFX8JuvhUC3dwbFcewH0Y5FJ39LsdaFoNWaW379XlwuNCJM861MYmjDqRoPWD+rxXIi5UShfzy+15jiBj6svj4Rx9FchpWA0wp4saWgPG97EB+RtVVx+yj7I9g+zHa0d9OLF + + kfnG7pt8XudM4WhIKqmj+MjiZ+Y+ZeTJpZt+dahAcHHMuzlohVJpXJTCg5ohsKR/CKACHG2kORV0ChzgJhraCHPxjQXcOfyMF2rgCPGzIP+xAAQVYw0GXWHsXNSUqGHvFoNPhjDgWqh4VFJMt0vS+37a8Gggb5Hj9o97XAwo+ednd6S7Kvuq1bLLjza8Hk+xEV4/TUK9rVlExO9ECix3ceQ89is6wCrMr9fY1ouq3s/mSP5bS2F0+HAozY6+Dy+D + uGLAEafB2zBh9aNM/Y6rnIf0fW4afb2LRJSpW4BB+BezogqDYxt6OQEKQGaVZJnmEh2ofo9OibbKwO4azsQz2h228kR377GBIGQI8F11R1L9ZGRgl8znjIN33JyQRGJlMZMXEbkbbvtbhXnPM4FmtPKJg/uMOWXrvdDiQWqoMxLirMVFJ/dDnLzeu3ulg+b1gA9H4MqZEd369zdNkfvRypD4GSZhIJqlDoAl8n1I2Xnf4IYzhOlsHIiOdy0pM2AR + IJqfoKt3YSS43Jz5sGW3Mct9V1156I2YTNFvVSJIxTFdPtlBc6I+qWmq2EUyZzvJNY8ppVCuXprzVOhol1wQQriPV6w0fXvdQ4X9K2k6m95v/Ohstp0dT08MpWPKPquGzYIb6mS4k8HVOEgsmNU9ImekxhIfmK/gw66/JUF4VAF7wKXmHH9Vi3wXc4xwpsZAfbvTM5+C/b2vhHR/0KbWhTWHtVgygoCfc2RocWkLW2nUosE9T6whzCymf/60ZVGW + LnAoxRlqhP3rnCnI9GSvxB/w5TlA7+FIU2dBq5ELwkiY6AzJ6l1HlXqvY6qjUElmHwOTXLfmRZVv7IT5xRqneTAHGIkCBR6CJEr4k14RnXOV1VrXpWH42H9zwQpUT0fvMcveOR+HUnvdfvScGz3EsTaXK5HY2anLqwsCMYyhF6ugc0qRKxEEzv9hN+CP4j4ved+J6PZIIF19Hce/bxwYb/GR0nPLikAdbB7zViAUaXEB3s2Xx2ysqvN7dUKyf2KRyucaAS8KczvrxAXPhYR7peNX5IoyUVsO95DFjWdK8bBISyKqf2HXUUcnQZo6Ukf+tv5Vua8dwgYtnt8UZO0CYGWU3UBH22wcB5WKvGa7FV5UMfnCjAc2ELM5yRKo0FnABeZ2btJ3v4r1G64UbD0atDpTmYF+7m0STqMa5OsOdRLqhy3Z1xficX8uWQtm9Gq8kQOqLOyLZGraQPRzhmXRCKIZwh4sNCZ28W394oLOR9cZcHy2Kjp2pBNwKkT/dPqJ +
\ No newline at end of file