(feature): terms of service

This commit is contained in:
Maksym Sadovnychyy 2025-11-15 20:46:26 +01:00
parent 105ca2aa70
commit 4495b2209c
12 changed files with 196 additions and 116 deletions

View File

@ -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 ## Versions History
* 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 implementation)
* 31 May, 2024 - V3.0 (Webapi and containerization) * 31 May, 2024 - V3.0 (Webapi and containerization)
* 11 Aug, 2024 - V3.1 (Release) * 11 Aug, 2024 - V3.1 (Release)
* 11 Sep, 2025 - V3.2 New WebUI with authentication * 11 Sep, 2025 - V3.2 New WebUI with authentication
* 15 Nov, 2025 - V3.3 Pre release
*
## Haproxy configuration ## Haproxy configuration
```bash ```bash

View File

@ -5,8 +5,15 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<None Include="../../README.md" Pack="true" PackagePath="" />
<None Include="../../LICENSE.md" Pack="true" PackagePath="" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />

View File

@ -5,8 +5,15 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace> <RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<None Include="../../README.md" Pack="true" PackagePath="" />
<None Include="../../LICENSE.md" Pack="true" PackagePath="" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.5.9" /> <PackageReference Include="MaksIT.Core" Version="1.5.9" />
<PackageReference Include="MaksIT.Results" Version="1.1.1" /> <PackageReference Include="MaksIT.Results" Version="1.1.1" />

View File

@ -26,9 +26,9 @@ using System.Text;
namespace MaksIT.LetsEncrypt.Services; namespace MaksIT.LetsEncrypt.Services;
public interface ILetsEncryptService { public interface ILetsEncryptService {
Result<RegistrationCache?> GetRegistrationCache(Guid sessionId);
Task<Result> ConfigureClient(Guid sessionId, bool isStaging); Task<Result> ConfigureClient(Guid sessionId, bool isStaging);
Task<Result> Init(Guid sessionId,Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache); Task<Result> Init(Guid sessionId,Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache);
Result<RegistrationCache?> GetRegistrationCache(Guid sessionId);
Result<string?> GetTermsOfServiceUri(Guid sessionId); Result<string?> GetTermsOfServiceUri(Guid sessionId);
Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType); Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType);
Task<Result> CompleteChallenges(Guid sessionId); Task<Result> CompleteChallenges(Guid sessionId);
@ -59,7 +59,14 @@ public class LetsEncryptService : ILetsEncryptService {
_memoryCache = cache; _memoryCache = cache;
} }
public Result<RegistrationCache?> GetRegistrationCache(Guid sessionId) {
var state = GetOrCreateState(sessionId);
if (state?.Cache == null)
return Result<RegistrationCache?>.InternalServerError(null);
return Result<RegistrationCache?>.Ok(state.Cache);
}
#region ConfigureClient #region ConfigureClient
public async Task<Result> ConfigureClient(Guid sessionId, bool isStaging) { public async Task<Result> ConfigureClient(Guid sessionId, bool isStaging) {
@ -205,15 +212,6 @@ public class LetsEncryptService : ILetsEncryptService {
} }
#endregion #endregion
public Result<RegistrationCache?> GetRegistrationCache(Guid sessionId) {
var state = GetOrCreateState(sessionId);
if(state?.Cache == null)
return Result<RegistrationCache?>.InternalServerError(null);
return Result<RegistrationCache?>.Ok(state.Cache);
}
#region GetTermsOfService #region GetTermsOfService
public Result<string?> GetTermsOfServiceUri(Guid sessionId) { public Result<string?> GetTermsOfServiceUri(Guid sessionId) {
try { try {
@ -882,31 +880,6 @@ public class LetsEncryptService : ILetsEncryptService {
ResponseText = responseText ResponseText = responseText
}; };
} }
#endregion
// Helper for status comparison // Helper for status comparison
private static bool StatusEquals(string? status, OrderStatus expected) => status == expected.GetDisplayName(); private static bool StatusEquals(string? status, OrderStatus expected) => status == expected.GetDisplayName();
@ -920,4 +893,5 @@ public class LetsEncryptService : ILetsEncryptService {
_logger.LogError(ex, defaultMessage); _logger.LogError(ex, defaultMessage);
return Result<T?>.InternalServerError(defaultValue, [.. ex.ExtractMessages()]); return Result<T?>.InternalServerError(defaultValue, [.. ex.ExtractMessages()]);
} }
#endregion
} }

View File

@ -97,7 +97,6 @@ const AppMap: AppMapType[] = [
title: 'Terms of Service', title: 'Terms of Service',
routes: ['/terms-of-service'], routes: ['/terms-of-service'],
page: LetsEncryptTermsOfServicePage, page: LetsEncryptTermsOfServicePage,
linkArea: [LinkArea.SideMenu]
}, },
{ {

View File

@ -134,7 +134,7 @@ const EditAccount: FC<EditAccountProps> = (props) => {
return patchRequest return patchRequest
} }
const handleSubmit = () => { const handleSubmit = async () => {
if (!formIsValid) return if (!formIsValid) return
const fromFormState = mapFormStateToPatchRequest(formState) const fromFormState = mapFormStateToPatchRequest(formState)
@ -164,14 +164,15 @@ const EditAccount: FC<EditAccountProps> = (props) => {
return return
} }
patchData<PatchAccountRequest, GetAccountResponse>(GetApiRoute(ApiRoutes.ACCOUNT_PATCH).route const response = await patchData<PatchAccountRequest, GetAccountResponse>(GetApiRoute(ApiRoutes.ACCOUNT_PATCH).route
.replace('{accountId}', accountId), delta, 120000 .replace('{accountId}', accountId), delta, 120000
).then((response) => { )
if (!response) return if (!response) return
handleInitialization(response) handleInitialization(response)
onSubmitted?.(response) onSubmitted?.(response)
})
} }
const handleCancel = () => { const handleCancel = () => {

View File

@ -3,14 +3,14 @@ import { FormContainer, FormContent, FormFooter, FormHeader } from '../component
import { postData } from '../axiosConfig' import { postData } from '../axiosConfig'
import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse' import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse'
import { ApiRoutes, GetApiRoute } from '../AppMap' 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 { ChallengeType } from '../entities/ChallengeType'
import z, { array, boolean, object, Schema, string } from 'zod' import z, { array, boolean, object, Schema, string } from 'zod'
import { useFormState } from '../hooks/useFormState' import { useFormState } from '../hooks/useFormState'
import { enumToArr } from '../functions' import { enumToArr } from '../functions'
import { PostAccountRequest, PostAccountRequestSchema } from '../models/letsEncryptServer/account/requests/PostAccountRequest' import { PostAccountRequest, PostAccountRequestSchema } from '../models/letsEncryptServer/account/requests/PostAccountRequest'
import { addToast } from '../components/Toast/addToast' 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 { PlusIcon, TrashIcon } from 'lucide-react'
import { FieldContainer } from '../components/editors/FieldContainer' import { FieldContainer } from '../components/editors/FieldContainer'
@ -26,6 +26,8 @@ interface RegisterFormProps {
challengeType: ChallengeType challengeType: ChallengeType
isStaging: boolean isStaging: boolean
agreeToS: boolean
} }
const RegisterFormProto = (): RegisterFormProps => ({ const RegisterFormProto = (): RegisterFormProps => ({
@ -38,7 +40,9 @@ const RegisterFormProto = (): RegisterFormProps => ({
hostnames: [], hostnames: [],
challengeType: ChallengeType.http01, challengeType: ChallengeType.http01,
isStaging: true isStaging: true,
agreeToS: false,
}) })
const RegisterFormSchema: Schema<RegisterFormProps> = object({ const RegisterFormSchema: Schema<RegisterFormProps> = object({
@ -51,7 +55,41 @@ const RegisterFormSchema: Schema<RegisterFormProps> = object({
hostnames: array(string()), hostnames: array(string()),
challengeType: z.enum(ChallengeType), 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 { interface RegisterProps {
@ -71,7 +109,7 @@ const Register: FC<RegisterProps> = () => {
validationSchema: RegisterFormSchema, validationSchema: RegisterFormSchema,
}) })
const handleSubmit = () => { const handleSubmit = async () => {
if (!formIsValid) return if (!formIsValid) return
const requestData: PostAccountRequest = { const requestData: PostAccountRequest = {
@ -80,6 +118,7 @@ const Register: FC<RegisterProps> = () => {
hostnames: formState.hostnames, hostnames: formState.hostnames,
challengeType: formState.challengeType, challengeType: formState.challengeType,
isStaging: formState.isStaging, isStaging: formState.isStaging,
agreeToS: formState.agreeToS
} }
const request = PostAccountRequestSchema.safeParse(requestData) const request = PostAccountRequestSchema.safeParse(requestData)
@ -92,12 +131,15 @@ const Register: FC<RegisterProps> = () => {
return return
} }
postData<PostAccountRequest, GetAccountResponse>(GetApiRoute(ApiRoutes.ACCOUNT_POST).route, request.data, 120000) const response = await postData<PostAccountRequest, GetAccountResponse>(
.then(response => { GetApiRoute(ApiRoutes.ACCOUNT_POST).route,
request.data,
120000
)
if (!response) return if (!response) return
navigate('/') navigate('/')
})
} }
return <FormContainer> return <FormContainer>
@ -112,8 +154,12 @@ const Register: FC<RegisterProps> = () => {
placeholder={'Account Description'} placeholder={'Account Description'}
errorText={errors.description} errorText={errors.description}
/> />
<h3 className={'col-span-12'}>Contacts:</h3> <FieldContainer
<ul className={'col-span-12'}> colspan={12}
label={'Contacts'}
errorText={errors.contacts}
>
<ul>
{formState.contacts.map((contact) => ( {formState.contacts.map((contact) => (
<li key={contact} className={'grid grid-cols-12 gap-4 w-full pb-2'}> <li key={contact} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
<span className={'col-span-10'}>{contact}</span> <span className={'col-span-10'}>{contact}</span>
@ -129,6 +175,7 @@ const Register: FC<RegisterProps> = () => {
</li> </li>
))} ))}
</ul> </ul>
</FieldContainer>
<TextBoxComponent <TextBoxComponent
colspan={10} colspan={10}
label={'New Contact'} label={'New Contact'}
@ -166,8 +213,12 @@ const Register: FC<RegisterProps> = () => {
errorText={errors.challengeType} errorText={errors.challengeType}
/> />
</div> </div>
<h3 className={'col-span-12'}>Hostnames:</h3> <FieldContainer
<ul className={'col-span-12'}> colspan={12}
label={'Hostnames'}
errorText={errors.hostnames}
>
<ul>
{formState.hostnames.map((hostname) => ( {formState.hostnames.map((hostname) => (
<li key={hostname} className={'grid grid-cols-12 gap-4 w-full pb-2'}> <li key={hostname} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
<span className={'col-span-10'}>{hostname}</span> <span className={'col-span-10'}>{hostname}</span>
@ -183,6 +234,7 @@ const Register: FC<RegisterProps> = () => {
</li> </li>
))} ))}
</ul> </ul>
</FieldContainer>
<TextBoxComponent <TextBoxComponent
colspan={10} colspan={10}
label={'New Hostname'} label={'New Hostname'}
@ -222,6 +274,18 @@ const Register: FC<RegisterProps> = () => {
}} }}
errorText={errors.isStaging} errorText={errors.isStaging}
/> />
<Link to={'/terms-of-service'} className={'col-span-12 text-blue-600 underline'}> Terms of Service</Link>
<CheckBoxComponent
colspan={12}
label={'I agree to the LetsEncrypt Terms of Service'}
value={formState.agreeToS}
onChange={(e) => {
handleInputChange('agreeToS', e.target.checked)
}}
errorText={errors.agreeToS}
/>
</div> </div>
</FormContent> </FormContent>
<FormFooter rightChildren={ <FormFooter rightChildren={

View File

@ -45,7 +45,7 @@ const ChangePassword: FC<ChangePasswordProps> = (props) => {
onClose?.() onClose?.()
} }
const handleSave = () => { const handleSave = async () => {
if (formIsValid) { if (formIsValid) {
const data: PatchUserChangePasswordRequest = { const data: PatchUserChangePasswordRequest = {
password: formState.password, password: formState.password,
@ -54,12 +54,11 @@ const ChangePassword: FC<ChangePasswordProps> = (props) => {
} }
} }
patchData<PatchUserChangePasswordRequest, UserResponse>(GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId), data) const response = await patchData<PatchUserChangePasswordRequest, UserResponse>(GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId), data)
.then(response => {
if (!response) return if (!response) return
handleOnClose() handleOnClose()
})
} }
} }

View File

@ -8,6 +8,7 @@ export interface PostAccountRequest extends RequestModelBase {
challengeType: string challengeType: string
hostnames: string[] hostnames: string[]
isStaging: boolean isStaging: boolean
agreeToS: boolean
} }
export const PostAccountRequestSchema: Schema<PostAccountRequest> = RequestModelBaseSchema.and( export const PostAccountRequestSchema: Schema<PostAccountRequest> = RequestModelBaseSchema.and(
@ -16,6 +17,7 @@ export const PostAccountRequestSchema: Schema<PostAccountRequest> = RequestModel
contacts: array(string()), contacts: array(string()),
hostnames: array(string()), hostnames: array(string()),
challengeType: z.enum(ChallengeType), challengeType: z.enum(ChallengeType),
isStaging: boolean() isStaging: boolean(),
agreeToS: boolean()
}) })
) )

View File

@ -1,9 +1,8 @@
using Microsoft.Extensions.Options; using MaksIT.LetsEncrypt.Entities;
using MaksIT.Webapi.Services;
using MaksIT.LetsEncrypt.Entities;
using MaksIT.Results; using MaksIT.Results;
using MaksIT.Webapi.Services;
using Microsoft.Extensions.Options;
using System;
namespace MaksIT.Webapi.BackgroundServices { namespace MaksIT.Webapi.BackgroundServices {
public class AutoRenewal : BackgroundService { public class AutoRenewal : BackgroundService {
@ -13,6 +12,8 @@ namespace MaksIT.Webapi.BackgroundServices {
private readonly ICacheService _cacheService; private readonly ICacheService _cacheService;
private readonly ICertsFlowService _certsFlowService; private readonly ICertsFlowService _certsFlowService;
private static readonly Random _random = new();
public AutoRenewal( public AutoRenewal(
IOptions<Configuration> appSettings, IOptions<Configuration> appSettings,
ILogger<AutoRenewal> logger, ILogger<AutoRenewal> logger,
@ -47,23 +48,37 @@ namespace MaksIT.Webapi.BackgroundServices {
private async Task<Result> ProcessAccountAsync(RegistrationCache cache) { private async Task<Result> ProcessAccountAsync(RegistrationCache cache) {
var hostnames = cache.GetHostsWithUpcomingSslExpiry(); var hosts = cache.GetHosts();
if (hostnames == null) { var toRenew = new List<string>();
_logger.LogError("Unexpected hostnames null");
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(); 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) if (!fullFlowResult.IsSuccess)
return fullFlowResult; return fullFlowResult;
_logger.LogInformation($"Certificates renewed for account {cache.AccountId}"); _logger.LogInformation($"Certificates renewed for account {cache.AccountId}: {string.Join(", ", toRenew)}");
return Result.Ok(); return Result.Ok();
} }

View File

@ -7,8 +7,15 @@
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace> <RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath> <DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<None Include="../../README.md" Pack="true" PackagePath="" />
<None Include="../../LICENSE.md" Pack="true" PackagePath="" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MaksIT.Results" Version="1.1.1" /> <PackageReference Include="MaksIT.Results" Version="1.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />

View File

@ -9,6 +9,7 @@ public class PostAccountRequest : RequestModelBase {
public required string ChallengeType { get; set; } public required string ChallengeType { get; set; }
public required string[] Hostnames { get; set; } public required string[] Hostnames { get; set; }
public required bool IsStaging { get; set; } public required bool IsStaging { get; set; }
public required bool AgreeToS { get; set; }
public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext) { public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {
if (string.IsNullOrWhiteSpace(Description)) if (string.IsNullOrWhiteSpace(Description))
@ -22,5 +23,8 @@ public class PostAccountRequest : RequestModelBase {
if (string.IsNullOrWhiteSpace(ChallengeType) && ChallengeType != "http-01") if (string.IsNullOrWhiteSpace(ChallengeType) && ChallengeType != "http-01")
yield return new ValidationResult("ChallengeType is required", [nameof(ChallengeType)]); 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)]);
} }
} }