mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2025-12-31 04:00:03 +01:00
(feature): terms of service
This commit is contained in:
parent
105ca2aa70
commit
4495b2209c
@ -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
|
||||
|
||||
@ -5,8 +5,15 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="../../README.md" Pack="true" PackagePath="" />
|
||||
<None Include="../../LICENSE.md" Pack="true" PackagePath="" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
|
||||
|
||||
@ -5,8 +5,15 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="../../README.md" Pack="true" PackagePath="" />
|
||||
<None Include="../../LICENSE.md" Pack="true" PackagePath="" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MaksIT.Core" Version="1.5.9" />
|
||||
<PackageReference Include="MaksIT.Results" Version="1.1.1" />
|
||||
|
||||
@ -26,9 +26,9 @@ using System.Text;
|
||||
namespace MaksIT.LetsEncrypt.Services;
|
||||
|
||||
public interface ILetsEncryptService {
|
||||
Result<RegistrationCache?> GetRegistrationCache(Guid sessionId);
|
||||
Task<Result> ConfigureClient(Guid sessionId, bool isStaging);
|
||||
Task<Result> Init(Guid sessionId,Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache);
|
||||
Result<RegistrationCache?> GetRegistrationCache(Guid sessionId);
|
||||
Result<string?> GetTermsOfServiceUri(Guid sessionId);
|
||||
Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType);
|
||||
Task<Result> CompleteChallenges(Guid sessionId);
|
||||
@ -59,7 +59,14 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
_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
|
||||
public async Task<Result> ConfigureClient(Guid sessionId, bool isStaging) {
|
||||
@ -205,15 +212,6 @@ public class LetsEncryptService : ILetsEncryptService {
|
||||
}
|
||||
#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
|
||||
public Result<string?> 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<T?>.InternalServerError(defaultValue, [.. ex.ExtractMessages()]);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -97,7 +97,6 @@ const AppMap: AppMapType[] = [
|
||||
title: 'Terms of Service',
|
||||
routes: ['/terms-of-service'],
|
||||
page: LetsEncryptTermsOfServicePage,
|
||||
linkArea: [LinkArea.SideMenu]
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@ -134,7 +134,7 @@ const EditAccount: FC<EditAccountProps> = (props) => {
|
||||
return patchRequest
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!formIsValid) return
|
||||
|
||||
const fromFormState = mapFormStateToPatchRequest(formState)
|
||||
@ -164,14 +164,15 @@ const EditAccount: FC<EditAccountProps> = (props) => {
|
||||
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
|
||||
).then((response) => {
|
||||
)
|
||||
|
||||
if (!response) return
|
||||
|
||||
handleInitialization(response)
|
||||
onSubmitted?.(response)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
|
||||
@ -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<RegisterFormProps> = object({
|
||||
@ -51,7 +55,41 @@ const RegisterFormSchema: Schema<RegisterFormProps> = 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<RegisterProps> = () => {
|
||||
validationSchema: RegisterFormSchema,
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!formIsValid) return
|
||||
|
||||
const requestData: PostAccountRequest = {
|
||||
@ -80,6 +118,7 @@ const Register: FC<RegisterProps> = () => {
|
||||
hostnames: formState.hostnames,
|
||||
challengeType: formState.challengeType,
|
||||
isStaging: formState.isStaging,
|
||||
agreeToS: formState.agreeToS
|
||||
}
|
||||
|
||||
const request = PostAccountRequestSchema.safeParse(requestData)
|
||||
@ -92,12 +131,15 @@ const Register: FC<RegisterProps> = () => {
|
||||
return
|
||||
}
|
||||
|
||||
postData<PostAccountRequest, GetAccountResponse>(GetApiRoute(ApiRoutes.ACCOUNT_POST).route, request.data, 120000)
|
||||
.then(response => {
|
||||
const response = await postData<PostAccountRequest, GetAccountResponse>(
|
||||
GetApiRoute(ApiRoutes.ACCOUNT_POST).route,
|
||||
request.data,
|
||||
120000
|
||||
)
|
||||
|
||||
if (!response) return
|
||||
|
||||
navigate('/')
|
||||
})
|
||||
}
|
||||
|
||||
return <FormContainer>
|
||||
@ -112,8 +154,12 @@ const Register: FC<RegisterProps> = () => {
|
||||
placeholder={'Account Description'}
|
||||
errorText={errors.description}
|
||||
/>
|
||||
<h3 className={'col-span-12'}>Contacts:</h3>
|
||||
<ul className={'col-span-12'}>
|
||||
<FieldContainer
|
||||
colspan={12}
|
||||
label={'Contacts'}
|
||||
errorText={errors.contacts}
|
||||
>
|
||||
<ul>
|
||||
{formState.contacts.map((contact) => (
|
||||
<li key={contact} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
|
||||
<span className={'col-span-10'}>{contact}</span>
|
||||
@ -129,6 +175,7 @@ const Register: FC<RegisterProps> = () => {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</FieldContainer>
|
||||
<TextBoxComponent
|
||||
colspan={10}
|
||||
label={'New Contact'}
|
||||
@ -166,8 +213,12 @@ const Register: FC<RegisterProps> = () => {
|
||||
errorText={errors.challengeType}
|
||||
/>
|
||||
</div>
|
||||
<h3 className={'col-span-12'}>Hostnames:</h3>
|
||||
<ul className={'col-span-12'}>
|
||||
<FieldContainer
|
||||
colspan={12}
|
||||
label={'Hostnames'}
|
||||
errorText={errors.hostnames}
|
||||
>
|
||||
<ul>
|
||||
{formState.hostnames.map((hostname) => (
|
||||
<li key={hostname} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
|
||||
<span className={'col-span-10'}>{hostname}</span>
|
||||
@ -183,6 +234,7 @@ const Register: FC<RegisterProps> = () => {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</FieldContainer>
|
||||
<TextBoxComponent
|
||||
colspan={10}
|
||||
label={'New Hostname'}
|
||||
@ -222,6 +274,18 @@ const Register: FC<RegisterProps> = () => {
|
||||
}}
|
||||
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>
|
||||
</FormContent>
|
||||
<FormFooter rightChildren={
|
||||
|
||||
@ -45,7 +45,7 @@ const ChangePassword: FC<ChangePasswordProps> = (props) => {
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
if (formIsValid) {
|
||||
const data: PatchUserChangePasswordRequest = {
|
||||
password: formState.password,
|
||||
@ -54,12 +54,11 @@ const ChangePassword: FC<ChangePasswordProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
patchData<PatchUserChangePasswordRequest, UserResponse>(GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId), data)
|
||||
.then(response => {
|
||||
const response = await patchData<PatchUserChangePasswordRequest, UserResponse>(GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId), data)
|
||||
|
||||
if (!response) return
|
||||
|
||||
handleOnClose()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ export interface PostAccountRequest extends RequestModelBase {
|
||||
challengeType: string
|
||||
hostnames: string[]
|
||||
isStaging: boolean
|
||||
agreeToS: boolean
|
||||
}
|
||||
|
||||
export const PostAccountRequestSchema: Schema<PostAccountRequest> = RequestModelBaseSchema.and(
|
||||
@ -16,6 +17,7 @@ export const PostAccountRequestSchema: Schema<PostAccountRequest> = RequestModel
|
||||
contacts: array(string()),
|
||||
hostnames: array(string()),
|
||||
challengeType: z.enum(ChallengeType),
|
||||
isStaging: boolean()
|
||||
isStaging: boolean(),
|
||||
agreeToS: boolean()
|
||||
})
|
||||
)
|
||||
@ -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<Configuration> appSettings,
|
||||
ILogger<AutoRenewal> logger,
|
||||
@ -47,23 +48,37 @@ namespace MaksIT.Webapi.BackgroundServices {
|
||||
|
||||
private async Task<Result> ProcessAccountAsync(RegistrationCache cache) {
|
||||
|
||||
var hostnames = cache.GetHostsWithUpcomingSslExpiry();
|
||||
if (hostnames == null) {
|
||||
_logger.LogError("Unexpected hostnames null");
|
||||
var hosts = cache.GetHosts();
|
||||
var toRenew = new List<string>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@ -7,8 +7,15 @@
|
||||
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="../../README.md" Pack="true" PackagePath="" />
|
||||
<None Include="../../LICENSE.md" Pack="true" PackagePath="" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MaksIT.Results" Version="1.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
|
||||
@ -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<ValidationResult> 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)]);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user