diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index 2bae1d12..36bd3501 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -210,5 +210,27 @@ "next": "Next", "open_dashboard": "Open Dashboard", "install_saved": "All settings saved", + "encryption_title": "Encryption", + "encryption_desc": "Encryption (HTTPS/TLS) support for both DNS and admin web interface", + "encryption_config_saved": "Encryption config saved", + "encryption_server": "Server name", + "encryption_server_enter": "Enter your domain name", + "encryption_server_desc": "In order to use HTTPS, you need yo enter the server name that matches your SSL 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_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 letsencrypt.org 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_key": "Private key", + "encryption_key_input": "Copy/paste your PEM-encoded private key for your cerficate here.", + "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" } \ No newline at end of file diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 1bb99064..da33f0fe 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -650,3 +650,34 @@ 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(); + 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 { + await apiClient.setTlsConfig(config); + dispatch(setTlsConfigSuccess(config)); + dispatch(addSuccessToast('encryption_config_saved')); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(setTlsConfigFailure()); + } +}; diff --git a/client/src/api/Api.js b/client/src/api/Api.js index 0dac781a..1971fe4a 100644 --- a/client/src/api/Api.js +++ b/client/src/api/Api.js @@ -354,4 +354,22 @@ export default class Api { }; return this.makeRequest(path, method, parameters); } + + // DNS-over-HTTPS and DNS-over-TLS + TLS_STATUS = { path: 'tls/status', method: 'GET' }; + TLS_CONFIG = { path: 'tls/configure', method: 'POST' }; + + getTlsStatus() { + const { path, method } = this.TLS_STATUS; + return this.makeRequest(path, method); + } + + setTlsConfig(config) { + const { path, method } = this.TLS_CONFIG; + const parameters = { + data: config, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, parameters); + } } diff --git a/client/src/components/Settings/Dhcp/Form.js b/client/src/components/Settings/Dhcp/Form.js index 5b810c7b..af7c1931 100644 --- a/client/src/components/Settings/Dhcp/Form.js +++ b/client/src/components/Settings/Dhcp/Form.js @@ -1,48 +1,10 @@ -import React, { Fragment } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { Field, reduxForm } from 'redux-form'; -import { withNamespaces, Trans } from 'react-i18next'; +import { withNamespaces } from 'react-i18next'; import flow from 'lodash/flow'; -import { R_IPV4 } from '../../../helpers/constants'; - -const required = (value) => { - if (value || value === 0) { - return false; - } - return form_error_required; -}; - -const ipv4 = (value) => { - if (value && !new RegExp(R_IPV4).test(value)) { - return form_error_ip_format; - } - return false; -}; - -const isPositive = (value) => { - if ((value || value === 0) && (value <= 0)) { - return form_error_positive; - } - return false; -}; - -const toNumber = value => value && parseInt(value, 10); - -const renderField = ({ - input, className, placeholder, type, disabled, meta: { touched, error }, -}) => ( - - - {!disabled && touched && (error && {error})} - -); +import { renderField, required, ipv4, isPositive, toNumber } from '../../../helpers/form'; const Form = (props) => { const { @@ -57,7 +19,7 @@ const Form = (props) => {
-
+
{ validate={[ipv4, required]} />
-
+
{
-
+
@@ -108,7 +70,7 @@ const Form = (props) => {
-
+
{ {!processing && interfaces &&
-
+
{ + const errors = {}; + + if (values.port_dns_over_tls === values.port_https) { + errors.port_dns_over_tls = i18n.t('form_error_equal'); + errors.port_https = i18n.t('form_error_equal'); + } + + return errors; +}; + +const Form = (props) => { + const { + t, + handleSubmit, + invalid, + submitting, + processing, + } = props; + + return ( + +
+
+ +
+
+
+ +
+ encryption_server_desc +
+
+
+
+
+ +
+ encryption_redirect_desc +
+
+
+
+
+
+
+ + +
+ encryption_https_desc +
+
+
+
+
+ + +
+ encryption_dot_desc +
+
+
+
+
+
+
+ +
+ encryption_certificates_desc +
+ +
+
+ encryption_status: +
+
+ encryption_certificates_for + *.example.org, example.org +
+
+ encryption_expire + 2022-01-01 +
+
+
+
+
+
+
+
+ + +
+
+ encryption_status: +
+
Valid RSA private key
+
+
+
+
+ + + + ); +}; + +Form.propTypes = { + handleSubmit: PropTypes.func.isRequired, + submitting: PropTypes.bool.isRequired, + invalid: PropTypes.bool.isRequired, + initialValues: PropTypes.object.isRequired, + processing: PropTypes.bool.isRequired, + t: PropTypes.func.isRequired, +}; + +export default flow([ + withNamespaces(), + reduxForm({ + form: 'encryptionForm', + validate, + }), +])(Form); diff --git a/client/src/components/Settings/Encryption/index.js b/client/src/components/Settings/Encryption/index.js new file mode 100644 index 00000000..b4f876a7 --- /dev/null +++ b/client/src/components/Settings/Encryption/index.js @@ -0,0 +1,47 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withNamespaces } from 'react-i18next'; + +import Form from './Form'; +import Card from '../../ui/Card'; + +class Encryption extends Component { + handleFormSubmit = (values) => { + this.props.setTlsConfig(values); + }; + + render() { + const { encryption, t } = this.props; + const { + processing, + processingConfig, + ...values + } = encryption; + + return ( +
+ {encryption && !encryption.processing && + +
+ + } +
+ ); + } +} + +Encryption.propTypes = { + setTlsConfig: PropTypes.func.isRequired, + encryption: PropTypes.object.isRequired, + t: PropTypes.func.isRequired, +}; + +export default withNamespaces()(Encryption); diff --git a/client/src/components/Settings/Settings.css b/client/src/components/Settings/Settings.css index 9530ef36..b364be6e 100644 --- a/client/src/components/Settings/Settings.css +++ b/client/src/components/Settings/Settings.css @@ -7,8 +7,8 @@ margin-bottom: 0; } -.form__group--dhcp:last-child { - margin-bottom: 15px; +.form__group--settings:last-child { + margin-bottom: 20px; } .btn-standard { @@ -48,3 +48,23 @@ .dhcp { min-height: 450px; } + +.form__desc { + margin-top: 10px; + font-size: 13px; + color: rgba(74, 74, 74, 0.7); +} + +.form__desc--top { + margin: 0 0 8px; +} + +.form__label--bold { + font-weight: 700; +} + +.form__status { + margin-top: 10px; + font-size: 14px; + line-height: 1.7; +} diff --git a/client/src/components/Settings/index.js b/client/src/components/Settings/index.js index 24e56329..d3d0706b 100644 --- a/client/src/components/Settings/index.js +++ b/client/src/components/Settings/index.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { withNamespaces, Trans } from 'react-i18next'; import Upstream from './Upstream'; import Dhcp from './Dhcp'; +import Encryption from './Encryption'; import Checkbox from '../ui/Checkbox'; import Loading from '../ui/Loading'; import PageTitle from '../ui/PageTitle'; @@ -37,6 +38,7 @@ class Settings extends Component { this.props.initSettings(this.settings); this.props.getDhcpStatus(); this.props.getDhcpInterfaces(); + this.props.getTlsStatus(); } handleUpstreamChange = (value) => { @@ -95,6 +97,10 @@ class Settings extends Component { handleUpstreamSubmit={this.handleUpstreamSubmit} handleUpstreamTest={this.handleUpstreamTest} /> + { - const { settings, dashboard, dhcp } = state; - const props = { settings, dashboard, dhcp }; + const { + settings, + dashboard, + dhcp, + encryption, + } = state; + const props = { + settings, + dashboard, + dhcp, + encryption, + }; return props; }; @@ -32,6 +44,8 @@ const mapDispatchToProps = { getDhcpInterfaces, setDhcpConfig, findActiveDhcp, + getTlsStatus, + setTlsConfig, }; export default connect( diff --git a/client/src/helpers/form.js b/client/src/helpers/form.js new file mode 100644 index 00000000..055d1138 --- /dev/null +++ b/client/src/helpers/form.js @@ -0,0 +1,72 @@ +import React, { Fragment } from 'react'; +import { Trans } from 'react-i18next'; + +import { R_IPV4 } from '../helpers/constants'; + +export const renderField = ({ + input, id, className, placeholder, type, disabled, meta: { touched, error }, +}) => ( + + + {!disabled && touched && (error && {error})} + +); + +export const renderSelectField = ({ + input, placeholder, disabled, meta: { touched, error }, +}) => ( + + + {!disabled && touched && (error && {error})} + +); + +export const required = (value) => { + if (value || value === 0) { + return false; + } + return form_error_required; +}; + +export const ipv4 = (value) => { + if (value && !new RegExp(R_IPV4).test(value)) { + return form_error_ip_format; + } + return false; +}; + +export const isPositive = (value) => { + if ((value || value === 0) && (value <= 0)) { + return form_error_positive; + } + return false; +}; + +export const port = (value) => { + if (value < 80 || value > 65535) { + return form_error_port_range; + } + return false; +}; + +export const toNumber = value => value && parseInt(value, 10); diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index 19bbbf63..b80f7f37 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -302,6 +302,33 @@ 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, +}); + export default combineReducers({ settings, dashboard, @@ -309,6 +336,7 @@ export default combineReducers({ filtering, toasts, dhcp, + encryption, loadingBar: loadingBarReducer, form: formReducer, }); diff --git a/control.go b/control.go index 71bb3dd5..7199ff11 100644 --- a/control.go +++ b/control.go @@ -1038,7 +1038,7 @@ func handleTLSStatus(w http.ResponseWriter, r *http.Request) { func handleTLSConfigure(w http.ResponseWriter, r *http.Request) { newconfig := tlsConfig{} - err := json.NewDecoder(r.body).Decode(&newconfig) + err := json.NewDecoder(r.Body).Decode(&newconfig) if err != nil { httpError(w, http.StatusBadRequest, "Failed to parse new TLS config json: %s", err) return