localghost
localghost

Reputation: 409

ASP.NET Core receive custom config via HTTP to configure Kestrel

This feels like a simple problem to solve, but in all my searching I'm still yet to find a solution that works for me. Could be another case of not finding what I'm after as I'm not searching the right 'thing', but here we are..

I have a C# Web API program where I want to configure the kestrel server from a config object.

I receive this config into my service via rest call, into a CustomConfig object. I can get this config object either in Program.cs or in Startup.cs, but since I don't want to repeat myself and make additional calls, I don't want to do this in both places.

My preference is to get the config in Startup.cs since that's where the rest of my configuration code sits, and is where I'm already using my CustomConfig object. However, I can't find a way to configure the kestrel server to use the certificate I'm giving it (in Startup.cs), nor can I see a way to inject this config into Startup.cs from Program.cs.

In other projects I have passed the location of the PFX file as an environment variable: ASPNETCORE_Kestrel__Certificates__Default__Path (in which case everything works without additional code config), but in this project all config must be retrieved via rest call, so this is not an option here.

I currently have everything running, but only by making the rest call to get config twice. The current implementation to configure kestrel is storing the PFX in CustomConfig as a base64 string, and configuring in Program.cs:

public static IHostBuilder CreateHostBuilder(string[] args)
        {
            return Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    CustomConfig config = CustomConfig() // <- I receive config here
                    webBuilder.UseStartup<Startup>();
                    webBuilder.UseKestrel(options =>
                    {
                       options.ConfigureHttpsDefaults(d =>
                       {
                           byte[] pfxBytes = Convert.FromBase64String(config.Base64PFXBytes);
                           d.ServerCertificate = new X509Certificate2(pfxBytes, "sslKey");
                       });
                    });
                });
        }

To summarise..

So I'm looking for help to either:

Hopefully that makes sense.. Welcome any & all solutions / additional questions for clarity!

Thanks in advance!

Upvotes: 3

Views: 1648

Answers (1)

abdusco
abdusco

Reputation: 11081

ASP.NET Core abstracts configuration using IConfiguration interface. Without going into details, it collects configurations from various IConfigurationSource and layers them on top of each other, which lets us override a setting that's defined in one source in another source by defining it with the same key.

1. Implementing an IConfigurationSource

Let's implement an IConfigurationSource. We can use ConfigurationSource abstract class as our starting point. We'll use an in-memory implementation, then switch to a remote source.

class RemoteConfigurationSource : IConfigurationSource
{
    public IConfigurationProvider Build(IConfigurationBuilder builder)
    {
        return new RemoteConfigurationProvider(_options);
    }

    private class RemoteConfigurationProvider : ConfigurationProvider
    {
        public override void Load()
        {
            // TODO: fetch data from the API
            var remoteConfig = new Dictionary<string, string>
            {
                { "CertificateOptions:PfxBase64", "MIIKkQIBAz....gfQ" },
                { "CertificateOptions:Password", "secret" },
            };
            Data = remoteConfig;
        }
    }
}

Then add this to the configuration builder in the ConfigureHostConfiguration callback:

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureHostConfiguration(builder =>
            {
                // add new source
                builder.AddRemoteConfiguration();
            })
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>().UseKestrel((context, options) =>
                {
                    var certificateOptions = context.Configuration
                        .GetSection(KestrelCertificateOptions.ConfigurationKey)
                        .Get<KestrelCertificateOptions>();
                    options.ConfigureHttpsDefaults(adapterOptions =>
                        adapterOptions.ServerCertificate = certificateOptions.Certificate);
                });
            });
}

public static class ConfigurationBuilderExtensions
{
    public static IConfigurationBuilder AddRemoteConfiguration(this IConfigurationBuilder builder) =>
        builder.Add(new RemoteConfigurationSource());
}

class KestrelCertificateOptions
{
    public const string ConfigurationKey = "CertificateOptions";
    public string PfxBase64 { get; set; }
    public string Password { get; set; }
    public X509Certificate2 Certificate => new X509Certificate2(Convert.FromBase64String(PfxBase64), Password);
}

When we run the app, ASP.NET Core will load and use our in-memory configuration.

2. Fetching configuration data from an API

Now let's fetch the config from a remote API. It needs to return configuration values with sections names delimited with with a colon :. Here's the same config as JSON, filed under CertificateOptions section:

{
  "CertificateOptions:PfxBase64": "MII....oCAgfQ",
  "CertificateOptions:Password": "secret"
}

Assume the API wraps returns this data wrapped as:

{
  "Application": "MyApp",
  "LastChanged": "2021-08-09 14:38:00",
  "Data": {
    "CertificateOptions:PfxBase64": "MIIK...oCAgfQ",
    "CertificateOptions:Password": "secret"
  }
}

so we need to take only Data key into account when fetching the data.

class RemoteConfigurationSource : IConfigurationSource
{
    public IConfigurationProvider Build(IConfigurationBuilder builder)
    {
        return new RemoteConfigurationProvider();
    }

    private class RemoteConfigurationProvider : ConfigurationProvider
    {
        public override void Load()
        {
            // We cannot await this method, so have to do sync-over-async. 
            // Not an issue, because it's a one-time thing.
            var result = LoadRemoteConfig().GetAwaiter().GetResult();
            Data = result.Data;
        }

        private async Task<RemoteConfigResult> LoadRemoteConfig()
        {
            // We cannot use IHttpClientFactory here, since ServiceProvider isn't even built yet.
            using var httpClient = new HttpClient();
            // ... add headers, token to request
            return await httpClient.GetFromJsonAsync<RemoteConfigResult>("https://example.com/path/to/json");
        }
    }

    private class RemoteConfigResult
    {
        public Dictionary<string, string> Data { get; set; }
    }
}

To clean things up a bit, we can move the URL and other credentials to appsettings.json:

{
  "Logging": {
    /*...*/
  },
  "RemoteConfiguration": {
    "Url": "https://jsonkeeper.com/b/B78I",
    "ApplicationId": "myconfigappid",
    "Secret": "myconfigapisecret"
  }
}

Then build a temporary IConfiguration add as many sources as necessary, then fetch these values:

// Read credentials from appsettings.json
var remoteConfigurationOptions = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json", optional: false)
    .Build()
    .GetSection(RemoteConfigurationOptions.ConfigurationKey)
    .Get<RemoteConfigurationOptions>();
    
public class RemoteConfigurationOptions
{
    public const string ConfigurationKey = "RemoteConfiguration";
    public string Url { get; set; }
    public string ApplicationId { get; set; }
    public string Secret { get; set; }
}

Then pass this object to our configuration source, which in turns passes it down to the configuration provider

Upvotes: 6

Related Questions