Browse Source

Implemented a basic https certificate issuing using lets encrypt

Marcel Baumgartner 1 year ago
parent
commit
732abe53e0

+ 29 - 0
Moonlight/App/Configuration/ConfigV1.cs

@@ -26,6 +26,8 @@ public class ConfigV1
         [Description("Specify the latency threshold which has to be reached in order to trigger the warning message")]
         public int LatencyCheckThreshold { get; set; } = 1000;
 
+        [JsonProperty("LetsEncrypt")] public LetsEncrypt LetsEncrypt { get; set; } = new();
+
         [JsonProperty("Auth")] public AuthData Auth { get; set; } = new();
 
         [JsonProperty("Database")] public DatabaseData Database { get; set; } = new();
@@ -63,6 +65,33 @@ public class ConfigV1
         [JsonProperty("Tickets")] public TicketsData Tickets { get; set; } = new();
     }
     
+    public class LetsEncrypt
+    {
+        [JsonProperty("Enable")]
+        [Description("Enable automatic lets encrypt certificate issuing")]
+        public bool Enable { get; set; } = false;
+        
+        [JsonProperty("ExpireEmail")]
+        [Description("Lets encrypt will send you an email upon certificate expiration to this address")]
+        public string ExpireEmail { get; set; } = "your@email.test";
+        
+        [JsonProperty("CountryCode")]
+        [Description("Country code to use for generating the certificate")]
+        public string CountryCode { get; set; } = "DE";
+        
+        [JsonProperty("State")]
+        [Description("State to use for generating the certificate")]
+        public string State { get; set; } = "Germany";
+        
+        [JsonProperty("Locality")]
+        [Description("Locality to use for generating the certificate")]
+        public string Locality { get; set; } = "Bavaria";
+        
+        [JsonProperty("Organization")]
+        [Description("Organization to use for generating the certificate")]
+        public string Organization { get; set; } = "Moonlight Panel";
+    }
+    
     public class TicketsData
     {
         [JsonProperty("WelcomeMessage")]

+ 7 - 2
Moonlight/App/Events/EventSystem.cs

@@ -113,13 +113,18 @@ public class EventSystem
         return Task.CompletedTask;
     }
     
-    public Task<T> WaitForEvent<T>(string id, object handle, Func<T, bool> filter)
+    public Task<T> WaitForEvent<T>(string id, object handle, Func<T, bool>? filter = null)
     {
         var taskCompletionSource = new TaskCompletionSource<T>();
     
         Func<T, Task> action = async data =>
         {
-            if (filter.Invoke(data))
+            if (filter == null)
+            {
+                taskCompletionSource.SetResult(data);
+                await Off(id, handle);
+            }
+            else if(filter.Invoke(data))
             {
                 taskCompletionSource.SetResult(data);
                 await Off(id, handle);

+ 33 - 0
Moonlight/App/Http/Controllers/WellKnown/AcmeController.cs

@@ -0,0 +1,33 @@
+using Microsoft.AspNetCore.Mvc;
+using Moonlight.App.Events;
+using Moonlight.App.Services;
+
+namespace Moonlight.App.Http.Controllers.WellKnown;
+
+[ApiController]
+[Route(".well-known/acme-challenge")]
+public class AcmeController : Controller
+{
+    private readonly LetsEncryptService LetsEncryptService;
+    private readonly EventSystem Event;
+
+    public AcmeController(LetsEncryptService letsEncryptService, EventSystem eventSystem)
+    {
+        LetsEncryptService = letsEncryptService;
+        Event = eventSystem;
+    }
+
+    [HttpGet("{token}")]
+    public async Task<ActionResult> Get([FromRoute] string token)
+    {
+        if (string.IsNullOrEmpty(LetsEncryptService.HttpChallenge) || string.IsNullOrEmpty(LetsEncryptService.HttpChallengeToken))
+            return Problem();
+
+        if (string.IsNullOrEmpty(token) || LetsEncryptService.HttpChallengeToken != token)
+            return Problem();
+
+        await Event.Emit("letsEncrypt.challengeFetched");
+
+        return Ok(LetsEncryptService.HttpChallenge);
+    }
+}

+ 0 - 1
Moonlight/App/Services/ConfigService.cs

@@ -17,7 +17,6 @@ public class ConfigService
     public ConfigService(StorageService storageService)
     {
         StorageService = storageService;
-        StorageService.EnsureCreated();
 
         if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ML_CONFIG_PATH")))
             Path = Environment.GetEnvironmentVariable("ML_CONFIG_PATH")!;

+ 1 - 0
Moonlight/App/Services/Files/StorageService.cs

@@ -13,6 +13,7 @@ public class StorageService
         Directory.CreateDirectory(PathBuilder.Dir("storage", "backups"));
         Directory.CreateDirectory(PathBuilder.Dir("storage", "logs"));
         Directory.CreateDirectory(PathBuilder.Dir("storage", "plugins"));
+        Directory.CreateDirectory(PathBuilder.Dir("storage", "certs"));
 
         await UpdateResources();
 

+ 173 - 0
Moonlight/App/Services/LetsEncryptService.cs

@@ -0,0 +1,173 @@
+using System.Security.Cryptography.X509Certificates;
+using Certes;
+using Certes.Acme;
+using Microsoft.AspNetCore.Connections;
+using Microsoft.AspNetCore.Http.Connections;
+using Moonlight.App.Events;
+using Moonlight.App.Helpers;
+
+namespace Moonlight.App.Services;
+
+public class LetsEncryptService
+{
+    private readonly ConfigService ConfigService;
+    private readonly string LetsEncryptCertPath;
+    private readonly EventSystem Event;
+    private X509Certificate2 Certificate;
+
+    public string HttpChallenge { get; private set; } = "";
+    public string HttpChallengeToken { get; private set; } = "";
+
+    public LetsEncryptService(ConfigService configService, EventSystem eventSystem)
+    {
+        ConfigService = configService;
+        Event = eventSystem;
+        LetsEncryptCertPath = PathBuilder.File("storage", "certs", "letsencrypt.pfx");
+    }
+
+    public async Task AutoProcess()
+    {
+        if (!ConfigService.Get().Moonlight.LetsEncrypt.Enable)
+            return;
+
+        if (await CheckNeedsRenewal())
+        {
+            try
+            {
+                await Renew();
+            }
+            catch (Exception e)
+            {
+                Logger.Error("Unable to issue lets encrypt certificate");
+                Logger.Error(e);
+            }
+        }
+        else
+            Logger.Info("Skipping lets encrypt renewal");
+
+        await LoadCertificate();
+    }
+
+    private Task LoadCertificate()
+    {
+        try
+        {
+            Certificate = new X509Certificate2(
+                LetsEncryptCertPath, 
+                ConfigService.Get().Moonlight.Security.Token
+            );
+            
+            Logger.Info($"Loaded ssl certificate. '{Certificate.FriendlyName}' issued by '{Certificate.IssuerName.Name}'");
+        }
+        catch (Exception e)
+        {
+            Logger.Warn("Unable to load ssl certificates");
+            Logger.Warn(e);
+        }
+        
+        return Task.CompletedTask;
+    }
+    
+    private async Task Renew()
+    {
+        Logger.Info("Renewing lets encrypt certificate");
+        
+        var uri = new Uri(ConfigService.Get().Moonlight.AppUrl);
+        var config = ConfigService.Get().Moonlight.LetsEncrypt;
+
+        if (uri.HostNameType == UriHostNameType.IPv4 || uri.HostNameType == UriHostNameType.IPv6)
+        {
+            Logger.Warn("You cannot use an ip to issue a lets encrypt certificate");
+            return;
+        }
+
+        var acmeContext = new AcmeContext(WellKnownServers.LetsEncryptV2);
+
+        Logger.Info($"Starting lets encrypt certificate issuing. Using acme server '{acmeContext.DirectoryUri}'");
+
+        var account = await acmeContext.NewAccount(config.ExpireEmail, true);
+
+        Logger.Info("Creating order");
+        var order = await acmeContext.NewOrder(new[] { uri.Host });
+        var authZ = (await order.Authorizations()).First();
+
+        var challenge = await authZ.Http();
+
+        HttpChallengeToken = challenge.Token;
+        HttpChallenge = challenge.KeyAuthz;
+
+        Logger.Info("Waiting for http challenge to complete");
+
+        Task.Run(async () =>
+        {
+            await Task.Delay(TimeSpan.FromSeconds(3));
+
+            try
+            {
+                await challenge.Validate();
+            }
+            catch (Exception e)
+            {
+                Logger.Error("Unable to validate challenge");
+                Logger.Error(e);
+            }
+        });
+
+        await Event.WaitForEvent<Object>("letsEncrypt.challengeFetched", this);
+
+        Logger.Info("Generating certificate");
+
+        var privateKey = KeyFactory.NewKey(KeyAlgorithm.ES256);
+
+        var certificate = await order.Generate(new CsrInfo
+        {
+            CountryName = config.CountryCode,
+            State = config.State,
+            Locality = config.Locality,
+            Organization = config.Organization,
+            OrganizationUnit = "Dev",
+            CommonName = uri.Host
+        }, privateKey);
+
+        var builder = certificate.ToPfx(privateKey);
+
+        var certBytes = builder.Build(
+            uri.Host,
+            ConfigService.Get().Moonlight.Security.Token
+        );
+
+        Logger.Info($"Saved lets encrypt certificate to '{LetsEncryptCertPath}'");
+        await File.WriteAllBytesAsync(LetsEncryptCertPath, certBytes);
+    }
+
+    private Task<bool> CheckNeedsRenewal()
+    {
+        if (!File.Exists(LetsEncryptCertPath))
+        {
+            Logger.Info("No lets encrypt certificate found");
+            return Task.FromResult(true);
+        }
+
+        var existingCert = new X509Certificate2(LetsEncryptCertPath, ConfigService.Get().Moonlight.Security.Token);
+        var expirationDate = existingCert.NotAfter;
+
+        if (DateTime.Now < expirationDate)
+        {
+            Logger.Info($"Lets encrypt certificate valid until {Formatter.FormatDate(expirationDate)}");
+            return Task.FromResult(false);
+        }
+
+        Logger.Info("Lets encrypt certificate expired");
+        return Task.FromResult(true);
+    }
+
+    public X509Certificate2? SelectCertificate(ConnectionContext? context, string? domain)
+    {
+        if (context == null)
+            return null;
+        
+        Logger.Info(domain);
+
+        return Certificate;
+    }
+}

+ 1 - 0
Moonlight/Moonlight.csproj

@@ -17,6 +17,7 @@
     <PackageReference Include="BlazorDownloadFile" Version="2.4.0.2" />
     <PackageReference Include="BlazorMonaco" Version="2.1.0" />
     <PackageReference Include="BlazorTable" Version="1.17.0" />
+    <PackageReference Include="Certes" Version="3.0.4" />
     <PackageReference Include="CloudFlare.Client" Version="6.1.4" />
     <PackageReference Include="CurrieTechnologies.Razor.SweetAlert2" Version="5.5.0" />
     <PackageReference Include="Discord.Net" Version="3.10.0" />

+ 20 - 3
Moonlight/Program.cs

@@ -114,6 +114,17 @@ namespace Moonlight
 
             var builder = WebApplication.CreateBuilder(args);
 
+            var eventSystem = new EventSystem();
+            var letsEncryptService = new LetsEncryptService(configService, eventSystem);
+
+            builder.WebHost.ConfigureKestrel(options =>
+            {
+                options.ConfigureHttpsDefaults(httpsOptions =>
+                {
+                    httpsOptions.ServerCertificateSelector = letsEncryptService.SelectCertificate;
+                }); 
+            });
+
             var pluginService = new PluginService();
             await pluginService.BuildServices(builder.Services);
 
@@ -176,7 +187,7 @@ namespace Moonlight
             builder.Services.AddScoped(typeof(Repository<>));
 
             // Services
-            builder.Services.AddSingleton<ConfigService>();
+            builder.Services.AddSingleton(configService);
             builder.Services.AddSingleton<StorageService>();
             builder.Services.AddScoped<CookieService>();
             builder.Services.AddScoped<IdentityService>();
@@ -200,7 +211,7 @@ namespace Moonlight
             builder.Services.AddScoped<WebSpaceService>();
             builder.Services.AddScoped<StatisticsViewService>();
             builder.Services.AddSingleton<DateTimeService>();
-            builder.Services.AddSingleton<EventSystem>();
+            builder.Services.AddSingleton(eventSystem);
             builder.Services.AddScoped<FileDownloadService>();
             builder.Services.AddScoped<ForgeService>();
             builder.Services.AddScoped<FabricService>();
@@ -254,6 +265,7 @@ namespace Moonlight
             builder.Services.AddSingleton<TempMailService>();
             builder.Services.AddSingleton<DdosProtectionService>();
             builder.Services.AddSingleton(pluginService);
+            builder.Services.AddSingleton(letsEncryptService);
             
             // Other
             builder.Services.AddSingleton<MoonlightService>();
@@ -310,7 +322,12 @@ namespace Moonlight
             // Discord bot service
             //var discordBotService = app.Services.GetRequiredService<DiscordBotService>();
 
-            await app.RunAsync();
+            Task.Run(async () =>
+            {
+                await letsEncryptService.AutoProcess();
+            });
+            
+            await app.RunAsync(configService.Get().Moonlight.AppUrl);
         }
     }
 }