Monitoring SSL Certificate Expiry's with a .NET 6 Minimal API and Slack

Monitoring SSL Certificate Expiry's with a .NET 6 Minimal API and Slack

ยท

8 min read

We are going to be looking at creating:

  • A .NET 6 Minimal API to return SSL Certificate expiry date information on a list of sites.
  • A slack command to get the data from our API and display it in slack!

It has probably happened to every company at some point -> an SSL certificate expires and then your services start failing. It happens to the big players too:

Basically, it can happen to absolutely anyone and any company, because companies are run by people and people are forgetful!

Although there are automated solutions out there for managing SSL renewal -> e.g. Lets Encrypt letsencrypt.org, companies should still have a technical solution in place to make sure the right people can quickly see the expiry dates of all SSL certificates under management.

Okay, lets get into it...

Building our API - [GET] /ssl endpoint

So firstly, you will need the .NET 6 SDK installed - you can grab it from here. So what is a Minimal API? How does this differ from your standard .NET Web API? From the horses mouth:

Minimal APIs are architected to create HTTP APIs with minimal dependencies. They are ideal for microservices and apps that want to include only the minimum files, features, and dependencies in ASP.NET Core.

You can create your a .NET 6 Minimal API directly through Visual Studio 2022 or through the .NET CLI.

  • If you are doing this over the CLI, simply just run dotnet new web -o MinApi and you are ready to go!
  • If you are doing this through VS2022, create a new .NET Core Web API project, fill out the usual project name details, and then on the final create screen, deselect "Use Controllers (uncheck to use minimal APIs)

image.png

You will now have a default .NET 6 Minimal API, with all code simply defined in the Program.cs. By default, this also installs Swagger (A UI explorer for your endpoints).

image.png

As usual, Microsoft has scaffolded us an example endpoint to return some sample data about the weather, available at ->

/weatherforecast

image.png.

I'm going to delete the following lines as I don't really need to expose the Swagger UI for this small project:

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

Im also going to delete the sample /weatherforecast endpoint and the summaries array that contains some different temperature descriptions.

Im now thinking about what sites I need to monitor. I don't want to use a database to store these as I want to keep this as lightweight as possible, so Im just going to store these in the appsettings.json. Create a new array in here to store your values

  "Sites": [
    "https://www.google.com",
    "https://www.reddit.com"
  ]

After deleting everything I didn't want, my entire application (Program.cs) looks like this (Yes an API in 5 lines of code!):

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseHttpsRedirection();
app.MapGet("/ssl", () => { return "Up and running!"; });
app.Run();

Okay now lets build out the /ssl endpoint that is going to monitor the SSL Expiry dates for the above websites. For this we will be using HttpClient, HttpClientHandler(specifically the ServerCertificateCustomValidationCallback Func where we will get the expiry status!).

   DateTime expiry = DateTime.UtcNow;
    var httpClientHandler = new HttpClientHandler
    {
        ServerCertificateCustomValidationCallback = (request, cert, chain, policyErrors) =>
        {
            expiry = cert.NotAfter;
            return true;
        }
    };
    using HttpClient httpClient = new HttpClient(httpClientHandler);

Here we are creating an instance of httpClientHandler and returning a callback method with validates the server certificate, and then returning true if the NotAfter Property (A DateTime object that represents the expiration date of the certificate) is greater than the current DateTime!

Lets firstly test this on a hardcoded site!

        await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, "https://www.google.com"));
        return "Expiry is " + expiry;

My full endpoint is now:

app.MapGet("/ssl", async () =>
{
    DateTime expiry = DateTime.UtcNow;
    var httpClientHandler = new HttpClientHandler
    {
        ServerCertificateCustomValidationCallback = (request, cert, chain, policyErrors) =>
        {
            expiry = cert.NotAfter;
            return true;
        }
    };
    using HttpClient httpClient = new HttpClient(httpClientHandler);
    await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, "https://www.google.com"));
    return "Expiry is: " + expiry;
});

Lets run the API and see what we get...

image.png

Comparing this to the actual certificate...

image.png

Looks good to me! ๐Ÿ‘

Right, now lets change our endpoint a bit to use the website values we set in appsettings.json

We can do this by using the default builder.Configuration:

var sites = builder.Configuration.GetSection("Sites").Get<List<string>>();

Now we just have to loop through each site in our list of sites! I am also going to add the site url and its expiry date to a List after each iteration, and then return the our List as json!

Our endpoint now looks like...

app.MapGet("/ssl", async () =>
{
    DateTime expiry = DateTime.UtcNow;
    var httpClientHandler = new HttpClientHandler
    {
        ServerCertificateCustomValidationCallback = (request, cert, chain, policyErrors) =>
        {
            expiry = cert.NotAfter;
            return true;
        }
    };
    using HttpClient httpClient = new HttpClient(httpClientHandler);
    List<string> expiryValues = new List<string>();  
    foreach(var site in sites)
    {
        await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, site));
        expiryValues.Add(site + " - " + expiry);
    }
    return JsonSerializer.Serialize(expiryValues);
});

Running the API now gives us the SSL expiry date of each site in our appsettings.json Sites array!

image.png

Slack Integration Part 1 - Creating an endpoint for slack to post data too

The documentation for the Slack Slash Command API can be found here api.slack.com/interactivity/slash-commands

Slack sends a post request to our application with the following data

image.png

So firstly, lets create a C# object that will represent this:

namespace SslMinimalAPI.Models;

public class SlackSlashCommandRequest
{
    public string Token { get; set; }

    public string TeamId { get; set; }

    public string TeamDomain { get; set; }

    public string EnterpriseId { get; set; }

    public string EnterpriseName { get; set; }

    public string ChannelId { get; set; }

    public string ChannelName { get; set; }

    public string UserId { get; set; }

    public string UserName { get; set; }

    public string Command { get; set; }

    public string Text { get; set; }

    public string ResponseUrl { get; set; }

    public string TriggerId { get; set; }

}

We are also using the Scoped Namespace feature that is apart of C# 10 -> Less boilerplate, less curly braces = better readability!

Now, we could just alter our original endpoint to match HttpPost requests, and to take a SlackSlashCommandRequest object, and keep our whole "application" in one endpoint, however I still want my original endpoint so let's create a new endpoint to accept the slack request.

For now lets just return a hardcoded string...

app.MapPost("/slack", () =>
{
    return "Hello slack!";
}).Accepts<SlackSlashCommandRequest>("application/x-www-form-urlencoded");

Before we move onto the slack app creation, we need expose our API to the internet. For now I am just going to expose our API through a tunnel to localhost via NGROK ๐Ÿ‘

Installing Ngrok

Following the instructions at https://ngrok.com/download and run it (against the same port your API is at):

./ngrok http https://localhost:7105

Your API will now be exposed to the internet!

image.png

Slack Integration Part 2 - Creating the Slack Application

I now want to create a slack command that will return our message, and later return our SSL checker data! Let's create an Slack Command application

Firstly go to Slack and create a new app - https://api.slack.com/apps/

image.png

image.png

image.png

In the URL, enter your Ngrok URL, looking at the /slack endpoint we just created.

E.g. 248d-2-121-89-69.ngrok.io/slack

Now install your slack command into your slack workspace!

image.png

Running our /ssl command now returns the string we defined in our /slack endpoint!

image.png

Now lets grab our actual data!

Firstly I am going to refractor the logic to grab the SSL expiry dates away from just the /ssl endpoint and into its own method

/// <summary>
/// Returns SSL Expiry Data Info from the list of sites defined in our Sites[] array in appsettings
/// </summary>
async Task<List<string>> GetSslData()
{
    DateTime expiry = DateTime.UtcNow;
    var httpClientHandler = new HttpClientHandler
    {
        ServerCertificateCustomValidationCallback = (request, cert, chain, policyErrors) =>
        {
            expiry = cert.NotAfter;
            return true;
        }
    };
    using HttpClient httpClient = new HttpClient(httpClientHandler);
    List<string> expiryValues = new List<string>();
    foreach (var site in sites)
    {
        await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, site));
        expiryValues.Add(site + " - " + expiry);
    }
    return expiryValues ;
}

Now lets update our /ssl endpoint to use this

/// <summary>
/// Returns our SSL Expiry Data as JSON
/// </summary>
app.MapGet("/ssl", async () =>
{
    var sites = await GetSslData();  
    return JsonSerializer.Serialize(sites);
});

The reason for this is because our /slack endpoint is also going to use this new method. Lets grab our SSL Data from our new method, and deserialize this to get a list of strings. We can create a bullet point list in slack just using the bullet point character and passing in a \n in a string. That works for me so lets do that! ๐Ÿ‘

/// <summary>
/// An endpoint for slack to post our slack command too, and for us to return our message
/// </summary>
app.MapPost("/slack", async () =>
{
    var sslSiteList = await GetSslData();
    StringBuilder sb = new StringBuilder();

    if(sslSiteList == null || sslSiteList.Count == 0)
    {
        return "No sites found";
    }

    foreach(var sslSite in sslSiteList)
    {
        sb.Append("โ€ข " + sslSite+"\n");
    }

    return sb.ToString();
}).Accepts<SlackSlashCommandRequest>("application/x-www-form-urlencoded");

Lets run our /ssl command in slack once more to see if it all works...

image.png

Not too shabby! The full source is available on Github ๐Ÿ’ป

ย