(feature): remote server cert upload

This commit is contained in:
Maksym Sadovnychyy 2024-06-01 17:42:11 +02:00
parent d75265c621
commit a661489b4f
5 changed files with 265 additions and 120 deletions

View File

@ -49,48 +49,6 @@
},
"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": {
@ -318,7 +276,7 @@
],
"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}",
"raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\"\r\n ],\r\n \"challengeType\": \"http-01\"\r\n}",
"options": {
"raw": {
"language": "json"
@ -341,6 +299,48 @@
},
"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": "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": "complete challenges",
"request": {
@ -396,7 +396,7 @@
],
"body": {
"mode": "raw",
"raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\",\r\n \"www.maks-it.com\"\r\n ]\r\n}",
"raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\"\r\n ]\r\n}",
"options": {
"raw": {
"language": "json"
@ -435,7 +435,7 @@
],
"body": {
"mode": "raw",
"raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\",\r\n \"www.maks-it.com\"\r\n ]\r\n}",
"raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\"\r\n ]\r\n}",
"options": {
"raw": {
"language": "json"
@ -474,7 +474,7 @@
],
"body": {
"mode": "raw",
"raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\",\r\n \"www.maks-it.com\"\r\n ]\r\n}",
"raw": "{\r\n \"hostnames\": [\r\n \"maks-it.com\"\r\n ]\r\n}",
"options": {
"raw": {
"language": "json"

View File

@ -10,6 +10,23 @@ Simple client to obtain Let's Encrypt HTTPS certificates developed with .net cor
## Haproxy configuration
```bash
# Create the user with a normal shell
sudo useradd -m -s /bin/bash acme
# Set the user's password
sudo passwd acme
```
```bash
sudo passwd acme
```
```bash
sudo mkdir /etc/haproxy/certs
chown acme:root /etc/haproxy/certs
```
```bash
#---------------------------------------------------------------------
# Example configuration for a possible web application. See the
@ -59,8 +76,6 @@ global
# 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

View File

@ -1,15 +1,14 @@
namespace MaksIT.LetsEncryptServer {
public class SSHClientConfing {
public required string User { get; set; }
public required string Key { get; set; }
}
public class Server {
public required string Ip { get; set; }
public required int Port { get; set; }
public string Path { get; set; }
public required SSHClientConfing SSH { get; set; }
public required int SocketPort { get; set; }
public required int SSHPort { get; set; }
public required string Path { get; set; }
public required string Username { get; set; }
public string? Password { get; set; }
public string[]? PrivateKeys { get; set; }
}
public class Configuration {

View File

@ -7,8 +7,7 @@ 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;
using MaksIT.SSHProvider;
namespace MaksIT.LetsEncryptServer.Services;
@ -116,41 +115,30 @@ public class CertsFlowService : ICertsFlowService {
}
public (Dictionary<string, string>?, IDomainResult) ApplyCertificates(Guid sessionId, GetCertificatesRequest requestData) {
var haproxyHelper = new HaproxyCertificateUpdater();
var result = new Dictionary<string, string>();
var results = 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);
results.Add(subject, content);
}
return IDomainResult.Success(result);
}
var uploadResult = UploadToServer(results);
if (!uploadResult.IsSuccess)
return (null, uploadResult);
var notifyResult = NotifyHaproxy(results.Select(x => x.Key));
if (!notifyResult.IsSuccess)
return (null, notifyResult);
return IDomainResult.Success(results);
}
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}");
// }
//}
DeleteExporedChallenges();
var fileContent = File.ReadAllText(Path.Combine(_acmePath, fileName));
if (fileContent == null)
@ -159,49 +147,192 @@ public class CertsFlowService : ICertsFlowService {
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}";
private IDomainResult UploadToServer(Dictionary<string, string> results) {
var server = _appSettings.Server;
try {
SendCommand($"new ssl cert {certFileName}");
SendCommand($"set ssl cert {certFileName} <<\n{fullCert}\n");
SendCommand($"commit ssl cert {certFileName}");
using (SSHService sshClient = (server.PrivateKeys != null && server.PrivateKeys.Any(x => !string.IsNullOrWhiteSpace(x)))
? new SSHService(_logger, server.Ip, server.SSHPort, server.Username, server.PrivateKeys)
: !string.IsNullOrWhiteSpace(server.Password)
? new SSHService(_logger, server.Ip, server.SSHPort, server.Username, server.Password)
: throw new ArgumentNullException("Neither private keys nor password was provided")) {
Console.WriteLine($"Certificate for {subject} updated successfully");
var sshConnectResult = sshClient.Connect();
if (!sshConnectResult.IsSuccess)
return sshConnectResult;
foreach (var result in results) {
var uploadResult = sshClient.Upload(server.Path, result.Key, Encoding.UTF8.GetBytes(result.Value));
if (!uploadResult.IsSuccess)
return uploadResult;
}
}
}
catch (Exception ex) {
Console.WriteLine($"Exception while updating certificate for {subject}: {ex.Message}");
var message = "Unable to upload files to remote server";
_logger.LogError(ex, message);
return IDomainResult.CriticalDependencyError(message);
}
return IDomainResult.Success();
}
/**
abort ssl cert <certfile> : abort a transaction for a certificate file
add acl [@<ver>] <acl> <pattern> : add an acl entry
add map [@<ver>] <map> <key> <val> : add a map entry (payload supported instead of key/val)
add ssl crt-list <list> <cert> [opts]* : add to crt-list file <list> a line <cert> or a payload
clear acl [@<ver>] <acl> : clear the contents of this acl
clear counters [all] : clear max statistics counters (or all counters)
clear map [@<ver>] <map> : clear the contents of this map
clear table <table> [<filter>]* : remove an entry from a table (filter: data/key)
commit acl @<ver> <acl> : commit the ACL at this version
commit map @<ver> <map> : commit the map at this version
commit ssl cert <certfile> : commit a certificate file
del acl <acl> [<key>|#<ref>] : delete acl entries matching <key>
del map <map> [<key>|#<ref>] : delete map entries matching <key>
del ssl cert <certfile> : delete an unused certificate file
del ssl crt-list <list> <cert[:line]> : delete a line <cert> from crt-list file <list>
disable agent : disable agent checks
disable dynamic-cookie backend <bk> : disable dynamic cookies on a specific backend
disable frontend <frontend> : temporarily disable specific frontend
disable health : disable health checks
disable server (DEPRECATED) : disable a server for maintenance (use 'set server' instead)
enable agent : enable agent checks
enable dynamic-cookie backend <bk> : enable dynamic cookies on a specific backend
enable frontend <frontend> : re-enable specific frontend
enable health : enable health checks
enable server (DEPRECATED) : enable a disabled server (use 'set server' instead)
get acl <acl> <value> : report the patterns matching a sample for an ACL
get map <acl> <value> : report the keys and values matching a sample for a map
get var <name> : retrieve contents of a process-wide variable
get weight <bk>/<srv> : report a server's current weight
new ssl cert <certfile> : create a new certificate file to be used in a crt-list or a directory
operator : lower the level of the current CLI session to operator
prepare acl <acl> : prepare a new version for atomic ACL replacement
prepare map <acl> : prepare a new version for atomic map replacement
set dynamic-cookie-key backend <bk> <k> : change a backend secret key for dynamic cookies
set map <map> [<key>|#<ref>] <value> : modify a map entry
set maxconn frontend <frontend> <value> : change a frontend's maxconn setting
set maxconn global <value> : change the per-process maxconn setting
set maxconn server <bk>/<srv> : change a server's maxconn setting
set profiling <what> {auto|on|off} : enable/disable resource profiling (tasks,memory)
set rate-limit <setting> <value> : change a rate limiting value
set server <bk>/<srv> [opts] : change a server's state, weight, address or ssl
set severity-output [none|number|string]: set presence of severity level in feedback information
set ssl cert <certfile> <payload> : replace a certificate file
set ssl ocsp-response <resp|payload> : update a certificate's OCSP Response from a base64-encode DER
set ssl tls-key [id|file] <key> : set the next TLS key for the <id> or <file> listener to <key>
set table <table> key <k> [data.* <v>]* : update or create a table entry's data
set timeout [cli] <delay> : change a timeout setting
set weight <bk>/<srv> (DEPRECATED) : change a server's weight (use 'set server' instead)
show acl [@<ver>] <acl>] : report available acls or dump an acl's contents
show activity : show per-thread activity stats (for support/developers)
show backend : list backends in the current running config
show cache : show cache status
show cli level : display the level of the current CLI session
show cli sockets : dump list of cli sockets
show env [var] : dump environment variables known to the process
show errors [<px>] [request|response] : report last request and/or response errors for each proxy
show events [<sink>] [-w] [-n] : show event sink state
show fd [num] : dump list of file descriptors in use or a specific one
show info [desc|json|typed|float]* : report information about the running process
show libs : show loaded object files and libraries
show map [@ver] [map] : report available maps or dump a map's contents
show peers [dict|-] [section] : dump some information about all the peers or this peers section
show pools : report information about the memory pools usage
show profiling [<what>|<#lines>|byaddr]*: show profiling state (all,status,tasks,memory)
show resolvers [id] : dumps counters from all resolvers section and associated name servers
show schema json : report schema used for stats
show servers conn [<backend>] : dump server connections status (all or for a single backend)
show servers state [<backend>] : dump volatile server information (all or for a single backend)
show sess [id] : report the list of current sessions or dump this exact session
show ssl cert [<certfile>] : display the SSL certificates used in memory, or the details of a file
show ssl crt-list [-n] [<list>] : show the list of crt-lists or the content of a crt-list file <list>
show startup-logs : report logs emitted during HAProxy startup
show stat [desc|json|no-maint|typed|up]*: report counters for each proxy and server
show table <table> [<filter>]* : report table usage stats or dump this table's contents (filter: data/key)
show tasks : show running tasks
show threads : show some threads debugging information
show tls-keys [id|*] : show tls keys references or dump tls ticket keys when id specified
show trace [<module>] : show live tracing state
show version : show version of the current process
shutdown frontend <frontend> : stop a specific frontend
shutdown session [id] : kill a specific session
shutdown sessions server <bk>/<srv> : kill sessions on a server
trace [<module>|0] [cmd [args...]] : manage live tracing (empty to list, 0 to stop all)
user : lower the level of the current CLI session to user
help [<command>] : list matching or all commands
prompt : toggle interactive mode with prompt
quit : disconnect
*/
private IDomainResult NotifyHaproxy(IEnumerable<string> certFiles) {
var server = _appSettings.Server;
try {
using (var client = new TcpClient(server.Ip, server.SocketPort))
using (var networkStream = client.GetStream())
using (var writer = new StreamWriter(networkStream, Encoding.ASCII))
using (var reader = new StreamReader(networkStream, Encoding.ASCII)) {
writer.AutoFlush = true;
foreach (var certFile in certFiles) {
// Prepare the certificate
string prepareCommand = $"new ssl cert {server.Path}/{certFile}\n";
writer.WriteLine(prepareCommand);
string prepareResponse = reader.ReadLine();
if (prepareResponse.Contains("error", StringComparison.OrdinalIgnoreCase)) {
_logger.LogError($"Error while preparing certificate {certFile}: {prepareResponse}");
return IDomainResult.CriticalDependencyError($"Error while preparing certificate {certFile}");
}
// Commit the certificate
string commitCommand = $"commit ssl cert {server.Path}/{certFile}\n";
writer.WriteLine(commitCommand);
string commitResponse = reader.ReadLine();
if (commitResponse.Contains("error", StringComparison.OrdinalIgnoreCase)) {
_logger.LogError($"Error while committing certificate {certFile}: {commitResponse}");
return IDomainResult.CriticalDependencyError($"Error while committing certificate {certFile}");
}
}
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();
_logger.LogInformation("Certificates committed successfully.");
}
}
catch (Exception ex) {
var message = "An error occurred while committing certificates";
_logger.LogError(ex, message);
string response = reader.ReadToEnd();
if (!response.Contains("Success")) {
throw new Exception($"Command failed: {response}");
return IDomainResult.CriticalDependencyError(message);
}
return IDomainResult.Success();
}
private void DeleteExporedChallenges() {
var currentDate = DateTime.Now;
foreach (var file in Directory.GetFiles(_acmePath)) {
try {
var creationTime = 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}");
}
}
catch (Exception ex) {
_logger.LogWarning(ex, "File cannot be deleted");
}
}
}

View File

@ -15,12 +15,12 @@
"Server": {
"Ip": "192.168.1.4",
"Port": 9999,
"SocketPort": 9999,
"SSHPort": 22,
"Path": "/etc/haproxy/certs",
"SSH": {
"User": "",
"Key": ""
}
"Username": "acme",
"PrivateKeys": [],
"Password": "acme"
}
}
}