diff --git a/src/Agent/Agent.csproj b/src/Agent/Agent.csproj index cd7c57d..8867ed6 100644 --- a/src/Agent/Agent.csproj +++ b/src/Agent/Agent.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/src/ClientApp/ApiRoutes.tsx b/src/ClientApp/ApiRoutes.tsx index 6995cdc..ee111db 100644 --- a/src/ClientApp/ApiRoutes.tsx +++ b/src/ClientApp/ApiRoutes.tsx @@ -20,14 +20,14 @@ enum ApiRoutes { // CERTS_FLOW_HOSRS_WITH_UPCOMING_SSL_EXPIRY = `api/CertsFlow/HostsWithUpcomingSslExpiry/{sessionId}` } +const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? '' + const GetApiRoute = (route: ApiRoutes, ...args: string[]): string => { let result: string = route args.forEach((arg) => { result = result.replace(/{.*?}/, arg) }) - // TODO: need env var - return `http://localhost:8080/${result}` - //return `http://websrv0001.corp.maks-it.com:8080/${result}` + return `${apiBase.replace(/\/+$/, '')}/${result.replace(/^\/+/, '')}` } export { GetApiRoute, ApiRoutes } diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 3d31787..27d9738 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -14,7 +14,8 @@ - + + diff --git a/src/Core/LockManager.cs b/src/Core/LockManager.cs index a5f6119..44ed543 100644 --- a/src/Core/LockManager.cs +++ b/src/Core/LockManager.cs @@ -1,105 +1,93 @@ using System; using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; +using System.Threading.RateLimiting; public class LockManager : IDisposable { private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); private readonly ConcurrentDictionary _reentrantCounts = new ConcurrentDictionary(); + private readonly TokenBucketRateLimiter _rateLimiter = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions { + TokenLimit = 5, // max 5 requests per second (adjust as needed) + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 100, + ReplenishmentPeriod = TimeSpan.FromSeconds(1), + TokensPerPeriod = 5, + AutoReplenishment = true + }); public async Task ExecuteWithLockAsync(Func> action) { + var lease = await _rateLimiter.AcquireAsync(1); + if (!lease.IsAcquired) throw new InvalidOperationException("Rate limit exceeded"); + var threadId = Thread.CurrentThread.ManagedThreadId; - - if (!_reentrantCounts.ContainsKey(threadId)) { - _reentrantCounts[threadId] = 0; - } - - if (_reentrantCounts[threadId] == 0) { - await _semaphore.WaitAsync(); - } - + if (!_reentrantCounts.ContainsKey(threadId)) _reentrantCounts[threadId] = 0; + if (_reentrantCounts[threadId] == 0) await _semaphore.WaitAsync(); _reentrantCounts[threadId]++; try { return await action(); } finally { _reentrantCounts[threadId]--; - if (_reentrantCounts[threadId] == 0) { - _semaphore.Release(); - } + if (_reentrantCounts[threadId] == 0) _semaphore.Release(); + lease.Dispose(); } } public async Task ExecuteWithLockAsync(Func action) { + var lease = await _rateLimiter.AcquireAsync(1); + if (!lease.IsAcquired) throw new InvalidOperationException("Rate limit exceeded"); + var threadId = Thread.CurrentThread.ManagedThreadId; - - if (!_reentrantCounts.ContainsKey(threadId)) { - _reentrantCounts[threadId] = 0; - } - - if (_reentrantCounts[threadId] == 0) { - await _semaphore.WaitAsync(); - } - + if (!_reentrantCounts.ContainsKey(threadId)) _reentrantCounts[threadId] = 0; + if (_reentrantCounts[threadId] == 0) await _semaphore.WaitAsync(); _reentrantCounts[threadId]++; try { await action(); } finally { _reentrantCounts[threadId]--; - if (_reentrantCounts[threadId] == 0) { - _semaphore.Release(); - } + if (_reentrantCounts[threadId] == 0) _semaphore.Release(); + lease.Dispose(); } } public async Task ExecuteWithLockAsync(Func action) { + var lease = await _rateLimiter.AcquireAsync(1); + if (!lease.IsAcquired) throw new InvalidOperationException("Rate limit exceeded"); + var threadId = Thread.CurrentThread.ManagedThreadId; - - if (!_reentrantCounts.ContainsKey(threadId)) { - _reentrantCounts[threadId] = 0; - } - - if (_reentrantCounts[threadId] == 0) { - await _semaphore.WaitAsync(); - } - + if (!_reentrantCounts.ContainsKey(threadId)) _reentrantCounts[threadId] = 0; + if (_reentrantCounts[threadId] == 0) await _semaphore.WaitAsync(); _reentrantCounts[threadId]++; try { return await Task.Run(action); } finally { _reentrantCounts[threadId]--; - if (_reentrantCounts[threadId] == 0) { - _semaphore.Release(); - } + if (_reentrantCounts[threadId] == 0) _semaphore.Release(); + lease.Dispose(); } } public async Task ExecuteWithLockAsync(Action action) { + var lease = await _rateLimiter.AcquireAsync(1); + if (!lease.IsAcquired) throw new InvalidOperationException("Rate limit exceeded"); + var threadId = Thread.CurrentThread.ManagedThreadId; - - if (!_reentrantCounts.ContainsKey(threadId)) { - _reentrantCounts[threadId] = 0; - } - - if (_reentrantCounts[threadId] == 0) { - await _semaphore.WaitAsync(); - } - + if (!_reentrantCounts.ContainsKey(threadId)) _reentrantCounts[threadId] = 0; + if (_reentrantCounts[threadId] == 0) await _semaphore.WaitAsync(); _reentrantCounts[threadId]++; try { await Task.Run(action); } finally { _reentrantCounts[threadId]--; - if (_reentrantCounts[threadId] == 0) { - _semaphore.Release(); - } + if (_reentrantCounts[threadId] == 0) _semaphore.Release(); + lease.Dispose(); } } public void Dispose() { _semaphore.Dispose(); + _rateLimiter.Dispose(); } } diff --git a/src/Deploy-Helm.bat b/src/Deploy-Helm.bat new file mode 100644 index 0000000..40d54a5 --- /dev/null +++ b/src/Deploy-Helm.bat @@ -0,0 +1,7 @@ +@echo off + +REM Change directory to the location of the script +cd /d %~dp0 + +REM Invoke the PowerShell script (Release-NuGetPackage.ps1) in the same directory +powershell -ExecutionPolicy Bypass -File "%~dp0Deploy-Helm.ps1" diff --git a/src/Deploy-Helm.ps1 b/src/Deploy-Helm.ps1 new file mode 100644 index 0000000..18f98b0 --- /dev/null +++ b/src/Deploy-Helm.ps1 @@ -0,0 +1,65 @@ +# Set variables +$projectName = "certs-ui" +$namespace = "certs-ui" +$chartPath = "./helm" +$harborUrl = "cr.maks-it.com" +$loadBalancerIP = "172.16.0.5" + +# Retrieve and decode username:password from environment variable (Base64) +try { + $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Env:CR_MAKS_IT)) +} catch { + Write-Error "Failed to decode CR_MAKS_IT as Base64. Expected base64('username:password')." + exit 1 +} + +# Split decoded credentials +$creds = $decoded -split ':', 2 +$harborUsername = $creds[0] +$harborPassword = $creds[1] + +# Verify environment variable +if (-not $harborUsername -or -not $harborPassword) { + Write-Error "Decoded CR_MAKS_IT must be in the format 'username:password'." + exit 1 +} + +# Ensure namespace exists +if (-not (kubectl get ns $namespace -o name 2>$null)) { + Write-Output "Creating namespace '$namespace'..." + kubectl create namespace $namespace | Out-Null +} +else { + Write-Output "Namespace '$namespace' already exists." +} + +# Create or update Docker registry pull secret +Write-Output "Creating or updating image pull secret..." +kubectl -n $namespace create secret docker-registry cr-maksit-pull ` + --docker-server=$harborUrl ` + --docker-username=$harborUsername ` + --docker-password=$harborPassword ` + --docker-email="devnull@maks-it.com" ` + --dry-run=client -o yaml | kubectl apply -f - | Out-Null + +# Lint Helm chart +Write-Output "Linting Helm chart..." +helm lint $chartPath + +# Render Helm chart to verify output (optional) +Write-Output "Rendering Helm chart for validation..." +helm template $projectName $chartPath -n $namespace | Out-Null + +# Deploy Helm release +Write-Output "Deploying Helm release '$projectName'..." +helm upgrade --install $projectName $chartPath -n $namespace ` + --set imagePullSecret.create=false ` + --set imagePullSecrets[0].name=cr-maksit-pull ` + +# Check deployment status +Write-Output "Waiting for deployment rollout..." +kubectl -n $namespace rollout status deployment/$projectName-reverseproxy + +# Display service details +Write-Output "Service information:" +kubectl -n $namespace get svc $projectName-reverseproxy diff --git a/src/LetsEncrypt.sln b/src/LetsEncrypt.sln index 8db719a..1194f3f 100644 --- a/src/LetsEncrypt.sln +++ b/src/LetsEncrypt.sln @@ -5,16 +5,10 @@ VisualStudioVersion = 17.6.33815.320 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LetsEncrypt", "LetsEncrypt\LetsEncrypt.csproj", "{7DE431E5-889C-434E-AD02-9F89D7A0ED27}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LetsEncryptConsole", "LetsEncryptConsole\LetsEncryptConsole.csproj", "{2E4BE41E-E442-4CB8-824E-9888FFAA1BEF}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "Core\Core.csproj", "{27A58A5F-B52A-44F2-9639-84C6F02EA75D}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SSHProvider", "SSHProvider\SSHProvider.csproj", "{B6556305-D728-4368-A22C-93079C236808}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{3374FDB1-C95E-4103-8E14-5BBF0BDC4E9D}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SSHProviderTests", "Tests\SSHSerivceTests\SSHProviderTests.csproj", "{3937760A-FFB3-4A8C-ABD1-CDDCE1D977C4}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LetsEncryptServer", "LetsEncryptServer\LetsEncryptServer.csproj", "{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}" EndProject Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{0233E43F-435D-4309-B20C-ECD4BFBD2E63}" @@ -35,22 +29,10 @@ Global {7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Debug|Any CPU.Build.0 = Debug|Any CPU {7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Release|Any CPU.ActiveCfg = Release|Any CPU {7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Release|Any CPU.Build.0 = Release|Any CPU - {2E4BE41E-E442-4CB8-824E-9888FFAA1BEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2E4BE41E-E442-4CB8-824E-9888FFAA1BEF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2E4BE41E-E442-4CB8-824E-9888FFAA1BEF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2E4BE41E-E442-4CB8-824E-9888FFAA1BEF}.Release|Any CPU.Build.0 = Release|Any CPU {27A58A5F-B52A-44F2-9639-84C6F02EA75D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {27A58A5F-B52A-44F2-9639-84C6F02EA75D}.Debug|Any CPU.Build.0 = Debug|Any CPU {27A58A5F-B52A-44F2-9639-84C6F02EA75D}.Release|Any CPU.ActiveCfg = Release|Any CPU {27A58A5F-B52A-44F2-9639-84C6F02EA75D}.Release|Any CPU.Build.0 = Release|Any CPU - {B6556305-D728-4368-A22C-93079C236808}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B6556305-D728-4368-A22C-93079C236808}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B6556305-D728-4368-A22C-93079C236808}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B6556305-D728-4368-A22C-93079C236808}.Release|Any CPU.Build.0 = Release|Any CPU - {3937760A-FFB3-4A8C-ABD1-CDDCE1D977C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3937760A-FFB3-4A8C-ABD1-CDDCE1D977C4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3937760A-FFB3-4A8C-ABD1-CDDCE1D977C4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3937760A-FFB3-4A8C-ABD1-CDDCE1D977C4}.Release|Any CPU.Build.0 = Release|Any CPU {B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Debug|Any CPU.Build.0 = Debug|Any CPU {B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -75,9 +57,6 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {3937760A-FFB3-4A8C-ABD1-CDDCE1D977C4} = {3374FDB1-C95E-4103-8E14-5BBF0BDC4E9D} - EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B78BD325-B2C1-456C-8EA8-42F9B89E0351} EndGlobalSection diff --git a/src/LetsEncrypt/Entities/ContentType.cs b/src/LetsEncrypt/Entities/ContentType.cs new file mode 100644 index 0000000..c107033 --- /dev/null +++ b/src/LetsEncrypt/Entities/ContentType.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace MaksIT.LetsEncrypt.Entities +{ + public enum ContentType + { + [Display(Name = "application/jose+json")] + JoseJson, + [Display(Name = "application/problem+json")] + ProblemJson, + [Display(Name = "application/pem-certificate-chain")] + PemCertificateChain, + [Display(Name = "application/json")] + Json + } + + public static class ContentTypeExtensions + { + public static string GetDisplayName(this ContentType contentType) + { + var type = typeof(ContentType); + var memInfo = type.GetMember(contentType.ToString()); + var attributes = memInfo[0].GetCustomAttributes(typeof(DisplayAttribute), false); + return attributes.Length > 0 ? ((DisplayAttribute)attributes[0]).Name : contentType.ToString(); + } + } +} diff --git a/src/LetsEncrypt/Entities/OrderStatus.cs b/src/LetsEncrypt/Entities/OrderStatus.cs new file mode 100644 index 0000000..438164b --- /dev/null +++ b/src/LetsEncrypt/Entities/OrderStatus.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace MaksIT.LetsEncrypt.Entities +{ + public enum OrderStatus + { + [Display(Name = "pending")] + Pending, + [Display(Name = "valid")] + Valid, + [Display(Name = "ready")] + Ready, + [Display(Name = "processing")] + Processing + } + + public static class OrderStatusExtensions + { + public static string GetDisplayName(this OrderStatus status) + { + var type = typeof(OrderStatus); + var memInfo = type.GetMember(status.ToString()); + var attributes = memInfo[0].GetCustomAttributes(typeof(DisplayAttribute), false); + return attributes.Length > 0 ? ((DisplayAttribute)attributes[0]).Name : status.ToString(); + } + } +} diff --git a/src/LetsEncrypt/LetsEncrypt.csproj b/src/LetsEncrypt/LetsEncrypt.csproj index c4a3748..194f00c 100644 --- a/src/LetsEncrypt/LetsEncrypt.csproj +++ b/src/LetsEncrypt/LetsEncrypt.csproj @@ -8,12 +8,12 @@ - - - - - - + + + + + + diff --git a/src/LetsEncrypt/Services/LetsEncryptService.cs b/src/LetsEncrypt/Services/LetsEncryptService.cs index ce32b36..1960e7a 100644 --- a/src/LetsEncrypt/Services/LetsEncryptService.cs +++ b/src/LetsEncrypt/Services/LetsEncryptService.cs @@ -22,7 +22,6 @@ using MaksIT.LetsEncrypt.Models.Interfaces; using MaksIT.LetsEncrypt.Models.Requests; using MaksIT.LetsEncrypt.Entities.Jws; using MaksIT.LetsEncrypt.Entities.LetsEncrypt; -using Microsoft.Extensions.Options; namespace MaksIT.LetsEncrypt.Services; @@ -39,6 +38,10 @@ public interface ILetsEncryptService { } public class LetsEncryptService : ILetsEncryptService { + private const string DnsType = "dns"; + private const string DirectoryEndpoint = "directory"; + private const string ReplayNonceHeader = "Replay-Nonce"; + private readonly ILogger _logger; private readonly LetsEncryptConfiguration _appSettings; private readonly HttpClient _httpClient; @@ -57,40 +60,61 @@ public class LetsEncryptService : ILetsEncryptService { } private State GetOrCreateState(Guid sessionId) { - if (!_memoryCache.TryGetValue(sessionId, out State state)) { + if (!_memoryCache.TryGetValue(sessionId, out State? state) || state == null) { state = new State(); _memoryCache.Set(sessionId, state, TimeSpan.FromHours(1)); } return state; } + // Helper: Send ACME request and process response + private async Task> SendAcmeRequest(HttpRequestMessage request, State state, HttpMethod method) { + var response = await _httpClient.SendAsync(request); + UpdateStateNonceIfNeeded(response, state, method); + var responseText = await response.Content.ReadAsStringAsync(); + HandleProblemResponseAsync(response, responseText); + return ProcessResponseContent(response, responseText); + } + + // Helper: Poll challenge status until valid or timeout + private async Task PollChallengeStatus(Guid sessionId, AuthorizationChallengeChallenge challenge, State state) { + if (challenge?.Url == null) return IDomainResult.Failed("Challenge URL is null"); + var start = DateTime.UtcNow; + while (true) { + var pollRequest = new HttpRequestMessage(HttpMethod.Post, challenge.Url); + await HandleNonceAsync(sessionId, challenge.Url, state); + var pollJson = EncodeMessage(true, null, state, new JwsHeader { + Url = challenge.Url, + Nonce = state.Nonce + }); + PrepareRequestContent(pollRequest, pollJson, HttpMethod.Post); + var pollResponse = await _httpClient.SendAsync(pollRequest); + UpdateStateNonceIfNeeded(pollResponse, state, HttpMethod.Post); + var pollResponseText = await pollResponse.Content.ReadAsStringAsync(); + HandleProblemResponseAsync(pollResponse, pollResponseText); + var authChallenge = ProcessResponseContent(pollResponse, pollResponseText); + if (authChallenge.Result?.Status != "pending") + return authChallenge.Result?.Status == "valid" ? IDomainResult.Success() : IDomainResult.Failed(); + if ((DateTime.UtcNow - start).Seconds > 120) + return IDomainResult.Failed("Timeout"); + await Task.Delay(1000); + } + } + #region ConfigureClient public async Task ConfigureClient(Guid sessionId, bool isStaging) { try { var state = GetOrCreateState(sessionId); - state.IsStaging = isStaging; - // TODO: need to propagate from Configuration _httpClient.BaseAddress ??= new Uri(isStaging ? _appSettings.Staging : _appSettings.Production); - if (state.Directory == null) { - var request = new HttpRequestMessage(HttpMethod.Get, new Uri("directory", UriKind.Relative)); - await HandleNonceAsync(sessionId, new Uri("directory", UriKind.Relative), state); - - var response = await _httpClient.SendAsync(request); - UpdateStateNonceIfNeededAsync(response, state, HttpMethod.Get); - - var responseText = await response.Content.ReadAsStringAsync(); - HandleProblemResponseAsync(response, responseText); - - var directory = ProcessResponseContent(response, responseText); - - state.Directory = directory.Result; + var request = new HttpRequestMessage(HttpMethod.Get, new Uri(DirectoryEndpoint, UriKind.Relative)); + await HandleNonceAsync(sessionId, new Uri(DirectoryEndpoint, UriKind.Relative), state); + var directory = await SendAcmeRequest(request, state, HttpMethod.Get); + state.Directory = directory.Result ?? throw new InvalidOperationException("Directory response is null"); } - return IDomainResult.Success(); - } - catch (Exception ex) { + } catch (Exception ex) { const string message = "Let's Encrypt client unhandled exception"; _logger.LogError(ex, message); return IDomainResult.CriticalDependencyError(message); @@ -104,64 +128,42 @@ public class LetsEncryptService : ILetsEncryptService { _logger.LogError("Invalid sessionId"); return IDomainResult.Failed(); } - if (contacts == null || contacts.Length == 0) { _logger.LogError("Contacts are null or empty"); return IDomainResult.Failed(); } - var state = GetOrCreateState(sessionId); - if (state.Directory == null) { _logger.LogError("State directory is null"); return IDomainResult.Failed(); } - _logger.LogInformation($"Executing {nameof(Init)}..."); - try { var accountKey = new RSACryptoServiceProvider(4096); - if (cache != null && cache.AccountKey != null) { state.Cache = cache; accountKey.ImportCspBlob(cache.AccountKey); state.JwsService = new JwsService(accountKey); - state.JwsService.SetKeyId(cache.Location.ToString()); - } - else { + state.JwsService.SetKeyId(cache.Location?.ToString() ?? string.Empty); + } else { state.JwsService = new JwsService(accountKey); - var letsEncryptOrder = new Account { TermsOfServiceAgreed = true, Contacts = contacts.Select(contact => $"mailto:{contact}").ToArray() }; - var request = new HttpRequestMessage(HttpMethod.Post, state.Directory.NewAccount); - - await HandleNonceAsync(sessionId, state.Directory.NewAccount, state); - var json = EncodeMessage(false, letsEncryptOrder, state, new JwsHeader { Url = state.Directory.NewAccount, Nonce = state.Nonce }); PrepareRequestContent(request, json, HttpMethod.Post); - - var response = await _httpClient.SendAsync(request); - UpdateStateNonceIfNeededAsync(response, state, HttpMethod.Post); - - var responseText = await response.Content.ReadAsStringAsync(); - HandleProblemResponseAsync(response, responseText); - - var result = ProcessResponseContent(response, responseText); - - state.JwsService.SetKeyId(result.Result.Location.ToString()); - - if (result.Result.Status != "valid") { - _logger.LogError($"Account status is not valid, was: {result.Result.Status} \r\n {result.ResponseText}"); + var result = await SendAcmeRequest(request, state, HttpMethod.Post); + state.JwsService.SetKeyId(result.Result?.Location?.ToString() ?? string.Empty); + if (result.Result?.Status != "valid") { + _logger.LogError($"Account status is not valid, was: {result.Result?.Status} \r\n {result.ResponseText}"); return IDomainResult.Failed(); } - state.Cache = new RegistrationCache { AccountId = accountId, Description = description, @@ -169,14 +171,12 @@ public class LetsEncryptService : ILetsEncryptService { IsStaging = state.IsStaging, Location = result.Result.Location, AccountKey = accountKey.ExportCspBlob(true), - Id = result.Result.Id, + Id = result.Result.Id ?? string.Empty, Key = result.Result.Key }; } - return IDomainResult.Success(); - } - catch (Exception ex) { + } catch (Exception ex) { const string message = "Let's Encrypt client unhandled exception"; _logger.LogError(ex, message); return IDomainResult.CriticalDependencyError(message); @@ -186,10 +186,8 @@ public class LetsEncryptService : ILetsEncryptService { public (RegistrationCache?, IDomainResult) GetRegistrationCache(Guid sessionId) { var state = GetOrCreateState(sessionId); - if(state?.Cache == null) return IDomainResult.Failed(); - return IDomainResult.Success(state.Cache); } @@ -197,18 +195,13 @@ public class LetsEncryptService : ILetsEncryptService { public (string?, IDomainResult) GetTermsOfServiceUri(Guid sessionId) { try { var state = GetOrCreateState(sessionId); - _logger.LogInformation($"Executing {nameof(GetTermsOfServiceUri)}..."); - - if (state.Directory == null) { + if (state.Directory?.Meta?.TermsOfService == null) { return IDomainResult.Failed(); } - return IDomainResult.Success(state.Directory.Meta.TermsOfService); - } - catch (Exception ex) { + } catch (Exception ex) { var message = "Let's Encrypt client unhandled exception"; - _logger.LogError(ex, message); return IDomainResult.CriticalDependencyError(message); } @@ -219,107 +212,76 @@ public class LetsEncryptService : ILetsEncryptService { public async Task<(Dictionary?, IDomainResult)> NewOrder(Guid sessionId, string[] hostnames, string challengeType) { try { var state = GetOrCreateState(sessionId); - _logger.LogInformation($"Executing {nameof(NewOrder)}..."); - state.Challenges.Clear(); - var letsEncryptOrder = new Order { Expires = DateTime.UtcNow.AddDays(2), - Identifiers = hostnames.Select(hostname => new OrderIdentifier { - Type = "dns", - Value = hostname - }).ToArray() + Identifiers = hostnames?.Where(h => h != null).Select(hostname => new OrderIdentifier { + Type = DnsType, + Value = hostname ?? string.Empty + }).ToArray() ?? Array.Empty() }; - + if (state.Directory == null || state.Directory.NewOrder == null) + return (null, IDomainResult.Failed()); var request = new HttpRequestMessage(HttpMethod.Post, state.Directory.NewOrder); await HandleNonceAsync(sessionId, state.Directory.NewOrder, state); - var json = EncodeMessage(false, letsEncryptOrder, state, new JwsHeader { Url = state.Directory.NewOrder, Nonce = state.Nonce }); PrepareRequestContent(request, json, HttpMethod.Post); - - var response = await _httpClient.SendAsync(request); - UpdateStateNonceIfNeededAsync(response, state, HttpMethod.Post); - - var responseText = await response.Content.ReadAsStringAsync(); - HandleProblemResponseAsync(response, responseText); - - var order = ProcessResponseContent(response, responseText); - - if (order.Result.Status == "ready") - return IDomainResult.Success(new Dictionary()); - - if (order.Result.Status != "pending") { - _logger.LogError($"Created new order and expected status 'pending', but got: {order.Result.Status} \r\n {order.Result}"); - return IDomainResult.Failed?>(); + var order = await SendAcmeRequest(request, state, HttpMethod.Post); + if (StatusEquals(order.Result?.Status, OrderStatus.Ready)) + return (new Dictionary(), IDomainResult.Success()); + if (!StatusEquals(order.Result?.Status, OrderStatus.Pending)) { + _logger.LogError($"Created new order and expected status '{OrderStatus.Pending.GetDisplayName()}', but got: {order.Result?.Status} \r\n {order.Result}"); + return (null, IDomainResult.Failed()); } - state.CurrentOrder = order.Result; - var results = new Dictionary(); - foreach (var item in state.CurrentOrder.Authorizations) { - + foreach (var item in state.CurrentOrder?.Authorizations ?? Array.Empty()) { + if (item == null) continue; request = new HttpRequestMessage(HttpMethod.Post, item); await HandleNonceAsync(sessionId, item, state); - json = EncodeMessage(true, null, state, new JwsHeader { Url = item, Nonce = state.Nonce }); PrepareRequestContent(request, json, HttpMethod.Post); - - - - response = await _httpClient.SendAsync(request); - UpdateStateNonceIfNeededAsync(response, state, HttpMethod.Post); - - responseText = await response.Content.ReadAsStringAsync(); - HandleProblemResponseAsync(response, responseText); - - var challengeResponse = ProcessResponseContent(response, responseText); - - - if (challengeResponse.Result.Status == "valid") + var challengeResponse = await SendAcmeRequest(request, state, HttpMethod.Post); + if (StatusEquals(challengeResponse.Result?.Status, OrderStatus.Valid)) continue; - - if (challengeResponse.Result.Status != "pending") { - _logger.LogError($"Expected authorization status 'pending', but got: {state.CurrentOrder.Status} \r\n {challengeResponse.ResponseText}"); - return IDomainResult.Failed?>(); + if (!StatusEquals(challengeResponse.Result?.Status, OrderStatus.Pending)) { + _logger.LogError($"Expected authorization status '{OrderStatus.Pending.GetDisplayName()}', but got: {state.CurrentOrder?.Status} \r\n {challengeResponse.ResponseText}"); + return (null, IDomainResult.Failed()); + } + var challenge = challengeResponse.Result?.Challenges?.FirstOrDefault(x => x?.Type == challengeType); + if (challenge == null || challenge.Token == null) { + _logger.LogError("Challenge or token is null"); + return (null, IDomainResult.Failed()); } - - var challenge = challengeResponse.Result.Challenges.First(x => x.Type == challengeType); state.Challenges.Add(challenge); - state.Cache.ChallengeType = challengeType; - - var keyToken = state.JwsService.GetKeyAuthorization(challenge.Token); - + if (state.Cache != null) state.Cache.ChallengeType = challengeType; + var keyToken = state.JwsService != null ? state.JwsService.GetKeyAuthorization(challenge.Token) : string.Empty; switch (challengeType) { case "dns-01": using (var sha256 = SHA256.Create()) { - var dnsToken = state.JwsService.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken))); - results[challengeResponse.Result.Identifier.Value] = dnsToken; + var dnsToken = state.JwsService != null ? state.JwsService.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken ?? string.Empty))) : string.Empty; + results[challengeResponse.Result?.Identifier?.Value ?? string.Empty] = dnsToken; } break; - case "http-01": - results[challengeResponse.Result.Identifier.Value] = keyToken; + results[challengeResponse.Result?.Identifier?.Value ?? string.Empty] = keyToken ?? string.Empty; break; - default: throw new NotImplementedException(); } } - - return IDomainResult.Success(results); - } - catch (Exception ex) { + return (results, IDomainResult.Success()); + } catch (Exception ex) { var message = "Let's Encrypt client unhandled exception"; - _logger.LogError(ex, message); - return IDomainResult.CriticalDependencyError?>(message); + return (null, IDomainResult.CriticalDependencyError(message)); } } #endregion @@ -328,68 +290,31 @@ public class LetsEncryptService : ILetsEncryptService { public async Task CompleteChallenges(Guid sessionId) { try { var state = GetOrCreateState(sessionId); - _logger.LogInformation($"Executing {nameof(CompleteChallenges)}..."); - if (state.CurrentOrder?.Identifiers == null) { return IDomainResult.Failed("Current order identifiers are null"); } - for (var index = 0; index < state.Challenges.Count; index++) { var challenge = state.Challenges[index]; - var start = DateTime.UtcNow; - - while (true) { - var authorizeChallenge = new AuthorizeChallenge(); - - switch (challenge.Type) { - case "dns-01": - authorizeChallenge.KeyAuthorization = state.JwsService.GetKeyAuthorization(challenge.Token); - break; - - case "http-01": - break; - } - - var request = new HttpRequestMessage(HttpMethod.Post, challenge.Url); - await HandleNonceAsync(sessionId, challenge.Url, state); - - var json = EncodeMessage(false, "{}", state, new JwsHeader { - Url = challenge.Url, - Nonce = state.Nonce - }); - PrepareRequestContent(request, json, HttpMethod.Post); - - var response = await _httpClient.SendAsync(request); - UpdateStateNonceIfNeededAsync(response, state, HttpMethod.Post); - - var responseText = await response.Content.ReadAsStringAsync(); - HandleProblemResponseAsync(response, responseText); - - var authChallenge = ProcessResponseContent(response, responseText); - //return IDomainResult.Success(result); - - - if (authChallenge.Result.Status == "valid") - break; - - if (authChallenge.Result.Status != "pending") { - _logger.LogError($"Challenge failed with status {authChallenge.Result.Status} \r\n {authChallenge.ResponseText}"); - return IDomainResult.Failed(); - } - - await Task.Delay(1000); - - if ((DateTime.UtcNow - start).Seconds > 120) - return IDomainResult.Failed("Timeout"); + if (challenge?.Url == null) { + _logger.LogError("Challenge URL is null"); + return IDomainResult.Failed(); } + var request = new HttpRequestMessage(HttpMethod.Post, challenge.Url); + await HandleNonceAsync(sessionId, challenge.Url, state); + var json = EncodeMessage(false, "{}", state, new JwsHeader { + Url = challenge.Url, + Nonce = state.Nonce + }); + PrepareRequestContent(request, json, HttpMethod.Post); + var authChallenge = await SendAcmeRequest(request, state, HttpMethod.Post); + var result = await PollChallengeStatus(sessionId, challenge, state); + if (!result.IsSuccess) + return result; } - return IDomainResult.Success(); - } - catch (Exception ex) { + } catch (Exception ex) { var message = "Let's Encrypt client unhandled exception"; - _logger.LogError(ex, message); return IDomainResult.CriticalDependencyError(message); } @@ -400,40 +325,26 @@ public class LetsEncryptService : ILetsEncryptService { public async Task GetOrder(Guid sessionId, string[] hostnames) { try { _logger.LogInformation($"Executing {nameof(GetOrder)}"); - var state = GetOrCreateState(sessionId); - var letsEncryptOrder = new Order { Expires = DateTime.UtcNow.AddDays(2), - Identifiers = hostnames.Select(hostname => new OrderIdentifier { + Identifiers = hostnames?.Where(h => h != null).Select(hostname => new OrderIdentifier { Type = "dns", - Value = hostname - }).ToArray() + Value = hostname! + }).ToArray() ?? Array.Empty() }; - - var request = new HttpRequestMessage(HttpMethod.Post, state.Directory.NewOrder); + var request = new HttpRequestMessage(HttpMethod.Post, state.Directory!.NewOrder); await HandleNonceAsync(sessionId, state.Directory.NewOrder, state); - var json = EncodeMessage(false, letsEncryptOrder, state, new JwsHeader { Url = state.Directory.NewOrder, Nonce = state.Nonce }); PrepareRequestContent(request, json, HttpMethod.Post); - - var response = await _httpClient.SendAsync(request); - UpdateStateNonceIfNeededAsync(response, state, HttpMethod.Post); - - var responseText = await response.Content.ReadAsStringAsync(); - HandleProblemResponseAsync(response, responseText); - - var order = ProcessResponseContent(response, responseText); + var order = await SendAcmeRequest(request, state, HttpMethod.Post); state.CurrentOrder = order.Result; - return IDomainResult.Success(); - } - catch (Exception ex) { + } catch (Exception ex) { var message = "Let's Encrypt client unhandled exception"; - _logger.LogError(ex, message); return IDomainResult.CriticalDependencyError(message); } @@ -444,136 +355,89 @@ public class LetsEncryptService : ILetsEncryptService { public async Task GetCertificate(Guid sessionId, string subject) { try { var state = GetOrCreateState(sessionId); - _logger.LogInformation($"Executing {nameof(GetCertificate)}..."); - - if (state.CurrentOrder == null) { + if (state.CurrentOrder?.Identifiers == null) { return IDomainResult.Failed(); } - var key = new RSACryptoServiceProvider(4096); var csr = new CertificateRequest("CN=" + subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var san = new SubjectAlternativeNameBuilder(); - foreach (var host in state.CurrentOrder.Identifiers) - san.AddDnsName(host.Value); - + foreach (var host in state.CurrentOrder.Identifiers) { + if (host?.Value != null) + san.AddDnsName(host.Value); + } csr.CertificateExtensions.Add(san.Build()); - var letsEncryptOrder = new FinalizeRequest { - Csr = state.JwsService.Base64UrlEncoded(csr.CreateSigningRequest()) + Csr = state.JwsService!.Base64UrlEncoded(csr.CreateSigningRequest()) }; - Uri? certificateUrl = default; - var start = DateTime.UtcNow; - while (certificateUrl == null) { - // https://community.letsencrypt.org/t/breaking-changes-in-asynchronous-order-finalization-api/195882 - await GetOrder(sessionId, state.CurrentOrder.Identifiers.Select(x => x.Value).ToArray()); - - if (state.CurrentOrder.Status == "ready") { - - - var request = new HttpRequestMessage(HttpMethod.Post, state.CurrentOrder.Finalize); - await HandleNonceAsync(sessionId, state.CurrentOrder.Finalize, state); - + var hostnames = state.CurrentOrder.Identifiers?.Select(x => x?.Value).Where(x => x != null).Cast().ToArray() ?? Array.Empty(); + await GetOrder(sessionId, hostnames); + var status = state.CurrentOrder?.Status; + if (StatusEquals(status, OrderStatus.Ready)) { + var request = new HttpRequestMessage(HttpMethod.Post, state.CurrentOrder.Finalize!); + await HandleNonceAsync(sessionId, state.CurrentOrder.Finalize!, state); var json = EncodeMessage(false, letsEncryptOrder, state, new JwsHeader { Url = state.CurrentOrder.Finalize, Nonce = state.Nonce }); PrepareRequestContent(request, json, HttpMethod.Post); - - var response = await _httpClient.SendAsync(request); - UpdateStateNonceIfNeededAsync(response, state, HttpMethod.Post); - - var responseText = await response.Content.ReadAsStringAsync(); - HandleProblemResponseAsync(response, responseText); - - var order = ProcessResponseContent(response, responseText); - - - - if (order.Result.Status == "processing") { - - request = new HttpRequestMessage(HttpMethod.Post, state.CurrentOrder.Location); - await HandleNonceAsync(sessionId, state.CurrentOrder.Location, state); - + var order = await SendAcmeRequest(request, state, HttpMethod.Post); + if (StatusEquals(order.Result?.Status, OrderStatus.Processing)) { + request = new HttpRequestMessage(HttpMethod.Post, state.CurrentOrder.Location!); + await HandleNonceAsync(sessionId, state.CurrentOrder.Location!, state); json = EncodeMessage(true, null, state, new JwsHeader { Url = state.CurrentOrder.Location, Nonce = state.Nonce }); PrepareRequestContent(request, json, HttpMethod.Post); - - response = await _httpClient.SendAsync(request); - UpdateStateNonceIfNeededAsync(response, state, HttpMethod.Post); - - responseText = await response.Content.ReadAsStringAsync(); - HandleProblemResponseAsync(response, responseText); - - order = ProcessResponseContent(response, responseText); + order = await SendAcmeRequest(request, state, HttpMethod.Post); } - - if (order.Result.Status == "valid") { + if (StatusEquals(order.Result?.Status, OrderStatus.Valid)) { certificateUrl = order.Result.Certificate; } + } else if (StatusEquals(status, OrderStatus.Valid)) { + certificateUrl = state.CurrentOrder.Certificate; + break; } - if ((DateTime.UtcNow - start).Seconds > 120) throw new TimeoutException(); - await Task.Delay(1000); - } - - - - var finalRequest = new HttpRequestMessage(HttpMethod.Post, certificateUrl); - await HandleNonceAsync(sessionId, certificateUrl, state); - + } + var finalRequest = new HttpRequestMessage(HttpMethod.Post, certificateUrl!); + await HandleNonceAsync(sessionId, certificateUrl!, state); var finalJson = EncodeMessage(true, null, state, new JwsHeader { Url = certificateUrl, Nonce = state.Nonce }); PrepareRequestContent(finalRequest, finalJson, HttpMethod.Post); - - var finalResponse = await _httpClient.SendAsync(finalRequest); - UpdateStateNonceIfNeededAsync(finalResponse, state, HttpMethod.Post); - - var finalResponseText = await finalResponse.Content.ReadAsStringAsync(); - HandleProblemResponseAsync(finalResponse, finalResponseText); - - var pem = ProcessResponseContent(finalResponse, finalResponseText); - - + var pem = await SendAcmeRequest(finalRequest, state, HttpMethod.Post); if (state.Cache == null) { _logger.LogError($"{nameof(state.Cache)} is null"); return IDomainResult.Failed(); } - state.Cache.CachedCerts ??= new Dictionary(); state.Cache.CachedCerts[subject] = new CertificateCache { - Cert = pem.Result, + Cert = pem.Result ?? string.Empty, Private = key.ExportCspBlob(true), PrivatePem = key.ExportRSAPrivateKeyPem() }; - - var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem.Result)); - - - + var certPem = pem.Result ?? string.Empty; + if (!string.IsNullOrEmpty(certPem)) { + var cert = new X509Certificate2(Encoding.UTF8.GetBytes(certPem)); + } return IDomainResult.Success(); - } - catch (Exception ex) { + } catch (Exception ex) { var message = "Let's Encrypt client unhandled exception"; - _logger.LogError(ex, message); return IDomainResult.CriticalDependencyError(message); } } #endregion - public Task KeyChange(Guid sessionId) { throw new NotImplementedException(); } @@ -581,58 +445,44 @@ public class LetsEncryptService : ILetsEncryptService { public async Task RevokeCertificate(Guid sessionId, string subject, RevokeReason reason) { try { var state = GetOrCreateState(sessionId); - _logger.LogInformation($"Executing {nameof(RevokeCertificate)}..."); - - if (state.Cache == null || state.Cache.CachedCerts == null || !state.Cache.CachedCerts.TryGetValue(subject, out var certificateCache)) { + if (state.Cache?.CachedCerts == null || !state.Cache.CachedCerts.TryGetValue(subject, out var certificateCache) || certificateCache == null) { _logger.LogError("Certificate not found in cache"); return IDomainResult.Failed("Certificate not found"); } - - // Load the certificate from PEM format and convert it to DER format - var certificate = new X509Certificate2(Encoding.UTF8.GetBytes(certificateCache.Cert)); + var certPem = certificateCache.Cert ?? string.Empty; + if (string.IsNullOrEmpty(certPem)) { + _logger.LogError("Certificate PEM is null or empty"); + return IDomainResult.Failed("Certificate PEM is null or empty"); + } + var certificate = new X509Certificate2(Encoding.UTF8.GetBytes(certPem)); var derEncodedCert = certificate.Export(X509ContentType.Cert); - var base64UrlEncodedCert = state.JwsService.Base64UrlEncoded(derEncodedCert); - - + var base64UrlEncodedCert = state.JwsService!.Base64UrlEncoded(derEncodedCert); var revokeRequest = new RevokeRequest { Certificate = base64UrlEncodedCert, Reason = (int)reason }; - - var request = new HttpRequestMessage(HttpMethod.Post, state.Directory.RevokeCert); + var request = new HttpRequestMessage(HttpMethod.Post, state.Directory!.RevokeCert); await HandleNonceAsync(sessionId, state.Directory.RevokeCert, state); - var jwsHeader = new JwsHeader { Url = state.Directory.RevokeCert, Nonce = state.Nonce }; - var json = state.JwsService.Encode(revokeRequest, jwsHeader).ToJson(); - request.Content = new StringContent(json); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/jose+json"); - + request.Content.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(ContentType.JoseJson)); var response = await _httpClient.SendAsync(request); - UpdateStateNonceIfNeededAsync(response, state, HttpMethod.Post); - + UpdateStateNonceIfNeeded(response, state, HttpMethod.Post); var responseText = await response.Content.ReadAsStringAsync(); - if (response.Content.Headers.ContentType?.MediaType == "application/problem+json") { + if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.ProblemJson)) { var erroObj = responseText.ToObject(); } - if (!response.IsSuccessStatusCode) IDomainResult.CriticalDependencyError(responseText); - - - // Remove the certificate from the cache after successful revocation state.Cache.CachedCerts.Remove(subject); - _logger.LogInformation("Certificate revoked successfully"); - return IDomainResult.Success(); - } - catch (Exception ex) { + } catch (Exception ex) { var message = "Let's Encrypt client unhandled exception"; _logger.LogError(ex, message); return IDomainResult.CriticalDependencyError($"{message}: {ex.Message}"); @@ -641,6 +491,7 @@ public class LetsEncryptService : ILetsEncryptService { #region SendAsync private async Task HandleNonceAsync(Guid sessionId, Uri uri, State state) { + if (uri == null) throw new ArgumentNullException(nameof(uri)); if (uri.OriginalString != "directory") { var (nonce, newNonceResult) = await NewNonce(sessionId); if (!newNonceResult.IsSuccess || nonce == null) { @@ -656,63 +507,63 @@ public class LetsEncryptService : ILetsEncryptService { private async Task<(string?, IDomainResult)> NewNonce(Guid sessionId) { try { var state = GetOrCreateState(sessionId); - _logger.LogInformation($"Executing {nameof(NewNonce)}..."); - - if (state.Directory == null) - IDomainResult.Failed(); - + if (state.Directory?.NewNonce == null) + return (null, IDomainResult.Failed()); var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, state.Directory.NewNonce)); - return IDomainResult.Success(result.Headers.GetValues("Replay-Nonce").First()); + var nonce = result.Headers.GetValues("Replay-Nonce").FirstOrDefault(); + return (nonce, IDomainResult.Success()); } catch (Exception ex) { var message = "Let's Encrypt client unhandled exception"; - _logger.LogError(ex, message); - return IDomainResult.CriticalDependencyError(message); + return (null, IDomainResult.CriticalDependencyError(message)); } } private string EncodeMessage(bool isPostAsGet, object? requestModel, State state, JwsHeader jwsHeader) { return isPostAsGet - ? state.JwsService.Encode(jwsHeader).ToJson() - : state.JwsService.Encode(requestModel, jwsHeader).ToJson(); + ? state.JwsService!.Encode(jwsHeader).ToJson() + : state.JwsService!.Encode(requestModel, jwsHeader).ToJson(); } + private static string GetContentType(ContentType type) => type.GetDisplayName(); + private void PrepareRequestContent(HttpRequestMessage request, string json, HttpMethod method) { - request.Content = new StringContent(json); - var contentType = method == HttpMethod.Post ? "application/jose+json" : "application/json"; + request.Content = new StringContent(json ?? string.Empty); + var contentType = method == HttpMethod.Post ? GetContentType(ContentType.JoseJson) : GetContentType(ContentType.Json); request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); } - private void UpdateStateNonceIfNeededAsync(HttpResponseMessage response, State state, HttpMethod method) { - if (method == HttpMethod.Post && response.Headers.Contains("Replay-Nonce")) { - state.Nonce = response.Headers.GetValues("Replay-Nonce").First(); - } - } - private void HandleProblemResponseAsync(HttpResponseMessage response, string responseText) { - if (response.Content.Headers.ContentType?.MediaType == "application/problem+json") { + if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.ProblemJson)) { throw new LetsEncrytException(responseText.ToObject(), response); } } private SendResult ProcessResponseContent(HttpResponseMessage response, string responseText) { - if (response.Content.Headers.ContentType?.MediaType == "application/pem-certificate-chain" && typeof(TResult) == typeof(string)) { + if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.PemCertificateChain) && typeof(TResult) == typeof(string)) { return new SendResult { Result = (TResult)(object)responseText }; } - var responseContent = responseText.ToObject(); if (responseContent is IHasLocation ihl && response.Headers.Location != null) { ihl.Location = response.Headers.Location; } - return new SendResult { Result = responseContent, ResponseText = responseText }; } #endregion + + private void UpdateStateNonceIfNeeded(HttpResponseMessage response, State state, HttpMethod method) { + if (method == HttpMethod.Post && response.Headers.Contains(ReplayNonceHeader)) { + state.Nonce = response.Headers.GetValues(ReplayNonceHeader).FirstOrDefault(); + } + } + + // Helper for status comparison + private static bool StatusEquals(string? status, OrderStatus expected) => status == expected.GetDisplayName(); } diff --git a/src/LetsEncryptServer/LetsEncryptServer.csproj b/src/LetsEncryptServer/LetsEncryptServer.csproj index 5b4d566..0605ead 100644 --- a/src/LetsEncryptServer/LetsEncryptServer.csproj +++ b/src/LetsEncryptServer/LetsEncryptServer.csproj @@ -9,10 +9,10 @@ - - - - + + + + diff --git a/src/Release-DockerImage.bat b/src/Release-DockerImage.bat new file mode 100644 index 0000000..93070d3 --- /dev/null +++ b/src/Release-DockerImage.bat @@ -0,0 +1,7 @@ +@echo off + +REM Change directory to the location of the script +cd /d %~dp0 + +REM Invoke the PowerShell script (Release-NuGetPackage.ps1) in the same directory +powershell -ExecutionPolicy Bypass -File "%~dp0Release-DockerImage.ps1" diff --git a/src/Release-DockerImage.ps1 b/src/Release-DockerImage.ps1 new file mode 100644 index 0000000..8415431 --- /dev/null +++ b/src/Release-DockerImage.ps1 @@ -0,0 +1,59 @@ +# Set variables +$projectName = "certs-ui" +$harborUrl = "cr.maks-it.com" # e.g., "harbor.yourdomain.com" +$tag = "latest" # Customize the tag as needed + +# Retrieve and decode username:password from environment variable (Base64 encoded) +try { + $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Env:CR_MAKS_IT)) +} catch { + throw "Failed to decode CR_MAKS_IT as Base64. Ensure it's base64('username:password'). Error: $_" +} + +# Split decoded credentials +$creds = $decoded -split ':', 2 +if ($creds.Count -ne 2) { + throw "Invalid decoded CR_MAKS_IT format. Expected 'username:password'." +} + +$harborUsername = $creds[0] +$harborPassword = $creds[1] + +# Authenticate with Harbor +Write-Output "Logging into $harborUrl as $harborUsername..." +$loginResult = $harborPassword | docker login $harborUrl -u $harborUsername --password-stdin 2>&1 +if ($LASTEXITCODE -ne 0 -or ($loginResult -notmatch "Login Succeeded")) { + throw "Docker login failed for $harborUrl.`n$loginResult" +} + +# List of services to build and push with the current context +$services = @{ + "reverseproxy" = "ReverseProxy/Dockerfile" + "server" = "LetsEncryptServer/Dockerfile" + "client" = "ClientApp/Dockerfile" +} + +$contextPath = "." + +foreach ($service in $services.Keys) { + $dockerfilePath = $services[$service] + $imageName = "$harborUrl/$projectName/${service}:${tag}" + + # Build the Docker image + Write-Output "Building image $imageName from $dockerfilePath..." + docker build -t $imageName -f $dockerfilePath $contextPath + if ($LASTEXITCODE -ne 0) { + throw "Docker build failed for $imageName" + } + + # Push the Docker image + Write-Output "Pushing image $imageName..." + docker push $imageName + if ($LASTEXITCODE -ne 0) { + throw "Docker push failed for $imageName" + } +} + +# Logout after pushing images +docker logout $harborUrl | Out-Null +Write-Output "Completed successfully." diff --git a/src/ReleaseAndDeploy.bat b/src/ReleaseAndDeploy.bat new file mode 100644 index 0000000..64fb452 --- /dev/null +++ b/src/ReleaseAndDeploy.bat @@ -0,0 +1,14 @@ +@echo off +setlocal + +REM Get the directory of the current script +set "SCRIPT_DIR=%~dp0" + +REM Run the first batch file +call "%SCRIPT_DIR%Release-DockerImage.bat" + +REM Run the second batch file +call "%SCRIPT_DIR%Deploy-Helm.bat" + +echo All scripts completed. +pause \ No newline at end of file diff --git a/src/ReverseProxy/ReverseProxy.csproj b/src/ReverseProxy/ReverseProxy.csproj index e07bfdd..722f2de 100644 --- a/src/ReverseProxy/ReverseProxy.csproj +++ b/src/ReverseProxy/ReverseProxy.csproj @@ -8,9 +8,9 @@ - - - + + + diff --git a/src/ReverseProxy/appsettings.json b/src/ReverseProxy/appsettings.json index 295531d..8ec7b6a 100644 --- a/src/ReverseProxy/appsettings.json +++ b/src/ReverseProxy/appsettings.json @@ -2,43 +2,31 @@ "ReverseProxy": { "Routes": { "well-known-acme-challenge-route": { - "Match": { - "Path": "/.well-known/acme-challenge/{**catch-all}" - }, + "Match": { "Path": "/.well-known/acme-challenge/{**catch-all}" }, "ClusterId": "letsencrypt-server" }, "swagger-route": { - "Match": { - "Path": "/swagger/{**catch-all}" - }, + "Match": { "Path": "/swagger/{**catch-all}" }, "ClusterId": "letsencrypt-server" }, "api-route": { - "Match": { - "Path": "/api/{**catch-all}" - }, + "Match": { "Path": "/api/{**catch-all}" }, "ClusterId": "letsencrypt-server" }, "default-route": { - "Match": { - "Path": "{**catch-all}" - }, + "Match": { "Path": "{**catch-all}" }, "ClusterId": "letsencrypt-app" } }, "Clusters": { "letsencrypt-server": { "Destinations": { - "destination1": { - "Address": "http://letsencrypt-server:5000/" - } + "d1": { "Address": "http://certs-ui-server:5000/" } } }, "letsencrypt-app": { "Destinations": { - "destination1": { - "Address": "http://letsencrypt-app:3000/" - } + "d1": { "Address": "http://certs-ui-client:3000/" } } } } diff --git a/src/SSHProvider/Configuration.cs b/src/SSHProvider/Configuration.cs deleted file mode 100644 index 7f082ab..0000000 --- a/src/SSHProvider/Configuration.cs +++ /dev/null @@ -1,5 +0,0 @@ - -namespace MaksIT.SSHProvider; - -public class Configuration { -} diff --git a/src/SSHProvider/SSHProvider.csproj b/src/SSHProvider/SSHProvider.csproj deleted file mode 100644 index 7ab36da..0000000 --- a/src/SSHProvider/SSHProvider.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - net8.0 - enable - enable - - - - - - - - - - diff --git a/src/SSHProvider/SSHService.cs b/src/SSHProvider/SSHService.cs deleted file mode 100644 index b39e609..0000000 --- a/src/SSHProvider/SSHService.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.Text; -using System.Text.RegularExpressions; - -using Microsoft.Extensions.Logging; - -using DomainResults.Common; - -using Renci.SshNet; -using Renci.SshNet.Common; - -namespace MaksIT.SSHProvider { - - public interface ISSHService : IDisposable { - IDomainResult Upload(string workingdirectory, string fileName, byte[] bytes); - - IDomainResult ListDir(string workingdirectory); - - IDomainResult Download(); - } - - public class SSHService : ISSHService { - - public readonly ILogger _logger; - - public readonly SshClient _sshClient; - public readonly SftpClient _sftpClient; - - public SSHService( - ILogger logger, - string host, - int port, - string username, - string password - ) { - - if(string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) - throw new ArgumentNullException($"{nameof(username)} or {nameof(password)} is null, empty or white space"); - - _logger = logger; - _sshClient = new SshClient(host, port, username, password); - _sftpClient = new SftpClient(host, port, username, password); - } - - - public SSHService( - ILogger logger, - string host, - int port, - string username, - string [] privateKeys - ) { - - if (string.IsNullOrWhiteSpace(username) || privateKeys.Any(x => string.IsNullOrWhiteSpace(x))) - throw new ArgumentNullException($"{nameof(username)} or {nameof(privateKeys)} contains key which is null, empty or white space"); - - _logger = logger; - - var privateKeyFiles = new List(); - foreach (var privateKey in privateKeys) { - using (var ms = new MemoryStream(Encoding.ASCII.GetBytes(privateKey))) { - privateKeyFiles.Add(new PrivateKeyFile(ms)); - } - } - - _sshClient = new SshClient(host, port, username, privateKeyFiles.ToArray()); - _sftpClient = new SftpClient(host, port, username, privateKeyFiles.ToArray()); - } - - public IDomainResult Connect() { - try { - _sshClient.Connect(); - _sftpClient.Connect(); - - return IDomainResult.Success(); - } - catch (Exception ex){ - _logger.LogError(ex, "SSH Service unhandled exeption"); - return IDomainResult.CriticalDependencyError(); - } - } - - public IDomainResult Upload(string workingdirectory, string fileName, byte[] bytes) { - - try { - _sftpClient.ChangeDirectory(workingdirectory); - _logger.LogInformation($"Changed directory to {workingdirectory}"); - - using var memoryStream = new MemoryStream(bytes); - - _logger.LogInformation($"Uploading {fileName} ({memoryStream.Length:N0} bytes)"); - - _sftpClient.BufferSize = 4 * 1024; // bypass Payload error large files - _sftpClient.UploadFile(memoryStream, fileName); - - return IDomainResult.Success(); - } - catch (Exception ex) { - _logger.LogError(ex, "SSH Service unhandled exeption"); - return IDomainResult.CriticalDependencyError(); - } - } - - public IDomainResult ListDir(string workingdirectory) { - try { - - var listDirectory = _sftpClient.ListDirectory(workingdirectory); - - _logger.LogInformation($"Listing directory:"); - - foreach (var file in listDirectory) { - _logger.LogInformation($" - " + file.Name); - } - - return IDomainResult.Success(); - } - - catch (Exception ex) { - _logger.LogError(ex, "SSH Service unhandled exeption"); - return IDomainResult.CriticalDependencyError(); - } -} - - public IDomainResult Download() { - return IDomainResult.Failed(); - } - - public IDomainResult RunSudoCommand(string password, string command) { - try { - command = $"sudo {command}"; - - using (var shellStream = _sshClient.CreateShellStream("xterm", 80, 24, 800, 600, 1024, new Dictionary { - { TerminalModes.ECHO, 53 } - })) { - // Get logged in - string rep = shellStream.Expect(new Regex(@"[$>]"), TimeSpan.FromSeconds(10)); // expect user prompt with timeout - _logger.LogInformation("Initial prompt: {Prompt}", rep); - - // Send command - shellStream.WriteLine(command); - rep = shellStream.Expect(new Regex(@"([$#>:])"), TimeSpan.FromSeconds(10)); // expect password or user prompt with timeout - _logger.LogInformation("After command prompt: {Prompt}", rep); - - // Check to send password - if (rep.Contains(":")) { - // Send password - shellStream.WriteLine(password); - rep = shellStream.Expect(new Regex(@"[$#>]"), TimeSpan.FromSeconds(10)); // expect user or root prompt with timeout - _logger.LogInformation("After password prompt: {Prompt}", rep); - } - - return IDomainResult.Success(); - } - } - catch (Exception ex) { - _logger.LogError(ex, "SSH Service unhandled exception"); - return IDomainResult.CriticalDependencyError(); - } - } - - public void Dispose() { - _sshClient.Disconnect(); - _sshClient.Dispose(); - - _sftpClient.Disconnect(); - _sftpClient.Dispose(); - } - } -} diff --git a/src/Tests/SSHSerivceTests/Abstractions/ConfigurationBase.cs b/src/Tests/SSHSerivceTests/Abstractions/ConfigurationBase.cs deleted file mode 100644 index 147f91e..0000000 --- a/src/Tests/SSHSerivceTests/Abstractions/ConfigurationBase.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -using Xunit; - -//using PecMgr.VaultProvider.Extensions; -//using PecMgr.VaultProvider; -//using PecMgr.Core.Abstractions; - -namespace MaksIT.Tests.SSHProviderTests.Abstractions; -//[TestCaseOrderer(PriorityOrderer.Name, PriorityOrderer.Assembly)] -public abstract class ConfigurationBase { - - protected IConfiguration Configuration; - - protected ServiceCollection ServiceCollection = new ServiceCollection(); - - protected ServiceProvider ServiceProvider { get => ServiceCollection.BuildServiceProvider(); } - - public ConfigurationBase() { - Configuration = InitConfig(); - ConfigureServices(ServiceCollection); - } - - protected abstract void ConfigureServices(IServiceCollection services); - - private IConfiguration InitConfig() { - var aspNetCoreEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - var currentDirectory = Directory.GetCurrentDirectory(); - - var configurationBuilder = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddEnvironmentVariables(); - - if (!string.IsNullOrWhiteSpace(aspNetCoreEnvironment) && new FileInfo(Path.Combine(currentDirectory, $"appsettings.{aspNetCoreEnvironment}.json")).Exists) - configurationBuilder.AddJsonFile($"appsettings.{aspNetCoreEnvironment}.json", true); - else if (new FileInfo(Path.Combine(currentDirectory, "appsettings.json")).Exists) - configurationBuilder.AddJsonFile("appsettings.json", true, true); - else - throw new FileNotFoundException($"Unable to find appsetting.json in {currentDirectory}"); - - //var builtConfig = configurationBuilder.Build(); - //var vaultOptions = builtConfig.GetSection("Vault"); - - //configurationBuilder.AddVault(options => { - // options.Address = vaultOptions["Address"]; - - // options.UnsealKeys = vaultOptions.GetSection("UnsealKeys").Get>(); - - // options.AuthMethod = EnumerationStringId.FromValue(vaultOptions["AuthMethod"]); - // options.AppRoleAuthMethod = vaultOptions.GetSection("AppRoleAuthMethod").Get(); - // options.TokenAuthMethod = vaultOptions.GetSection("TokenAuthMethod").Get(); - - // options.MountPath = vaultOptions["MountPath"]; - // options.SecretType = vaultOptions["SecretType"]; - - // options.ConfigurationMappings = vaultOptions.GetSection("ConfigurationMappings").Get>(); - //}); - - return configurationBuilder.Build(); - } -} diff --git a/src/Tests/SSHSerivceTests/Abstractions/ServiceBase.cs b/src/Tests/SSHSerivceTests/Abstractions/ServiceBase.cs deleted file mode 100644 index b608520..0000000 --- a/src/Tests/SSHSerivceTests/Abstractions/ServiceBase.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -using Serilog; - - -using Microsoft.Extensions.Configuration; -using MaksIT.SSHProvider; - -namespace MaksIT.Tests.SSHProviderTests.Abstractions; -public abstract class ServicesBase : ConfigurationBase { - - public ServicesBase() : base() { } - - protected override void ConfigureServices(IServiceCollection services) { - // configure strongly typed settings objects - var appSettingsSection = Configuration.GetSection("Configuration"); - services.Configure(appSettingsSection); - var appSettings = appSettingsSection.Get(); - - #region configurazione logging - services.AddLogging(configure => { - configure.AddSerilog(new LoggerConfiguration() - //.ReadFrom.Configuration(_configuration) - .CreateLogger()); - }); - #endregion - - } -} \ No newline at end of file diff --git a/src/Tests/SSHSerivceTests/SSHProviderTests.csproj b/src/Tests/SSHSerivceTests/SSHProviderTests.csproj deleted file mode 100644 index dd0551b..0000000 --- a/src/Tests/SSHSerivceTests/SSHProviderTests.csproj +++ /dev/null @@ -1,54 +0,0 @@ - - - - net8.0 - enable - enable - - false - true - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - - diff --git a/src/Tests/SSHSerivceTests/UnitTest1.cs b/src/Tests/SSHSerivceTests/UnitTest1.cs deleted file mode 100644 index b4cfa22..0000000 --- a/src/Tests/SSHSerivceTests/UnitTest1.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Security.Cryptography; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -using MaksIT.SSHProvider; - -using MaksIT.Tests.SSHProviderTests.Abstractions; - -namespace MaksIT.SSHSerivceTests; -public class UnitTest1 : ServicesBase { - - public readonly string _appPath = AppDomain.CurrentDomain.BaseDirectory; - - [Fact] - public void UploadFile() { - - var username = ""; - var password = ""; - var filePath = Path.Combine(_appPath, "randomfile.txt"); - CreateRandomFile(filePath, 1); - - var logger = ServiceProvider.GetService>(); - - using var sshService = new SSHService(logger, "192.168.0.10", 22, username, password); - sshService.Connect(); - - var bytes = File.ReadAllBytes(filePath); - - logger.LogInformation($"Uploading {filePath} ({bytes.Length:N0} bytes)"); - - sshService.RunSudoCommand(password, "chown nginx:nginx /var/www/ssl -R"); - sshService.RunSudoCommand(password, "chmod 777 /var/www/ssl -R"); - - sshService.Upload("/var/www/ssl", Path.GetFileName(filePath), bytes); - - sshService.RunSudoCommand(password, "chown nginx:nginx /var/www/ssl -R"); - sshService.RunSudoCommand(password, "chmod 775 /var/www/ssl -R"); - } - - private void CreateRandomFile(string filePath, int sizeInMb) { - // Note: block size must be a factor of 1MB to avoid rounding errors - const int blockSize = 1024 * 8; - const int blocksPerMb = (1024 * 1024) / blockSize; - - byte[] data = new byte[blockSize]; - - using (RNGCryptoServiceProvider crypto = new RNGCryptoServiceProvider()) { - using (FileStream stream = File.OpenWrite(filePath)) { - for (int i = 0; i < sizeInMb * blocksPerMb; i++) { - crypto.GetBytes(data); - stream.Write(data, 0, data.Length); - } - } - } - } -} diff --git a/src/Tests/SSHSerivceTests/Usings.cs b/src/Tests/SSHSerivceTests/Usings.cs deleted file mode 100644 index 8c927eb..0000000 --- a/src/Tests/SSHSerivceTests/Usings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; \ No newline at end of file diff --git a/src/Tests/SSHSerivceTests/appsettings.json b/src/Tests/SSHSerivceTests/appsettings.json deleted file mode 100644 index 0a219f9..0000000 --- a/src/Tests/SSHSerivceTests/appsettings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "Serilog": { - "Using": [ "Serilog.Settings.Configuration", "Serilog.Expressions", "Serilog.Sinks.Console" ], - "MinimumLevel": "Verbose", - "Enrich": [ "FromLogContext", "WithMachineName", "WithProcessId", "WithThreadId" ], - "WriteTo": [ - { - "Name": "Console", - "Args": { - "restrictedToMinimumLevel": "Verbose", - //"formatter": "Serilog.Formatting.Compact.RenderedCompactJsonFormatter, Serilog.Formatting.Compact" - - "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console", - "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {NewLine}{Exception}" - } - } - ] - }, - "Configuration": { - - } -} diff --git a/src/docker-compose.override.yml b/src/docker-compose.override.yml index d23c1d9..3832182 100644 --- a/src/docker-compose.override.yml +++ b/src/docker-compose.override.yml @@ -14,8 +14,6 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - LETSENCRYPT_SERVER=http://localhost:8080 - # ports: - # - "3000:3000" networks: - maks-it @@ -31,10 +29,8 @@ services: - MAKS-IT_AGENT_KEY=UGnCaElLLJClHgUeet/yr7vNvPf13b1WkDJQMfsiP6I= - MAKS-IT_AGENT_SERVICE=haproxy volumes: - - ./docker-compose/LetsEncryptServer/acme:/app/bin/Debug/net8.0/acme - - ./docker-compose/LetsEncryptServer/cache:/app/bin/Debug/net8.0/cache - ports: - - "5000:5000" + - D:\Compose\MaksIT.CertsUI\acme:/app/bin/Debug/net8.0/acme + - D:\Compose\MaksIT.CertsUI\cache:/app/bin/Debug/net8.0/cache networks: - maks-it diff --git a/src/helm/Chart.yaml b/src/helm/Chart.yaml new file mode 100644 index 0000000..b37879e --- /dev/null +++ b/src/helm/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: certs-ui +description: MaksIT CertsUI +type: application +version: 0.1.0 +appVersion: "latest" \ No newline at end of file diff --git a/src/helm/templates/_helpers.tpl b/src/helm/templates/_helpers.tpl new file mode 100644 index 0000000..9a5449a --- /dev/null +++ b/src/helm/templates/_helpers.tpl @@ -0,0 +1,35 @@ +{{- define "certs-ui.name" -}} +{{- .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end }} + +{{- define "certs-ui.fullname" -}} +{{- $name := .Chart.Name -}} +{{- $rel := .Release.Name -}} +{{- if or (hasPrefix (printf "%s-" $name) $rel) (eq $rel $name) -}} +{{- $rel | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" $rel $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end }} + +{{- define "certs-ui.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" -}} +{{- end }} + +{{- define "certs-ui.labels" -}} +app.kubernetes.io/name: {{ include "certs-ui.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +helm.sh/chart: {{ include "certs-ui.chart" . }} +{{- end }} + +{{- /* Image pull secrets (global) -> list of names) */ -}} +{{- define "certs-ui.imagePullSecrets" -}} +{{- $ips := default (list) .Values.global.imagePullSecrets -}} +{{- if $ips }} +imagePullSecrets: +{{- range $ips }} + - name: {{ .name }} +{{- end }} +{{- end }} +{{- end }} diff --git a/src/helm/templates/deployments.yaml b/src/helm/templates/deployments.yaml new file mode 100644 index 0000000..5a74f36 --- /dev/null +++ b/src/helm/templates/deployments.yaml @@ -0,0 +1,83 @@ +{{- $root := . -}} +{{- range $compName, $comp := .Values.components }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "certs-ui.fullname" $root }}-{{ $compName }} + labels: + {{- include "certs-ui.labels" $root | nindent 4 }} + app.kubernetes.io/component: {{ $compName }} +spec: + replicas: {{ default 1 $comp.replicas }} + selector: + matchLabels: + app.kubernetes.io/instance: {{ $root.Release.Name }} + app.kubernetes.io/name: {{ include "certs-ui.name" $root }} + app.kubernetes.io/component: {{ $compName }} + template: + metadata: + labels: + {{- include "certs-ui.labels" $root | nindent 8 }} + app.kubernetes.io/component: {{ $compName }} + {{- if and $comp.secretsFile $comp.secretsFile.forceUpdate }} + annotations: + "checksum/secrets-file": {{ (default "" $comp.secretsFile.content) | toString | sha256sum | quote }} + {{- end }} + spec: + {{- include "certs-ui.imagePullSecrets" $root | nindent 6 }} + containers: + - name: {{ $compName }} + image: "{{ $comp.image.registry }}/{{ $comp.image.repository }}:{{ $comp.image.tag }}" + imagePullPolicy: {{ default "IfNotPresent" $comp.image.pullPolicy }} + {{ $svc := default dict $comp.service }} + {{ $tgt := default 8080 $svc.targetPort }} + ports: + - name: http + containerPort: {{ $tgt }} + {{- if $comp.env }} + env: + {{- range $comp.env }} + - name: {{ .name }} + value: {{ .value | quote }} + {{- end }} + {{- end }} + {{- $p := default dict $comp.persistence -}} + {{- $vols := default (list) $p.volumes -}} + {{- $hasVols := gt (len $vols) 0 -}} + {{- $hasSecret := (hasKey $comp "secretsFile") -}} + {{- if or $hasVols $hasSecret }} + volumeMounts: + {{- range $vol := $vols }} + - name: {{ $compName }}-{{ $vol.name }} + mountPath: {{ $vol.mountPath }} + {{- end }} + {{- if $comp.secretsFile }} + - name: {{ $compName }}-secrets + mountPath: {{ $comp.secretsFile.mountPath }} + subPath: {{ base $comp.secretsFile.mountPath }} + {{- end }} + {{- end }} + {{- if or $hasVols $hasSecret }} + volumes: + {{- range $vol := $vols }} + - name: {{ $compName }}-{{ $vol.name }} + {{- if eq $vol.type "pvc" }} + persistentVolumeClaim: + claimName: {{ include "certs-ui.fullname" $root }}-{{ $compName }}-{{ $vol.name }} + {{- else if eq $vol.type "emptyDir" }} + emptyDir: {{ toYaml (default dict $vol.emptyDir) | nindent 12 }} + {{- else }} + emptyDir: {} + {{- end }} + {{- end }} + {{- if $comp.secretsFile }} + - name: {{ $compName }}-secrets + secret: + secretName: {{ include "certs-ui.fullname" $root }}-{{ $compName }}-secrets + items: + - key: {{ $comp.secretsFile.key }} + path: {{ base $comp.secretsFile.mountPath }} + {{- end }} + {{- end }} +{{- end }} diff --git a/src/helm/templates/pvc.yaml b/src/helm/templates/pvc.yaml new file mode 100644 index 0000000..f263783 --- /dev/null +++ b/src/helm/templates/pvc.yaml @@ -0,0 +1,23 @@ +{{- $root := . -}} +{{- range $compName, $comp := .Values.components }} +{{- $p := default dict $comp.persistence }} +{{- $vols := default (list) $p.volumes }} +{{- range $vol := $vols }} +{{- if and (eq $vol.type "pvc") $vol.pvc.create }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "certs-ui.fullname" $root }}-{{ $compName }}-{{ $vol.name }} + labels: + {{- include "certs-ui.labels" $root | nindent 4 }} + app.kubernetes.io/component: {{ $compName }} +spec: + accessModes: {{ toYaml (default (list "ReadWriteOnce") $vol.pvc.accessModes) | nindent 2 }} + resources: + requests: + storage: {{ default "1Gi" $vol.pvc.size }} + storageClassName: {{ default "local-path" $vol.pvc.storageClass }} +{{- end }} +{{- end }} +{{- end }} diff --git a/src/helm/templates/secret-appsecrets.yaml b/src/helm/templates/secret-appsecrets.yaml new file mode 100644 index 0000000..5712e50 --- /dev/null +++ b/src/helm/templates/secret-appsecrets.yaml @@ -0,0 +1,28 @@ +{{- $root := . -}} +{{- range $compName, $comp := .Values.components }} +{{- if $comp.secretsFile }} +{{- $sf := $comp.secretsFile -}} +{{- $secretName := printf "%s-%s-secrets" (include "certs-ui.fullname" $root) $compName -}} +{{- $existing := lookup "v1" "Secret" $root.Release.Namespace $secretName -}} +{{- if and $sf.keep $existing }} +{{/* keep=true and Secret exists -> render nothing */}} +{{- else }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ $secretName }} + labels: + {{- include "certs-ui.labels" $root | nindent 4 }} + app.kubernetes.io/component: {{ $compName }} + {{- if $sf.keep }} + annotations: + "helm.sh/resource-policy": keep + {{- end }} +type: Opaque +stringData: + {{ $sf.key }}: | +{{ $sf.content | indent 4 }} +{{- end }} +{{- end }} +{{- end }} diff --git a/src/helm/templates/services.yaml b/src/helm/templates/services.yaml new file mode 100644 index 0000000..1f005e9 --- /dev/null +++ b/src/helm/templates/services.yaml @@ -0,0 +1,73 @@ +{{- $root := . -}} +{{- range $compName, $comp := .Values.components }} +{{- $svc := default dict $comp.service }} +{{- if and $svc $svc.enabled }} +{{- $stype := default "ClusterIP" $svc.type }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "certs-ui.fullname" $root }}-{{ $compName }} + labels: + {{- include "certs-ui.labels" $root | nindent 4 }} + app.kubernetes.io/component: {{ $compName }} + {{- if $svc.labels }} +{{ toYaml $svc.labels | nindent 4 }} + {{- end }} + {{- if $svc.annotations }} + annotations: +{{ toYaml $svc.annotations | nindent 4 }} + {{- end }} +spec: + type: {{ $stype }} + {{- if $svc.clusterIP }} + clusterIP: {{ $svc.clusterIP }} + {{- end }} + {{- if $svc.loadBalancerClass }} + loadBalancerClass: {{ $svc.loadBalancerClass }} + {{- end }} + {{- if and (or (eq $stype "LoadBalancer") (eq $stype "NodePort")) ($svc.allocateLoadBalancerNodePorts | default nil) }} + allocateLoadBalancerNodePorts: {{ $svc.allocateLoadBalancerNodePorts }} + {{- end }} + {{- if and (eq $stype "LoadBalancer") $svc.loadBalancerSourceRanges }} + loadBalancerSourceRanges: +{{ toYaml $svc.loadBalancerSourceRanges | nindent 4 }} + {{- end }} + {{- if and (eq $stype "LoadBalancer") $svc.ipFamilies }} + ipFamilies: +{{ toYaml $svc.ipFamilies | nindent 4 }} + {{- end }} + {{- if and (eq $stype "LoadBalancer") $svc.ipFamilyPolicy }} + ipFamilyPolicy: {{ $svc.ipFamilyPolicy }} + {{- end }} + {{- if and (eq $stype "LoadBalancer") $svc.loadBalancerIP }} + loadBalancerIP: {{ $svc.loadBalancerIP }} + {{- end }} + ports: + - name: http + port: {{ default 80 $svc.port }} + targetPort: {{ default 80 $svc.targetPort }} + {{- if eq $stype "NodePort" }} + {{- if $svc.nodePort }} + nodePort: {{ $svc.nodePort }} + {{- end }} + {{- end }} + selector: + app.kubernetes.io/instance: {{ $root.Release.Name }} + app.kubernetes.io/name: {{ include "certs-ui.name" $root }} + app.kubernetes.io/component: {{ $compName }} + {{- if and (ne $stype "ClusterIP") $svc.externalTrafficPolicy }} + externalTrafficPolicy: {{ $svc.externalTrafficPolicy }} + {{- end }} + {{- if and (eq $stype "LoadBalancer") $svc.healthCheckNodePort }} + healthCheckNodePort: {{ $svc.healthCheckNodePort }} + {{- end }} + {{- if and (typeIs "string" $svc.sessionAffinity) $svc.sessionAffinity }} + sessionAffinity: {{ $svc.sessionAffinity }} + {{- if and (eq $svc.sessionAffinity "ClientIP") (typeIs "map" $svc.sessionAffinityConfig) }} + sessionAffinityConfig: +{{ toYaml $svc.sessionAffinityConfig | nindent 4 }} + {{- end }} + {{- end }} +{{ end }} +{{ end }} diff --git a/src/helm/values.yaml b/src/helm/values.yaml new file mode 100644 index 0000000..89a4251 --- /dev/null +++ b/src/helm/values.yaml @@ -0,0 +1,96 @@ +global: + imagePullSecrets: + - name: cr-maksit-pull + +components: + server: + replicas: 1 + image: + registry: cr.maks-it.com + repository: certs-ui/server + tag: latest + pullPolicy: Always + env: + - name: ASPNETCORE_ENVIRONMENT + value: Development + - name: ASPNETCORE_HTTP_PORTS + value: "5000" + service: + enabled: true + type: ClusterIP + port: 5000 + targetPort: 5000 + persistence: + volumes: + - name: acme + mountPath: /acme + type: pvc + pvc: + create: true + storageClass: local-path + size: 50Mi + accessModes: [ReadWriteOnce] + - name: cache + mountPath: /cache + type: pvc + pvc: + create: true + storageClass: local-path + size: 50Mi + accessModes: [ReadWriteOnce] + secretsFile: + key: appsecrets.json + mountPath: /secrets/appsecrets.json + content: | + { + "Agent": { + } + } + keep: true + forceUpdate: false + + client: + replicas: 1 + image: + registry: cr.maks-it.com + repository: certs-ui/client + tag: latest + pullPolicy: Always + env: + - name: ASPNETCORE_ENVIRONMENT + value: Development + - name: NEXT_PUBLIC_API_BASE_URL + value: http://certs-ui-server:5000 + service: + enabled: true + type: ClusterIP + port: 3000 + targetPort: 3000 + + reverseproxy: + replicas: 1 + image: + registry: cr.maks-it.com + repository: certs-ui/reverseproxy + tag: latest + pullPolicy: Always + env: + - name: ASPNETCORE_ENVIRONMENT + value: Development + - name: ASPNETCORE_HTTP_PORTS + value: "8080" + service: + enabled: true + type: LoadBalancer + port: 8080 + targetPort: 8080 + loadBalancerIP: "172.16.0.5" + annotations: + lbipam.cilium.io/ips: "172.16.0.5" + labels: + export: "bgp" + externalTrafficPolicy: Local + sessionAffinity: ClientIP + sessionAffinityConfig: + clientIP: + timeoutSeconds: 10800 \ No newline at end of file