Implemented a basic https certificate issuing using lets encrypt

This commit is contained in:
Marcel Baumgartner 2023-09-04 21:58:26 +02:00
parent dda302ebf1
commit 732abe53e0
8 changed files with 264 additions and 6 deletions

View file

@ -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")]

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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")!;

View file

@ -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();

View file

@ -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;
}
}

View file

@ -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" />

View file

@ -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);
}
}
}