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)]); } }