From cff3f45883cea1d903bceff633f6fbcfea6e63a6 Mon Sep 17 00:00:00 2001 From: Ildar Kamalov Date: Wed, 13 Aug 2025 19:28:46 +0300 Subject: [PATCH] ADG-10295 add encryption settings page --- client_v2/src/__locales/en.json | 52 +- .../controls/Checkbox/Checkbox.module.pcss | 6 +- .../common/controls/Input/Input.module.pcss | 4 +- client_v2/src/common/controls/Input/Input.tsx | 2 +- .../common/controls/Radio/Radio.module.pcss | 4 + .../src/common/ui/FaqTooltip/FaqTooltip.tsx | 35 ++ client_v2/src/common/ui/FaqTooltip/index.ts | 1 + .../common/ui/FaqTooltip/styles.module.pcss | 19 + .../ui}/SettingsGroup/RadioGroup.tsx | 0 .../ui}/SettingsGroup/SwitchGroup.tsx | 0 .../ui}/SettingsGroup/index.ts | 0 .../ui}/SettingsGroup/styles.module.pcss | 0 client_v2/src/components/App/index.tsx | 8 +- client_v2/src/components/Encryption/Form.tsx | 564 ++++++++++++++++++ .../Encryption/Status/CertificateStatus.tsx | 34 ++ .../Encryption/Status/KeyStatus.tsx | 14 + .../Encryption/Status/StatusBlock.tsx | 28 + .../Encryption/Status/ValidationStatus.tsx | 17 + .../src/components/Encryption/Status/index.ts | 3 + .../Encryption/Status/styles.module.pcss | 47 ++ client_v2/src/components/Encryption/index.tsx | 115 ++++ .../components/Encryption/styles.module.pcss | 16 + .../src/components/Settings/FiltersConfig.tsx | 5 +- .../IgnoredDomains/IgnoredDomains.tsx | 30 +- .../IgnoredDomains/styles.module.pcss | 14 - .../components/Settings/LogsConfig/Form.tsx | 3 +- .../src/components/Settings/Settings.tsx | 31 +- .../components/Settings/StatsConfig/Form.tsx | 4 +- client_v2/src/lib/theme/Form.module.pcss | 6 + 29 files changed, 980 insertions(+), 82 deletions(-) create mode 100644 client_v2/src/common/ui/FaqTooltip/FaqTooltip.tsx create mode 100644 client_v2/src/common/ui/FaqTooltip/index.ts create mode 100644 client_v2/src/common/ui/FaqTooltip/styles.module.pcss rename client_v2/src/{components/Settings => common/ui}/SettingsGroup/RadioGroup.tsx (100%) rename client_v2/src/{components/Settings => common/ui}/SettingsGroup/SwitchGroup.tsx (100%) rename client_v2/src/{components/Settings => common/ui}/SettingsGroup/index.ts (100%) rename client_v2/src/{components/Settings => common/ui}/SettingsGroup/styles.module.pcss (100%) create mode 100644 client_v2/src/components/Encryption/Form.tsx create mode 100644 client_v2/src/components/Encryption/Status/CertificateStatus.tsx create mode 100644 client_v2/src/components/Encryption/Status/KeyStatus.tsx create mode 100644 client_v2/src/components/Encryption/Status/StatusBlock.tsx create mode 100644 client_v2/src/components/Encryption/Status/ValidationStatus.tsx create mode 100644 client_v2/src/components/Encryption/Status/index.ts create mode 100644 client_v2/src/components/Encryption/Status/styles.module.pcss create mode 100644 client_v2/src/components/Encryption/index.tsx create mode 100644 client_v2/src/components/Encryption/styles.module.pcss diff --git a/client_v2/src/__locales/en.json b/client_v2/src/__locales/en.json index b5d11521..74845df0 100644 --- a/client_v2/src/__locales/en.json +++ b/client_v2/src/__locales/en.json @@ -393,40 +393,23 @@ "next": "Next", "open_dashboard": "Open Dashboard", "install_saved": "Saved successfully", - "encryption_title": "Encryption", "encryption_desc": "Encryption (HTTPS/QUIC/TLS) support for both DNS and admin web interface", "encryption_config_saved": "Encryption configuration saved", - "encryption_server": "Server name", "encryption_server_enter": "Enter your domain name", "encryption_server_desc": "If set, AdGuard Home detects ClientIDs, responds to DDR queries, and performs additional connection validations. If not set, these features are disabled. Must match one of the DNS Names in the certificate.", "encryption_redirect": "Redirect to HTTPS automatically", "encryption_redirect_desc": "If checked, AdGuard Home will automatically redirect you from HTTP to HTTPS addresses.", - "encryption_https": "HTTPS port", "encryption_https_desc": "If HTTPS port is configured, AdGuard Home admin interface will be accessible via HTTPS, and it will also provide DNS-over-HTTPS on '/dns-query' location.", - "encryption_dot": "DNS-over-TLS port", "encryption_dot_desc": "If this port is configured, AdGuard Home will run a DNS-over-TLS server on this port.", - "encryption_doq": "DNS-over-QUIC port", "encryption_doq_desc": "If this port is configured, AdGuard Home will run a DNS-over-QUIC server on this port.", - "encryption_certificates": "Certificates", - "encryption_certificates_desc": "In order to use encryption, you need to provide a valid SSL certificates chain for your domain. You can get a free certificate on <0>{{link}} or you can buy it from one of the trusted Certificate Authorities.", "encryption_certificates_input": "Copy/paste your PEM-encoded certificates here.", "encryption_status": "Status", - "encryption_expire": "Expires", - "encryption_key": "Private key", "encryption_key_input": "Copy/paste your PEM-encoded private key for your certificate here.", "encryption_enable": "Enable Encryption (HTTPS, DNS-over-HTTPS, and DNS-over-TLS)", "encryption_enable_desc": "If encryption is enabled, AdGuard Home admin interface will work over HTTPS, and the DNS server will listen for requests over DNS-over-HTTPS and DNS-over-TLS.", - "encryption_chain_valid": "Certificate chain is valid", - "encryption_chain_invalid": "Certificate chain is invalid", - "encryption_key_valid": "This is a valid {{type}} private key", - "encryption_key_invalid": "This is an invalid {{type}} private key", - "encryption_subject": "Subject", - "encryption_issuer": "Issuer", - "encryption_hostnames": "Hostnames", "encryption_reset": "Are you sure you want to reset encryption settings?", "encryption_warning": "Warning", "encryption_plain_dns_enable": "Enable plain DNS", - "encryption_plain_dns_desc": "Plain DNS is enabled by default. You can disable it to force all devices to use encrypted DNS. To do this, you must enable at least one encrypted DNS protocol", "encryption_plain_dns_error": "To disable plain DNS, enable at least one encrypted DNS protocol", "topline_expiring_certificate": "Your SSL certificate is about to expire. Update <0>Encryption settings.", "topline_expired_certificate": "Your SSL certificate is expired. Update <0>Encryption settings.", @@ -805,5 +788,38 @@ "settings_tooltip_examples": "Examples:", "settings_notify_changes_saved": "Changes saved", "settings_notify_query_log_cleared": "Query log cleared", - "settings_notify_statistics_cleared": "Statistics cleared" + "settings_notify_statistics_cleared": "Statistics cleared", + "encryption_title": "Encryption", + "encryption_encrypted_dns": "Encrypted DNS", + "encryption_encrypted_dns_desc": "The AdGuard Home admin interface uses HTTPS. The DNS server can use DNS-over-HTTPS, DNS-over-TLS, and DNS-over-QUIC", + "encryption_plain_dns": "Plain DNS", + "encryption_plain_dns_desc": "Plain DNS is enabled by default. You can disable it to force all devices to use encrypted DNS. To do this, you must specify at least one encrypted DNS protocol", + "encryption_server": "Server name", + "encryption_server_tooltip_1": "If set, AdGuard Home will detect ClientIDs, respond to DDR queries, and perform additional connection validations. If not set, these features are disabled", + "encryption_server_tooltip_2": "Must match a DNS name in the certificate", + "encryption_https": "HTTPS port", + "encryption_https_tooltip": "This port provides HTTPS access for the AdGuard Home admin interface. It also provides DNS-over-HTTPS at the '/dns-query' location", + "encryption_dot": "DNS-over-TLS port", + "encryption_dot_tooltip": "This port is used to run a DNS-over-TLS server", + "encryption_doq": "DNS-over-QUIC port", + "encryption_doq_tooltip": "This port is used to run a DNS-over-QUIC server", + "encryption_force_redirect": "Automatically redirect from HTTP to HTTPS", + "encryption_certificates": "Certificates", + "encryption_certificates_desc": "To use encryption, you must provide a valid SSL certificate chain for your domain. You can get a free SSL certificate from letsencrypt.org or buy one from a trusted Certificate Authority", + "encryption_key": "Private key", + "encryption_disable_plain_dns": "Disable plain DNS?", + "encryption_disable_plain_dns_desc": "Devices that do not support encrypted DNS may not work properly", + "disable": "Disable", + "encryption_chain_valid": "Certificate chain is valid", + "encryption_chain_invalid": "Certificate chain is invalid", + "encryption_key_valid": "Private key is valid", + "encryption_key_invalid": "Private key is invalid", + "encryption_subject": "Subject: %value%", + "encryption_issuer": "Issuer: %value%", + "encryption_expire": "Expires: %value%", + "encryption_hostnames": "Hostnames: %value%", + "encryption_certificate_has_issues": "Certificate has issues", + "encryption_confirm_clear": "Reset encryption settings?", + "encryption_confirm_clear_desc": "This will reset all encryption settings to default values", + "confirm": "Confirm" } diff --git a/client_v2/src/common/controls/Checkbox/Checkbox.module.pcss b/client_v2/src/common/controls/Checkbox/Checkbox.module.pcss index 0561a769..6bce1db5 100644 --- a/client_v2/src/common/controls/Checkbox/Checkbox.module.pcss +++ b/client_v2/src/common/controls/Checkbox/Checkbox.module.pcss @@ -36,13 +36,9 @@ .label { display: grid; align-items: center; - font-size: 14px; + font-size: 16px; font-weight: var(--weight-regular); user-select: none; - - @media (min-width: 1024px) { - font-size: 16px; - } } .icon { diff --git a/client_v2/src/common/controls/Input/Input.module.pcss b/client_v2/src/common/controls/Input/Input.module.pcss index c4b148cc..f46029a9 100644 --- a/client_v2/src/common/controls/Input/Input.module.pcss +++ b/client_v2/src/common/controls/Input/Input.module.pcss @@ -99,7 +99,9 @@ } .inputLabel { - display: block; + display: flex; + align-items: center; + gap: 8px; margin-bottom: 4px; font-size: 14px; line-height: 24px; diff --git a/client_v2/src/common/controls/Input/Input.tsx b/client_v2/src/common/controls/Input/Input.tsx index 10dfc86e..5811b006 100644 --- a/client_v2/src/common/controls/Input/Input.tsx +++ b/client_v2/src/common/controls/Input/Input.tsx @@ -3,7 +3,7 @@ import cn from 'clsx'; import s from './Input.module.pcss'; -type Props = ComponentProps<'input'> & { +type Props = Omit, 'size'> & { label?: ReactNode; className?: string; innerClassName?: string; diff --git a/client_v2/src/common/controls/Radio/Radio.module.pcss b/client_v2/src/common/controls/Radio/Radio.module.pcss index 1b25b4e8..0c89e852 100644 --- a/client_v2/src/common/controls/Radio/Radio.module.pcss +++ b/client_v2/src/common/controls/Radio/Radio.module.pcss @@ -33,5 +33,9 @@ } .input:disabled + .handler .icon { + color: var(--disabled-gray-icons); +} + +.input:disabled:checked + .handler .icon { color: var(--disabled-main-button); } diff --git a/client_v2/src/common/ui/FaqTooltip/FaqTooltip.tsx b/client_v2/src/common/ui/FaqTooltip/FaqTooltip.tsx new file mode 100644 index 00000000..1accd3b2 --- /dev/null +++ b/client_v2/src/common/ui/FaqTooltip/FaqTooltip.tsx @@ -0,0 +1,35 @@ +import React, { ReactNode } from 'react'; +import cn from 'clsx'; + +import { Dropdown } from 'panel/common/ui/Dropdown'; +import theme from 'panel/lib/theme'; +import { Icon } from 'panel/common/ui/Icon'; + +import s from './styles.module.pcss'; + +type Props = { + text: ReactNode; + menuSize?: 'small' | 'large'; +}; + +export const FaqTooltip = ({ text, menuSize = 'small' }: Props) => { + return ( + + {text} + + } + className={s.dropdown} + position="bottomLeft" + noIcon> +
+ +
+
+ ); +}; diff --git a/client_v2/src/common/ui/FaqTooltip/index.ts b/client_v2/src/common/ui/FaqTooltip/index.ts new file mode 100644 index 00000000..1e21f015 --- /dev/null +++ b/client_v2/src/common/ui/FaqTooltip/index.ts @@ -0,0 +1 @@ +export { FaqTooltip } from './FaqTooltip'; diff --git a/client_v2/src/common/ui/FaqTooltip/styles.module.pcss b/client_v2/src/common/ui/FaqTooltip/styles.module.pcss new file mode 100644 index 00000000..404373b0 --- /dev/null +++ b/client_v2/src/common/ui/FaqTooltip/styles.module.pcss @@ -0,0 +1,19 @@ +.trigger { + cursor: pointer; + color: var(--default-gray-icons); +} + +.menu { + padding: 16px; + background-color: var(--fills-backgrounds-page-background-additional); + + @media (min-width: 768px) { + max-width: 280px; + } + + &_large { + @media (min-width: 768px) { + max-width: 340px; + } + } +} diff --git a/client_v2/src/components/Settings/SettingsGroup/RadioGroup.tsx b/client_v2/src/common/ui/SettingsGroup/RadioGroup.tsx similarity index 100% rename from client_v2/src/components/Settings/SettingsGroup/RadioGroup.tsx rename to client_v2/src/common/ui/SettingsGroup/RadioGroup.tsx diff --git a/client_v2/src/components/Settings/SettingsGroup/SwitchGroup.tsx b/client_v2/src/common/ui/SettingsGroup/SwitchGroup.tsx similarity index 100% rename from client_v2/src/components/Settings/SettingsGroup/SwitchGroup.tsx rename to client_v2/src/common/ui/SettingsGroup/SwitchGroup.tsx diff --git a/client_v2/src/components/Settings/SettingsGroup/index.ts b/client_v2/src/common/ui/SettingsGroup/index.ts similarity index 100% rename from client_v2/src/components/Settings/SettingsGroup/index.ts rename to client_v2/src/common/ui/SettingsGroup/index.ts diff --git a/client_v2/src/components/Settings/SettingsGroup/styles.module.pcss b/client_v2/src/common/ui/SettingsGroup/styles.module.pcss similarity index 100% rename from client_v2/src/components/Settings/SettingsGroup/styles.module.pcss rename to client_v2/src/common/ui/SettingsGroup/styles.module.pcss diff --git a/client_v2/src/components/App/index.tsx b/client_v2/src/components/App/index.tsx index 2e713552..ccf42c0b 100644 --- a/client_v2/src/components/App/index.tsx +++ b/client_v2/src/components/App/index.tsx @@ -8,8 +8,9 @@ import { Icons } from 'panel/common/ui/Icons'; import { Footer } from 'panel/common/ui/Footer'; import { Header } from 'panel/common/ui/Header'; import { Settings } from 'panel/components/Settings'; - import { LocalesType } from 'panel/common/intl'; +import { Encryption } from 'panel/components/Encryption'; + import Toasts from '../Toasts'; import i18n from '../../i18n'; import { THEMES } from '../../helpers/constants'; @@ -31,6 +32,11 @@ const ROUTES: RouteConfig[] = [ component: Settings, exact: true, }, + { + path: '/encryption', + component: Encryption, + exact: true, + }, ]; const App = () => { diff --git a/client_v2/src/components/Encryption/Form.tsx b/client_v2/src/components/Encryption/Form.tsx new file mode 100644 index 00000000..45c274a2 --- /dev/null +++ b/client_v2/src/components/Encryption/Form.tsx @@ -0,0 +1,564 @@ +import React, { useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { Controller, useForm } from 'react-hook-form'; +import i18next from 'i18next'; +import cn from 'clsx'; + +import { toNumber } from 'panel/helpers/form'; +import { DNS_OVER_QUIC_PORT, DNS_OVER_TLS_PORT, STANDARD_HTTPS_PORT, ENCRYPTION_SOURCE } from 'panel/helpers/constants'; +import { EncryptionData } from 'panel/initialState'; +import { + validateServerName, + validateIsSafePort, + validatePort, + validatePortQuic, + validatePortTLS, + validatePlainDns, +} from 'panel/helpers/validators'; +import { Checkbox } from 'panel/common/controls/Checkbox'; +import { Input } from 'panel/common/controls/Input'; +import { Radio } from 'panel/common/controls/Radio'; +import { Textarea } from 'panel/common/controls/Textarea'; +import { useDispatch } from 'react-redux'; +import { setTlsConfig, validateTlsConfig } from 'panel/actions/encryption'; +import { Button } from 'panel/common/ui/Button'; +import intl from 'panel/common/intl'; +import { SwitchGroup } from 'panel/common/ui/SettingsGroup'; +import theme from 'panel/lib/theme'; + +import { FaqTooltip } from 'panel/common/ui/FaqTooltip'; +import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog'; +import { KeyStatus, CertificateStatus, ValidationStatus } from './Status'; + +import s from './styles.module.pcss'; + +const certificateSourceOptions = [ + { + text: i18next.t('encryption_certificates_source_path'), + value: ENCRYPTION_SOURCE.PATH, + }, + { + text: i18next.t('encryption_certificates_source_content'), + value: ENCRYPTION_SOURCE.CONTENT, + }, +]; + +const keySourceOptions = [ + { + text: i18next.t('encryption_key_source_path'), + value: ENCRYPTION_SOURCE.PATH, + }, + { + text: i18next.t('encryption_key_source_content'), + value: ENCRYPTION_SOURCE.CONTENT, + }, +]; + +export type EncryptionFormValues = { + enabled?: boolean; + serve_plain_dns?: boolean; + server_name?: string; + force_https?: boolean; + port_https?: number; + port_dns_over_tls?: number; + port_dns_over_quic?: number; + certificate_chain?: string; + private_key?: string; + certificate_path?: string; + private_key_path?: string; + certificate_source?: string; + key_source?: string; + private_key_saved?: boolean; +}; + +type Props = { + initialValues: EncryptionFormValues; + encryption: EncryptionData; + onSubmit: (values: EncryptionFormValues) => void; + debouncedConfigValidation: (values: EncryptionFormValues) => void; +}; + +const defaultValues = { + enabled: false, + serve_plain_dns: true, + server_name: '', + force_https: false, + port_https: STANDARD_HTTPS_PORT, + port_dns_over_tls: DNS_OVER_TLS_PORT, + port_dns_over_quic: DNS_OVER_QUIC_PORT, + certificate_chain: '', + private_key: '', + certificate_path: '', + private_key_path: '', + certificate_source: ENCRYPTION_SOURCE.PATH, + key_source: ENCRYPTION_SOURCE.PATH, + private_key_saved: false, +}; + +export const Form = ({ initialValues, encryption, onSubmit, debouncedConfigValidation }: Props) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const [openConfirmDialog, setOpenConfirmDialog] = useState(false); + + const { + not_after, + valid_chain, + valid_key, + valid_cert, + valid_pair, + dns_names, + issuer, + subject, + warning_validation, + processingConfig, + processingValidate, + } = encryption; + + const { + control, + handleSubmit, + watch, + reset, + setValue, + setError, + getValues, + formState: { isSubmitting, isValid }, + } = useForm({ + defaultValues: { + ...defaultValues, + ...initialValues, + }, + mode: 'onBlur', + }); + + const { + enabled: isEnabled, + serve_plain_dns: servePlainDns, + certificate_chain: certificateChain, + private_key: privateKey, + private_key_path: privateKeyPath, + key_source: privateKeySource, + private_key_saved: privateKeySaved, + certificate_path: certificatePath, + certificate_source: certificateSource, + } = watch(); + + const handleBlur = () => { + debouncedConfigValidation(getValues()); + }; + + const isSavingDisabled = () => { + const processing = isSubmitting || processingConfig || processingValidate; + + if (servePlainDns && !isEnabled) { + return !isValid || processing; + } + + return !isValid || processing || !valid_key || !valid_cert || !valid_pair; + }; + + const handleResetConfirmOpen = () => setOpenConfirmDialog(true); + + const handleResetConfirmClose = () => setOpenConfirmDialog(false); + + const handleReset = () => { + reset(); + dispatch(setTlsConfig(defaultValues)); + dispatch(validateTlsConfig(defaultValues)); + }; + + const validatePorts = (values: EncryptionFormValues) => { + const errors: { port_dns_over_tls?: string; port_https?: string } = {}; + + if (values.port_dns_over_tls && values.port_https) { + if (values.port_dns_over_tls === values.port_https) { + errors.port_dns_over_tls = i18next.t('form_error_equal'); + errors.port_https = i18next.t('form_error_equal'); + } + } + + return errors; + }; + + const onFormSubmit = (data: EncryptionFormValues) => { + const validationErrors = validatePorts(data); + + if (Object.keys(validationErrors).length > 0) { + Object.entries(validationErrors).forEach(([field, message]) => { + setError(field as keyof EncryptionFormValues, { type: 'manual', message }); + }); + } else { + onSubmit(data); + } + }; + + const isDisabled = isSavingDisabled(); + const isWarning = valid_key && valid_cert && valid_pair; + + return ( +
+ ( + + )} + /> + + validatePlainDns(value, getValues()), + }} + render={({ field }) => ( + + )} + /> + +
+
+ ( + + {intl.getMessage('encryption_server')} + + +
+ {intl.getMessage('encryption_server_tooltip_1')} +
+
+ {intl.getMessage('encryption_server_tooltip_2')} +
+ + } + menuSize="large" + /> + + } + placeholder={t('encryption_server_enter')} + errorMessage={fieldState.error?.message} + disabled={!isEnabled} + onBlur={handleBlur} + /> + )} + /> +
+ +
+ ( + + {intl.getMessage('encryption_https')} + + + + } + placeholder={t('encryption_https')} + errorMessage={fieldState.error?.message} + disabled={!isEnabled} + onChange={(e) => { + const { value } = e.target; + field.onChange(toNumber(value)); + }} + onBlur={handleBlur} + /> + )} + /> +
+ +
+ ( + + {intl.getMessage('encryption_dot')} + + + + } + placeholder={t('encryption_dot')} + errorMessage={fieldState.error?.message} + disabled={!isEnabled} + onChange={(e) => { + const { value } = e.target; + field.onChange(toNumber(value)); + }} + onBlur={handleBlur} + /> + )} + /> +
+ +
+ ( + + {intl.getMessage('encryption_doq')} + + + + } + placeholder={t('encryption_doq')} + errorMessage={fieldState.error?.message} + disabled={!isEnabled} + onChange={(e) => { + const { value } = e.target; + field.onChange(toNumber(value)); + }} + onBlur={handleBlur} + /> + )} + /> +
+
+ + ( + + )} + /> + +

+ {intl.getMessage('encryption_certificates')} +

+ +

+ {intl.getMessage('encryption_certificates_desc', { + a: (text: string) => ( + + {text} + + ), + })} +

+ +
+ ( + + )} + /> + +
+ {certificateSource === ENCRYPTION_SOURCE.CONTENT ? ( + ( +