diff --git a/Source/Automatic Parking.sln b/Source/Automatic Parking.sln
index 9e928dc..3939122 100644
--- a/Source/Automatic Parking.sln
+++ b/Source/Automatic Parking.sln
@@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
compose.yaml = compose.yaml
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CertificateManager", "CertificateManager\CertificateManager.csproj", "{8118AB3F-CF86-4B17-9C0B-E27C37FCE638}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -17,5 +19,9 @@ Global
{93F01B86-2434-42E2-AE67-774BA61CFF7B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{93F01B86-2434-42E2-AE67-774BA61CFF7B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{93F01B86-2434-42E2-AE67-774BA61CFF7B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8118AB3F-CF86-4B17-9C0B-E27C37FCE638}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8118AB3F-CF86-4B17-9C0B-E27C37FCE638}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8118AB3F-CF86-4B17-9C0B-E27C37FCE638}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8118AB3F-CF86-4B17-9C0B-E27C37FCE638}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/Source/CertificateManager/CertificateManager.csproj b/Source/CertificateManager/CertificateManager.csproj
new file mode 100644
index 0000000..dc04320
--- /dev/null
+++ b/Source/CertificateManager/CertificateManager.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+ true
+ dotnet-CertificateManager-9dd1279b-a435-420b-b714-c1f017b1b0df
+ Linux
+
+
+
+
+
+
+
+
+
+
+ .dockerignore
+
+
+
diff --git a/Source/CertificateManager/Dockerfile b/Source/CertificateManager/Dockerfile
new file mode 100644
index 0000000..f25d5a8
--- /dev/null
+++ b/Source/CertificateManager/Dockerfile
@@ -0,0 +1,21 @@
+FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
+USER $APP_UID
+WORKDIR /app
+
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+COPY ["CertificateManager/CertificateManager.csproj", "CertificateManager/"]
+RUN dotnet restore "CertificateManager/CertificateManager.csproj"
+COPY . .
+WORKDIR "/src/CertificateManager"
+RUN dotnet build "./CertificateManager.csproj" -c $BUILD_CONFIGURATION -o /app/build
+
+FROM build AS publish
+ARG BUILD_CONFIGURATION=Release
+RUN dotnet publish "./CertificateManager.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "CertificateManager.dll"]
diff --git a/Source/CertificateManager/Models/NameComCredentials.cs b/Source/CertificateManager/Models/NameComCredentials.cs
new file mode 100644
index 0000000..106fe56
--- /dev/null
+++ b/Source/CertificateManager/Models/NameComCredentials.cs
@@ -0,0 +1,3 @@
+namespace CertificateManager.Models;
+
+public record NameComCredentials(String Username, String APIToken, string Server = "https://api.name.com/v4");
\ No newline at end of file
diff --git a/Source/CertificateManager/Program.cs b/Source/CertificateManager/Program.cs
new file mode 100644
index 0000000..8e501f2
--- /dev/null
+++ b/Source/CertificateManager/Program.cs
@@ -0,0 +1,10 @@
+using CertificateManager;
+
+var builder = Host.CreateApplicationBuilder(args);
+builder.Services.AddHostedService();
+
+builder.Logging.ClearProviders();
+builder.Logging.AddConsole();
+
+var host = builder.Build();
+host.Run();
\ No newline at end of file
diff --git a/Source/CertificateManager/Properties/launchSettings.json b/Source/CertificateManager/Properties/launchSettings.json
new file mode 100644
index 0000000..cf4a5e2
--- /dev/null
+++ b/Source/CertificateManager/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "CertificateManager": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "DOTNET_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/Source/CertificateManager/Worker.cs b/Source/CertificateManager/Worker.cs
new file mode 100644
index 0000000..5ef3f10
--- /dev/null
+++ b/Source/CertificateManager/Worker.cs
@@ -0,0 +1,45 @@
+using System.Diagnostics.CodeAnalysis;
+using Certes;
+using Certes.Acme;
+using CertificateManager.Models;
+
+namespace CertificateManager;
+
+public class Worker(ILogger logger, TimeProvider timeProvider, IConfiguration configuration) : BackgroundService
+{
+ [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")]
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ logger.LogTrace("Certificate manager started");
+
+ // Local keys and CA
+ string localDomain = configuration.GetValue("localDomain", "local");
+ string keysPath = configuration.GetValue("keys_path", "/Certificates/Keys");
+ string caPath = configuration.GetValue("ca_path", "/Certificates/CA");
+ string localPath = configuration.GetValue("local_wildcard_path", "/Certificates/Local");
+
+ // Real CA, real domain
+ string domain = configuration.GetValue("domain", "automatic-parking.app");
+ string wildcardPath = configuration.GetValue("wildcard_path", "/Certificates/Wildcard");
+
+ string acmeEmail = configuration.GetValue("acmeEmail", "");
+ logger.LogTrace("Acme email provided: {acmeEmail}", acmeEmail);
+
+ string nameComUsername = configuration.GetValue("nameComUsername", "");
+ string nameComToken = configuration.GetValue("nameComAPIToken", "");
+ string nameComServer = configuration.GetValue("nameComServer", "https://api.name.com/v4");
+ NameComCredentials nameComCredentials = new NameComCredentials(nameComUsername, nameComToken, nameComServer);
+ logger.LogTrace("Name.com credentials provided: {nameComUsername} (with token of {nameComTokenLength} characters)", nameComUsername, nameComToken.Length);
+
+ // Generate keys, CA and certificates
+ var keys = GenerateKeys(keysPath, "private.pem", "public.pem", "chain.pem");
+ var ca = CreateRootCA(keys, caPath, "private.pem", "public.pem");
+ var local = CreateWildcardCertificate(ca, localDomain, localPath, "private.pem", "public.pem", "chain.pem");
+
+ var external = AcquireWildcardCertificate(domain, nameComCredentials, wildcardPath, "private.pem", "public.pem", "chain.pem");
+ DateTimeOffset expiry = external.Expires;
+ logger.LogTrace("Wildcard certificate will expire on {expiry}", expiry);
+
+ await Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/Source/CertificateManager/appsettings.Development.json b/Source/CertificateManager/appsettings.Development.json
new file mode 100644
index 0000000..b2dcdb6
--- /dev/null
+++ b/Source/CertificateManager/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ }
+}
diff --git a/Source/CertificateManager/appsettings.json b/Source/CertificateManager/appsettings.json
new file mode 100644
index 0000000..b2dcdb6
--- /dev/null
+++ b/Source/CertificateManager/appsettings.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ }
+}