diff --git a/README.md b/README.md index b8facc9..40c58c0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -# tinyconfig -TinyConfig is a simple library to build custom configuration provider in .NET with reload support. +# TinyConfig + +TinyConfig is a simple library to democratize building custom configuration provider in .NET. diff --git a/TinyConfig.sln b/TinyConfig.sln new file mode 100644 index 0000000..6f16465 --- /dev/null +++ b/TinyConfig.sln @@ -0,0 +1,18 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.6.30114.105 +MinimumVisualStudioVersion = 10.0.40219.1 +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/TinyConfig.Abstractions/ITinyConfigOptions.cs b/src/TinyConfig.Abstractions/ITinyConfigOptions.cs new file mode 100644 index 0000000..bfb5197 --- /dev/null +++ b/src/TinyConfig.Abstractions/ITinyConfigOptions.cs @@ -0,0 +1,26 @@ + +namespace TinyConfig.Abstractions +{ + /// + /// tiny config options + /// + public interface ITinyConfigOptions + { + /// + /// indicate whether tiny config is enabled, false by default + /// + bool Enabled { get => false; } + + /// + /// indicate whether configuration should be reloaded when a change occurs + /// Has default value "false" + /// + bool ReloadOnChange { get => false; } + + /// + /// number of seconds to wait before reloading after a change + /// Has default value "60" seconds + /// + int ReloadDelay { get => 60; } + } +} diff --git a/src/TinyConfig.Abstractions/ITinyConfigStore.cs b/src/TinyConfig.Abstractions/ITinyConfigStore.cs new file mode 100644 index 0000000..65aeb93 --- /dev/null +++ b/src/TinyConfig.Abstractions/ITinyConfigStore.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace TinyConfig.Abstractions +{ + /// + /// wraps a configuration store, providing access to the actual configuration values + /// + public interface ITinyConfigStore + { + /// + /// check if a change has occurred on the configuration store + /// + /// + /// true is settings has changed + Task HasChanged(object versionToken = null); + + /// + /// get all settings from configuration store + /// + /// a list containing all configuration key value pairs, with table version token + Task<(IEnumerable, object)> GetAllWithVersionToken(); + } +} diff --git a/src/TinyConfig.Abstractions/ITinySetting.cs b/src/TinyConfig.Abstractions/ITinySetting.cs new file mode 100644 index 0000000..1ff8e61 --- /dev/null +++ b/src/TinyConfig.Abstractions/ITinySetting.cs @@ -0,0 +1,24 @@ + +namespace TinyConfig.Abstractions +{ + /// + /// settings model + /// + public interface ITinySetting + { + /// + /// settings id (key) + /// + string Id { get; } + + /// + /// settings value + /// + string Value { get; } + + /// + /// settings version + /// + byte[] Version { get; } + } +} diff --git a/src/TinyConfig.Abstractions/TinyConfig.Abstractions.csproj b/src/TinyConfig.Abstractions/TinyConfig.Abstractions.csproj new file mode 100644 index 0000000..f51c9c8 --- /dev/null +++ b/src/TinyConfig.Abstractions/TinyConfig.Abstractions.csproj @@ -0,0 +1,7 @@ + + + + netstandard2.1 + + + diff --git a/src/TinyConfig.Core/Extensions/ConfigurationBuilderExtensions.cs b/src/TinyConfig.Core/Extensions/ConfigurationBuilderExtensions.cs new file mode 100644 index 0000000..d6517d3 --- /dev/null +++ b/src/TinyConfig.Core/Extensions/ConfigurationBuilderExtensions.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using TinyConfig.Abstractions; + +namespace TinyConfig.Core.Extensions +{ + /// + /// configuration builder extensions + /// + public static class ConfigurationBuilderExtensions + { + private const string ENABLED_FLAG = "TinyConfig:Enabled"; + private const string CONFIG_SECTION = "TinyConfig"; + + /// + /// add db configuration to list of config + /// + /// + /// existing configuration value + /// logger instance + /// + public static IConfigurationBuilder AddTinyConfig( + this IConfigurationBuilder builder, IConfiguration configuration, + ILogger logger = null) + where TOptions : class, ITinyConfigOptions, new() + where TStore : class, ITinyConfigStore + { + if (configuration.GetValue(ENABLED_FLAG)) + { + var config = new TOptions(); + configuration.Bind(CONFIG_SECTION, config); + return builder.Add(new TinyConfigSource(config, logger)); + } + + return builder; + } + } +} diff --git a/src/TinyConfig.Core/Extensions/ServiceCollectionExtensions.cs b/src/TinyConfig.Core/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..c42fabd --- /dev/null +++ b/src/TinyConfig.Core/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TinyConfig.Abstractions; + +namespace TinyConfig.Core.Extensions +{ + /// + /// service collection extensions + /// + public static class ServiceCollectionExtensions + { + /// + /// add tiny configuration reloader + /// + /// + /// + /// concrete implementation for config + /// concrete implementation for store + /// + public static IServiceCollection AddTinyConfigReloader(this IServiceCollection services, + IConfiguration configuration) + where TOptions : class, ITinyConfigOptions, new() + where TStore : class, ITinyConfigStore => + services.AddSingleton((IConfigurationRoot) configuration) + .AddHostedService>(); + } +} diff --git a/src/TinyConfig.Core/TinyConfig.Core.csproj b/src/TinyConfig.Core/TinyConfig.Core.csproj new file mode 100644 index 0000000..fcfc1eb --- /dev/null +++ b/src/TinyConfig.Core/TinyConfig.Core.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.1 + + + + + + + + + + + + diff --git a/src/TinyConfig.Core/TinyConfigChangeWatcher.cs b/src/TinyConfig.Core/TinyConfigChangeWatcher.cs new file mode 100644 index 0000000..115c1df --- /dev/null +++ b/src/TinyConfig.Core/TinyConfigChangeWatcher.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TinyConfig.Abstractions; + +namespace TinyConfig.Core +{ + /// + /// Background service to notify about configuration data changes. + /// + public class TinyConfigChangeWatcher : BackgroundService + where TOptions : class, ITinyConfigOptions + where TStore : class, ITinyConfigStore + { + private readonly ILogger> _logger; + private readonly IEnumerable> _configProviders; + + /// + /// Initializes a new instance of the class. + /// test. + /// + /// + /// + public TinyConfigChangeWatcher(IConfigurationRoot configurationRoot, ILogger> logger) + { + if (configurationRoot == null) + { + throw new ArgumentNullException(nameof(configurationRoot)); + } + + _logger = logger; + _configProviders = configurationRoot.Providers.OfType>().Where(p => p.ConfigurationSource.Options.ReloadOnChange).ToList() !; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var timers = new Dictionary(); // key - index of config provider, value - timer + var minTime = 6000; + var i = 0; + foreach (var provider in _configProviders) + { + var waitForSec = provider.ConfigurationSource.Options.ReloadDelay; + minTime = Math.Min(minTime, waitForSec); + timers[i] = waitForSec; + i++; + } + + _logger.LogInformation($"DbConfigChangeWatcher will use {minTime} seconds interval"); + + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(minTime), stoppingToken).ConfigureAwait(false); + if (stoppingToken.IsCancellationRequested) + { + break; + } + + for (var j = 0; j < _configProviders.Count(); j++) + { + var timer = timers[j]; + timer -= minTime; + if (timer <= 0) + { + _configProviders.ElementAt(j).Load(); + timers[j] = _configProviders.ElementAt(j).ConfigurationSource.Options.ReloadDelay; + } + else + { + timers[j] = timer; + } + } + } + } + } +} diff --git a/src/TinyConfig.Core/TinyConfigProvider.cs b/src/TinyConfig.Core/TinyConfigProvider.cs new file mode 100644 index 0000000..a5caf27 --- /dev/null +++ b/src/TinyConfig.Core/TinyConfigProvider.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using TinyConfig.Abstractions; + +namespace TinyConfig.Core +{ + /// + /// tiny configuration provider + /// + /// concrete implementation of configuration option + /// concrete implementation of configuration store + public class TinyConfigProvider : ConfigurationProvider + where TOptions : class, ITinyConfigOptions + where TStore : class, ITinyConfigStore + { + /// + /// configuration source + /// + public readonly TinyConfigSource ConfigurationSource; + private readonly ITinyConfigStore _store; + private readonly ILogger _logger; + private readonly Dictionary _versionsCache = new Dictionary(); + private object VersionToken; + + /// + /// ctor + /// + /// + /// + /// + public TinyConfigProvider(ITinyConfigStore store, + TinyConfigSource source, + ILogger logger = null) + { + ConfigurationSource = source; + _store = store; + _logger = logger; + } + + /// + /// load configuration from database + /// + public override void Load() + { + var anyCheck = _store.HasChanged(VersionToken); + anyCheck.Wait(); + if (anyCheck.Result) + { + _logger?.LogDebug("loading configuration from store: {0}", typeof(TStore)); + var reload = LoadDatabaseConfigs(); + _logger?.LogDebug("loaded configuration from store: {0}", typeof(TStore)); + if (reload) OnReload(); + } + } + + /// + /// reload config data + /// + public bool LoadDatabaseConfigs() + { + var reload = false; + var allConfigs = _store.GetAllWithVersionToken(); + allConfigs.Wait(); + + var (settings, lastVersion) = allConfigs.Result; + + foreach (var config in settings) + { + if (!_versionsCache.ContainsKey(config.Id)) + { + Set(config.Id, config.Value); + _versionsCache[config.Id] = config.Version; + reload = true; + } else + { + if (_versionsCache.TryGetValue(config.Id, out byte[] version) + && !version.SequenceEqual(config.Version)) + { + Set(config.Id, config.Value); + _versionsCache[config.Id] = config.Version; + reload = true; + } + } + } + + VersionToken = lastVersion; + + return reload; + } + } +} diff --git a/src/TinyConfig.Core/TinyConfigSource.cs b/src/TinyConfig.Core/TinyConfigSource.cs new file mode 100644 index 0000000..fc3d14c --- /dev/null +++ b/src/TinyConfig.Core/TinyConfigSource.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using TinyConfig.Abstractions; + +namespace TinyConfig.Core +{ + /// + /// configuration source builder + /// + /// concrete implementation of configuration options + /// concrete implementation of configuration store + public class TinyConfigSource : IConfigurationSource + where TOptions : class, ITinyConfigOptions + where TStore : class, ITinyConfigStore + { + /// + /// configuration options + /// + public TOptions Options; + private readonly ILogger _logger; + + /// + /// ctor + /// + /// + /// + public TinyConfigSource(TOptions options, ILogger logger = null) + { + Options = options; + _logger = logger; + } + + /// + /// build configuration source + /// + /// + /// + public IConfigurationProvider Build(IConfigurationBuilder builder) + { + var constructor = typeof(TStore).GetConstructor(new Type[] { typeof(TOptions) }); + ITinyConfigStore store = constructor.Invoke(new object[] { Options }) as ITinyConfigStore; + return new TinyConfigProvider(store, this, _logger); + } + } +} diff --git a/src/TinyConfig.SqlServer/Core/SimpleSetting.cs b/src/TinyConfig.SqlServer/Core/SimpleSetting.cs new file mode 100644 index 0000000..6f474d4 --- /dev/null +++ b/src/TinyConfig.SqlServer/Core/SimpleSetting.cs @@ -0,0 +1,30 @@ +using System; +using TinyConfig.Abstractions; + +namespace TinyConfig.SqlServer.Core +{ + /// + /// simple setting model + /// + public class SimpleSetting : ITinySetting + { + /// + public string Id { get; set; } + + /// + public string Value { get; set; } + + /// + public byte[] Version { get; set; } + + /// + /// indicate if field is a secret + /// + public bool IsSecret { get; set; } + + /// + /// date entry was last modified + /// + public DateTime LastModifiedOn { get; set; } + } +} diff --git a/src/TinyConfig.SqlServer/Core/SqlServerConfigOptions.cs b/src/TinyConfig.SqlServer/Core/SqlServerConfigOptions.cs new file mode 100644 index 0000000..83190cc --- /dev/null +++ b/src/TinyConfig.SqlServer/Core/SqlServerConfigOptions.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; +using TinyConfig.Abstractions; + +namespace TinyConfig.SqlServer.Core +{ + /// + /// simple db config options + /// + public class SqlServerConfigOptions : ITinyConfigOptions + { + /// + /// connection string + /// + [NotNull] + public string ConnectionString { get; } + + /// + /// table name for storing settings + /// + [NotNull] + public string TableName { get; } + + /// + /// encryption key for sensitive credentials + /// + [NotNull] + public string EncryptionKey { get; set; } + + /// + /// indicate whether to reload on change + /// + public bool ReloadOnChange { get; set; } + + /// + /// wait time before retrying reloading for changes + /// + public int ReloadDelay { get; set; } = 60; + } +} diff --git a/src/TinyConfig.SqlServer/Core/SqlServerConfigStore.cs b/src/TinyConfig.SqlServer/Core/SqlServerConfigStore.cs new file mode 100644 index 0000000..0b63991 --- /dev/null +++ b/src/TinyConfig.SqlServer/Core/SqlServerConfigStore.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Dapper; +using TinyConfig.Abstractions; +using TinyConfig.SqlServer.Cryptography; + +namespace TinyConfig.SqlServer.Core +{ + /// + /// simple db config store + /// + public class SqlServerConfigStore : ITinyConfigStore + { + private readonly SqlServerConfigOptions _options; + private readonly AesCrypto _aes; + + public SqlServerConfigStore(SqlServerConfigOptions options) + { + _options = options; + _aes = new AesCrypto(options.EncryptionKey); + } + + /// + public async Task<(IEnumerable, object)> GetAllWithVersionToken() + { + using var conn = CreateConnection(); + var result = await conn.QueryMultipleAsync($"{SelectAllQuery}; {LastVersionQuery}"); + var settings = await result.ReadAsync(); + var lastVersion = await result.ReadAsync(); + + return (settings.Select(entry => { + if (entry.IsSecret) entry.Value = _aes.Decrypt(entry.Value); + return entry; + }), lastVersion); + } + + /// + public async Task HasChanged(object versionToken = null) + { + using var conn = CreateConnection(); + var mostRecentLastModified = await conn.ExecuteScalarAsync(LastVersionQuery); + if (mostRecentLastModified.HasValue && versionToken != null) + return mostRecentLastModified.Value.CompareTo(versionToken) != 0; + return mostRecentLastModified.HasValue; + } + + /// + /// create new + /// + /// + private SqlConnection CreateConnection() => new SqlConnection(_options.ConnectionString); + + private string SelectAllQuery => $"SELECT * FROM {_options.TableName}"; + + private string LastVersionQuery => $"SELECT TOP 1 LastModifiedOn FROM {_options.TableName} ORDER BY LastModifiedOn DESC"; + } +} diff --git a/src/TinyConfig.SqlServer/Cryptography/AesCrypto.cs b/src/TinyConfig.SqlServer/Cryptography/AesCrypto.cs new file mode 100644 index 0000000..ebf832c --- /dev/null +++ b/src/TinyConfig.SqlServer/Cryptography/AesCrypto.cs @@ -0,0 +1,114 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace TinyConfig.SqlServer.Cryptography +{ + /// + /// AES helper + /// + public class AesCrypto + { + private readonly byte[] _passwordBytes; + private const int SALT_SIZE = 16; + + /// + /// creates a new + /// + /// + public AesCrypto(string password) + { + _passwordBytes = Encoding.UTF8.GetBytes(password); + } + + #region helper methods + private static byte[] GenerateSalt() + { + // initialize a byte array to hold salt + var data = new byte[SALT_SIZE]; + // create an instance of random number generator + using var rng = new RNGCryptoServiceProvider(); + // generate random numbers and fill salt with the generated value + for (int i = 0; i < 10; i++) rng.GetBytes(data); + // return filled byte array + return data; + } + + private RijndaelManaged CreateAes(byte[] salt) + { + const int keySize = 256; + const int blockSize = 128; + const int ITERATIONS = 200; + // create key from password and salt + var key = new Rfc2898DeriveBytes(_passwordBytes, salt, ITERATIONS); + + // create an managed aes instance + return new RijndaelManaged() + { + KeySize = keySize, + BlockSize = blockSize, + Mode = CipherMode.CBC, + Padding = PaddingMode.PKCS7, + Key = key.GetBytes(keySize / 8), + IV = key.GetBytes(blockSize / 8) + }; + } + #endregion + + #region Encrypt + /// + /// encrypt and return encrypted string + /// + /// + public string Encrypt(string data) + { + // generate salt, unique per call + var salt = GenerateSalt(); + + // create an managed aes instance + var aes = CreateAes(salt); + + // write salt to output file + using var resultStream = new MemoryStream(); + resultStream.Write(salt, 0, salt.Length); + + // create a crypto stream in write mode with outputfile and configured aes instance + using(var cryptoStream = new CryptoStream(resultStream, aes.CreateEncryptor(), CryptoStreamMode.Write, true)) + { + using var swEncrypt = new StreamWriter(cryptoStream, Encoding.UTF8); + //Write all data to the stream. + swEncrypt.Write(data); + } + + resultStream.Position = 0; + return Convert.ToBase64String(resultStream.ToArray()); + } + #endregion + + #region Decrypt + /// + /// decrypt and return decrypted string + /// + public string Decrypt(string data) + { + var plainText = string.Empty; + var salt = new byte[SALT_SIZE]; + using var inputStream = new MemoryStream(Convert.FromBase64String(data)); + inputStream.Read(salt, 0, salt.Length); + // create an managed aes instance + var aes = CreateAes(salt); + + // create a crypto stream in read mode with input file and configured aes instance + using(var cryptoStream = new CryptoStream(inputStream, aes.CreateDecryptor(), CryptoStreamMode.Read, true)) + { + using var srDecrypt = new StreamReader(cryptoStream); + // Read the decrypted bytes from the decrypting stream + // and place them in a string. + plainText = srDecrypt.ReadToEnd(); + } + return plainText; + } + #endregion + } +} diff --git a/src/TinyConfig.SqlServer/Extensions/ConfigurationBuilderExtensions.cs b/src/TinyConfig.SqlServer/Extensions/ConfigurationBuilderExtensions.cs new file mode 100644 index 0000000..718f67e --- /dev/null +++ b/src/TinyConfig.SqlServer/Extensions/ConfigurationBuilderExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using TinyConfig.Core.Extensions; +using TinyConfig.SqlServer.Core; + +namespace TinyConfig.SqlServer.Extensions +{ + /// + /// configuration builder extensions + /// + public static class ConfigurationBuilderExtensions + { + /// + /// add db configuration to list of config + /// + /// + /// existing configuration value + /// logger instance + /// + public static IConfigurationBuilder AddSqlServerConfig( + this IConfigurationBuilder builder, IConfiguration configuration, + ILogger logger = null) => + builder.AddTinyConfig(configuration, logger); + } +} diff --git a/src/TinyConfig.SqlServer/Extensions/ServiceCollectionExtensions.cs b/src/TinyConfig.SqlServer/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..1d82e63 --- /dev/null +++ b/src/TinyConfig.SqlServer/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TinyConfig.Core.Extensions; +using TinyConfig.SqlServer.Core; + +namespace TinyConfig.SqlServer.Extensions +{ + /// + /// service collection extensions + /// + public static class ServiceCollectionExtensions + { + /// + /// add database configuration reloader + /// + /// + /// + /// + public static IServiceCollection AddSqlServerConfigReloader(this IServiceCollection services, + IConfiguration configuration) => + services.AddTinyConfigReloader(configuration); + } +} diff --git a/src/TinyConfig.SqlServer/TinyConfig.SqlServer.csproj b/src/TinyConfig.SqlServer/TinyConfig.SqlServer.csproj new file mode 100644 index 0000000..c9bdd63 --- /dev/null +++ b/src/TinyConfig.SqlServer/TinyConfig.SqlServer.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.1 + + + + + + + + + + + + +