From 327f3be2c76d16b1c6b2f87a051485ea96543e3d Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Sun, 6 Aug 2023 22:35:13 +0200 Subject: [PATCH] (refactor): domain results --- .../Entities/LetsEncrypt/SendResult.cs | 10 + .../Exceptions/LetsEncrytException.cs | 30 +- src/LetsEncrypt/Models/Responses/Problem.cs | 15 + .../Services/LetsEncryptService.cs | 704 +++++++++++------- src/LetsEncryptConsole/App.cs | 304 ++++---- 5 files changed, 619 insertions(+), 444 deletions(-) create mode 100644 src/LetsEncrypt/Entities/LetsEncrypt/SendResult.cs create mode 100644 src/LetsEncrypt/Models/Responses/Problem.cs diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/SendResult.cs b/src/LetsEncrypt/Entities/LetsEncrypt/SendResult.cs new file mode 100644 index 0000000..dd55fd1 --- /dev/null +++ b/src/LetsEncrypt/Entities/LetsEncrypt/SendResult.cs @@ -0,0 +1,10 @@ +namespace MaksIT.LetsEncrypt.Entities { + public class SendResult { + + public TResult? Result { get; set; } + + public string? ResponseText { get; set; } + + + } +} diff --git a/src/LetsEncrypt/Exceptions/LetsEncrytException.cs b/src/LetsEncrypt/Exceptions/LetsEncrytException.cs index 78198d7..184dbb9 100644 --- a/src/LetsEncrypt/Exceptions/LetsEncrytException.cs +++ b/src/LetsEncrypt/Exceptions/LetsEncrytException.cs @@ -1,23 +1,19 @@ - +using MaksIT.Core.Extensions; +using MaksIT.LetsEncrypt.Models.Responses; + namespace MaksIT.LetsEncrypt.Exceptions; public class LetsEncrytException : Exception { - public LetsEncrytException(Problem problem, HttpResponseMessage response) - : base($"{problem.Type}: {problem.Detail}") { + + public Problem? Problem { get; } + + public HttpResponseMessage Response { get; } + + public LetsEncrytException( + Problem? problem, + HttpResponseMessage response + ) : base(problem != null ? $"{problem.Type}: {problem.Detail}" : "") { + Problem = problem; Response = response; } - - public Problem Problem { get; } - - public HttpResponseMessage Response { get; } } - - -public class Problem { - public string Type { get; set; } - - public string Detail { get; set; } - - public string RawJson { get; set; } -} - diff --git a/src/LetsEncrypt/Models/Responses/Problem.cs b/src/LetsEncrypt/Models/Responses/Problem.cs new file mode 100644 index 0000000..fa7811b --- /dev/null +++ b/src/LetsEncrypt/Models/Responses/Problem.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MaksIT.LetsEncrypt.Models.Responses { + public class Problem { + public string Type { get; set; } + + public string Detail { get; set; } + + public string RawJson { get; set; } + } +} diff --git a/src/LetsEncrypt/Services/LetsEncryptService.cs b/src/LetsEncrypt/Services/LetsEncryptService.cs index ff78184..ac27dc2 100644 --- a/src/LetsEncrypt/Services/LetsEncryptService.cs +++ b/src/LetsEncrypt/Services/LetsEncryptService.cs @@ -18,24 +18,25 @@ using MaksIT.LetsEncrypt.Models.Responses; using MaksIT.LetsEncrypt.Models.Interfaces; using MaksIT.LetsEncrypt.Models.Requests; using MaksIT.LetsEncrypt.Entities.Jws; +using DomainResults.Common; namespace MaksIT.LetsEncrypt.Services; public interface ILetsEncryptService { - Task ConfigureClient(string url); + Task ConfigureClient(string url); - Task Init(string[] contacts, RegistrationCache? registrationCache); - - string GetTermsOfServiceUri(); - - - Task> NewOrder(string[] hostnames, string challengeType); - Task CompleteChallenges(); - Task GetOrder(string[] hostnames); - Task<(X509Certificate2 Cert, RSA PrivateKey)> GetCertificate(string subject); + Task Init(string[] contacts, RegistrationCache? registrationCache); RegistrationCache? GetRegistrationCache(); + + (string?, IDomainResult) GetTermsOfServiceUri(); + + + Task<(Dictionary?, IDomainResult)> NewOrder(string[] hostnames, string challengeType); + Task CompleteChallenges(); + Task GetOrder(string[] hostnames); + Task<((X509Certificate2 Cert, RSA PrivateKey)?, IDomainResult)> GetCertificate(string subject); } @@ -76,11 +77,22 @@ public class LetsEncryptService : ILetsEncryptService { /// /// /// - public async Task ConfigureClient(string url) { + public async Task ConfigureClient(string url) { + try { + _httpClient.BaseAddress ??= new Uri(url); - _httpClient.BaseAddress ??= new Uri(url); + var (directory, getAcmeDirectoryResult) = await SendAsync(HttpMethod.Get, new Uri("directory", UriKind.Relative), false, null); + if (!getAcmeDirectoryResult.IsSuccess) + return getAcmeDirectoryResult; - (_directory, _) = await SendAsync(HttpMethod.Get, new Uri("directory", UriKind.Relative), false, null); + _directory = directory.Result; + + return IDomainResult.Success(); + } + catch (Exception ex) { + _logger.LogError(ex, "Let's Encrypt client unhandled exception"); + return IDomainResult.CriticalDependencyError(); + } } /// @@ -89,42 +101,57 @@ public class LetsEncryptService : ILetsEncryptService { /// /// /// - public async Task Init(string? [] contacts, RegistrationCache? cache) { + public async Task Init(string? [] contacts, RegistrationCache? cache) { - if (contacts == null || contacts.Length == 0) - throw new ArgumentNullException(); + try { - if (_directory == null) - throw new ArgumentNullException(); + _logger.LogInformation($"Executing {nameof(Init)}..."); - var accountKey = new RSACryptoServiceProvider(4096); + if (contacts == null || contacts.Length == 0) + return IDomainResult.Failed(); - if (cache != null && cache.AccountKey != null) { - _cache = cache; - accountKey.ImportCspBlob(cache.AccountKey); + if (_directory == null) + return IDomainResult.Failed(); + + var accountKey = new RSACryptoServiceProvider(4096); + + if (cache != null && cache.AccountKey != null) { + _cache = cache; + accountKey.ImportCspBlob(cache.AccountKey); + } + + // New Account request + _jwsService = new JwsService(accountKey); + + + var letsEncryptOrder = new Account { + TermsOfServiceAgreed = true, + Contacts = contacts.Select(contact => $"mailto:{contact}").ToArray() + }; + + var (account, postAccuntResult) = await SendAsync(HttpMethod.Post, _directory.NewAccount, false, letsEncryptOrder); + _jwsService.SetKeyId(account.Result.Location.ToString()); + + if (account.Result.Status != "valid") { + _logger.LogError($"Account status is not valid, was: {account.Result.Status} \r\n {account.ResponseText}"); + return IDomainResult.Failed(); + } + + _cache = new RegistrationCache { + Location = account.Result.Location, + AccountKey = accountKey.ExportCspBlob(true), + Id = account.Result.Id, + Key = account.Result.Key + }; + + return IDomainResult.Success(); } + catch (Exception ex) { + var message = "Let's Encrypt client unhandled exception"; - // New Account request - _jwsService = new JwsService(accountKey); - - - var letsEncryptOrder = new Account { - TermsOfServiceAgreed = true, - Contacts = contacts.Select(contact => $"mailto:{contact}").ToArray() - }; - - var (account, response) = await SendAsync(HttpMethod.Post, _directory.NewAccount, false, letsEncryptOrder); - _jwsService.SetKeyId(account.Location.ToString()); - - if (account.Status != "valid") - throw new InvalidOperationException($"Account status is not valid, was: {account.Status} \r\n {response}"); - - _cache = new RegistrationCache { - Location = account.Location, - AccountKey = accountKey.ExportCspBlob(true), - Id = account.Id, - Key = account.Key - }; + _logger.LogError(ex, message); + return IDomainResult.CriticalDependencyError(message); + } } /// @@ -139,16 +166,25 @@ public class LetsEncryptService : ILetsEncryptService { /// /// /// - public string GetTermsOfServiceUri() { + public (string?, IDomainResult) GetTermsOfServiceUri() { + try { - if (_directory == null) - throw new NullReferenceException(); + _logger.LogInformation($"Executing {nameof(GetTermsOfServiceUri)}..."); - return _directory.Meta.TermsOfService; + if (_directory == null) { + return IDomainResult.Failed(); + } + + return IDomainResult.Success(_directory.Meta.TermsOfService); + } + catch (Exception ex) { + var message = "Let's Encrypt client unhandled exception"; + + _logger.LogError(ex, message); + return IDomainResult.CriticalDependencyError(message); + } } - - /// /// Create new Certificate Order. In case you want the wildcard-certificate you must select dns-01 challange. /// @@ -164,82 +200,104 @@ public class LetsEncryptService : ILetsEncryptService { /// /// /// - public async Task> NewOrder(string[] hostnames, string challengeType) { - _challenges.Clear(); + public async Task<(Dictionary?, IDomainResult)> NewOrder(string[] hostnames, string challengeType) { + try { - var letsEncryptOrder = new Order { - Expires = DateTime.UtcNow.AddDays(2), - Identifiers = hostnames.Select(hostname => new OrderIdentifier { - Type = "dns", - Value = hostname - }).ToArray() - }; + _logger.LogInformation($"Executing {nameof(NewOrder)}..."); - var (order, response) = await SendAsync(HttpMethod.Post, _directory.NewOrder, false, letsEncryptOrder); + _challenges.Clear(); - if (order.Status == "ready") - return new Dictionary(); + var letsEncryptOrder = new Order { + Expires = DateTime.UtcNow.AddDays(2), + Identifiers = hostnames.Select(hostname => new OrderIdentifier { + Type = "dns", + Value = hostname + }).ToArray() + }; - if (order.Status != "pending") - throw new InvalidOperationException($"Created new order and expected status 'pending', but got: {order.Status} \r\n {response}"); - - _currentOrder = order; - - var results = new Dictionary(); - foreach (var item in order.Authorizations) { - var (challengeResponse, responseText) = await SendAsync(HttpMethod.Post, item, true, null); - - if (challengeResponse.Status == "valid") - continue; - - if (challengeResponse.Status != "pending") - throw new InvalidOperationException($"Expected autorization status 'pending', but got: {order.Status} \r\n {responseText}"); - - var challenge = challengeResponse.Challenges.First(x => x.Type == challengeType); - _challenges.Add(challenge); - - var keyToken = _jwsService.GetKeyAuthorization(challenge.Token); - - switch (challengeType) { - - // A client fulfills this challenge by constructing a key authorization - // from the "token" value provided in the challenge and the client's - // account key. The client then computes the SHA-256 digest [FIPS180-4] - // of the key authorization. - // - // The record provisioned to the DNS contains the base64url encoding of - // this digest. - - case "dns-01": { - using (var sha256 = SHA256.Create()) { - var dnsToken = _jwsService.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken))); - results[challengeResponse.Identifier.Value] = dnsToken; - } - break; - } - - - // A client fulfills this challenge by constructing a key authorization - // from the "token" value provided in the challenge and the client's - // account key. The client then provisions the key authorization as a - // resource on the HTTP server for the domain in question. - // - // The path at which the resource is provisioned is comprised of the - // fixed prefix "/.well-known/acme-challenge/", followed by the "token" - // value in the challenge. The value of the resource MUST be the ASCII - // representation of the key authorization. - - case "http-01": { - results[challengeResponse.Identifier.Value] = keyToken; - break; - } - - default: - throw new NotImplementedException(); + var (order, postNewOrderResult) = await SendAsync(HttpMethod.Post, _directory.NewOrder, false, letsEncryptOrder); + if (!postNewOrderResult.IsSuccess) { + return (null, postNewOrderResult); } - } - return results; + 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?>(); + } + + _currentOrder = order.Result; + + var results = new Dictionary(); + foreach (var item in order.Result.Authorizations) { + + var (challengeResponse, postAuthorisationChallengeResult) = await SendAsync(HttpMethod.Post, item, true, null); + if (!postAuthorisationChallengeResult.IsSuccess) { + return (null, postAuthorisationChallengeResult); + } + + if (challengeResponse.Result.Status == "valid") + continue; + + if (challengeResponse.Result.Status != "pending") { + _logger.LogError($"Expected autorization status 'pending', but got: {order.Result.Status} \r\n {challengeResponse.ResponseText}"); + return IDomainResult.Failed?>(); + } + + var challenge = challengeResponse.Result.Challenges.First(x => x.Type == challengeType); + _challenges.Add(challenge); + + var keyToken = _jwsService.GetKeyAuthorization(challenge.Token); + + switch (challengeType) { + + // A client fulfills this challenge by constructing a key authorization + // from the "token" value provided in the challenge and the client's + // account key. The client then computes the SHA-256 digest [FIPS180-4] + // of the key authorization. + // + // The record provisioned to the DNS contains the base64url encoding of + // this digest. + + case "dns-01": { + using (var sha256 = SHA256.Create()) { + var dnsToken = _jwsService.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken))); + results[challengeResponse.Result.Identifier.Value] = dnsToken; + } + break; + } + + + // A client fulfills this challenge by constructing a key authorization + // from the "token" value provided in the challenge and the client's + // account key. The client then provisions the key authorization as a + // resource on the HTTP server for the domain in question. + // + // The path at which the resource is provisioned is comprised of the + // fixed prefix "/.well-known/acme-challenge/", followed by the "token" + // value in the challenge. The value of the resource MUST be the ASCII + // representation of the key authorization. + + case "http-01": { + results[challengeResponse.Result.Identifier.Value] = keyToken; + break; + } + + default: + throw new NotImplementedException(); + } + } + + return IDomainResult.Success(results); + } + catch (Exception ex) { + var message = "Let's Encrypt client unhandled exception"; + + _logger.LogError(ex, message); + return IDomainResult.CriticalDependencyError?>(message); + } } /// @@ -247,36 +305,63 @@ public class LetsEncryptService : ILetsEncryptService { /// /// /// - public async Task CompleteChallenges() { + public async Task CompleteChallenges() { + try { - for (var index = 0; index < _challenges.Count; index++) { + _logger.LogInformation($"Executing {nameof(CompleteChallenges)}..."); - var challenge = _challenges[index]; - - while (true) { - AuthorizeChallenge authorizeChallenge = new AuthorizeChallenge(); - - switch (challenge.Type) { - case "dns-01": { - authorizeChallenge.KeyAuthorization = _jwsService.GetKeyAuthorization(challenge.Token); - //var (result, responseText) = await SendAsync(HttpMethod.Post, challenge.Url, authorizeChallenge, token); - break; - } - - case "http-01": { - break; - } - } - - var (result, responseText) = await SendAsync(HttpMethod.Post, challenge.Url, false, "{}"); - - if (result.Status == "valid") - break; - if (result.Status != "pending") - throw new InvalidOperationException($"Failed autorization of {_currentOrder.Identifiers[index].Value} \r\n {responseText}"); - - await Task.Delay(1000); + if (_currentOrder?.Identifiers == null) { + return IDomainResult.Failed(); } + + for (var index = 0; index < _challenges.Count; index++) { + + var challenge = _challenges[index]; + + var start = DateTime.UtcNow; + + while (true) { + var authorizeChallenge = new AuthorizeChallenge(); + + switch (challenge.Type) { + case "dns-01": { + authorizeChallenge.KeyAuthorization = _jwsService.GetKeyAuthorization(challenge.Token); + //var (result, responseText) = await SendAsync(HttpMethod.Post, challenge.Url, authorizeChallenge, token); + break; + } + + case "http-01": { + break; + } + } + + var (authChallenge, postAuthChallengeResult) = await SendAsync(HttpMethod.Post, challenge.Url, false, "{}"); + if (!postAuthChallengeResult.IsSuccess) { + return postAuthChallengeResult; + } + + if (authChallenge.Result.Status == "valid") + break; + + if (authChallenge.Result.Status != "pending") { + _logger.LogError($"Failed autorization of {_currentOrder.Identifiers[index].Value} \r\n {authChallenge.ResponseText}"); + return IDomainResult.Failed(); + } + + await Task.Delay(1000); + + if ((DateTime.UtcNow - start).Seconds > 120) + throw new TimeoutException(); + } + } + + return IDomainResult.Success(); + } + catch (Exception ex) { + var message = "Let's Encrypt client unhandled exception"; + + _logger.LogError(ex, message); + return IDomainResult.CriticalDependencyError(message); } } @@ -285,19 +370,34 @@ public class LetsEncryptService : ILetsEncryptService { /// /// /// - public async Task GetOrder(string[] hostnames) { + public async Task GetOrder(string[] hostnames) { - var letsEncryptOrder = new Order { - Expires = DateTime.UtcNow.AddDays(2), - Identifiers = hostnames.Select(hostname => new OrderIdentifier { - Type = "dns", - Value = hostname - }).ToArray() - }; + try { - var (order, response) = await SendAsync(HttpMethod.Post, _directory.NewOrder, false, letsEncryptOrder); + _logger.LogInformation($"Executing {nameof(GetOrder)}"); - _currentOrder = order; + var letsEncryptOrder = new Order { + Expires = DateTime.UtcNow.AddDays(2), + Identifiers = hostnames.Select(hostname => new OrderIdentifier { + Type = "dns", + Value = hostname + }).ToArray() + }; + + var (order, postOrderResult) = await SendAsync(HttpMethod.Post, _directory.NewOrder, false, letsEncryptOrder); + if (!postOrderResult.IsSuccess) + return postOrderResult; + + _currentOrder = order.Result; + + return IDomainResult.Success(); + } + catch (Exception ex) { + var message = "Let's Encrypt client unhandled exception"; + + _logger.LogError(ex, message); + return IDomainResult.CriticalDependencyError(message); + } } /// @@ -306,71 +406,87 @@ public class LetsEncryptService : ILetsEncryptService { /// /// Cert and Private key /// - public async Task<(X509Certificate2 Cert, RSA PrivateKey)> GetCertificate(string subject) { + public async Task<((X509Certificate2 Cert, RSA PrivateKey)?, IDomainResult)> GetCertificate(string subject) { - _logger.LogInformation($"Invoked: {nameof(GetCertificate)}"); + try { + _logger.LogInformation($"Executing {nameof(GetCertificate)}..."); - - if (_currentOrder == null) - throw new ArgumentNullException(); - - var key = new RSACryptoServiceProvider(4096); - var csr = new CertificateRequest("CN=" + subject, - key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - - var san = new SubjectAlternativeNameBuilder(); - foreach (var host in _currentOrder.Identifiers) - san.AddDnsName(host.Value); - - csr.CertificateExtensions.Add(san.Build()); - - var letsEncryptOrder = new FinalizeRequest { - Csr = _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(_currentOrder.Identifiers.Select(x => x.Value).ToArray()); - - if (_currentOrder.Status == "ready") { - var (response, responseText) = await SendAsync(HttpMethod.Post, _currentOrder.Finalize, false, letsEncryptOrder); - - if (response.Status == "processing") - (response, responseText) = await SendAsync(HttpMethod.Post, _currentOrder.Location, true, null); - - if (response.Status == "valid") { - certificateUrl = response.Certificate; - } + if (_currentOrder == null) { + return IDomainResult.Failed<(X509Certificate2 Cert, RSA PrivateKey)?>(); } - if ((start - DateTime.UtcNow).Seconds > 120) - throw new TimeoutException(); + var key = new RSACryptoServiceProvider(4096); + var csr = new CertificateRequest("CN=" + subject, + key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - await Task.Delay(1000); - continue; + var san = new SubjectAlternativeNameBuilder(); + foreach (var host in _currentOrder.Identifiers) + san.AddDnsName(host.Value); - // throw new InvalidOperationException(/*$"Invalid order status: "*/); + csr.CertificateExtensions.Add(san.Build()); + + var letsEncryptOrder = new FinalizeRequest { + Csr = _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(_currentOrder.Identifiers.Select(x => x.Value).ToArray()); + + if (_currentOrder.Status == "ready") { + var (order, postOrderResult) = await SendAsync(HttpMethod.Post, _currentOrder.Finalize, false, letsEncryptOrder); + if (!postOrderResult.IsSuccess || order?.Result == null) + return (null, postOrderResult); + + + if (order.Result.Status == "processing") { + (order, postOrderResult) = await SendAsync(HttpMethod.Post, _currentOrder.Location, true, null); + if (!postOrderResult.IsSuccess || order?.Result == null) + return (null, postOrderResult); + } + + if (order.Result.Status == "valid") { + certificateUrl = order.Result.Certificate; + } + } + + if ((DateTime.UtcNow - start).Seconds > 120) + throw new TimeoutException(); + + await Task.Delay(1000); + } + + var (pem, postPemResult) = await SendAsync(HttpMethod.Post, certificateUrl, true, null); + if (!postPemResult.IsSuccess || pem?.Result == null) + return (null, postPemResult); + + + if (_cache == null) { + _logger.LogError($"{nameof(_cache)} is null"); + return IDomainResult.Failed<(X509Certificate2 Cert, RSA PrivateKey)?>(); + } + + _cache.CachedCerts ??= new Dictionary(); + _cache.CachedCerts[subject] = new CertificateCache { + Cert = pem.Result, + Private = key.ExportCspBlob(true) + }; + + var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem.Result)); + + return IDomainResult.Success((cert, key)); } + catch (Exception ex) { + var message = "Let's Encrypt client unhandled exception"; - var (pem, _) = await SendAsync(HttpMethod.Post, certificateUrl, true, null); - - if (_cache == null) - throw new NullReferenceException(); - - _cache.CachedCerts ??= new Dictionary(); - _cache.CachedCerts[subject] = new CertificateCache { - Cert = pem, - Private = key.ExportCspBlob(true) - }; - - var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem)); - - return (cert, key); + _logger.LogError(ex, message); + return IDomainResult.CriticalDependencyError< (X509Certificate2 Cert, RSA PrivateKey)?>(message); + } } /// @@ -378,7 +494,7 @@ public class LetsEncryptService : ILetsEncryptService { /// /// /// - public Task KeyChange() { + public Task KeyChange() { throw new NotImplementedException(); } @@ -387,88 +503,128 @@ public class LetsEncryptService : ILetsEncryptService { /// /// /// - public Task RevokeCertificate() { + public Task RevokeCertificate() { throw new NotImplementedException(); } + + /// + /// Request New Nonce to be able to start POST requests + /// + /// + /// + private async Task<(string?, IDomainResult)> NewNonce() { + + try { + + _logger.LogInformation($"Executing {nameof(NewNonce)}..."); + + if (_directory == null) + IDomainResult.Failed(); + + var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, _directory.NewNonce)); + return IDomainResult.Success(result.Headers.GetValues("Replay-Nonce").First()); + + } + catch (Exception ex) { + var message = "Let's Encrypt client unhandled exception"; + + _logger.LogError(ex, message); + return IDomainResult.CriticalDependencyError(message); + } + } + /// /// Main method used to send data to LetsEncrypt /// /// /// /// - /// + /// /// /// - private async Task<(TResult, string)> SendAsync(HttpMethod method, Uri uri, bool isPostAsGet, object? message) where TResult : class { - var request = new HttpRequestMessage(method, uri); + private async Task<(SendResult?, IDomainResult)> SendAsync(HttpMethod method, Uri uri, bool isPostAsGet, object? requestModel) { + try { - _nonce = uri.OriginalString != "directory" - ? await NewNonce() - : default; + _logger.LogInformation($"Executing {nameof(SendAsync)}..."); - if (message != null || isPostAsGet) { - var jwsHeader = new JwsHeader { - Url = uri, - }; + //if (_jwsService == null) { + // _logger.LogError($"{nameof(_jwsService)} is null"); + // return IDomainResult.Failed?>(); + //} - if (_nonce != null) - jwsHeader.Nonce = _nonce; + var request = new HttpRequestMessage(method, uri); - var encodedMessage = isPostAsGet - ? _jwsService.Encode(jwsHeader) - : _jwsService.Encode(message, jwsHeader); + if (uri.OriginalString != "directory") { + var (nonce, newNonceResult) = await NewNonce(); + if (!newNonceResult.IsSuccess || nonce == null) { + return (null, newNonceResult); + } - var json = encodedMessage.ToJson(); + _nonce = nonce; + } + else { + _nonce = default; + } - request.Content = new StringContent(json); + if (requestModel != null || isPostAsGet) { + var jwsHeader = new JwsHeader { + Url = uri, + }; + + if (_nonce != null) + jwsHeader.Nonce = _nonce; + + var encodedMessage = isPostAsGet + ? _jwsService.Encode(jwsHeader) + : _jwsService.Encode(requestModel, jwsHeader); + + var json = encodedMessage.ToJson(); + + request.Content = new StringContent(json); + + var requestType = "application/json"; + if (method == HttpMethod.Post) + requestType = "application/jose+json"; + + request.Content.Headers.Remove("Content-Type"); + request.Content.Headers.Add("Content-Type", requestType); + } + + var response = await _httpClient.SendAsync(request); - var requestType = "application/json"; if (method == HttpMethod.Post) - requestType = "application/jose+json"; + _nonce = response.Headers.GetValues("Replay-Nonce").First(); + + var responseText = await response.Content.ReadAsStringAsync(); + + if (response.Content.Headers.ContentType?.MediaType == "application/problem+json") + throw new LetsEncrytException(responseText.ToObject(), response); + + if (response.Content.Headers.ContentType?.MediaType == "application/pem-certificate-chain" && typeof(TResult) == typeof(string)) { + return IDomainResult.Success(new SendResult { + Result = (TResult)(object)responseText + }); + } + + var responseContent = responseText.ToObject(); + + if (responseContent is IHasLocation ihl) { + if (response.Headers.Location != null) + ihl.Location = response.Headers.Location; + } + + return IDomainResult.Success(new SendResult { + Result = responseContent, + ResponseText = responseText + }); - request.Content.Headers.Remove("Content-Type"); - request.Content.Headers.Add("Content-Type", requestType); } + catch (Exception ex) { + var message = "Let's Encrypt client unhandled exception"; - var response = await _httpClient.SendAsync(request); - - if (method == HttpMethod.Post) - _nonce = response.Headers.GetValues("Replay-Nonce").First(); - - if (response.Content.Headers.ContentType.MediaType == "application/problem+json") { - var problemJson = await response.Content.ReadAsStringAsync(); - var problem = problemJson.ToObject(); - problem.RawJson = problemJson; - throw new LetsEncrytException(problem, response); + _logger.LogError(ex, message); + return IDomainResult.CriticalDependencyError?>(message); } - - var responseText = await response.Content.ReadAsStringAsync(); - - if (typeof(TResult) == typeof(string) && response.Content.Headers.ContentType.MediaType == "application/pem-certificate-chain") { - return ((TResult)(object)responseText, null); - } - - var responseContent = responseText.ToObject(); - - if (responseContent is IHasLocation ihl) { - if (response.Headers.Location != null) - ihl.Location = response.Headers.Location; - } - - return (responseContent, responseText); - } - - /// - /// Request New Nonce to be able to start POST requests - /// - /// - /// - private async Task NewNonce() { - if (_directory == null) - throw new NotImplementedException(); - - var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, _directory.NewNonce)); - return result.Headers.GetValues("Replay-Nonce").First(); } } diff --git a/src/LetsEncryptConsole/App.cs b/src/LetsEncryptConsole/App.cs index d5866a2..9d12d13 100644 --- a/src/LetsEncryptConsole/App.cs +++ b/src/LetsEncryptConsole/App.cs @@ -39,204 +39,200 @@ public class App : IApp { public async Task Run(string[] args) { - _logger.LogInformation("Letsencrypt client estarted..."); + try { + _logger.LogInformation("Let's Encrypt client. Started..."); + + foreach (var env in _appSettings.Environments?.Where(x => x.Active) ?? new List()) { - foreach (var env in _appSettings.Environments?.Where(x => x.Active) ?? new List()) { - try { _logger.LogInformation($"Let's Encrypt C# .Net Core Client, environment: {env.Name}"); //loop all customers foreach (Customer customer in _appSettings.Customers?.Where(x => x.Active) ?? new List()) { - try { - _logger.LogInformation($"Managing customer: {customer.Id} - {customer.Name} {customer.LastName}"); - //define cache folder - string cachePath = Path.Combine(_appPath, customer.Id, env.Name, "cache"); - if (!Directory.Exists(cachePath)) { - Directory.CreateDirectory(cachePath); + _logger.LogInformation($"Managing customer: {customer.Id} - {customer.Name} {customer.LastName}"); + + //define cache folder + string cachePath = Path.Combine(_appPath, customer.Id, env.Name, "cache"); + if (!Directory.Exists(cachePath)) { + Directory.CreateDirectory(cachePath); + } + + //check acme directory + var acmePath = Path.Combine(_appPath, customer.Id, env.Name, "acme"); + if (!Directory.Exists(acmePath)) { + Directory.CreateDirectory(acmePath); + } + + //loop each customer website + foreach (Site site in customer.Sites?.Where(s => s.Active) ?? new List()) { + _logger.LogInformation($"Managing site: {site.Name}"); + + + //create folder for ssl + string sslPath = Path.Combine(_appPath, customer.Id, env.Name, "ssl", site.Name); + if (!Directory.Exists(sslPath)) { + Directory.CreateDirectory(sslPath); } - //check acme directory - var acmePath = Path.Combine(_appPath, customer.Id, env.Name, "acme"); - if (!Directory.Exists(acmePath)) { - Directory.CreateDirectory(acmePath); + var cacheFile = Path.Combine(cachePath, $"{site.Name}.lets-encrypt.cache.json"); + + + + #region LetsEncrypt client configuration and local registration cache initialization + _logger.LogInformation("1. Client Initialization..."); + + await _letsEncryptService.ConfigureClient(env.Url); + + var registrationCache = (File.Exists(cacheFile) + ? File.ReadAllText(cacheFile) + : null) + .ToObject(); + + var initResult = await _letsEncryptService.Init(customer.Contacts, registrationCache); + if (!initResult.IsSuccess) { + continue; } + #endregion - //loop each customer website - foreach (Site site in customer.Sites?.Where(s => s.Active) ?? new List()) { - _logger.LogInformation($"Managing site: {site.Name}"); + #region LetsEncrypt terms of service + _logger.LogInformation($"Terms of service: {_letsEncryptService.GetTermsOfServiceUri()}"); + #endregion - try { - //create folder for ssl - string sslPath = Path.Combine(_appPath, customer.Id, env.Name, "ssl", site.Name); - if (!Directory.Exists(sslPath)) { - Directory.CreateDirectory(sslPath); - } + // get cached certificate and check if it's valid + // if valid check if cert and key exists otherwise recreate + // else continue with new certificate request + var certRes = new CachedCertificateResult(); + if (registrationCache != null && registrationCache.TryGetCachedCertificate(site.Name, out certRes)) { - var cacheFile = Path.Combine(cachePath, $"{site.Name}.lets-encrypt.cache.json"); + File.WriteAllText(Path.Combine(sslPath, $"{site.Name}.crt"), certRes.Certificate); - //1. Client initialization - _logger.LogInformation("1. Client Initialization..."); + if (certRes.PrivateKey != null) + File.WriteAllText(Path.Combine(sslPath, $"{site.Name}.key"), certRes.PrivateKey.ExportRSAPrivateKeyPem()); - #region LetsEncrypt client configuration - await _letsEncryptService.ConfigureClient(env.Url); - #endregion + _logger.LogInformation("Certificate and Key exists and valid. Restored from cache."); + } + else { - #region LetsEncrypt local registration cache initialization - var registrationCache = (File.Exists(cacheFile) - ? File.ReadAllText(cacheFile) - : null) - .ToObject(); - await _letsEncryptService.Init(customer.Contacts, registrationCache); - #endregion + //create new orders + #region LetsEncrypt new order + _logger.LogInformation("2. Client New Order..."); - #region LetsEncrypt terms of service - _logger.LogInformation($"Terms of service: {_letsEncryptService.GetTermsOfServiceUri()}"); - #endregion + var (orders, newOrderResult) = await _letsEncryptService.NewOrder(site.Hosts, site.Challenge); + if (!newOrderResult.IsSuccess || orders == null) { + continue; + } + #endregion - // get cached certificate and check if it's valid - // if valid check if cert and key exists otherwise recreate - // else continue with new certificate request - var certRes = new CachedCertificateResult(); - if (registrationCache != null && registrationCache.TryGetCachedCertificate(site.Name, out certRes)) { + if (orders.Count > 0) { + switch (site.Challenge) { + case "http-01": { + //ensure to enable static file discovery on server in .well-known/acme-challenge + //and listen on 80 port - File.WriteAllText(Path.Combine(sslPath, $"{site.Name}.crt"), certRes.Certificate); - - if (certRes.PrivateKey != null) - File.WriteAllText(Path.Combine(sslPath, $"{site.Name}.key"), certRes.PrivateKey.ExportRSAPrivateKeyPem()); + foreach (FileInfo file in new DirectoryInfo(acmePath).GetFiles()) + file.Delete(); - _logger.LogInformation("Certificate and Key exists and valid. Restored from cache."); - } - else { + foreach (var result in orders) { + Console.WriteLine($"Key: {result.Key}, Value: {result.Value}"); + string[] splitToken = result.Value.Split('.'); - //try to make new order - try { - //create new orders - Console.WriteLine("2. Client New Order..."); - - #region LetsEncrypt new order - var orders = await _letsEncryptService.NewOrder(site.Hosts, site.Challenge); - #endregion - - if (orders.Count > 0) { - switch (site.Challenge) { - case "http-01": { - //ensure to enable static file discovery on server in .well-known/acme-challenge - //and listen on 80 port - - foreach (FileInfo file in new DirectoryInfo(acmePath).GetFiles()) - file.Delete(); - - foreach (var result in orders) { - Console.WriteLine($"Key: {result.Key}, Value: {result.Value}"); - string[] splitToken = result.Value.Split('.'); - - File.WriteAllText(Path.Combine(acmePath, splitToken[0]), result.Value); - } - - foreach (FileInfo file in new DirectoryInfo(acmePath).GetFiles()) { - if (env?.SSH?.Active ?? false) { - UploadFiles(_logger, env.SSH, env.ACME.Linux.Path, file.Name, File.ReadAllBytes(file.FullName), env.ACME.Linux.Owner, env.ACME.Linux.ChangeMode); - } - else { - throw new NotImplementedException(); - } - } - - break; - } - - case "dns-01": { - //Manage DNS server MX record, depends from provider - throw new NotImplementedException(); - } - - default: { - throw new NotImplementedException(); - } + File.WriteAllText(Path.Combine(acmePath, splitToken[0]), result.Value); } - - #region LetsEncrypt complete challenges - _logger.LogInformation("3. Client Complete Challange..."); - await _letsEncryptService.CompleteChallenges(); - _logger.LogInformation("Challanges comleted."); - #endregion - - await Task.Delay(1000); - - #region Download new certificate - _logger.LogInformation("4. Download certificate..."); - var (cert, key) = await _letsEncryptService.GetCertificate(site.Name); - #endregion - - #region Persist cache - registrationCache = _letsEncryptService.GetRegistrationCache(); - File.WriteAllText(cacheFile, registrationCache.ToJson()); - #endregion - } - - #region Save cert and key to filesystem - certRes = new CachedCertificateResult(); - if (registrationCache.TryGetCachedCertificate(site.Name, out certRes)) { - - File.WriteAllText(Path.Combine(sslPath, $"{site.Name}.crt"), certRes.Certificate); - - if(certRes.PrivateKey != null) - File.WriteAllText(Path.Combine(sslPath, $"{site.Name}.key"), certRes.PrivateKey.ExportRSAPrivateKeyPem()); - - _logger.LogInformation("Certificate saved."); - - foreach (FileInfo file in new DirectoryInfo(sslPath).GetFiles()) { - + foreach (FileInfo file in new DirectoryInfo(acmePath).GetFiles()) { if (env?.SSH?.Active ?? false) { - UploadFiles(_logger, env.SSH, $"{env.SSL.Linux.Path}/{site.Name}", file.Name, File.ReadAllBytes(file.FullName), env.SSL.Linux.Owner, env.SSL.Linux.ChangeMode); + UploadFiles(_logger, env.SSH, env.ACME.Linux.Path, file.Name, File.ReadAllBytes(file.FullName), env.ACME.Linux.Owner, env.ACME.Linux.ChangeMode); } else { throw new NotImplementedException(); } } + break; } - else { - _logger.LogError("Unable to get new cached certificate."); - } - #endregion - } - catch (Exception ex) { - _logger.LogError(ex, ""); - await _letsEncryptService.GetOrder(site.Hosts); - } + case "dns-01": { + //Manage DNS server MX record, depends from provider + throw new NotImplementedException(); + } + + default: { + throw new NotImplementedException(); + } } + #region LetsEncrypt complete challenges + _logger.LogInformation("3. Client Complete Challange..."); + var completeChallengesResult = await _letsEncryptService.CompleteChallenges(); + if (!completeChallengesResult.IsSuccess) { + continue; + } + _logger.LogInformation("Challanges comleted."); + #endregion + await Task.Delay(1000); + + #region Download new certificate + _logger.LogInformation("4. Download certificate..."); + var (certData, getCertResult) = await _letsEncryptService.GetCertificate(site.Name); + if (!getCertResult.IsSuccess || certData == null) { + continue; + } + + // not used in this scenario + // var (cert, key) = certData.Value; + #endregion + + #region Persist cache + registrationCache = _letsEncryptService.GetRegistrationCache(); + File.WriteAllText(cacheFile, registrationCache.ToJson()); + #endregion + } + + #region Save cert and key to filesystem + certRes = new CachedCertificateResult(); + if (registrationCache.TryGetCachedCertificate(site.Name, out certRes)) { + + File.WriteAllText(Path.Combine(sslPath, $"{site.Name}.crt"), certRes.Certificate); + + if (certRes.PrivateKey != null) + File.WriteAllText(Path.Combine(sslPath, $"{site.Name}.key"), certRes.PrivateKey.ExportRSAPrivateKeyPem()); + + _logger.LogInformation("Certificate saved."); + + foreach (FileInfo file in new DirectoryInfo(sslPath).GetFiles()) { + + if (env?.SSH?.Active ?? false) { + UploadFiles(_logger, env.SSH, $"{env.SSL.Linux.Path}/{site.Name}", file.Name, File.ReadAllBytes(file.FullName), env.SSL.Linux.Owner, env.SSL.Linux.ChangeMode); + } + else { + throw new NotImplementedException(); + } + } } - catch (Exception ex) { - _logger.LogError(ex, "Customer unhandled error"); + else { + _logger.LogError("Unable to get new cached certificate."); } + #endregion + } } - catch (Exception ex) { - _logger.LogError(ex, "Environment unhandled error"); - } } + } - if (env.Name == "ProductionV2") { - _terminalService.Exec("systemctl restart nginx"); - } - } - catch (Exception ex) { - _logger.LogError(ex.Message.ToString()); - break; - } + _logger.LogInformation($"Let's Encrypt client. Execution complete."); } + catch (Exception ex) { + _logger.LogError(ex, $"Let's Encrypt client. Unhandled exception."); + } + + } - + private void UploadFiles( @@ -244,7 +240,7 @@ public class App : IApp { SSHClientSettings sshSettings, string workDir, string fileName, - byte [] bytes, + byte[] bytes, string owner, string changeMode ) { @@ -260,5 +256,7 @@ public class App : IApp { sshService.RunSudoCommand(sshSettings.Password, $"chown {owner} {workDir} -R"); sshService.RunSudoCommand(sshSettings.Password, $"chmod {changeMode} {workDir} -R"); + + //sshService.RunSudoCommand(sshSettings.Password, $"systemctl restart nginx"); } }