diff --git a/AGHTechDoc.md b/AGHTechDoc.md index b3d52e1e..4f0c5c8d 100644 --- a/AGHTechDoc.md +++ b/AGHTechDoc.md @@ -942,7 +942,7 @@ Error response (Client not found): ### API: Find clients by IP This method returns the list of clients (manual and auto-clients) matching the IP list. -For auto-clients only `name`, `ids` and `whois_info` fields are set. Other fields are empty. +For auto-clients only `name`, `ids`, `whois_info`, `disallowed`, and `disallowed_rule` fields are set. Other fields are empty. Request: @@ -968,11 +968,16 @@ Response: key: "value" ... } + + "disallowed": false, + "disallowed_rule": "..." } } ... ] +* `disallowed` - whether the client's IP is blocked or not. +* `disallowed_rule` - the rule due to which the client is disallowed. If `disallowed` is `true`, and this string is empty - it means that the client IP is disallowed by the "allowed IP list", i.e. it is not included in allowed. ## DNS general settings diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index b6235a66..26e7df08 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -582,5 +582,6 @@ "click_to_view_queries": "Click to view queries", "port_53_faq_link": "Port 53 is often occupied by \"DNSStubListener\" or \"systemd-resolved\" services. Please read <0>this instruction on how to resolve this.", "adg_will_drop_dns_queries": "AdGuard Home will be dropping all DNS queries from this client.", + "client_not_in_allowed_clients": "The client is not allowed because it is not in the \"Allowed clients\" list.", "experimental": "Experimental" } diff --git a/client/src/__tests__/helpers.test.js b/client/src/__tests__/helpers.test.js index bb371be4..309b40e6 100644 --- a/client/src/__tests__/helpers.test.js +++ b/client/src/__tests__/helpers.test.js @@ -1,136 +1,5 @@ -import { - countClientsStatistics, findAddressType, getIpMatchListStatus, sortIp, -} from '../helpers/helpers'; -import { ADDRESS_TYPES, IP_MATCH_LIST_STATUS } from '../helpers/constants'; - -describe('getIpMatchListStatus', () => { - describe('IPv4', () => { - test('should return EXACT on find the exact ip match', () => { - const list = `127.0.0.2 -2001:db8:11a3:9d7:0:0:0:0 -192.168.0.1/8 -127.0.0.1 -127.0.0.3`; - expect(getIpMatchListStatus('127.0.0.1', list)) - .toEqual(IP_MATCH_LIST_STATUS.EXACT); - }); - - test('should return CIDR on find the cidr match', () => { - const list = `127.0.0.2 -2001:db8:11a3:9d7:0:0:0:0 -192.168.0.1/8 -127.0.0.0/24 -127.0.0.3`; - expect(getIpMatchListStatus('127.0.0.1', list)) - .toEqual(IP_MATCH_LIST_STATUS.CIDR); - }); - - test('should return NOT_FOUND if the ip is not in the list', () => { - const list = `127.0.0.1 -2001:db8:11a3:9d7:0:0:0:0 -192.168.0.1/8 -127.0.0.2 -127.0.0.3`; - expect(getIpMatchListStatus('127.0.0.4', list)) - .toEqual(IP_MATCH_LIST_STATUS.NOT_FOUND); - }); - - test('should return the first EXACT or CIDR match in the list', () => { - const list1 = `2001:db8:11a3:9d7:0:0:0:0 -127.0.0.1 -127.0.0.8/24 -127.0.0.3`; - expect(getIpMatchListStatus('127.0.0.1', list1)) - .toEqual(IP_MATCH_LIST_STATUS.EXACT); - - const list2 = `2001:db8:11a3:9d7:ffff:ffff:ffff:ffff -2001:0db8:11a3:09d7:0000:0000:0000:0000/64 -127.0.0.0/24 -127.0.0.1 -127.0.0.8/24 -127.0.0.3`; - expect(getIpMatchListStatus('127.0.0.1', list2)) - .toEqual(IP_MATCH_LIST_STATUS.CIDR); - }); - }); - - describe('IPv6', () => { - test('should return EXACT on find the exact ip match', () => { - const list = `127.0.0.0 -2001:db8:11a3:9d7:0:0:0:0 -2001:db8:11a3:9d7:ffff:ffff:ffff:ffff -127.0.0.1`; - expect(getIpMatchListStatus('2001:db8:11a3:9d7:0:0:0:0', list)) - .toEqual(IP_MATCH_LIST_STATUS.EXACT); - }); - - test('should return EXACT on find the exact ip match of short and long notation', () => { - const list = `127.0.0.0 -192.168.0.1/8 -2001:db8:: -127.0.0.2`; - expect(getIpMatchListStatus('2001:db8:0:0:0:0:0:0', list)) - .toEqual(IP_MATCH_LIST_STATUS.EXACT); - }); - - test('should return CIDR on find the cidr match', () => { - const list1 = `2001:0db8:11a3:09d7:0000:0000:0000:0000/64 -127.0.0.1 -127.0.0.2`; - expect(getIpMatchListStatus('2001:db8:11a3:9d7:0:0:0:0', list1)) - .toEqual(IP_MATCH_LIST_STATUS.CIDR); - - const list2 = `2001:0db8::/16 -127.0.0.0 -2001:db8:11a3:9d7:0:0:0:0 -2001:db8:: -2001:db8:11a3:9d7:ffff:ffff:ffff:ffff -127.0.0.1`; - expect(getIpMatchListStatus('2001:db1::', list2)) - .toEqual(IP_MATCH_LIST_STATUS.CIDR); - }); - - test('should return NOT_FOUND if the ip is not in the list', () => { - const list = `2001:db8:11a3:9d7:0:0:0:0 -2001:0db8:11a3:09d7:0000:0000:0000:0000/64 -127.0.0.1 -127.0.0.2`; - expect(getIpMatchListStatus('::', list)) - .toEqual(IP_MATCH_LIST_STATUS.NOT_FOUND); - }); - - test('should return the first EXACT or CIDR match in the list', () => { - const list1 = `2001:db8:11a3:9d7:0:0:0:0 -2001:0db8:11a3:09d7:0000:0000:0000:0000/64 -127.0.0.3`; - expect(getIpMatchListStatus('2001:db8:11a3:9d7:0:0:0:0', list1)) - .toEqual(IP_MATCH_LIST_STATUS.EXACT); - - const list2 = `2001:0db8:11a3:09d7:0000:0000:0000:0000/64 -2001:db8:11a3:9d7:0:0:0:0 -127.0.0.3`; - expect(getIpMatchListStatus('2001:db8:11a3:9d7:0:0:0:0', list2)) - .toEqual(IP_MATCH_LIST_STATUS.CIDR); - }); - }); - - describe('Empty list or IP', () => { - test('should return NOT_FOUND on empty ip', () => { - const list = `127.0.0.0 -2001:db8:11a3:9d7:0:0:0:0 -2001:db8:11a3:9d7:ffff:ffff:ffff:ffff -127.0.0.1`; - expect(getIpMatchListStatus('', list)) - .toEqual(IP_MATCH_LIST_STATUS.NOT_FOUND); - }); - - test('should return NOT_FOUND on empty list', () => { - const list = ''; - expect(getIpMatchListStatus('127.0.0.1', list)) - .toEqual(IP_MATCH_LIST_STATUS.NOT_FOUND); - }); - }); -}); +import { sortIp, countClientsStatistics, findAddressType } from '../helpers/helpers'; +import { ADDRESS_TYPES } from '../helpers/constants'; describe('sortIp', () => { describe('ipv4', () => { diff --git a/client/src/actions/access.js b/client/src/actions/access.js index e04daac0..17acb5e8 100644 --- a/client/src/actions/access.js +++ b/client/src/actions/access.js @@ -3,7 +3,6 @@ import i18next from 'i18next'; import apiClient from '../api/Api'; import { addErrorToast, addSuccessToast } from './toasts'; -import { BLOCK_ACTIONS } from '../helpers/constants'; import { splitByNewLine } from '../helpers/helpers'; export const getAccessListRequest = createAction('GET_ACCESS_LIST_REQUEST'); @@ -49,19 +48,16 @@ export const toggleClientBlockRequest = createAction('TOGGLE_CLIENT_BLOCK_REQUES export const toggleClientBlockFailure = createAction('TOGGLE_CLIENT_BLOCK_FAILURE'); export const toggleClientBlockSuccess = createAction('TOGGLE_CLIENT_BLOCK_SUCCESS'); -export const toggleClientBlock = (type, ip) => async (dispatch) => { +export const toggleClientBlock = (ip, disallowed, disallowed_rule) => async (dispatch) => { dispatch(toggleClientBlockRequest()); try { const { - allowed_clients, disallowed_clients, blocked_hosts, + allowed_clients, blocked_hosts, disallowed_clients = [], } = await apiClient.getAccessList(); - let updatedDisallowedClients = disallowed_clients || []; - if (type === BLOCK_ACTIONS.UNBLOCK && updatedDisallowedClients.includes(ip)) { - updatedDisallowedClients = updatedDisallowedClients.filter((client) => client !== ip); - } else if (type === BLOCK_ACTIONS.BLOCK && !updatedDisallowedClients.includes(ip)) { - updatedDisallowedClients.push(ip); - } + const updatedDisallowedClients = disallowed + ? disallowed_clients.filter((client) => client !== disallowed_rule) + : disallowed_clients.concat(ip); const values = { allowed_clients, @@ -72,9 +68,9 @@ export const toggleClientBlock = (type, ip) => async (dispatch) => { await apiClient.setAccessList(values); dispatch(toggleClientBlockSuccess(values)); - if (type === BLOCK_ACTIONS.UNBLOCK) { - dispatch(addSuccessToast(i18next.t('client_unblocked', { ip }))); - } else if (type === BLOCK_ACTIONS.BLOCK) { + if (disallowed) { + dispatch(addSuccessToast(i18next.t('client_unblocked', { ip: disallowed_rule }))); + } else { dispatch(addSuccessToast(i18next.t('client_blocked', { ip }))); } } catch (error) { diff --git a/client/src/actions/queryLogs.js b/client/src/actions/queryLogs.js index bfb9a609..1c653fb3 100644 --- a/client/src/actions/queryLogs.js +++ b/client/src/actions/queryLogs.js @@ -7,6 +7,17 @@ import { } from '../helpers/constants'; import { addErrorToast, addSuccessToast } from './toasts'; +const enrichWithClientInfo = async (logs) => { + const clientsParams = getParamsForClientsSearch(logs, 'client'); + + if (Object.keys(clientsParams).length > 0) { + const clients = await apiClient.findClients(clientsParams); + return addClientInfo(logs, clients, 'client'); + } + + return logs; +}; + const getLogsWithParams = async (config) => { const { older_than, filter, ...values } = config; const rawLogs = await apiClient.getQueryLog({ @@ -14,13 +25,8 @@ const getLogsWithParams = async (config) => { older_than, }); const { data, oldest } = rawLogs; - let logs = normalizeLogs(data); - const clientsParams = getParamsForClientsSearch(logs, 'client'); - - if (Object.keys(clientsParams).length > 0) { - const clients = await apiClient.findClients(clientsParams); - logs = addClientInfo(logs, clients, 'client'); - } + const normalizedLogs = normalizeLogs(data); + const logs = await enrichWithClientInfo(normalizedLogs); return { logs, @@ -82,6 +88,23 @@ export const getLogsRequest = createAction('GET_LOGS_REQUEST'); export const getLogsFailure = createAction('GET_LOGS_FAILURE'); export const getLogsSuccess = createAction('GET_LOGS_SUCCESS'); +export const updateLogs = () => async (dispatch, getState) => { + try { + const { logs, oldest, older_than } = getState().queryLogs; + + const enrichedLogs = await enrichWithClientInfo(logs); + + dispatch(getLogsSuccess({ + logs: enrichedLogs, + oldest, + older_than, + })); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(getLogsFailure(error)); + } +}; + export const getLogs = () => async (dispatch, getState) => { dispatch(getLogsRequest()); try { diff --git a/client/src/components/Dashboard/Clients.js b/client/src/components/Dashboard/Clients.js index 2f25bda3..ccac7baf 100644 --- a/client/src/components/Dashboard/Clients.js +++ b/client/src/components/Dashboard/Clients.js @@ -8,10 +8,11 @@ import classNames from 'classnames'; import Card from '../ui/Card'; import Cell from '../ui/Cell'; -import { getPercent, getIpMatchListStatus, sortIp } from '../../helpers/helpers'; -import { BLOCK_ACTIONS, IP_MATCH_LIST_STATUS, STATUS_COLORS } from '../../helpers/constants'; +import { getPercent, sortIp } from '../../helpers/helpers'; +import { BLOCK_ACTIONS, STATUS_COLORS } from '../../helpers/constants'; import { toggleClientBlock } from '../../actions/access'; import { renderFormattedClientCell } from '../../helpers/renderFormattedClientCell'; +import { getStats } from '../../actions/stats'; const getClientsPercentColor = (percent) => { if (percent > 50) { @@ -33,59 +34,51 @@ const CountCell = (row) => { return ; }; -const renderBlockingButton = (ip) => { +const renderBlockingButton = (ip, disallowed, disallowed_rule) => { const dispatch = useDispatch(); const { t } = useTranslation(); const processingSet = useSelector((state) => state.access.processingSet); - const disallowed_clients = useSelector( - (state) => state.access.disallowed_clients, shallowEqual, - ); - - const ipMatchListStatus = getIpMatchListStatus(ip, disallowed_clients); - - if (ipMatchListStatus === IP_MATCH_LIST_STATUS.CIDR) { - return null; - } - - const isNotFound = ipMatchListStatus === IP_MATCH_LIST_STATUS.NOT_FOUND; - const type = isNotFound ? BLOCK_ACTIONS.BLOCK : BLOCK_ACTIONS.UNBLOCK; - const text = type; const buttonClass = classNames('button-action button-action--main', { - 'button-action--unblock': !isNotFound, + 'button-action--unblock': disallowed, }); - const toggleClientStatus = (type, ip) => { - const confirmMessage = type === BLOCK_ACTIONS.BLOCK - ? `${t('adg_will_drop_dns_queries')} ${t('client_confirm_block', { ip })}` - : t('client_confirm_unblock', { ip }); + const toggleClientStatus = async (ip, disallowed, disallowed_rule) => { + const confirmMessage = disallowed + ? t('client_confirm_unblock', { ip: disallowed_rule }) + : `${t('adg_will_drop_dns_queries')} ${t('client_confirm_block', { ip })}`; if (window.confirm(confirmMessage)) { - dispatch(toggleClientBlock(type, ip)); + await dispatch(toggleClientBlock(ip, disallowed, disallowed_rule)); + await dispatch(getStats()); } }; - const onClick = () => toggleClientStatus(type, ip); + const onClick = () => toggleClientStatus(ip, disallowed, disallowed_rule); + const text = disallowed ? BLOCK_ACTIONS.UNBLOCK : BLOCK_ACTIONS.BLOCK; + + const isNotInAllowedList = disallowed && disallowed_rule === ''; return
- -
; + + ; }; const ClientCell = (row) => { - const { value, original: { info } } = row; + const { value, original: { info, info: { disallowed, disallowed_rule } } } = row; return <>
{renderFormattedClientCell(value, info, true)} - {renderBlockingButton(value)} + {renderBlockingButton(value, disallowed, disallowed_rule)}
; }; @@ -96,7 +89,6 @@ const Clients = ({ }) => { const { t } = useTranslation(); const topClients = useSelector((state) => state.stats.topClients, shallowEqual); - const disallowedClients = useSelector((state) => state.access.disallowed_clients, shallowEqual); return ; diff --git a/client/src/components/Logs/Cells/ClientCell.js b/client/src/components/Logs/Cells/ClientCell.js index a468bfcb..56a3440c 100644 --- a/client/src/components/Logs/Cells/ClientCell.js +++ b/client/src/components/Logs/Cells/ClientCell.js @@ -11,12 +11,16 @@ import IconTooltip from './IconTooltip'; import { renderFormattedClientCell } from '../../../helpers/renderFormattedClientCell'; import { toggleClientBlock } from '../../../actions/access'; import { getBlockClientInfo } from './helpers'; +import { getStats } from '../../../actions/stats'; +import { updateLogs } from '../../../actions/queryLogs'; const ClientCell = ({ client, domain, info, - info: { name, whois_info }, + info: { + name, whois_info, disallowed, disallowed_rule, + }, reason, }) => { const { t } = useTranslation(); @@ -26,11 +30,6 @@ const ClientCell = ({ const isDetailed = useSelector((state) => state.queryLogs.isDetailed); const [isOptionsOpened, setOptionsOpened] = useState(false); - const disallowed_clients = useSelector( - (state) => state.access.disallowed_clients, - shallowEqual, - ); - const autoClient = autoClients.find((autoClient) => autoClient.name === client); const source = autoClient?.source; const whoisAvailable = whois_info && Object.keys(whois_info).length > 0; @@ -66,43 +65,50 @@ const ClientCell = ({ const { confirmMessage, buttonKey: blockingClientKey, - type, - } = getBlockClientInfo(client, disallowed_clients); + isNotInAllowedList, + } = getBlockClientInfo(client, disallowed, disallowed_rule); const blockingForClientKey = isFiltered ? 'unblock_for_this_client_only' : 'block_for_this_client_only'; const clientNameBlockingFor = getBlockingClientName(clients, client); - const BUTTON_OPTIONS_TO_ACTION_MAP = { - [blockingForClientKey]: () => { - dispatch(toggleBlockingForClient(buttonType, domain, clientNameBlockingFor)); + const BUTTON_OPTIONS = [ + { + name: blockingForClientKey, + onClick: () => { + dispatch(toggleBlockingForClient(buttonType, domain, clientNameBlockingFor)); + }, }, - [blockingClientKey]: () => { - const message = `${type === BLOCK_ACTIONS.BLOCK ? t('adg_will_drop_dns_queries') : ''} ${t(confirmMessage, { ip: client })}`; - if (window.confirm(message)) { - dispatch(toggleClientBlock(type, client)); - } + { + name: blockingClientKey, + onClick: async () => { + if (window.confirm(confirmMessage)) { + await dispatch(toggleClientBlock(client, disallowed, disallowed_rule)); + await dispatch(updateLogs()); + } + }, + disabled: isNotInAllowedList, }, - }; + ]; const onClick = async () => { await dispatch(toggleBlocking(buttonType, domain)); + await dispatch(getStats()); }; - const getOptions = (optionToActionMap) => { - const options = Object.entries(optionToActionMap); + const getOptions = (options) => { if (options.length === 0) { return null; } - return <>{options - .map(([name, onClick]) =>
{options.map(({ name, onClick, disabled }) =>
)}; + )}; }; - const content = getOptions(BUTTON_OPTIONS_TO_ACTION_MAP); + const content = getOptions(BUTTON_OPTIONS); const buttonClass = classNames('button-action button-action--main', { 'button-action--unblock': isFiltered, @@ -168,6 +174,8 @@ ClientCell.propTypes = { city: propTypes.string, orgname: propTypes.string, }), + disallowed: propTypes.bool.isRequired, + disallowed_rule: propTypes.string.isRequired, }), ]), reason: propTypes.string.isRequired, diff --git a/client/src/components/Logs/Cells/helpers/index.js b/client/src/components/Logs/Cells/helpers/index.js index 61e7ff5c..b843ed99 100644 --- a/client/src/components/Logs/Cells/helpers/index.js +++ b/client/src/components/Logs/Cells/helpers/index.js @@ -1,19 +1,18 @@ -import { getIpMatchListStatus } from '../../../../helpers/helpers'; -import { BLOCK_ACTIONS, IP_MATCH_LIST_STATUS } from '../../../../helpers/constants'; +import i18next from 'i18next'; export const BUTTON_PREFIX = 'btn_'; -export const getBlockClientInfo = (client, disallowed_clients) => { - const ipMatchListStatus = getIpMatchListStatus(client, disallowed_clients); +export const getBlockClientInfo = (ip, disallowed, disallowed_rule) => { + const confirmMessage = disallowed + ? i18next.t('client_confirm_unblock', { ip: disallowed_rule }) + : `${i18next.t('adg_will_drop_dns_queries')} ${i18next.t('client_confirm_block', { ip })}`; - const isNotFound = ipMatchListStatus === IP_MATCH_LIST_STATUS.NOT_FOUND; - const type = isNotFound ? BLOCK_ACTIONS.BLOCK : BLOCK_ACTIONS.UNBLOCK; + const buttonKey = i18next.t(disallowed ? 'allow_this_client' : 'disallow_this_client'); + const isNotInAllowedList = disallowed && disallowed_rule === ''; - const confirmMessage = isNotFound ? 'client_confirm_block' : 'client_confirm_unblock'; - const buttonKey = isNotFound ? 'disallow_this_client' : 'allow_this_client'; return { confirmMessage, buttonKey, - type, + isNotInAllowedList, }; }; diff --git a/client/src/components/Logs/Cells/index.js b/client/src/components/Logs/Cells/index.js index 4f96a5e9..2e2635d9 100644 --- a/client/src/components/Logs/Cells/index.js +++ b/client/src/components/Logs/Cells/index.js @@ -32,6 +32,7 @@ import ClientCell from './ClientCell'; import '../Logs.css'; import { toggleClientBlock } from '../../../actions/access'; import { getBlockClientInfo, BUTTON_PREFIX } from './helpers'; +import { updateLogs } from '../../../actions/queryLogs'; const Row = memo(({ style, @@ -49,21 +50,19 @@ const Row = memo(({ const whitelistFilters = useSelector((state) => state.filtering.whitelistFilters, shallowEqual); const autoClients = useSelector((state) => state.dashboard.autoClients, shallowEqual); - const disallowed_clients = useSelector( - (state) => state.access.disallowed_clients, - shallowEqual, - ); - const clients = useSelector((state) => state.dashboard.clients); const onClick = () => { - if (!isSmallScreen) { return; } + if (!isSmallScreen) { + return; + } const { answer_dnssec, client, domain, elapsedMs, info, + info: { disallowed, disallowed_rule }, reason, response, time, @@ -94,7 +93,7 @@ const Row = memo(({ const isFiltered = checkFiltered(reason); const isBlocked = reason === FILTERED_STATUS.FILTERED_BLACK_LIST - || reason === FILTERED_STATUS.FILTERED_BLOCKED_SERVICE; + || reason === FILTERED_STATUS.FILTERED_BLOCKED_SERVICE; const buttonType = isFiltered ? BLOCK_ACTIONS.UNBLOCK : BLOCK_ACTIONS.BLOCK; const onToggleBlock = () => { @@ -113,8 +112,8 @@ const Row = memo(({ const { confirmMessage, buttonKey: blockingClientKey, - type: blockType, - } = getBlockClientInfo(client, disallowed_clients); + isNotInAllowedList, + } = getBlockClientInfo(client, disallowed, disallowed_rule); const blockingForClientKey = isFiltered ? 'unblock_for_this_client_only' : 'block_for_this_client_only'; const clientNameBlockingFor = getBlockingClientName(clients, client); @@ -123,13 +122,33 @@ const Row = memo(({ dispatch(toggleBlockingForClient(buttonType, domain, clientNameBlockingFor)); }; - const onBlockingClientClick = () => { - const message = `${blockType === BLOCK_ACTIONS.BLOCK ? t('adg_will_drop_dns_queries') : ''} ${t(confirmMessage, { ip: client })}`; - if (window.confirm(message)) { - dispatch(toggleClientBlock(blockType, client)); + const onBlockingClientClick = async () => { + if (window.confirm(confirmMessage)) { + await dispatch(toggleClientBlock(client, disallowed, disallowed_rule)); + await dispatch(updateLogs()); + setModalOpened(false); } }; + const blockButton = ; + + const blockForClientButton = ; + + const blockClientButton = ; + const detailedData = { time_table_header: formatTime(time, LONG_TIME_FORMAT), date: formatDateTime(time, DEFAULT_SHORT_DATE_FORMAT_OPTIONS), @@ -144,12 +163,12 @@ const Row = memo(({ table_name: tracker?.name, category_label: hasTracker && captitalizeWords(tracker.category), tracker_source: hasTracker && sourceData - && {sourceData.name} - , + && {sourceData.name} + , response_details: 'title', install_settings_dns: upstream, elapsed: formattedElapsedMs, @@ -166,12 +185,9 @@ const Row = memo(({ source_label: source, validated_with_dnssec: dnssec_enabled ? Boolean(answer_dnssec) : false, original_response: originalResponse?.join('\n'), - [BUTTON_PREFIX + buttonType]:
{t(buttonType)}
, - [BUTTON_PREFIX + blockingForClientKey]:
{t(blockingForClientKey)}
, - [BUTTON_PREFIX + blockingClientKey]:
{t(blockingClientKey)}
, + [BUTTON_PREFIX + buttonType]: blockButton, + [BUTTON_PREFIX + blockingForClientKey]: blockForClientButton, + [BUTTON_PREFIX + blockingClientKey]: blockClientButton, }; setDetailedDataCurrent(processContent(detailedData)); diff --git a/client/src/components/Logs/Logs.css b/client/src/components/Logs/Logs.css index b230ab8b..901c3ab0 100644 --- a/client/src/components/Logs/Logs.css +++ b/client/src/components/Logs/Logs.css @@ -96,7 +96,7 @@ } .bg--danger { - color: var(--danger); + color: var(--danger) !important; } .form-control--search { @@ -293,7 +293,20 @@ background: var(--btn-unblock-disabled); } -.button-action--arrow-option:hover { +.button-action--arrow-option { + background: transparent; + border: 0; + display: block; + width: 100%; + text-align: left; + color: inherit; +} + +.button-action--arrow-option:disabled { + display: none; +} + +.tooltip-custom__container .button-action--arrow-option:not(:disabled):hover { cursor: pointer; background: var(--gray-f3); overflow: hidden; @@ -327,7 +340,7 @@ } .logs__row--red { - background-color: var(--red); + background-color: var(--red) !important; } .logs__row--white { diff --git a/client/src/helpers/constants.js b/client/src/helpers/constants.js index 08fd1529..70f9e317 100644 --- a/client/src/helpers/constants.js +++ b/client/src/helpers/constants.js @@ -479,12 +479,6 @@ export const DNS_REQUEST_OPTIONS = { LOAD_BALANCING: '', }; -export const IP_MATCH_LIST_STATUS = { - NOT_FOUND: 'NOT_FOUND', // not found in the list - EXACT: 'EXACT', // found exact match (including the match of short and long forms) - CIDR: 'CIDR', // the ip is in the specified CIDR range -}; - export const DHCP_FORM_NAMES = { DHCPv4: 'dhcpv4', DHCPv6: 'dhcpv6', diff --git a/client/src/helpers/helpers.js b/client/src/helpers/helpers.js index b0bbea5a..934268ac 100644 --- a/client/src/helpers/helpers.js +++ b/client/src/helpers/helpers.js @@ -25,7 +25,6 @@ import { DHCP_VALUES_PLACEHOLDERS, FILTERED, FILTERED_STATUS, - IP_MATCH_LIST_STATUS, SERVICES_ID_NAME_MAP, STANDARD_DNS_PORT, STANDARD_HTTPS_PORT, @@ -133,16 +132,14 @@ export const normalizeTopStats = (stats) => ( })) ); -export const addClientInfo = (data, clients, param) => ( - data.map((row) => { - const clientIp = row[param]; - const info = clients.find((item) => item[clientIp]) || ''; - return { - ...row, - info: info?.[clientIp] ?? '', - }; - }) -); +export const addClientInfo = (data, clients, param) => data.map((row) => { + const clientIp = row[param]; + const info = clients.find((item) => item[clientIp]) || ''; + return { + ...row, + info: info?.[clientIp] ?? '', + }; +}); export const normalizeFilters = (filters) => ( filters ? filters.map((filter) => { @@ -530,75 +527,6 @@ export const isIpInCidr = (ip, cidr) => { } }; -/** - * The purpose of this method is to quickly check - * if this IP can possibly be in the specified CIDR range. - * - * @param ip {string} - * @param listItem {string} - * @returns {boolean} - */ -const isIpQuickMatchCIDR = (ip, listItem) => { - const ipv6 = ip.indexOf(':') !== -1; - const cidrIpv6 = listItem.indexOf(':') !== -1; - if (ipv6 !== cidrIpv6) { - // CIDR is for a different IP type - return false; - } - - if (cidrIpv6) { - // We don't do quick check for IPv6 addresses - return true; - } - - const idx = listItem.indexOf('/'); - if (idx === -1) { - // Not a CIDR, return false immediately - return false; - } - - const cidrIp = listItem.substring(0, idx); - const cidrRange = parseInt(listItem.substring(idx + 1), 10); - if (Number.isNaN(cidrRange)) { - // Not a valid CIDR - return false; - } - - const parts = cidrIp.split('.'); - if (parts.length !== 4) { - // Invalid IP, return immediately - return false; - } - - // Now depending on the range we check if the IP can possibly be in that range - if (cidrRange < 8) { - // Use the slow approach - return true; - } - - if (cidrRange < 16) { - // Check the first part - // Example: 0.0.0.0/8 matches 0.*.*.* - return ip.indexOf(`${parts[0]}.`) === 0; - } - - if (cidrRange < 24) { - // Check the first two parts - // Example: 0.0.0.0/16 matches 0.0.*.* - return ip.indexOf(`${parts[0]}.${parts[1]}.`) === 0; - } - - if (cidrRange <= 32) { - // Check the first two parts - // Example: 0.0.0.0/16 matches 0.0.*.* - return ip.indexOf(`${parts[0]}.${parts[1]}.${parts[2]}.`) === 0; - } - - // range for IPv4 CIDR cannot be more than 32 - // no need to check further, this CIDR is invalid - return false; -}; - /** * * @param ipOrCidr @@ -622,50 +550,6 @@ export const findAddressType = (address) => { } }; -/** - * @param ip {string} - * @param list {string} - * @returns {'EXACT' | 'CIDR' | 'NOT_FOND'} - */ -export const getIpMatchListStatus = (ip, list) => { - if (!ip || !list) { - return IP_MATCH_LIST_STATUS.NOT_FOUND; - } - - const listArr = list.trim() - .split('\n'); - - try { - for (let i = 0; i < listArr.length; i += 1) { - const listItem = listArr[i]; - - if (ip === listItem.trim()) { - return IP_MATCH_LIST_STATUS.EXACT; - } - - // Using ipaddr.js is quite slow so we first do a quick check - // to see if it's possible that this IP may be in the specified CIDR range - if (isIpQuickMatchCIDR(ip, listItem)) { - const parsedIp = ipaddr.parse(ip); - const isItemAnIp = ipaddr.isValid(listItem); - const parsedItem = isItemAnIp ? ipaddr.parse(listItem) : ipaddr.parseCIDR(listItem); - - if (isItemAnIp && parsedIp.toString() === parsedItem.toString()) { - return IP_MATCH_LIST_STATUS.EXACT; - } - - if (!isItemAnIp && isIpMatchCidr(parsedIp, parsedItem)) { - return IP_MATCH_LIST_STATUS.CIDR; - } - } - } - return IP_MATCH_LIST_STATUS.NOT_FOUND; - } catch (e) { - console.error(e); - return IP_MATCH_LIST_STATUS.NOT_FOUND; - } -}; - /** * @param ids {string[]} * @returns {Object} diff --git a/client/src/reducers/dhcp.js b/client/src/reducers/dhcp.js index 5f9f0f58..d6e2868a 100644 --- a/client/src/reducers/dhcp.js +++ b/client/src/reducers/dhcp.js @@ -2,7 +2,6 @@ import { handleActions } from 'redux-actions'; import * as actions from '../actions'; import { enrichWithConcatenatedIpAddresses } from '../helpers/helpers'; -// todo: figure out if we cat get rid of redux-form state duplication const dhcp = handleActions( { [actions.getDhcpStatusRequest]: (state) => ({ diff --git a/client/webpack.dev.js b/client/webpack.dev.js index afcc3506..3d34cb28 100644 --- a/client/webpack.dev.js +++ b/client/webpack.dev.js @@ -44,6 +44,7 @@ const getDevServerConfig = (proxyUrl = BASE_URL) => { proxy: { [proxyUrl]: `http://${devServerHost}:${port}`, }, + open: true, }; }; diff --git a/dnsforward/access.go b/dnsforward/access.go index eadd1141..dbe60de5 100644 --- a/dnsforward/access.go +++ b/dnsforward/access.go @@ -80,43 +80,46 @@ func processIPCIDRArray(dst *map[string]bool, dstIPNet *[]net.IPNet, src []strin } // IsBlockedIP - return TRUE if this client should be blocked -func (a *accessCtx) IsBlockedIP(ip string) bool { +// Returns the item from the "disallowedClients" list that lead to blocking IP. +// If it returns TRUE and an empty string, it means that the "allowedClients" is not empty, +// but the ip does not belong to it. +func (a *accessCtx) IsBlockedIP(ip string) (bool, string) { a.lock.Lock() defer a.lock.Unlock() if len(a.allowedClients) != 0 || len(a.allowedClientsIPNet) != 0 { _, ok := a.allowedClients[ip] if ok { - return false + return false, "" } if len(a.allowedClientsIPNet) != 0 { ipAddr := net.ParseIP(ip) for _, ipnet := range a.allowedClientsIPNet { if ipnet.Contains(ipAddr) { - return false + return false, "" } } } - return true + return true, "" } _, ok := a.disallowedClients[ip] if ok { - return true + return true, ip } if len(a.disallowedClientsIPNet) != 0 { ipAddr := net.ParseIP(ip) for _, ipnet := range a.disallowedClientsIPNet { if ipnet.Contains(ipAddr) { - return true + return true, ipnet.String() } } } - return false + return false, "" } // IsBlockedDomain - return TRUE if this domain should be blocked diff --git a/dnsforward/access_test.go b/dnsforward/access_test.go new file mode 100644 index 00000000..b760d554 --- /dev/null +++ b/dnsforward/access_test.go @@ -0,0 +1,73 @@ +package dnsforward + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsBlockedIPAllowed(t *testing.T) { + a := &accessCtx{} + assert.True(t, a.Init([]string{"1.1.1.1", "2.2.0.0/16"}, nil, nil) == nil) + + disallowed, disallowedRule := a.IsBlockedIP("1.1.1.1") + assert.False(t, disallowed) + assert.Equal(t, "", disallowedRule) + + disallowed, disallowedRule = a.IsBlockedIP("1.1.1.2") + assert.True(t, disallowed) + assert.Equal(t, "", disallowedRule) + + disallowed, disallowedRule = a.IsBlockedIP("2.2.1.1") + assert.False(t, disallowed) + assert.Equal(t, "", disallowedRule) + + disallowed, disallowedRule = a.IsBlockedIP("2.3.1.1") + assert.True(t, disallowed) + assert.Equal(t, "", disallowedRule) +} + +func TestIsBlockedIPDisallowed(t *testing.T) { + a := &accessCtx{} + assert.True(t, a.Init(nil, []string{"1.1.1.1", "2.2.0.0/16"}, nil) == nil) + + disallowed, disallowedRule := a.IsBlockedIP("1.1.1.1") + assert.True(t, disallowed) + assert.Equal(t, "1.1.1.1", disallowedRule) + + disallowed, disallowedRule = a.IsBlockedIP("1.1.1.2") + assert.False(t, disallowed) + assert.Equal(t, "", disallowedRule) + + disallowed, disallowedRule = a.IsBlockedIP("2.2.1.1") + assert.True(t, disallowed) + assert.Equal(t, "2.2.0.0/16", disallowedRule) + + disallowed, disallowedRule = a.IsBlockedIP("2.3.1.1") + assert.False(t, disallowed) + assert.Equal(t, "", disallowedRule) +} + +func TestIsBlockedIPBlockedDomain(t *testing.T) { + a := &accessCtx{} + assert.True(t, a.Init(nil, nil, []string{"host1", + "host2", + "*.host.com", + "||host3.com^", + }) == nil) + + // match by "host2.com" + assert.True(t, a.IsBlockedDomain("host1")) + assert.True(t, a.IsBlockedDomain("host2")) + assert.True(t, !a.IsBlockedDomain("host3")) + + // match by wildcard "*.host.com" + assert.True(t, !a.IsBlockedDomain("host.com")) + assert.True(t, a.IsBlockedDomain("asdf.host.com")) + assert.True(t, a.IsBlockedDomain("qwer.asdf.host.com")) + assert.True(t, !a.IsBlockedDomain("asdf.zhost.com")) + + // match by wildcard "||host3.com^" + assert.True(t, a.IsBlockedDomain("host3.com")) + assert.True(t, a.IsBlockedDomain("asdf.host3.com")) +} diff --git a/dnsforward/dnsforward.go b/dnsforward/dnsforward.go index 0cb645dd..1d2c3d7b 100644 --- a/dnsforward/dnsforward.go +++ b/dnsforward/dnsforward.go @@ -301,3 +301,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { p.ServeHTTP(w, r) } } + +// IsBlockedIP - return TRUE if this client should be blocked +func (s *Server) IsBlockedIP(ip string) (bool, string) { + return s.access.IsBlockedIP(ip) +} diff --git a/dnsforward/dnsforward_test.go b/dnsforward/dnsforward_test.go index 889321b1..80336055 100644 --- a/dnsforward/dnsforward_test.go +++ b/dnsforward/dnsforward_test.go @@ -912,50 +912,6 @@ func publicKey(priv interface{}) interface{} { } } -func TestIsBlockedIPAllowed(t *testing.T) { - a := &accessCtx{} - assert.True(t, a.Init([]string{"1.1.1.1", "2.2.0.0/16"}, nil, nil) == nil) - - assert.True(t, !a.IsBlockedIP("1.1.1.1")) - assert.True(t, a.IsBlockedIP("1.1.1.2")) - assert.True(t, !a.IsBlockedIP("2.2.1.1")) - assert.True(t, a.IsBlockedIP("2.3.1.1")) -} - -func TestIsBlockedIPDisallowed(t *testing.T) { - a := &accessCtx{} - assert.True(t, a.Init(nil, []string{"1.1.1.1", "2.2.0.0/16"}, nil) == nil) - - assert.True(t, a.IsBlockedIP("1.1.1.1")) - assert.True(t, !a.IsBlockedIP("1.1.1.2")) - assert.True(t, a.IsBlockedIP("2.2.1.1")) - assert.True(t, !a.IsBlockedIP("2.3.1.1")) -} - -func TestIsBlockedIPBlockedDomain(t *testing.T) { - a := &accessCtx{} - assert.True(t, a.Init(nil, nil, []string{"host1", - "host2", - "*.host.com", - "||host3.com^", - }) == nil) - - // match by "host2.com" - assert.True(t, a.IsBlockedDomain("host1")) - assert.True(t, a.IsBlockedDomain("host2")) - assert.True(t, !a.IsBlockedDomain("host3")) - - // match by wildcard "*.host.com" - assert.True(t, !a.IsBlockedDomain("host.com")) - assert.True(t, a.IsBlockedDomain("asdf.host.com")) - assert.True(t, a.IsBlockedDomain("qwer.asdf.host.com")) - assert.True(t, !a.IsBlockedDomain("asdf.zhost.com")) - - // match by wildcard "||host3.com^" - assert.True(t, a.IsBlockedDomain("host3.com")) - assert.True(t, a.IsBlockedDomain("asdf.host3.com")) -} - func TestValidateUpstream(t *testing.T) { invalidUpstreams := []string{"1.2.3.4.5", "123.3.7m", diff --git a/dnsforward/filter.go b/dnsforward/filter.go index 4ef3979d..d2e5535a 100644 --- a/dnsforward/filter.go +++ b/dnsforward/filter.go @@ -12,7 +12,8 @@ import ( func (s *Server) beforeRequestHandler(_ *proxy.Proxy, d *proxy.DNSContext) (bool, error) { ip := ipFromAddr(d.Addr) - if s.access.IsBlockedIP(ip) { + disallowed, _ := s.access.IsBlockedIP(ip) + if disallowed { log.Tracef("Client IP %s is blocked by settings", ip) return false, nil } diff --git a/home/clients.go b/home/clients.go index 252c6e2b..fce33cb3 100644 --- a/home/clients.go +++ b/home/clients.go @@ -80,6 +80,9 @@ type clientsContainer struct { // dhcpServer is used for looking up clients IP addresses by MAC addresses dhcpServer *dhcpd.Server + // dnsServer is used for checking clients IP status access list status + dnsServer *dnsforward.Server + autoHosts *util.AutoHosts // get entries from system hosts-files testing bool // if TRUE, this object is used for internal tests diff --git a/home/clients_http.go b/home/clients_http.go index c48e4937..42fa6f2a 100644 --- a/home/clients_http.go +++ b/home/clients_http.go @@ -21,6 +21,17 @@ type clientJSON struct { BlockedServices []string `json:"blocked_services"` Upstreams []string `json:"upstreams"` + + WhoisInfo map[string]interface{} `json:"whois_info"` + + // Disallowed - if true -- client's IP is not disallowed + // Otherwise, it is blocked. + Disallowed bool `json:"disallowed"` + + // DisallowedRule - the rule due to which the client is disallowed + // If Disallowed is true, and this string is empty - it means that the client IP + // is disallowed by the "allowed IP list", i.e. it is not included in allowed. + DisallowedRule string `json:"disallowed_rule"` } type clientHostJSON struct { @@ -38,7 +49,7 @@ type clientListJSON struct { } // respond with information about configured clients -func (clients *clientsContainer) handleGetClients(w http.ResponseWriter, r *http.Request) { +func (clients *clientsContainer) handleGetClients(w http.ResponseWriter, _ *http.Request) { data := clientListJSON{} clients.lock.Lock() @@ -123,15 +134,9 @@ func clientToJSON(c *Client) clientJSON { return cj } -type clientHostJSONWithID struct { - IDs []string `json:"ids"` - Name string `json:"name"` - WhoisInfo map[string]interface{} `json:"whois_info"` -} - // Convert ClientHost object to JSON -func clientHostToJSON(ip string, ch ClientHost) clientHostJSONWithID { - cj := clientHostJSONWithID{ +func clientHostToJSON(ip string, ch ClientHost) clientJSON { + cj := clientJSON{ Name: ch.Host, IDs: []string{ip}, } @@ -255,9 +260,13 @@ func (clients *clientsContainer) handleFindClient(w http.ResponseWriter, r *http continue // a client with this IP isn't found } cj := clientHostToJSON(ip, ch) + + cj.Disallowed, cj.DisallowedRule = clients.dnsServer.IsBlockedIP(ip) el[ip] = cj } else { cj := clientToJSON(&c) + + cj.Disallowed, cj.DisallowedRule = clients.dnsServer.IsBlockedIP(ip) el[ip] = cj } diff --git a/home/dns.go b/home/dns.go index 820b441a..bd70ce38 100644 --- a/home/dns.go +++ b/home/dns.go @@ -70,6 +70,7 @@ func initDNSServer() error { p.DHCPServer = Context.dhcpServer } Context.dnsServer = dnsforward.NewServer(p) + Context.clients.dnsServer = Context.dnsServer dnsConfig := generateServerConfig() err = Context.dnsServer.Prepare(&dnsConfig) if err != nil { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index d7a5d2cd..c50d9485 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1765,7 +1765,61 @@ components: properties: 1.2.3.4: items: - $ref: "#/components/schemas/Client" + $ref: "#/components/schemas/ClientFindSubEntry" + + ClientFindSubEntry: + type: object + properties: + name: + type: string + description: Name + example: localhost + ids: + type: array + description: IP, CIDR or MAC address + items: + type: string + use_global_settings: + type: boolean + filtering_enabled: + type: boolean + parental_enabled: + type: boolean + safebrowsing_enabled: + type: boolean + safesearch_enabled: + type: boolean + use_global_blocked_services: + type: boolean + blocked_services: + type: array + items: + type: string + upstreams: + type: array + items: + type: string + whois_info: + type: array + items: + $ref: "#/components/schemas/WhoisInfo" + disallowed: + type: boolean + description: > + Whether the client's IP is blocked or not. + disallowed_rule: + type: string + description: > + The rule due to which the client is disallowed. + If `disallowed` is `true`, and this string is empty - it means that + the client IP is disallowed by the "allowed IP list", i.e. it is not included in allowed list. + + WhoisInfo: + type: object + properties: + key: + type: string + Clients: type: object properties: