Merge branch 'master' into 1914-refused-blocking-mode

This commit is contained in:
Andrey Meshkov
2020-09-09 13:51:53 +03:00
50 changed files with 1532 additions and 236 deletions

View File

@@ -45,7 +45,7 @@
"dhcp_warning": "Wenn Sie den DHCP-Server trotzdem aktivieren möchten, stellen Sie sicher, dass sich in Ihrem Netzwerk kein anderer aktiver DHCP-Server befindet. Andernfalls kann es bei angeschlossenen Geräten zu einem Ausfall des Internets kommen!",
"dhcp_error": "Es konnte nicht ermittelt werden, ob es einen anderen DHCP-Server im Netzwerk gibt.",
"dhcp_static_ip_error": "Um den DHCP-Server nutzen zu können, muss eine statische IP-Adresse festgelegt werden. Es konnte nicht ermittelt werden, ob diese Netzwerkschnittstelle mit statischer IP-Adresse konfiguriert ist. Bitte legen Sie eine statische IP-Adresse manuell fest.",
"dhcp_dynamic_ip_found": "Ihr System verwendet die dynamische Konfiguration der IP-Adresse für die Schnittstelle <0>{{interfaceName}}</0>. Um den DHCP-Server nutzen zu können, muss eine statische IP-Adresse festgelegt werden. Ihre aktuelle IP-Adresse ist <0>{{ipAddress}}}</0>. Diese IP-Adresse wird automatisch als statisch festgelegt, sobald Sie auf die Schaltfläche „DHCP aktivieren” klicken.",
"dhcp_dynamic_ip_found": "Ihr System verwendet die dynamische Konfiguration der IP-Adresse für die Schnittstelle <0>{{interfaceName}}</0>. Um den DHCP-Server nutzen zu können, muss eine statische IP-Adresse festgelegt werden. Ihre aktuelle IP-Adresse ist <0>{{ipAddress}}</0>. Diese IP-Adresse wird automatisch als statisch festgelegt, sobald Sie auf die Schaltfläche „DHCP aktivieren” klicken.",
"dhcp_lease_added": "Statischer Lease „{{key}}” erfolgreich hinzugefügt",
"dhcp_lease_deleted": "Statischer Lease „{{key}}” erfolgreich entfernt",
"dhcp_new_static_lease": "Neuer statischer Lease",
@@ -99,7 +99,7 @@
"no_clients_found": "Keine Clients gefunden",
"general_statistics": "Allgemeine Statistiken",
"number_of_dns_query_days": "Anzahl der in den letzten {{count}} Tagen verarbeiteten DNS-Anfragen",
"number_of_dns_query_days_plural": "Anzahl der DNS-Abfragen, die in den letzten {{count}}} Tagen verarbeitet wurden",
"number_of_dns_query_days_plural": "Anzahl der DNS-Abfragen, die in den letzten {{count}} Tagen verarbeitet wurden",
"number_of_dns_query_24_hours": "Anzahl der in den letzten 24 Stunden durchgeführten DNS-Anfragen",
"number_of_dns_query_blocked_24_hours": "Anzahl der durch Werbefilter und Host-Blocklisten geblockten DNS-Anfragen",
"number_of_dns_query_blocked_24_hours_by_sec": "Anzahl der durch das AdGuard-Modul für Internet-Sicherheit blockierten DNS-Anfragen",

View File

@@ -186,6 +186,7 @@
"example_upstream_regular": "regular DNS (over UDP)",
"example_upstream_dot": "encrypted <0>DNS-over-TLS</0>",
"example_upstream_doh": "encrypted <0>DNS-over-HTTPS</0>",
"example_upstream_doq": "encrypted <0>DNS-over-QUIC</0>",
"example_upstream_sdns": "you can use <0>DNS Stamps</0> for <1>DNSCrypt</1> or <2>DNS-over-HTTPS</2> resolvers",
"example_upstream_tcp": "regular DNS (over TCP)",
"all_lists_up_to_date_toast": "All lists are already up-to-date",
@@ -194,6 +195,10 @@
"dns_test_not_ok_toast": "Server \"{{key}}\": could not be used, please check that you've written it correctly",
"unblock": "Unblock",
"block": "Block",
"disallow_this_client": "Disallow this client",
"allow_this_client": "Allow this client",
"block_for_this_client_only": "Block for this client only",
"unblock_for_this_client_only": "Unblock for this client only",
"time_table_header": "Time",
"date": "Date",
"domain_name_table_header": "Domain name",
@@ -327,6 +332,8 @@
"encryption_https_desc": "If HTTPS port is configured, AdGuard Home admin interface will be accessible via HTTPS, and it will also provide DNS-over-HTTPS on '/dns-query' location.",
"encryption_dot": "DNS-over-TLS port",
"encryption_dot_desc": "If this port is configured, AdGuard Home will run a DNS-over-TLS server on this port.",
"encryption_doq": "DNS-over-QUIC port",
"encryption_doq_desc": "If this port is configured, AdGuard Home will run a DNS-over-QUIC server on this port. It's experimental and may not be reliable. Also, there are not too many clients that support it at the moment.",
"encryption_certificates": "Certificates",
"encryption_certificates_desc": "In order to use encryption, you need to provide a valid SSL certificates chain for your domain. You can get a free certificate on <0>{{link}}</0> or you can buy it from one of the trusted Certificate Authorities.",
"encryption_certificates_input": "Copy/paste your PEM-encoded certificates here.",
@@ -360,7 +367,7 @@
"fix": "Fix",
"dns_providers": "Here is a <0>list of known DNS providers</0> to choose from.",
"update_now": "Update now",
"update_failed": "Auto-update failed. Please <a href='https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started#update'>follow the steps</a> to update manually.",
"update_failed": "Auto-update failed. Please <a>follow these steps</a> to update manually.",
"processing_update": "Please wait, AdGuard Home is being updated",
"clients_title": "Clients",
"clients_desc": "Configure devices connected to AdGuard Home",
@@ -570,5 +577,7 @@
"setup_config_to_enable_dhcp_server": "Setup config to enable DHCP server",
"original_response": "Original response",
"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</0> on how to resolve this."
"port_53_faq_link": "Port 53 is often occupied by \"DNSStubListener\" or \"systemd-resolved\" services. Please read <0>this instruction</0> on how to resolve this.",
"adg_will_drop_dns_queries": "AdGuard Home will be dropping all DNS queries from this client.",
"experimental": "Experimental"
}

View File

@@ -1,5 +1,7 @@
import { getIpMatchListStatus, sortIp } from '../helpers/helpers';
import { IP_MATCH_LIST_STATUS } from '../helpers/constants';
import {
countClientsStatistics, findAddressType, getIpMatchListStatus, sortIp,
} from '../helpers/helpers';
import { ADDRESS_TYPES, IP_MATCH_LIST_STATUS } from '../helpers/constants';
describe('getIpMatchListStatus', () => {
describe('IPv4', () => {
@@ -482,3 +484,56 @@ describe('sortIp', () => {
});
});
});
describe('findAddressType', () => {
describe('ip', () => {
expect(findAddressType('127.0.0.1')).toStrictEqual(ADDRESS_TYPES.IP);
});
describe('cidr', () => {
expect(findAddressType('127.0.0.1/8')).toStrictEqual(ADDRESS_TYPES.CIDR);
});
describe('mac', () => {
expect(findAddressType('00:1B:44:11:3A:B7')).toStrictEqual(ADDRESS_TYPES.UNKNOWN);
});
});
describe('countClientsStatistics', () => {
test('single ip', () => {
expect(countClientsStatistics(['127.0.0.1'], {
'127.0.0.1': 1,
})).toStrictEqual(1);
});
test('multiple ip', () => {
expect(countClientsStatistics(['127.0.0.1', '127.0.0.2'], {
'127.0.0.1': 1,
'127.0.0.2': 2,
})).toStrictEqual(1 + 2);
});
test('cidr', () => {
expect(countClientsStatistics(['127.0.0.0/8'], {
'127.0.0.1': 1,
'127.0.0.2': 2,
})).toStrictEqual(1 + 2);
});
test('cidr and multiple ip', () => {
expect(countClientsStatistics(['1.1.1.1', '2.2.2.2', '3.3.3.0/24'], {
'1.1.1.1': 1,
'2.2.2.2': 2,
'3.3.3.3': 3,
})).toStrictEqual(1 + 2 + 3);
});
test('mac', () => {
expect(countClientsStatistics(['00:1B:44:11:3A:B7', '2.2.2.2', '3.3.3.0/24'], {
'1.1.1.1': 1,
'2.2.2.2': 2,
'3.3.3.3': 3,
})).toStrictEqual(2 + 3);
});
test('not found', () => {
expect(countClientsStatistics(['4.4.4.4', '5.5.5.5', '6.6.6.6'], {
'1.1.1.1': 1,
'2.2.2.2': 2,
'3.3.3.3': 3,
})).toStrictEqual(0);
});
});

View File

@@ -34,6 +34,7 @@ export const setTlsConfig = (config) => async (dispatch, getState) => {
values.private_key = btoa(values.private_key);
values.port_https = values.port_https || 0;
values.port_dns_over_tls = values.port_dns_over_tls || 0;
values.port_dns_over_quic = values.port_dns_over_quic || 0;
const response = await apiClient.setTlsConfig(values);
response.certificate_chain = atob(response.certificate_chain);
@@ -59,6 +60,7 @@ export const validateTlsConfig = (config) => async (dispatch) => {
values.private_key = btoa(values.private_key);
values.port_https = values.port_https || 0;
values.port_dns_over_tls = values.port_dns_over_tls || 0;
values.port_dns_over_quic = values.port_dns_over_quic || 0;
const response = await apiClient.validateTlsConfig(values);
response.certificate_chain = atob(response.certificate_chain);

View File

@@ -4,9 +4,10 @@ import axios from 'axios';
import endsWith from 'lodash/endsWith';
import escapeRegExp from 'lodash/escapeRegExp';
import React from 'react';
import { splitByNewLine, sortClients } from '../helpers/helpers';
import {
BLOCK_ACTIONS, CHECK_TIMEOUT, STATUS_RESPONSE, SETTINGS_NAMES, FORM_NAME,
BLOCK_ACTIONS, CHECK_TIMEOUT, STATUS_RESPONSE, SETTINGS_NAMES, FORM_NAME, GETTING_STARTED_LINK,
} from '../helpers/constants';
import { areEqualVersions } from '../helpers/version';
import { getTlsStatus } from './encryption';
@@ -184,7 +185,14 @@ export const getUpdate = () => async (dispatch, getState) => {
dispatch(getUpdateRequest());
const handleRequestError = () => {
dispatch(addNoticeToast({ error: 'update_failed' }));
const options = {
components: {
a: <a href={GETTING_STARTED_LINK} target="_blank"
rel="noopener noreferrer" />,
},
};
dispatch(addNoticeToast({ error: 'update_failed', options }));
dispatch(getUpdateFailure());
};
@@ -545,15 +553,17 @@ export const removeStaticLease = (config) => async (dispatch) => {
export const removeToast = createAction('REMOVE_TOAST');
export const toggleBlocking = (type, domain) => async (dispatch, getState) => {
export const toggleBlocking = (
type, domain, baseRule, baseUnblocking,
) => async (dispatch, getState) => {
const baseBlockingRule = baseRule || `||${domain}^$important`;
const baseUnblockingRule = baseUnblocking || `@@${baseBlockingRule}`;
const { userRules } = getState().filtering;
const lineEnding = !endsWith(userRules, '\n') ? '\n' : '';
const baseRule = `||${domain}^$important`;
const baseUnblocking = `@@${baseRule}`;
const blockingRule = type === BLOCK_ACTIONS.BLOCK ? baseUnblocking : baseRule;
const unblockingRule = type === BLOCK_ACTIONS.BLOCK ? baseRule : baseUnblocking;
const blockingRule = type === BLOCK_ACTIONS.BLOCK ? baseUnblockingRule : baseBlockingRule;
const unblockingRule = type === BLOCK_ACTIONS.BLOCK ? baseBlockingRule : baseUnblockingRule;
const preparedBlockingRule = new RegExp(`(^|\n)${escapeRegExp(blockingRule)}($|\n)`);
const preparedUnblockingRule = new RegExp(`(^|\n)${escapeRegExp(unblockingRule)}($|\n)`);
@@ -576,3 +586,10 @@ export const toggleBlocking = (type, domain) => async (dispatch, getState) => {
dispatch(getFilteringStatus());
};
export const toggleBlockingForClient = (type, domain, client) => {
const baseRule = `||${domain}^$client='${client.replace(/'/g, '/\'')}'`;
const baseUnblocking = `@@${baseRule}`;
return toggleBlocking(type, domain, baseRule, baseUnblocking);
};

View File

@@ -66,3 +66,12 @@ body {
.select--no-warning {
margin-bottom: 1.375rem;
}
.button-action {
visibility: hidden;
}
.logs__row:hover .button-action,
.button-action--active {
visibility: visible;
}

View File

@@ -51,15 +51,16 @@ const renderBlockingButton = (ip) => {
const type = isNotFound ? BLOCK_ACTIONS.BLOCK : BLOCK_ACTIONS.UNBLOCK;
const text = type;
const className = classNames('btn btn-sm', {
'btn-outline-danger': isNotFound,
'btn-outline-secondary': !isNotFound,
const buttonClass = classNames('button-action button-action--main', {
'button-action--unblock': !isNotFound,
});
const toggleClientStatus = (type, ip) => {
const confirmMessage = type === BLOCK_ACTIONS.BLOCK ? 'client_confirm_block' : 'client_confirm_unblock';
const confirmMessage = type === BLOCK_ACTIONS.BLOCK
? `${t('adg_will_drop_dns_queries')} ${t('client_confirm_block', { ip })}`
: t('client_confirm_unblock', { ip });
if (window.confirm(t(confirmMessage, { ip }))) {
if (window.confirm(confirmMessage)) {
dispatch(toggleClientBlock(type, ip));
}
};
@@ -69,7 +70,7 @@ const renderBlockingButton = (ip) => {
return <div className="table__action pl-4">
<button
type="button"
className={className}
className={buttonClass}
onClick={onClick}
disabled={processingSet}
>
@@ -82,7 +83,7 @@ const ClientCell = (row) => {
const { value, original: { info } } = row;
return <>
<div className="logs__row logs__row--overflow logs__row--column d-flex">
<div className="logs__row logs__row--overflow logs__row--column d-flex align-items-center">
{renderFormattedClientCell(value, info, true)}
{renderBlockingButton(value)}
</div>

View File

@@ -164,6 +164,10 @@
color: #9aa0ac;
}
.nav-icon--white {
color: #fff;
}
.header-brand-img {
height: 32px;
}

View File

@@ -1,14 +1,16 @@
import React from 'react';
import React, { useState } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { nanoid } from 'nanoid';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import propTypes from 'prop-types';
import { checkFiltered } from '../../../helpers/helpers';
import { checkFiltered, getBlockingClientName } from '../../../helpers/helpers';
import { BLOCK_ACTIONS } from '../../../helpers/constants';
import { toggleBlocking } from '../../../actions';
import { toggleBlocking, toggleBlockingForClient } from '../../../actions';
import IconTooltip from './IconTooltip';
import { renderFormattedClientCell } from '../../../helpers/renderFormattedClientCell';
import { toggleClientBlock } from '../../../actions/access';
import { getBlockClientInfo } from './helpers';
const ClientCell = ({
client,
@@ -22,6 +24,12 @@ const ClientCell = ({
const autoClients = useSelector((state) => state.dashboard.autoClients, shallowEqual);
const processingRules = useSelector((state) => state.filtering.processingRules);
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;
@@ -53,22 +61,81 @@ const ClientCell = ({
const renderBlockingButton = (isFiltered, domain) => {
const buttonType = isFiltered ? BLOCK_ACTIONS.UNBLOCK : BLOCK_ACTIONS.BLOCK;
const clients = useSelector((state) => state.dashboard.clients);
const buttonClass = classNames('btn btn-sm logs__cell--block-button', {
'btn-outline-secondary': isFiltered,
'btn-outline-danger': !isFiltered,
});
const {
confirmMessage,
buttonKey: blockingClientKey,
type,
} = getBlockClientInfo(client, disallowed_clients);
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));
},
[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));
}
},
};
const onClick = () => dispatch(toggleBlocking(buttonType, domain));
return <button
type="button"
className={buttonClass}
onClick={onClick}
disabled={processingRules}
>
{t(buttonType)}
</button>;
const getOptions = (optionToActionMap) => {
const options = Object.entries(optionToActionMap);
if (options.length === 0) {
return null;
}
return <>{options
.map(([name, onClick]) => <div
key={name}
className="button-action--arrow-option px-4 py-2"
onClick={onClick}
>{t(name)}
</div>)}</>;
};
const content = getOptions(BUTTON_OPTIONS_TO_ACTION_MAP);
const buttonClass = classNames('button-action button-action--main', {
'button-action--unblock': isFiltered,
'button-action--with-options': content,
'button-action--active': isOptionsOpened,
});
const buttonArrowClass = classNames('button-action button-action--arrow', {
'button-action--unblock': isFiltered,
'button-action--active': isOptionsOpened,
});
const containerClass = classNames('button-action__container', {
'button-action__container--detailed': isDetailed,
});
return <div className={containerClass}>
<button type="button"
className={buttonClass}
onClick={onClick}
disabled={processingRules}
>
{t(buttonType)}
</button>
{content && <button className={buttonArrowClass} disabled={processingRules}>
<IconTooltip
className='h-100'
tooltipClass='button-action--arrow-option-container'
xlinkHref='chevron-down'
triggerClass='button-action--icon'
content={content} placement="bottom-end" trigger="click"
onVisibilityChange={setOptionsOpened}
/>
</button>}
</div>;
};
return <div className="o-hidden h-100 logs__cell logs__cell--client" role="gridcell">
@@ -81,9 +148,7 @@ const ClientCell = ({
</div>
{isDetailed && name && !whoisAvailable
&& <div className="detailed-info d-none d-sm-block logs__text"
title={name}>
{name}
</div>}
title={name}>{name}</div>}
</div>
{renderBlockingButton(isFiltered, domain)}
</div>;

View File

@@ -14,6 +14,7 @@ import IconTooltip from './IconTooltip';
const DomainCell = ({
answer_dnssec,
service_name,
client_proto,
domain,
time,
@@ -49,6 +50,10 @@ const DomainCell = ({
protocol,
};
if (service_name) {
requestDetailsObj.check_service = service_name;
}
const sourceData = getSourceData(tracker);
const knownTrackerDataObj = {
@@ -98,7 +103,7 @@ const DomainCell = ({
xlinkHref='privacy' contentItemClass='key-colon' renderContent={renderContent}
place='bottom' />
<div className={valueClass}>
<div className="text-truncate" title={domain}>{domain}</div>
<div className="text-truncate" title={domain}>{service_name || domain}</div>
{details && isDetailed
&& <div className="detailed-info d-none d-sm-block text-truncate"
title={details}>{details}</div>}
@@ -112,6 +117,7 @@ DomainCell.propTypes = {
domain: propTypes.string.isRequired,
time: propTypes.string.isRequired,
type: propTypes.string.isRequired,
service_name: propTypes.string,
tracker: propTypes.object,
};

View File

@@ -6,17 +6,21 @@ import { processContent } from '../../../helpers/helpers';
import Tooltip from '../../ui/Tooltip';
import 'react-popper-tooltip/dist/styles.css';
import './IconTooltip.css';
import { SHOW_TOOLTIP_DELAY } from '../../../helpers/constants';
const IconTooltip = ({
className,
contentItemClass,
columnClass,
triggerClass,
canShowTooltip = true,
xlinkHref,
title,
placement,
tooltipClass,
content,
trigger,
onVisibilityChange,
renderContent = content ? React.Children.map(
processContent(content),
(item, idx) => <div key={idx} className={contentItemClass}>
@@ -36,6 +40,10 @@ const IconTooltip = ({
className={tooltipClassName}
content={tooltipContent}
placement={placement}
triggerClass={triggerClass}
trigger={trigger}
onVisibilityChange={onVisibilityChange}
delayShow={trigger === 'click' ? 0 : SHOW_TOOLTIP_DELAY}
>
{xlinkHref && <svg className={className}>
<use xlinkHref={`#${xlinkHref}`} />
@@ -45,6 +53,8 @@ const IconTooltip = ({
IconTooltip.propTypes = {
className: PropTypes.string,
trigger: PropTypes.string,
triggerClass: PropTypes.string,
contentItemClass: PropTypes.string,
columnClass: PropTypes.string,
tooltipClass: PropTypes.string,
@@ -52,11 +62,9 @@ IconTooltip.propTypes = {
placement: PropTypes.string,
canShowTooltip: PropTypes.bool,
xlinkHref: PropTypes.string,
content: PropTypes.oneOfType([
PropTypes.string,
PropTypes.array,
]),
content: PropTypes.node,
renderContent: PropTypes.arrayOf(PropTypes.element),
onVisibilityChange: PropTypes.func,
};
export default IconTooltip;

View File

@@ -0,0 +1,19 @@
import { getIpMatchListStatus } from '../../../../helpers/helpers';
import { BLOCK_ACTIONS, IP_MATCH_LIST_STATUS } from '../../../../helpers/constants';
export const BUTTON_PREFIX = 'btn_';
export const getBlockClientInfo = (client, disallowed_clients) => {
const ipMatchListStatus = getIpMatchListStatus(client, disallowed_clients);
const isNotFound = ipMatchListStatus === IP_MATCH_LIST_STATUS.NOT_FOUND;
const type = isNotFound ? BLOCK_ACTIONS.BLOCK : BLOCK_ACTIONS.UNBLOCK;
const confirmMessage = isNotFound ? 'client_confirm_block' : 'client_confirm_unblock';
const buttonKey = isNotFound ? 'disallow_this_client' : 'allow_this_client';
return {
confirmMessage,
buttonKey,
type,
};
};

View File

@@ -9,6 +9,7 @@ import {
formatDateTime,
formatElapsedMs,
formatTime,
getBlockingClientName,
getFilterName,
processContent,
} from '../../../helpers/helpers';
@@ -22,12 +23,14 @@ import {
SCHEME_TO_PROTOCOL_MAP,
} from '../../../helpers/constants';
import { getSourceData } from '../../../helpers/trackers/trackers';
import { toggleBlocking } from '../../../actions';
import { toggleBlocking, toggleBlockingForClient } from '../../../actions';
import DateCell from './DateCell';
import DomainCell from './DomainCell';
import ResponseCell from './ResponseCell';
import ClientCell from './ClientCell';
import '../Logs.css';
import { toggleClientBlock } from '../../../actions/access';
import { getBlockClientInfo, BUTTON_PREFIX } from './helpers';
const Row = memo(({
style,
@@ -45,6 +48,13 @@ 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; }
const {
@@ -98,6 +108,26 @@ const Row = memo(({
const filter = getFilterName(filters, whitelistFilters, filterId);
const {
confirmMessage,
buttonKey: blockingClientKey,
type: blockType,
} = getBlockClientInfo(client, disallowed_clients);
const blockingForClientKey = isFiltered ? 'unblock_for_this_client_only' : 'block_for_this_client_only';
const clientNameBlockingFor = getBlockingClientName(clients, client);
const onBlockingForClientClick = () => {
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 detailedData = {
time_table_header: formatTime(time, LONG_TIME_FORMAT),
date: formatDateTime(time, DEFAULT_SHORT_DATE_FORMAT_OPTIONS),
@@ -132,10 +162,12 @@ const Row = memo(({
source_label: source,
validated_with_dnssec: dnssec_enabled ? Boolean(answer_dnssec) : false,
original_response: originalResponse?.join('\n'),
[buttonType]: <div onClick={onToggleBlock}
className={classNames('title--border text-center', {
'bg--danger': isBlocked,
})}>{t(buttonType)}</div>,
[BUTTON_PREFIX + buttonType]: <div onClick={onToggleBlock}
className={classNames('title--border text-center', {
'bg--danger': isBlocked,
})}>{t(buttonType)}</div>,
[BUTTON_PREFIX + blockingForClientKey]: <div onClick={onBlockingForClientClick} className='text-center font-weight-bold py-2'>{t(blockingForClientKey)}</div>,
[BUTTON_PREFIX + blockingClientKey]: <div onClick={onBlockingClientClick} className='text-center font-weight-bold py-2'>{t(blockingClientKey)}</div>,
};
setDetailedDataCurrent(processContent(detailedData));

View File

@@ -10,9 +10,20 @@
--size-client: 123;
--gray-216: rgba(216, 216, 216, 0.23);
--gray-4d: #4D4D4D;
--gray-f3: #F3F3F3;
--gray-8: #888;
--danger: #DF3812;
--white80: rgba(255, 255, 255, 0.8);
--btn-block: #C23814;
--btn-block-disabled: #E3B3A6;
--btn-block-active: #A62200;
--btn-unblock: #888888;
--btn-unblock-disabled: #D8D8D8;
--btn-unblock-active: #4D4D4D;
--option-border-radius: 4px;
}
.logs__text {
@@ -191,6 +202,7 @@
width: 7.6875rem;
flex: var(--size-client) 0 auto;
padding-right: 0;
position: relative;
}
.logs__cell--header__container > .logs__cell--header__item {
@@ -202,12 +214,95 @@
padding-right: 0;
}
.logs__cell--block-button {
max-height: 1.75rem;
position: relative;
left: 10%;
top: 40%;
visibility: hidden;
.button-action__container {
display: flex;
position: absolute;
right: 0;
bottom: 0.5rem;
height: 1.6rem;
}
.button-action__container--detailed {
bottom: 1.3rem;
}
.button-action {
outline: 0 !important;
background: var(--btn-block);
border-radius: var(--option-border-radius);
font-size: 0.8rem;
color: var(--white);
letter-spacing: 0;
text-align: center;
line-height: 28px;
border: 0;
}
.button-action--unblock {
background: var(--btn-unblock);
}
.button-action--main {
padding: 0 1rem;
display: flex;
align-items: center;
}
.button-action--with-options {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.button-action--arrow {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 1px solid var(--white);
width: 1.5625rem;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.button-action:hover {
cursor: pointer;
}
.button-action--arrow .button-action--icon {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.button-action:active {
background: var(--btn-block-active);
}
.button-action--unblock:active {
background: var(--btn-unblock-active);
}
.button-action:disabled {
background: var(--btn-block-disabled);
cursor: default;
}
.button-action--unblock:disabled {
background: var(--btn-unblock-disabled);
}
.button-action--arrow-option:hover {
cursor: pointer;
background: var(--gray-f3);
overflow: hidden;
}
.button-action--arrow-option-container {
overflow: visible;
transform-origin: left;
padding: 1rem 0;
}
.logs__row {
@@ -222,14 +317,6 @@
border-bottom: 2px solid var(--gray-216);
}
.logs__table .logs__row:hover .logs__cell--block-button {
visibility: visible;
}
.logs__table .logs__row .logs__cell--block-button:disabled {
background-color: var(--white) !important;
}
/* QUERY_STATUS_COLORS */
.logs__row--blue {
background-color: var(--blue);
@@ -301,3 +388,28 @@
.logs__table .loading:before {
min-height: 100%;
}
.logs__whois {
display: inline;
font-size: 12px;
white-space: nowrap;
}
.logs__whois::after {
content: "|";
padding: 0 5px;
opacity: 0.3;
}
.logs__whois:last-child::after {
content: "";
}
.logs__whois-icon.icons {
position: relative;
top: -2px;
width: 12px;
height: 12px;
margin-right: 1px;
opacity: 0.5;
}

View File

@@ -23,15 +23,16 @@ import {
} from '../../actions/queryLogs';
import InfiniteTable from './InfiniteTable';
import './Logs.css';
import { BUTTON_PREFIX } from './Cells/helpers';
const processContent = (data, buttonType) => Object.entries(data)
const processContent = (data) => Object.entries(data)
.map(([key, value]) => {
if (!value) {
return null;
}
const isTitle = value === 'title';
const isButton = key === buttonType;
const isButton = key.startsWith(BUTTON_PREFIX);
const isBoolean = typeof value === 'boolean';
const isHidden = isBoolean && value === false;

View File

@@ -4,7 +4,7 @@ import { Trans, withTranslation } from 'react-i18next';
import ReactTable from 'react-table';
import { MODAL_TYPE } from '../../../helpers/constants';
import { splitByNewLine } from '../../../helpers/helpers';
import { splitByNewLine, countClientsStatistics } from '../../../helpers/helpers';
import Card from '../../ui/Card';
import Modal from './Modal';
import CellWrap from '../../ui/CellWrap';
@@ -204,7 +204,10 @@ class ClientsTable extends Component {
{
Header: this.props.t('requests_count'),
id: 'statistics',
accessor: (row) => this.props.normalizedTopClients.configured[row.name] || 0,
accessor: (row) => countClientsStatistics(
row.ids,
this.props.normalizedTopClients.auto,
),
sortMethod: (a, b) => b - a,
minWidth: 120,
Cell: (row) => {

View File

@@ -14,7 +14,7 @@ const getFormattedWhois = (value, t) => {
<div key={key} title={t(key)}>
{icon && (
<Fragment>
<svg className="logs__whois-icon text-muted-dark icons">
<svg className="logs__whois-icon text-muted-dark icons icon--24">
<use xlinkHref={`#${icon}`} />
</svg>
&nbsp;

View File

@@ -63,6 +63,27 @@ const Examples = (props) => (
</Trans>
</span>
</li>
<li>
<code>quic://dns-unfiltered.adguard.com:784</code> &nbsp;
<span>
<Trans
components={[
<a
href="https://tools.ietf.org/html/draft-huitema-quic-dnsoquic-07"
target="_blank"
rel="noopener noreferrer"
key="0"
>
DNS-over-QUIC
</a>,
]}
>
example_upstream_doq
</Trans>
&nbsp;
<span className="text-lowercase">(<Trans>experimental</Trans>)</span>
</span>
</li>
<li>
<code>tcp://9.9.9.9</code> <Trans>example_upstream_tcp</Trans>
</li>

View File

@@ -11,11 +11,15 @@ import {
renderRadioField,
toNumber,
} from '../../../helpers/form';
import { validateIsSafePort, validatePort, validatePortTLS } from '../../../helpers/validators';
import {
validateIsSafePort, validatePort, validatePortQuic, validatePortTLS,
} from '../../../helpers/validators';
import i18n from '../../../i18n';
import KeyStatus from './KeyStatus';
import CertificateStatus from './CertificateStatus';
import { DNS_OVER_TLS_PORT, FORM_NAME, STANDARD_HTTPS_PORT } from '../../../helpers/constants';
import {
DNS_OVER_QUIC_PORT, DNS_OVER_TLS_PORT, FORM_NAME, STANDARD_HTTPS_PORT,
} from '../../../helpers/constants';
const validate = (values) => {
const errors = {};
@@ -38,6 +42,7 @@ const clearFields = (change, setTlsConfig, t) => {
certificate_path: '',
port_https: STANDARD_HTTPS_PORT,
port_dns_over_tls: DNS_OVER_TLS_PORT,
port_dns_over_quic: DNS_OVER_QUIC_PORT,
server_name: '',
force_https: false,
enabled: false,
@@ -189,6 +194,30 @@ let Form = (props) => {
</div>
</div>
</div>
<div className="col-lg-6">
<div className="form__group form__group--settings">
<label className="form__label" htmlFor="port_dns_over_quic">
<Trans>encryption_doq</Trans>
&nbsp;
<span className="text-lowercase">(<Trans>experimental</Trans>)</span>
</label>
<Field
id="port_dns_over_quic"
name="port_dns_over_quic"
component={renderInputField}
type="number"
className="form-control"
placeholder={t('encryption_doq')}
validate={[validatePortQuic]}
normalize={toNumber}
onChange={handleChange}
disabled={!isEnabled}
/>
<div className="form__desc">
<Trans>encryption_doq_desc</Trans>
</div>
</div>
</div>
</div>
<div className="row">
<div className="col-12">

View File

@@ -66,6 +66,7 @@ class Encryption extends Component {
force_https,
port_https,
port_dns_over_tls,
port_dns_over_quic,
certificate_chain,
private_key,
certificate_path,
@@ -78,6 +79,7 @@ class Encryption extends Component {
force_https,
port_https,
port_dns_over_tls,
port_dns_over_quic,
certificate_chain,
private_key,
certificate_path,

View File

@@ -54,7 +54,7 @@
}
.form__message--error {
color: var(--red);
color: #cd201f;
}
.form__message--left-pad {

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Trans } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { TOAST_TIMEOUTS } from '../../helpers/constants';
import { removeToast } from '../../actions';
@@ -9,8 +9,8 @@ const Toast = ({
id,
message,
type,
options,
}) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const [timerId, setTimerId] = useState(null);
@@ -30,7 +30,12 @@ const Toast = ({
return <div className={`toast toast--${type}`}
onMouseOver={clearRemoveToastTimeout}
onMouseOut={setRemoveToastTimeout}>
<p className="toast__content">{t(message)}</p>
<p className="toast__content">
<Trans
i18nKey={message}
{...options}
/>
</p>
<button className="toast__dismiss" onClick={removeCurrentToast}>
<svg stroke="#fff" fill="none" width="20" height="20" strokeWidth="2"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
@@ -45,6 +50,7 @@ Toast.propTypes = {
id: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
options: PropTypes.object,
};
export default Toast;

View File

@@ -16,7 +16,11 @@
.card-table-overflow--limited {
overflow-y: auto;
max-height: 280px;
max-height: 17.5rem;
}
.card-table-overflow--limited.clients__table {
max-height: 18rem;
}
.card-actions {

View File

@@ -344,6 +344,14 @@ const Icons = () => (
<path d="M60 54.5h8v40h-8zM60 35.5h8v8h-8z" />
</svg>
</symbol>
<symbol id="chevron-down" viewBox="0 0 24 24">
<g fill="none" fillRule="evenodd">
<path d="M0 0h24v24H0z" fill="#878787" fillOpacity=".01" />
<path stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
d="M8.036 10.93l3.93 4.07 4.068-3.93" />
</g>
</symbol>
</svg>
);

View File

@@ -20,6 +20,7 @@ const Tooltip = ({
trigger = 'hover',
delayShow = SHOW_TOOLTIP_DELAY,
delayHide = HIDE_TOOLTIP_DELAY,
onVisibilityChange,
}) => {
const { t } = useTranslation();
const touchEventsAvailable = 'ontouchstart' in window;
@@ -73,6 +74,7 @@ const Tooltip = ({
delayHide={delayHideValue}
delayShow={delayShowValue}
tooltip={renderTooltip}
onVisibilityChange={onVisibilityChange}
>
{renderTrigger}
</TooltipTrigger>
@@ -90,10 +92,11 @@ Tooltip.propTypes = {
).isRequired,
placement: propTypes.string,
trigger: propTypes.string,
delayHide: propTypes.string,
delayShow: propTypes.string,
delayHide: propTypes.number,
delayShow: propTypes.number,
className: propTypes.string,
triggerClass: propTypes.string,
onVisibilityChange: propTypes.func,
};
export default Tooltip;

View File

@@ -53,6 +53,8 @@ export const REPOSITORY = {
export const PRIVACY_POLICY_LINK = 'https://adguard.com/privacy/home.html';
export const PORT_53_FAQ_LINK = 'https://github.com/AdguardTeam/AdGuardHome/wiki/FAQ#bindinuse';
export const GETTING_STARTED_LINK = 'https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started#update';
export const ADDRESS_IN_USE_TEXT = 'address already in use';
export const INSTALL_FIRST_STEP = 1;
@@ -69,6 +71,7 @@ export const STANDARD_DNS_PORT = 53;
export const STANDARD_WEB_PORT = 80;
export const STANDARD_HTTPS_PORT = 443;
export const DNS_OVER_TLS_PORT = 853;
export const DNS_OVER_QUIC_PORT = 784;
export const MAX_PORT = 65535;
export const EMPTY_DATE = '0001-01-01T00:00:00Z';
@@ -76,8 +79,6 @@ export const EMPTY_DATE = '0001-01-01T00:00:00Z';
export const DEBOUNCE_TIMEOUT = 300;
export const DEBOUNCE_FILTER_TIMEOUT = 500;
export const CHECK_TIMEOUT = 1000;
export const SUCCESS_TOAST_TIMEOUT = 5000;
export const FAILURE_TOAST_TIMEOUT = 30000;
export const HIDE_TOOLTIP_DELAY = 300;
export const SHOW_TOOLTIP_DELAY = 200;
export const MODAL_OPEN_TIMEOUT = 150;
@@ -541,8 +542,17 @@ export const TOAST_TYPES = {
NOTICE: 'notice',
};
export const SUCCESS_TOAST_TIMEOUT = 5000;
export const FAILURE_TOAST_TIMEOUT = 30000;
export const TOAST_TIMEOUTS = {
[TOAST_TYPES.SUCCESS]: 5000,
[TOAST_TYPES.ERROR]: 30000,
[TOAST_TYPES.NOTICE]: 30000,
[TOAST_TYPES.SUCCESS]: SUCCESS_TOAST_TIMEOUT,
[TOAST_TYPES.ERROR]: FAILURE_TOAST_TIMEOUT,
[TOAST_TYPES.NOTICE]: FAILURE_TOAST_TIMEOUT,
};
export const ADDRESS_TYPES = {
IP: 'IP',
CIDR: 'CIDR',
UNKNOWN: 'UNKNOWN',
};

View File

@@ -14,6 +14,7 @@ import queryString from 'query-string';
import { getTrackerData } from './trackers/trackers';
import {
ADDRESS_TYPES,
CHECK_TIMEOUT,
CUSTOM_FILTERING_RULES_ID,
DEFAULT_DATE_FORMAT_OPTIONS,
@@ -97,7 +98,7 @@ export const normalizeLogs = (logs) => logs.map((log) => {
filterId,
rule,
status,
serviceName: service_name,
service_name,
originalAnswer: original_answer,
originalResponse: processResponse(original_answer),
tracker: getTrackerData(domain),
@@ -509,6 +510,18 @@ const isIpMatchCidr = (parsedIp, parsedCidr) => {
}
};
export const isIpInCidr = (ip, cidr) => {
try {
const parsedIp = ipaddr.parse(ip);
const parsedCidr = ipaddr.parseCIDR(cidr);
return isIpMatchCidr(parsedIp, parsedCidr);
} catch (e) {
console.error(e);
return false;
}
};
/**
* The purpose of this method is to quickly check
* if this IP can possibly be in the specified CIDR range.
@@ -578,6 +591,29 @@ const isIpQuickMatchCIDR = (ip, listItem) => {
return false;
};
/**
*
* @param ipOrCidr
* @returns {'IP' | 'CIDR' | 'UNKNOWN'}
*
*/
export const findAddressType = (address) => {
try {
const cidrMaybe = address.includes('/');
if (!cidrMaybe && ipaddr.isValid(address)) {
return ADDRESS_TYPES.IP;
}
if (cidrMaybe && ipaddr.parseCIDR(address)) {
return ADDRESS_TYPES.CIDR;
}
return ADDRESS_TYPES.UNKNOWN;
} catch (e) {
return ADDRESS_TYPES.UNKNOWN;
}
};
/**
* @param ip {string}
* @param list {string}
@@ -622,6 +658,42 @@ export const getIpMatchListStatus = (ip, list) => {
}
};
/**
* @param ids {string[]}
* @returns {Object}
*/
export const separateIpsAndCidrs = (ids) => ids.reduce((acc, curr) => {
const addressType = findAddressType(curr);
if (addressType === ADDRESS_TYPES.IP) {
acc.ips.push(curr);
}
if (addressType === ADDRESS_TYPES.CIDR) {
acc.cidrs.push(curr);
}
return acc;
}, { ips: [], cidrs: [] });
export const countClientsStatistics = (ids, autoClients) => {
const { ips, cidrs } = separateIpsAndCidrs(ids);
const ipsCount = ips.reduce((acc, curr) => {
const count = autoClients[curr] || 0;
return acc + count;
}, 0);
const cidrsCount = Object.entries(autoClients)
.reduce((acc, curr) => {
const [id, count] = curr;
if (cidrs.some((cidr) => isIpInCidr(id, cidr))) {
// eslint-disable-next-line no-param-reassign
acc += count;
}
return acc;
}, 0);
return ipsCount + cidrsCount;
};
/**
* @param {string} elapsedMs
@@ -836,3 +908,21 @@ export const isScrolledIntoView = (el) => {
return elemTop < window.innerHeight && elemBottom >= 0;
};
/**
* If this is a manually created client, return its name.
* If this is a "runtime" client, return it's IP address.
* @param clients {Array.<object>}
* @param ip {string}
* @returns {string}
*/
export const getBlockingClientName = (clients, ip) => {
for (let i = 0; i < clients.length; i += 1) {
const client = clients[i];
if (client.ids.includes(ip)) {
return client.name;
}
}
return ip;
};

View File

@@ -9,7 +9,7 @@ const getFormattedWhois = (whois) => {
.map((key) => {
const icon = WHOIS_ICONS[key];
return (
<span className="logs__whois text-muted " key={key} title={whoisInfo[key]}>
<span className="logs__whois text-muted" key={key} title={whoisInfo[key]}>
{icon && (
<>
<svg className="logs__whois-icon icons icon--18">

View File

@@ -180,6 +180,12 @@ export const validatePortTLS = (value) => {
return undefined;
};
/**
* @param value {number}
* @returns {undefined|string}
*/
export const validatePortQuic = validatePortTLS;
/**
* @param value {number}
* @returns {undefined|string}

View File

@@ -15,6 +15,7 @@ const toasts = handleActions({
const errorToast = {
id: nanoid(),
message,
options: payload.options,
type: TOAST_TYPES.ERROR,
};
@@ -35,6 +36,7 @@ const toasts = handleActions({
const noticeToast = {
id: nanoid(),
message: payload.error.toString(),
options: payload.options,
type: TOAST_TYPES.NOTICE,
};