using Microsoft.Extensions.Options; using MaksIT.Results; using MaksIT.LetsEncrypt.Entities; using MaksIT.LetsEncrypt.Entities.LetsEncrypt; using MaksIT.LetsEncrypt.Services; using MaksIT.Webapi.Abstractions.Services; namespace MaksIT.Webapi.Services; public interface ICertsFlowService { Result GetTermsOfService(Guid sessionId); Task CompleteChallengesAsync(Guid sessionId); Task> ConfigureClientAsync(bool isStaging); Task> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts); Task?>> NewOrderAsync(Guid sessionId, string[] hostnames, string challengeType); Task GetOrderAsync(Guid sessionId, string[] hostnames); Task GetCertificatesAsync(Guid sessionId, string[] hostnames); Task?>> ApplyCertificatesAsync(Guid accountId); Task RevokeCertificatesAsync(Guid sessionId, string[] hostnames); Task> FullFlow(bool isStaging, Guid? accountId, string description, string[] contacts, string challengeType, string[] hostnames); Task FullRevocationFlow(bool isStaging, Guid accountId, string description, string[] contacts, string[] hostnames); Result AcmeChallenge(string fileName); } public class CertsFlowService( IOptions appSettings, ILogger logger, HttpClient httpClient, ILetsEncryptService letsEncryptService, ICacheService cacheService, IAgentService agentService ) : ServiceBase( logger, appSettings ), ICertsFlowService { private readonly string _acmePath = appSettings.Value.AcmeFolder; public Result GetTermsOfService(Guid sessionId) { var result = letsEncryptService.GetTermsOfServiceUri(sessionId); if (!result.IsSuccess || result.Value == null) return result; var termsOfServiceUrl = result.Value; try { var fileName = Path.GetFileName(new Uri(termsOfServiceUrl).LocalPath); var termsOfServicePdfPath = Path.Combine(_appSettings.DataFolder, fileName); foreach (var file in Directory.GetFiles(_appSettings.DataFolder, "*.pdf")) { if (!string.Equals(Path.GetFileName(file), fileName, StringComparison.OrdinalIgnoreCase)) { try { File.Delete(file); } catch { /* ignore */ } } } byte[] pdfBytes; if (File.Exists(termsOfServicePdfPath)) { pdfBytes = File.ReadAllBytes(termsOfServicePdfPath); } else { pdfBytes = httpClient.GetByteArrayAsync(termsOfServiceUrl).GetAwaiter().GetResult(); File.WriteAllBytes(termsOfServicePdfPath, pdfBytes); } var base64 = Convert.ToBase64String(pdfBytes); return Result.Ok(base64); } catch (Exception ex) { logger.LogError(ex, "Failed to download, cache, or convert Terms of Service PDF"); return Result.InternalServerError(null, $"Failed to download, cache, or convert Terms of Service PDF: {ex.Message}"); } } public async Task CompleteChallengesAsync(Guid sessionId) { return await letsEncryptService.CompleteChallenges(sessionId); } public async Task> ConfigureClientAsync(bool isStaging) { var sessionId = Guid.NewGuid(); var result = await letsEncryptService.ConfigureClient(sessionId, isStaging); if (!result.IsSuccess) return result.ToResultOfType(default); return Result.Ok(sessionId); } public async Task> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts) { RegistrationCache? cache = null; if (accountId == null) { accountId = Guid.NewGuid(); } else { var cacheResult = await cacheService.LoadAccountFromCacheAsync(accountId.Value); if (!cacheResult.IsSuccess || cacheResult.Value == null) { accountId = Guid.NewGuid(); } else { cache = cacheResult.Value; } } var result = await letsEncryptService.Init(sessionId, accountId.Value, description, contacts, cache); if (!result.IsSuccess) return result.ToResultOfType(default); return Result.Ok(accountId.Value); } public async Task?>> NewOrderAsync(Guid sessionId, string[] hostnames, string challengeType) { var orderResult = await letsEncryptService.NewOrder(sessionId, hostnames, challengeType); if (!orderResult.IsSuccess || orderResult.Value == null) return orderResult.ToResultOfType?>(_ => null); var challenges = new List(); foreach (var kvp in orderResult.Value) { string[] splitToken = kvp.Value.Split('.'); File.WriteAllText(Path.Combine(_acmePath, splitToken[0]), kvp.Value); challenges.Add(splitToken[0]); } return Result?>.Ok(challenges); } public async Task GetCertificatesAsync(Guid sessionId, string[] hostnames) { foreach (var subject in hostnames) { var result = await letsEncryptService.GetCertificate(sessionId, subject); if (!result.IsSuccess) return result; Thread.Sleep(1000); } var cacheResult = letsEncryptService.GetRegistrationCache(sessionId); if (!cacheResult.IsSuccess || cacheResult.Value == null) return cacheResult; var saveResult = await cacheService.SaveToCacheAsync(cacheResult.Value.AccountId, cacheResult.Value); if (!saveResult.IsSuccess) return saveResult; return Result.Ok(); } public async Task GetOrderAsync(Guid sessionId, string[] hostnames) { return await letsEncryptService.GetOrder(sessionId, hostnames); } public async Task?>> ApplyCertificatesAsync(Guid accountId) { var cacheResult = await cacheService.LoadAccountFromCacheAsync(accountId); if (!cacheResult.IsSuccess || cacheResult.Value?.CachedCerts == null) return cacheResult.ToResultOfType?>(_ => null); var cache = cacheResult.Value; var results = cache.GetCertsPemPerHostname(); if (cache.IsDisabled) return Result?>.BadRequest(null, $"Account {accountId} is disabled"); if (cache.IsStaging) return Result?>.UnprocessableEntity(null, $"Found certs for {string.Join(',', results.Keys)} (staging environment)"); var uploadResult = await agentService.UploadCerts(results); if (!uploadResult.IsSuccess) return uploadResult.ToResultOfType?>(default); var reloadResult = await agentService.ReloadService(_appSettings.Agent.ServiceToReload); if (!reloadResult.IsSuccess) return reloadResult.ToResultOfType?>(default); return Result?>.Ok(results); } public async Task RevokeCertificatesAsync(Guid sessionId, string[] hostnames) { foreach (var hostname in hostnames) { var result = await letsEncryptService.RevokeCertificate(sessionId, hostname, RevokeReason.Unspecified); if (!result.IsSuccess) return result; } var cacheResult = letsEncryptService.GetRegistrationCache(sessionId); if (!cacheResult.IsSuccess || cacheResult.Value == null) return cacheResult; var saveResult = await cacheService.SaveToCacheAsync(cacheResult.Value.AccountId, cacheResult.Value); if (!saveResult.IsSuccess) return saveResult; return Result.Ok(); } public async Task> FullFlow(bool isStaging, Guid? accountId, string description, string[] contacts, string challengeType, string[] hostnames) { var sessionResult = await ConfigureClientAsync(isStaging); if (!sessionResult.IsSuccess || sessionResult.Value == null) return sessionResult; var sessionId = sessionResult.Value.Value; var initResult = await InitAsync(sessionId, accountId, description, contacts); if (!initResult.IsSuccess || initResult.Value == null) return initResult.ToResultOfType(_ => null); if (accountId == null) accountId = initResult.Value; var challengesResult = await NewOrderAsync(sessionId, hostnames, challengeType); if (!challengesResult.IsSuccess) { await TryPersistRegistrationCacheFromSessionAsync(sessionId); return challengesResult.ToResultOfType(_ => null); } if (challengesResult.Value?.Count > 0) { var challengeResult = await CompleteChallengesAsync(sessionId); if (!challengeResult.IsSuccess) { await TryPersistRegistrationCacheFromSessionAsync(sessionId); return challengeResult.ToResultOfType(default); } } var getOrderResult = await GetOrderAsync(sessionId, hostnames); if (!getOrderResult.IsSuccess) { await TryPersistRegistrationCacheFromSessionAsync(sessionId); return getOrderResult.ToResultOfType(default); } var certsResult = await GetCertificatesAsync(sessionId, hostnames); if (!certsResult.IsSuccess) { await TryPersistRegistrationCacheFromSessionAsync(sessionId); return certsResult.ToResultOfType(default); } if (!isStaging) { var applyCertsResult = await ApplyCertificatesAsync(accountId.Value); if (!applyCertsResult.IsSuccess) { await TryPersistRegistrationCacheFromSessionAsync(sessionId); return applyCertsResult.ToResultOfType(_ => null); } } return Result.Ok(initResult.Value); } public async Task FullRevocationFlow(bool isStaging, Guid accountId, string description, string[] contacts, string[] hostnames) { var sessionResult = await ConfigureClientAsync(isStaging); if (!sessionResult.IsSuccess || sessionResult.Value == null) return sessionResult; var sessionId = sessionResult.Value.Value; var initResult = await InitAsync(sessionId, accountId, description, contacts); if (!initResult.IsSuccess) return initResult; var revokeResult = await RevokeCertificatesAsync(sessionId, hostnames); if (!revokeResult.IsSuccess) return revokeResult; return Result.Ok(); } public Result AcmeChallenge(string fileName) { DeleteExporedChallenges(); var challengePath = Path.Combine(_acmePath, fileName); if (!File.Exists(challengePath)) return Result.NotFound(null); var fileContent = File.ReadAllText(Path.Combine(_acmePath, fileName)); return Result.Ok(fileContent); } private async Task TryPersistRegistrationCacheFromSessionAsync(Guid sessionId) { var cacheResult = letsEncryptService.GetRegistrationCache(sessionId); if (!cacheResult.IsSuccess || cacheResult.Value == null) return; var saveResult = await cacheService.SaveToCacheAsync(cacheResult.Value.AccountId, cacheResult.Value); if (!saveResult.IsSuccess) logger.LogWarning("Could not persist registration cache after ACME flow step for account {AccountId}.", cacheResult.Value.AccountId); } private void DeleteExporedChallenges() { var currentDate = DateTime.Now; foreach (var file in Directory.GetFiles(_acmePath)) { try { var creationTime = File.GetCreationTime(file); var timeDifference = currentDate - creationTime; if (timeDifference.TotalDays > 1) { File.Delete(file); logger.LogInformation($"Deleted file: {file}"); } } catch (Exception ex) { logger.LogWarning(ex, "File cannot be deleted"); } } } }