diff --git a/AGHTechDoc.md b/AGHTechDoc.md index cfe0304f..bd526fc8 100644 --- a/AGHTechDoc.md +++ b/AGHTechDoc.md @@ -27,6 +27,10 @@ Contents: * DNS access settings * List access settings * Set access settings +* Rewrites + * API: List rewrite entries + * API: Add a rewrite entry + * API: Remove a rewrite entry ## First startup @@ -682,3 +686,60 @@ Request: Response: 200 OK + + +## Rewrites + +This section allows the administrator to easily configure custom DNS response for a specific domain name. +A, AAAA and CNAME records are supported. + + +### API: List rewrite entries + +Request: + + GET /control/rewrite/list + +Response: + + 200 OK + + [ + { + domain: "..." + answer: "..." + } + ... + ] + + +### API: Add a rewrite entry + +Request: + + POST /control/rewrite/add + + { + domain: "..." + answer: "..." // "1.2.3.4" (A) || "::1" (AAAA) || "hostname" (CNAME) + } + +Response: + + 200 OK + + +### API: Remove a rewrite entry + +Request: + + POST /control/rewrite/delete + + { + domain: "..." + answer: "..." + } + +Response: + + 200 OK diff --git a/client/package-lock.json b/client/package-lock.json index 3edaa670..cedee41d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -945,12 +945,27 @@ } }, "axios": { - "version": "0.18.0", - "resolved": "http://registry.npmjs.org/axios/-/axios-0.18.0.tgz", - "integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz", + "integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==", "requires": { - "follow-redirects": "^1.3.0", - "is-buffer": "^1.1.5" + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" + }, + "dependencies": { + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "requires": { + "debug": "=3.1.0" + } + }, + "is-buffer": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", + "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==" + } } }, "axobject-query": { @@ -5124,6 +5139,7 @@ "version": "1.5.7", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.7.tgz", "integrity": "sha512-NONJVIFiX7Z8k2WxfqBjtwqMifx7X42ORLFrOZ2LTKGj71G3C0kfdyTqGqr8fx5zSX6Foo/D95dgGWbPUiwnew==", + "dev": true, "requires": { "debug": "^3.1.0" } @@ -6933,7 +6949,8 @@ "is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true }, "is-builtin-module": { "version": "1.0.0", @@ -7386,9 +7403,9 @@ } }, "lodash": { - "version": "4.17.11", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", - "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, "lodash-es": { "version": "4.17.10", @@ -7767,9 +7784,9 @@ } }, "mixin-deep": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.1.tgz", - "integrity": "sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", "dev": true, "requires": { "for-in": "^1.0.2", @@ -10191,6 +10208,14 @@ } } }, + "react-router-hash-link": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/react-router-hash-link/-/react-router-hash-link-1.2.2.tgz", + "integrity": "sha512-LBthLVHdqPeKDVt3+cFRhy15Z7veikOvdKRZRfyBR2vjqIE7rxn+tKLjb6DOmLm6JpoQVemVDnxQ35RVnEHdQA==", + "requires": { + "prop-types": "^15.6.0" + } + }, "react-table": { "version": "6.8.6", "resolved": "https://registry.npmjs.org/react-table/-/react-table-6.8.6.tgz", @@ -10848,9 +10873,9 @@ "dev": true }, "set-value": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.0.tgz", - "integrity": "sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", "dev": true, "requires": { "extend-shallow": "^2.0.1", @@ -12478,38 +12503,15 @@ } }, "union-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", - "integrity": "sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", "dev": true, "requires": { "arr-union": "^3.1.0", "get-value": "^2.0.6", "is-extendable": "^0.1.1", - "set-value": "^0.4.3" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - }, - "set-value": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-0.4.3.tgz", - "integrity": "sha1-fbCPnT0i3H945Trzw79GZuzfzPE=", - "dev": true, - "requires": { - "extend-shallow": "^2.0.1", - "is-extendable": "^0.1.1", - "is-plain-object": "^2.0.1", - "to-object-path": "^0.3.0" - } - } + "set-value": "^2.0.1" } }, "uniq": { diff --git a/client/package.json b/client/package.json index 05d91b7a..dc64d590 100644 --- a/client/package.json +++ b/client/package.json @@ -10,13 +10,13 @@ }, "dependencies": { "@nivo/line": "^0.49.1", - "axios": "^0.18.0", + "axios": "^0.18.1", "classnames": "^2.2.6", "date-fns": "^1.29.0", "file-saver": "^1.3.8", "i18next": "^12.0.0", "i18next-browser-languagedetector": "^2.2.3", - "lodash": "^4.17.11", + "lodash": "^4.17.15", "nanoid": "^1.2.3", "prop-types": "^15.6.1", "react": "^16.4.0", @@ -27,6 +27,7 @@ "react-redux": "^5.0.7", "react-redux-loading-bar": "^4.0.7", "react-router-dom": "^4.2.2", + "react-router-hash-link": "^1.2.2", "react-table": "^6.8.6", "react-transition-group": "^2.4.0", "redux": "^4.0.0", diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index c20ff970..4aaac0e0 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -331,5 +331,18 @@ "setup_dns_privacy_other_3": "<0>dnscrypt-proxy supports <1>DNS-over-HTTPS.", "setup_dns_privacy_other_4": "<0>Mozilla Firefox supports <1>DNS-over-HTTPS.", "setup_dns_privacy_other_5": "You will find more implementations <0>here and <1>here.", - "setup_dns_notice": "In order to use <1>DNS-over-HTTPS or <1>DNS-over-TLS, you need to <0>configure Encryption in AdGuard Home settings." -} \ No newline at end of file + "setup_dns_notice": "In order to use <1>DNS-over-HTTPS or <1>DNS-over-TLS, you need to <0>configure Encryption in AdGuard Home settings.", + "rewrite_added": "DNS rewrite for \"{{key}}\" successfully added", + "rewrite_deleted": "DNS rewrite for \"{{key}}\" successfully deleted", + "rewrite_add": "Add DNS rewrite", + "rewrite_not_found": "No DNS rewrites found", + "rewrite_confirm_delete": "Are you sure you want to delete DNS rewrite for \"{{key}}\"?", + "rewrite_desc": "Allows to easily configure custom DNS response for a specific domain name.", + "rewrite_applied": "Applied Rewrite rule", + "dns_rewrites": "DNS rewrites", + "form_domain": "Enter domain", + "form_answer": "Enter IP address or domain name", + "form_error_domain_format": "Invalid domain format", + "form_error_answer_format": "Invalid answer format", + "configure": "Configure" +} diff --git a/client/src/actions/rewrites.js b/client/src/actions/rewrites.js new file mode 100644 index 00000000..df846fdd --- /dev/null +++ b/client/src/actions/rewrites.js @@ -0,0 +1,58 @@ +import { createAction } from 'redux-actions'; +import { t } from 'i18next'; +import Api from '../api/Api'; +import { addErrorToast, addSuccessToast } from './index'; + +const apiClient = new Api(); + +export const toggleRewritesModal = createAction('TOGGLE_REWRITES_MODAL'); + +export const getRewritesListRequest = createAction('GET_REWRITES_LIST_REQUEST'); +export const getRewritesListFailure = createAction('GET_REWRITES_LIST_FAILURE'); +export const getRewritesListSuccess = createAction('GET_REWRITES_LIST_SUCCESS'); + +export const getRewritesList = () => async (dispatch) => { + dispatch(getRewritesListRequest()); + try { + const data = await apiClient.getRewritesList(); + dispatch(getRewritesListSuccess(data)); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(getRewritesListFailure()); + } +}; + +export const addRewriteRequest = createAction('ADD_REWRITE_REQUEST'); +export const addRewriteFailure = createAction('ADD_REWRITE_FAILURE'); +export const addRewriteSuccess = createAction('ADD_REWRITE_SUCCESS'); + +export const addRewrite = config => async (dispatch) => { + dispatch(addRewriteRequest()); + try { + await apiClient.addRewrite(config); + dispatch(addRewriteSuccess(config)); + dispatch(toggleRewritesModal()); + dispatch(getRewritesList()); + dispatch(addSuccessToast(t('rewrite_added', { key: config.domain }))); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(addRewriteFailure()); + } +}; + +export const deleteRewriteRequest = createAction('DELETE_REWRITE_REQUEST'); +export const deleteRewriteFailure = createAction('DELETE_REWRITE_FAILURE'); +export const deleteRewriteSuccess = createAction('DELETE_REWRITE_SUCCESS'); + +export const deleteRewrite = config => async (dispatch) => { + dispatch(deleteRewriteRequest()); + try { + await apiClient.deleteRewrite(config); + dispatch(deleteRewriteSuccess()); + dispatch(getRewritesList()); + dispatch(addSuccessToast(t('rewrite_deleted', { key: config.domain }))); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(deleteRewriteFailure()); + } +}; diff --git a/client/src/api/Api.js b/client/src/api/Api.js index 76b17888..766cd499 100644 --- a/client/src/api/Api.js +++ b/client/src/api/Api.js @@ -481,4 +481,32 @@ export default class Api { }; return this.makeRequest(path, method, parameters); } + + // DNS rewrites + REWRITES_LIST = { path: 'rewrite/list', method: 'GET' }; + REWRITE_ADD = { path: 'rewrite/add', method: 'POST' }; + REWRITE_DELETE = { path: 'rewrite/delete', method: 'POST' }; + + getRewritesList() { + const { path, method } = this.REWRITES_LIST; + return this.makeRequest(path, method); + } + + addRewrite(config) { + const { path, method } = this.REWRITE_ADD; + const parameters = { + data: config, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, parameters); + } + + deleteRewrite(config) { + const { path, method } = this.REWRITE_DELETE; + const parameters = { + data: config, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, parameters); + } } diff --git a/client/src/components/Logs/Logs.css b/client/src/components/Logs/Logs.css index 1b39f592..3205e424 100644 --- a/client/src/components/Logs/Logs.css +++ b/client/src/components/Logs/Logs.css @@ -13,6 +13,12 @@ overflow: hidden; } +.logs__row--column { + flex-direction: column; + align-items: flex-start; + overflow: hidden; +} + .logs__row .list-unstyled { margin-bottom: 0; overflow: hidden; diff --git a/client/src/components/Logs/index.js b/client/src/components/Logs/index.js index 8a675a05..f8891206 100644 --- a/client/src/components/Logs/index.js +++ b/client/src/components/Logs/index.js @@ -5,6 +5,7 @@ import { saveAs } from 'file-saver/FileSaver'; import escapeRegExp from 'lodash/escapeRegExp'; import endsWith from 'lodash/endsWith'; import { Trans, withNamespaces } from 'react-i18next'; +import { HashLink as Link } from 'react-router-hash-link'; import { formatTime, getClientName } from '../../helpers/helpers'; import { getTrackerData } from '../../helpers/trackers/trackers'; @@ -125,6 +126,7 @@ class Logs extends Component { const rule = row && row.original && row.original.rule; const { filterId } = row.original; const { filters } = this.props.filtering; + const isRewrite = reason && reason === 'Rewrite'; let filterName = ''; if (reason === 'FilteredBlackList' || reason === 'NotFilteredWhiteList') { @@ -161,14 +163,16 @@ class Logs extends Component { const isRenderTooltip = reason === 'NotFilteredWhiteList'; return ( -
+
+ {isRewrite && rewrite_applied} {this.renderTooltip(isRenderTooltip, rule, filterName)}
); } return ( -
+
+ {isRewrite && rewrite_applied} empty_response_status {this.renderTooltip(isFiltered, rule, filterName)}
@@ -197,6 +201,7 @@ class Logs extends Component { Cell: (row) => { const { reason } = row.original; const isFiltered = row ? reason.indexOf('Filtered') === 0 : false; + const isRewrite = reason && reason === 'Rewrite'; const clientName = getClientName(dashboard.clients, row.value) || getClientName(dashboard.autoClients, row.value); let client; @@ -207,6 +212,21 @@ class Logs extends Component { client = row.value; } + if (isRewrite) { + return ( + +
+ {client} +
+
+ + configure + +
+
+ ); + } + return (
@@ -261,6 +281,10 @@ class Logs extends Component { return { className: 'green', }; + } else if (rowInfo.original.reason === 'Rewrite') { + return { + className: 'blue', + }; } return { diff --git a/client/src/components/Settings/Dns/Rewrites/Form.js b/client/src/components/Settings/Dns/Rewrites/Form.js new file mode 100644 index 00000000..2d6b27a3 --- /dev/null +++ b/client/src/components/Settings/Dns/Rewrites/Form.js @@ -0,0 +1,89 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Field, reduxForm } from 'redux-form'; +import { Trans, withNamespaces } from 'react-i18next'; +import flow from 'lodash/flow'; + +import { renderField, required, domain, answer } from '../../../../helpers/form'; + +const Form = (props) => { + const { + t, + handleSubmit, + reset, + pristine, + submitting, + toggleRewritesModal, + processingAdd, + } = props; + + return ( +
+
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+ ); +}; + +Form.propTypes = { + pristine: PropTypes.bool.isRequired, + handleSubmit: PropTypes.func.isRequired, + reset: PropTypes.func.isRequired, + toggleRewritesModal: PropTypes.func.isRequired, + submitting: PropTypes.bool.isRequired, + processingAdd: PropTypes.bool.isRequired, + t: PropTypes.func.isRequired, +}; + +export default flow([ + withNamespaces(), + reduxForm({ + form: 'rewritesForm', + enableReinitialize: true, + }), +])(Form); diff --git a/client/src/components/Settings/Dns/Rewrites/Modal.js b/client/src/components/Settings/Dns/Rewrites/Modal.js new file mode 100644 index 00000000..eba58bb9 --- /dev/null +++ b/client/src/components/Settings/Dns/Rewrites/Modal.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Trans, withNamespaces } from 'react-i18next'; +import ReactModal from 'react-modal'; + +import Form from './Form'; + +const Modal = (props) => { + const { + isModalOpen, + handleSubmit, + toggleRewritesModal, + processingAdd, + processingDelete, + } = props; + + return ( + toggleRewritesModal()} + > +
+
+

+ Add DNS rewrite +

+ +
+
+
+
+ ); +}; + +Modal.propTypes = { + isModalOpen: PropTypes.bool.isRequired, + handleSubmit: PropTypes.func.isRequired, + toggleRewritesModal: PropTypes.func.isRequired, + processingAdd: PropTypes.bool.isRequired, + processingDelete: PropTypes.bool.isRequired, +}; + +export default withNamespaces()(Modal); diff --git a/client/src/components/Settings/Dns/Rewrites/Table.js b/client/src/components/Settings/Dns/Rewrites/Table.js new file mode 100644 index 00000000..78d5489c --- /dev/null +++ b/client/src/components/Settings/Dns/Rewrites/Table.js @@ -0,0 +1,87 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import ReactTable from 'react-table'; +import { withNamespaces } from 'react-i18next'; + +class Table extends Component { + cellWrap = ({ value }) => ( +
+ + {value} + +
+ ); + + columns = [ + { + Header: 'Domain', + accessor: 'domain', + Cell: this.cellWrap, + }, + { + Header: 'Answer', + accessor: 'answer', + Cell: this.cellWrap, + }, + { + Header: this.props.t('actions_table_header'), + accessor: 'actions', + maxWidth: 100, + Cell: value => ( +
+ +
+ ), + }, + ]; + + render() { + const { + t, list, processing, processingAdd, processingDelete, + } = this.props; + + return ( + + ); + } +} + +Table.propTypes = { + t: PropTypes.func.isRequired, + list: PropTypes.array.isRequired, + processing: PropTypes.bool.isRequired, + processingAdd: PropTypes.bool.isRequired, + processingDelete: PropTypes.bool.isRequired, + handleDelete: PropTypes.func.isRequired, +}; + +export default withNamespaces()(Table); diff --git a/client/src/components/Settings/Dns/Rewrites/index.js b/client/src/components/Settings/Dns/Rewrites/index.js new file mode 100644 index 00000000..e4e0b193 --- /dev/null +++ b/client/src/components/Settings/Dns/Rewrites/index.js @@ -0,0 +1,83 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Trans, withNamespaces } from 'react-i18next'; + +import Table from './Table'; +import Modal from './Modal'; +import Card from '../../../ui/Card'; + +class Rewrites extends Component { + handleSubmit = (values) => { + this.props.addRewrite(values); + }; + + handleDelete = (values) => { + // eslint-disable-next-line no-alert + if (window.confirm(this.props.t('rewrite_confirm_delete', { key: values.domain }))) { + this.props.deleteRewrite(values); + } + }; + + render() { + const { + t, + rewrites, + toggleRewritesModal, + } = this.props; + + const { + list, + isModalOpen, + processing, + processingAdd, + processingDelete, + } = rewrites; + + return ( + + + + + + + + + + ); + } +} + +Rewrites.propTypes = { + t: PropTypes.func.isRequired, + getRewritesList: PropTypes.func.isRequired, + toggleRewritesModal: PropTypes.func.isRequired, + addRewrite: PropTypes.func.isRequired, + deleteRewrite: PropTypes.func.isRequired, + rewrites: PropTypes.object.isRequired, +}; + +export default withNamespaces()(Rewrites); diff --git a/client/src/components/Settings/Dns/index.js b/client/src/components/Settings/Dns/index.js index 2b038b1f..cb9c9e4a 100644 --- a/client/src/components/Settings/Dns/index.js +++ b/client/src/components/Settings/Dns/index.js @@ -4,12 +4,14 @@ import { withNamespaces } from 'react-i18next'; import Upstream from './Upstream'; import Access from './Access'; +import Rewrites from './Rewrites'; import PageTitle from '../../ui/PageTitle'; import Loading from '../../ui/Loading'; class Dns extends Component { componentDidMount() { this.props.getAccessList(); + this.props.getRewritesList(); } render() { @@ -18,9 +20,14 @@ class Dns extends Component { dashboard, settings, access, + rewrites, setAccessList, testUpstream, setUpstream, + getRewritesList, + addRewrite, + deleteRewrite, + toggleRewritesModal, } = this.props; return ( @@ -39,6 +46,13 @@ class Dns extends Component { testUpstream={testUpstream} /> + )} @@ -54,6 +68,11 @@ Dns.propTypes = { getAccessList: PropTypes.func.isRequired, setAccessList: PropTypes.func.isRequired, access: PropTypes.object.isRequired, + rewrites: PropTypes.object.isRequired, + getRewritesList: PropTypes.func.isRequired, + addRewrite: PropTypes.func.isRequired, + deleteRewrite: PropTypes.func.isRequired, + toggleRewritesModal: PropTypes.func.isRequired, t: PropTypes.func.isRequired, }; diff --git a/client/src/components/ui/Card.js b/client/src/components/ui/Card.js index 01a06dac..65c15487 100644 --- a/client/src/components/ui/Card.js +++ b/client/src/components/ui/Card.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import './Card.css'; const Card = props => ( -
+
{props.title &&
@@ -30,6 +30,7 @@ const Card = props => ( ); Card.propTypes = { + id: PropTypes.string, title: PropTypes.string, subtitle: PropTypes.string, bodyType: PropTypes.string, diff --git a/client/src/components/ui/ReactTable.css b/client/src/components/ui/ReactTable.css index e1de27b4..48a35dc7 100644 --- a/client/src/components/ui/ReactTable.css +++ b/client/src/components/ui/ReactTable.css @@ -15,3 +15,7 @@ .rt-tr-group .green { background-color: #f1faf3; } + +.rt-tr-group .blue { + background-color: #ecf7ff; +} diff --git a/client/src/containers/Dns.js b/client/src/containers/Dns.js index aa3c78ad..4f245c1a 100644 --- a/client/src/containers/Dns.js +++ b/client/src/containers/Dns.js @@ -1,14 +1,23 @@ import { connect } from 'react-redux'; import { handleUpstreamChange, setUpstream, testUpstream } from '../actions'; import { getAccessList, setAccessList } from '../actions/access'; +import { + getRewritesList, + addRewrite, + deleteRewrite, + toggleRewritesModal, +} from '../actions/rewrites'; import Dns from '../components/Settings/Dns'; const mapStateToProps = (state) => { - const { dashboard, settings, access } = state; + const { + dashboard, settings, access, rewrites, + } = state; const props = { dashboard, settings, access, + rewrites, }; return props; }; @@ -19,6 +28,10 @@ const mapDispatchToProps = { testUpstream, getAccessList, setAccessList, + getRewritesList, + addRewrite, + deleteRewrite, + toggleRewritesModal, }; export default connect( diff --git a/client/src/helpers/constants.js b/client/src/helpers/constants.js index 9faf2e5f..896d873e 100644 --- a/client/src/helpers/constants.js +++ b/client/src/helpers/constants.js @@ -1,5 +1,7 @@ export const R_URL_REQUIRES_PROTOCOL = /^https?:\/\/[^/\s]+(\/.*)?$/; +export const R_HOST = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$/; export const R_IPV4 = /^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$/g; +export const R_IPV6 = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/g; export const R_MAC = /^((([a-fA-F0-9][a-fA-F0-9]+[-]){5}|([a-fA-F0-9][a-fA-F0-9]+[:]){5})([a-fA-F0-9][a-fA-F0-9])$)|(^([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]+[.]){2}([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]))$/g; export const STATS_NAMES = { diff --git a/client/src/helpers/form.js b/client/src/helpers/form.js index 39c4b7aa..c4ffcb73 100644 --- a/client/src/helpers/form.js +++ b/client/src/helpers/form.js @@ -1,10 +1,16 @@ import React, { Fragment } from 'react'; import { Trans } from 'react-i18next'; -import { R_IPV4, R_MAC, UNSAFE_PORTS } from '../helpers/constants'; +import { R_IPV4, R_MAC, R_HOST, R_IPV6, UNSAFE_PORTS } from '../helpers/constants'; export const renderField = ({ - input, id, className, placeholder, type, disabled, meta: { touched, error }, + input, + id, + className, + placeholder, + type, + disabled, + meta: { touched, error }, }) => ( - {!disabled && touched && (error && {error})} + {!disabled && + touched && + (error && {error})} ); @@ -24,20 +32,17 @@ export const renderSelectField = ({ }) => ( - {!disabled && touched && (error && {error})} + {!disabled && + touched && + (error && {error})} ); @@ -63,7 +68,7 @@ export const mac = (value) => { }; export const isPositive = (value) => { - if ((value || value === 0) && (value <= 0)) { + if ((value || value === 0) && value <= 0) { return form_error_positive; } return false; @@ -92,4 +97,23 @@ export const isSafePort = (value) => { return false; }; +export const domain = (value) => { + if (value && !new RegExp(R_HOST).test(value)) { + return form_error_domain_format; + } + return false; +}; + +export const answer = (value) => { + if ( + value && + (!new RegExp(R_IPV4).test(value) && + !new RegExp(R_IPV6).test(value) && + !new RegExp(R_HOST).test(value)) + ) { + return form_error_answer_format; + } + 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 d3eb7342..32026a08 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -9,6 +9,7 @@ import toasts from './toasts'; import encryption from './encryption'; import clients from './clients'; import access from './access'; +import rewrites from './rewrites'; const settings = handleActions({ [actions.initSettingsRequest]: state => ({ ...state, processing: true }), @@ -422,6 +423,7 @@ export default combineReducers({ encryption, clients, access, + rewrites, loadingBar: loadingBarReducer, form: formReducer, }); diff --git a/client/src/reducers/rewrites.js b/client/src/reducers/rewrites.js new file mode 100644 index 00000000..d680b830 --- /dev/null +++ b/client/src/reducers/rewrites.js @@ -0,0 +1,50 @@ +import { handleActions } from 'redux-actions'; + +import * as actions from '../actions/rewrites'; + +const rewrites = handleActions( + { + [actions.getRewritesListRequest]: state => ({ ...state, processing: true }), + [actions.getRewritesListFailure]: state => ({ ...state, processing: false }), + [actions.getRewritesListSuccess]: (state, { payload }) => { + const newState = { + ...state, + list: payload, + processing: false, + }; + return newState; + }, + + [actions.addRewriteRequest]: state => ({ ...state, processingAdd: true }), + [actions.addRewriteFailure]: state => ({ ...state, processingAdd: false }), + [actions.addRewriteSuccess]: (state, { payload }) => { + const newState = { + ...state, + list: [...state.list, ...payload], + processingAdd: false, + }; + return newState; + }, + + [actions.deleteRewriteRequest]: state => ({ ...state, processingDelete: true }), + [actions.deleteRewriteFailure]: state => ({ ...state, processingDelete: false }), + [actions.deleteRewriteSuccess]: state => ({ ...state, processingDelete: false }), + + [actions.toggleRewritesModal]: (state) => { + const newState = { + ...state, + isModalOpen: !state.isModalOpen, + }; + return newState; + }, + }, + { + processing: true, + processingAdd: false, + processingDelete: false, + isModalOpen: false, + list: [], + }, +); + +export default rewrites; diff --git a/client/webpack.dev.js b/client/webpack.dev.js index 79589a08..442921e4 100644 --- a/client/webpack.dev.js +++ b/client/webpack.dev.js @@ -2,15 +2,18 @@ const merge = require('webpack-merge'); const common = require('./webpack.common.js'); module.exports = merge(common, { + devtool: 'eval-source-map', module: { - rules: [{ - test: /\.js$/, - exclude: /node_modules/, - loader: 'eslint-loader', - options: { - emitWarning: true, - configFile: 'dev.eslintrc', + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + loader: 'eslint-loader', + options: { + emitWarning: true, + configFile: 'dev.eslintrc', + }, }, - }], + ], }, }); diff --git a/dnsfilter/dnsfilter.go b/dnsfilter/dnsfilter.go index bc03e448..20d17493 100644 --- a/dnsfilter/dnsfilter.go +++ b/dnsfilter/dnsfilter.go @@ -39,7 +39,7 @@ const defaultParentalURL = "%s://%s/check-parental-control-hash?prefixes=%s&sens const defaultParentalSensitivity = 13 // use "TEEN" by default const maxDialCacheSize = 2 // the number of host names for safebrowsing and parental control -// Custom filtering settings +// RequestFilteringSettings is custom filtering settings type RequestFilteringSettings struct { FilteringEnabled bool SafeSearchEnabled bool @@ -47,6 +47,12 @@ type RequestFilteringSettings struct { ParentalEnabled bool } +// RewriteEntry is a rewrite array element +type RewriteEntry struct { + Domain string `yaml:"domain"` + Answer string `yaml:"answer"` // IP address or canonical name +} + // Config allows you to configure DNS filtering with New() or just change variables directly. type Config struct { ParentalSensitivity int `yaml:"parental_sensitivity"` // must be either 3, 10, 13 or 17 @@ -60,6 +66,8 @@ type Config struct { SafeSearchCacheSize int `yaml:"safesearch_cache_size"` ParentalCacheSize int `yaml:"parental_cache_size"` + Rewrites []RewriteEntry `yaml:"rewrites"` + // Filtering callback function FilterHandler func(clientAddr string, settings *RequestFilteringSettings) `yaml:"-"` } @@ -131,8 +139,31 @@ const ( FilteredInvalid // FilteredSafeSearch - the host was replaced with safesearch variant FilteredSafeSearch + + // ReasonRewrite - rewrite rule was applied + ReasonRewrite ) +func (i Reason) String() string { + names := []string{ + "NotFilteredNotFound", + "NotFilteredWhiteList", + "NotFilteredError", + + "FilteredBlackList", + "FilteredSafeBrowsing", + "FilteredParental", + "FilteredInvalid", + "FilteredSafeSearch", + + "Rewrite", + } + if uint(i) >= uint(len(names)) { + return "" + } + return names[i] +} + type dnsFilterContext struct { stats Stats dialCache gcache.Cache // "host" -> "IP" cache for safebrowsing and parental control servers @@ -150,6 +181,10 @@ type Result struct { Rule string `json:",omitempty"` // Original rule text IP net.IP `json:",omitempty"` // Not nil only in the case of a hosts file syntax FilterID int64 `json:",omitempty"` // Filter ID the rule belongs to + + // for ReasonRewrite: + CanonName string `json:",omitempty"` // CNAME value + IPList []net.IP `json:",omitempty"` // list of IP addresses } // Matched can be used to see if any match at all was found, no matter filtered or not @@ -180,6 +215,12 @@ func (d *Dnsfilter) CheckHost(host string, qtype uint16, clientAddr string) (Res var result Result var err error + + result = d.processRewrites(host, qtype) + if result.Reason == ReasonRewrite { + return result, nil + } + // try filter lists first if setts.FilteringEnabled { result, err = d.matchHost(host, qtype) @@ -234,6 +275,57 @@ func (d *Dnsfilter) CheckHost(host string, qtype uint16, clientAddr string) (Res return Result{}, nil } +// Process rewrites table +// . Find CNAME for a domain name +// . if found, set domain name to canonical name +// . Find A or AAAA record for a domain name +// . if found, return IP addresses +func (d *Dnsfilter) processRewrites(host string, qtype uint16) Result { + var res Result + + for _, r := range d.Rewrites { + if r.Domain != host { + continue + } + + ip := net.ParseIP(r.Answer) + if ip == nil { + log.Debug("Rewrite: CNAME for %s is %s", host, r.Answer) + host = r.Answer + res.CanonName = r.Answer + res.Reason = ReasonRewrite + break + } + } + + for _, r := range d.Rewrites { + if r.Domain != host { + continue + } + + ip := net.ParseIP(r.Answer) + if ip == nil { + continue + } + ip4 := ip.To4() + + if qtype == dns.TypeA && ip4 != nil { + res.IPList = append(res.IPList, ip4) + log.Debug("Rewrite: A for %s is %s", host, ip4) + + } else if qtype == dns.TypeAAAA && ip4 == nil { + res.IPList = append(res.IPList, ip) + log.Debug("Rewrite: AAAA for %s is %s", host, ip) + } + } + + if len(res.IPList) != 0 { + res.Reason = ReasonRewrite + } + + return res +} + func setCacheResult(cache *fastcache.Cache, host string, res Result) { var buf bytes.Buffer enc := gob.NewEncoder(&buf) diff --git a/dnsfilter/reason_string.go b/dnsfilter/reason_string.go deleted file mode 100644 index cb097e52..00000000 --- a/dnsfilter/reason_string.go +++ /dev/null @@ -1,16 +0,0 @@ -// Code generated by "stringer -type=Reason"; DO NOT EDIT. - -package dnsfilter - -import "strconv" - -const _Reason_name = "NotFilteredNotFoundNotFilteredWhiteListNotFilteredErrorFilteredBlackListFilteredSafeBrowsingFilteredParentalFilteredInvalidFilteredSafeSearch" - -var _Reason_index = [...]uint8{0, 19, 39, 55, 72, 92, 108, 123, 141} - -func (i Reason) String() string { - if i < 0 || i >= Reason(len(_Reason_index)-1) { - return "Reason(" + strconv.FormatInt(int64(i), 10) + ")" - } - return _Reason_name[_Reason_index[i]:_Reason_index[i+1]] -} diff --git a/dnsforward/dnsforward.go b/dnsforward/dnsforward.go index ef9fbc20..1661f68b 100644 --- a/dnsforward/dnsforward.go +++ b/dnsforward/dnsforward.go @@ -453,11 +453,31 @@ func (s *Server) handleDNSRequest(p *proxy.Proxy, d *proxy.DNSContext) error { } if d.Res == nil { + answer := []dns.RR{} + originalQuestion := d.Req.Question[0] + + if res.Reason == dnsfilter.ReasonRewrite && len(res.CanonName) != 0 { + answer = append(answer, s.genCNAMEAnswer(d.Req, res.CanonName)) + // resolve canonical name, not the original host name + d.Req.Question[0].Name = dns.Fqdn(res.CanonName) + } + // request was not filtered so let it be processed further err = p.Resolve(d) if err != nil { return err } + + if res.Reason == dnsfilter.ReasonRewrite && len(res.CanonName) != 0 { + + d.Req.Question[0] = originalQuestion + d.Res.Question[0] = originalQuestion + + if len(d.Res.Answer) != 0 { + answer = append(answer, d.Res.Answer...) // host -> IP + d.Res.Answer = answer + } + } } shouldLog := true @@ -485,8 +505,10 @@ func (s *Server) handleDNSRequest(p *proxy.Proxy, d *proxy.DNSContext) error { // filterDNSRequest applies the dnsFilter and sets d.Res if the request was filtered func (s *Server) filterDNSRequest(d *proxy.DNSContext) (*dnsfilter.Result, error) { - msg := d.Req - host := strings.TrimSuffix(msg.Question[0].Name, ".") + var res dnsfilter.Result + req := d.Req + host := strings.TrimSuffix(req.Question[0].Name, ".") + origHost := host s.RLock() protectionEnabled := s.conf.ProtectionEnabled @@ -497,7 +519,10 @@ func (s *Server) filterDNSRequest(d *proxy.DNSContext) (*dnsfilter.Result, error return nil, nil } - var res dnsfilter.Result + if host != origHost { + log.Debug("Rewrite: not supported: CNAME for %s is %s", origHost, host) + } + var err error clientAddr := "" @@ -508,9 +533,35 @@ func (s *Server) filterDNSRequest(d *proxy.DNSContext) (*dnsfilter.Result, error if err != nil { // Return immediately if there's an error return nil, errorx.Decorate(err, "dnsfilter failed to check host '%s'", host) + } else if res.IsFiltered { // log.Tracef("Host %s is filtered, reason - '%s', matched rule: '%s'", host, res.Reason, res.Rule) d.Res = s.genDNSFilterMessage(d, &res) + + } else if res.Reason == dnsfilter.ReasonRewrite && len(res.IPList) != 0 { + resp := dns.Msg{} + resp.SetReply(req) + + name := host + if len(res.CanonName) != 0 { + resp.Answer = append(resp.Answer, s.genCNAMEAnswer(req, res.CanonName)) + name = res.CanonName + } + + for _, ip := range res.IPList { + if req.Question[0].Qtype == dns.TypeA { + a := s.genAAnswer(req, ip) + a.Hdr.Name = dns.Fqdn(name) + resp.Answer = append(resp.Answer, a) + + } else if req.Question[0].Qtype == dns.TypeAAAA { + a := s.genAAAAAnswer(req, res.IP) + a.Hdr.Name = dns.Fqdn(name) + resp.Answer = append(resp.Answer, a) + } + } + + d.Res = &resp } return &res, err @@ -644,6 +695,19 @@ func (s *Server) genBlockedHost(request *dns.Msg, newAddr string, d *proxy.DNSCo return &resp } +// Make a CNAME response +func (s *Server) genCNAMEAnswer(req *dns.Msg, cname string) *dns.CNAME { + answer := new(dns.CNAME) + answer.Hdr = dns.RR_Header{ + Name: req.Question[0].Name, + Rrtype: dns.TypeCNAME, + Ttl: s.conf.BlockedResponseTTL, + Class: dns.ClassINET, + } + answer.Target = dns.Fqdn(cname) + return answer +} + func (s *Server) genNXDomain(request *dns.Msg) *dns.Msg { resp := dns.Msg{} resp.SetRcode(request, dns.RcodeNameError) diff --git a/home/control.go b/home/control.go index b012f9e9..daaf5dbd 100644 --- a/home/control.go +++ b/home/control.go @@ -1021,6 +1021,7 @@ func registerControlHandlers() { RegisterTLSHandlers() RegisterClientsHandlers() + registerRewritesHandlers() http.HandleFunc("/dns-query", postInstall(handleDOH)) } diff --git a/home/dns_rewrites.go b/home/dns_rewrites.go new file mode 100644 index 00000000..816739ab --- /dev/null +++ b/home/dns_rewrites.go @@ -0,0 +1,107 @@ +package home + +import ( + "encoding/json" + "net/http" + + "github.com/AdguardTeam/AdGuardHome/dnsfilter" + "github.com/AdguardTeam/golibs/log" +) + +type rewriteEntryJSON struct { + Domain string `json:"domain"` + Answer string `json:"answer"` +} + +func handleRewriteList(w http.ResponseWriter, r *http.Request) { + log.Tracef("%s %v", r.Method, r.URL) + + arr := []*rewriteEntryJSON{} + + config.RLock() + for _, ent := range config.DNS.Rewrites { + jsent := rewriteEntryJSON{ + Domain: ent.Domain, + Answer: ent.Answer, + } + arr = append(arr, &jsent) + } + config.RUnlock() + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(arr) + if err != nil { + httpError(w, http.StatusInternalServerError, "json.Encode: %s", err) + return + } +} + +func handleRewriteAdd(w http.ResponseWriter, r *http.Request) { + log.Tracef("%s %v", r.Method, r.URL) + + jsent := rewriteEntryJSON{} + err := json.NewDecoder(r.Body).Decode(&jsent) + if err != nil { + httpError(w, http.StatusBadRequest, "json.Decode: %s", err) + return + } + + ent := dnsfilter.RewriteEntry{ + Domain: jsent.Domain, + Answer: jsent.Answer, + } + config.Lock() + config.DNS.Rewrites = append(config.DNS.Rewrites, ent) + config.Unlock() + log.Debug("Rewrites: added element: %s -> %s [%d]", + ent.Domain, ent.Answer, len(config.DNS.Rewrites)) + + err = writeAllConfigsAndReloadDNS() + if err != nil { + httpError(w, http.StatusBadRequest, "%s", err) + return + } + + returnOK(w) +} + +func handleRewriteDelete(w http.ResponseWriter, r *http.Request) { + log.Tracef("%s %v", r.Method, r.URL) + + jsent := rewriteEntryJSON{} + err := json.NewDecoder(r.Body).Decode(&jsent) + if err != nil { + httpError(w, http.StatusBadRequest, "json.Decode: %s", err) + return + } + + entDel := dnsfilter.RewriteEntry{ + Domain: jsent.Domain, + Answer: jsent.Answer, + } + arr := []dnsfilter.RewriteEntry{} + config.Lock() + for _, ent := range config.DNS.Rewrites { + if ent == entDel { + log.Debug("Rewrites: removed element: %s -> %s", ent.Domain, ent.Answer) + continue + } + arr = append(arr, ent) + } + config.DNS.Rewrites = arr + config.Unlock() + + err = writeAllConfigsAndReloadDNS() + if err != nil { + httpError(w, http.StatusBadRequest, "%s", err) + return + } + + returnOK(w) +} + +func registerRewritesHandlers() { + http.HandleFunc("/control/rewrite/list", postInstall(optionalAuth(ensureGET(handleRewriteList)))) + http.HandleFunc("/control/rewrite/add", postInstall(optionalAuth(ensurePOST(handleRewriteAdd)))) + http.HandleFunc("/control/rewrite/delete", postInstall(optionalAuth(ensurePOST(handleRewriteDelete)))) +} diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index fd472e87..d5ea137d 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -786,6 +786,54 @@ paths: 200: description: OK + # -------------------------------------------------- + # Rewrite methods + # -------------------------------------------------- + + /rewrite/list: + get: + tags: + - rewrite + operationId: rewriteList + summary: 'Get list of Rewrite rules' + responses: + 200: + description: OK + schema: + $ref: "#/definitions/RewriteList" + + /rewrite/add: + post: + tags: + - rewrite + operationId: rewriteAdd + summary: 'Add a new Rewrite rule' + parameters: + - in: "body" + name: "body" + required: true + schema: + $ref: "#/definitions/RewriteEntry" + responses: + 200: + description: OK + + /rewrite/delete: + post: + tags: + - rewrite + operationId: rewriteDelete + summary: 'Remove a Rewrite rule' + parameters: + - in: "body" + name: "body" + required: true + schema: + $ref: "#/definitions/RewriteEntry" + responses: + 200: + description: OK + # -------------------------------------------------- # I18N methods # -------------------------------------------------- @@ -1571,6 +1619,25 @@ definitions: items: $ref: "#/definitions/ClientAuto" description: "Auto-Clients array" + + RewriteList: + type: "array" + items: + $ref: "#/definitions/RewriteEntry" + description: "Rewrite rules array" + RewriteEntry: + type: "object" + description: "Rewrite rule" + properties: + domain: + type: "string" + description: "Domain name" + example: "example.org" + answer: + type: "string" + description: "value of A, AAAA or CNAME DNS record" + example: "127.0.0.1" + CheckConfigRequest: type: "object" description: "Configuration to be checked"