Added validation on change and enable encryption checkbox

This commit is contained in:
Ildar Kamalov 2019-02-18 16:06:27 +03:00
parent d44f68e844
commit 05cce8b107
15 changed files with 356 additions and 123 deletions

View File

@ -219,18 +219,27 @@
"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_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_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}}</0> or you can buy it from one of the trusted Certificate Authorities.",
"encryption_certificates_input": "Copy/paste your PEM-encoded cerificates here.",
"encryption_status": "Status",
"encryption_certificates_for": "Certificates for {{domains}}",
"encryption_expire": "Expire on {{date}}",
"encryption_expire": "Expires",
"encryption_key": "Private key",
"encryption_key_input": "Copy/paste your PEM-encoded private key for your cerficate 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",
"topline_expiring_certificate": "Your SSL certificate is about to expire. Update <0>Encryption settings</0>.",
"topline_expired_certificate": "Your SSL certificate is expired. Update <0>Encryption settings</0>.",
"form_error_port_range": "Enter port value in the range of 80-65535",
"form_error_equal": "Shouldn't be equal",
"form_error_password": "Password mismatched",

View File

@ -0,0 +1,70 @@
import { createAction } from 'redux-actions';
import Api from '../api/Api';
import { addErrorToast, addSuccessToast } from './index';
const apiClient = new Api();
export const getTlsStatusRequest = createAction('GET_TLS_STATUS_REQUEST');
export const getTlsStatusFailure = createAction('GET_TLS_STATUS_FAILURE');
export const getTlsStatusSuccess = createAction('GET_TLS_STATUS_SUCCESS');
export const getTlsStatus = () => async (dispatch) => {
dispatch(getTlsStatusRequest());
try {
const status = await apiClient.getTlsStatus();
status.certificate_chain = atob(status.certificate_chain);
status.private_key = atob(status.private_key);
dispatch(getTlsStatusSuccess(status));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(getTlsStatusFailure());
}
};
export const setTlsConfigRequest = createAction('SET_TLS_CONFIG_REQUEST');
export const setTlsConfigFailure = createAction('SET_TLS_CONFIG_FAILURE');
export const setTlsConfigSuccess = createAction('SET_TLS_CONFIG_SUCCESS');
export const setTlsConfig = config => async (dispatch) => {
dispatch(setTlsConfigRequest());
try {
const values = { ...config };
values.certificate_chain = btoa(values.certificate_chain);
values.private_key = btoa(values.private_key);
values.port_https = values.port_https || 0;
values.port_dns_over_tls = values.port_dns_over_tls || 0;
const response = await apiClient.setTlsConfig(values);
response.certificate_chain = atob(response.certificate_chain);
response.private_key = atob(response.private_key);
dispatch(setTlsConfigSuccess(response));
dispatch(addSuccessToast('encryption_config_saved'));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(setTlsConfigFailure());
}
};
export const validateTlsConfigRequest = createAction('VALIDATE_TLS_CONFIG_REQUEST');
export const validateTlsConfigFailure = createAction('VALIDATE_TLS_CONFIG_FAILURE');
export const validateTlsConfigSuccess = createAction('VALIDATE_TLS_CONFIG_SUCCESS');
export const validateTlsConfig = config => async (dispatch) => {
dispatch(validateTlsConfigRequest());
try {
const values = { ...config };
values.certificate_chain = btoa(values.certificate_chain);
values.private_key = btoa(values.private_key);
values.port_https = values.port_https || 0;
values.port_dns_over_tls = values.port_dns_over_tls || 0;
const response = await apiClient.validateTlsConfig(values);
response.certificate_chain = atob(response.certificate_chain);
response.private_key = atob(response.private_key);
dispatch(validateTlsConfigSuccess(response));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(validateTlsConfigFailure());
}
};

View File

@ -650,44 +650,3 @@ export const toggleDhcp = config => async (dispatch) => {
}
}
};
export const getTlsStatusRequest = createAction('GET_TLS_STATUS_REQUEST');
export const getTlsStatusFailure = createAction('GET_TLS_STATUS_FAILURE');
export const getTlsStatusSuccess = createAction('GET_TLS_STATUS_SUCCESS');
export const getTlsStatus = () => async (dispatch) => {
dispatch(getTlsStatusRequest());
try {
const status = await apiClient.getTlsStatus();
status.certificate_chain = atob(status.certificate_chain);
status.private_key = atob(status.private_key);
dispatch(getTlsStatusSuccess(status));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(getTlsStatusFailure());
}
};
export const setTlsConfigRequest = createAction('SET_TLS_CONFIG_REQUEST');
export const setTlsConfigFailure = createAction('SET_TLS_CONFIG_FAILURE');
export const setTlsConfigSuccess = createAction('SET_TLS_CONFIG_SUCCESS');
export const setTlsConfig = config => async (dispatch) => {
dispatch(setTlsConfigRequest());
try {
const values = { ...config };
values.certificate_chain = btoa(values.certificate_chain);
values.private_key = btoa(values.private_key);
values.port_https = values.port_https || 0;
values.port_dns_over_tls = values.port_dns_over_tls || 0;
await apiClient.setTlsConfig(values);
dispatch(setTlsConfigSuccess(config));
dispatch(addSuccessToast('encryption_config_saved'));
dispatch(getTlsStatus());
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(setTlsConfigFailure());
}
};

View File

@ -358,6 +358,7 @@ export default class Api {
// DNS-over-HTTPS and DNS-over-TLS
TLS_STATUS = { path: 'tls/status', method: 'GET' };
TLS_CONFIG = { path: 'tls/configure', method: 'POST' };
TLS_VALIDATE = { path: 'tls/validate', method: 'POST' };
getTlsStatus() {
const { path, method } = this.TLS_STATUS;
@ -372,4 +373,13 @@ export default class Api {
};
return this.makeRequest(path, method, parameters);
}
validateTlsConfig(config) {
const { path, method } = this.TLS_VALIDATE;
const parameters = {
data: config,
headers: { 'Content-Type': 'application/json' },
};
return this.makeRequest(path, method, parameters);
}
}

View File

@ -1,7 +1,7 @@
import React, { Component, Fragment } from 'react';
import { HashRouter, Route } from 'react-router-dom';
import PropTypes from 'prop-types';
import { Trans, withNamespaces } from 'react-i18next';
import { withNamespaces } from 'react-i18next';
import LoadingBar from 'react-redux-loading-bar';
import 'react-table/react-table.css';
@ -18,6 +18,7 @@ import Footer from '../ui/Footer';
import Toasts from '../Toasts';
import Status from '../ui/Status';
import Topline from '../ui/Topline';
import EncryptionTopline from '../ui/EncryptionTopline';
import i18n from '../../i18n';
class App extends Component {
@ -56,7 +57,6 @@ class App extends Component {
!dashboard.processingVersions &&
dashboard.isCoreRunning &&
dashboard.isUpdateAvailable;
const isExpiringCertificate = !encryption.processing && encryption.warning;
return (
<HashRouter hashType='noslash'>
@ -66,12 +66,8 @@ class App extends Component {
{dashboard.announcement} <a href={dashboard.announcementUrl} target="_blank" rel="noopener noreferrer">Click here</a> for more info.
</Topline>
}
{isExpiringCertificate &&
<Topline type="warning">
<Trans components={[<a href="#settings" key="0">link</a>]}>
topline_expiring_certificate
</Trans>
</Topline>
{!encryption.processing &&
<EncryptionTopline notAfter={encryption.not_after} />
}
<LoadingBar className="loading-bar" updateTime={1000} />
<Route component={Header} />

View File

@ -12,7 +12,6 @@ import './Header.css';
class Header extends Component {
state = {
isMenuOpen: false,
isDropdownOpen: false,
};
toggleMenuOpen = () => {
@ -25,6 +24,7 @@ class Header extends Component {
render() {
const { dashboard } = this.props;
const { isMenuOpen } = this.state;
const badgeClass = classnames({
'badge dns-status': true,
'badge-success': dashboard.protectionEnabled,
@ -52,7 +52,7 @@ class Header extends Component {
</div>
<Menu
location={this.props.location}
isMenuOpen={this.state.isMenuOpen}
isMenuOpen={isMenuOpen}
toggleMenuOpen={this.toggleMenuOpen}
closeMenu={this.closeMenu}
/>

View File

@ -1,10 +1,13 @@
import React, { Fragment } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { Field, reduxForm } from 'redux-form';
import { Field, reduxForm, formValueSelector } from 'redux-form';
import { Trans, withNamespaces } from 'react-i18next';
import flow from 'lodash/flow';
import format from 'date-fns/format';
import { renderField, renderSelectField, toNumber, port } from '../../../helpers/form';
import { EMPTY_DATE } from '../../../helpers/constants';
import i18n from '../../../i18n';
const validate = (values) => {
@ -20,21 +23,46 @@ const validate = (values) => {
return errors;
};
const Form = (props) => {
let Form = (props) => {
const {
t,
handleSubmit,
handleChange,
isEnabled,
certificateChain,
privateKey,
reset,
invalid,
submitting,
processing,
statusCert,
statusKey,
not_after,
valid_chain,
valid_key,
dns_names,
key_type,
issuer,
subject,
warning_validation,
} = props;
return (
<form onSubmit={handleSubmit}>
<div className="row">
<div className="col-12">
<div className="form__group form__group--settings">
<Field
name="enabled"
type="checkbox"
component={renderSelectField}
placeholder={t('encryption_enable')}
onChange={handleChange}
/>
</div>
<div className="form__desc">
<Trans>encryption_enable_desc</Trans>
</div>
<hr/>
</div>
<div className="col-12">
<label className="form__label" htmlFor="server_name">
<Trans>encryption_server</Trans>
@ -49,6 +77,8 @@ const Form = (props) => {
type="text"
className="form-control"
placeholder={t('encryption_server_enter')}
onChange={handleChange}
disabled={!isEnabled}
/>
<div className="form__desc">
<Trans>encryption_server_desc</Trans>
@ -62,6 +92,8 @@ const Form = (props) => {
type="checkbox"
component={renderSelectField}
placeholder={t('encryption_redirect')}
onChange={handleChange}
disabled={!isEnabled}
/>
<div className="form__desc">
<Trans>encryption_redirect_desc</Trans>
@ -84,6 +116,8 @@ const Form = (props) => {
placeholder={t('encryption_https')}
validate={[port]}
normalize={toNumber}
onChange={handleChange}
disabled={!isEnabled}
/>
<div className="form__desc">
<Trans>encryption_https_desc</Trans>
@ -104,6 +138,8 @@ const Form = (props) => {
placeholder={t('encryption_dot')}
validate={[port]}
normalize={toNumber}
onChange={handleChange}
disabled={!isEnabled}
/>
<div className="form__desc">
<Trans>encryption_dot_desc</Trans>
@ -132,28 +168,42 @@ const Form = (props) => {
type="text"
className="form-control form-control--textarea"
placeholder={t('encryption_certificates_input')}
onChange={handleChange}
disabled={!isEnabled}
/>
<div className="form__status">
{statusCert &&
{certificateChain &&
<Fragment>
<div className="form__label form__label--bold">
<Trans>encryption_status</Trans>:
</div>
<div>
{statusCert}
</div>
<ul>
<li className={valid_chain ? 'text-success' : 'text-danger'}>
{valid_chain ?
<Trans>encryption_chain_valid</Trans>
: <Trans>encryption_chain_invalid</Trans>
}
</li>
{subject &&
<li><Trans>encryption_subject</Trans>: {subject}</li>
}
{issuer &&
<li><Trans>encryption_issuer</Trans>: {issuer}</li>
}
{not_after && not_after !== EMPTY_DATE &&
<li>
<Trans>encryption_expire</Trans>:&nbsp;
{format(not_after, 'YYYY-MM-DD HH:mm:ss')}
</li>
}
{dns_names &&
<li>
<Trans>encryption_hostnames</Trans>: {dns_names}
</li>
}
</ul>
</Fragment>
}
{/* <div>
<Trans values={{ domains: '*.example.org, example.org' }}>
encryption_certificates_for
</Trans>
</div>
<div>
<Trans values={{ date: '2022-01-01' }}>
encryption_expire
</Trans>
</div> */}
</div>
</div>
</div>
@ -171,33 +221,54 @@ const Form = (props) => {
type="text"
className="form-control form-control--textarea"
placeholder="Copy/paste your PEM-encoded private key for your cerficate here."
onChange={handleChange}
disabled={!isEnabled}
/>
<div className="form__status">
{statusKey &&
{privateKey &&
<Fragment>
<div className="form__label form__label--bold">
<Trans>encryption_status</Trans>:
</div>
<div>
{statusKey}
</div>
<p className={valid_key ? 'text-success' : 'text-danger'}>
{valid_key ?
<Trans values={{ type: key_type }}>
encryption_key_valid
</Trans>
: <Trans values={{ type: key_type }}>
encryption_key_invalid
</Trans>
}
</p>
</Fragment>
}
</div>
</div>
</div>
<div className="col-12">
<p className="text-danger">
{warning_validation && warning_validation}
</p>
</div>
</div>
<div className="btn-list">
<div className="btn-list mt-2">
<button
type="submit"
className="btn btn-success btn-standart"
disabled={invalid || submitting || processing}
disabled={
invalid
|| submitting
|| processing
|| !valid_chain
|| !valid_key
|| warning_validation
}
>
<Trans>save_config</Trans>
</button>
<button
type="submit"
type="button"
className="btn btn-secondary btn-standart"
disabled={submitting || processing}
onClick={reset}
@ -211,16 +282,40 @@ const Form = (props) => {
Form.propTypes = {
handleSubmit: PropTypes.func.isRequired,
handleChange: PropTypes.func,
isEnabled: PropTypes.bool.isRequired,
certificateChain: PropTypes.string.isRequired,
privateKey: PropTypes.string.isRequired,
reset: PropTypes.func.isRequired,
submitting: PropTypes.bool.isRequired,
invalid: PropTypes.bool.isRequired,
initialValues: PropTypes.object.isRequired,
processing: PropTypes.bool.isRequired,
statusCert: PropTypes.string,
statusKey: PropTypes.string,
status_key: PropTypes.string,
not_after: PropTypes.string,
warning_validation: PropTypes.string,
valid_chain: PropTypes.bool,
valid_key: PropTypes.bool,
dns_names: PropTypes.string,
key_type: PropTypes.string,
issuer: PropTypes.string,
subject: PropTypes.string,
t: PropTypes.func.isRequired,
};
const selector = formValueSelector('encryptionForm');
Form = connect((state) => {
const isEnabled = selector(state, 'enabled');
const certificateChain = selector(state, 'certificate_chain');
const privateKey = selector(state, 'private_key');
return {
isEnabled,
certificateChain,
privateKey,
};
})(Form);
export default flow([
withNamespaces(),
reduxForm({

View File

@ -1,6 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withNamespaces } from 'react-i18next';
import debounce from 'lodash/debounce';
import Form from './Form';
import Card from '../../ui/Card';
@ -10,14 +11,20 @@ class Encryption extends Component {
this.props.setTlsConfig(values);
};
handleFormChange = debounce((values) => {
this.props.validateTlsConfig(values);
}, 300);
render() {
const { encryption, t } = this.props;
const {
processing,
processingConfig,
status_cert: statusCert,
status_key: statusKey,
...values
enabled,
server_name,
force_https,
port_https,
port_dns_over_tls,
certificate_chain,
private_key,
} = encryption;
return (
@ -29,11 +36,19 @@ class Encryption extends Component {
bodyType="card-body box-body--settings"
>
<Form
initialValues={{ ...values }}
initialValues={{
enabled,
server_name,
force_https,
port_https,
port_dns_over_tls,
certificate_chain,
private_key,
}}
processing={encryption.processingConfig}
statusCert={statusCert}
statusKey={statusKey}
onSubmit={this.handleFormSubmit}
onChange={this.handleFormChange}
{...this.props.encryption}
/>
</Card>
}
@ -44,6 +59,7 @@ class Encryption extends Component {
Encryption.propTypes = {
setTlsConfig: PropTypes.func.isRequired,
validateTlsConfig: PropTypes.func.isRequired,
encryption: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
};

View File

@ -100,6 +100,7 @@ class Settings extends Component {
<Encryption
encryption={this.props.encryption}
setTlsConfig={this.props.setTlsConfig}
validateTlsConfig={this.props.validateTlsConfig}
/>
<Dhcp
dhcp={this.props.dhcp}

View File

@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Trans, withNamespaces } from 'react-i18next';
import isAfter from 'date-fns/is_after';
import addDays from 'date-fns/add_days';
import Topline from './Topline';
import { EMPTY_DATE } from '../../helpers/constants';
const EncryptionTopline = (props) => {
if (props.notAfter !== EMPTY_DATE) {
const isAboutExpire = isAfter(addDays(Date.now(), 30), props.notAfter);
const isExpired = isAfter(Date.now(), props.notAfter);
if (isExpired) {
return (
<Topline type="danger">
<Trans components={[<a href="#settings" key="0">link</a>]}>
topline_expired_certificate
</Trans>
</Topline>
);
} else if (isAboutExpire) {
return (
<Topline type="warning">
<Trans components={[<a href="#settings" key="0">link</a>]}>
topline_expiring_certificate
</Trans>
</Topline>
);
}
}
return false;
};
EncryptionTopline.propTypes = {
notAfter: PropTypes.string.isRequired,
};
export default withNamespaces()(EncryptionTopline);

View File

@ -11,9 +11,12 @@ import {
getDhcpInterfaces,
setDhcpConfig,
findActiveDhcp,
} from '../actions';
import {
getTlsStatus,
setTlsConfig,
} from '../actions';
validateTlsConfig,
} from '../actions/encryption';
import Settings from '../components/Settings';
const mapStateToProps = (state) => {
@ -46,6 +49,7 @@ const mapDispatchToProps = {
findActiveDhcp,
getTlsStatus,
setTlsConfig,
validateTlsConfig,
};
export default connect(

View File

@ -73,3 +73,4 @@ export const SETTINGS_NAMES = {
export const STANDARD_DNS_PORT = 53;
export const STANDARD_WEB_PORT = 80;
export const EMPTY_DATE = '0001-01-01T00:00:00Z';

View File

@ -0,0 +1,62 @@
import { handleActions } from 'redux-actions';
import * as actions from '../actions/encryption';
const encryption = handleActions({
[actions.getTlsStatusRequest]: state => ({ ...state, processing: true }),
[actions.getTlsStatusFailure]: state => ({ ...state, processing: false }),
[actions.getTlsStatusSuccess]: (state, { payload }) => {
const newState = {
...state,
...payload,
processing: false,
};
return newState;
},
[actions.setTlsConfigRequest]: state => ({ ...state, processingConfig: true }),
[actions.setTlsConfigFailure]: state => ({ ...state, processingConfig: false }),
[actions.setTlsConfigSuccess]: (state, { payload }) => {
const newState = {
...state,
...payload,
processingConfig: false,
};
return newState;
},
[actions.validateTlsConfigRequest]: state => ({ ...state, processingValidate: true }),
[actions.validateTlsConfigFailure]: state => ({ ...state, processingValidate: false }),
[actions.validateTlsConfigSuccess]: (state, { payload }) => {
const newState = {
...state,
...payload,
processingValidate: false,
};
return newState;
},
}, {
processing: true,
processingConfig: false,
processingValidate: false,
enabled: false,
dns_names: null,
force_https: false,
issuer: '',
key_type: '',
not_after: '',
not_before: '',
port_dns_over_tls: 853,
port_https: 443,
subject: '',
valid_chain: false,
valid_key: false,
status_cert: '',
status_key: '',
certificate_chain: '',
private_key: '',
server_name: '',
warning_validation: '',
});
export default encryption;

View File

@ -6,6 +6,7 @@ import versionCompare from '../helpers/versionCompare';
import * as actions from '../actions';
import toasts from './toasts';
import encryption from './encryption';
const settings = handleActions({
[actions.initSettingsRequest]: state => ({ ...state, processing: true }),
@ -302,38 +303,6 @@ const dhcp = handleActions({
leases: [],
});
const encryption = handleActions({
[actions.getTlsStatusRequest]: state => ({ ...state, processing: true }),
[actions.getTlsStatusFailure]: state => ({ ...state, processing: false }),
[actions.getTlsStatusSuccess]: (state, { payload }) => {
const newState = {
...state,
...payload,
processing: false,
};
return newState;
},
[actions.setTlsConfigRequest]: state => ({ ...state, processingConfig: true }),
[actions.setTlsConfigFailure]: state => ({ ...state, processingConfig: false }),
[actions.setTlsConfigSuccess]: (state, { payload }) => {
const newState = {
...state,
...payload,
processingConfig: false,
};
return newState;
},
}, {
processing: true,
processingConfig: false,
status_cert: '',
status_key: '',
certificate_chain: '',
private_key: '',
server_name: '',
});
export default combineReducers({
settings,
dashboard,

View File

@ -63,7 +63,7 @@ type dnsConfig struct {
var defaultDNS = []string{"tls://1.1.1.1", "tls://1.0.0.1"}
type tlsConfigSettings struct {
Enabled bool `yaml:"enaled" json:"enabled"`
Enabled bool `yaml:"enabled" json:"enabled"`
ServerName string `yaml:"server_name" json:"server_name,omitempty"`
ForceHTTPS bool `yaml:"force_https" json:"force_https,omitempty"`
PortHTTPS int `yaml:"port_https" json:"port_https,omitempty"`