From c817bc10386c788db2aa737cc43031c51d42c64f Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Thu, 27 Jun 2024 01:13:33 +0200 Subject: [PATCH] (feature): create account from UI, general improvements --- src/ClientApp/ApiRoutes.tsx | 13 +- src/ClientApp/app/functions/enums.ts | 45 ++++ src/ClientApp/app/functions/index.ts | 3 +- src/ClientApp/app/page.tsx | 175 ++++++++++++--- src/ClientApp/app/register/page.tsx | 203 ++++++++++++------ src/ClientApp/components/pageContainer.tsx | 19 ++ src/ClientApp/components/toast.tsx | 21 +- src/ClientApp/controls/customCheckbox.tsx | 57 +++++ src/ClientApp/controls/customEnumSelect.tsx | 28 +++ src/ClientApp/controls/customInput.tsx | 6 + src/ClientApp/controls/customRadioGroup.tsx | 77 +++++++ src/ClientApp/controls/customSelect.tsx | 110 ++++++++++ src/ClientApp/controls/index.ts | 13 +- src/ClientApp/entities/CacheAccount.ts | 2 + .../entities/CacheAccountHostname.ts | 1 + src/ClientApp/entities/ChallengeTypes.ts | 4 + src/ClientApp/hooks/useValidation.tsx | 5 + .../account/responses/GetAccountResponse.ts | 4 +- .../account/responses/HostnameResponse.ts | 1 + .../certsFlow/PostAccountRequest.ts | 47 +++- src/ClientApp/package-lock.json | 22 +- src/ClientApp/package.json | 4 +- src/ClientApp/redux/slices/toastSlice.ts | 36 ++-- .../LetsEncrypt/AuthorizationChallange.cs | 12 -- .../LetsEncrypt/CachedCertificateResult.cs | 11 - .../Entities/LetsEncrypt/CachedHostname.cs | 16 ++ .../Entities/LetsEncrypt/CertificateCache.cs | 8 + .../Entities/LetsEncrypt/RegistrationCache.cs | 37 ++-- .../Entities/LetsEncrypt/SendResult.cs | 10 - src/LetsEncrypt/Entities/LetsEncrypt/State.cs | 19 ++ .../AuthorizationChallengeChallenge.cs | 13 ++ .../AuthorizationChallengeResponse.cs | 7 +- .../Models/Responses/SendResult.cs | 12 ++ .../Services/LetsEncryptService.cs | 53 ++--- .../BackgroundServices/AutoRenewal.cs | 8 +- src/LetsEncryptServer/Configuration.cs | 1 - .../Controllers/CertsFlowController.cs | 4 +- .../Services/AccoutService.cs | 63 +++--- .../Services/CertsFlowService.cs | 64 +++--- src/LetsEncryptServer/appsettings.json | 2 - .../Account/Requests/PostAccountRequest.cs | 4 +- .../Account/Responses/GetAccountResponse.cs | 5 +- .../Account/Responses/HostnameResponse.cs | 3 +- .../Requests/ConfigureClientRequest.cs | 11 + .../b2a279ae-0306-4d77-9408-66a078f34fa6.json | 1 - 45 files changed, 931 insertions(+), 329 deletions(-) create mode 100644 src/ClientApp/app/functions/enums.ts create mode 100644 src/ClientApp/components/pageContainer.tsx create mode 100644 src/ClientApp/controls/customCheckbox.tsx create mode 100644 src/ClientApp/controls/customEnumSelect.tsx create mode 100644 src/ClientApp/controls/customRadioGroup.tsx create mode 100644 src/ClientApp/controls/customSelect.tsx create mode 100644 src/ClientApp/entities/ChallengeTypes.ts delete mode 100644 src/LetsEncrypt/Entities/LetsEncrypt/AuthorizationChallange.cs delete mode 100644 src/LetsEncrypt/Entities/LetsEncrypt/CachedCertificateResult.cs create mode 100644 src/LetsEncrypt/Entities/LetsEncrypt/CachedHostname.cs create mode 100644 src/LetsEncrypt/Entities/LetsEncrypt/CertificateCache.cs delete mode 100644 src/LetsEncrypt/Entities/LetsEncrypt/SendResult.cs create mode 100644 src/LetsEncrypt/Entities/LetsEncrypt/State.cs create mode 100644 src/LetsEncrypt/Models/Responses/AuthorizationChallengeChallenge.cs create mode 100644 src/LetsEncrypt/Models/Responses/SendResult.cs create mode 100644 src/Models/LetsEncryptServer/CertsFlow/Requests/ConfigureClientRequest.cs delete mode 100644 src/docker-compose/LetsEncryptServer/cache/b2a279ae-0306-4d77-9408-66a078f34fa6.json diff --git a/src/ClientApp/ApiRoutes.tsx b/src/ClientApp/ApiRoutes.tsx index 41e549e..841bc7c 100644 --- a/src/ClientApp/ApiRoutes.tsx +++ b/src/ClientApp/ApiRoutes.tsx @@ -1,9 +1,14 @@ enum ApiRoutes { ACCOUNTS = 'api/accounts', - ACCOUNT = 'api/account/{accountId}', - ACCOUNT_CONTACTS = 'api/account/{accountId}/contacts', - ACCOUNT_CONTACT = 'api/account/{accountId}/contact/{index}', - ACCOUNT_HOSTNAMES = 'api/account/{accountId}/hostnames' + + ACCOUNT = 'api/account', + ACCOUNT_ID = 'api/account/{accountId}', + + ACCOUNT_ID_CONTACTS = 'api/account/{accountId}/contacts', + ACCOUNT_ID_CONTACT_ID = 'api/account/{accountId}/contact/{index}', + + ACCOUNT_ID_HOSTNAMES = 'api/account/{accountId}/hostnames', + ACCOUNT_ID_HOSTNAME_ID = 'api/account/{accountId}/hostname/{index}' // CERTS_FLOW_CONFIGURE_CLIENT = `api/CertsFlow/ConfigureClient`, // CERTS_FLOW_TERMS_OF_SERVICE = `api/CertsFlow/TermsOfService/{sessionId}`, diff --git a/src/ClientApp/app/functions/enums.ts b/src/ClientApp/app/functions/enums.ts new file mode 100644 index 0000000..24fe4f2 --- /dev/null +++ b/src/ClientApp/app/functions/enums.ts @@ -0,0 +1,45 @@ +interface EnumKeyValue { + key: string + value: string | number +} + +const enumToArray = ( + enumObj: T +): EnumKeyValue[] => { + return Object.keys(enumObj) + .filter((key) => isNaN(Number(key))) // Ensure that only string keys are considered + .map((key) => ({ + key, + value: enumObj[key as keyof typeof enumObj] + })) + .map((entry) => ({ + key: entry.key, + value: + typeof entry.value === 'string' && !isNaN(Number(entry.value)) + ? Number(entry.value) + : entry.value + })) +} + +const enumToObject = ( + enumObj: T +): { [key: string]: EnumKeyValue } => { + return Object.keys(enumObj) + .filter((key) => isNaN(Number(key))) // Ensure that only string keys are considered + .reduce( + (acc, key) => { + const value = enumObj[key as keyof typeof enumObj] + acc[key] = { + key, + value: + typeof value === 'string' && !isNaN(Number(value)) + ? Number(value) + : value + } + return acc + }, + {} as { [key: string]: EnumKeyValue } + ) +} + +export { enumToArray, enumToObject } diff --git a/src/ClientApp/app/functions/index.ts b/src/ClientApp/app/functions/index.ts index 8d93905..977f09f 100644 --- a/src/ClientApp/app/functions/index.ts +++ b/src/ClientApp/app/functions/index.ts @@ -1,3 +1,4 @@ import { deepCopy } from './deepCopy' +import { enumToArray, enumToObject } from './enums' -export { deepCopy } +export { deepCopy, enumToArray, enumToObject } diff --git a/src/ClientApp/app/page.tsx b/src/ClientApp/app/page.tsx index 9a8b8e8..eb0da81 100644 --- a/src/ClientApp/app/page.tsx +++ b/src/ClientApp/app/page.tsx @@ -8,11 +8,19 @@ import { isValidEmail, isValidHostname } from '@/hooks/useValidation' -import { CustomButton, CustomInput } from '@/controls' -import { TrashIcon, PlusIcon } from '@heroicons/react/24/solid' +import { + CustomButton, + CustomCheckbox, + CustomEnumSelect, + CustomInput, + CustomRadioGroup +} from '@/controls' import { GetAccountResponse } from '@/models/letsEncryptServer/account/responses/GetAccountResponse' -import { deepCopy } from './functions' +import { deepCopy, enumToArray } from './functions' import { CacheAccount } from '@/entities/CacheAccount' +import { ChallengeTypes } from '@/entities/ChallengeTypes' +import { FaPlus, FaTrash } from 'react-icons/fa' +import { PageContainer } from '@/components/pageContainer' export default function Page() { const [accounts, setAccounts] = useState([]) @@ -56,6 +64,7 @@ export default function Page() { accounts?.forEach((account) => { newAccounts.push({ accountId: account.accountId, + isDisabled: account.isDisabled, description: account.description, contacts: account.contacts, challengeType: account.challengeType, @@ -63,9 +72,11 @@ export default function Page() { account.hostnames?.map((h) => ({ hostname: h.hostname, expires: new Date(h.expires), - isUpcomingExpire: h.isUpcomingExpire + isUpcomingExpire: h.isUpcomingExpire, + isDisabled: h.isDisabled })) ?? [], - isEditMode: false + isEditMode: false, + isStaging: account.isStaging }) }) @@ -87,6 +98,40 @@ export default function Page() { ) } + const handleDescriptionChange = (accountId: string, value: string) => { + setAccounts( + accounts.map((account) => + account.accountId === accountId + ? { ...account, description: value } + : account + ) + ) + } + + const validateDescription = (description: string) => { + return description.length > 0 ? '' : 'Description is required.' + } + + const handleIsDisabledChange = (accountId: string, value: boolean) => { + setAccounts( + accounts.map((account) => + account.accountId === accountId + ? { ...account, isDisabled: value } + : account + ) + ) + } + + const handleChallengeTypeChange = (accountId: string, option: any) => { + setAccounts( + accounts.map((account) => + account.accountId === accountId + ? { ...account, challengeType: option.value } + : account + ) + ) + } + const deleteAccount = (accountId: string) => { setAccounts(accounts.filter((account) => account.accountId !== accountId)) @@ -99,20 +144,20 @@ export default function Page() { if (account?.contacts.length ?? 0 < 1) return // TODO: Remove from cache - httpService.delete( - GetApiRoute(ApiRoutes.ACCOUNT_CONTACT, accountId, contact) - ) + // httpService.delete( + // GetApiRoute(ApiRoutes.ACCOUNT_CONTACT, accountId, contact) + // ) - setAccounts( - accounts.map((account) => - account.accountId === accountId - ? { - ...account, - contacts: account.contacts.filter((c) => c !== contact) - } - : account - ) - ) + // setAccounts( + // accounts.map((account) => + // account.accountId === accountId + // ? { + // ...account, + // contacts: account.contacts.filter((c) => c !== contact) + // } + // : account + // ) + // ) } const addContact = (accountId: string) => { @@ -180,7 +225,8 @@ export default function Page() { { hostname: newHostname, expires: new Date(), - isUpcomingExpire: false + isUpcomingExpire: false, + isDisabled: false } ] } @@ -251,10 +297,7 @@ export default function Page() { } return ( -
-

- LetsEncrypt Auto Renew -

+ {accounts.map((account) => (
handleSubmit(e, account.accountId)}>
-

Description:

+ + handleDescriptionChange(account.accountId, value) + } + placeholder="Add new description" + type="text" + error={validateDescription(account.description ?? '')} + title="Description" + inputClassName="border p-2 rounded w-full" + errorClassName="text-red-500 text-sm mt-1" + className="mr-2 flex-grow" + />
+ +
+ + handleIsDisabledChange(account.accountId, value) + } + /> +
+

Contacts:

    @@ -292,7 +358,7 @@ export default function Page() { } className="bg-red-500 text-white p-2 rounded ml-2" > - + ))} @@ -314,7 +380,7 @@ export default function Page() { onClick={() => addContact(account.accountId)} className="bg-green-500 text-white p-2 rounded ml-2" > - +
@@ -345,7 +411,7 @@ export default function Page() { } className="bg-red-500 text-white p-2 rounded ml-2" > - + ))} @@ -367,7 +433,7 @@ export default function Page() { onClick={() => addHostname(account.accountId)} className="bg-green-500 text-white p-2 rounded ml-2" > - +
@@ -377,7 +443,7 @@ export default function Page() { onClick={() => deleteAccount(account.accountId)} className="bg-red-500 text-white p-2 rounded ml-2" > - +
+ +
+ +
+

Contacts:

    @@ -405,31 +480,61 @@ export default function Page() {
-

- Challenge type: {account.challengeType} -

+ + handleChallengeTypeChange(account.accountId, option) + } + disabled={true} + />
-
+

Hostnames:

    {account.hostnames.map((hostname) => (
  • {hostname.hostname} - {hostname.expires.toDateString()} - {hostname.isUpcomingExpire ? 'Upcoming' : 'Not Upcoming'} +
  • ))}
+ +
+ +
)}
))} - + ) } diff --git a/src/ClientApp/app/register/page.tsx b/src/ClientApp/app/register/page.tsx index 442c440..7d31692 100644 --- a/src/ClientApp/app/register/page.tsx +++ b/src/ClientApp/app/register/page.tsx @@ -1,31 +1,56 @@ 'use client' -import { ApiRoutes, GetApiRoute } from '@/ApiRoutes' -import { httpService } from '@/services/httpService' import { FormEvent, useEffect, useRef, useState } from 'react' import { useValidation, + isBypass, isValidContact, isValidHostname } from '@/hooks/useValidation' -import { CustomButton, CustomInput } from '@/controls' +import { + CustomButton, + CustomEnumSelect, + CustomInput, + CustomRadioGroup +} from '@/controls' import { FaTrash, FaPlus } from 'react-icons/fa' import { deepCopy } from '../functions' import { PostAccountRequest, validatePostAccountRequest } from '@/models/letsEncryptServer/certsFlow/PostAccountRequest' -import App from 'next/app' import { useAppDispatch } from '@/redux/store' import { showToast } from '@/redux/slices/toastSlice' +import { ChallengeTypes } from '@/entities/ChallengeTypes' +import { GetAccountResponse } from '@/models/letsEncryptServer/account/responses/GetAccountResponse' +import { httpService } from '@/services/httpService' +import { ApiRoutes, GetApiRoute } from '@/ApiRoutes' +import { PageContainer } from '@/components/pageContainer' const RegisterPage = () => { - const [account, setAccount] = useState(null) + const [account, setAccount] = useState({ + description: '', + contacts: [], + challengeType: '', + hostnames: [], + isStaging: true + }) const dispatch = useAppDispatch() const { - value: newContact, + value: description, + error: descriptionError, + handleChange: handleDescriptionChange, + reset: resetDescription + } = useValidation({ + initialValue: '', + validateFn: isBypass, + errorMessage: '' + }) + + const { + value: contact, error: contactError, handleChange: handleContactChange, reset: resetContact @@ -36,7 +61,18 @@ const RegisterPage = () => { }) const { - value: newHostname, + value: challengeType, + error: challengeTypeError, + handleChange: handleChallengeTypeChange, + reset: resetChallengeType + } = useValidation({ + initialValue: ChallengeTypes.http01, + validateFn: isBypass, + errorMessage: '' + }) + + const { + value: hostname, error: hostnameError, handleChange: handleHostnameChange, reset: resetHostname @@ -47,19 +83,25 @@ const RegisterPage = () => { }) const init = useRef(false) - useEffect(() => { if (init.current) return init.current = true }, []) - const handleDescription = (description: string) => {} + useEffect(() => { + setAccount((prev) => { + const newAccount = deepCopy(prev) + newAccount.description = description + newAccount.challengeType = challengeType + return newAccount + }) + }, [description, challengeType]) const handleAddContact = () => { if ( - newContact === '' || - account?.contacts.includes(newContact) || + contact === '' || + account?.contacts.includes(contact) || contactError !== '' ) { resetContact() @@ -67,26 +109,26 @@ const RegisterPage = () => { } setAccount((prev) => { - const newAccount: PostAccountRequest = - prev !== null - ? deepCopy(prev) - : { - contacts: [], - hostnames: [] - } - - newAccount.contacts.push(newContact) - + const newAccount = deepCopy(prev) + newAccount.contacts.push(contact) return newAccount }) resetContact() } + const handleDeleteContact = (contact: string) => { + setAccount((prev) => { + const newAccount = deepCopy(prev) + newAccount.contacts = newAccount.contacts.filter((c) => c !== contact) + return newAccount + }) + } + const handleAddHostname = () => { if ( - newHostname === '' || - account?.hostnames.includes(newHostname) || + hostname === '' || + account?.hostnames.includes(hostname) || hostnameError !== '' ) { resetHostname() @@ -94,40 +136,18 @@ const RegisterPage = () => { } setAccount((prev) => { - const newAccount: PostAccountRequest = - prev !== null - ? deepCopy(prev) - : { - contacts: [], - hostnames: [] - } - - newAccount.hostnames.push(newHostname) - + const newAccount = deepCopy(prev) + newAccount.hostnames.push(hostname) return newAccount }) resetHostname() } - const handleDeleteContact = (contact: string) => { - setAccount((prev) => { - if (prev === null) return null - - const newAccount = deepCopy(prev) - newAccount.contacts = newAccount.contacts.filter((c) => c !== contact) - - return newAccount - }) - } - const handleDeleteHostname = (hostname: string) => { setAccount((prev) => { - if (prev === null) return null - const newAccount = deepCopy(prev) newAccount.hostnames = newAccount.hostnames.filter((h) => h !== hostname) - return newAccount }) } @@ -135,41 +155,49 @@ const RegisterPage = () => { const handleSubmit = async (e: FormEvent) => { e.preventDefault() - const error = validatePostAccountRequest(account) - if (error) { - console.error(`Validation failed: ${error}`) - // dipatch toasterror - dispatch(showToast({ message: error, type: 'error' })) + const errors = validatePostAccountRequest(account) - return + if (errors.length > 0) { + errors.forEach((error) => { + dispatch(showToast({ message: error, type: 'error' })) + }) + } else { + dispatch( + showToast({ message: 'Request model is valid', type: 'success' }) + ) + + httpService + .post< + PostAccountRequest, + GetAccountResponse + >(GetApiRoute(ApiRoutes.ACCOUNT), account) + .then((response) => { + console.log(response) + dispatch(showToast({ message: 'Account created', type: 'success' })) + }) } - - // httpService.post('', account) - - console.log(account) } return ( -
-

- Register LetsEncrypt Account -

+

Contacts:

    - {account?.contacts.map((contact) => ( + {account.contacts.map((contact) => (
  • {
{
+
+ +
+

Hostnames:

    - {account?.hostnames.map((hostname) => ( + {account.hostnames.map((hostname) => (
  • {
{
+ +
+ { + setAccount((prev) => { + const newAccount = deepCopy(prev) + newAccount.isStaging = value === 'staging' + return newAccount + }) + }} + title="LetsEncrypt Environment" + className="" + radioClassName="" + errorClassName="text-red-500 text-sm mt-1" + /> +
+ { Create Account
-
+ ) } diff --git a/src/ClientApp/components/pageContainer.tsx b/src/ClientApp/components/pageContainer.tsx new file mode 100644 index 0000000..738fbc7 --- /dev/null +++ b/src/ClientApp/components/pageContainer.tsx @@ -0,0 +1,19 @@ +interface PageContainerProps { + title?: string + children: React.ReactNode +} + +const PageContainer: React.FC = (props) => { + const { title, children } = props + + return ( +
+ {title && ( +

{title}

+ )} + {children} +
+ ) +} + +export { PageContainer } diff --git a/src/ClientApp/components/toast.tsx b/src/ClientApp/components/toast.tsx index ca33771..5502381 100644 --- a/src/ClientApp/components/toast.tsx +++ b/src/ClientApp/components/toast.tsx @@ -4,33 +4,34 @@ import { ToastContainer, toast } from 'react-toastify' import 'react-toastify/dist/ReactToastify.css' import { useDispatch, useSelector } from 'react-redux' import { RootState } from '@/redux/store' -import { clearToast } from '@/redux/slices/toastSlice' +import { clearToast, removeToast } from '@/redux/slices/toastSlice' const Toast = () => { const dispatch = useDispatch() const toastState = useSelector((state: RootState) => state.toast) useEffect(() => { - if (toastState.message) { - switch (toastState.type) { + toastState.messages.forEach((toastMessage) => { + switch (toastMessage.type) { case 'success': - toast.success(toastState.message) + toast.success(toastMessage.message) break case 'error': - toast.error(toastState.message) + toast.error(toastMessage.message) break case 'info': - toast.info(toastState.message) + toast.info(toastMessage.message) break case 'warning': - toast.warn(toastState.message) + toast.warn(toastMessage.message) break default: - toast(toastState.message) + toast(toastMessage.message) break } - dispatch(clearToast()) - } + + dispatch(removeToast(toastMessage.id)) + }) }, [toastState, dispatch]) return ( diff --git a/src/ClientApp/controls/customCheckbox.tsx b/src/ClientApp/controls/customCheckbox.tsx new file mode 100644 index 0000000..819a089 --- /dev/null +++ b/src/ClientApp/controls/customCheckbox.tsx @@ -0,0 +1,57 @@ +// components/CustomCheckbox.tsx +import React, { FC } from 'react' + +interface CustomCheckboxProps { + checked: boolean + onChange?: (checked: boolean) => void + label?: string + checkboxClassName?: string + labelClassName?: string + error?: string + errorClassName?: string + className?: string + readOnly?: boolean + disabled?: boolean +} + +const CustomCheckbox: FC = (props) => { + const { + checked, + onChange, + label, + checkboxClassName = '', + labelClassName = '', + error, + errorClassName = '', + className = '', + readOnly = false, + disabled = false + } = props + + const handleChange = (e: React.ChangeEvent) => { + if (!readOnly && !disabled) { + onChange?.(e.target.checked) + } + } + + return ( +
+ + {error && ( +

{error}

+ )} +
+ ) +} + +export { CustomCheckbox } diff --git a/src/ClientApp/controls/customEnumSelect.tsx b/src/ClientApp/controls/customEnumSelect.tsx new file mode 100644 index 0000000..d3cf3fc --- /dev/null +++ b/src/ClientApp/controls/customEnumSelect.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import { + CustomSelect, + CustomSelectOption, + CustomSelectPropsBase +} from './customSelect' +import { enumToArray } from '@/app/functions' + +interface CustomEnumSelectProps extends CustomSelectPropsBase { + enumType: any +} + +const CustomEnumSelect: React.FC = (props) => { + const { enumType, ...customSelectProps } = props + + const options = enumToArray(enumType).map((item) => { + const option: CustomSelectOption = { + value: `${item.value}`, + label: item.key + } + + return option + }) + + return +} + +export { CustomEnumSelect } diff --git a/src/ClientApp/controls/customInput.tsx b/src/ClientApp/controls/customInput.tsx index 9e9f48a..c51d088 100644 --- a/src/ClientApp/controls/customInput.tsx +++ b/src/ClientApp/controls/customInput.tsx @@ -12,6 +12,8 @@ interface CustomInputProps { inputClassName?: string errorClassName?: string className?: string + readOnly?: boolean + disabled?: boolean children?: React.ReactNode // Added for additional elements } @@ -25,6 +27,8 @@ const CustomInput: FC = ({ inputClassName = '', errorClassName = '', className = '', + readOnly = false, + disabled = false, children // Added for additional elements }) => { const handleChange = (e: React.ChangeEvent) => { @@ -41,6 +45,8 @@ const CustomInput: FC = ({ onChange={handleChange} placeholder={placeholder} className={`flex-grow ${inputClassName}`} + readOnly={readOnly} + disabled={disabled} /> {children &&
{children}
} diff --git a/src/ClientApp/controls/customRadioGroup.tsx b/src/ClientApp/controls/customRadioGroup.tsx new file mode 100644 index 0000000..fbed0e8 --- /dev/null +++ b/src/ClientApp/controls/customRadioGroup.tsx @@ -0,0 +1,77 @@ +// components/CustomRadioGroup.tsx +import React, { useState, useEffect } from 'react' + +interface CustomRadioOption { + value: string + label: string +} + +interface CustomRadioGroupProps { + options: CustomRadioOption[] + initialValue?: string + onChange?: (value: string) => void + title?: string + error?: string + className?: string + radioClassName?: string + errorClassName?: string + readOnly?: boolean + disabled?: boolean +} + +const CustomRadioGroup: React.FC = ({ + options, + initialValue, + onChange, + title, + error, + className = '', + radioClassName = '', + errorClassName = '', + readOnly = false, + disabled = false +}) => { + const [selectedValue, setSelectedValue] = useState(initialValue || '') + + useEffect(() => { + if (initialValue) { + setSelectedValue(initialValue) + } + }, [initialValue]) + + const handleOptionChange = (value: string) => { + if (!readOnly && !disabled) { + setSelectedValue(value) + onChange?.(value) + } + } + + return ( +
+ {title && } +
+ {options.map((option) => ( + + ))} +
+ {error && ( +

{error}

+ )} +
+ ) +} + +export { CustomRadioGroup } diff --git a/src/ClientApp/controls/customSelect.tsx b/src/ClientApp/controls/customSelect.tsx new file mode 100644 index 0000000..3dbcfff --- /dev/null +++ b/src/ClientApp/controls/customSelect.tsx @@ -0,0 +1,110 @@ +import React, { useRef, useEffect, useState } from 'react' +import { FaChevronDown, FaChevronUp } from 'react-icons/fa' + +export interface CustomSelectOption { + value: string + label: string +} + +export interface CustomSelectPropsBase { + selectedValue: string | null | undefined + onChange: (value: string) => void + readOnly?: boolean + disabled?: boolean + title?: string + error?: string + className?: string + selectBoxClassName?: string + errorClassName?: string +} + +interface CustomSelectProps extends CustomSelectPropsBase { + options: CustomSelectOption[] +} + +const CustomSelect: React.FC = ({ + options, + selectedValue, + onChange, + readOnly = false, + disabled = false, + title, + error, + className = '', + selectBoxClassName = '', + errorClassName = '' +}) => { + const [isOpen, setIsOpen] = useState(false) + const selectBoxRef = useRef(null) + + const handleToggle = () => { + if (!readOnly && !disabled) { + setIsOpen(!isOpen) + } + } + + const handleOptionClick = (option: CustomSelectOption) => { + if (!readOnly && !disabled) { + onChange(option.value) + setIsOpen(false) + } + } + + const handleClickOutside = (event: MouseEvent) => { + if ( + selectBoxRef.current && + !selectBoxRef.current.contains(event.target as Node) + ) { + setIsOpen(false) + } + } + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + + const selectedOption = + options.find((option) => option.value === selectedValue) || null + + return ( +
+ {title && } +
+
+ {selectedOption ? selectedOption.label : 'Select an option'} + {isOpen ? ( + + ) : ( + + )} +
+ {isOpen && ( +
    + {options.map((option) => ( +
  • handleOptionClick(option)} + > + {option.label} +
  • + ))} +
+ )} +
+ {error && ( +

{error}

+ )} +
+ ) +} + +export { CustomSelect } diff --git a/src/ClientApp/controls/index.ts b/src/ClientApp/controls/index.ts index 02decac..8b61be3 100644 --- a/src/ClientApp/controls/index.ts +++ b/src/ClientApp/controls/index.ts @@ -1,4 +1,15 @@ import { CustomButton } from './customButton' import { CustomInput } from './customInput' +import { CustomCheckbox } from './customCheckbox' +import { CustomSelect } from './customSelect' +import { CustomEnumSelect } from './customEnumSelect' +import { CustomRadioGroup } from './customRadioGroup' -export { CustomButton, CustomInput } +export { + CustomButton, + CustomInput, + CustomCheckbox, + CustomSelect, + CustomEnumSelect, + CustomRadioGroup +} diff --git a/src/ClientApp/entities/CacheAccount.ts b/src/ClientApp/entities/CacheAccount.ts index 01dbda6..67a0407 100644 --- a/src/ClientApp/entities/CacheAccount.ts +++ b/src/ClientApp/entities/CacheAccount.ts @@ -2,9 +2,11 @@ import { CacheAccountHostname } from './CacheAccountHostname' export interface CacheAccount { accountId: string + isDisabled: boolean description?: string contacts: string[] challengeType?: string hostnames: CacheAccountHostname[] isEditMode: boolean + isStaging: boolean } diff --git a/src/ClientApp/entities/CacheAccountHostname.ts b/src/ClientApp/entities/CacheAccountHostname.ts index 1ef6401..2500012 100644 --- a/src/ClientApp/entities/CacheAccountHostname.ts +++ b/src/ClientApp/entities/CacheAccountHostname.ts @@ -2,4 +2,5 @@ export interface CacheAccountHostname { hostname: string expires: Date isUpcomingExpire: boolean + isDisabled: boolean } diff --git a/src/ClientApp/entities/ChallengeTypes.ts b/src/ClientApp/entities/ChallengeTypes.ts new file mode 100644 index 0000000..dde8b07 --- /dev/null +++ b/src/ClientApp/entities/ChallengeTypes.ts @@ -0,0 +1,4 @@ +export enum ChallengeTypes { + http01 = 'http-01', + dns01 = 'dns-01' +} diff --git a/src/ClientApp/hooks/useValidation.tsx b/src/ClientApp/hooks/useValidation.tsx index 7f36ec6..198e89e 100644 --- a/src/ClientApp/hooks/useValidation.tsx +++ b/src/ClientApp/hooks/useValidation.tsx @@ -1,6 +1,10 @@ import { useState, useEffect, useCallback } from 'react' // Helper functions for validation +const isBypass = (value: any) => { + return true +} + const isValidEmail = (email: string) => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ return emailRegex.test(email) @@ -87,6 +91,7 @@ const useValidation = ( export { useValidation, + isBypass, isValidEmail, isValidPhoneNumber, isValidContact, diff --git a/src/ClientApp/models/letsEncryptServer/account/responses/GetAccountResponse.ts b/src/ClientApp/models/letsEncryptServer/account/responses/GetAccountResponse.ts index eee38e2..52f984d 100644 --- a/src/ClientApp/models/letsEncryptServer/account/responses/GetAccountResponse.ts +++ b/src/ClientApp/models/letsEncryptServer/account/responses/GetAccountResponse.ts @@ -2,8 +2,10 @@ import { HostnameResponse } from './HostnameResponse' export interface GetAccountResponse { accountId: string - description?: string + isDisabled: boolean + description: string contacts: string[] challengeType?: string hostnames?: HostnameResponse[] + isStaging: boolean } diff --git a/src/ClientApp/models/letsEncryptServer/account/responses/HostnameResponse.ts b/src/ClientApp/models/letsEncryptServer/account/responses/HostnameResponse.ts index 70311e7..2d077db 100644 --- a/src/ClientApp/models/letsEncryptServer/account/responses/HostnameResponse.ts +++ b/src/ClientApp/models/letsEncryptServer/account/responses/HostnameResponse.ts @@ -2,4 +2,5 @@ export interface HostnameResponse { hostname: string expires: string isUpcomingExpire: boolean + isDisabled: boolean } diff --git a/src/ClientApp/models/letsEncryptServer/certsFlow/PostAccountRequest.ts b/src/ClientApp/models/letsEncryptServer/certsFlow/PostAccountRequest.ts index 4946dc5..ba95e03 100644 --- a/src/ClientApp/models/letsEncryptServer/certsFlow/PostAccountRequest.ts +++ b/src/ClientApp/models/letsEncryptServer/certsFlow/PostAccountRequest.ts @@ -1,32 +1,57 @@ import { isValidContact, isValidHostname } from '@/hooks/useValidation' export interface PostAccountRequest { - description?: string + description: string contacts: string[] + challengeType: string hostnames: string[] + isStaging: boolean } const validatePostAccountRequest = ( request: PostAccountRequest | null -): string | null => { - if (request === null) return 'Request is null' +): string[] => { + const errors: string[] = [] + + if (request === null) { + errors.push('Request is null') + return errors + } + + // Validate description + if (request.description === '') { + errors.push('Description cannot be empty') + } // Validate contacts - for (const contact of request.contacts) { + if (request.contacts.length === 0) { + errors.push('Contacts cannot be empty') + } + + request.contacts.forEach((contact) => { if (!isValidContact(contact)) { - return `Invalid contact: ${contact}` + errors.push(`Invalid contact: ${contact}`) } + }) + + // Validate challenge type + if (request.challengeType === '') { + errors.push('Challenge type cannot be empty') } // Validate hostnames - for (const hostname of request.hostnames) { - if (!isValidHostname(hostname)) { - return `Invalid hostname: ${hostname}` - } + if (request.hostnames.length === 0) { + errors.push('Hostnames cannot be empty') } - // If all validations pass, return null - return null + request.hostnames.forEach((hostname) => { + if (!isValidHostname(hostname)) { + errors.push(`Invalid hostname: ${hostname}`) + } + }) + + // Return the array of errors + return errors } export { validatePostAccountRequest } diff --git a/src/ClientApp/package-lock.json b/src/ClientApp/package-lock.json index e5127c5..38c0212 100644 --- a/src/ClientApp/package-lock.json +++ b/src/ClientApp/package-lock.json @@ -15,12 +15,14 @@ "react-dom": "^18", "react-icons": "^5.2.1", "react-redux": "^9.1.2", - "react-toastify": "^10.0.5" + "react-toastify": "^10.0.5", + "uuid": "^10.0.0" }, "devDependencies": { "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/uuid": "^10.0.0", "eslint": "^8", "eslint-config-next": "14.2.3", "eslint-config-prettier": "^9.1.0", @@ -538,6 +540,12 @@ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, "node_modules/@typescript-eslint/parser": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", @@ -4814,6 +4822,18 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/src/ClientApp/package.json b/src/ClientApp/package.json index bf3f7f0..fa8d264 100644 --- a/src/ClientApp/package.json +++ b/src/ClientApp/package.json @@ -16,12 +16,14 @@ "react-dom": "^18", "react-icons": "^5.2.1", "react-redux": "^9.1.2", - "react-toastify": "^10.0.5" + "react-toastify": "^10.0.5", + "uuid": "^10.0.0" }, "devDependencies": { "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/uuid": "^10.0.0", "eslint": "^8", "eslint-config-next": "14.2.3", "eslint-config-prettier": "^9.1.0", diff --git a/src/ClientApp/redux/slices/toastSlice.ts b/src/ClientApp/redux/slices/toastSlice.ts index 63eec9d..e6cf4c4 100644 --- a/src/ClientApp/redux/slices/toastSlice.ts +++ b/src/ClientApp/redux/slices/toastSlice.ts @@ -1,36 +1,42 @@ // store/toastSlice.ts import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { v4 as uuidv4 } from 'uuid' // Assuming UUID is used for generating unique IDs -interface ToastState { +interface ToastMessage { + id: string // Add an id field message: string type: 'success' | 'error' | 'info' | 'warning' } +interface ToastState { + messages: ToastMessage[] +} + const initialState: ToastState = { - message: '', - type: 'info' + messages: [] } const toastSlice = createSlice({ name: 'toast', initialState, reducers: { - showToast: ( - state, - action: PayloadAction<{ - message: string - type: 'success' | 'error' | 'info' | 'warning' - }> - ) => { - state.message = action.payload.message - state.type = action.payload.type + showToast: (state, action: PayloadAction>) => { + // Generate a unique ID for each toast message + const id = uuidv4() + const newMessage = { ...action.payload, id } + state.messages.push(newMessage) }, clearToast: (state) => { - state.message = '' - state.type = 'info' + state.messages = [] + }, + removeToast: (state, action: PayloadAction) => { + // Remove a specific toast message by ID + state.messages = state.messages.filter( + (message) => message.id !== action.payload + ) } } }) -export const { showToast, clearToast } = toastSlice.actions +export const { showToast, clearToast, removeToast } = toastSlice.actions export default toastSlice.reducer diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/AuthorizationChallange.cs b/src/LetsEncrypt/Entities/LetsEncrypt/AuthorizationChallange.cs deleted file mode 100644 index c937703..0000000 --- a/src/LetsEncrypt/Entities/LetsEncrypt/AuthorizationChallange.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace MaksIT.LetsEncrypt.Entities; -public class AuthorizationChallenge { - public Uri? Url { get; set; } - - public string? Type { get; set; } - - public string? Status { get; set; } - - public string? Token { get; set; } -} diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/CachedCertificateResult.cs b/src/LetsEncrypt/Entities/LetsEncrypt/CachedCertificateResult.cs deleted file mode 100644 index c149212..0000000 --- a/src/LetsEncrypt/Entities/LetsEncrypt/CachedCertificateResult.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Security.Cryptography; - -namespace MaksIT.LetsEncrypt.Entities; - -public class CachedCertificateResult { - public RSACryptoServiceProvider? PrivateKey { get; set; } - - public string PrivateKeyPem => PrivateKey == null ? "" : PrivateKey.ExportRSAPrivateKeyPem(); - - public string? Certificate { get; set; } -} diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/CachedHostname.cs b/src/LetsEncrypt/Entities/LetsEncrypt/CachedHostname.cs new file mode 100644 index 0000000..2aba48e --- /dev/null +++ b/src/LetsEncrypt/Entities/LetsEncrypt/CachedHostname.cs @@ -0,0 +1,16 @@ +namespace MaksIT.LetsEncrypt.Entities { + public class CachedHostname { + public string Hostname { get; set; } + public DateTime Expires { get; set; } + public bool IsUpcomingExpire { get; set; } + + public bool IsDisabled { get; set; } + + public CachedHostname(string hostname, DateTime expires, bool isUpcomingExpire, bool isDisabled) { + Hostname = hostname; + Expires = expires; + IsUpcomingExpire = isUpcomingExpire; + IsDisabled = isDisabled; + } + } +} diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/CertificateCache.cs b/src/LetsEncrypt/Entities/LetsEncrypt/CertificateCache.cs new file mode 100644 index 0000000..4a38765 --- /dev/null +++ b/src/LetsEncrypt/Entities/LetsEncrypt/CertificateCache.cs @@ -0,0 +1,8 @@ +namespace MaksIT.LetsEncrypt.Entities; + +public class CertificateCache { + public required string Cert { get; set; } + public required byte[]? Private { get; set; } + public required string? PrivatePem { get; set; } + public bool IsDisabled { get; set; } +} diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs b/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs index 0fc51c0..bfe19d6 100644 --- a/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs +++ b/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs @@ -6,22 +6,6 @@ using System.Security.Cryptography.X509Certificates; using MaksIT.LetsEncrypt.Entities.Jws; namespace MaksIT.LetsEncrypt.Entities; -public class CertificateCache { - public string? Cert { get; set; } - public byte[]? Private { get; set; } -} - -public class CachedHostname { - public string Hostname { get; set; } - public DateTime Expires { get; set; } - public bool IsUpcomingExpire { get; set; } - - public CachedHostname(string hostname, DateTime expires, bool isUpcomingExpire) { - Hostname = hostname; - Expires = expires; - IsUpcomingExpire = isUpcomingExpire; - } -} public class RegistrationCache { @@ -30,9 +14,12 @@ public class RegistrationCache { /// Field used to identify cache by account id /// public required Guid AccountId { get; set; } + public bool IsDisabled { get; set; } public string? Description { get; set; } public required string[] Contacts { get; set; } public string? ChallengeType { get; set; } + + public required bool IsStaging { get; set; } #endregion @@ -54,7 +41,7 @@ public class RegistrationCache { foreach (var result in CachedCerts) { var (subject, cachedChert) = result; - if (cachedChert.Cert != null) { + if (cachedChert.Cert != null && !cachedChert.IsDisabled) { var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert)); // if it is about to expire, we need to refresh @@ -79,7 +66,12 @@ public class RegistrationCache { if (cachedChert.Cert != null) { var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert)); - hosts.Add(new CachedHostname(subject, cert.NotAfter, (cert.NotAfter - DateTime.UtcNow).TotalDays < 30)); + hosts.Add(new CachedHostname( + subject, + cert.NotAfter, + (cert.NotAfter - DateTime.UtcNow).TotalDays < 30, + cachedChert.IsDisabled + )); } } @@ -92,7 +84,7 @@ public class RegistrationCache { /// /// /// - public bool TryGetCachedCertificate(string subject, out CachedCertificateResult? value) { + public bool TryGetCachedCertificate(string subject, out CertificateCache? value) { value = null; if (CachedCerts == null) @@ -110,9 +102,10 @@ public class RegistrationCache { var rsa = new RSACryptoServiceProvider(4096); rsa.ImportCspBlob(cache.Private); - value = new CachedCertificateResult { - Certificate = cache.Cert, - PrivateKey = rsa + value = new CertificateCache { + Cert = cache.Cert, + Private = rsa.ExportCspBlob(true), + PrivatePem = rsa.ExportRSAPrivateKeyPem() }; return true; } diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/SendResult.cs b/src/LetsEncrypt/Entities/LetsEncrypt/SendResult.cs deleted file mode 100644 index dd55fd1..0000000 --- a/src/LetsEncrypt/Entities/LetsEncrypt/SendResult.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace MaksIT.LetsEncrypt.Entities { - public class SendResult { - - public TResult? Result { get; set; } - - public string? ResponseText { get; set; } - - - } -} diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/State.cs b/src/LetsEncrypt/Entities/LetsEncrypt/State.cs new file mode 100644 index 0000000..e936cb5 --- /dev/null +++ b/src/LetsEncrypt/Entities/LetsEncrypt/State.cs @@ -0,0 +1,19 @@ +using MaksIT.LetsEncrypt.Models.Responses; +using MaksIT.LetsEncrypt.Services; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MaksIT.LetsEncrypt.Entities.LetsEncrypt { + public class State { + public bool IsStaging { get; set; } + public AcmeDirectory? Directory { get; set; } + public JwsService? JwsService { get; set; } + public Order? CurrentOrder { get; set; } + public List Challenges { get; } = new List(); + public string? Nonce { get; set; } + public RegistrationCache? Cache { get; set; } + } +} diff --git a/src/LetsEncrypt/Models/Responses/AuthorizationChallengeChallenge.cs b/src/LetsEncrypt/Models/Responses/AuthorizationChallengeChallenge.cs new file mode 100644 index 0000000..3b90d16 --- /dev/null +++ b/src/LetsEncrypt/Models/Responses/AuthorizationChallengeChallenge.cs @@ -0,0 +1,13 @@ +using System; + +namespace MaksIT.LetsEncrypt.Models.Responses; +public class AuthorizationChallengeChallenge +{ + public Uri? Url { get; set; } + + public string? Type { get; set; } + + public string? Status { get; set; } + + public string? Token { get; set; } +} diff --git a/src/LetsEncrypt/Models/Responses/AuthorizationChallengeResponse.cs b/src/LetsEncrypt/Models/Responses/AuthorizationChallengeResponse.cs index c6dba8b..43bf740 100644 --- a/src/LetsEncrypt/Models/Responses/AuthorizationChallengeResponse.cs +++ b/src/LetsEncrypt/Models/Responses/AuthorizationChallengeResponse.cs @@ -1,7 +1,4 @@ - -using MaksIT.LetsEncrypt.Entities; - -namespace MaksIT.LetsEncrypt.Models.Responses; +namespace MaksIT.LetsEncrypt.Models.Responses; public class AuthorizationChallengeResponse { public OrderIdentifier? Identifier { get; set; } @@ -12,7 +9,7 @@ public class AuthorizationChallengeResponse { public bool Wildcard { get; set; } - public AuthorizationChallenge[]? Challenges { get; set; } + public AuthorizationChallengeChallenge[]? Challenges { get; set; } } public class AuthorizeChallenge { diff --git a/src/LetsEncrypt/Models/Responses/SendResult.cs b/src/LetsEncrypt/Models/Responses/SendResult.cs new file mode 100644 index 0000000..a083358 --- /dev/null +++ b/src/LetsEncrypt/Models/Responses/SendResult.cs @@ -0,0 +1,12 @@ +namespace MaksIT.LetsEncrypt.Models.Responses +{ + public class SendResult + { + + public TResult? Result { get; set; } + + public string? ResponseText { get; set; } + + + } +} diff --git a/src/LetsEncrypt/Services/LetsEncryptService.cs b/src/LetsEncrypt/Services/LetsEncryptService.cs index fc55ca9..326cd33 100644 --- a/src/LetsEncrypt/Services/LetsEncryptService.cs +++ b/src/LetsEncrypt/Services/LetsEncryptService.cs @@ -15,11 +15,12 @@ using MaksIT.LetsEncrypt.Models.Responses; using MaksIT.LetsEncrypt.Models.Interfaces; using MaksIT.LetsEncrypt.Models.Requests; using MaksIT.LetsEncrypt.Entities.Jws; +using MaksIT.LetsEncrypt.Entities.LetsEncrypt; namespace MaksIT.LetsEncrypt.Services; public interface ILetsEncryptService { - Task ConfigureClient(Guid sessionId, string url); + Task ConfigureClient(Guid sessionId, bool isStaging); Task Init(Guid sessionId,Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache); (RegistrationCache?, IDomainResult) GetRegistrationCache(Guid sessionId); (string?, IDomainResult) GetTermsOfServiceUri(Guid sessionId); @@ -27,8 +28,6 @@ public interface ILetsEncryptService { Task CompleteChallenges(Guid sessionId); Task GetOrder(Guid sessionId, string[] hostnames); Task GetCertificate(Guid sessionId, string subject); - (string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId); - (CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject); } public class LetsEncryptService : ILetsEncryptService { @@ -54,11 +53,17 @@ public class LetsEncryptService : ILetsEncryptService { } #region ConfigureClient - public async Task ConfigureClient(Guid sessionId, string url) { + public async Task ConfigureClient(Guid sessionId, bool isStaging) { try { var state = GetOrCreateState(sessionId); - _httpClient.BaseAddress ??= new Uri(url); + state.IsStaging = isStaging; + // TODO: need to propagate from Configuration + _httpClient.BaseAddress ??= new Uri(isStaging + ? "https://acme-staging-v02.api.letsencrypt.org/directory" + : "https://acme-v02.api.letsencrypt.org/directory"); + + if (state.Directory == null) { var (directory, getAcmeDirectoryResult) = await SendAsync(sessionId, HttpMethod.Get, new Uri("directory", UriKind.Relative), false, null); @@ -133,6 +138,7 @@ public class LetsEncryptService : ILetsEncryptService { AccountId = accountId, Description = description, Contacts = contacts, + IsStaging = state.IsStaging, Location = account.Result.Location, AccountKey = accountKey.ExportCspBlob(true), @@ -307,7 +313,7 @@ public class LetsEncryptService : ILetsEncryptService { await Task.Delay(1000); if ((DateTime.UtcNow - start).Seconds > 120) - throw new TimeoutException(); + return IDomainResult.Failed("Timeout"); } } @@ -421,7 +427,8 @@ public class LetsEncryptService : ILetsEncryptService { state.Cache.CachedCerts ??= new Dictionary(); state.Cache.CachedCerts[subject] = new CertificateCache { Cert = pem.Result, - Private = key.ExportCspBlob(true) + Private = key.ExportCspBlob(true), + PrivatePem = key.ExportRSAPrivateKeyPem() }; var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem.Result)); @@ -437,29 +444,6 @@ public class LetsEncryptService : ILetsEncryptService { } #endregion - #region TryGetCachedCertificate - public (string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId) { - - var state = GetOrCreateState(sessionId); - if (state.Cache == null) - return IDomainResult.Failed(); - - return IDomainResult.Success(state.Cache.GetHostsWithUpcomingSslExpiry()); - } - - 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); - } - - return IDomainResult.Failed(); - } - #endregion - public Task KeyChange(Guid sessionId) { throw new NotImplementedException(); @@ -679,13 +663,4 @@ public class LetsEncryptService : ILetsEncryptService { }; } #endregion - - private class State { - public AcmeDirectory? Directory { get; set; } - public JwsService? JwsService { get; set; } - public Order? CurrentOrder { get; set; } - public List Challenges { get; } = new List(); - public string? Nonce { get; set; } - public RegistrationCache? Cache { get; set; } - } } diff --git a/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs b/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs index 2fd9f4c..6e6e79e 100644 --- a/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs +++ b/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs @@ -37,7 +37,7 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices { continue; } - foreach (var account in accountsResponse) { + foreach (var account in accountsResponse.Where(x => !x.IsDisabled)) { await ProcessAccountAsync(account); } @@ -61,7 +61,7 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices { return IDomainResult.Success(); } - var renewResult = await RenewCertificatesForHostnames(cache.AccountId, cache.Description, cache.Contacts, hostnames, cache.ChallengeType); + var renewResult = await RenewCertificatesForHostnames(cache.AccountId, cache.Description, cache.Contacts, hostnames, cache.ChallengeType, cache.IsStaging); if (!renewResult.IsSuccess) return renewResult; @@ -70,8 +70,8 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices { return IDomainResult.Success(); } - private async Task RenewCertificatesForHostnames(Guid accountId, string description, string[] contacts, string[] hostnames, string challengeType) { - var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync(); + private async Task RenewCertificatesForHostnames(Guid accountId, string description, string[] contacts, string[] hostnames, string challengeType, bool isStaging) { + var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync(isStaging); if (!configureClientResult.IsSuccess || sessionId == null) { LogErrors(configureClientResult.Errors); return configureClientResult; diff --git a/src/LetsEncryptServer/Configuration.cs b/src/LetsEncryptServer/Configuration.cs index ced1dd4..ebe14f1 100644 --- a/src/LetsEncryptServer/Configuration.cs +++ b/src/LetsEncryptServer/Configuration.cs @@ -11,7 +11,6 @@ public class Configuration { public required string Production { get; set; } public required string Staging { get; set; } - public required bool DevMode { get; set; } public required Agent Agent { get; set; } } } diff --git a/src/LetsEncryptServer/Controllers/CertsFlowController.cs b/src/LetsEncryptServer/Controllers/CertsFlowController.cs index 6602849..6ae66be 100644 --- a/src/LetsEncryptServer/Controllers/CertsFlowController.cs +++ b/src/LetsEncryptServer/Controllers/CertsFlowController.cs @@ -27,8 +27,8 @@ namespace MaksIT.LetsEncryptServer.Controllers { /// /// sessionId [HttpPost("configure-client")] - public async Task ConfigureClient() { - var result = await _certsFlowService.ConfigureClientAsync(); + public async Task ConfigureClient([FromBody] ConfigureClientRequest requestData) { + var result = await _certsFlowService.ConfigureClientAsync(requestData); return result.ToActionResult(); } diff --git a/src/LetsEncryptServer/Services/AccoutService.cs b/src/LetsEncryptServer/Services/AccoutService.cs index 6633246..0411e0b 100644 --- a/src/LetsEncryptServer/Services/AccoutService.cs +++ b/src/LetsEncryptServer/Services/AccoutService.cs @@ -1,10 +1,6 @@ -using System.Text.Json; +using DomainResults.Common; -using DomainResults.Common; - -using MaksIT.Core.Extensions; using MaksIT.LetsEncrypt.Entities; -using MaksIT.LetsEncrypt.Models.Responses; using MaksIT.Models; using MaksIT.Models.LetsEncryptServer.Account.Requests; using MaksIT.Models.LetsEncryptServer.Account.Responses; @@ -53,41 +49,30 @@ public class AccountService : IAccountService { #region Accounts public async Task<(GetAccountResponse[]?, IDomainResult)> GetAccountsAsync() { - + var (caches, result) = await _cacheService.LoadAccountsFromCacheAsync(); if (!result.IsSuccess || caches == null) { return (null, result); } - var accounts = caches.Select(cache => new GetAccountResponse { - AccountId = cache.AccountId, - Description = cache.Description, - Contacts = cache.Contacts, - ChallengeType = cache.ChallengeType, - Hostnames = GetHostnamesFromCache(cache).ToArray() - }); + var accounts = caches + .Select(x => CreateGetAccountResponse(x.AccountId, x)) + .ToArray(); - return IDomainResult.Success(accounts.ToArray()); + return IDomainResult.Success(accounts); } public async Task<(GetAccountResponse?, IDomainResult)> GetAccountAsync(Guid accountId) { - var (cache, result) = await _cacheService.LoadAccountFromCacheAsync(accountId); - if (!result.IsSuccess || cache == null) { - return (null, result); - } + var (cache, result) = await _cacheService.LoadAccountFromCacheAsync(accountId); + if (!result.IsSuccess || cache == null) { + return (null, result); + } - var response = new GetAccountResponse { - AccountId = accountId, - Description = cache.Description, - Contacts = cache.Contacts, - Hostnames = GetHostnamesFromCache(cache).ToArray() - }; - - return IDomainResult.Success(response); + return IDomainResult.Success(CreateGetAccountResponse(accountId, cache)); } public async Task<(GetAccountResponse?, IDomainResult)> PostAccountAsync(PostAccountRequest requestData) { - var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync(); + var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync(requestData.IsStaging); if (!configureClientResult.IsSuccess || sessionId == null) { //LogErrors(configureClientResult.Errors); return (null, configureClientResult); @@ -147,7 +132,7 @@ public class AccountService : IAccountService { return (null, saveResult); } - return CreateGetAccountResponse(accountId, cache); + return IDomainResult.Success(CreateGetAccountResponse(accountId, cache)); } public async Task<(GetAccountResponse?, IDomainResult)> PatchAccountAsync(Guid accountId, PatchAccountRequest requestData) { @@ -190,7 +175,7 @@ public class AccountService : IAccountService { return (null, saveResult); } - return CreateGetAccountResponse(accountId, cache); + return IDomainResult.Success(CreateGetAccountResponse(accountId, cache)); } public async Task DeleteAccountAsync(Guid accountId) { @@ -227,7 +212,7 @@ public class AccountService : IAccountService { return (null, saveResult); } - return CreateGetAccountResponse(accountId, cache); + return IDomainResult.Success(CreateGetAccountResponse(accountId, cache)); } public async Task<(GetAccountResponse?, IDomainResult)> PatchContactsAsync(Guid accountId, PatchContactsRequest requestData) { @@ -266,7 +251,7 @@ public class AccountService : IAccountService { return (null, saveResult); } - return CreateGetAccountResponse(accountId, cache); + return IDomainResult.Success(CreateGetAccountResponse(accountId, cache)); } public async Task DeleteContactAsync(Guid accountId, int index) { @@ -311,7 +296,8 @@ public class AccountService : IAccountService { var hosts = cache.GetHosts().Select(x => new HostnameResponse { Hostname = x.Hostname, Expires = x.Expires, - IsUpcomingExpire = x.IsUpcomingExpire + IsUpcomingExpire = x.IsUpcomingExpire, + IsDisabled = x.IsDisabled }).ToList(); return hosts; @@ -321,15 +307,18 @@ public class AccountService : IAccountService { #region Helper Methods - private (GetAccountResponse?, IDomainResult) CreateGetAccountResponse(Guid accountId, RegistrationCache cache) { - var hostnames = GetHostnamesFromCache(cache) ?? new List(); + private GetAccountResponse CreateGetAccountResponse(Guid accountId, RegistrationCache cache) { + var hostnames = GetHostnamesFromCache(cache) ?? []; - return (new GetAccountResponse { + return new GetAccountResponse { AccountId = accountId, + IsDisabled = cache.IsDisabled, Description = cache.Description, Contacts = cache.Contacts, - Hostnames = hostnames.ToArray() - }, IDomainResult.Success()); + ChallengeType = cache.ChallengeType, + Hostnames = [.. hostnames], + IsStaging = cache.IsStaging + }; } diff --git a/src/LetsEncryptServer/Services/CertsFlowService.cs b/src/LetsEncryptServer/Services/CertsFlowService.cs index 0d7e5be..6a2525e 100644 --- a/src/LetsEncryptServer/Services/CertsFlowService.cs +++ b/src/LetsEncryptServer/Services/CertsFlowService.cs @@ -5,17 +5,19 @@ using DomainResults.Common; using MaksIT.LetsEncrypt.Entities; using MaksIT.LetsEncrypt.Services; using MaksIT.Models.LetsEncryptServer.CertsFlow.Requests; +using System.Security.Cryptography; namespace MaksIT.LetsEncryptServer.Services; public interface ICertsCommonService { - Task<(Guid?, IDomainResult)> ConfigureClientAsync(); + (string?, IDomainResult) GetTermsOfService(Guid sessionId); Task CompleteChallengesAsync(Guid sessionId); } public interface ICertsInternalService : ICertsCommonService { + Task<(Guid?, IDomainResult)> ConfigureClientAsync(bool isStaging); Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts); Task<(List?, IDomainResult)> NewOrderAsync(Guid sessionId, string[] hostnames, string challengeType); Task GetOrderAsync(Guid sessionId, string[] hostnames); @@ -24,6 +26,7 @@ public interface ICertsInternalService : ICertsCommonService { } public interface ICertsRestService : ICertsCommonService { + Task<(Guid?, IDomainResult)> ConfigureClientAsync(ConfigureClientRequest requestData); Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, InitRequest requestData); Task<(List?, IDomainResult)> NewOrderAsync(Guid sessionId, NewOrderRequest requestData); Task GetOrderAsync(Guid sessionId, GetOrderRequest requestData); @@ -38,7 +41,7 @@ public interface ICertsRestChallengeService { public interface ICertsFlowService : ICertsInternalService, ICertsRestService, - ICertsRestChallengeService {} + ICertsRestChallengeService { } public class CertsFlowService : ICertsFlowService { @@ -69,20 +72,8 @@ public class CertsFlowService : ICertsFlowService { } #region Common methods - - 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); @@ -95,11 +86,20 @@ public class CertsFlowService : ICertsFlowService { public async Task CompleteChallengesAsync(Guid sessionId) { return await _letsEncryptService.CompleteChallenges(sessionId); } - + #endregion #region Internal methods - + public async Task<(Guid?, IDomainResult)> ConfigureClientAsync(bool isStaging) { + var sessionId = Guid.NewGuid(); + + var result = await _letsEncryptService.ConfigureClient(sessionId, isStaging); + if (!result.IsSuccess) + return (null, result); + + return IDomainResult.Success(sessionId); + } + public async Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts) { RegistrationCache? cache = null; @@ -161,18 +161,22 @@ public class CertsFlowService : ICertsFlowService { } public async Task<(Dictionary?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, string[] hostnames) { + + var (cache, getCacheResult) = _letsEncryptService.GetRegistrationCache(sessionId); + if (!getCacheResult.IsSuccess || cache?.CachedCerts == null) + return (null, getCacheResult); + + var results = new Dictionary(); - - foreach (var subject in hostnames) { - var (cert, getCertResult) = _letsEncryptService.TryGetCachedCertificate(sessionId, subject); - if (!getCertResult.IsSuccess || cert == null) - return (null, getCertResult); - - var content = $"{cert.Certificate}\n{cert.PrivateKeyPem}"; - results.Add(subject, content); + foreach (var hostname in hostnames) { + CertificateCache? cert; + if (cache.TryGetCachedCertificate(hostname, out cert)) { + var content = $"{cert.Cert}\n{cert.PrivatePem}"; + results.Add(hostname, content); + } } - // TODO: send the certificates to the server + // Send the certificates to the via agent var uploadResult = await _agentService.UploadCerts(results); if (!uploadResult.IsSuccess) return (null, uploadResult); @@ -183,10 +187,12 @@ public class CertsFlowService : ICertsFlowService { return IDomainResult.Success(results); } - + #endregion - #region Webapi specific methods + #region REST methods + public Task<(Guid?, IDomainResult)> ConfigureClientAsync(ConfigureClientRequest requestData) => + ConfigureClientAsync(requestData.IsStaging); public Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, InitRequest requestData) => InitAsync(sessionId, accountId, requestData.Description, requestData.Contacts); @@ -202,10 +208,10 @@ public class CertsFlowService : ICertsFlowService { public Task<(Dictionary?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData) => ApplyCertificatesAsync(sessionId, requestData.Hostnames); - + #endregion - #region Acme Challenge Webapi specific methods + #region Acme Challenge REST methods public (string?, IDomainResult) AcmeChallenge(string fileName) { DeleteExporedChallenges(); diff --git a/src/LetsEncryptServer/appsettings.json b/src/LetsEncryptServer/appsettings.json index 8726755..e6e78da 100644 --- a/src/LetsEncryptServer/appsettings.json +++ b/src/LetsEncryptServer/appsettings.json @@ -11,8 +11,6 @@ "Production": "https://acme-v02.api.letsencrypt.org/directory", "Staging": "https://acme-staging-v02.api.letsencrypt.org/directory", - "DevMode": false, - "Agent": { "AgentHostname": "http://lblsrv0001.corp.maks-it.com", "AgentPort": 5000, diff --git a/src/Models/LetsEncryptServer/Account/Requests/PostAccountRequest.cs b/src/Models/LetsEncryptServer/Account/Requests/PostAccountRequest.cs index 150abb4..2bdd365 100644 --- a/src/Models/LetsEncryptServer/Account/Requests/PostAccountRequest.cs +++ b/src/Models/LetsEncryptServer/Account/Requests/PostAccountRequest.cs @@ -4,9 +4,9 @@ namespace MaksIT.Models.LetsEncryptServer.Account.Requests { public class PostAccountRequest : IValidatableObject { public required string Description { get; set; } public required string[] Contacts { get; set; } - public required string[] Hostnames { get; set; } - public required string ChallengeType { get; set; } + public required string[] Hostnames { get; set; } + public required bool IsStaging { get; set; } public IEnumerable Validate(ValidationContext validationContext) { if (string.IsNullOrWhiteSpace(Description)) diff --git a/src/Models/LetsEncryptServer/Account/Responses/GetAccountResponse.cs b/src/Models/LetsEncryptServer/Account/Responses/GetAccountResponse.cs index 0ae4663..73d7b81 100644 --- a/src/Models/LetsEncryptServer/Account/Responses/GetAccountResponse.cs +++ b/src/Models/LetsEncryptServer/Account/Responses/GetAccountResponse.cs @@ -7,13 +7,16 @@ using System.Threading.Tasks; namespace MaksIT.Models.LetsEncryptServer.Account.Responses { public class GetAccountResponse { public Guid AccountId { get; set; } + public required bool IsDisabled { get; set; } public string? Description { get; set; } - public required string [] Contacts { get; set; } + public required string[] Contacts { get; set; } public string? ChallengeType { get; set; } public HostnameResponse[]? Hostnames { get; set; } + + public required bool IsStaging { get; set; } } } diff --git a/src/Models/LetsEncryptServer/Account/Responses/HostnameResponse.cs b/src/Models/LetsEncryptServer/Account/Responses/HostnameResponse.cs index 81a18ca..6a98912 100644 --- a/src/Models/LetsEncryptServer/Account/Responses/HostnameResponse.cs +++ b/src/Models/LetsEncryptServer/Account/Responses/HostnameResponse.cs @@ -6,9 +6,10 @@ using System.Threading.Tasks; namespace MaksIT.Models.LetsEncryptServer.Account.Responses { public class HostnameResponse { - public string Hostname { get; set; } + public required string Hostname { get; set; } public DateTime Expires { get; set; } public bool IsUpcomingExpire { get; set; } + public bool IsDisabled { get; set; } } } diff --git a/src/Models/LetsEncryptServer/CertsFlow/Requests/ConfigureClientRequest.cs b/src/Models/LetsEncryptServer/CertsFlow/Requests/ConfigureClientRequest.cs new file mode 100644 index 0000000..752d468 --- /dev/null +++ b/src/Models/LetsEncryptServer/CertsFlow/Requests/ConfigureClientRequest.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests { + public class ConfigureClientRequest { + public bool IsStaging { get; set; } + } +} diff --git a/src/docker-compose/LetsEncryptServer/cache/b2a279ae-0306-4d77-9408-66a078f34fa6.json b/src/docker-compose/LetsEncryptServer/cache/b2a279ae-0306-4d77-9408-66a078f34fa6.json deleted file mode 100644 index 26243e1..0000000 --- a/src/docker-compose/LetsEncryptServer/cache/b2a279ae-0306-4d77-9408-66a078f34fa6.json +++ /dev/null @@ -1 +0,0 @@ -{"AccountId":"b2a279ae-0306-4d77-9408-66a078f34fa6","Description":"Main Prod","ChallengeType":"http-01","Contacts":["maksym.sadovnychyy@gmail.com"],"CachedCerts":{"maks-it.com":{"Cert":"-----BEGIN CERTIFICATE-----\nMIIF9zCCBN\u002BgAwIBAgISA\u002BeXxGMTkVzdcOun4cTxIFBuMA0GCSqGSIb3DQEBCwUA\nMDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD\nEwNSMTEwHhcNMjQwNjA4MTkzMjQ4WhcNMjQwOTA2MTkzMjQ3WjAWMRQwEgYDVQQD\nEwttYWtzLWl0LmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIZ5\nBv\u002BhJjZfolnnM23Fk8a6l9l3ezMVTxMSZM62sZ8toC\u002BWSBcx0mGJ6qLhTF348xlF\nGQzzZ7OLHv\u002BZq/wn0in8Qlu/n7OWlFHBUTBCy5bp2MXxrstJaY/pfDz2WYVgBDvh\nsPiRdfA/UY1f05qlUFamENuTNzDSflIKHIG0HCu31B4mA7v423Ai8vrTm3yav8KH\n10SnwSz0lCXMrB7V94BxCpqFYLUZLS8oJjIn26xEg4rBWCJlxKyBQq1T/KhuTfcg\nOMNjyR1bZU1\u002Bj31XRy0qcNsOfCsHAk/Ojae6q9Oy7ACFd31pTAmmf9LQlEtlD/fx\nszQLfTAKwY8dIWDJ5Va\u002BWhYlJSBF95cU3iJ/vFyQn1FaylAQ2P\u002B0/esXPSwJxQrE\nNs5lRunzwiHANYXQN\u002B9I9o4cL6n5HO1/orCSg/4FDJUNq7gnLlZNDewiaRGk0497\nONnsnt\u002BOLvaa7Jhdgx5GYXTaB3x9IBgNqVYm9CY5\u002ByQnbhXHPPVHnpCnWgahmCk6\nUzxREKCpy5Z4fU/6/YQz5/DOfeEdFemOZcgTIJguiI7Q\u002BHQyOIsiKbMlOcIPr4Kg\nUoStj6gZrWnHJirpW8TzeMnCiX3P8bICFj4mu4LKbc/5\u002BJBfBuq4Y53xGvZHTS2s\nI/J/2bwp2bdKDKkGhC\u002Byam015GyUZiYvEyyvVK6VAgMBAAGjggIgMIICHDAOBgNV\nHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1Ud\nEwEB/wQCMAAwHQYDVR0OBBYEFBkWLP9fRmxkZlBHOyXzIYlCfTP4MB8GA1UdIwQY\nMBaAFMXPRqTq9MPAemyVxC2wXpIvJuO5MFcGCCsGAQUFBwEBBEswSTAiBggrBgEF\nBQcwAYYWaHR0cDovL3IxMS5vLmxlbmNyLm9yZzAjBggrBgEFBQcwAoYXaHR0cDov\nL3IxMS5pLmxlbmNyLm9yZy8wKAYDVR0RBCEwH4IQYXV0aC5tYWtzLWl0LmNvbYIL\nbWFrcy1pdC5jb20wEwYDVR0gBAwwCjAIBgZngQwBAgEwggEDBgorBgEEAdZ5AgQC\nBIH0BIHxAO8AdQBIsONr2qZHNA/lagL6nTDrHFIBy1bdLIHZu7\u002BrOdiEcwAAAY/5\njcSDAAAEAwBGMEQCIEiozrVWl8AuGtl4y37/Fap70in576K355cVKOW3YTzOAiAq\nxqqrNaptO1WjAk09UgdJdcp\u002BixLgU6zuxi713gFxDQB2AN/hVuuqBa\u002B1nA\u002BGcY2o\nwDJOrlbZbqf1pWoB0cE7vlJcAAABj/mNxVEAAAQDAEcwRQIhAO2RqHLAAo3Dj8j\u002B\nRUReVWpVVjmuIVIm5RJjA8Gd68tDAiAonKRBChW6nbCYWK/MrC4JCm\u002BnCk0AQK7S\nTwSQ8EyXQjANBgkqhkiG9w0BAQsFAAOCAQEAqwS1KQtJFWuktlgBcVxP4jfKTzu8\nO620Y8RnEgrPXkoMB5juJkeISnxW11DZvTcRGqvdfT71nPNyqJWsKn/kWinC9Yi9\nXDqSb0/AdKXA81PCBGuZIVUYNf95zC61TvLnJ36qlrG8X/\u002BGDcJU8Afs3WtxuQks\nZVmFSkueuVfFXAK4yQceuhR2IEXponz\u002BgHNh1dDMyonfsdF4dmY3YFDUjUoEVz0K\nCTT0HTYR7c3pLFm184o5ieqnmTPvaN76OTDJt5It50l/Z93wFRXh4RuI1CixuoVo\n2f1SyyXasCilLttQAgabqFTFytvL/W\u002BsLqWBDaX0/QAldWg5i4N4Azvb7g==\n-----END CERTIFICATE-----\n\n-----BEGIN CERTIFICATE-----\nMIIFBjCCAu6gAwIBAgIRAIp9PhPWLzDvI4a9KQdrNPgwDQYJKoZIhvcNAQELBQAw\nTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\ncmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw\nWhcNMjcwMzEyMjM1OTU5WjAzMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg\nRW5jcnlwdDEMMAoGA1UEAxMDUjExMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAuoe8XBsAOcvKCs3UZxD5ATylTqVhyybKUvsVAbe5KPUoHu0nsyQYOWcJ\nDAjs4DqwO3cOvfPlOVRBDE6uQdaZdN5R2\u002B97/1i9qLcT9t4x1fJyyXJqC4N0lZxG\nAGQUmfOx2SLZzaiSqhwmej/\u002B71gFewiVgdtxD4774zEJuwm\u002BUE1fj5F2PVqdnoPy\n6cRms\u002BEGZkNIGIBloDcYmpuEMpexsr3E\u002BBUAnSeI\u002B\u002BJjF5ZsmydnS8TbKF5pwnnw\nSVzgJFDhxLyhBax7QG0AtMJBP6dYuC/FXJuluwme8f7rsIU5/agK70XEeOtlKsLP\nXzze41xNG/cLJyuqC0J3U095ah2H2QIDAQABo4H4MIH1MA4GA1UdDwEB/wQEAwIB\nhjAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwEgYDVR0TAQH/BAgwBgEB\n/wIBADAdBgNVHQ4EFgQUxc9GpOr0w8B6bJXELbBeki8m47kwHwYDVR0jBBgwFoAU\nebRZ5nu25eQBc4AIiMgaWPbpm24wMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAC\nhhZodHRwOi8veDEuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcG\nA1UdHwQgMB4wHKAaoBiGFmh0dHA6Ly94MS5jLmxlbmNyLm9yZy8wDQYJKoZIhvcN\nAQELBQADggIBAE7iiV0KAxyQOND1H/lxXPjDj7I3iHpvsCUf7b632IYGjukJhM1y\nv4Hz/MrPU0jtvfZpQtSlET41yBOykh0FX\u002Bou1Nj4ScOt9ZmWnO8m2OG0JAtIIE38\n01S0qcYhyOE2G/93ZCkXufBL713qzXnQv5C/viOykNpKqUgxdKlEC\u002BHi9i2DcaR1\ne9KUwQUZRhy5j/PEdEglKg3l9dtD4tuTm7kZtB8v32oOjzHTYw\u002B7KdzdZiw/sBtn\nUfhBPORNuay4pJxmY/WrhSMdzFO2q3Gu3MUBcdo27goYKjL9CTF8j/Zz55yctUoV\naneCWs/ajUX\u002BHypkBTA\u002Bc8LGDLnWO2NKq0YD/pnARkAnYGPfUDoHR9gVSp/qRx\u002BZ\nWghiDLZsMwhN1zjtSC0uBWiugF3vTNzYIEFfaPG7Ws3jDrAMMYebQ95JQ\u002BHIBD/R\nPBuHRTBpqKlyDnkSHDHYPiNX3adPoPAcgdF3H2/W0rmoswMWgTlLn1Wu0mrks7/q\npdWfS6PJ1jty80r2VKsM/Dj3YIDfbjXKdaFU5C\u002B8bhfJGqU3taKauuz0wHVGT3eo\n6FlWkWYtbt4pgdamlwVeZEW\u002BLM7qZEJEsMNPrfC03APKmZsJgpWCDWOKZvkZcvjV\nuYkQ4omYCTX5ohy\u002BknMjdOmdH9c7SpqEWBDC86fiNex\u002BO0XOMEZSa8DA\n-----END CERTIFICATE-----\n","Private":"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"},"auth.maks-it.com":{"Cert":"-----BEGIN CERTIFICATE-----\nMIIF/TCCBOWgAwIBAgISA\u002BHfMDeeJZZXZPh6AIy1PyhAMA0GCSqGSIb3DQEBCwUA\nMDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD\nEwNSMTAwHhcNMjQwNjA4MTkzMjU1WhcNMjQwOTA2MTkzMjU0WjAbMRkwFwYDVQQD\nExBhdXRoLm1ha3MtaXQuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC\nAgEA3iP5fkBDs3ZQyGTBdAbcdAbpco9O5WtzAEFlSG5Qzy68pdAGi4IZNS2rnIOd\nCyR0Eo7JAKha6Xj8/dKjVu9rREL/0Mea/c\u002B20YDe/ouTeSZng\u002Bk5yscfWCYowxS6\n0kx2LDJvfi8FMR1PxPV0WzbRnFid4XN668UDUoEAsNvOHUEtdHc0HC4Fn5AxGmXs\ndYan2PzGUTyXDud8akCyOHK4LYjIQnZtPXTDh77bCSDYC4/l5H6X4tdGc3xDJW02\nZh8aYI9qagVZhZX9YFRr49\u002BOiJlRsui2g4oRPy4hPNDD0wQeOlis9Gsl6VDx6JCI\n3HOhmZ3w7nL/HtPx6zamAO0CkhKTjasifV8aQs/hFNatcfC2SFXfKeVzd7lyfdOB\nTSkeGqHjKuu/djc/Jo3Os\u002BlBiLceiHYzYDqYenO25lQJR7exkIPVGY\u002B9vfWdOU\u002BH\nbUVZKBwjy4Dc29f1bSG5Pqlhn\u002BvLqZuBittWhAjy2lF4nZMVPZBpmC\u002BGD0kLWX4x\nvEjEOO6QNDoB76fDddcvnu4pSjwWE/EvcjZsldtnjgrxnfGBcFmY4rQaudNfAl/0\nAuPQgI7VyoxzGxnzv0d\u002BEwR9Ek92p0z\u002BFx1eqvzr8T7U0NqiKWdWgZs4SgycTPSh\na7z9mZ0RRxW4FFVBIA30Ay9SRmN8sMRf9oiqctBnDVvjplECAwEAAaOCAiEwggId\nMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIw\nDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUBAYb7yYdAxSEQk6HowroXIlXac8wHwYD\nVR0jBBgwFoAUu7zDR6XkvKnGw6RyDBCNojXhyOgwVwYIKwYBBQUHAQEESzBJMCIG\nCCsGAQUFBzABhhZodHRwOi8vcjEwLm8ubGVuY3Iub3JnMCMGCCsGAQUFBzAChhdo\ndHRwOi8vcjEwLmkubGVuY3Iub3JnLzAoBgNVHREEITAfghBhdXRoLm1ha3MtaXQu\nY29tggttYWtzLWl0LmNvbTATBgNVHSAEDDAKMAgGBmeBDAECATCCAQQGCisGAQQB\n1nkCBAIEgfUEgfIA8AB2AEiw42vapkc0D\u002BVqAvqdMOscUgHLVt0sgdm7v6s52IRz\nAAABj/mN4NwAAAQDAEcwRQIgNFpxl\u002Bhm49jYxsaTbJpYj6o1SA0RRGs4spNSqtp3\ntb8CIQC8zl8Qv3eyJLOxXoE7F4yYZtXI9xTMrqg1hHsoJLqfAwB2AO7N0GTV2xrO\nxVy3nbTNE6Iyh0Z8vOzew1FIWUZxH7WbAAABj/mN4N8AAAQDAEcwRQIgVKnCn3gB\nPv1aqt5vDDd/44QLiA/56h82qRHOGptkaSkCIQDEcHea400qS5C63zghYSFKF21Z\nvwGqovbi3fj9sfRueTANBgkqhkiG9w0BAQsFAAOCAQEAPO/6RmCl2y73Vp4bBIWB\nSuhhsZ0BYg4owFMoryWoOFojcG2mgxR\u002B5kokJUWVWxAg0r8OPPzucjFqDNtxmqpY\nA/cHjctO6iXsKK1wR2rEyXUUcqs41uFvSpTJ8Vqpa9QjzwY4w3AdOrTQTPDodQKc\ngPNwrAvlmlmsopYyjo7LsyPDKBwBzilf3W3WJeRTW0ls8micPkX\u002B31s0cH4xj7FQ\nhnHJ1iKQ1elkM0kGscTjA1gwVQRap8\u002BQpZZGj20h9OPpoJYYdPwMu5tePL0bVBzJ\nsuA5nNn1GR/8E\u002BdDNM9Gnv4NGUDi8SQmA4hhlr6lcaYgKZL\u002B4OsXvwnf3si8QKrp\nlA==\n-----END CERTIFICATE-----\n\n-----BEGIN CERTIFICATE-----\nMIIFBTCCAu2gAwIBAgIQS6hSk/eaL6JzBkuoBI110DANBgkqhkiG9w0BAQsFADBP\nMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy\nY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNDAzMTMwMDAwMDBa\nFw0yNzAzMTIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBF\nbmNyeXB0MQwwCgYDVQQDEwNSMTAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK\nAoIBAQDPV\u002BXmxFQS7bRH/sknWHZGUCiMHT6I3wWd1bUYKb3dtVq/\u002BvbOo76vACFL\nYlpaPAEvxVgD9on/jhFD68G14BQHlo9vH9fnuoE5CXVlt8KvGFs3Jijno/QHK20a\n/6tYvJWuQP/py1fEtVt/eA0YYbwX51TGu0mRzW4Y0YCF7qZlNrx06rxQTOr8IfM4\nFpOUurDTazgGzRYSespSdcitdrLCnF2YRVxvYXvGLe48E1KGAdlX5jgc3421H5KR\nmudKHMxFqHJV8LDmowfs/acbZp4/SItxhHFYyTr6717yW0QrPHTnj7JHwQdqzZq3\nDZb3EoEmUVQK7GH29/Xi8orIlQ2NAgMBAAGjgfgwgfUwDgYDVR0PAQH/BAQDAgGG\nMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATASBgNVHRMBAf8ECDAGAQH/\nAgEAMB0GA1UdDgQWBBS7vMNHpeS8qcbDpHIMEI2iNeHI6DAfBgNVHSMEGDAWgBR5\ntFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKG\nFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0gBAwwCjAIBgZngQwBAgEwJwYD\nVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVuY3Iub3JnLzANBgkqhkiG9w0B\nAQsFAAOCAgEAkrHnQTfreZ2B5s3iJeE6IOmQRJWjgVzPw139vaBw1bGWKCIL0vIo\nzwzn1OZDjCQiHcFCktEJr59L9MhwTyAWsVrdAfYf\u002BB9haxQnsHKNY67u4s5Lzzfd\nu6PUzeetUK29v\u002BPsPmI2cJkxp\u002BiN3epi4hKu9ZzUPSwMqtCceb7qPVxEbpYxY1p9\n1n5PJKBLBX9eb9LU6l8zSxPWV7bK3lG4XaMJgnT9x3ies7msFtpKK5bDtotij/l0\nGaKeA97pb5uwD9KgWvaFXMIEt8jVTjLEvwRdvCn294GPDF08U8lAkIv7tghluaQh\n1QnlE4SEN4LOECj8dsIGJXpGUk3aU3KkJz9icKy\u002BaUgA\u002B2cP21uh6NcDIS3XyfaZ\nQjmDQ993ChII8SXWupQZVBiIpcWO4RqZk3lr7Bz5MUCwzDIA359e57SSq5CCkY0N\n4B6Vulk7LktfwrdGNVI5BsC9qqxSwSKgRJeZ9wygIaehbHFHFhcBaMDKpiZlBHyz\nrsnnlFXCb5s8HKn5LsUgGvB24L7sGNZP2CX7dhHov\u002BYhD\u002BjozLW2p9W4959Bz2Ei\nRmqDtmiXLnzqTpXbI\u002BsuyCsohKRg6Un0RC47\u002BcpiVwHiXZAW\u002Bcn8eiNIjqbVgXLx\nKPpdzvvtTnOPlC7SQZSYmdunr3Bf9b77AiC/ZidstK36dRILKz7OA54=\n-----END CERTIFICATE-----\n","Private":"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"}},"AccountKey":"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","Id":null,"Key":{"kty":"RSA","kid":null,"use":null,"n":"rnPtd1dxav2zok86-2oqfXis9DyGdaYyAfB9Qk5gmzNztit8xoOCcpw68STS3-L8q91KOfV5rHWfR9tnhBa3vwif5OVwr942a1YDUsxx7W-fToj5CR79gF26jQfhOvbLJ2Os-WhBLWQmDumWAf5-ySwI-LFHpL32JB_JZdyR5hGQI33VqJiHe14_dQnmF9rVxoUp2Q-Shtaz3HPGZdQlhkfxJKsCmIoNtU7MRhlWHRWY5-ZUxHNkVYz_Hrz9BIp2a4FBY2S45aiSYWUxSSVcE1cymHJCCFBYWGXpu5in1BhN-Sx-F6PK4otQcwmegpl9SE7cxBywGAogqAesRI0A2YorloxyR8Bm6piF4pksbNtzulugoPA-9wk1M3rVARt5m_g7QEftDch5eMq9doaanCJCFvjT7ow4RBGMw9LevRyYNfasi1DHKb9d0rv--TrxGvunfpt3olVoSiVE0qDqbmcoAnX8_xXp2j9LSqUNuT4mE4eZTrqCePdD4fQULU2wDnfrp7-rQILA6MMtuyj662KhpJIibl_EqWjBxvMuB8jfAsyu_RuUOuBLWIkbbKqcCWq5VeBAavtJG4R7VVqS1WEOGsuq8igmTJHy7Nwry8Vsg47hIdRBdRIUnCa2hEoKsafsaRH7S_fvTD31s5lJyp2dIT8ZTdVFQcIlFBHHsW8","e":"AQAB","d":null,"p":null,"q":null,"dp":null,"dq":null,"qi":null,"oth":null,"alg":null},"Location":"https://acme-v02.api.letsencrypt.org/acme/acct/1771419987"} \ No newline at end of file