diff --git a/AGHTechDoc.md b/AGHTechDoc.md index 4b2833d7..cfeddd40 100644 --- a/AGHTechDoc.md +++ b/AGHTechDoc.md @@ -42,6 +42,15 @@ Contents: * API: Clear statistics data * API: Set statistics parameters * API: Get statistics parameters +* Query logs + * API: Set querylog parameters + * API: Get querylog parameters + + +## Relations between subsystems + +![](agh-arch.png) + ## First startup @@ -976,3 +985,37 @@ Response: { "interval": 1 | 7 | 30 | 90 } + + +## Query logs + +### API: Set querylog parameters + +Request: + + POST /control/querylog_config + + { + "enabled": true | false + "interval": 1 | 7 | 30 | 90 + } + +Response: + + 200 OK + + +### API: Get querylog parameters + +Request: + + GET /control/querylog_info + +Response: + + 200 OK + + { + "enabled": true | false + "interval": 1 | 7 | 30 | 90 + } diff --git a/agh-arch.png b/agh-arch.png new file mode 100644 index 00000000..a0410075 Binary files /dev/null and b/agh-arch.png differ diff --git a/client/package-lock.json b/client/package-lock.json index 0c596c27..b2070cdb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -5027,11 +5027,6 @@ } } }, - "file-saver": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-1.3.8.tgz", - "integrity": "sha512-spKHSBQIxxS81N/O21WmuXA2F6wppUCsutpzenOeZzOCCJ5gEfcbqJP983IrpLXzYmXnMUa6J03SubcNPdKrlg==" - }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", diff --git a/client/package.json b/client/package.json index 6c5f8e4e..0e8b6d4d 100644 --- a/client/package.json +++ b/client/package.json @@ -13,7 +13,6 @@ "axios": "^0.19.0", "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.15", diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index 119c1828..570d28e1 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -98,7 +98,6 @@ "enforce_safe_search": "Enforce safe search", "enforce_save_search_hint": "AdGuard Home can enforce safe search in the following search engines: Google, Youtube, Bing, DuckDuckGo and Yandex.", "no_servers_specified": "No servers specified", - "no_settings": "No settings", "general_settings": "General settings", "dns_settings": "DNS settings", "encryption_settings": "Encryption settings", @@ -163,10 +162,7 @@ "show_all_filter_type": "Show all", "show_filtered_type": "Show filtered", "no_logs_found": "No logs found", - "disabled_log_btn": "Disable log", - "download_log_file_btn": "Download log file", "refresh_btn": "Refresh", - "enabled_log_btn": "Enable log", "last_dns_queries": "Last 5000 DNS queries", "previous_btn": "Previous", "next_btn": "Next", @@ -177,10 +173,15 @@ "updated_custom_filtering_toast": "Updated the custom filtering rules", "rule_removed_from_custom_filtering_toast": "Rule removed from the custom filtering rules", "rule_added_to_custom_filtering_toast": "Rule added to the custom filtering rules", - "query_log_disabled_toast": "Query log disabled", - "query_log_enabled_toast": "Query log enabled", "query_log_response_status": "Status: {{value}}", "query_log_filtered": "Filtered by {{filter}}", + "query_log_confirm_clear": "Are you sure you want to clear the entire query log? This will also clear statistics on the dashboard.", + "query_log_cleared": "The query log has been successfully cleared", + "query_log_clear": "Clear query logs", + "query_log_retention": "Query logs retention", + "query_log_enable": "Enable log", + "query_log_configuration": "Logs configuration", + "query_log_disabled": "The query log is disabled and can be configured in the <0>settings", "source_label": "Source", "found_in_known_domain_db": "Found in the known domains database.", "category_label": "Category", @@ -372,7 +373,7 @@ "domain": "Domain", "answer": "Answer", "filter_added_successfully": "The filter has been successfully added", - "statistics_logs": "Statistics and logs", + "statistics_configuration": "Statistics configuration", "statistics_retention": "Statistics retention", "statistics_retention_desc": "If you decrease the interval value, some data will be lost", "statistics_clear": " Clear statistics", diff --git a/client/src/actions/access.js b/client/src/actions/access.js index b10062cb..5b5272d7 100644 --- a/client/src/actions/access.js +++ b/client/src/actions/access.js @@ -1,10 +1,8 @@ import { createAction } from 'redux-actions'; -import Api from '../api/Api'; +import apiClient from '../api/Api'; import { addErrorToast, addSuccessToast } from './index'; import { normalizeTextarea } from '../helpers/helpers'; -const apiClient = new Api(); - export const getAccessListRequest = createAction('GET_ACCESS_LIST_REQUEST'); export const getAccessListFailure = createAction('GET_ACCESS_LIST_FAILURE'); export const getAccessListSuccess = createAction('GET_ACCESS_LIST_SUCCESS'); diff --git a/client/src/actions/clients.js b/client/src/actions/clients.js index 6af28871..3974a38c 100644 --- a/client/src/actions/clients.js +++ b/client/src/actions/clients.js @@ -1,11 +1,9 @@ import { createAction } from 'redux-actions'; import { t } from 'i18next'; -import Api from '../api/Api'; +import apiClient from '../api/Api'; import { addErrorToast, addSuccessToast, getClients } from './index'; import { CLIENT_ID } from '../helpers/constants'; -const apiClient = new Api(); - export const toggleClientModal = createAction('TOGGLE_CLIENT_MODAL'); export const addClientRequest = createAction('ADD_CLIENT_REQUEST'); diff --git a/client/src/actions/encryption.js b/client/src/actions/encryption.js index 6d6f3332..be86bd59 100644 --- a/client/src/actions/encryption.js +++ b/client/src/actions/encryption.js @@ -1,10 +1,8 @@ import { createAction } from 'redux-actions'; -import Api from '../api/Api'; +import apiClient from '../api/Api'; import { addErrorToast, addSuccessToast } from './index'; import { redirectToCurrentProtocol } from '../helpers/helpers'; -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'); diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 98d77ca4..8a132060 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -4,12 +4,10 @@ import { showLoading, hideLoading } from 'react-redux-loading-bar'; import axios from 'axios'; import versionCompare from '../helpers/versionCompare'; -import { normalizeFilteringStatus, normalizeLogs, normalizeTextarea, sortClients } from '../helpers/helpers'; +import { normalizeFilteringStatus, normalizeTextarea, sortClients } from '../helpers/helpers'; import { SETTINGS_NAMES, CHECK_TIMEOUT } from '../helpers/constants'; import { getTlsStatus } from './encryption'; -import Api from '../api/Api'; - -const apiClient = new Api(); +import apiClient from '../api/Api'; export const addErrorToast = createAction('ADD_ERROR_TOAST'); export const addSuccessToast = createAction('ADD_SUCCESS_TOAST'); @@ -292,52 +290,6 @@ export const disableDns = () => async (dispatch) => { } }; -export const getLogsRequest = createAction('GET_LOGS_REQUEST'); -export const getLogsFailure = createAction('GET_LOGS_FAILURE'); -export const getLogsSuccess = createAction('GET_LOGS_SUCCESS'); - -export const getLogs = () => async (dispatch, getState) => { - dispatch(getLogsRequest()); - const timer = setInterval(async () => { - const state = getState(); - if (state.dashboard.isCoreRunning) { - clearInterval(timer); - try { - const logs = normalizeLogs(await apiClient.getQueryLog()); - dispatch(getLogsSuccess(logs)); - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(getLogsFailure(error)); - } - } - }, 100); -}; - -export const toggleLogStatusRequest = createAction('TOGGLE_LOGS_REQUEST'); -export const toggleLogStatusFailure = createAction('TOGGLE_LOGS_FAILURE'); -export const toggleLogStatusSuccess = createAction('TOGGLE_LOGS_SUCCESS'); - -export const toggleLogStatus = queryLogEnabled => async (dispatch) => { - dispatch(toggleLogStatusRequest()); - let toggleMethod; - let successMessage; - if (queryLogEnabled) { - toggleMethod = apiClient.disableQueryLog.bind(apiClient); - successMessage = 'query_log_disabled_toast'; - } else { - toggleMethod = apiClient.enableQueryLog.bind(apiClient); - successMessage = 'query_log_enabled_toast'; - } - try { - await toggleMethod(); - dispatch(addSuccessToast(successMessage)); - dispatch(toggleLogStatusSuccess()); - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(toggleLogStatusFailure()); - } -}; - export const setRulesRequest = createAction('SET_RULES_REQUEST'); export const setRulesFailure = createAction('SET_RULES_FAILURE'); export const setRulesSuccess = createAction('SET_RULES_SUCCESS'); @@ -465,23 +417,6 @@ export const removeFilter = url => async (dispatch) => { export const toggleFilteringModal = createAction('FILTERING_MODAL_TOGGLE'); -export const downloadQueryLogRequest = createAction('DOWNLOAD_QUERY_LOG_REQUEST'); -export const downloadQueryLogFailure = createAction('DOWNLOAD_QUERY_LOG_FAILURE'); -export const downloadQueryLogSuccess = createAction('DOWNLOAD_QUERY_LOG_SUCCESS'); - -export const downloadQueryLog = () => async (dispatch) => { - let data; - dispatch(downloadQueryLogRequest()); - try { - data = await apiClient.downloadQueryLog(); - dispatch(downloadQueryLogSuccess()); - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(downloadQueryLogFailure()); - } - return data; -}; - export const handleUpstreamChange = createAction('HANDLE_UPSTREAM_CHANGE'); export const setUpstreamRequest = createAction('SET_UPSTREAM_REQUEST'); export const setUpstreamFailure = createAction('SET_UPSTREAM_FAILURE'); diff --git a/client/src/actions/install.js b/client/src/actions/install.js index 3070ff2d..62983892 100644 --- a/client/src/actions/install.js +++ b/client/src/actions/install.js @@ -1,9 +1,7 @@ import { createAction } from 'redux-actions'; -import Api from '../api/Api'; +import apiClient from '../api/Api'; import { addErrorToast, addSuccessToast } from './index'; -const apiClient = new Api(); - export const nextStep = createAction('NEXT_STEP'); export const prevStep = createAction('PREV_STEP'); diff --git a/client/src/actions/queryLogs.js b/client/src/actions/queryLogs.js new file mode 100644 index 00000000..c68ddf15 --- /dev/null +++ b/client/src/actions/queryLogs.js @@ -0,0 +1,67 @@ +import { createAction } from 'redux-actions'; + +import apiClient from '../api/Api'; +import { addErrorToast, addSuccessToast } from './index'; +import { normalizeLogs } from '../helpers/helpers'; + +export const getLogsRequest = createAction('GET_LOGS_REQUEST'); +export const getLogsFailure = createAction('GET_LOGS_FAILURE'); +export const getLogsSuccess = createAction('GET_LOGS_SUCCESS'); + +export const getLogs = () => async (dispatch) => { + dispatch(getLogsRequest()); + try { + const logs = normalizeLogs(await apiClient.getQueryLog()); + dispatch(getLogsSuccess(logs)); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(getLogsFailure(error)); + } +}; + +export const clearLogsRequest = createAction('CLEAR_LOGS_REQUEST'); +export const clearLogsFailure = createAction('CLEAR_LOGS_FAILURE'); +export const clearLogsSuccess = createAction('CLEAR_LOGS_SUCCESS'); + +export const clearLogs = () => async (dispatch) => { + dispatch(clearLogsRequest()); + try { + await apiClient.clearQueryLog(); + dispatch(clearLogsSuccess()); + dispatch(addSuccessToast('query_log_cleared')); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(clearLogsFailure(error)); + } +}; + +export const getLogsConfigRequest = createAction('GET_LOGS_CONFIG_REQUEST'); +export const getLogsConfigFailure = createAction('GET_LOGS_CONFIG_FAILURE'); +export const getLogsConfigSuccess = createAction('GET_LOGS_CONFIG_SUCCESS'); + +export const getLogsConfig = () => async (dispatch) => { + dispatch(getLogsConfigRequest()); + try { + const data = await apiClient.getQueryLogInfo(); + dispatch(getLogsConfigSuccess(data)); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(getLogsConfigFailure()); + } +}; + +export const setLogsConfigRequest = createAction('SET_LOGS_CONFIG_REQUEST'); +export const setLogsConfigFailure = createAction('SET_LOGS_CONFIG_FAILURE'); +export const setLogsConfigSuccess = createAction('SET_LOGS_CONFIG_SUCCESS'); + +export const setLogsConfig = config => async (dispatch) => { + dispatch(setLogsConfigRequest()); + try { + await apiClient.setQueryLogConfig(config); + dispatch(addSuccessToast('config_successfully_saved')); + dispatch(setLogsConfigSuccess(config)); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(setLogsConfigFailure()); + } +}; diff --git a/client/src/actions/rewrites.js b/client/src/actions/rewrites.js index df846fdd..1ff4a012 100644 --- a/client/src/actions/rewrites.js +++ b/client/src/actions/rewrites.js @@ -1,10 +1,8 @@ import { createAction } from 'redux-actions'; import { t } from 'i18next'; -import Api from '../api/Api'; +import apiClient 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'); diff --git a/client/src/actions/services.js b/client/src/actions/services.js index 7aae500f..c6e478de 100644 --- a/client/src/actions/services.js +++ b/client/src/actions/services.js @@ -1,9 +1,7 @@ import { createAction } from 'redux-actions'; -import Api from '../api/Api'; +import apiClient from '../api/Api'; import { addErrorToast, addSuccessToast } from './index'; -const apiClient = new Api(); - export const getBlockedServicesRequest = createAction('GET_BLOCKED_SERVICES_REQUEST'); export const getBlockedServicesFailure = createAction('GET_BLOCKED_SERVICES_FAILURE'); export const getBlockedServicesSuccess = createAction('GET_BLOCKED_SERVICES_SUCCESS'); diff --git a/client/src/actions/stats.js b/client/src/actions/stats.js index a24b3ec7..d8ab5bf5 100644 --- a/client/src/actions/stats.js +++ b/client/src/actions/stats.js @@ -1,14 +1,12 @@ import { createAction } from 'redux-actions'; -import Api from '../api/Api'; +import apiClient from '../api/Api'; import { addErrorToast, addSuccessToast } from './index'; import { normalizeTopStats, secondsToMilliseconds } from '../helpers/helpers'; -const apiClient = new Api(); - -export const getStatsConfigRequest = createAction('GET_LOGS_CONFIG_REQUEST'); -export const getStatsConfigFailure = createAction('GET_LOGS_CONFIG_FAILURE'); -export const getStatsConfigSuccess = createAction('GET_LOGS_CONFIG_SUCCESS'); +export const getStatsConfigRequest = createAction('GET_STATS_CONFIG_REQUEST'); +export const getStatsConfigFailure = createAction('GET_STATS_CONFIG_FAILURE'); +export const getStatsConfigSuccess = createAction('GET_STATS_CONFIG_SUCCESS'); export const getStatsConfig = () => async (dispatch) => { dispatch(getStatsConfigRequest()); diff --git a/client/src/api/Api.js b/client/src/api/Api.js index 0f63f2ea..f39b28dc 100644 --- a/client/src/api/Api.js +++ b/client/src/api/Api.js @@ -1,6 +1,6 @@ import axios from 'axios'; -export default class Api { +class Api { baseUrl = 'control'; async makeRequest(path, method = 'POST', config) { @@ -25,9 +25,6 @@ export default class Api { GLOBAL_START = { path: 'start', method: 'POST' }; GLOBAL_STATUS = { path: 'status', method: 'GET' }; GLOBAL_STOP = { path: 'stop', method: 'POST' }; - GLOBAL_QUERY_LOG = { path: 'querylog', method: 'GET' }; - GLOBAL_QUERY_LOG_ENABLE = { path: 'querylog_enable', method: 'POST' }; - GLOBAL_QUERY_LOG_DISABLE = { path: 'querylog_disable', method: 'POST' }; GLOBAL_SET_UPSTREAM_DNS = { path: 'set_upstreams_config', method: 'POST' }; GLOBAL_TEST_UPSTREAM_DNS = { path: 'test_upstream_dns', method: 'POST' }; GLOBAL_VERSION = { path: 'version.json', method: 'POST' }; @@ -50,27 +47,6 @@ export default class Api { return this.makeRequest(path, method); } - getQueryLog() { - const { path, method } = this.GLOBAL_QUERY_LOG; - return this.makeRequest(path, method); - } - - downloadQueryLog() { - const { path, method } = this.GLOBAL_QUERY_LOG; - const queryString = '?download=1'; - return this.makeRequest(path + queryString, method); - } - - enableQueryLog() { - const { path, method } = this.GLOBAL_QUERY_LOG_ENABLE; - return this.makeRequest(path, method); - } - - disableQueryLog() { - const { path, method } = this.GLOBAL_QUERY_LOG_DISABLE; - return this.makeRequest(path, method); - } - setUpstream(url) { const { path, method } = this.GLOBAL_SET_UPSTREAM_DNS; const config = { @@ -521,4 +497,37 @@ export default class Api { const { path, method } = this.STATS_RESET; return this.makeRequest(path, method); } + + // Query log + GET_QUERY_LOG = { path: 'querylog', method: 'GET' }; + QUERY_LOG_CONFIG = { path: 'querylog_config', method: 'POST' }; + QUERY_LOG_INFO = { path: 'querylog_info', method: 'GET' }; + QUERY_LOG_CLEAR = { path: 'querylog_clear', method: 'POST' }; + + getQueryLog() { + const { path, method } = this.GET_QUERY_LOG; + return this.makeRequest(path, method); + } + + getQueryLogInfo() { + const { path, method } = this.QUERY_LOG_INFO; + return this.makeRequest(path, method); + } + + setQueryLogConfig(data) { + const { path, method } = this.QUERY_LOG_CONFIG; + const config = { + data, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, config); + } + + clearQueryLog() { + const { path, method } = this.QUERY_LOG_CLEAR; + return this.makeRequest(path, method); + } } + +const apiClient = new Api(); +export default apiClient; diff --git a/client/src/components/Logs/index.js b/client/src/components/Logs/index.js index 5d5471e9..e4f880b0 100644 --- a/client/src/components/Logs/index.js +++ b/client/src/components/Logs/index.js @@ -1,7 +1,6 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import ReactTable from 'react-table'; -import { saveAs } from 'file-saver/FileSaver'; import escapeRegExp from 'lodash/escapeRegExp'; import endsWith from 'lodash/endsWith'; import { Trans, withNamespaces } from 'react-i18next'; @@ -17,7 +16,6 @@ import PopoverFiltered from '../ui/PopoverFilter'; import Popover from '../ui/Popover'; import './Logs.css'; -const DOWNLOAD_LOG_FILENAME = 'dns-logs.txt'; const FILTERED_REASON = 'Filtered'; const RESPONSE_FILTER = { ALL: 'all', @@ -29,18 +27,19 @@ class Logs extends Component { this.getLogs(); this.props.getFilteringStatus(); this.props.getClients(); + this.props.getLogsConfig(); } componentDidUpdate(prevProps) { // get logs when queryLog becomes enabled - if (this.props.dashboard.queryLogEnabled && !prevProps.dashboard.queryLogEnabled) { + if (this.props.queryLogs.enabled && !prevProps.queryLogs.enabled) { this.props.getLogs(); } } getLogs = () => { // get logs on initialization if queryLogIsEnabled - if (this.props.dashboard.queryLogEnabled) { + if (this.props.queryLogs.enabled) { this.props.getLogs(); } }; @@ -155,10 +154,7 @@ class Logs extends Component { } else { const filterItem = Object.keys(filters).filter(key => filters[key].id === filterId)[0]; - if ( - typeof filterItem !== 'undefined' && - typeof filters[filterItem] !== 'undefined' - ) { + if (typeof filterItem !== 'undefined' && typeof filters[filterItem] !== 'undefined') { filterName = filters[filterItem].name; } @@ -255,10 +251,7 @@ class Logs extends Component { if (filter.value === RESPONSE_FILTER.FILTERED) { // eslint-disable-next-line no-underscore-dangle const { reason } = row._original; - return ( - this.checkFiltered(reason) || - this.checkWhiteList(reason) - ); + return this.checkFiltered(reason) || this.checkWhiteList(reason); } return true; }, @@ -347,74 +340,44 @@ class Logs extends Component { return null; } - handleDownloadButton = async (e) => { - e.preventDefault(); - const data = await this.props.downloadQueryLog(); - const jsonStr = JSON.stringify(data); - const dataBlob = new Blob([jsonStr], { type: 'text/plain;charset=utf-8' }); - saveAs(dataBlob, DOWNLOAD_LOG_FILENAME); - }; - - renderButtons(queryLogEnabled, logStatusProcessing) { - if (queryLogEnabled) { - return ( - - - - - - ); - } - - return ( - - ); - } - render() { const { queryLogs, dashboard, t } = this.props; - const { queryLogEnabled } = dashboard; + const { enabled, processingGetLogs, processingGetConfig } = queryLogs; + const { processingClients } = dashboard; + const isDataReady = + !processingGetLogs && !processingGetConfig && !dashboard.processingClients; + + const refreshButton = enabled ? ( + + ) : ( + '' + ); + return ( -
- {this.renderButtons(queryLogEnabled, dashboard.logStatusProcessing)} -
+ {refreshButton}
- {queryLogEnabled && - queryLogs.getLogsProcessing && - dashboard.processingClients && } - {queryLogEnabled && - !queryLogs.getLogsProcessing && - !dashboard.processingClients && - this.renderLogs(queryLogs.logs)} + {enabled && (processingGetLogs || processingClients || processingGetConfig) && ( + + )} + {enabled && isDataReady && this.renderLogs(queryLogs.logs)} + {!enabled && !processingGetConfig && ( +
+ link]}> + query_log_disabled + +
+ )}
); @@ -425,13 +388,12 @@ Logs.propTypes = { getLogs: PropTypes.func.isRequired, queryLogs: PropTypes.object.isRequired, dashboard: PropTypes.object.isRequired, - toggleLogStatus: PropTypes.func.isRequired, - downloadQueryLog: PropTypes.func.isRequired, getFilteringStatus: PropTypes.func.isRequired, filtering: PropTypes.object.isRequired, setRules: PropTypes.func.isRequired, addSuccessToast: PropTypes.func.isRequired, getClients: PropTypes.func.isRequired, + getLogsConfig: PropTypes.func.isRequired, t: PropTypes.func.isRequired, }; diff --git a/client/src/components/Settings/LogsConfig/Form.js b/client/src/components/Settings/LogsConfig/Form.js new file mode 100644 index 00000000..e90c6487 --- /dev/null +++ b/client/src/components/Settings/LogsConfig/Form.js @@ -0,0 +1,82 @@ +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 { renderSelectField, renderRadioField, toNumber } from '../../../helpers/form'; +import { QUERY_LOG_INTERVALS_DAYS } from '../../../helpers/constants'; + +const getIntervalFields = (processing, t, handleChange, toNumber) => + QUERY_LOG_INTERVALS_DAYS.map((interval) => { + const title = + interval === 1 ? t('interval_24_hour') : t('interval_days', { count: interval }); + + return ( + + ); + }); + +const Form = (props) => { + const { + handleSubmit, handleChange, processing, t, + } = props; + + return ( +
+
+
+
+ +
+
+
+ +
+
+
+
+ {getIntervalFields(processing, t, handleChange, toNumber)} +
+
+
+
+
+ ); +}; + +Form.propTypes = { + handleSubmit: PropTypes.func.isRequired, + handleChange: PropTypes.func, + change: PropTypes.func.isRequired, + submitting: PropTypes.bool.isRequired, + invalid: PropTypes.bool.isRequired, + processing: PropTypes.bool.isRequired, + t: PropTypes.func.isRequired, +}; + +export default flow([ + withNamespaces(), + reduxForm({ + form: 'logConfigForm', + }), +])(Form); diff --git a/client/src/components/Settings/LogsConfig/index.js b/client/src/components/Settings/LogsConfig/index.js new file mode 100644 index 00000000..8b519827 --- /dev/null +++ b/client/src/components/Settings/LogsConfig/index.js @@ -0,0 +1,69 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withNamespaces, Trans } from 'react-i18next'; +import debounce from 'lodash/debounce'; + +import { DEBOUNCE_TIMEOUT } from '../../../helpers/constants'; +import Card from '../../ui/Card'; +import Form from './Form'; + +class LogsConfig extends Component { + handleFormChange = debounce((values) => { + this.props.setLogsConfig(values); + }, DEBOUNCE_TIMEOUT); + + handleClear = () => { + const { t, clearLogs } = this.props; + // eslint-disable-next-line no-alert + if (window.confirm(t('query_log_confirm_clear'))) { + clearLogs(); + } + }; + + render() { + const { + t, enabled, interval, processing, processingClear, + } = this.props; + + return ( + +
+
+ + +
+
+ ); + } +} + +LogsConfig.propTypes = { + interval: PropTypes.number.isRequired, + enabled: PropTypes.bool.isRequired, + processing: PropTypes.bool.isRequired, + processingClear: PropTypes.bool.isRequired, + setLogsConfig: PropTypes.func.isRequired, + clearLogs: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, +}; + +export default withNamespaces()(LogsConfig); diff --git a/client/src/components/Settings/StatsConfig/Form.js b/client/src/components/Settings/StatsConfig/Form.js index 463476c9..7da5286f 100644 --- a/client/src/components/Settings/StatsConfig/Form.js +++ b/client/src/components/Settings/StatsConfig/Form.js @@ -9,9 +9,8 @@ import { STATS_INTERVALS_DAYS } from '../../../helpers/constants'; const getIntervalFields = (processing, t, handleChange, toNumber) => STATS_INTERVALS_DAYS.map((interval) => { - const title = interval === 1 - ? t('interval_24_hour') - : t('interval_days', { count: interval }); + const title = + interval === 1 ? t('interval_24_hour') : t('interval_days', { count: interval }); return ( {
-
-
+
{getIntervalFields(processing, t, handleChange, toNumber)}
@@ -69,6 +68,6 @@ Form.propTypes = { export default flow([ withNamespaces(), reduxForm({ - form: 'logConfigForm', + form: 'statsConfigForm', }), ])(Form); diff --git a/client/src/components/Settings/StatsConfig/index.js b/client/src/components/Settings/StatsConfig/index.js index b649c6c9..86c23b38 100644 --- a/client/src/components/Settings/StatsConfig/index.js +++ b/client/src/components/Settings/StatsConfig/index.js @@ -4,8 +4,8 @@ import { withNamespaces, Trans } from 'react-i18next'; import debounce from 'lodash/debounce'; import { DEBOUNCE_TIMEOUT } from '../../../helpers/constants'; -import Form from './Form'; import Card from '../../ui/Card'; +import Form from './Form'; class StatsConfig extends Component { handleFormChange = debounce((values) => { @@ -26,7 +26,7 @@ class StatsConfig extends Component { } = this.props; return ( - +
diff --git a/client/src/components/Settings/index.js b/client/src/components/Settings/index.js index 5f7f2fd6..2ca90782 100644 --- a/client/src/components/Settings/index.js +++ b/client/src/components/Settings/index.js @@ -1,9 +1,10 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; -import { withNamespaces, Trans } from 'react-i18next'; +import { withNamespaces } from 'react-i18next'; import Services from './Services'; import StatsConfig from './StatsConfig'; +import LogsConfig from './LogsConfig'; import Checkbox from '../ui/Checkbox'; import Loading from '../ui/Loading'; import PageTitle from '../ui/PageTitle'; @@ -39,6 +40,7 @@ class Settings extends Component { this.props.initSettings(this.settings); this.props.getBlockedServices(); this.props.getStatsConfig(); + this.props.getLogsConfig(); } renderSettings = (settings) => { @@ -55,11 +57,7 @@ class Settings extends Component { ); }); } - return ( -
- no_settings -
- ); + return ''; }; render() { @@ -70,13 +68,23 @@ class Settings extends Component { setStatsConfig, resetStats, stats, + queryLogs, + setLogsConfig, + clearLogs, t, } = this.props; + + const isDataReady = + !settings.processing && + !services.processing && + !stats.processingGetConfig && + !queryLogs.processingGetConfig; + return ( - {settings.processing && } - {!settings.processing && ( + {!isDataReady && } + {isDataReady && (
@@ -95,6 +103,16 @@ class Settings extends Component { resetStats={resetStats} />
+
+ +
{ @@ -10,12 +11,11 @@ const mapStateToProps = (state) => { const mapDispatchToProps = { getLogs, - toggleLogStatus, - downloadQueryLog, getFilteringStatus, setRules, addSuccessToast, getClients, + getLogsConfig, }; export default connect( diff --git a/client/src/containers/Settings.js b/client/src/containers/Settings.js index 14f08cfc..b78e140d 100644 --- a/client/src/containers/Settings.js +++ b/client/src/containers/Settings.js @@ -2,14 +2,18 @@ import { connect } from 'react-redux'; import { initSettings, toggleSetting } from '../actions'; import { getBlockedServices, setBlockedServices } from '../actions/services'; import { getStatsConfig, setStatsConfig, resetStats } from '../actions/stats'; +import { clearLogs, getLogsConfig, setLogsConfig } from '../actions/queryLogs'; import Settings from '../components/Settings'; const mapStateToProps = (state) => { - const { settings, services, stats } = state; + const { + settings, services, stats, queryLogs, + } = state; const props = { settings, services, stats, + queryLogs, }; return props; }; @@ -22,6 +26,9 @@ const mapDispatchToProps = { getStatsConfig, setStatsConfig, resetStats, + clearLogs, + getLogsConfig, + setLogsConfig, }; export default connect( diff --git a/client/src/helpers/constants.js b/client/src/helpers/constants.js index 3551a41a..6001293a 100644 --- a/client/src/helpers/constants.js +++ b/client/src/helpers/constants.js @@ -263,3 +263,5 @@ export const FILTERED_STATUS = { }; export const STATS_INTERVALS_DAYS = [1, 7, 30, 90]; + +export const QUERY_LOG_INTERVALS_DAYS = [1, 7, 30, 90]; diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index 2af571b2..49e7729f 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -12,6 +12,7 @@ import access from './access'; import rewrites from './rewrites'; import services from './services'; import stats from './stats'; +import queryLogs from './queryLogs'; const settings = handleActions({ [actions.initSettingsRequest]: state => ({ ...state, processing: true }), @@ -43,6 +44,7 @@ const settings = handleActions({ processingTestUpstream: false, processingSetUpstream: false, processingDhcpStatus: false, + settingsList: {}, }); const dashboard = handleActions({ @@ -54,7 +56,6 @@ const dashboard = handleActions({ running, dns_port: dnsPort, dns_addresses: dnsAddresses, - querylog_enabled: queryLogEnabled, upstream_dns: upstreamDns, bootstrap_dns: bootstrapDns, all_servers: allServers, @@ -69,7 +70,6 @@ const dashboard = handleActions({ dnsVersion: version, dnsPort, dnsAddresses, - queryLogEnabled, upstreamDns: upstreamDns.join('\n'), bootstrapDns: bootstrapDns.join('\n'), allServers, @@ -94,13 +94,6 @@ const dashboard = handleActions({ return newState; }, - [actions.toggleLogStatusRequest]: state => ({ ...state, logStatusProcessing: true }), - [actions.toggleLogStatusFailure]: state => ({ ...state, logStatusProcessing: false }), - [actions.toggleLogStatusSuccess]: (state) => { - const { queryLogEnabled } = state; - return ({ ...state, queryLogEnabled: !queryLogEnabled, logStatusProcessing: false }); - }, - [actions.getVersionRequest]: state => ({ ...state, processingVersion: true }), [actions.getVersionFailure]: state => ({ ...state, processingVersion: false }), [actions.getVersionSuccess]: (state, { payload }) => { @@ -179,7 +172,6 @@ const dashboard = handleActions({ }, { processing: true, isCoreRunning: false, - logStatusProcessing: false, processingVersion: true, processingFiltering: true, processingClients: true, @@ -197,22 +189,6 @@ const dashboard = handleActions({ autoClients: [], }); -const queryLogs = handleActions({ - [actions.getLogsRequest]: state => ({ ...state, getLogsProcessing: true }), - [actions.getLogsFailure]: state => ({ ...state, getLogsProcessing: false }), - [actions.getLogsSuccess]: (state, { payload }) => { - const newState = { ...state, logs: payload, getLogsProcessing: false }; - return newState; - }, - [actions.downloadQueryLogRequest]: state => ({ ...state, logsDownloading: true }), - [actions.downloadQueryLogFailure]: state => ({ ...state, logsDownloading: false }), - [actions.downloadQueryLogSuccess]: state => ({ ...state, logsDownloading: false }), -}, { - getLogsProcessing: false, - logsDownloading: false, - logs: [], -}); - const filtering = handleActions({ [actions.setRulesRequest]: state => ({ ...state, processingRules: true }), [actions.setRulesFailure]: state => ({ ...state, processingRules: false }), diff --git a/client/src/reducers/queryLogs.js b/client/src/reducers/queryLogs.js new file mode 100644 index 00000000..3dfbadff --- /dev/null +++ b/client/src/reducers/queryLogs.js @@ -0,0 +1,49 @@ +import { handleActions } from 'redux-actions'; + +import * as actions from '../actions/queryLogs'; + +const queryLogs = handleActions( + { + [actions.getLogsRequest]: state => ({ ...state, processingGetLogs: true }), + [actions.getLogsFailure]: state => ({ ...state, processingGetLogs: false }), + [actions.getLogsSuccess]: (state, { payload }) => { + const newState = { ...state, logs: payload, processingGetLogs: false }; + return newState; + }, + + [actions.clearLogsRequest]: state => ({ ...state, processingClear: true }), + [actions.clearLogsFailure]: state => ({ ...state, processingClear: false }), + [actions.clearLogsSuccess]: state => ({ + ...state, + logs: [], + processingClear: false, + }), + + [actions.getLogsConfigRequest]: state => ({ ...state, processingGetConfig: true }), + [actions.getLogsConfigFailure]: state => ({ ...state, processingGetConfig: false }), + [actions.getLogsConfigSuccess]: (state, { payload }) => ({ + ...state, + ...payload, + processingGetConfig: false, + }), + + [actions.setLogsConfigRequest]: state => ({ ...state, processingSetConfig: true }), + [actions.setLogsConfigFailure]: state => ({ ...state, processingSetConfig: false }), + [actions.setLogsConfigSuccess]: (state, { payload }) => ({ + ...state, + ...payload, + processingSetConfig: false, + }), + }, + { + processingGetLogs: true, + processingClear: false, + processingGetConfig: false, + processingSetConfig: false, + logs: [], + interval: 1, + enabled: true, + }, +); + +export default queryLogs; diff --git a/dnsforward/dnsforward.go b/dnsforward/dnsforward.go index 0fffd0a5..ee1c1e0d 100644 --- a/dnsforward/dnsforward.go +++ b/dnsforward/dnsforward.go @@ -11,6 +11,7 @@ import ( "time" "github.com/AdguardTeam/AdGuardHome/dnsfilter" + "github.com/AdguardTeam/AdGuardHome/querylog" "github.com/AdguardTeam/AdGuardHome/stats" "github.com/AdguardTeam/dnsproxy/proxy" "github.com/AdguardTeam/dnsproxy/upstream" @@ -40,7 +41,7 @@ const ( type Server struct { dnsProxy *proxy.Proxy // DNS proxy instance dnsFilter *dnsfilter.Dnsfilter // DNS filter instance - queryLog *queryLog // Query log instance + queryLog querylog.QueryLog // Query log instance stats stats.Stats AllowedClients map[string]bool // IP addresses of whitelist clients @@ -54,16 +55,11 @@ type Server struct { } // NewServer creates a new instance of the dnsforward.Server -// baseDir is the base directory for query logs // Note: this function must be called only once -func NewServer(baseDir string, stats stats.Stats) *Server { - s := &Server{ - queryLog: newQueryLog(baseDir), - } +func NewServer(stats stats.Stats, queryLog querylog.QueryLog) *Server { + s := &Server{} s.stats = stats - - log.Printf("Start DNS server periodic jobs") - go s.queryLog.periodicQueryLogRotate() + s.queryLog = queryLog return s } @@ -75,6 +71,7 @@ type FilteringConfig struct { BlockingMode string `yaml:"blocking_mode"` // mode how to answer filtered requests BlockedResponseTTL uint32 `yaml:"blocked_response_ttl"` // if 0, then default is used (3600) QueryLogEnabled bool `yaml:"querylog_enabled"` // if true, query log is enabled + QueryLogInterval uint32 `yaml:"querylog_interval"` // time interval for query log (in days) Ratelimit int `yaml:"ratelimit"` // max number of requests per second from a given IP (0 to disable) RatelimitWhitelist []string `yaml:"ratelimit_whitelist"` // a list of whitelisted client IP addresses RefuseAny bool `yaml:"refuse_any"` // if true, refuse ANY requests @@ -303,8 +300,7 @@ func (s *Server) stopInternal() error { s.dnsFilter = nil } - // flush remainder to file - return s.queryLog.flushLogBuffer(true) + return nil } // IsRunning returns true if the DNS server is running @@ -343,13 +339,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.RUnlock() } -// GetQueryLog returns a map with the current query log ready to be converted to a JSON -func (s *Server) GetQueryLog() []map[string]interface{} { - s.RLock() - defer s.RUnlock() - return s.queryLog.getQueryLog() -} - // Return TRUE if this client should be blocked func (s *Server) isBlockedIP(ip string) bool { if len(s.AllowedClients) != 0 || len(s.AllowedClientsIPNet) != 0 { @@ -469,12 +458,12 @@ func (s *Server) handleDNSRequest(p *proxy.Proxy, d *proxy.DNSContext) error { } elapsed := time.Since(start) - if s.conf.QueryLogEnabled && shouldLog { + if s.conf.QueryLogEnabled && shouldLog && s.queryLog != nil { upstreamAddr := "" if d.Upstream != nil { upstreamAddr = d.Upstream.Address() } - _ = s.queryLog.logRequest(msg, d.Res, res, elapsed, d.Addr, upstreamAddr) + s.queryLog.Add(msg, d.Res, res, elapsed, d.Addr, upstreamAddr) } s.updateStats(d, elapsed, *res) diff --git a/dnsforward/dnsforward_test.go b/dnsforward/dnsforward_test.go index 740a43b8..92a1e01b 100644 --- a/dnsforward/dnsforward_test.go +++ b/dnsforward/dnsforward_test.go @@ -10,7 +10,6 @@ import ( "encoding/pem" "math/big" "net" - "os" "sync" "testing" "time" @@ -18,18 +17,15 @@ import ( "github.com/AdguardTeam/AdGuardHome/dnsfilter" "github.com/AdguardTeam/dnsproxy/proxy" "github.com/miekg/dns" - "github.com/stretchr/testify/assert" ) const ( tlsServerName = "testdns.adguard.com" - dataDir = "testData" testMessagesCount = 10 ) func TestServer(t *testing.T) { s := createTestServer(t) - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -45,10 +41,6 @@ func TestServer(t *testing.T) { } assertGoogleAResponse(t, reply) - // check query log and stats - log := s.GetQueryLog() - assert.Equal(t, 1, len(log), "Log size") - // message over TCP req = createGoogleATestMessage() addr = s.dnsProxy.Addr("tcp") @@ -59,10 +51,6 @@ func TestServer(t *testing.T) { } assertGoogleAResponse(t, reply) - // check query log and stats again - log = s.GetQueryLog() - assert.Equal(t, 2, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -72,7 +60,6 @@ func TestServer(t *testing.T) { func TestServerWithProtectionDisabled(t *testing.T) { s := createTestServer(t) s.conf.ProtectionEnabled = false - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -88,10 +75,6 @@ func TestServerWithProtectionDisabled(t *testing.T) { } assertGoogleAResponse(t, reply) - // check query log and stats - log := s.GetQueryLog() - assert.Equal(t, 1, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -102,7 +85,6 @@ func TestDotServer(t *testing.T) { // Prepare the proxy server _, certPem, keyPem := createServerTLSConfig(t) s := createTestServer(t) - defer removeDataDir(t) s.conf.TLSConfig = TLSConfig{ TLSListenAddr: &net.TCPAddr{Port: 0}, @@ -143,7 +125,6 @@ func TestDotServer(t *testing.T) { func TestServerRace(t *testing.T) { s := createTestServer(t) - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -168,7 +149,6 @@ func TestServerRace(t *testing.T) { func TestSafeSearch(t *testing.T) { s := createTestServer(t) s.conf.SafeSearchEnabled = true - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -210,7 +190,6 @@ func TestSafeSearch(t *testing.T) { func TestInvalidRequest(t *testing.T) { s := createTestServer(t) - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -229,11 +208,6 @@ func TestInvalidRequest(t *testing.T) { t.Fatalf("got a response to an invalid query") } - // check query log and stats - // invalid requests aren't written to the query log - log := s.GetQueryLog() - assert.Equal(t, 0, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -242,7 +216,6 @@ func TestInvalidRequest(t *testing.T) { func TestBlockedRequest(t *testing.T) { s := createTestServer(t) - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -267,10 +240,6 @@ func TestBlockedRequest(t *testing.T) { t.Fatalf("Wrong response: %s", reply.String()) } - // check query log and stats - log := s.GetQueryLog() - assert.Equal(t, 1, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -280,7 +249,6 @@ func TestBlockedRequest(t *testing.T) { func TestNullBlockedRequest(t *testing.T) { s := createTestServer(t) s.conf.FilteringConfig.BlockingMode = "null_ip" - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -312,10 +280,6 @@ func TestNullBlockedRequest(t *testing.T) { t.Fatalf("DNS server %s returned wrong answer type instead of A: %v", addr, reply.Answer[0]) } - // check query log and stats - log := s.GetQueryLog() - assert.Equal(t, 1, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -324,7 +288,6 @@ func TestNullBlockedRequest(t *testing.T) { func TestBlockedByHosts(t *testing.T) { s := createTestServer(t) - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -356,10 +319,6 @@ func TestBlockedByHosts(t *testing.T) { t.Fatalf("DNS server %s returned wrong answer type instead of A: %v", addr, reply.Answer[0]) } - // check query log and stats - log := s.GetQueryLog() - assert.Equal(t, 1, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -368,7 +327,6 @@ func TestBlockedByHosts(t *testing.T) { func TestBlockedBySafeBrowsing(t *testing.T) { s := createTestServer(t) - defer removeDataDir(t) err := s.Start(nil) if err != nil { t.Fatalf("Failed to start server: %s", err) @@ -411,10 +369,6 @@ func TestBlockedBySafeBrowsing(t *testing.T) { t.Fatalf("DNS server %s returned wrong answer type instead of A: %v", addr, reply.Answer[0]) } - // check query log and stats - log := s.GetQueryLog() - assert.Equal(t, 1, len(log), "Log size") - err = s.Stop() if err != nil { t.Fatalf("DNS server failed to stop: %s", err) @@ -422,7 +376,7 @@ func TestBlockedBySafeBrowsing(t *testing.T) { } func createTestServer(t *testing.T) *Server { - s := NewServer(createDataDir(t), nil) + s := NewServer(nil, nil) s.conf.UDPListenAddr = &net.UDPAddr{Port: 0} s.conf.TCPListenAddr = &net.TCPAddr{Port: 0} @@ -489,21 +443,6 @@ func createServerTLSConfig(t *testing.T) (*tls.Config, []byte, []byte) { return &tls.Config{Certificates: []tls.Certificate{cert}, ServerName: tlsServerName, MinVersion: tls.VersionTLS12}, certPem, keyPem } -func createDataDir(t *testing.T) string { - err := os.MkdirAll(dataDir, 0755) - if err != nil { - t.Fatalf("Cannot create %s: %s", dataDir, err) - } - return dataDir -} - -func removeDataDir(t *testing.T) { - err := os.RemoveAll(dataDir) - if err != nil { - t.Fatalf("Cannot remove %s: %s", dataDir, err) - } -} - func sendTestMessageAsync(t *testing.T, conn *dns.Conn, g *sync.WaitGroup) { defer func() { g.Done() @@ -607,7 +546,6 @@ func TestIsBlockedIPAllowed(t *testing.T) { s.conf.AllowedClients = []string{"1.1.1.1", "2.2.0.0/16"} err := s.Start(nil) - defer removeDataDir(t) if err != nil { t.Fatalf("Failed to start server: %s", err) } @@ -631,7 +569,6 @@ func TestIsBlockedIPDisallowed(t *testing.T) { s.conf.DisallowedClients = []string{"1.1.1.1", "2.2.0.0/16"} err := s.Start(nil) - defer removeDataDir(t) if err != nil { t.Fatalf("Failed to start server: %s", err) } @@ -655,7 +592,6 @@ func TestIsBlockedIPBlockedDomain(t *testing.T) { s.conf.BlockedHosts = []string{"host1", "host2"} err := s.Start(nil) - defer removeDataDir(t) if err != nil { t.Fatalf("Failed to start server: %s", err) } diff --git a/home/config.go b/home/config.go index 4edc168f..9323b1ce 100644 --- a/home/config.go +++ b/home/config.go @@ -12,6 +12,7 @@ import ( "github.com/AdguardTeam/AdGuardHome/dhcpd" "github.com/AdguardTeam/AdGuardHome/dnsfilter" "github.com/AdguardTeam/AdGuardHome/dnsforward" + "github.com/AdguardTeam/AdGuardHome/querylog" "github.com/AdguardTeam/AdGuardHome/stats" "github.com/AdguardTeam/golibs/file" "github.com/AdguardTeam/golibs/log" @@ -70,6 +71,7 @@ type configuration struct { transport *http.Transport client *http.Client stats stats.Stats + queryLog querylog.QueryLog // cached version.json to avoid hammering github.io for each page reload versionCheckJSON []byte @@ -175,6 +177,7 @@ var config = configuration{ BlockingMode: "nxdomain", // mode how to answer filtered requests BlockedResponseTTL: 10, // in seconds QueryLogEnabled: true, + QueryLogInterval: 1, Ratelimit: 20, RefuseAny: true, BootstrapDNS: defaultBootstrap, @@ -274,6 +277,10 @@ func parseConfig() error { config.DNS.StatsInterval = 1 } + if !checkQueryLogInterval(config.DNS.QueryLogInterval) { + config.DNS.QueryLogInterval = 1 + } + for _, cy := range config.Clients { cli := Client{ Name: cy.Name, diff --git a/home/control.go b/home/control.go index 5ac71d6d..27187626 100644 --- a/home/control.go +++ b/home/control.go @@ -146,35 +146,6 @@ func handleProtectionDisable(w http.ResponseWriter, r *http.Request) { httpUpdateConfigReloadDNSReturnOK(w, r) } -// ----- -// stats -// ----- -func handleQueryLogEnable(w http.ResponseWriter, r *http.Request) { - config.DNS.QueryLogEnabled = true - httpUpdateConfigReloadDNSReturnOK(w, r) -} - -func handleQueryLogDisable(w http.ResponseWriter, r *http.Request) { - config.DNS.QueryLogEnabled = false - httpUpdateConfigReloadDNSReturnOK(w, r) -} - -func handleQueryLog(w http.ResponseWriter, r *http.Request) { - data := config.dnsServer.GetQueryLog() - - jsonVal, err := json.Marshal(data) - if err != nil { - httpError(w, http.StatusInternalServerError, "Couldn't marshal data into json: %s", err) - return - } - - w.Header().Set("Content-Type", "application/json") - _, err = w.Write(jsonVal) - if err != nil { - httpError(w, http.StatusInternalServerError, "Unable to write response json: %s", err) - } -} - // ----------------------- // upstreams configuration // ----------------------- @@ -570,9 +541,6 @@ func registerControlHandlers() { httpRegister(http.MethodGet, "/control/status", handleStatus) httpRegister(http.MethodPost, "/control/enable_protection", handleProtectionEnable) httpRegister(http.MethodPost, "/control/disable_protection", handleProtectionDisable) - httpRegister(http.MethodGet, "/control/querylog", handleQueryLog) - httpRegister(http.MethodPost, "/control/querylog_enable", handleQueryLogEnable) - httpRegister(http.MethodPost, "/control/querylog_disable", handleQueryLogDisable) httpRegister(http.MethodPost, "/control/set_upstreams_config", handleSetUpstreamConfig) httpRegister(http.MethodPost, "/control/test_upstream_dns", handleTestUpstreamDNS) httpRegister(http.MethodPost, "/control/i18n/change_language", handleI18nChangeLanguage) @@ -611,6 +579,7 @@ func registerControlHandlers() { RegisterClientsHandlers() registerRewritesHandlers() RegisterBlockedServicesHandlers() + RegisterQueryLogHandlers() RegisterStatsHandlers() http.HandleFunc("/dns-query", postInstall(handleDOH)) diff --git a/home/control_querylog.go b/home/control_querylog.go new file mode 100644 index 00000000..43ac3869 --- /dev/null +++ b/home/control_querylog.go @@ -0,0 +1,91 @@ +package home + +import ( + "encoding/json" + "net/http" + + "github.com/AdguardTeam/AdGuardHome/querylog" +) + +func handleQueryLog(w http.ResponseWriter, r *http.Request) { + data := config.queryLog.GetData() + + jsonVal, err := json.Marshal(data) + if err != nil { + httpError(w, http.StatusInternalServerError, "Couldn't marshal data into json: %s", err) + return + } + + w.Header().Set("Content-Type", "application/json") + _, err = w.Write(jsonVal) + if err != nil { + httpError(w, http.StatusInternalServerError, "Unable to write response json: %s", err) + } +} + +func handleQueryLogClear(w http.ResponseWriter, r *http.Request) { + config.queryLog.Clear() + returnOK(w) +} + +type qlogConfig struct { + Enabled bool `json:"enabled"` + Interval uint32 `json:"interval"` +} + +// Get configuration +func handleQueryLogInfo(w http.ResponseWriter, r *http.Request) { + resp := qlogConfig{} + resp.Enabled = config.DNS.QueryLogEnabled + resp.Interval = config.DNS.QueryLogInterval + + jsonVal, err := json.Marshal(resp) + if err != nil { + httpError(w, http.StatusInternalServerError, "json encode: %s", err) + return + } + w.Header().Set("Content-Type", "application/json") + _, err = w.Write(jsonVal) + if err != nil { + httpError(w, http.StatusInternalServerError, "http write: %s", err) + } +} + +// Set configuration +func handleQueryLogConfig(w http.ResponseWriter, r *http.Request) { + + reqData := qlogConfig{} + err := json.NewDecoder(r.Body).Decode(&reqData) + if err != nil { + httpError(w, http.StatusBadRequest, "json decode: %s", err) + return + } + + if !checkQueryLogInterval(reqData.Interval) { + httpError(w, http.StatusBadRequest, "Unsupported interval") + return + } + + config.DNS.QueryLogEnabled = reqData.Enabled + config.DNS.QueryLogInterval = reqData.Interval + _ = config.write() + + conf := querylog.Config{ + Interval: config.DNS.QueryLogInterval * 24, + } + config.queryLog.Configure(conf) + + returnOK(w) +} + +func checkQueryLogInterval(i uint32) bool { + return i == 1 || i == 7 || i == 30 || i == 90 +} + +// RegisterQueryLogHandlers - register handlers +func RegisterQueryLogHandlers() { + httpRegister(http.MethodGet, "/control/querylog", handleQueryLog) + httpRegister(http.MethodGet, "/control/querylog_info", handleQueryLogInfo) + httpRegister(http.MethodPost, "/control/querylog_clear", handleQueryLogClear) + httpRegister(http.MethodPost, "/control/querylog_config", handleQueryLogConfig) +} diff --git a/home/dns.go b/home/dns.go index b1d1b0ca..53958bd5 100644 --- a/home/dns.go +++ b/home/dns.go @@ -9,6 +9,7 @@ import ( "github.com/AdguardTeam/AdGuardHome/dnsfilter" "github.com/AdguardTeam/AdGuardHome/dnsforward" + "github.com/AdguardTeam/AdGuardHome/querylog" "github.com/AdguardTeam/AdGuardHome/stats" "github.com/AdguardTeam/dnsproxy/proxy" "github.com/AdguardTeam/dnsproxy/upstream" @@ -40,7 +41,12 @@ func initDNSServer(baseDir string) { if err != nil { log.Fatal("Couldn't initialize statistics module") } - config.dnsServer = dnsforward.NewServer(baseDir, config.stats) + conf := querylog.Config{ + BaseDir: baseDir, + Interval: config.DNS.QueryLogInterval * 24, + } + config.queryLog = querylog.New(conf) + config.dnsServer = dnsforward.NewServer(config.stats, config.queryLog) initRDNS() } @@ -186,6 +192,7 @@ func stopDNSServer() error { } config.stats.Close() + config.queryLog.Close() return nil } diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index f7f56379..6542ee02 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -191,21 +191,42 @@ paths: description: OK schema: $ref: '#/definitions/QueryLog' - /querylog_enable: - post: + + /querylog_info: + get: tags: - log - operationId: querylogEnable - summary: 'Enable querylog' + operationId: queryLogInfo + summary: 'Get query log parameters' responses: 200: description: OK - /querylog_disable: + schema: + $ref: "#/definitions/QueryLogConfig" + + /querylog_config: post: tags: - log - operationId: querylogDisable - summary: 'Disable filtering' + operationId: queryLogConfig + summary: "Set query log parameters" + consumes: + - application/json + parameters: + - in: "body" + name: "body" + schema: + $ref: "#/definitions/QueryLogConfig" + responses: + 200: + description: OK + + /querylog_clear: + post: + tags: + - log + operationId: querylogClear + summary: 'Clear query log' responses: 200: description: OK @@ -244,6 +265,7 @@ paths: summary: 'Get statistics parameters' responses: 200: + description: OK schema: $ref: "#/definitions/StatsConfig" description: OK @@ -1360,6 +1382,18 @@ definitions: description: "Query log" items: $ref: "#/definitions/QueryLogItem" + + QueryLogConfig: + type: "object" + description: "Query log configuration" + properties: + enabled: + type: "boolean" + description: "Is query log enabled" + interval: + type: "integer" + description: "Time period to keep data (1 | 7 | 30 | 90)" + TlsConfig: type: "object" description: "TLS configuration settings and status" diff --git a/dnsforward/querylog.go b/querylog/qlog.go similarity index 72% rename from dnsforward/querylog.go rename to querylog/qlog.go index fbbeb7f2..cfc0604c 100644 --- a/dnsforward/querylog.go +++ b/querylog/qlog.go @@ -1,8 +1,9 @@ -package dnsforward +package querylog import ( "fmt" "net" + "os" "path/filepath" "strconv" "strings" @@ -15,16 +16,14 @@ import ( ) const ( - logBufferCap = 5000 // maximum capacity of logBuffer before it's flushed to disk - queryLogTimeLimit = time.Hour * 24 // how far in the past we care about querylogs - queryLogRotationPeriod = time.Hour * 24 // rotate the log every 24 hours - queryLogFileName = "querylog.json" // .gz added during compression - queryLogSize = 5000 // maximum API response for /querylog - queryLogTopSize = 500 // Keep in memory only top N values + logBufferCap = 5000 // maximum capacity of logBuffer before it's flushed to disk + queryLogFileName = "querylog.json" // .gz added during compression + queryLogSize = 5000 // maximum API response for /querylog ) // queryLog is a structure that writes and reads the DNS query log type queryLog struct { + conf Config logFile string // path to the log file logBufferLock sync.RWMutex @@ -32,16 +31,53 @@ type queryLog struct { fileFlushLock sync.Mutex // synchronize a file-flushing goroutine and main thread flushPending bool // don't start another goroutine while the previous one is still running - queryLogCache []*logEntry - queryLogLock sync.RWMutex + cache []*logEntry + lock sync.RWMutex } // newQueryLog creates a new instance of the query log -func newQueryLog(baseDir string) *queryLog { - l := &queryLog{ - logFile: filepath.Join(baseDir, queryLogFileName), +func newQueryLog(conf Config) *queryLog { + l := queryLog{} + l.logFile = filepath.Join(conf.BaseDir, queryLogFileName) + l.conf = conf + go l.periodicQueryLogRotate() + go l.fillFromFile() + return &l +} + +func (l *queryLog) Close() { + _ = l.flushLogBuffer(true) +} + +func (l *queryLog) Configure(conf Config) { + l.conf = conf +} + +// Clear memory buffer and remove the file +func (l *queryLog) Clear() { + l.fileFlushLock.Lock() + defer l.fileFlushLock.Unlock() + + l.logBufferLock.Lock() + l.logBuffer = nil + l.flushPending = false + l.logBufferLock.Unlock() + + l.lock.Lock() + l.cache = nil + l.lock.Unlock() + + err := os.Remove(l.logFile + ".1") + if err != nil { + log.Error("file remove: %s: %s", l.logFile+".1", err) } - return l + + err = os.Remove(l.logFile) + if err != nil { + log.Error("file remove: %s: %s", l.logFile, err) + } + + log.Debug("Query log: cleared") } type logEntry struct { @@ -54,17 +90,28 @@ type logEntry struct { Upstream string `json:",omitempty"` // if empty, means it was cached } -func (l *queryLog) logRequest(question *dns.Msg, answer *dns.Msg, result *dnsfilter.Result, elapsed time.Duration, addr net.Addr, upstream string) *logEntry { +// getIPString is a helper function that extracts IP address from net.Addr +func getIPString(addr net.Addr) string { + switch addr := addr.(type) { + case *net.UDPAddr: + return addr.IP.String() + case *net.TCPAddr: + return addr.IP.String() + } + return "" +} + +func (l *queryLog) Add(question *dns.Msg, answer *dns.Msg, result *dnsfilter.Result, elapsed time.Duration, addr net.Addr, upstream string) { var q []byte var a []byte var err error - ip := GetIPString(addr) + ip := getIPString(addr) if question != nil { q, err = question.Pack() if err != nil { log.Printf("failed to pack question for querylog: %s", err) - return nil + return } } @@ -72,7 +119,7 @@ func (l *queryLog) logRequest(question *dns.Msg, answer *dns.Msg, result *dnsfil a, err = answer.Pack() if err != nil { log.Printf("failed to pack answer for querylog: %s", err) - return nil + return } } @@ -101,13 +148,13 @@ func (l *queryLog) logRequest(question *dns.Msg, answer *dns.Msg, result *dnsfil } } l.logBufferLock.Unlock() - l.queryLogLock.Lock() - l.queryLogCache = append(l.queryLogCache, &entry) - if len(l.queryLogCache) > queryLogSize { - toremove := len(l.queryLogCache) - queryLogSize - l.queryLogCache = l.queryLogCache[toremove:] + l.lock.Lock() + l.cache = append(l.cache, &entry) + if len(l.cache) > queryLogSize { + toremove := len(l.cache) - queryLogSize + l.cache = l.cache[toremove:] } - l.queryLogLock.Unlock() + l.lock.Unlock() // if buffer needs to be flushed to disk, do it now if needFlush { @@ -115,16 +162,14 @@ func (l *queryLog) logRequest(question *dns.Msg, answer *dns.Msg, result *dnsfil // do it in separate goroutine -- we are stalling DNS response this whole time go l.flushLogBuffer(false) // nolint } - - return &entry } // getQueryLogJson returns a map with the current query log ready to be converted to a JSON -func (l *queryLog) getQueryLog() []map[string]interface{} { - l.queryLogLock.RLock() - values := make([]*logEntry, len(l.queryLogCache)) - copy(values, l.queryLogCache) - l.queryLogLock.RUnlock() +func (l *queryLog) GetData() []map[string]interface{} { + l.lock.RLock() + values := make([]*logEntry, len(l.cache)) + copy(values, l.cache) + l.lock.RUnlock() // reverse it so that newest is first for left, right := 0, len(values)-1; left < right; left, right = left+1, right-1 { diff --git a/querylog/querylog.go b/querylog/querylog.go new file mode 100644 index 00000000..c995183b --- /dev/null +++ b/querylog/querylog.go @@ -0,0 +1,33 @@ +package querylog + +import ( + "net" + "time" + + "github.com/AdguardTeam/AdGuardHome/dnsfilter" + "github.com/miekg/dns" +) + +// QueryLog - main interface +type QueryLog interface { + Close() + + // Set new configuration at runtime + // Currently only 'Interval' field is supported. + Configure(conf Config) + + Add(question *dns.Msg, answer *dns.Msg, result *dnsfilter.Result, elapsed time.Duration, addr net.Addr, upstream string) + GetData() []map[string]interface{} + Clear() +} + +// Config - configuration object +type Config struct { + BaseDir string // directory where log file is stored + Interval uint32 // interval to rotate logs (in hours) +} + +// New - create instance +func New(conf Config) QueryLog { + return newQueryLog(conf) +} diff --git a/dnsforward/querylog_file.go b/querylog/querylog_file.go similarity index 56% rename from dnsforward/querylog_file.go rename to querylog/querylog_file.go index e990fdec..6f6f887a 100644 --- a/dnsforward/querylog_file.go +++ b/querylog/querylog_file.go @@ -1,4 +1,4 @@ -package dnsforward +package querylog import ( "bytes" @@ -11,6 +11,7 @@ import ( "github.com/AdguardTeam/golibs/log" "github.com/go-test/deep" + "github.com/miekg/dns" ) var ( @@ -170,7 +171,7 @@ func (l *queryLog) rotateQueryLog() error { } func (l *queryLog) periodicQueryLogRotate() { - for range time.Tick(queryLogRotationPeriod) { + for range time.Tick(time.Duration(l.conf.Interval) * time.Hour) { err := l.rotateQueryLog() if err != nil { log.Error("Failed to rotate querylog: %s", err) @@ -178,3 +179,152 @@ func (l *queryLog) periodicQueryLogRotate() { } } } + +// Reader is the DB reader context +type Reader struct { + f *os.File + jd *json.Decoder + now time.Time + ql *queryLog + + files []string + ifile int + + count uint64 // returned elements counter +} + +// OpenReader locks the file and returns reader object or nil on error +func (l *queryLog) OpenReader() *Reader { + r := Reader{} + r.ql = l + r.now = time.Now() + + return &r +} + +// Close closes the reader +func (r *Reader) Close() { + elapsed := time.Since(r.now) + var perunit time.Duration + if r.count > 0 { + perunit = elapsed / time.Duration(r.count) + } + log.Debug("querylog: read %d entries in %v, %v/entry", + r.count, elapsed, perunit) + + if r.f != nil { + r.f.Close() + } +} + +// BeginRead starts reading +func (r *Reader) BeginRead() { + r.files = []string{ + r.ql.logFile, + r.ql.logFile + ".1", + } +} + +// Next returns the next entry or nil if reading is finished +func (r *Reader) Next() *logEntry { // nolint + var err error + for { + // open file if needed + if r.f == nil { + if r.ifile == len(r.files) { + return nil + } + fn := r.files[r.ifile] + r.f, err = os.Open(fn) + if err != nil { + log.Error("Failed to open file \"%s\": %s", fn, err) + r.ifile++ + continue + } + } + + // open decoder if needed + if r.jd == nil { + r.jd = json.NewDecoder(r.f) + } + + // check if there's data + if !r.jd.More() { + r.jd = nil + r.f.Close() + r.f = nil + r.ifile++ + continue + } + + // read data + var entry logEntry + err = r.jd.Decode(&entry) + if err != nil { + log.Error("Failed to decode: %s", err) + // next entry can be fine, try more + continue + } + r.count++ + return &entry + } +} + +// Total returns the total number of items +func (r *Reader) Total() int { + return 0 +} + +// Fill cache from file +func (l *queryLog) fillFromFile() { + now := time.Now() + validFrom := now.Unix() - int64(l.conf.Interval*60*60) + r := l.OpenReader() + if r == nil { + return + } + + r.BeginRead() + + for { + entry := r.Next() + if entry == nil { + break + } + + if entry.Time.Unix() < validFrom { + continue + } + + if len(entry.Question) == 0 { + log.Printf("entry question is absent, skipping") + continue + } + + if entry.Time.After(now) { + log.Printf("t %v vs %v is in the future, ignoring", entry.Time, now) + continue + } + + q := new(dns.Msg) + if err := q.Unpack(entry.Question); err != nil { + log.Printf("failed to unpack dns message question: %s", err) + continue + } + + if len(q.Question) != 1 { + log.Printf("malformed dns message, has no questions, skipping") + continue + } + + l.lock.Lock() + l.cache = append(l.cache, entry) + if len(l.cache) > queryLogSize { + toremove := len(l.cache) - queryLogSize + l.cache = l.cache[toremove:] + } + l.lock.Unlock() + } + + r.Close() +} diff --git a/querylog/querylog_test.go b/querylog/querylog_test.go new file mode 100644 index 00000000..8da84183 --- /dev/null +++ b/querylog/querylog_test.go @@ -0,0 +1,43 @@ +package querylog + +import ( + "net" + "testing" + + "github.com/AdguardTeam/AdGuardHome/dnsfilter" + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" +) + +func TestQueryLog(t *testing.T) { + conf := Config{ + Interval: 1, + } + l := New(conf) + + q := dns.Msg{} + q.Question = append(q.Question, dns.Question{ + Name: "example.org.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }) + + a := dns.Msg{} + a.Question = append(a.Question, q.Question[0]) + answer := new(dns.A) + answer.Hdr = dns.RR_Header{ + Name: q.Question[0].Name, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + } + answer.A = net.IP{1, 2, 3, 4} + a.Answer = append(a.Answer, answer) + + res := dnsfilter.Result{} + l.Add(&q, &a, &res, 0, nil, "upstream") + + d := l.GetData() + m := d[0] + mq := m["question"].(map[string]interface{}) + assert.True(t, mq["host"].(string) == "example.org") +}