diff --git a/client/src/__tests__/helpers.test.js b/client/src/__tests__/helpers.test.js index 674214db..f974cca6 100644 --- a/client/src/__tests__/helpers.test.js +++ b/client/src/__tests__/helpers.test.js @@ -1,4 +1,4 @@ -import { getIpMatchListStatus } from '../helpers/helpers'; +import { getIpMatchListStatus, sortIp } from '../helpers/helpers'; import { IP_MATCH_LIST_STATUS } from '../helpers/constants'; describe('getIpMatchListStatus', () => { @@ -129,3 +129,356 @@ describe('getIpMatchListStatus', () => { }); }); }); + +describe('sortIp', () => { + describe('ipv4', () => { + test('one octet differ', () => { + const arr = [ + '127.0.2.0', + '127.0.3.0', + '127.0.1.0', + ]; + const sortedArr = [ + '127.0.1.0', + '127.0.2.0', + '127.0.3.0', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + }); + test('few octets differ', () => { + const arr = [ + '192.168.11.10', + '192.168.10.0', + '192.168.11.11', + '192.168.10.10', + '192.168.1.10', + '192.168.0.1', + '192.168.1.0', + '192.168.1.1', + '192.168.11.0', + '192.168.0.10', + '192.168.10.11', + '192.168.0.11', + '192.168.1.11', + '192.168.0.0', + '192.168.10.1', + '192.168.11.1', + ]; + const sortedArr = [ + '192.168.0.0', + '192.168.0.1', + '192.168.0.10', + '192.168.0.11', + '192.168.1.0', + '192.168.1.1', + '192.168.1.10', + '192.168.1.11', + '192.168.10.0', + '192.168.10.1', + '192.168.10.10', + '192.168.10.11', + '192.168.11.0', + '192.168.11.1', + '192.168.11.10', + '192.168.11.11', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + + // Example from issue https://github.com/AdguardTeam/AdGuardHome/issues/1778#issuecomment-640937599 + const arr2 = [ + '192.168.2.11', + '192.168.3.1', + '192.168.2.100', + '192.168.2.2', + '192.168.2.1', + '192.168.2.10', + '192.168.2.99', + '192.168.2.200', + '192.168.2.199', + ]; + const sortedArr2 = [ + '192.168.2.1', + '192.168.2.2', + '192.168.2.10', + '192.168.2.11', + '192.168.2.99', + '192.168.2.100', + '192.168.2.199', + '192.168.2.200', + '192.168.3.1', + ]; + expect(arr2.sort(sortIp)).toStrictEqual(sortedArr2); + }); + }); + describe('ipv6', () => { + test('only long form', () => { + const arr = [ + '2001:db8:11a3:9d7:0:0:0:2', + '2001:db8:11a3:9d7:0:0:0:3', + '2001:db8:11a3:9d7:0:0:0:1', + ]; + const sortedArr = [ + '2001:db8:11a3:9d7:0:0:0:1', + '2001:db8:11a3:9d7:0:0:0:2', + '2001:db8:11a3:9d7:0:0:0:3', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + }); + test('only short form', () => { + const arr = [ + '2001:db8::', + '2001:db7::', + '2001:db9::', + ]; + const sortedArr = [ + '2001:db7::', + '2001:db8::', + '2001:db9::', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + }); + test('long and short forms', () => { + const arr = [ + '2001:db8::', + '2001:db7:11a3:9d7:0:0:0:2', + '2001:db6:11a3:9d7:0:0:0:1', + '2001:db6::', + '2001:db7:11a3:9d7:0:0:0:1', + '2001:db7::', + ]; + const sortedArr = [ + '2001:db6::', + '2001:db6:11a3:9d7:0:0:0:1', + '2001:db7::', + '2001:db7:11a3:9d7:0:0:0:1', + '2001:db7:11a3:9d7:0:0:0:2', + '2001:db8::', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + }); + }); + describe('ipv4 and ipv6', () => { + test('ipv6 long form', () => { + const arr = [ + '127.0.0.3', + '2001:db8:11a3:9d7:0:0:0:1', + '2001:db8:11a3:9d7:0:0:0:3', + '127.0.0.1', + '2001:db8:11a3:9d7:0:0:0:2', + '127.0.0.2', + ]; + const sortedArr = [ + '127.0.0.1', + '127.0.0.2', + '127.0.0.3', + '2001:db8:11a3:9d7:0:0:0:1', + '2001:db8:11a3:9d7:0:0:0:2', + '2001:db8:11a3:9d7:0:0:0:3', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + }); + test('ipv6 short form', () => { + const arr = [ + '2001:db8:11a3:9d7::1', + '127.0.0.3', + '2001:db8:11a3:9d7::3', + '127.0.0.1', + '2001:db8:11a3:9d7::2', + '127.0.0.2', + ]; + const sortedArr = [ + '127.0.0.1', + '127.0.0.2', + '127.0.0.3', + '2001:db8:11a3:9d7::1', + '2001:db8:11a3:9d7::2', + '2001:db8:11a3:9d7::3', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + }); + test('ipv6 long and short forms', () => { + const arr = [ + '2001:db8:11a3:9d7::1', + '127.0.0.3', + '2001:db8:11a3:9d7:0:0:0:2', + '127.0.0.1', + '2001:db8:11a3:9d7::3', + '127.0.0.2', + ]; + const sortedArr = [ + '127.0.0.1', + '127.0.0.2', + '127.0.0.3', + '2001:db8:11a3:9d7::1', + '2001:db8:11a3:9d7:0:0:0:2', + '2001:db8:11a3:9d7::3', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + }); + test('always put ipv4 before ipv6', () => { + const arr = [ + '::1', + '0.0.0.2', + '127.0.0.1', + '::2', + '2001:db8:11a3:9d7:0:0:0:2', + '0.0.0.1', + '2001:db8:11a3:9d7::1', + ]; + const sortedArr = [ + '0.0.0.1', + '0.0.0.2', + '127.0.0.1', + '::1', + '::2', + '2001:db8:11a3:9d7::1', + '2001:db8:11a3:9d7:0:0:0:2', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + }); + }); + describe('cidr', () => { + test('only ipv4 cidr', () => { + const arr = [ + '192.168.0.1/9', + '192.168.0.1/7', + '192.168.0.1/8', + ]; + const sortedArr = [ + '192.168.0.1/7', + '192.168.0.1/8', + '192.168.0.1/9', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + }); + test('ipv4 and cidr ipv4', () => { + const arr = [ + '192.168.0.1/9', + '192.168.0.1', + '192.168.0.1/32', + '192.168.0.1/7', + '192.168.0.1/8', + ]; + const sortedArr = [ + '192.168.0.1/7', + '192.168.0.1/8', + '192.168.0.1/9', + '192.168.0.1/32', + '192.168.0.1', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + }); + test('only ipv6 cidr', () => { + const arr = [ + '2001:db8:11a3:9d7::1/32', + '2001:db8:11a3:9d7::1/64', + '2001:db8:11a3:9d7::1/128', + '2001:db8:11a3:9d7::1/24', + ]; + const sortedArr = [ + '2001:db8:11a3:9d7::1/24', + '2001:db8:11a3:9d7::1/32', + '2001:db8:11a3:9d7::1/64', + '2001:db8:11a3:9d7::1/128', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + }); + test('ipv6 and cidr ipv6', () => { + const arr = [ + '2001:db8:11a3:9d7::1/32', + '2001:db8:11a3:9d7::1', + '2001:db8:11a3:9d7::1/64', + '2001:db8:11a3:9d7::1/128', + '2001:db8:11a3:9d7::1/24', + ]; + const sortedArr = [ + '2001:db8:11a3:9d7::1/24', + '2001:db8:11a3:9d7::1/32', + '2001:db8:11a3:9d7::1/64', + '2001:db8:11a3:9d7::1/128', + '2001:db8:11a3:9d7::1', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + }); + }); + describe('invalid input', () => { + const originalError = console.error; + + beforeEach(() => { + console.error = jest.fn(); + }); + + afterEach(() => { + expect(console.error).toHaveBeenCalled(); + console.error = originalError; + }); + + test('invalid strings', () => { + const arr = ['invalid ip', 'invalid cidr']; + expect(arr.sort(sortIp)).toStrictEqual(arr); + }); + test('invalid ip', () => { + const arr = ['127.0.0.2.', '.127.0.0.1.', '.2001:db8:11a3:9d7:0:0:0:0']; + expect(arr.sort(sortIp)).toStrictEqual(arr); + }); + test('invalid cidr', () => { + const arr = ['127.0.0.2/33', '2001:db8:11a3:9d7:0:0:0:0/129']; + expect(arr.sort(sortIp)).toStrictEqual(arr); + }); + test('valid and invalid ip', () => { + const arr = ['127.0.0.4.', '127.0.0.1', '.127.0.0.3', '127.0.0.2']; + expect(arr.sort(sortIp)).toStrictEqual(arr); + }); + }); + describe('mixed', () => { + test('ipv4, ipv6 in short and long forms and cidr', () => { + const arr = [ + '2001:db8:11a3:9d7:0:0:0:1/32', + '192.168.1.2', + '127.0.0.2', + '2001:db8:11a3:9d7::1/128', + '2001:db8:11a3:9d7:0:0:0:1', + '127.0.0.1/12', + '192.168.1.1', + '2001:db8::/32', + '2001:db8:11a3:9d7::1/24', + '192.168.1.2/12', + '2001:db7::/32', + '127.0.0.1', + '2001:db8:11a3:9d7:0:0:0:2', + '192.168.1.1/24', + '2001:db7::/64', + '2001:db7::', + '2001:db8::', + '2001:db8:11a3:9d7:0:0:0:1/128', + '192.168.1.1/12', + '127.0.0.1/32', + '::1', + ]; + const sortedArr = [ + '127.0.0.1/12', + '127.0.0.1/32', + '127.0.0.1', + '127.0.0.2', + '192.168.1.1/12', + '192.168.1.1/24', + '192.168.1.1', + '192.168.1.2/12', + '192.168.1.2', + '::1', + '2001:db7::/32', + '2001:db7::/64', + '2001:db7::', + '2001:db8::/32', + '2001:db8::', + '2001:db8:11a3:9d7::1/24', + '2001:db8:11a3:9d7:0:0:0:1/32', + '2001:db8:11a3:9d7::1/128', + '2001:db8:11a3:9d7:0:0:0:1/128', + '2001:db8:11a3:9d7:0:0:0:1', + '2001:db8:11a3:9d7:0:0:0:2', + ]; + expect(arr.sort(sortIp)).toStrictEqual(sortedArr); + }); + }); +}); diff --git a/client/src/components/Dashboard/Clients.js b/client/src/components/Dashboard/Clients.js index 0716ab33..c6ac1c16 100644 --- a/client/src/components/Dashboard/Clients.js +++ b/client/src/components/Dashboard/Clients.js @@ -6,7 +6,7 @@ import { Trans, withTranslation } from 'react-i18next'; import Card from '../ui/Card'; import Cell from '../ui/Cell'; -import { getPercent, getIpMatchListStatus } from '../../helpers/helpers'; +import { getPercent, getIpMatchListStatus, sortIp } from '../../helpers/helpers'; import { IP_MATCH_LIST_STATUS, STATUS_COLORS } from '../../helpers/constants'; import { formatClientCell } from '../../helpers/formatClientCell'; @@ -99,7 +99,7 @@ const Clients = ({ { Header: 'IP', accessor: 'ip', - sortMethod: (a, b) => parseInt(a.replace(/\./g, ''), 10) - parseInt(b.replace(/\./g, ''), 10), + sortMethod: sortIp, Cell: clientCell(t, toggleClientStatus, processingAccessSet, disallowedClients), }, { diff --git a/client/src/components/Settings/Clients/AutoClients.js b/client/src/components/Settings/Clients/AutoClients.js index 743e62f0..f4f5d502 100644 --- a/client/src/components/Settings/Clients/AutoClients.js +++ b/client/src/components/Settings/Clients/AutoClients.js @@ -8,6 +8,7 @@ import CellWrap from '../../ui/CellWrap'; import whoisCell from './whoisCell'; import LogsSearchLink from '../../ui/LogsSearchLink'; +import { sortIp } from '../../../helpers/helpers'; const COLUMN_MIN_WIDTH = 200; @@ -18,6 +19,7 @@ class AutoClients extends Component { accessor: 'ip', minWidth: COLUMN_MIN_WIDTH, Cell: CellWrap, + sortMethod: sortIp, }, { Header: this.props.t('table_name'), diff --git a/client/src/components/Settings/Dhcp/Leases.js b/client/src/components/Settings/Dhcp/Leases.js index 5ba00819..70400538 100644 --- a/client/src/components/Settings/Dhcp/Leases.js +++ b/client/src/components/Settings/Dhcp/Leases.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import ReactTable from 'react-table'; import { Trans, withTranslation } from 'react-i18next'; import { LEASES_TABLE_DEFAULT_PAGE_SIZE } from '../../../helpers/constants'; +import { sortIp } from '../../../helpers/helpers'; class Leases extends Component { cellWrap = ({ value }) => ( @@ -27,6 +28,7 @@ class Leases extends Component { Header: 'IP', accessor: 'ip', Cell: this.cellWrap, + sortMethod: sortIp, }, { Header: dhcp_table_hostname, accessor: 'hostname', diff --git a/client/src/components/Settings/Dhcp/StaticLeases/index.js b/client/src/components/Settings/Dhcp/StaticLeases/index.js index 7a24a3b0..72383cc4 100644 --- a/client/src/components/Settings/Dhcp/StaticLeases/index.js +++ b/client/src/components/Settings/Dhcp/StaticLeases/index.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import ReactTable from 'react-table'; import { Trans, withTranslation } from 'react-i18next'; import { LEASES_TABLE_DEFAULT_PAGE_SIZE } from '../../../../helpers/constants'; - +import { sortIp } from '../../../../helpers/helpers'; import Modal from './Modal'; class StaticLeases extends Component { @@ -49,6 +49,7 @@ class StaticLeases extends Component { { Header: 'IP', accessor: 'ip', + sortMethod: sortIp, Cell: this.cellWrap, }, { diff --git a/client/src/helpers/helpers.js b/client/src/helpers/helpers.js index 0d54c1a3..6003d0b8 100644 --- a/client/src/helpers/helpers.js +++ b/client/src/helpers/helpers.js @@ -681,3 +681,68 @@ export const processContent = (content) => (Array.isArray(content) export const getObjectKeysSorted = (object, sortKey) => Object.entries(object) .sort(([, { [sortKey]: order1 }], [, { [sortKey]: order2 }]) => order1 - order2) .map(([key]) => key); + +/** + * @param ip + * @returns {[IPv4|IPv6, 33|129]} + */ +const getParsedIpWithPrefixLength = (ip) => { + const MAX_PREFIX_LENGTH_V4 = 32; + const MAX_PREFIX_LENGTH_V6 = 128; + + const parsedIp = ipaddr.parse(ip); + const prefixLength = parsedIp.kind() === 'ipv4' ? MAX_PREFIX_LENGTH_V4 : MAX_PREFIX_LENGTH_V6; + + // Increment prefix length to always put IP after CIDR, e.g. 127.0.0.1/32, 127.0.0.1 + return [parsedIp, prefixLength + 1]; +}; + +/** + * Helper function for IP and CIDR comparison (supports both v4 and v6) + * @param item - ip or cidr + * @returns {number[]} + */ +const getAddressesComparisonBytes = (item) => { + // Sort ipv4 before ipv6 + const IP_V4_COMPARISON_CODE = 0; + const IP_V6_COMPARISON_CODE = 1; + + const [parsedIp, cidr] = ipaddr.isValid(item) + ? getParsedIpWithPrefixLength(item) + : ipaddr.parseCIDR(item); + + const [normalizedBytes, ipVersionComparisonCode] = parsedIp.kind() === 'ipv4' + ? [parsedIp.toIPv4MappedAddress().parts, IP_V4_COMPARISON_CODE] + : [parsedIp.parts, IP_V6_COMPARISON_CODE]; + + return [ipVersionComparisonCode, ...normalizedBytes, cidr]; +}; + +/** + * Compare function for IP and CIDR sort in ascending order (supports both v4 and v6) + * @param a + * @param b + * @returns {number} -1 | 0 | 1 + */ +export const sortIp = (a, b) => { + try { + const comparisonBytesA = getAddressesComparisonBytes(a); + const comparisonBytesB = getAddressesComparisonBytes(b); + + for (let i = 0; i < comparisonBytesA.length; i += 1) { + const byteA = comparisonBytesA[i]; + const byteB = comparisonBytesB[i]; + + if (byteA === byteB) { + // eslint-disable-next-line no-continue + continue; + } + return byteA > byteB ? 1 : -1; + } + + return 0; + } catch (e) { + console.error(e); + return 0; + } +};