mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2025-12-31 12:10:03 +01:00
(feature): webapi implementation and containerization
This commit is contained in:
parent
150b8e76fc
commit
d75265c621
501
LetsEncrypt.postman_collection.json
Normal file
501
LetsEncrypt.postman_collection.json
Normal 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
102
README.md
@ -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
|
||||||
|
```
|
||||||
@ -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>
|
||||||
|
|||||||
24
src/Core/Logger/ConsoleLogger.cs
Normal file
24
src/Core/Logger/ConsoleLogger.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
19
src/Core/Logger/ConsoleLoggerProvider.cs
Normal file
19
src/Core/Logger/ConsoleLoggerProvider.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/Core/Logger/ConsoleLoggerServiceExtension.cs
Normal file
8
src/Core/Logger/ConsoleLoggerServiceExtension.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Logger;
|
||||||
|
|
||||||
|
public static class MyCustomLoggerExtensions {
|
||||||
|
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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; }
|
||||||
}
|
}
|
||||||
@ -1,153 +1,178 @@
|
|||||||
/**
|
|
||||||
* https://community.letsencrypt.org/t/trying-to-do-post-as-get-but-getting-post-jws-not-signed/108371
|
|
||||||
* https://tools.ietf.org/html/rfc8555#section-6.2
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
using MaksIT.LetsEncrypt.Entities;
|
using MaksIT.LetsEncrypt.Entities;
|
||||||
using MaksIT.LetsEncrypt.Exceptions;
|
using MaksIT.LetsEncrypt.Exceptions;
|
||||||
using MaksIT.Core.Extensions;
|
using MaksIT.Core.Extensions;
|
||||||
|
|
||||||
using MaksIT.LetsEncrypt.Models.Responses;
|
using MaksIT.LetsEncrypt.Models.Responses;
|
||||||
using MaksIT.LetsEncrypt.Models.Interfaces;
|
using MaksIT.LetsEncrypt.Models.Interfaces;
|
||||||
using MaksIT.LetsEncrypt.Models.Requests;
|
using MaksIT.LetsEncrypt.Models.Requests;
|
||||||
using MaksIT.LetsEncrypt.Entities.Jws;
|
using MaksIT.LetsEncrypt.Entities.Jws;
|
||||||
using DomainResults.Common;
|
using DomainResults.Common;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
namespace MaksIT.LetsEncrypt.Services;
|
namespace MaksIT.LetsEncrypt.Services;
|
||||||
|
|
||||||
public interface ILetsEncryptService {
|
public interface ILetsEncryptService {
|
||||||
Task<(AcmeDirectory?, IDomainResult)> ConfigureClient(string url);
|
Task<IDomainResult> ConfigureClient(Guid sessionId, string url);
|
||||||
Task<(RegistrationCache?, IDomainResult)> Init(Uri newAccount, Uri newNonce, string[] contacts);
|
Task<IDomainResult> Init(Guid sessionId, string[] contacts, RegistrationCache? registrationCache);
|
||||||
Task<((Order?, Dictionary<string, string>?, List<AuthorizationChallenge>?), IDomainResult)> NewOrder(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, string location, string[] hostnames, string challengeType);
|
RegistrationCache? GetRegistrationCache(Guid sessionId);
|
||||||
Task<IDomainResult> CompleteChallenges(Uri newNonce, byte[] accountKeyBytes, string location, Order currentOrder, List<AuthorizationChallenge> _challenges);
|
(string?, IDomainResult) GetTermsOfServiceUri(Guid sessionId);
|
||||||
Task<(Order?, IDomainResult)> GetOrder(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, string location, string[] hostnames);
|
Task<(Dictionary<string, string>?, IDomainResult)> NewOrder(Guid sessionId, string[] hostnames, string challengeType);
|
||||||
Task<(Dictionary<string, CertificateCache>?, IDomainResult)> GetCertificate(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, Order currentOrder, string location, string [] subjects);
|
Task<IDomainResult> CompleteChallenges(Guid sessionId);
|
||||||
|
Task<IDomainResult> GetOrder(Guid sessionId, string[] hostnames);
|
||||||
|
Task<IDomainResult> GetCertificate(Guid sessionId, string subject);
|
||||||
|
(CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LetsEncryptService : ILetsEncryptService {
|
public class LetsEncryptService : ILetsEncryptService {
|
||||||
|
|
||||||
private readonly ILogger<LetsEncryptService> _logger;
|
private readonly ILogger<LetsEncryptService> _logger;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
|
||||||
public LetsEncryptService(
|
public LetsEncryptService(
|
||||||
ILogger<LetsEncryptService> logger,
|
ILogger<LetsEncryptService> logger,
|
||||||
HttpClient httpClient
|
HttpClient httpClient,
|
||||||
) {
|
IMemoryCache cache) {
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
|
_cache = cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private State GetOrCreateState(Guid sessionId) {
|
||||||
|
if (!_cache.TryGetValue(sessionId, out State state)) {
|
||||||
|
state = new State();
|
||||||
|
_cache.Set(sessionId, state, TimeSpan.FromHours(1));
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region ConfigureClient
|
||||||
/// <summary>
|
public async Task<IDomainResult> ConfigureClient(Guid sessionId, string url) {
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="url"></param>
|
|
||||||
/// <param name="contacts"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<(AcmeDirectory?, IDomainResult)> ConfigureClient(string url) {
|
|
||||||
try {
|
try {
|
||||||
|
var state = GetOrCreateState(sessionId);
|
||||||
|
|
||||||
_httpClient.BaseAddress ??= new Uri(url);
|
_httpClient.BaseAddress ??= new Uri(url);
|
||||||
|
|
||||||
var (directory, getAcmeDirectoryResult) = await SendAsync<AcmeDirectory>(HttpMethod.Get, new Uri("directory", UriKind.Relative), false, null, null, null, null);
|
if (state.Directory == null) {
|
||||||
if (!getAcmeDirectoryResult.IsSuccess)
|
var (directory, getAcmeDirectoryResult) = await SendAsync<AcmeDirectory>(sessionId, HttpMethod.Get, new Uri("directory", UriKind.Relative), false, null);
|
||||||
return (null, getAcmeDirectoryResult);
|
if (!getAcmeDirectoryResult.IsSuccess || directory == null)
|
||||||
|
return getAcmeDirectoryResult;
|
||||||
|
|
||||||
var result = directory?.Result;
|
state.Directory = directory.Result;
|
||||||
|
}
|
||||||
|
|
||||||
return IDomainResult.Success(result);
|
return IDomainResult.Success();
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
_logger.LogError(ex, "Let's Encrypt client unhandled exception");
|
_logger.LogError(ex, "Let's Encrypt client unhandled exception");
|
||||||
return IDomainResult.CriticalDependencyError<AcmeDirectory>();
|
return IDomainResult.CriticalDependencyError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
/// <summary>
|
#region Init
|
||||||
/// Account creation or Initialization from cache
|
public async Task<IDomainResult> Init(Guid sessionId, string[] contacts, RegistrationCache? cache) {
|
||||||
/// </summary>
|
if (sessionId == Guid.Empty) {
|
||||||
/// <param name="contacts"></param>
|
_logger.LogError("Invalid sessionId");
|
||||||
/// <param name="token"></param>
|
return IDomainResult.Failed();
|
||||||
/// <returns></returns>
|
}
|
||||||
public async Task<(RegistrationCache?, IDomainResult)> Init(Uri newAccount, Uri newNonce, string[] contacts) {
|
|
||||||
|
|
||||||
try {
|
if (contacts == null || contacts.Length == 0) {
|
||||||
|
_logger.LogError("Contacts are null or empty");
|
||||||
|
return IDomainResult.Failed();
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = GetOrCreateState(sessionId);
|
||||||
|
|
||||||
|
if (state.Directory == null) {
|
||||||
|
_logger.LogError("State directory is null");
|
||||||
|
return IDomainResult.Failed();
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(Init)}...");
|
_logger.LogInformation($"Executing {nameof(Init)}...");
|
||||||
|
|
||||||
|
try {
|
||||||
var accountKey = new RSACryptoServiceProvider(4096);
|
var accountKey = new RSACryptoServiceProvider(4096);
|
||||||
var jwsService = new JwsService(accountKey);
|
|
||||||
|
|
||||||
|
if (cache != null && cache.AccountKey != null) {
|
||||||
|
state.Cache = cache;
|
||||||
|
accountKey.ImportCspBlob(cache.AccountKey);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// New Account request
|
||||||
|
state.JwsService = new JwsService(accountKey);
|
||||||
|
|
||||||
var letsEncryptOrder = new Account {
|
var letsEncryptOrder = new Account {
|
||||||
TermsOfServiceAgreed = true,
|
TermsOfServiceAgreed = true,
|
||||||
Contacts = contacts.Select(contact => $"mailto:{contact}").ToArray()
|
Contacts = contacts.Select(contact => $"mailto:{contact}").ToArray()
|
||||||
};
|
};
|
||||||
|
|
||||||
var (account, postAccuntResult) = await SendAsync<Account>(HttpMethod.Post, newAccount, false, letsEncryptOrder, accountKey, null, newNonce);
|
var (account, postAccountResult) = await SendAsync<Account>(sessionId, HttpMethod.Post, state.Directory.NewAccount, false, letsEncryptOrder);
|
||||||
if (!postAccuntResult.IsSuccess || account == null)
|
state.JwsService.SetKeyId(account.Result.Location.ToString());
|
||||||
return (null, postAccuntResult);
|
|
||||||
|
|
||||||
// Probably non necessary here
|
|
||||||
// jwsService.SetKeyId(account.Result.Location.ToString());
|
|
||||||
|
|
||||||
if (account.Result.Status != "valid") {
|
if (account.Result.Status != "valid") {
|
||||||
_logger.LogError($"Account status is not valid, was: {account.Result.Status} \r\n {account.ResponseText}");
|
_logger.LogError($"Account status is not valid, was: {account.Result.Status} \r\n {account.ResponseText}");
|
||||||
return IDomainResult.Failed<RegistrationCache>();
|
return IDomainResult.Failed();
|
||||||
}
|
}
|
||||||
|
|
||||||
var cache = new RegistrationCache {
|
state.Cache = new RegistrationCache {
|
||||||
Location = account.Result.Location,
|
Location = account.Result.Location,
|
||||||
AccountKey = accountKey.ExportCspBlob(true),
|
AccountKey = accountKey.ExportCspBlob(true),
|
||||||
Id = account.Result.Id,
|
Id = account.Result.Id,
|
||||||
Key = account.Result.Key
|
Key = account.Result.Key
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return IDomainResult.Success(cache);
|
return IDomainResult.Success();
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
var message = "Let's Encrypt client unhandled exception";
|
||||||
|
_logger.LogError(ex, message);
|
||||||
|
return IDomainResult.CriticalDependencyError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
public RegistrationCache? GetRegistrationCache(Guid sessionId) {
|
||||||
|
var state = GetOrCreateState(sessionId);
|
||||||
|
return state.Cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region GetTermsOfService
|
||||||
|
public (string?, IDomainResult) GetTermsOfServiceUri(Guid sessionId) {
|
||||||
|
try {
|
||||||
|
var state = GetOrCreateState(sessionId);
|
||||||
|
|
||||||
|
_logger.LogInformation($"Executing {nameof(GetTermsOfServiceUri)}...");
|
||||||
|
|
||||||
|
if (state.Directory == null) {
|
||||||
|
return IDomainResult.Failed<string?>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return IDomainResult.Success(state.Directory.Meta.TermsOfService);
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
var message = "Let's Encrypt client unhandled exception";
|
var message = "Let's Encrypt client unhandled exception";
|
||||||
|
|
||||||
_logger.LogError(ex, message);
|
_logger.LogError(ex, message);
|
||||||
return IDomainResult.CriticalDependencyError<RegistrationCache>(message);
|
return IDomainResult.CriticalDependencyError<string?>(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
/// <summary>
|
#region NewOrder
|
||||||
/// Create new Certificate Order. In case you want the wildcard-certificate you must select dns-01 challange.
|
public async Task<(Dictionary<string, string>?, IDomainResult)> NewOrder(Guid sessionId, string[] hostnames, string challengeType) {
|
||||||
/// <para>
|
|
||||||
/// Available challange types:
|
|
||||||
/// <list type="number">
|
|
||||||
/// <item>dns-01</item>
|
|
||||||
/// <item>http-01</item>
|
|
||||||
/// <item>tls-alpn-01</item>
|
|
||||||
/// </list>
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="hostnames"></param>
|
|
||||||
/// <param name="challengeType"></param>
|
|
||||||
/// <param name="token"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<((Order?, Dictionary<string, string>?, List<AuthorizationChallenge>?), IDomainResult)> NewOrder(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, string location, string[] hostnames, string challengeType) {
|
|
||||||
try {
|
try {
|
||||||
|
var state = GetOrCreateState(sessionId);
|
||||||
var accountKey = new RSACryptoServiceProvider(4096);
|
|
||||||
accountKey.ImportCspBlob(accountKeyBytes);
|
|
||||||
|
|
||||||
var jwsService = new JwsService(accountKey);
|
|
||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(NewOrder)}...");
|
_logger.LogInformation($"Executing {nameof(NewOrder)}...");
|
||||||
|
|
||||||
var currentOrder = default(Order);
|
state.Challenges.Clear();
|
||||||
var results = new Dictionary<string, string>();
|
|
||||||
var challenges = new List<AuthorizationChallenge>();
|
|
||||||
|
|
||||||
var letsEncryptOrder = new Order {
|
var letsEncryptOrder = new Order {
|
||||||
Expires = DateTime.UtcNow.AddDays(2),
|
Expires = DateTime.UtcNow.AddDays(2),
|
||||||
@ -157,132 +182,97 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
}).ToArray()
|
}).ToArray()
|
||||||
};
|
};
|
||||||
|
|
||||||
var (order, postNewOrderResult) = await SendAsync<Order>(HttpMethod.Post, newOrder, false, letsEncryptOrder, accountKey, location, newNonce);
|
var (order, postNewOrderResult) = await SendAsync<Order>(sessionId, HttpMethod.Post, state.Directory.NewOrder, false, letsEncryptOrder);
|
||||||
if (!postNewOrderResult.IsSuccess) {
|
if (!postNewOrderResult.IsSuccess) {
|
||||||
return ((null, null, null), postNewOrderResult);
|
return (null, postNewOrderResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (order.Result.Status == "ready")
|
if (order.Result.Status == "ready")
|
||||||
return IDomainResult.Success((currentOrder, results, challenges));
|
return IDomainResult.Success(new Dictionary<string, string>());
|
||||||
|
|
||||||
if (order.Result.Status != "pending") {
|
if (order.Result.Status != "pending") {
|
||||||
_logger.LogError($"Created new order and expected status 'pending', but got: {order.Result.Status} \r\n {order.Result}");
|
_logger.LogError($"Created new order and expected status 'pending', but got: {order.Result.Status} \r\n {order.Result}");
|
||||||
return IDomainResult.Failed<(Order?, Dictionary<string, string>?, List<AuthorizationChallenge>?)>();
|
return IDomainResult.Failed<Dictionary<string, string>?>();
|
||||||
}
|
}
|
||||||
|
|
||||||
currentOrder = order.Result;
|
state.CurrentOrder = order.Result;
|
||||||
|
|
||||||
|
var results = new Dictionary<string, string>();
|
||||||
foreach (var item in currentOrder.Authorizations) {
|
foreach (var item in order.Result.Authorizations) {
|
||||||
|
var (challengeResponse, postAuthorisationChallengeResult) = await SendAsync<AuthorizationChallengeResponse>(sessionId, HttpMethod.Post, item, true, null);
|
||||||
var (challengeResponse, postAuthorisationChallengeResult) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Post, item, true, null, accountKey, location, newNonce);
|
|
||||||
if (!postAuthorisationChallengeResult.IsSuccess) {
|
if (!postAuthorisationChallengeResult.IsSuccess) {
|
||||||
return ((null, null, null), postAuthorisationChallengeResult);
|
return (null, postAuthorisationChallengeResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (challengeResponse.Result.Status == "valid")
|
if (challengeResponse.Result.Status == "valid")
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (challengeResponse.Result.Status != "pending") {
|
if (challengeResponse.Result.Status != "pending") {
|
||||||
_logger.LogError($"Expected autorization status 'pending', but got: {currentOrder.Status} \r\n {challengeResponse.ResponseText}");
|
_logger.LogError($"Expected authorization status 'pending', but got: {order.Result.Status} \r\n {challengeResponse.ResponseText}");
|
||||||
return IDomainResult.Failed<(Order?, Dictionary<string, string>?, List<AuthorizationChallenge>?)>();
|
return IDomainResult.Failed<Dictionary<string, string>?>();
|
||||||
}
|
}
|
||||||
|
|
||||||
var challenge = challengeResponse.Result.Challenges.First(x => x.Type == challengeType);
|
var challenge = challengeResponse.Result.Challenges.First(x => x.Type == challengeType);
|
||||||
challenges.Add(challenge);
|
state.Challenges.Add(challenge);
|
||||||
|
|
||||||
var keyToken = jwsService.GetKeyAuthorization(challenge.Token);
|
var keyToken = state.JwsService.GetKeyAuthorization(challenge.Token);
|
||||||
|
|
||||||
switch (challengeType) {
|
switch (challengeType) {
|
||||||
|
case "dns-01":
|
||||||
// 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()) {
|
using (var sha256 = SHA256.Create()) {
|
||||||
var dnsToken = jwsService.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken)));
|
var dnsToken = state.JwsService.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken)));
|
||||||
results[challengeResponse.Result.Identifier.Value] = dnsToken;
|
results[challengeResponse.Result.Identifier.Value] = dnsToken;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
|
case "http-01":
|
||||||
// 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;
|
results[challengeResponse.Result.Identifier.Value] = keyToken;
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: reurn challenges
|
return IDomainResult.Success(results);
|
||||||
return IDomainResult.Success((currentOrder, results, challenges));
|
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
var message = "Let's Encrypt client unhandled exception";
|
var message = "Let's Encrypt client unhandled exception";
|
||||||
|
|
||||||
_logger.LogError(ex, message);
|
_logger.LogError(ex, message);
|
||||||
return IDomainResult.CriticalDependencyError<(Order?, Dictionary<string, string>?, List<AuthorizationChallenge>?)>(message);
|
return IDomainResult.CriticalDependencyError<Dictionary<string, string>?>(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
/// <summary>
|
#region CompleteChallenges
|
||||||
///
|
public async Task<IDomainResult> CompleteChallenges(Guid sessionId) {
|
||||||
/// </summary>
|
|
||||||
/// <returns></returns>
|
|
||||||
/// <exception cref="InvalidOperationException"></exception>
|
|
||||||
public async Task<IDomainResult> CompleteChallenges(Uri newNonce, byte[] accountKeyBytes, string location, Order currentOrder, List<AuthorizationChallenge> challenges) {
|
|
||||||
try {
|
try {
|
||||||
|
var state = GetOrCreateState(sessionId);
|
||||||
var accountKey = new RSACryptoServiceProvider(4096);
|
|
||||||
accountKey.ImportCspBlob(accountKeyBytes);
|
|
||||||
var jwsService = new JwsService(accountKey);
|
|
||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(CompleteChallenges)}...");
|
_logger.LogInformation($"Executing {nameof(CompleteChallenges)}...");
|
||||||
|
|
||||||
if (currentOrder?.Identifiers == null) {
|
if (state.CurrentOrder?.Identifiers == null) {
|
||||||
return IDomainResult.Failed();
|
return IDomainResult.Failed();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var index = 0; index < challenges.Count; index++) {
|
for (var index = 0; index < state.Challenges.Count; index++) {
|
||||||
|
var challenge = state.Challenges[index];
|
||||||
var challenge = challenges[index];
|
|
||||||
|
|
||||||
var start = DateTime.UtcNow;
|
var start = DateTime.UtcNow;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
var authorizeChallenge = new AuthorizeChallenge();
|
var authorizeChallenge = new AuthorizeChallenge();
|
||||||
|
|
||||||
switch (challenge.Type) {
|
switch (challenge.Type) {
|
||||||
case "dns-01": {
|
case "dns-01":
|
||||||
authorizeChallenge.KeyAuthorization = jwsService.GetKeyAuthorization(challenge.Token);
|
authorizeChallenge.KeyAuthorization = state.JwsService.GetKeyAuthorization(challenge.Token);
|
||||||
//var (result, responseText) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Post, challenge.Url, authorizeChallenge, token);
|
break;
|
||||||
|
|
||||||
|
case "http-01":
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "http-01": {
|
var (authChallenge, postAuthChallengeResult) = await SendAsync<AuthorizationChallengeResponse>(sessionId, HttpMethod.Post, challenge.Url, false, "{}");
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var (authChallenge, postAuthChallengeResult) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Post, challenge.Url, false, "{}", accountKey, location, newNonce);
|
|
||||||
if (!postAuthChallengeResult.IsSuccess) {
|
if (!postAuthChallengeResult.IsSuccess) {
|
||||||
return postAuthChallengeResult;
|
return postAuthChallengeResult;
|
||||||
}
|
}
|
||||||
@ -291,7 +281,7 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
if (authChallenge.Result.Status != "pending") {
|
if (authChallenge.Result.Status != "pending") {
|
||||||
_logger.LogError($"Failed autorization of {currentOrder.Identifiers[index].Value} \r\n {authChallenge.ResponseText}");
|
_logger.LogError($"Challenge failed with status {authChallenge.Result.Status} \r\n {authChallenge.ResponseText}");
|
||||||
return IDomainResult.Failed();
|
return IDomainResult.Failed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -311,20 +301,15 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
return IDomainResult.CriticalDependencyError(message);
|
return IDomainResult.CriticalDependencyError(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
/// <summary>
|
#region GetOrder
|
||||||
///
|
public async Task<IDomainResult> GetOrder(Guid sessionId, string[] hostnames) {
|
||||||
/// </summary>
|
|
||||||
/// <param name="hostnames"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<(Order?, IDomainResult)> GetOrder(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, string location, string[] hostnames) {
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var accountKey = new RSACryptoServiceProvider(4096);
|
|
||||||
accountKey.ImportCspBlob(accountKeyBytes);
|
|
||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(GetOrder)}");
|
_logger.LogInformation($"Executing {nameof(GetOrder)}");
|
||||||
|
|
||||||
|
var state = GetOrCreateState(sessionId);
|
||||||
|
|
||||||
var letsEncryptOrder = new Order {
|
var letsEncryptOrder = new Order {
|
||||||
Expires = DateTime.UtcNow.AddDays(2),
|
Expires = DateTime.UtcNow.AddDays(2),
|
||||||
Identifiers = hostnames.Select(hostname => new OrderIdentifier {
|
Identifiers = hostnames.Select(hostname => new OrderIdentifier {
|
||||||
@ -333,48 +318,32 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
}).ToArray()
|
}).ToArray()
|
||||||
};
|
};
|
||||||
|
|
||||||
var (order, postOrderResult) = await SendAsync<Order>(HttpMethod.Post, newOrder, false, letsEncryptOrder, accountKey, location, newNonce);
|
var (order, postOrderResult) = await SendAsync<Order>(sessionId, HttpMethod.Post, state.Directory.NewOrder, false, letsEncryptOrder);
|
||||||
if (!postOrderResult.IsSuccess)
|
if (!postOrderResult.IsSuccess)
|
||||||
return (null, postOrderResult);
|
return postOrderResult;
|
||||||
|
|
||||||
var currentOrder = order.Result;
|
state.CurrentOrder = order.Result;
|
||||||
|
|
||||||
return IDomainResult.Success(currentOrder);
|
return IDomainResult.Success();
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
var message = "Let's Encrypt client unhandled exception";
|
var message = "Let's Encrypt client unhandled exception";
|
||||||
|
|
||||||
_logger.LogError(ex, message);
|
_logger.LogError(ex, message);
|
||||||
return IDomainResult.CriticalDependencyError<Order?>(message);
|
return IDomainResult.CriticalDependencyError(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
/// <summary>
|
#region GetCertificates
|
||||||
///
|
public async Task<IDomainResult> GetCertificate(Guid sessionId, string subject) {
|
||||||
/// </summary>
|
|
||||||
/// <param name="subject"></param>
|
|
||||||
/// <returns>Cert and Private key</returns>
|
|
||||||
/// <exception cref="InvalidOperationException"></exception>
|
|
||||||
public async Task<(Dictionary<string, CertificateCache>?, IDomainResult)> GetCertificate(Uri newOrder, Uri newNonce, byte[] accountKeyBytes, Order currentOrder, string location, string [] subjects) {
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
var state = GetOrCreateState(sessionId);
|
||||||
var accountKey = new RSACryptoServiceProvider(4096);
|
|
||||||
accountKey.ImportCspBlob(accountKeyBytes);
|
|
||||||
|
|
||||||
var jwsService = new JwsService(accountKey);
|
|
||||||
|
|
||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(GetCertificate)}...");
|
_logger.LogInformation($"Executing {nameof(GetCertificate)}...");
|
||||||
|
|
||||||
var cachedCerts = new Dictionary<string, CertificateCache>();
|
if (state.CurrentOrder == null) {
|
||||||
|
return IDomainResult.Failed();
|
||||||
|
|
||||||
foreach (var subject in subjects) {
|
|
||||||
|
|
||||||
|
|
||||||
if (currentOrder == null) {
|
|
||||||
return IDomainResult.Failed<Dictionary<string, CertificateCache>>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var key = new RSACryptoServiceProvider(4096);
|
var key = new RSACryptoServiceProvider(4096);
|
||||||
@ -382,34 +351,32 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
|
||||||
var san = new SubjectAlternativeNameBuilder();
|
var san = new SubjectAlternativeNameBuilder();
|
||||||
foreach (var host in currentOrder.Identifiers)
|
foreach (var host in state.CurrentOrder.Identifiers)
|
||||||
san.AddDnsName(host.Value);
|
san.AddDnsName(host.Value);
|
||||||
|
|
||||||
csr.CertificateExtensions.Add(san.Build());
|
csr.CertificateExtensions.Add(san.Build());
|
||||||
|
|
||||||
var letsEncryptOrder = new FinalizeRequest {
|
var letsEncryptOrder = new FinalizeRequest {
|
||||||
Csr = jwsService.Base64UrlEncoded(csr.CreateSigningRequest())
|
Csr = state.JwsService.Base64UrlEncoded(csr.CreateSigningRequest())
|
||||||
};
|
};
|
||||||
|
|
||||||
Uri? certificateUrl = default;
|
Uri? certificateUrl = default;
|
||||||
|
|
||||||
|
|
||||||
var start = DateTime.UtcNow;
|
var start = DateTime.UtcNow;
|
||||||
|
|
||||||
while (certificateUrl == null) {
|
while (certificateUrl == null) {
|
||||||
// https://community.letsencrypt.org/t/breaking-changes-in-asynchronous-order-finalization-api/195882
|
// https://community.letsencrypt.org/t/breaking-changes-in-asynchronous-order-finalization-api/195882
|
||||||
await GetOrder(newOrder, newNonce, accountKeyBytes, location, currentOrder.Identifiers.Select(x => x.Value).ToArray());
|
await GetOrder(sessionId, state.CurrentOrder.Identifiers.Select(x => x.Value).ToArray());
|
||||||
|
|
||||||
if (currentOrder.Status == "ready") {
|
if (state.CurrentOrder.Status == "ready") {
|
||||||
var (order, postOrderResult) = await SendAsync<Order>(HttpMethod.Post, currentOrder.Finalize, false, letsEncryptOrder, accountKey, location, newNonce);
|
var (order, postOrderResult) = await SendAsync<Order>(sessionId, HttpMethod.Post, state.CurrentOrder.Finalize, false, letsEncryptOrder);
|
||||||
if (!postOrderResult.IsSuccess || order?.Result == null)
|
if (!postOrderResult.IsSuccess || order?.Result == null)
|
||||||
return (null, postOrderResult);
|
return postOrderResult;
|
||||||
|
|
||||||
|
|
||||||
if (order.Result.Status == "processing") {
|
if (order.Result.Status == "processing") {
|
||||||
(order, postOrderResult) = await SendAsync<Order>(HttpMethod.Post, currentOrder.Location, true, null, accountKey, location, newNonce);
|
(order, postOrderResult) = await SendAsync<Order>(sessionId, HttpMethod.Post, state.CurrentOrder.Location, true, null);
|
||||||
if (!postOrderResult.IsSuccess || order?.Result == null)
|
if (!postOrderResult.IsSuccess || order?.Result == null)
|
||||||
return (null, postOrderResult);
|
return postOrderResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (order.Result.Status == "valid") {
|
if (order.Result.Status == "valid") {
|
||||||
@ -423,64 +390,209 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
await Task.Delay(1000);
|
await Task.Delay(1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
var (pem, postPemResult) = await SendAsync<string>(HttpMethod.Post, certificateUrl, true, null, accountKey, location, newNonce);
|
var (pem, postPemResult) = await SendAsync<string>(sessionId, HttpMethod.Post, certificateUrl, true, null);
|
||||||
if (!postPemResult.IsSuccess || pem?.Result == null)
|
if (!postPemResult.IsSuccess || pem?.Result == null)
|
||||||
return (null, postPemResult);
|
return postPemResult;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
cachedCerts.Add(subject, new CertificateCache {
|
|
||||||
Cert = pem.Result,
|
|
||||||
Private = key.ExportCspBlob(true)
|
|
||||||
});
|
|
||||||
|
|
||||||
//var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem.Result));
|
|
||||||
|
|
||||||
|
if (state.Cache == null) {
|
||||||
|
_logger.LogError($"{nameof(state.Cache)} is null");
|
||||||
|
return IDomainResult.Failed();
|
||||||
}
|
}
|
||||||
|
|
||||||
return IDomainResult.Success(cachedCerts);
|
state.Cache.CachedCerts ??= new Dictionary<string, CertificateCache>();
|
||||||
|
state.Cache.CachedCerts[subject] = new CertificateCache {
|
||||||
|
Cert = pem.Result,
|
||||||
|
Private = key.ExportCspBlob(true)
|
||||||
|
};
|
||||||
|
|
||||||
|
var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem.Result));
|
||||||
|
|
||||||
|
return IDomainResult.Success();
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
var message = "Let's Encrypt client unhandled exception";
|
var message = "Let's Encrypt client unhandled exception";
|
||||||
|
|
||||||
_logger.LogError(ex, message);
|
_logger.LogError(ex, message);
|
||||||
return IDomainResult.CriticalDependencyError<Dictionary<string, CertificateCache>?>(message);
|
return IDomainResult.CriticalDependencyError(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region TryGetCachedCertificate
|
||||||
|
public (CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject) {
|
||||||
|
|
||||||
|
var state = GetOrCreateState(sessionId);
|
||||||
|
|
||||||
|
var certRes = new CachedCertificateResult();
|
||||||
|
if (state.Cache != null && state.Cache.TryGetCachedCertificate(subject, out certRes)) {
|
||||||
|
return IDomainResult.Success(certRes);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
return IDomainResult.Failed<CachedCertificateResult?>();
|
||||||
///
|
}
|
||||||
/// </summary>
|
#endregion
|
||||||
/// <param name="token"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<IDomainResult> KeyChange() {
|
public Task<IDomainResult> KeyChange(Guid sessionId) {
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<IDomainResult> RevokeCertificate(Guid sessionId) {
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="token"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<IDomainResult> RevokeCertificate() {
|
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region SendAsync
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Request New Nonce to be able to start POST requests
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="token"></param>
|
/// <typeparam name="TResult"></typeparam>
|
||||||
|
/// <param name="sessionId"></param>
|
||||||
|
/// <param name="method"></param>
|
||||||
|
/// <param name="uri"></param>
|
||||||
|
/// <param name="isPostAsGet"></param>
|
||||||
|
/// <param name="requestModel"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
private async Task<(string?, IDomainResult)> NewNonce(Uri newNonce) {
|
//private async Task<(SendResult<TResult>?, IDomainResult)> SendAsync<TResult>(Guid sessionId, HttpMethod method, Uri uri, bool isPostAsGet, object? requestModel) {
|
||||||
|
// try {
|
||||||
|
// var state = GetOrCreateState(sessionId);
|
||||||
|
|
||||||
|
// _logger.LogInformation($"Executing {nameof(SendAsync)}...");
|
||||||
|
|
||||||
|
// var request = new HttpRequestMessage(method, uri);
|
||||||
|
|
||||||
|
// if (uri.OriginalString != "directory") {
|
||||||
|
// var (nonce, newNonceResult) = await NewNonce(sessionId);
|
||||||
|
// if (!newNonceResult.IsSuccess || nonce == null) {
|
||||||
|
// return (null, newNonceResult);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// state.Nonce = nonce;
|
||||||
|
// }
|
||||||
|
// else {
|
||||||
|
// state.Nonce = default;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (requestModel != null || isPostAsGet) {
|
||||||
|
// var jwsHeader = new JwsHeader {
|
||||||
|
// Url = uri,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// if (state.Nonce != null)
|
||||||
|
// jwsHeader.Nonce = state.Nonce;
|
||||||
|
|
||||||
|
// var encodedMessage = isPostAsGet
|
||||||
|
// ? state.JwsService.Encode(jwsHeader)
|
||||||
|
// : state.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);
|
||||||
|
|
||||||
|
// if (method == HttpMethod.Post)
|
||||||
|
// state.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<Problem>(), response);
|
||||||
|
|
||||||
|
// if (response.Content.Headers.ContentType?.MediaType == "application/pem-certificate-chain" && typeof(TResult) == typeof(string)) {
|
||||||
|
// return IDomainResult.Success(new SendResult<TResult> {
|
||||||
|
// Result = (TResult)(object)responseText
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// var responseContent = responseText.ToObject<TResult>();
|
||||||
|
|
||||||
|
// if (responseContent is IHasLocation ihl) {
|
||||||
|
// if (response.Headers.Location != null)
|
||||||
|
// ihl.Location = response.Headers.Location;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return IDomainResult.Success(new SendResult<TResult> {
|
||||||
|
// Result = responseContent,
|
||||||
|
// ResponseText = responseText
|
||||||
|
// });
|
||||||
|
|
||||||
|
// }
|
||||||
|
// catch (Exception ex) {
|
||||||
|
// var message = "Let's Encrypt client unhandled exception";
|
||||||
|
|
||||||
|
// _logger.LogError(ex, message);
|
||||||
|
// return IDomainResult.CriticalDependencyError<SendResult<TResult>?>(message);
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
private async Task<(SendResult<TResult>?, IDomainResult)> SendAsync<TResult>(
|
||||||
|
Guid sessionId,
|
||||||
|
HttpMethod method,
|
||||||
|
Uri uri,
|
||||||
|
bool isPostAsGet,
|
||||||
|
object? requestModel
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
|
var state = GetOrCreateState(sessionId);
|
||||||
|
_logger.LogInformation($"Executing {nameof(SendAsync)}...");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(method, uri);
|
||||||
|
await HandleNonceAsync(sessionId, uri, state);
|
||||||
|
|
||||||
|
if (requestModel != null || isPostAsGet) {
|
||||||
|
var jwsHeader = CreateJwsHeader(uri, state.Nonce);
|
||||||
|
var json = EncodeMessage(isPostAsGet, requestModel, state, jwsHeader);
|
||||||
|
PrepareRequestContent(request, json, method);
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
|
await UpdateStateNonceIfNeededAsync(response, state, method);
|
||||||
|
|
||||||
|
var responseText = await response.Content.ReadAsStringAsync();
|
||||||
|
await HandleProblemResponseAsync(response, responseText);
|
||||||
|
|
||||||
|
var result = ProcessResponseContent<TResult>(response, responseText);
|
||||||
|
return IDomainResult.Success(result);
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
const string message = "Let's Encrypt client unhandled exception";
|
||||||
|
_logger.LogError(ex, message);
|
||||||
|
return IDomainResult.CriticalDependencyError<SendResult<TResult>?>(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleNonceAsync(Guid sessionId, Uri uri, State state) {
|
||||||
|
if (uri.OriginalString != "directory") {
|
||||||
|
var (nonce, newNonceResult) = await NewNonce(sessionId);
|
||||||
|
if (!newNonceResult.IsSuccess || nonce == null) {
|
||||||
|
throw new InvalidOperationException("Failed to retrieve nonce.");
|
||||||
|
}
|
||||||
|
state.Nonce = nonce;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
state.Nonce = default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string?, IDomainResult)> NewNonce(Guid sessionId) {
|
||||||
|
try {
|
||||||
|
var state = GetOrCreateState(sessionId);
|
||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(NewNonce)}...");
|
_logger.LogInformation($"Executing {nameof(NewNonce)}...");
|
||||||
|
|
||||||
var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, newNonce));
|
if (state.Directory == null)
|
||||||
return IDomainResult.Success(result.Headers.GetValues("Replay-Nonce").First());
|
IDomainResult.Failed();
|
||||||
|
|
||||||
|
var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, state.Directory.NewNonce));
|
||||||
|
return IDomainResult.Success(result.Headers.GetValues("Replay-Nonce").First());
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
var message = "Let's Encrypt client unhandled exception";
|
var message = "Let's Encrypt client unhandled exception";
|
||||||
@ -490,98 +602,62 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private JwsHeader CreateJwsHeader(Uri uri, string? nonce) {
|
||||||
/// Main method used to send data to LetsEncrypt
|
return new JwsHeader {
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="TResult"></typeparam>
|
|
||||||
/// <param name="method"></param>
|
|
||||||
/// <param name="uri"></param>
|
|
||||||
/// <param name="requestModel"></param>
|
|
||||||
/// <param name="token"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
private async Task<(SendResult<TResult>?, IDomainResult)> SendAsync<TResult>(HttpMethod method, Uri uri, bool isPostAsGet, object? requestModel, RSACryptoServiceProvider? accountKey, string? location, Uri? newNonce) {
|
|
||||||
try {
|
|
||||||
var _nonce = default(string?);
|
|
||||||
|
|
||||||
_logger.LogInformation($"Executing {nameof(SendAsync)}...");
|
|
||||||
|
|
||||||
var request = new HttpRequestMessage(method, uri);
|
|
||||||
|
|
||||||
if (uri.OriginalString != "directory") {
|
|
||||||
var (nonce, newNonceResult) = await NewNonce(newNonce);
|
|
||||||
if (!newNonceResult.IsSuccess || nonce == null) {
|
|
||||||
return (null, newNonceResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
_nonce = nonce;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestModel != null || isPostAsGet) {
|
|
||||||
|
|
||||||
if (accountKey == null)
|
|
||||||
return IDomainResult.Failed<SendResult<TResult>?>();
|
|
||||||
|
|
||||||
var jwsService = new JwsService(accountKey);
|
|
||||||
if(location != null)
|
|
||||||
jwsService.SetKeyId(location);
|
|
||||||
|
|
||||||
var jwsHeader = new JwsHeader {
|
|
||||||
Url = uri,
|
Url = uri,
|
||||||
|
Nonce = nonce
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
private string EncodeMessage(bool isPostAsGet, object? requestModel, State state, JwsHeader jwsHeader) {
|
||||||
|
return isPostAsGet
|
||||||
|
? state.JwsService.Encode(jwsHeader).ToJson()
|
||||||
|
: state.JwsService.Encode(requestModel, jwsHeader).ToJson();
|
||||||
|
}
|
||||||
|
|
||||||
if (method == HttpMethod.Post)
|
private void PrepareRequestContent(HttpRequestMessage request, string json, HttpMethod method) {
|
||||||
_nonce = response.Headers.GetValues("Replay-Nonce").First();
|
request.Content = new StringContent(json);
|
||||||
|
var contentType = method == HttpMethod.Post ? "application/jose+json" : "application/json";
|
||||||
|
request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||||
|
}
|
||||||
|
|
||||||
var responseText = await response.Content.ReadAsStringAsync();
|
private async Task UpdateStateNonceIfNeededAsync(HttpResponseMessage response, State state, HttpMethod method) {
|
||||||
|
if (method == HttpMethod.Post && response.Headers.Contains("Replay-Nonce")) {
|
||||||
|
state.Nonce = response.Headers.GetValues("Replay-Nonce").First();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (response.Content.Headers.ContentType?.MediaType == "application/problem+json")
|
private async Task HandleProblemResponseAsync(HttpResponseMessage response, string responseText) {
|
||||||
|
if (response.Content.Headers.ContentType?.MediaType == "application/problem+json") {
|
||||||
throw new LetsEncrytException(responseText.ToObject<Problem>(), response);
|
throw new LetsEncrytException(responseText.ToObject<Problem>(), response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SendResult<TResult> ProcessResponseContent<TResult>(HttpResponseMessage response, string responseText) {
|
||||||
if (response.Content.Headers.ContentType?.MediaType == "application/pem-certificate-chain" && typeof(TResult) == typeof(string)) {
|
if (response.Content.Headers.ContentType?.MediaType == "application/pem-certificate-chain" && typeof(TResult) == typeof(string)) {
|
||||||
return IDomainResult.Success(new SendResult<TResult> {
|
return new SendResult<TResult> {
|
||||||
Result = (TResult)(object)responseText
|
Result = (TResult)(object)responseText
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
var responseContent = responseText.ToObject<TResult>();
|
var responseContent = responseText.ToObject<TResult>();
|
||||||
|
if (responseContent is IHasLocation ihl && response.Headers.Location != null) {
|
||||||
if (responseContent is IHasLocation ihl) {
|
|
||||||
if (response.Headers.Location != null)
|
|
||||||
ihl.Location = response.Headers.Location;
|
ihl.Location = response.Headers.Location;
|
||||||
}
|
}
|
||||||
|
|
||||||
return IDomainResult.Success(new SendResult<TResult> {
|
return new SendResult<TResult> {
|
||||||
Result = responseContent,
|
Result = responseContent,
|
||||||
ResponseText = responseText
|
ResponseText = responseText
|
||||||
});
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
#endregion
|
||||||
var message = "Let's Encrypt client unhandled exception";
|
|
||||||
|
|
||||||
_logger.LogError(ex, message);
|
private class State {
|
||||||
return IDomainResult.CriticalDependencyError<SendResult<TResult>?>(message);
|
public AcmeDirectory? Directory { get; set; }
|
||||||
}
|
public JwsService? JwsService { get; set; }
|
||||||
|
public Order? CurrentOrder { get; set; }
|
||||||
|
public List<AuthorizationChallenge> Challenges { get; } = new List<AuthorizationChallenge>();
|
||||||
|
public string? Nonce { get; set; }
|
||||||
|
public RegistrationCache? Cache { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
|
||||||
private string? _id;
|
|
||||||
public string Id {
|
|
||||||
get => _id ?? string.Empty;
|
|
||||||
set => _id = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 class Server {
|
||||||
public required string Address { get; set; }
|
public required string Ip { get; set; }
|
||||||
public required string PrivateKey { get; set; }
|
public required int Port { get; set; }
|
||||||
public required string Path { get; set; }
|
public string Path { get; set; }
|
||||||
|
public required SSHClientConfing SSH { 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; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
/// <summary>
|
||||||
cacheData.Challenges = challenges;
|
/// When new certificate session is created, create or retrieve cache data by accountId
|
||||||
cacheData.Hostnames = requestData.Hostnames;
|
/// </summary>
|
||||||
|
/// <param name="sessionId"></param>
|
||||||
_memoryCache.Set(accountId, cacheData, _cacheEntryOptions);
|
/// <param name="accountId"></param>
|
||||||
|
/// <param name="requestData"></param>
|
||||||
return Ok(fullPaths);
|
/// <returns>accountId</returns>
|
||||||
|
[HttpPost("[action]/{sessionId}/{accountId?}")]
|
||||||
|
public async Task<IActionResult> Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) {
|
||||||
|
var resurt = await _certsFlowService.InitAsync(sessionId, accountId, requestData);
|
||||||
|
return resurt.ToActionResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("[action]/{accountId}")]
|
/// <summary>
|
||||||
public async Task<IActionResult> CompleteChallenges(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 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 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 (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) {
|
/// Get order status before certs retrieval
|
||||||
|
/// </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> GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) {
|
||||||
if (!configResult.IsSuccess || config == null)
|
var result = await _certsFlowService.GetOrderAsync(sessionId, requestData);
|
||||||
return configResult.ToActionResult();
|
return result.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)
|
/// <summary>
|
||||||
return BadRequest();
|
/// Download certs to local cache
|
||||||
|
/// </summary>
|
||||||
return Ok();
|
/// <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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
namespace MaksIT.LetsEncryptServer.Models.Requests {
|
||||||
|
public class GetCertificatesRequest {
|
||||||
|
public string[] Hostnames { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/LetsEncryptServer/Models/Requests/GetOrderRequest.cs
Normal file
5
src/LetsEncryptServer/Models/Requests/GetOrderRequest.cs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
namespace MaksIT.LetsEncryptServer.Models.Requests {
|
||||||
|
public class GetOrderRequest {
|
||||||
|
public string[] Hostnames { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
208
src/LetsEncryptServer/Services/CertsFlowService.cs
Normal file
208
src/LetsEncryptServer/Services/CertsFlowService.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user