(feature): migrate to helm and k8s, cleanup

This commit is contained in:
Maksym Sadovnychyy 2025-10-14 11:06:03 +02:00
parent 35eec5d864
commit 30f5ededa3
34 changed files with 790 additions and 851 deletions

View File

@ -8,8 +8,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.6" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
</ItemGroup>
<ItemGroup>

View File

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

View File

@ -14,7 +14,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
<PackageReference Include="System.Threading.RateLimiting" Version="9.0.9" />
</ItemGroup>
</Project>

View File

@ -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<int, int> _reentrantCounts = new ConcurrentDictionary<int, int>();
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<T> ExecuteWithLockAsync<T>(Func<Task<T>> 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<Task> 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<T> ExecuteWithLockAsync<T>(Func<T> 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();
}
}

7
src/Deploy-Helm.bat Normal file
View File

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

65
src/Deploy-Helm.ps1 Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -8,12 +8,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DomainResult.Common" Version="3.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="DomainResult.Common" Version="3.3.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.9" />
</ItemGroup>
<ItemGroup>

View File

@ -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<LetsEncryptService> _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<SendResult<T>> SendAcmeRequest<T>(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<T>(response, responseText);
}
// Helper: Poll challenge status until valid or timeout
private async Task<IDomainResult> 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<AuthorizationChallengeResponse>(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<IDomainResult> 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<AcmeDirectory>(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<AcmeDirectory>(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<Account>(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<Account>(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<RegistrationCache?>();
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<string?>();
}
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<string?>(message);
}
@ -219,107 +212,76 @@ public class LetsEncryptService : ILetsEncryptService {
public async Task<(Dictionary<string, string>?, 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<OrderIdentifier>()
};
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<Order>(response, responseText);
if (order.Result.Status == "ready")
return IDomainResult.Success(new Dictionary<string, string>());
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<Dictionary<string, string>?>();
var order = await SendAcmeRequest<Order>(request, state, HttpMethod.Post);
if (StatusEquals(order.Result?.Status, OrderStatus.Ready))
return (new Dictionary<string, string>(), 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<string, string>();
foreach (var item in state.CurrentOrder.Authorizations) {
foreach (var item in state.CurrentOrder?.Authorizations ?? Array.Empty<Uri>()) {
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<AuthorizationChallengeResponse>(response, responseText);
if (challengeResponse.Result.Status == "valid")
var challengeResponse = await SendAcmeRequest<AuthorizationChallengeResponse>(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<Dictionary<string, string>?>();
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<Dictionary<string, string>?>(message);
return (null, IDomainResult.CriticalDependencyError(message));
}
}
#endregion
@ -328,68 +290,31 @@ public class LetsEncryptService : ILetsEncryptService {
public async Task<IDomainResult> 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<AuthorizationChallengeResponse>(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<AuthorizationChallengeResponse>(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<IDomainResult> 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<OrderIdentifier>()
};
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<Order>(response, responseText);
var order = await SendAcmeRequest<Order>(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<IDomainResult> 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<string>().ToArray() ?? Array.Empty<string>();
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<Order>(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<Order>(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<Order>(response, responseText);
order = await SendAcmeRequest<Order>(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<string>(finalResponse, finalResponseText);
var pem = await SendAcmeRequest<string>(finalRequest, state, HttpMethod.Post);
if (state.Cache == null) {
_logger.LogError($"{nameof(state.Cache)} is null");
return IDomainResult.Failed();
}
state.Cache.CachedCerts ??= new Dictionary<string, CertificateCache>();
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<IDomainResult> KeyChange(Guid sessionId) {
throw new NotImplementedException();
}
@ -581,58 +445,44 @@ public class LetsEncryptService : ILetsEncryptService {
public async Task<IDomainResult> 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<Problem>();
}
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<string?>(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<Problem>(), response);
}
}
private SendResult<TResult> ProcessResponseContent<TResult>(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<TResult> {
Result = (TResult)(object)responseText
};
}
var responseContent = responseText.ToObject<TResult>();
if (responseContent is IHasLocation ihl && response.Headers.Location != null) {
ihl.Location = response.Headers.Location;
}
return new SendResult<TResult> {
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();
}

View File

@ -9,10 +9,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DomainResult" Version="3.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.20.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="DomainResult" Version="3.3.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.9" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
</ItemGroup>
<ItemGroup>

View File

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

View File

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

14
src/ReleaseAndDeploy.bat Normal file
View File

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

View File

@ -8,9 +8,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="8.0.7" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.6" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.1.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="9.0.9" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
</Project>

View File

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

View File

@ -1,5 +0,0 @@

namespace MaksIT.SSHProvider;
public class Configuration {
}

View File

@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DomainResult.Common" Version="3.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
<PackageReference Include="SSH.NET" Version="2024.0.0" />
</ItemGroup>
</Project>

View File

@ -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<PrivateKeyFile>();
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, uint> {
{ 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();
}
}
}

View File

@ -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<List<string>>();
// options.AuthMethod = EnumerationStringId.FromValue<AuthenticationMethod>(vaultOptions["AuthMethod"]);
// options.AppRoleAuthMethod = vaultOptions.GetSection("AppRoleAuthMethod").Get<AppRoleAuthMethod>();
// options.TokenAuthMethod = vaultOptions.GetSection("TokenAuthMethod").Get<TokenAuthMethod>();
// options.MountPath = vaultOptions["MountPath"];
// options.SecretType = vaultOptions["SecretType"];
// options.ConfigurationMappings = vaultOptions.GetSection("ConfigurationMappings").Get<Dictionary<string, string>>();
//});
return configurationBuilder.Build();
}
}

View File

@ -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<Configuration>(appSettingsSection);
var appSettings = appSettingsSection.Get<Configuration>();
#region configurazione logging
services.AddLogging(configure => {
configure.AddSerilog(new LoggerConfiguration()
//.ReadFrom.Configuration(_configuration)
.CreateLogger());
});
#endregion
}
}

View File

@ -1,54 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="Serilog.Enrichers.Span" Version="3.1.0" />
<PackageReference Include="Serilog.Expressions" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="2.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\SSHProvider\SSHProvider.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

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

View File

@ -1 +0,0 @@
global using Xunit;

View File

@ -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} <s:{SourceContext}>{NewLine}{Exception}"
}
}
]
},
"Configuration": {
}
}

View File

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

6
src/helm/Chart.yaml Normal file
View File

@ -0,0 +1,6 @@
apiVersion: v2
name: certs-ui
description: MaksIT CertsUI
type: application
version: 0.1.0
appVersion: "latest"

View File

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

View File

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

View File

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

View File

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

View File

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

96
src/helm/values.yaml Normal file
View File

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