diff --git a/Moonlight/App/Configuration/ConfigV1.cs b/Moonlight/App/Configuration/ConfigV1.cs index 4964bbc4..9f7b588f 100644 --- a/Moonlight/App/Configuration/ConfigV1.cs +++ b/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")] diff --git a/Moonlight/App/Events/EventSystem.cs b/Moonlight/App/Events/EventSystem.cs index ee9cc8c2..2994ed9c 100644 --- a/Moonlight/App/Events/EventSystem.cs +++ b/Moonlight/App/Events/EventSystem.cs @@ -113,13 +113,18 @@ public class EventSystem return Task.CompletedTask; } - public Task WaitForEvent(string id, object handle, Func filter) + public Task WaitForEvent(string id, object handle, Func? filter = null) { var taskCompletionSource = new TaskCompletionSource(); Func 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); diff --git a/Moonlight/App/Http/Controllers/WellKnown/AcmeController.cs b/Moonlight/App/Http/Controllers/WellKnown/AcmeController.cs new file mode 100644 index 00000000..0e6c64e9 --- /dev/null +++ b/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 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); + } +} \ No newline at end of file diff --git a/Moonlight/App/Services/ConfigService.cs b/Moonlight/App/Services/ConfigService.cs index 1de055a1..3be2fa22 100644 --- a/Moonlight/App/Services/ConfigService.cs +++ b/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")!; diff --git a/Moonlight/App/Services/Files/StorageService.cs b/Moonlight/App/Services/Files/StorageService.cs index 49fac176..402664ca 100644 --- a/Moonlight/App/Services/Files/StorageService.cs +++ b/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(); diff --git a/Moonlight/App/Services/LetsEncryptService.cs b/Moonlight/App/Services/LetsEncryptService.cs new file mode 100644 index 00000000..7635f695 --- /dev/null +++ b/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("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 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; + } +} \ No newline at end of file diff --git a/Moonlight/Moonlight.csproj b/Moonlight/Moonlight.csproj index d5d61207..73288185 100644 --- a/Moonlight/Moonlight.csproj +++ b/Moonlight/Moonlight.csproj @@ -17,6 +17,7 @@ + diff --git a/Moonlight/Program.cs b/Moonlight/Program.cs index e0b769d4..9b9d1786 100644 --- a/Moonlight/Program.cs +++ b/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(); + builder.Services.AddSingleton(configService); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -200,7 +211,7 @@ namespace Moonlight builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(eventSystem); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -254,6 +265,7 @@ namespace Moonlight builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(pluginService); + builder.Services.AddSingleton(letsEncryptService); // Other builder.Services.AddSingleton(); @@ -310,7 +322,12 @@ namespace Moonlight // Discord bot service //var discordBotService = app.Services.GetRequiredService(); - await app.RunAsync(); + Task.Run(async () => + { + await letsEncryptService.AutoProcess(); + }); + + await app.RunAsync(configService.Get().Moonlight.AppUrl); } } } \ No newline at end of file