(feature): webapi implementation and containerization

This commit is contained in:
Maksym Sadovnychyy 2024-06-01 01:10:21 +02:00
parent 150b8e76fc
commit d75265c621
18 changed files with 1473 additions and 625 deletions

View File

@ -0,0 +1,501 @@
{
"info": {
"_postman_id": "728f64b6-893b-43fa-802e-ee836d1dc372",
"name": "LetsEncrypt",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "33635244"
},
"item": [
{
"name": "letsencrypt staging",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "https://acme-staging-v02.api.letsencrypt.org/directory",
"protocol": "https",
"host": [
"acme-staging-v02",
"api",
"letsencrypt",
"org"
],
"path": [
"directory"
]
},
"description": "[https://letsencrypt.status.io/](https://letsencrypt.status.io/)"
},
"response": []
},
{
"name": "letsencrypt production",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "https://acme-v02.api.letsencrypt.org/directory",
"protocol": "https",
"host": [
"acme-v02",
"api",
"letsencrypt",
"org"
],
"path": [
"directory"
]
}
},
"response": []
},
{
"name": "acme-challenge",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://maks-it.com/.well-known/acme-challenge/{{challenge}}",
"protocol": "http",
"host": [
"maks-it",
"com"
],
"path": [
".well-known",
"acme-challenge",
"{{challenge}}"
]
}
},
"response": []
},
{
"name": "acme-challenge local",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:8080/.well-known/acme-challenge/{{challenge}}",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
".well-known",
"acme-challenge",
"{{challenge}}"
]
}
},
"response": []
},
{
"name": "terms of service",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:8080/CertsFlow/TermsOfService/{{sessionId}}",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"CertsFlow",
"TermsOfService",
"{{sessionId}}"
]
}
},
"response": []
},
{
"name": "configure client",
"event": [
{
"listen": "test",
"script": {
"exec": [
"// Ensure the response status code is 200 (OK)\r",
"if (pm.response.code === 200) {\r",
" // Get the plain text response\r",
" let responseBody = pm.response.text();\r",
" \r",
" // Remove the surrounding quotes if present\r",
" responseBody = responseBody.replace(/^\"|\"$/g, '');\r",
" \r",
" // Check if the response body is a valid GUID\r",
" if (/^[0-9a-fA-F-]{36}$/.test(responseBody)) {\r",
" // Set the environment variable sessionId with the response\r",
" pm.environment.set(\"sessionId\", responseBody);\r",
" console.log(`sessionId set to: ${responseBody}`);\r",
" } else {\r",
" console.log(\"Response body is not a valid GUID\");\r",
" }\r",
"} else {\r",
" console.log(`Request failed with status code: ${pm.response.code}`);\r",
"}\r",
""
],
"type": "text/javascript",
"packages": {}
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Accept",
"value": "application/json"
}
],
"url": {
"raw": "http://localhost:8080/CertsFlow/ConfigureClient",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"CertsFlow",
"ConfigureClient"
]
}
},
"response": []
},
{
"name": "init",
"event": [
{
"listen": "test",
"script": {
"exec": [
"// Ensure the response status code is 200 (OK)\r",
"if (pm.response.code === 200) {\r",
" // Get the plain text response\r",
" let responseBody = pm.response.text();\r",
" \r",
" // Remove the surrounding quotes if present\r",
" responseBody = responseBody.replace(/^\"|\"$/g, '');\r",
" \r",
" // Check if the response body is a valid GUID\r",
" if (/^[0-9a-fA-F-]{36}$/.test(responseBody)) {\r",
" // Set the environment variable accountId with the response\r",
" pm.environment.set(\"accountId\", responseBody);\r",
" console.log(`accountId set to: ${responseBody}`);\r",
" } else {\r",
" console.log(\"Response body is not a valid GUID\");\r",
" }\r",
"} else {\r",
" console.log(`Request failed with status code: ${pm.response.code}`);\r",
"}\r",
""
],
"type": "text/javascript",
"packages": {}
}
},
{
"listen": "prerequest",
"script": {
"exec": [
"// Retrieve sessionId and accountId from environment variables or global variables\r",
"var sessionId = pm.environment.get(\"sessionId\") || pm.globals.get(\"sessionId\");\r",
"var accountId = pm.environment.get(\"accountId\") || pm.globals.get(\"accountId\");\r",
"\r",
"// Base URL without the optional accountId parameter\r",
"var baseUrl = `http://localhost:8080/CertsFlow/Init/${sessionId}`;\r",
"\r",
"// Append the accountId if it is provided\r",
"if (accountId) {\r",
" pm.request.url = `${baseUrl}/${accountId}`;\r",
"} else {\r",
" pm.request.url = baseUrl;\r",
"}"
],
"type": "text/javascript",
"packages": {}
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Accept",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\r\n \"contacts\": [\r\n \"maksym.sadovnychyy@gmail.com\"\r\n ]\r\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "http://localhost:8080/CertsFlow/Init/{{sessionId}}/{{accountId}}",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"CertsFlow",
"Init",
"{{sessionId}}",
"{{accountId}}"
]
}
},
"response": []
},
{
"name": "new order",
"event": [
{
"listen": "test",
"script": {
"exec": [
"// Ensure the response status code is 200 (OK)\r",
"if (pm.response.code === 200) {\r",
" // Parse the JSON response\r",
" let responseBody;\r",
" try {\r",
" responseBody = pm.response.json();\r",
" } catch (e) {\r",
" console.error(\"Failed to parse JSON response:\", e);\r",
" return;\r",
" }\r",
"\r",
" // Check if the response is an array and has at least one element\r",
" if (Array.isArray(responseBody) && responseBody.length > 0) {\r",
" // Get the first element of the array\r",
" const firstElement = responseBody[0];\r",
" \r",
" // Set the environment variable challenge with the first element\r",
" pm.environment.set(\"challenge\", firstElement);\r",
" console.log(`challenge set to: ${firstElement}`);\r",
" } else {\r",
" console.log(\"Response body is not an array or is empty\");\r",
" }\r",
"} else {\r",
" console.log(`Request failed with status code: ${pm.response.code}`);\r",
"}\r",
""
],
"type": "text/javascript",
"packages": {}
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Accept",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\",\r\n \"www.maks-it.com\"\r\n ],\r\n \"challengeType\": \"http-01\"\r\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "http://localhost:8080/CertsFlow/NewOrder/{{sessionId}}",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"CertsFlow",
"NewOrder",
"{{sessionId}}"
]
}
},
"response": []
},
{
"name": "complete challenges",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Accept",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "http://localhost:8080/CertsFlow/CompleteChallenges/{{sessionId}}",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"CertsFlow",
"CompleteChallenges",
"{{sessionId}}"
]
}
},
"response": []
},
{
"name": "get order",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Accept",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\",\r\n \"www.maks-it.com\"\r\n ]\r\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "http://localhost:8080/CertsFlow/GetOrder/{{sessionId}}",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"CertsFlow",
"GetOrder",
"{{sessionId}}"
]
}
},
"response": []
},
{
"name": "get certificates",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Accept",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\",\r\n \"www.maks-it.com\"\r\n ]\r\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "http://localhost:8080/CertsFlow/GetCertificates/{{sessionId}}",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"CertsFlow",
"GetCertificates",
"{{sessionId}}"
]
}
},
"response": []
},
{
"name": "apply certificates",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Accept",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\",\r\n \"www.maks-it.com\"\r\n ]\r\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "http://localhost:8080/CertsFlow/ApplyCertificates/{{sessionId}}",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"CertsFlow",
"ApplyCertificates",
"{{sessionId}}"
]
}
},
"response": []
}
]
}

102
README.md
View File

@ -6,3 +6,105 @@ Simple client to obtain Let's Encrypt HTTPS certificates developed with .net cor
* 29 Jun, 2019 - V1.0 * 29 Jun, 2019 - V1.0
* 01 Nov, 2019 - V2.0 (Dependency Injection pattern impelemtation) * 01 Nov, 2019 - V2.0 (Dependency Injection pattern impelemtation)
* 31 May, 2024 - V3.0 (Webapi and containerization)
## Haproxy configuration
```bash
#---------------------------------------------------------------------
# Example configuration for a possible web application. See the
# full configuration options online.
#
# https://www.haproxy.org/download/1.8/doc/configuration.txt
#
#---------------------------------------------------------------------
#---------------------------------------------------------------------
# Global settings
#---------------------------------------------------------------------
global
# to have these messages end up in /var/log/haproxy.log you will
# need to:
#
# 1) configure syslog to accept network log events. This is done
# by adding the '-r' option to the SYSLOGD_OPTIONS in
# /etc/sysconfig/syslog
#
# 2) configure local2 events to go to the /var/log/haproxy.log
# file. A line like the following can be added to
# /etc/sysconfig/syslog
#
# local2.* /var/log/haproxy.log
#
log 127.0.0.1 local2
chroot /var/lib/haproxy
pidfile /var/run/haproxy.pid
maxconn 4000
user haproxy
group haproxy
daemon
# Adjust the maxconn value based on your server\'s capacity
maxconn 2048
# SSL certificates directory
# ca-base /etc/ssl/certs
#crt-base /etc/ssl/private
# Default SSL certificate (used if no SNI match)
#ssl-default-bind-crt /etc/haproxy/certs/default.pem
# turn on stats unix socket
# stats socket /var/lib/haproxy/stats level admin mode 660
#stats socket /var/run/haproxy/admin.sock level admin mode 660 user haproxy group haproxy
setenv ACCOUNT_THUMBPRINT \'\'
# utilize system-wide crypto-policies
ssl-default-bind-ciphers PROFILE=SYSTEM
ssl-default-server-ciphers PROFILE=SYSTEM
#---------------------------------------------------------------------
# common defaults that all the \'listen\' and \'backend\' sections will
# use if not designated in their block
#---------------------------------------------------------------------
defaults
mode http
log global
option httplog
option dontlognull
option http-server-close
option forwardfor except 127.0.0.0/8
option redispatch
retries 3
timeout http-request 10s
timeout queue 1m
timeout connect 10s
timeout client 1m
timeout server 1m
timeout http-keep-alive 10s
timeout check 10s
maxconn 3000
#---------------------------------------------------------------------
# Frontend configuration for handling multiple domains with SNI
#---------------------------------------------------------------------
frontend web
bind :80
bind :443 ssl crt /etc/haproxy/certs/ strict-sni
# Handling for ACME challenge paths
acl acme_challenge path_beg /.well-known/acme-challenge/
use_backend acme_challenge_backend if acme_challenge
#---------------------------------------------------------------------
# Backend configuration for ACME challenge
#---------------------------------------------------------------------
backend acme_challenge_backend
server acme_challenge 127.0.0.1:8080
```

View File

@ -13,4 +13,8 @@
<None Remove="Abstractions\**" /> <None Remove="Abstractions\**" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
</ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,24 @@

using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace MaksIT.Core.Logger;
public class MyCustomLogger : ILogger {
public IDisposable? BeginScope<TState>(TState state) where TState : notnull {
throw new NotImplementedException();
}
public bool IsEnabled(LogLevel logLevel) {
throw new NotImplementedException();
}
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) {
throw new NotImplementedException();
}
}

View File

@ -0,0 +1,19 @@
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace MaksIT.Core.Logger;
public class MyCustomLoggerProvider : ILoggerProvider {
public ILogger CreateLogger(string categoryName) {
throw new NotImplementedException();
}
public void Dispose() {
throw new NotImplementedException();
}
}

View File

@ -0,0 +1,8 @@
using Microsoft.Extensions.Logging;
namespace MaksIT.Core.Logger;
public static class MyCustomLoggerExtensions {
}

View File

@ -4,5 +4,8 @@ namespace MaksIT.LetsEncrypt.Entities;
public class CachedCertificateResult { public class CachedCertificateResult {
public RSACryptoServiceProvider? PrivateKey { get; set; } public RSACryptoServiceProvider? PrivateKey { get; set; }
public string PrivateKeyPem => PrivateKey == null ? "" : PrivateKey.ExportRSAPrivateKeyPem();
public string? Certificate { get; set; } public string? Certificate { get; set; }
} }

View File

@ -9,6 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="DomainResult.Common" Version="3.2.0" /> <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.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.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.Hosting" Version="8.0.0" />

View File

@ -1,25 +1,17 @@
using System; namespace MaksIT.LetsEncrypt.Models.Responses;
namespace MaksIT.LetsEncrypt.Models.Responses;
public class AcmeDirectory { public class AcmeDirectory {
public Uri NewNonce { get; set; }
public Uri NewAccount { get; set; }
public Uri NewOrder { get; set; }
// New authorization If the ACME server does not implement pre-authorization
// (Section 7.4.1) it MUST omit the "newAuthz" field of the directory.
// [JsonProperty("newAuthz")]
// public Uri NewAuthz { get; set; }
public Uri RevokeCertificate { get; set; }
public Uri KeyChange { get; set; } public Uri KeyChange { get; set; }
public AcmeDirectoryMeta Meta { get; set; } public AcmeDirectoryMeta Meta { get; set; }
public Uri NewAccount { get; set; }
public Uri NewNonce { get; set; }
public Uri NewOrder { get; set; }
public Uri RenewalInfo { get; set; }
public Uri RevokeCertificate { get; set; }
} }
public class AcmeDirectoryMeta { public class AcmeDirectoryMeta {
public string[] CaaIdentities { get; set; }
public string TermsOfService { get; set; } public string TermsOfService { get; set; }
public string Website { get; set; }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,36 +1,21 @@
namespace LetsEncryptServer { namespace MaksIT.LetsEncryptServer {
public class Site { public class SSHClientConfing {
public required string Name { get; set; } public required string User { get; set; }
public required string[] Hosts { get; set; } public required string Key { get; set; }
public required string Challenge { get; set; }
} }
public class Customer { public class Server {
private string? _id; public required string Ip { get; set; }
public string Id { public required int Port { get; set; }
get => _id ?? string.Empty; public string Path { get; set; }
set => _id = value; public required SSHClientConfing SSH { get; set; }
}
public bool Active { get; set; }
public string[]? Contacts { get; set; }
public string? Name { get; set; }
public string? LastName { get; set; }
public Site[]? Sites { get; set; }
}
public class Server {
public required string Address { get; set; }
public required string PrivateKey { get; set; }
public required string Path { get; set; }
} }
public class Configuration { public class Configuration {
public required string Production { get; set; } public required string Production { get; set; }
public required string Staging { get; set; } public required string Staging { get; set; }
public required bool DevMode { get; set; }
public required Server Server { get; set; } public required Server Server { get; set; }
public Customer[]? Customers { get; set; }
} }
} }

View File

@ -1,225 +1,115 @@
using DomainResults.Mvc;
using MaksIT.LetsEncrypt.Entities;
using MaksIT.LetsEncrypt.Models.Responses;
using MaksIT.LetsEncrypt.Services;
using MaksIT.LetsEncryptServer.Models.Requests;
using Microsoft.AspNetCore.Identity.Data;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System.IO;
using System.Security.Cryptography.X509Certificates;
using System.Text;
namespace LetsEncryptServer.Controllers; using DomainResults.Mvc;
public class LetsEncryptSession { using MaksIT.LetsEncryptServer.Models.Requests;
public RegistrationCache? RegistrationCache { get; set; } using MaksIT.LetsEncryptServer.Services;
public Order? CurrentOrder { get; set; }
public List<AuthorizationChallenge>? Challenges { get; set; }
public string[] Hostnames { get; set; } namespace MaksIT.LetsEncryptServer.Controllers;
}
[ApiController] [ApiController]
[Route("[controller]")] [Route("[controller]")]
public class CertsFlowController : ControllerBase { public class CertsFlowController : ControllerBase {
private readonly Configuration _appSettings; private readonly IOptions<Configuration> _appSettings;
private readonly IMemoryCache _memoryCache; private readonly ICertsFlowService _certsFlowService;
private readonly ILetsEncryptService _letsEncryptService;
private readonly string _acmePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "acme");
private readonly string _certPath = Path.Combine();
MemoryCacheEntryOptions _cacheEntryOptions = new MemoryCacheEntryOptions {
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
SlidingExpiration = TimeSpan.FromMinutes(2)
};
public CertsFlowController( public CertsFlowController(
IOptions<Configuration> appSettings, IOptions<Configuration> appSettings,
IMemoryCache memoryCache, ICertsFlowService certsFlowService
ILetsEncryptService letsEncryptService
) { ) {
_memoryCache = memoryCache; _appSettings = appSettings;
_appSettings = appSettings.Value; _certsFlowService = certsFlowService;
_letsEncryptService = letsEncryptService;
if (!Directory.Exists(_acmePath))
Directory.CreateDirectory(_acmePath);
Console.WriteLine(_acmePath);
} }
[HttpGet("[action]")] /// <summary>
public async Task<IActionResult> TermsOfService() { /// Initialize certificate flow session
var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); /// </summary>
/// <returns>sessionId</returns>
if (!configResult.IsSuccess || config == null)
return configResult.ToActionResult();
return Ok(config.Meta.TermsOfService);
}
[HttpPost("[action]")] [HttpPost("[action]")]
public async Task<IActionResult> Init([FromBody] InitRequest requestData) { public async Task<IActionResult> ConfigureClient() {
var result = await _certsFlowService.ConfigureClientAsync();
var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); return result.ToActionResult();
if (!configResult.IsSuccess || config == null)
return configResult.ToActionResult();
var (cache, cacheResult) = await _letsEncryptService.Init(config.NewAccount, config.NewNonce, requestData.Contacts);
if(!cacheResult.IsSuccess || cache == null)
return cacheResult.ToActionResult();
var cacheData = new LetsEncryptSession {
RegistrationCache = cache,
};
var accountId = Guid.NewGuid().ToString();
_memoryCache.Set(accountId, cacheData, _cacheEntryOptions);
return Ok(accountId);
} }
[HttpPost("[action]/{accountId}")] [HttpGet("[action]/{sessionId}")]
public async Task<IActionResult> NewOrder(string accountId, [FromBody] NewOrderRequest requestData) { public IActionResult TermsOfService(Guid sessionId) {
var result = _certsFlowService.GetTermsOfService(sessionId);
var cacheData = (LetsEncryptSession?)_memoryCache.Get(accountId); return result.ToActionResult();
if (cacheData?.RegistrationCache?.AccountKey == null)
return BadRequest();
var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory");
if (!configResult.IsSuccess || config == null)
return configResult.ToActionResult();
var (orderData, newOrderResult) = await _letsEncryptService.NewOrder(
config.NewOrder,
config.NewNonce,
cacheData.RegistrationCache.AccountKey,
cacheData.RegistrationCache.Location.ToString(),
requestData.Hostnames,
requestData.ChallengeType);
if (!newOrderResult.IsSuccess)
return newOrderResult.ToActionResult();
var(currentOrder, results, challenges) = orderData;
if (results?.Count == 0)
return StatusCode(500);
// TODO: save results to disk
var fullPaths = new List<string>();
foreach (var result in results) {
string[] splitToken = result.Value.Split('.');
System.IO.File.WriteAllText(Path.Combine(_acmePath, splitToken[0]), result.Value);
fullPaths.Add(splitToken[0]);
}
cacheData.CurrentOrder = currentOrder;
cacheData.Challenges = challenges;
cacheData.Hostnames = requestData.Hostnames;
_memoryCache.Set(accountId, cacheData, _cacheEntryOptions);
return Ok(fullPaths);
} }
[HttpPut("[action]/{accountId}")] /// <summary>
public async Task<IActionResult> CompleteChallenges(string accountId) { /// When new certificate session is created, create or retrieve cache data by accountId
/// </summary>
var cacheData = (LetsEncryptSession?)_memoryCache.Get(accountId); /// <param name="sessionId"></param>
if (cacheData?.RegistrationCache?.AccountKey == null) /// <param name="accountId"></param>
return BadRequest(); /// <param name="requestData"></param>
/// <returns>accountId</returns>
var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); [HttpPost("[action]/{sessionId}/{accountId?}")]
if (!configResult.IsSuccess || config == null) public async Task<IActionResult> Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) {
return configResult.ToActionResult(); var resurt = await _certsFlowService.InitAsync(sessionId, accountId, requestData);
return resurt.ToActionResult();
var challengeResult = await _letsEncryptService.CompleteChallenges(
config.NewNonce,
cacheData.RegistrationCache.AccountKey,
cacheData.RegistrationCache.Location.ToString(),
cacheData.CurrentOrder,
cacheData.Challenges
);
if (!challengeResult.IsSuccess)
return challengeResult.ToActionResult();
return Ok();
} }
[HttpGet("[action]/{accountId}")] /// <summary>
public async Task<IActionResult> GetOrder(string accountId) { /// After account initialization create new order request
/// </summary>
var cacheData = (LetsEncryptSession?)_memoryCache.Get(accountId); /// <param name="sessionId"></param>
if (cacheData?.RegistrationCache?.AccountKey == null) /// <param name="requestData"></param>
return BadRequest(); /// <returns></returns>
[HttpPost("[action]/{sessionId}")]
var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); public async Task<IActionResult> NewOrder(Guid sessionId, [FromBody] NewOrderRequest requestData) {
if (!configResult.IsSuccess || config == null) var result = await _certsFlowService.NewOrderAsync(sessionId, requestData);
return configResult.ToActionResult(); return result.ToActionResult();
var (currentOrder, currentOrderResult) = await _letsEncryptService.GetOrder(
config.NewOrder,
config.NewNonce,
cacheData.RegistrationCache.AccountKey,
cacheData.RegistrationCache.Location.ToString(),
cacheData.Hostnames
);
if(!currentOrderResult.IsSuccess)
return currentOrderResult.ToActionResult();
cacheData.CurrentOrder = currentOrder;
_memoryCache.Set(accountId, cacheData, _cacheEntryOptions);
return Ok();
} }
[HttpPost("[action]/{accountId}")] /// <summary>
public async Task<IActionResult> GetCertificate(string accountId) { /// After new order request complete challenges
/// </summary>
var cacheData = (LetsEncryptSession?)_memoryCache.Get(accountId); /// <param name="sessionId"></param>
if (cacheData?.RegistrationCache?.AccountKey == null) /// <returns></returns>
return BadRequest(); [HttpPost("[action]/{sessionId}")]
public async Task<IActionResult> CompleteChallenges(Guid sessionId) {
var (config, configResult) = await _letsEncryptService.ConfigureClient("https://acme-staging-v02.api.letsencrypt.org/directory"); var result = await _certsFlowService.CompleteChallengesAsync(sessionId);
if (!configResult.IsSuccess || config == null) return result.ToActionResult();
return configResult.ToActionResult();
var (cachedCerts, certsResult) = await _letsEncryptService.GetCertificate(
config.NewOrder,
config.NewNonce,
cacheData.RegistrationCache.AccountKey,
cacheData.CurrentOrder,
cacheData.RegistrationCache.Location.ToString(),
cacheData.Hostnames
);
if (!certsResult.IsSuccess || cachedCerts == null)
return certsResult.ToActionResult();
// TODO: write certs to filesystem
foreach (var (subject, cachedCert) in cachedCerts) {
var cert = new X509Certificate2(Encoding.UTF8.GetBytes(cachedCert.Cert));
}
if (!certsResult.IsSuccess)
return BadRequest();
return Ok();
} }
/// <summary>
/// Get order status before certs retrieval
/// </summary>
/// <param name="sessionId"></param>
/// <param name="requestData"></param>
/// <returns></returns>
[HttpPost("[action]/{sessionId}")]
public async Task<IActionResult> GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) {
var result = await _certsFlowService.GetOrderAsync(sessionId, requestData);
return result.ToActionResult();
}
/// <summary>
/// Download certs to local cache
/// </summary>
/// <param name="sessionId"></param>
/// <param name="requestData"></param>
/// <returns></returns>
[HttpPost("[action]/{sessionId}")]
public async Task<IActionResult> GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData);
return result.ToActionResult();
}
/// <summary>
/// Apply certs from local cache to remote server
/// </summary>
/// <param name="sessionId"></param>
/// <param name="requestData"></param>
/// <returns></returns>
[HttpPost("[action]/{sessionId}")]
public IActionResult ApplyCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
var result = _certsFlowService.ApplyCertificates(sessionId, requestData);
return result.ToActionResult();
}
} }

View File

@ -1,24 +1,28 @@
using MaksIT.LetsEncrypt.Services; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace LetsEncryptServer.Controllers; using DomainResults.Mvc;
using MaksIT.LetsEncryptServer.Services;
namespace MaksIT.LetsEncryptServer.Controllers;
[ApiController] [ApiController]
[Route(".well-known")] [Route(".well-known")]
public class WellKnownController : ControllerBase { public class WellKnownController : ControllerBase {
private readonly Configuration _appSettings; private readonly Configuration _appSettings;
private readonly ILetsEncryptService _letsEncryptService; private readonly ICertsFlowServiceBase _certsFlowService;
private readonly string _acmePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "acme"); private readonly string _acmePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "acme");
public WellKnownController( public WellKnownController(
IOptions<Configuration> appSettings, IOptions<Configuration> appSettings,
ILetsEncryptService letsEncryptService ICertsFlowService certsFlowService
) { ) {
_appSettings = appSettings.Value; _appSettings = appSettings.Value;
_letsEncryptService = letsEncryptService; _certsFlowService = certsFlowService;
if (!Directory.Exists(_acmePath)) if (!Directory.Exists(_acmePath))
Directory.CreateDirectory(_acmePath); Directory.CreateDirectory(_acmePath);
@ -27,12 +31,8 @@ public class WellKnownController : ControllerBase {
[HttpGet("acme-challenge/{fileName}")] [HttpGet("acme-challenge/{fileName}")]
public IActionResult AcmeChallenge(string fileName) { public IActionResult AcmeChallenge(string fileName) {
var result = _certsFlowService.AcmeChallenge(fileName);
var fileContent = System.IO.File.ReadAllText(Path.Combine(_acmePath, fileName)); return result.ToActionResult();
if (fileContent == null)
return NotFound();
return Ok(fileContent);
} }
} }

View File

@ -0,0 +1,5 @@
namespace MaksIT.LetsEncryptServer.Models.Requests {
public class GetCertificatesRequest {
public string[] Hostnames { get; set; }
}
}

View File

@ -0,0 +1,5 @@
namespace MaksIT.LetsEncryptServer.Models.Requests {
public class GetOrderRequest {
public string[] Hostnames { get; set; }
}
}

View File

@ -1,7 +1,21 @@
using MaksIT.LetsEncryptServer;
using MaksIT.LetsEncrypt.Services; using MaksIT.LetsEncrypt.Services;
using Microsoft.Extensions.DependencyInjection;
using MaksIT.LetsEncryptServer.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Extract configuration
var configuration = builder.Configuration;
// Configure strongly typed settings objects
var configurationSection = configuration.GetSection("Configuration");
var appSettings = configurationSection.Get<Configuration>() ?? throw new ArgumentNullException();
// Allow configurations to be available through IOptions<Configuration>
builder.Services.Configure<Configuration>(configurationSection);
// Add services to the container. // Add services to the container.
builder.Services.AddControllers(); builder.Services.AddControllers();
@ -12,6 +26,7 @@ builder.Services.AddSwaggerGen();
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<ILetsEncryptService, LetsEncryptService>(); builder.Services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
builder.Services.AddScoped<ICertsFlowService, CertsFlowService>();
var app = builder.Build(); var app = builder.Build();

View File

@ -0,0 +1,208 @@
using System.Text;
using System.Net.Sockets;
using Microsoft.Extensions.Options;
using DomainResults.Common;
using MaksIT.LetsEncrypt.Entities;
using MaksIT.LetsEncrypt.Services;
using MaksIT.LetsEncryptServer.Models.Requests;
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography;
namespace MaksIT.LetsEncryptServer.Services;
public interface ICertsFlowServiceBase {
(string?, IDomainResult) AcmeChallenge(string fileName);
}
public interface ICertsFlowService : ICertsFlowServiceBase {
Task<(Guid?, IDomainResult)> ConfigureClientAsync();
(string?, IDomainResult) GetTermsOfService(Guid sessionId);
Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, InitRequest requestData);
Task<(List<string>?, IDomainResult)> NewOrderAsync(Guid sessionId, NewOrderRequest requestData);
Task<IDomainResult> CompleteChallengesAsync(Guid sessionId);
Task<IDomainResult> GetOrderAsync(Guid sessionId, GetOrderRequest requestData);
Task<IDomainResult> GetCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData);
(Dictionary<string, string>?, IDomainResult) ApplyCertificates(Guid sessionId, GetCertificatesRequest requestData);
}
public class CertsFlowService : ICertsFlowService {
private readonly Configuration _appSettings;
private readonly ILogger<CertsFlowService> _logger;
private readonly ILetsEncryptService _letsEncryptService;
private readonly string _acmePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "acme");
public CertsFlowService(
IOptions<Configuration> appSettings,
ILogger<CertsFlowService> logger,
ILetsEncryptService letsEncryptService
) {
_appSettings = appSettings.Value;
_logger = logger;
_letsEncryptService = letsEncryptService;
if (!Directory.Exists(_acmePath))
Directory.CreateDirectory(_acmePath);
}
public async Task<(Guid?, IDomainResult)> ConfigureClientAsync() {
var sessionId = Guid.NewGuid();
var url = _appSettings.DevMode
? _appSettings.Staging
: _appSettings.Production;
var result = await _letsEncryptService.ConfigureClient(sessionId, url);
if (!result.IsSuccess)
return (null, result);
return IDomainResult.Success(sessionId);
}
public (string?, IDomainResult) GetTermsOfService(Guid sessionId) {
var (terms, getTermsResult) = _letsEncryptService.GetTermsOfServiceUri(sessionId);
if (!getTermsResult.IsSuccess || terms == null)
return (null, getTermsResult);
return IDomainResult.Success<string>(terms);
}
public async Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, InitRequest requestData) {
var cache = default(RegistrationCache);
if (accountId == null) {
accountId = Guid.NewGuid();
}
var result = await _letsEncryptService.Init(sessionId, requestData.Contacts, cache);
return result.IsSuccess ? IDomainResult.Success<Guid>(accountId.Value) : (null, result);
}
public async Task<(List<string>?, IDomainResult)> NewOrderAsync(Guid sessionId, NewOrderRequest requestData) {
var (results, newOrderResult) = await _letsEncryptService.NewOrder(sessionId, requestData.Hostnames, requestData.ChallengeType);
if (!newOrderResult.IsSuccess || results == null)
return (null, newOrderResult);
var challenges = new List<string>();
foreach (var result in results) {
string[] splitToken = result.Value.Split('.');
File.WriteAllText(Path.Combine(_acmePath, splitToken[0]), result.Value);
challenges.Add(splitToken[0]);
}
return IDomainResult.Success(challenges);
}
public async Task<IDomainResult> CompleteChallengesAsync(Guid sessionId) {
return await _letsEncryptService.CompleteChallenges(sessionId);
}
public async Task<IDomainResult> GetOrderAsync(Guid sessionId, GetOrderRequest requestData) {
return await _letsEncryptService.GetOrder(sessionId, requestData.Hostnames);
}
public async Task<IDomainResult> GetCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData) {
foreach (var subject in requestData.Hostnames) {
var result = await _letsEncryptService.GetCertificate(sessionId, subject);
if (!result.IsSuccess)
return result;
Thread.Sleep(1000);
}
return IDomainResult.Success();
}
public (Dictionary<string, string>?, IDomainResult) ApplyCertificates(Guid sessionId, GetCertificatesRequest requestData) {
var haproxyHelper = new HaproxyCertificateUpdater();
var result = new Dictionary<string, string>();
foreach (var subject in requestData.Hostnames) {
var (cert, getCertResult) = _letsEncryptService.TryGetCachedCertificate(sessionId, subject);
if (!getCertResult.IsSuccess || cert == null)
return (null, getCertResult);
//haproxyHelper.ApplyCertificates(subject, cert.Certificate, cert.PrivateKeyPem);
var content = $"{cert.Certificate}\n{cert.PrivateKeyPem}";
result.Add(subject, content);
}
return IDomainResult.Success(result);
}
public (string?, IDomainResult) AcmeChallenge(string fileName) {
//var currentDate = DateTime.Now;
//foreach (var file in Directory.GetFiles(_acmePath)) {
// var creationTime = System.IO.File.GetCreationTime(file);
// // Calculate the time difference
// var timeDifference = currentDate - creationTime;
// // If the file is older than 1 day, delete it
// if (timeDifference.TotalDays > 1) {
// File.Delete(file);
// _logger.LogInformation($"Deleted file: {file}");
// }
//}
var fileContent = File.ReadAllText(Path.Combine(_acmePath, fileName));
if (fileContent == null)
return IDomainResult.NotFound<string?>();
return IDomainResult.Success(fileContent);
}
}
public class HaproxyCertificateUpdater {
private readonly string haproxySocketAddress = "192.168.1.4";
private readonly int haproxySocketPort = 9999;
public void ApplyCertificates(string subject, string certPem, string keyPem) {
if (string.IsNullOrEmpty(certPem) || string.IsNullOrEmpty(keyPem)) {
Console.WriteLine($"Certificate or key for {subject} is invalid");
return;
}
string certFileName = $"/etc/haproxy/certs/{subject}.pem";
string fullCert = $"{certPem}\n{keyPem}";
try {
SendCommand($"new ssl cert {certFileName}");
SendCommand($"set ssl cert {certFileName} <<\n{fullCert}\n");
SendCommand($"commit ssl cert {certFileName}");
Console.WriteLine($"Certificate for {subject} updated successfully");
}
catch (Exception ex) {
Console.WriteLine($"Exception while updating certificate for {subject}: {ex.Message}");
}
}
private void SendCommand(string command) {
using (var client = new TcpClient(haproxySocketAddress, haproxySocketPort))
using (var stream = client.GetStream())
using (var writer = new StreamWriter(stream))
using (var reader = new StreamReader(stream)) {
writer.WriteLine(command);
writer.Flush();
string response = reader.ReadToEnd();
if (!response.Contains("Success")) {
throw new Exception($"Command failed: {response}");
}
}
}
}

View File

@ -11,6 +11,16 @@
"Production": "https://acme-v02.api.letsencrypt.org/directory", "Production": "https://acme-v02.api.letsencrypt.org/directory",
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory", "Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
"ServerPath": "/etc/haproxy/certs" "DevMode": true,
"Server": {
"Ip": "192.168.1.4",
"Port": 9999,
"Path": "/etc/haproxy/certs",
"SSH": {
"User": "",
"Key": ""
}
}
} }
} }