diff --git a/README.md b/README.md
index eb1fa1e..6a6ad13 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,16 @@
-# LetsEncrypt C# Client by Maks-IT.com
+# LetsEncrypt C# Client by MaksIT
-Simple client to obtain Let's Encrypt HTTPS certificates developed with .net core and curently works only with http challange
+Simple client to obtain Let's Encrypt HTTPS certificates developed with .net core and currently works only with http challenge
## Versions History
* 29 Jun, 2019 - V1.0
-* 01 Nov, 2019 - V2.0 (Dependency Injection pattern impelemtation)
+* 01 Nov, 2019 - V2.0 (Dependency Injection pattern implementation)
* 31 May, 2024 - V3.0 (Webapi and containerization)
* 11 Aug, 2024 - V3.1 (Release)
* 11 Sep, 2025 - V3.2 New WebUI with authentication
-
+* 15 Nov, 2025 - V3.3 Pre release
+*
## Haproxy configuration
```bash
diff --git a/src/Agent/Agent.csproj b/src/Agent/Agent.csproj
index 49c48b0..fc9e032 100644
--- a/src/Agent/Agent.csproj
+++ b/src/Agent/Agent.csproj
@@ -5,8 +5,15 @@
enable
enable
Linux
+ README.md
+ LICENSE.md
+
+
+
+
+
diff --git a/src/LetsEncrypt/LetsEncrypt.csproj b/src/LetsEncrypt/LetsEncrypt.csproj
index 1ec2aa5..191c1cb 100644
--- a/src/LetsEncrypt/LetsEncrypt.csproj
+++ b/src/LetsEncrypt/LetsEncrypt.csproj
@@ -5,8 +5,15 @@
enable
enable
MaksIT.$(MSBuildProjectName.Replace(" ", "_"))
+ README.md
+ LICENSE.md
+
+
+
+
+
diff --git a/src/LetsEncrypt/Services/LetsEncryptService.cs b/src/LetsEncrypt/Services/LetsEncryptService.cs
index 53f5fc1..2c542d0 100644
--- a/src/LetsEncrypt/Services/LetsEncryptService.cs
+++ b/src/LetsEncrypt/Services/LetsEncryptService.cs
@@ -26,9 +26,9 @@ using System.Text;
namespace MaksIT.LetsEncrypt.Services;
public interface ILetsEncryptService {
+ Result GetRegistrationCache(Guid sessionId);
Task ConfigureClient(Guid sessionId, bool isStaging);
Task Init(Guid sessionId,Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache);
- Result GetRegistrationCache(Guid sessionId);
Result GetTermsOfServiceUri(Guid sessionId);
Task?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType);
Task CompleteChallenges(Guid sessionId);
@@ -59,7 +59,14 @@ public class LetsEncryptService : ILetsEncryptService {
_memoryCache = cache;
}
-
+ public Result GetRegistrationCache(Guid sessionId) {
+ var state = GetOrCreateState(sessionId);
+
+ if (state?.Cache == null)
+ return Result.InternalServerError(null);
+
+ return Result.Ok(state.Cache);
+ }
#region ConfigureClient
public async Task ConfigureClient(Guid sessionId, bool isStaging) {
@@ -205,15 +212,6 @@ public class LetsEncryptService : ILetsEncryptService {
}
#endregion
- public Result GetRegistrationCache(Guid sessionId) {
- var state = GetOrCreateState(sessionId);
-
- if(state?.Cache == null)
- return Result.InternalServerError(null);
-
- return Result.Ok(state.Cache);
- }
-
#region GetTermsOfService
public Result GetTermsOfServiceUri(Guid sessionId) {
try {
@@ -882,31 +880,6 @@ public class LetsEncryptService : ILetsEncryptService {
ResponseText = responseText
};
}
- #endregion
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
// Helper for status comparison
private static bool StatusEquals(string? status, OrderStatus expected) => status == expected.GetDisplayName();
@@ -920,4 +893,5 @@ public class LetsEncryptService : ILetsEncryptService {
_logger.LogError(ex, defaultMessage);
return Result.InternalServerError(defaultValue, [.. ex.ExtractMessages()]);
}
+ #endregion
}
diff --git a/src/MaksIT.WebUI/src/AppMap.tsx b/src/MaksIT.WebUI/src/AppMap.tsx
index 1430d44..1bf039e 100644
--- a/src/MaksIT.WebUI/src/AppMap.tsx
+++ b/src/MaksIT.WebUI/src/AppMap.tsx
@@ -97,7 +97,6 @@ const AppMap: AppMapType[] = [
title: 'Terms of Service',
routes: ['/terms-of-service'],
page: LetsEncryptTermsOfServicePage,
- linkArea: [LinkArea.SideMenu]
},
{
diff --git a/src/MaksIT.WebUI/src/forms/EditAccount.tsx b/src/MaksIT.WebUI/src/forms/EditAccount.tsx
index 395291c..db5796d 100644
--- a/src/MaksIT.WebUI/src/forms/EditAccount.tsx
+++ b/src/MaksIT.WebUI/src/forms/EditAccount.tsx
@@ -134,7 +134,7 @@ const EditAccount: FC = (props) => {
return patchRequest
}
- const handleSubmit = () => {
+ const handleSubmit = async () => {
if (!formIsValid) return
const fromFormState = mapFormStateToPatchRequest(formState)
@@ -164,14 +164,15 @@ const EditAccount: FC = (props) => {
return
}
- patchData(GetApiRoute(ApiRoutes.ACCOUNT_PATCH).route
+ const response = await patchData(GetApiRoute(ApiRoutes.ACCOUNT_PATCH).route
.replace('{accountId}', accountId), delta, 120000
- ).then((response) => {
- if (!response) return
+ )
+
+ if (!response) return
+
+ handleInitialization(response)
+ onSubmitted?.(response)
- handleInitialization(response)
- onSubmitted?.(response)
- })
}
const handleCancel = () => {
diff --git a/src/MaksIT.WebUI/src/forms/Register.tsx b/src/MaksIT.WebUI/src/forms/Register.tsx
index 32bb908..8598515 100644
--- a/src/MaksIT.WebUI/src/forms/Register.tsx
+++ b/src/MaksIT.WebUI/src/forms/Register.tsx
@@ -3,14 +3,14 @@ import { FormContainer, FormContent, FormFooter, FormHeader } from '../component
import { postData } from '../axiosConfig'
import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse'
import { ApiRoutes, GetApiRoute } from '../AppMap'
-import { ButtonComponent, RadioGroupComponent, SelectBoxComponent, TextBoxComponent } from '../components/editors'
+import { ButtonComponent, CheckBoxComponent, RadioGroupComponent, SelectBoxComponent, TextBoxComponent } from '../components/editors'
import { ChallengeType } from '../entities/ChallengeType'
import z, { array, boolean, object, Schema, string } from 'zod'
import { useFormState } from '../hooks/useFormState'
import { enumToArr } from '../functions'
import { PostAccountRequest, PostAccountRequestSchema } from '../models/letsEncryptServer/account/requests/PostAccountRequest'
import { addToast } from '../components/Toast/addToast'
-import { useNavigate } from 'react-router-dom'
+import { Link, useNavigate } from 'react-router-dom'
import { PlusIcon, TrashIcon } from 'lucide-react'
import { FieldContainer } from '../components/editors/FieldContainer'
@@ -26,6 +26,8 @@ interface RegisterFormProps {
challengeType: ChallengeType
isStaging: boolean
+
+ agreeToS: boolean
}
const RegisterFormProto = (): RegisterFormProps => ({
@@ -38,7 +40,9 @@ const RegisterFormProto = (): RegisterFormProps => ({
hostnames: [],
challengeType: ChallengeType.http01,
- isStaging: true
+ isStaging: true,
+
+ agreeToS: false,
})
const RegisterFormSchema: Schema = object({
@@ -51,7 +55,41 @@ const RegisterFormSchema: Schema = object({
hostnames: array(string()),
challengeType: z.enum(ChallengeType),
- isStaging: boolean()
+ isStaging: boolean(),
+
+ agreeToS: boolean()
+}).superRefine((data, ctx) => {
+ if (data.description.trim() === '') {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Description is required',
+ path: ['description']
+ })
+ }
+
+ if (data.contacts.length === 0) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'At least one contact is required',
+ path: ['contacts']
+ })
+ }
+
+ if (data.hostnames.length === 0) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'At least one hostname is required',
+ path: ['hostnames']
+ })
+ }
+
+ if (!data.agreeToS) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'You must agree to the Terms of Service',
+ path: ['agreeToS']
+ })
+ }
})
interface RegisterProps {
@@ -71,7 +109,7 @@ const Register: FC = () => {
validationSchema: RegisterFormSchema,
})
- const handleSubmit = () => {
+ const handleSubmit = async () => {
if (!formIsValid) return
const requestData: PostAccountRequest = {
@@ -80,6 +118,7 @@ const Register: FC = () => {
hostnames: formState.hostnames,
challengeType: formState.challengeType,
isStaging: formState.isStaging,
+ agreeToS: formState.agreeToS
}
const request = PostAccountRequestSchema.safeParse(requestData)
@@ -92,12 +131,15 @@ const Register: FC = () => {
return
}
- postData(GetApiRoute(ApiRoutes.ACCOUNT_POST).route, request.data, 120000)
- .then(response => {
- if (!response) return
+ const response = await postData(
+ GetApiRoute(ApiRoutes.ACCOUNT_POST).route,
+ request.data,
+ 120000
+ )
- navigate('/')
- })
+ if (!response) return
+
+ navigate('/')
}
return
@@ -111,24 +153,29 @@ const Register: FC = () => {
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder={'Account Description'}
errorText={errors.description}
- />
- Contacts:
-
- {formState.contacts.map((contact) => (
- -
- {contact}
- {
- const updatedContacts = formState.contacts.filter(c => c !== contact)
- handleInputChange('contacts', updatedContacts)
- }}
- >
-
-
-
- ))}
-
+ />
+
+
+ {formState.contacts.map((contact) => (
+ -
+ {contact}
+ {
+ const updatedContacts = formState.contacts.filter(c => c !== contact)
+ handleInputChange('contacts', updatedContacts)
+ }}
+ >
+
+
+
+ ))}
+
+
= () => {
errorText={errors.challengeType}
/>
- Hostnames:
-
- {formState.hostnames.map((hostname) => (
- -
- {hostname}
- {
- const updatedHostnames = formState.hostnames.filter(h => h !== hostname)
- handleInputChange('hostnames', updatedHostnames)
- }}
- >
-
-
-
- ))}
-
+
+
+ {formState.hostnames.map((hostname) => (
+ -
+ {hostname}
+ {
+ const updatedHostnames = formState.hostnames.filter(h => h !== hostname)
+ handleInputChange('hostnames', updatedHostnames)
+ }}
+ >
+
+
+
+ ))}
+
+
= () => {
}}
errorText={errors.isStaging}
/>
+
+ Terms of Service
+ {
+ handleInputChange('agreeToS', e.target.checked)
+ }}
+ errorText={errors.agreeToS}
+ />
+
= (props) => {
onClose?.()
}
- const handleSave = () => {
+ const handleSave = async () => {
if (formIsValid) {
const data: PatchUserChangePasswordRequest = {
password: formState.password,
@@ -54,12 +54,11 @@ const ChangePassword: FC = (props) => {
}
}
- patchData(GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId), data)
- .then(response => {
- if (!response) return
+ const response = await patchData(GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId), data)
- handleOnClose()
- })
+ if (!response) return
+
+ handleOnClose()
}
}
diff --git a/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PostAccountRequest.ts b/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PostAccountRequest.ts
index 7a137f6..3abcb79 100644
--- a/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PostAccountRequest.ts
+++ b/src/MaksIT.WebUI/src/models/letsEncryptServer/account/requests/PostAccountRequest.ts
@@ -8,6 +8,7 @@ export interface PostAccountRequest extends RequestModelBase {
challengeType: string
hostnames: string[]
isStaging: boolean
+ agreeToS: boolean
}
export const PostAccountRequestSchema: Schema = RequestModelBaseSchema.and(
@@ -16,6 +17,7 @@ export const PostAccountRequestSchema: Schema = RequestModel
contacts: array(string()),
hostnames: array(string()),
challengeType: z.enum(ChallengeType),
- isStaging: boolean()
+ isStaging: boolean(),
+ agreeToS: boolean()
})
)
\ No newline at end of file
diff --git a/src/MaksIT.Webapi/BackgroundServices/AutoRenewal.cs b/src/MaksIT.Webapi/BackgroundServices/AutoRenewal.cs
index de2dcbb..a5bc6de 100644
--- a/src/MaksIT.Webapi/BackgroundServices/AutoRenewal.cs
+++ b/src/MaksIT.Webapi/BackgroundServices/AutoRenewal.cs
@@ -1,9 +1,8 @@
-using Microsoft.Extensions.Options;
-
-
-using MaksIT.Webapi.Services;
-using MaksIT.LetsEncrypt.Entities;
+using MaksIT.LetsEncrypt.Entities;
using MaksIT.Results;
+using MaksIT.Webapi.Services;
+using Microsoft.Extensions.Options;
+using System;
namespace MaksIT.Webapi.BackgroundServices {
public class AutoRenewal : BackgroundService {
@@ -13,6 +12,8 @@ namespace MaksIT.Webapi.BackgroundServices {
private readonly ICacheService _cacheService;
private readonly ICertsFlowService _certsFlowService;
+ private static readonly Random _random = new();
+
public AutoRenewal(
IOptions appSettings,
ILogger logger,
@@ -46,24 +47,38 @@ namespace MaksIT.Webapi.BackgroundServices {
}
private async Task ProcessAccountAsync(RegistrationCache cache) {
-
- var hostnames = cache.GetHostsWithUpcomingSslExpiry();
- if (hostnames == null) {
- _logger.LogError("Unexpected hostnames null");
+
+ var hosts = cache.GetHosts();
+ var toRenew = new List();
+
+ foreach (var host in hosts) {
+ if (host.IsDisabled)
+ continue;
+
+ // Only consider certs expiring within 30 days
+ if ((host.Expires - DateTime.UtcNow).TotalDays < 30) {
+ // Randomize renewal between 1 and 5 days before expiry
+ int randomDays = _random.Next(1, 6);
+ var renewalTime = host.Expires.AddDays(-randomDays);
+ if (DateTime.UtcNow >= renewalTime) {
+ toRenew.Add(host.Hostname);
+ }
+ }
+ }
+
+ if (!toRenew.Any()) {
+ _logger.LogInformation("No certificates are due for randomized renewal at this time.");
return Result.Ok();
}
+ var fullFlowResult = await _certsFlowService.FullFlow(
+ cache.IsStaging, cache.AccountId, cache.Description, cache.Contacts, cache.ChallengeType, toRenew.ToArray()
+ );
- if (!hostnames.Any()) {
- _logger.LogInformation("No hosts found with upcoming SSL expiry");
- return Result.Ok();
- }
-
- var fullFlowResult = await _certsFlowService.FullFlow(cache.IsStaging, cache.AccountId, cache.Description, cache.Contacts, cache.ChallengeType, hostnames);
if (!fullFlowResult.IsSuccess)
return fullFlowResult;
- _logger.LogInformation($"Certificates renewed for account {cache.AccountId}");
+ _logger.LogInformation($"Certificates renewed for account {cache.AccountId}: {string.Join(", ", toRenew)}");
return Result.Ok();
}
diff --git a/src/MaksIT.Webapi/MaksIT.Webapi.csproj b/src/MaksIT.Webapi/MaksIT.Webapi.csproj
index 18d7b12..b1f4087 100644
--- a/src/MaksIT.Webapi/MaksIT.Webapi.csproj
+++ b/src/MaksIT.Webapi/MaksIT.Webapi.csproj
@@ -7,8 +7,15 @@
$(MSBuildProjectName.Replace(" ", "_"))
Linux
..\docker-compose.dcproj
+ README.md
+ LICENSE.md
+
+
+
+
+
diff --git a/src/Models/LetsEncryptServer/Account/Requests/PostAccountRequest.cs b/src/Models/LetsEncryptServer/Account/Requests/PostAccountRequest.cs
index a816ef1..2167d0a 100644
--- a/src/Models/LetsEncryptServer/Account/Requests/PostAccountRequest.cs
+++ b/src/Models/LetsEncryptServer/Account/Requests/PostAccountRequest.cs
@@ -9,6 +9,7 @@ public class PostAccountRequest : RequestModelBase {
public required string ChallengeType { get; set; }
public required string[] Hostnames { get; set; }
public required bool IsStaging { get; set; }
+ public required bool AgreeToS { get; set; }
public override IEnumerable Validate(ValidationContext validationContext) {
if (string.IsNullOrWhiteSpace(Description))
@@ -22,5 +23,8 @@ public class PostAccountRequest : RequestModelBase {
if (string.IsNullOrWhiteSpace(ChallengeType) && ChallengeType != "http-01")
yield return new ValidationResult("ChallengeType is required", [nameof(ChallengeType)]);
+
+ if (!AgreeToS)
+ yield return new ValidationResult("You must agree to the Terms of Service", [nameof(AgreeToS)]);
}
}