Merge remote-tracking branch 'origin/master' into AGH-31-use-confs

# Conflicts:
#	internal/home/config.go
This commit is contained in:
Dimitry Kolyshev
2026-10-05 09:40:49 +07:00
19 changed files with 822 additions and 93 deletions
+1 -2
View File
@@ -283,9 +283,8 @@
'uses': 'AdGuardSoftwareLimited/actions/.github/workflows/create-gh-release.yml@master'
'with':
'artifact_name': 'adguardhome-github'
# TODO(e.burkov): Set the 'prerelease' argument based on the channel.
'draft': true
'prerelease': |
${{ startsWith(github.ref_name, 'beta-v') }}
# Use the actual application version, e.g. v0.108.1, as the tag, and not
# the branch name, so that it matches the announcement_url written to
# version.json by scripts/make/pack-release.sh.
+1
View File
@@ -1 +1,2 @@
.twosky.json
CHANGELOG.md
+13
View File
@@ -32,12 +32,25 @@ NOTE: Add new changes BELOW THIS COMMENT.
- DHCP server persisting uncommitted leases with zero expiry after `DHCPDISCOVER` messages ([#8572]).
- DHCPv4 settings not being saved when an IPv6-capable interface is selected and DHCPv6 is not configured ([#8608]).
- DHCP not being enabled when only the DHCPv6 section is configured.
- DHCP IPv4 range subnet validation not working ([#8609]).
- Client ID validation rejecting valid IPv6 CIDRs with an embedded dotted-decimal IPv4 address ([#8610]).
- Client ID validation accepting IPv4 addresses with leading-zero octets (`192.168.01.1`), malformed MAC addresses, and ClientIDs with leading or trailing hyphens.
### Removed
- The support for macOS 13 Ventura, see [Go 1.27 release notes][go-1.27.1-notes].
[#8565]: https://github.com/AdguardTeam/AdGuardHome/issues/8565
[#8572]: https://github.com/AdguardTeam/AdGuardHome/issues/8572
[#8608]: https://github.com/AdguardTeam/AdGuardHome/issues/8608
[#8609]: https://github.com/AdguardTeam/AdGuardHome/issues/8609
[#8610]: https://github.com/AdguardTeam/AdGuardHome/issues/8610
[#8613]: https://github.com/AdguardTeam/AdGuardHome/issues/8613
[go-1.27.1]: https://groups.google.com/g/golang-announce/c/QiTRm-HGGtI
[go-1.27.1-notes]: https://go.dev/doc/go1.27#darwin
+87 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, test, afterEach, vi, beforeEach, it } from 'vitest';
import { sortIp, countClientsStatistics, findAddressType, subnetMaskToBitMask } from '../helpers/helpers';
import {
sortIp,
countClientsStatistics,
findAddressType,
isValidCidr,
isValidIpv6,
subnetMaskToBitMask,
} from '../helpers/helpers';
import { ADDRESS_TYPES } from '../helpers/constants';
describe('sortIp', () => {
@@ -468,3 +475,82 @@ describe('subnetMaskToBitMask', () => {
).toEqual(true);
});
});
describe('isValidCidr', () => {
test('accepts IPv4 and IPv6 CIDR ranges', () => {
expect(isValidCidr('192.168.1.0/24')).toBe(true);
expect(isValidCidr('0.0.0.0/0')).toBe(true);
expect(isValidCidr('2001:db8::/64')).toBe(true);
});
// REGRESSION: GH #8610 — the retired R_CIDR_IPV6 used a literal `d` where
// it needed `\d`, so mixed-notation ranges were rejected.
test('accepts IPv6 CIDRs with an embedded dotted-decimal IPv4 address', () => {
expect(isValidCidr('::ffff:192.168.1.0/120')).toBe(true);
expect(isValidCidr('::ffff:1.2.3.4/128')).toBe(true);
});
test('rejects IPv6 CIDRs with a zone ID', () => {
// netip.ParsePrefix rejects zones in a prefix, even though
// netip.ParseAddr accepts them on a bare address.
expect(isValidCidr('fe80::1%eth0/64')).toBe(false);
});
test('rejects prefix lengths spelled with leading zeros', () => {
expect(isValidCidr('192.168.1.0/024')).toBe(false);
expect(isValidCidr('2001:db8::/064')).toBe(false);
});
test('rejects non-canonical IPv4 addresses', () => {
expect(isValidCidr('127.1/24')).toBe(false);
expect(isValidCidr('0x7f.0.0.1/8')).toBe(false);
expect(isValidCidr('010.0.0.1/8')).toBe(false);
expect(isValidCidr('12345/8')).toBe(false);
});
test('rejects invalid octets, prefix lengths and malformed input', () => {
expect(isValidCidr('192.168.1.256/24')).toBe(false);
expect(isValidCidr('2001:db8::/129')).toBe(false);
expect(isValidCidr('192.168.1.0')).toBe(false);
expect(isValidCidr('')).toBe(false);
});
});
describe('isValidIpv6', () => {
test('accepts IPv6 addresses, including zone IDs', () => {
expect(isValidIpv6('2001:db8::1')).toBe(true);
expect(isValidIpv6('fe80::1%eth0')).toBe(true);
expect(isValidIpv6('fe80::%eth0')).toBe(true);
expect(isValidIpv6('::ffff:192.168.1.1')).toBe(true);
});
// REGRESSION: netip.ParseAddr does not restrict the contents of a zone ID,
// so this value is an address with the zone `eth0/64`, not a CIDR.
test('accepts a zone ID with any non-empty contents', () => {
expect(isValidIpv6('fe80::1%eth0/64')).toBe(true);
expect(isValidIpv6('2001:db8::1%eth0')).toBe(true);
expect(isValidIpv6('fe80::1%a%b')).toBe(true);
});
test('rejects an empty zone ID', () => {
expect(isValidIpv6('fe80::1%')).toBe(false);
expect(isValidIpv6('%eth0')).toBe(false);
});
// REGRESSION: netip.ParseAddr rejects the dotted-decimal IPv4 tail of a
// mixed-notation IPv6 address when an octet has a leading zero.
test('rejects a non-canonical embedded IPv4 tail', () => {
expect(isValidIpv6('::ffff:192.168.01.1')).toBe(false);
});
// REGRESSION: netip.ParseAddr rejects an fe80 address with a zone ID but
// no groups after the colon.
test('rejects an fe80 address with no groups before the zone ID', () => {
expect(isValidIpv6('fe80:%eth0')).toBe(false);
});
test('rejects non-IPv6 values', () => {
expect(isValidIpv6('192.168.1.1')).toBe(false);
expect(isValidIpv6('')).toBe(false);
});
});
+404
View File
@@ -0,0 +1,404 @@
import { describe, expect, test, vi } from 'vitest';
import {
getDefaultV4Values,
getDefaultV6Values,
isSectionFilled,
omitEmptySections,
validateClientId,
validateGatewaySubnetMask,
validateIp,
validateIpForGatewaySubnetMask,
validateIpv4,
validateIpv6,
validateMac,
validateRequiredIfFilled,
validateRequiredIfSectionFilled,
} from '../helpers/validators';
vi.mock('i18next', () => ({
default: {
t: (key: string) => key,
},
}));
describe('validateIpForGatewaySubnetMask', () => {
test('rejects an IPv4 range address outside the configured subnet', () => {
const formValues = {
v4: {
gateway_ip: '192.168.1.1',
subnet_mask: '255.255.255.0',
range_start: '10.0.0.100',
range_end: '10.0.0.200',
lease_duration: 86400,
},
};
expect(validateIpForGatewaySubnetMask(formValues.v4.range_start, formValues)).toBe('subnet_error');
expect(validateIpForGatewaySubnetMask(formValues.v4.range_end, formValues)).toBe('subnet_error');
});
test('accepts an IPv4 range address inside the configured subnet', () => {
const formValues = {
v4: {
gateway_ip: '192.168.1.1',
subnet_mask: '255.255.255.0',
range_start: '192.168.1.100',
range_end: '192.168.1.200',
},
};
expect(validateIpForGatewaySubnetMask(formValues.v4.range_start, formValues)).toBeUndefined();
expect(validateIpForGatewaySubnetMask(formValues.v4.range_end, formValues)).toBeUndefined();
});
test('skips validation when the gateway or subnet mask is missing', () => {
expect(validateIpForGatewaySubnetMask('10.0.0.1', { v4: {} })).toBeUndefined();
expect(validateIpForGatewaySubnetMask('10.0.0.1', undefined)).toBeUndefined();
});
test('skips validation when the subnet mask is not a valid mask', () => {
const formValues = { v4: { gateway_ip: '192.168.1.1', subnet_mask: '1.2.3.4' } };
expect(validateIpForGatewaySubnetMask('10.0.0.1', formValues)).toBeUndefined();
});
});
describe('validateClientId', () => {
test('accepts IPv6 CIDRs with an embedded dotted-decimal IPv4 address', () => {
expect(validateClientId('::ffff:192.168.1.0/120')).toBeUndefined();
expect(validateClientId('::ffff:10.0.0.0/104')).toBeUndefined();
});
test('accepts the hexadecimal form of the same range', () => {
expect(validateClientId('::ffff:c0a8:100/120')).toBeUndefined();
});
test('accepts plain addresses, CIDRs, MACs and client IDs', () => {
expect(validateClientId('192.168.1.1')).toBeUndefined();
expect(validateClientId('2001:db8::/64')).toBeUndefined();
expect(validateClientId('192.168.1.0/24')).toBeUndefined();
expect(validateClientId('aa:bb:cc:dd:ee:ff')).toBeUndefined();
expect(validateClientId('my-client-id')).toBeUndefined();
});
test('rejects invalid octets and prefix lengths', () => {
expect(validateClientId('::ffff:192.168.1.256/120')).toBe('form_error_client_id_format');
expect(validateClientId('192.168.1.256/24')).toBe('form_error_client_id_format');
expect(validateClientId('2001:db8::/129')).toBe('form_error_client_id_format');
});
test('rejects malformed identifiers', () => {
expect(validateClientId('not a client id')).toBe('form_error_client_id_format');
});
// REGRESSION: netip.ParseAddr rejects IPv4 addresses with leading-zero
// octets, so the backend answers 400 for identifiers the form accepted.
test('rejects non-canonical IPv4 addresses with leading zeros', () => {
expect(validateClientId('192.168.01.1')).toBe('form_error_client_id_format');
expect(validateClientId('010.0.0.1')).toBe('form_error_client_id_format');
expect(validateClientId('192.168.001.1')).toBe('form_error_client_id_format');
});
test('rejects an IPv6 address with a non-canonical embedded IPv4 tail', () => {
expect(validateClientId('::ffff:192.168.01.1')).toBe('form_error_client_id_format');
expect(validateClientId('::ffff:192.168.1.1')).toBeUndefined();
});
// REGRESSION: netutil.ValidateHostnameLabel requires alphanumeric first
// and last runes, so leading/trailing hyphens are rejected by the server.
test('rejects client IDs with leading or trailing hyphens', () => {
expect(validateClientId('-abc')).toBe('form_error_client_id_format');
expect(validateClientId('abc-')).toBe('form_error_client_id_format');
expect(validateClientId('-')).toBe('form_error_client_id_format');
expect(validateClientId('---')).toBe('form_error_client_id_format');
expect(validateClientId('a-b')).toBeUndefined();
expect(validateClientId('a--b')).toBeUndefined();
});
// REGRESSION: net.ParseMAC requires exactly two hex digits per field and a
// uniform separator; the old R_MAC accepted over-long fields and mixed
// separators, which the server answers with 400.
test('rejects MAC addresses the server would refuse', () => {
expect(validateClientId('aaaa:bb:cc:dd:ee:ff')).toBe('form_error_client_id_format');
expect(validateClientId('aa:bbb:cc:dd:ee:ff')).toBe('form_error_client_id_format');
expect(validateClientId('aa:bb-cc:dd:ee:ff')).toBe('form_error_client_id_format');
expect(validateClientId('aaaaa.bbbbb.cccc')).toBe('form_error_client_id_format');
expect(validateClientId('aa:bb:cc:dd:ee:ff')).toBeUndefined();
expect(validateClientId('aa-bb-cc-dd-ee-ff')).toBeUndefined();
expect(validateClientId('aabb.ccdd.eeff')).toBeUndefined();
});
// REGRESSION: netip.ParseAddr rejects a zone ID on fe80: without at least
// one group after the colon.
test('rejects an fe80 address with no groups before the zone ID', () => {
expect(validateClientId('fe80:%eth0')).toBe('form_error_client_id_format');
expect(validateClientId('fe80::%eth0')).toBeUndefined();
expect(validateClientId('fe80::1%eth0')).toBeUndefined();
});
// REGRESSION: netip.ParseAddr treats everything after the first `%` as the
// zone ID without restricting its contents, so the server accepts this
// value as an address with a zone ID, not as a CIDR.
test('accepts IPv6 addresses with a zone ID the server parses as addresses', () => {
expect(validateClientId('fe80::1%eth0/64')).toBeUndefined();
expect(validateClientId('2001:db8::1%eth0')).toBeUndefined();
expect(validateClientId('fe80::1%')).toBe('form_error_client_id_format');
});
});
describe('validateIpv4', () => {
test('accepts canonical IPv4 addresses', () => {
expect(validateIpv4('0.0.0.0')).toBeUndefined();
expect(validateIpv4('192.168.1.1')).toBeUndefined();
expect(validateIpv4('255.255.255.255')).toBeUndefined();
});
// REGRESSION: the same netip.ParseAddr contract as validateClientId.
test('rejects non-canonical IPv4 addresses with leading zeros', () => {
expect(validateIpv4('192.168.01.1')).toBe('form_error_ip4_format');
expect(validateIpv4('010.0.0.1')).toBe('form_error_ip4_format');
expect(validateIpv4('192.168.1.01')).toBe('form_error_ip4_format');
});
});
describe('validateIp', () => {
test('accepts canonical IPv4 and IPv6 addresses', () => {
expect(validateIp('192.168.1.1')).toBeUndefined();
expect(validateIp('::1')).toBeUndefined();
});
test('rejects non-canonical IPv4 addresses with leading zeros', () => {
expect(validateIp('192.168.01.1')).toBe('form_error_ip_format');
expect(validateIp('010.0.0.1')).toBe('form_error_ip_format');
});
});
describe('validateIpv6', () => {
test('accepts IPv6 addresses, including zone IDs and embedded canonical IPv4', () => {
expect(validateIpv6('2001:db8::1')).toBeUndefined();
expect(validateIpv6('fe80::1%eth0')).toBeUndefined();
expect(validateIpv6('::ffff:192.168.1.1')).toBeUndefined();
});
test('rejects an IPv6 address with a non-canonical embedded IPv4 tail', () => {
expect(validateIpv6('::ffff:192.168.01.1')).toBe('form_error_ip6_format');
});
test('rejects an fe80 address with no groups before the zone ID', () => {
expect(validateIpv6('fe80:%eth0')).toBe('form_error_ip6_format');
});
});
describe('validateMac', () => {
test('accepts the formats the server parses', () => {
expect(validateMac('aa:bb:cc:dd:ee:ff')).toBeUndefined();
expect(validateMac('AA:BB:CC:DD:EE:FF')).toBeUndefined();
expect(validateMac('aa-bb-cc-dd-ee-ff')).toBeUndefined();
expect(validateMac('aabb.ccdd.eeff')).toBeUndefined();
expect(validateMac('00:00:5e:00:53:01:02:03')).toBeUndefined();
});
test('rejects over-long fields and mixed separators', () => {
expect(validateMac('aaaa:bb:cc:dd:ee:ff')).toBe('form_error_mac_format');
expect(validateMac('aa:bb-cc:dd:ee:ff')).toBe('form_error_mac_format');
expect(validateMac('aaaaa.bbbbb.cccc')).toBe('form_error_mac_format');
});
});
describe('isSectionFilled', () => {
test('is false for a missing or untouched section', () => {
expect(isSectionFilled(undefined)).toBe(false);
expect(isSectionFilled({})).toBe(false);
expect(isSectionFilled({ gateway_ip: '', subnet_mask: '', lease_duration: undefined })).toBe(false);
});
test('is true as soon as one value is set', () => {
expect(isSectionFilled({ range_start: 'fe80::1' })).toBe(true);
expect(isSectionFilled({ gateway_ip: '192.168.1.1' })).toBe(true);
});
// The backend reports the default lease duration of a DHCPv6 section that
// has never been configured, so raw server values must be normalized with
// `getDefaultV6Values` before the section is checked.
test('reports a raw unconfigured DHCPv6 section as filled in', () => {
const rawV6 = { range_start: '', lease_duration: 86400 };
expect(isSectionFilled(rawV6)).toBe(true);
expect(isSectionFilled(getDefaultV6Values(rawV6))).toBe(false);
});
});
describe('getDefaultV4Values', () => {
test('clears the lease duration of an untouched section', () => {
expect(
getDefaultV4Values({
gateway_ip: '',
subnet_mask: '',
range_start: '',
range_end: '',
lease_duration: 86400,
}),
).toEqual({
gateway_ip: '',
subnet_mask: '',
range_start: '',
range_end: '',
lease_duration: undefined,
});
});
test('keeps a configured section intact', () => {
const v4 = { gateway_ip: '192.168.1.1', subnet_mask: '255.255.255.0', lease_duration: 86400 };
expect(getDefaultV4Values(v4)).toBe(v4);
});
});
describe('getDefaultV6Values', () => {
// REGRESSION: the backend keeps the default lease duration of an
// unconfigured DHCPv6 section, which used to make the section look filled
// in, so it was sent to the backend and wiped the stored configuration.
test('clears the lease duration of an untouched section', () => {
const rawV6 = { range_start: '', lease_duration: 86400 };
const normalizedV6 = { range_start: '', lease_duration: undefined };
expect(getDefaultV6Values(rawV6)).toEqual(normalizedV6);
expect(omitEmptySections({ v4: {}, v6: getDefaultV6Values(rawV6) })).toEqual({});
});
test('keeps a configured section intact', () => {
const v6 = { range_start: 'fe80::1', lease_duration: 86400 };
expect(getDefaultV6Values(v6)).toBe(v6);
expect(omitEmptySections({ v4: {}, v6: getDefaultV6Values(v6) })).toEqual({ v6 });
});
});
describe('validateGatewaySubnetMask', () => {
// REGRESSION: AGH-171, mirror direction — both DHCP cards share one form
// instance, so an untouched DHCPv4 section is validated when DHCPv6 is
// saved and must not report an error there.
test('accepts an untouched DHCPv4 section', () => {
expect(validateGatewaySubnetMask(undefined, { v4: {} })).toBeUndefined();
expect(
validateGatewaySubnetMask(undefined, {
v4: {
gateway_ip: '',
subnet_mask: '',
range_start: '',
range_end: '',
lease_duration: undefined,
},
}),
).toBeUndefined();
expect(validateGatewaySubnetMask(undefined, undefined)).toBeUndefined();
});
test('rejects a partially filled section', () => {
expect(validateGatewaySubnetMask(undefined, { v4: { gateway_ip: '192.168.1.1' } })).toBe(
'gateway_or_subnet_invalid',
);
expect(validateGatewaySubnetMask(undefined, { v4: { subnet_mask: '255.255.255.0' } })).toBe(
'gateway_or_subnet_invalid',
);
});
test('accepts a valid gateway and subnet mask', () => {
expect(
validateGatewaySubnetMask(undefined, {
v4: { gateway_ip: '192.168.1.1', subnet_mask: '255.255.255.0' },
}),
).toBeUndefined();
});
test('rejects an invalid gateway or subnet mask', () => {
expect(
validateGatewaySubnetMask(undefined, {
v4: { gateway_ip: 'not-an-ip', subnet_mask: '255.255.255.0' },
}),
).toBe('gateway_or_subnet_invalid');
expect(
validateGatewaySubnetMask(undefined, {
v4: { gateway_ip: '192.168.1.1', subnet_mask: '1.2.3.4' },
}),
).toBe('gateway_or_subnet_invalid');
});
});
describe('omitEmptySections', () => {
const v4 = { gateway_ip: '192.168.1.1', subnet_mask: '255.255.255.0' };
const v6 = { range_start: 'fe80::1', lease_duration: 86400 };
test('keeps every filled section', () => {
expect(omitEmptySections({ v4, v6 })).toEqual({ v4, v6 });
});
test('leaves out a section the user has not filled in', () => {
expect(omitEmptySections({ v4: {}, v6 })).toEqual({ v6 });
expect(
omitEmptySections({
v4: {
gateway_ip: '',
subnet_mask: '',
range_start: '',
range_end: '',
lease_duration: 0,
},
v6,
}),
).toEqual({ v6 });
});
test('keeps only the filled section', () => {
expect(omitEmptySections({ v4, v6: {} })).toEqual({ v4 });
});
test('returns no sections when nothing is filled in', () => {
expect(omitEmptySections({ v4: {}, v6: {} })).toEqual({});
expect(omitEmptySections({})).toEqual({});
});
});
describe('validateRequiredIfSectionFilled', () => {
const validateV4 = validateRequiredIfSectionFilled('v4');
test('does not require a value while the section is empty', () => {
expect(validateV4('', { v4: { range_start: '', range_end: '', lease_duration: undefined } })).toBeUndefined();
expect(validateV4(undefined, {})).toBeUndefined();
expect(validateV4(undefined, undefined)).toBeUndefined();
});
test('requires a value as soon as the section has any value', () => {
expect(validateV4('', { v4: { gateway_ip: '', lease_duration: 86400 } })).toBe('form_error_required');
expect(validateV4(' ', { v4: { gateway_ip: '', lease_duration: 86400 } })).toBe('form_error_required');
expect(validateV4('192.168.1.1', { v4: { gateway_ip: '192.168.1.1' } })).toBeUndefined();
});
test('only looks at its own section', () => {
expect(validateV4(undefined, { v6: { range_start: 'fe80::1' } })).toBeUndefined();
});
});
describe('validateRequiredIfFilled', () => {
const validateLease = validateRequiredIfFilled('v6', 'range_start');
// REGRESSION: AGH-171 — DHCPv6 counts as configured only when its range
// start is set. The lease duration is pre-filled with a default, so it
// must not require a range on its own and block saving DHCPv4.
test('does not require a value when the v6 range start is empty', () => {
expect(validateLease('', { v6: { range_start: '', lease_duration: 86400 } })).toBeUndefined();
expect(validateLease(undefined, { v6: {} })).toBeUndefined();
expect(validateLease(undefined, undefined)).toBeUndefined();
});
test('requires a value once the v6 range start is filled', () => {
expect(validateLease('', { v6: { range_start: 'fe80::1' } })).toBe('form_error_required');
expect(validateLease(' ', { v6: { range_start: 'fe80::1' } })).toBe('form_error_required');
expect(validateLease('86400', { v6: { range_start: 'fe80::1' } })).toBeUndefined();
});
test('only looks at its own section and field', () => {
expect(validateLease(undefined, { v4: { gateway_ip: '192.168.1.1' } })).toBeUndefined();
});
});
@@ -4,12 +4,13 @@ import { useTranslation } from 'react-i18next';
import { UINT32_RANGE } from '../../../helpers/constants';
import {
isSectionFilled,
validateGatewaySubnetMask,
validateIpForGatewaySubnetMask,
validateIpv4,
validateIpv4RangeEnd,
validateNotInRange,
validateRequiredValue,
validateRequiredIfSectionFilled,
} from '../../../helpers/validators';
import { DhcpFormValues } from '.';
import { Input } from '../../ui/Controls/Input';
@@ -41,8 +42,7 @@ const FormDHCPv4 = ({ processingConfig, ipv4placeholders, interfaces, onSubmit }
const interfaceName = watch('interface_name');
const isInterfaceIncludesIpv4 = interfaces?.[interfaceName]?.ipv4_addresses;
const formValues = watch('v4');
const isEmptyConfig = !Object.values(formValues || {}).some(Boolean);
const isEmptyConfig = !isSectionFilled(watch('v4'));
const hasV4Errors = errors.v4 && Object.keys(errors.v4).length > 0;
const isDisabled = useMemo(() => {
@@ -60,7 +60,7 @@ const FormDHCPv4 = ({ processingConfig, ipv4placeholders, interfaces, onSubmit }
rules={{
validate: {
ipv4: validateIpv4,
required: (value) => (isEmptyConfig ? undefined : validateRequiredValue(value)),
required: validateRequiredIfSectionFilled('v4'),
notInRange: validateNotInRange,
},
}}
@@ -84,7 +84,7 @@ const FormDHCPv4 = ({ processingConfig, ipv4placeholders, interfaces, onSubmit }
control={control}
rules={{
validate: {
required: (value) => (isEmptyConfig ? undefined : validateRequiredValue(value)),
required: validateRequiredIfSectionFilled('v4'),
subnet: validateGatewaySubnetMask,
},
}}
@@ -165,7 +165,7 @@ const FormDHCPv4 = ({ processingConfig, ipv4placeholders, interfaces, onSubmit }
control={control}
rules={{
validate: {
required: (value) => (isEmptyConfig ? undefined : validateRequiredValue(value)),
required: validateRequiredIfSectionFilled('v4'),
},
}}
render={({ field, fieldState }) => (
@@ -3,7 +3,7 @@ import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { UINT32_RANGE } from '../../../helpers/constants';
import { validateIpv6, validateRequiredValue } from '../../../helpers/validators';
import { isSectionFilled, validateIpv6, validateRequiredIfFilled } from '../../../helpers/validators';
import { DhcpFormValues } from '.';
import { Input } from '../../ui/Controls/Input';
import { toNumber } from '../../../helpers/form';
@@ -23,7 +23,7 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }
const { t } = useTranslation();
const {
handleSubmit,
formState: { isSubmitting, isValid },
formState: { errors, isSubmitting },
control,
watch,
} = useFormContext<DhcpFormValues>();
@@ -31,12 +31,12 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }
const interfaceName = watch('interface_name');
const isInterfaceIncludesIpv6 = interfaces?.[interfaceName]?.ipv6_addresses;
const formValues = watch('v6');
const isEmptyConfig = !Object.values(formValues || {}).some(Boolean);
const isEmptyConfig = !isSectionFilled(watch('v6'));
const hasV6Errors = errors.v6 && Object.keys(errors.v6).length > 0;
const isDisabled = useMemo(() => {
return isSubmitting || !isValid || processingConfig || !isInterfaceIncludesIpv6 || isEmptyConfig;
}, [isSubmitting, isValid, processingConfig, isInterfaceIncludesIpv6, isEmptyConfig]);
return isSubmitting || hasV6Errors || processingConfig || !isInterfaceIncludesIpv6 || isEmptyConfig;
}, [isSubmitting, hasV6Errors, processingConfig, isInterfaceIncludesIpv6, isEmptyConfig]);
return (
<form onSubmit={handleSubmit(onSubmit)}>
@@ -56,7 +56,6 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }
validate: isInterfaceIncludesIpv6
? {
ipv6: validateIpv6,
required: validateRequiredValue,
}
: undefined,
}}
@@ -102,7 +101,7 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }
rules={{
validate: isInterfaceIncludesIpv6
? {
required: validateRequiredValue,
required: validateRequiredIfFilled('v6', 'range_start'),
}
: undefined,
}}
+25 -30
View File
@@ -37,6 +37,12 @@ import {
calculateDhcpPlaceholdersIpv6,
subnetMaskToBitMask,
} from '../../../helpers/helpers';
import {
getDefaultV4Values,
getDefaultV6Values,
isSectionFilled,
omitEmptySections,
} from '../../../helpers/validators';
import './index.css';
import { RootState } from '../../../initialState';
@@ -46,28 +52,13 @@ type IPv4FormValues = {
range_start?: string;
range_end?: string;
lease_duration?: number;
}
};
type IPv6FormValues = {
range_start?: string;
range_end?: string;
lease_duration?: number;
}
const getDefaultV4Values = (v4: IPv4FormValues) => {
const emptyForm = Object.entries(v4).every(
([key, value]) => key === 'lease_duration' || value === ''
);
if (emptyForm) {
return {
...v4,
lease_duration: undefined,
}
}
return v4;
}
};
export type DhcpFormValues = {
v4?: IPv4FormValues;
@@ -118,7 +109,7 @@ const Dhcp = () => {
mode: 'onBlur',
defaultValues: {
v4: getDefaultV4Values(v4),
v6,
v6: getDefaultV6Values(v6),
interface_name: interfaceName || '',
},
});
@@ -152,7 +143,7 @@ const Dhcp = () => {
},
v6: {
...DEFAULT_V6_VALUES,
...v6,
...getDefaultV6Values(v6),
},
interface_name: interfaceName || '',
});
@@ -191,7 +182,7 @@ const Dhcp = () => {
dispatch(
setDhcpConfig({
interface_name,
...values,
...omitEmptySections(values),
}),
);
};
@@ -202,9 +193,13 @@ const Dhcp = () => {
}
};
const enteredSomeV4Value = Object.values(v4).some(Boolean);
const enteredSomeV4Value = isSectionFilled(v4);
const enteredSomeV6Value = Object.values(v6).some(Boolean);
// The backend reports the default lease duration for a DHCPv6 section that
// has never been configured, so it must be normalized before checking
// whether the user has filled the section in.
const v6Values = getDefaultV6Values(v6);
const enteredSomeV6Value = isSectionFilled(v6Values);
const enteredSomeValue = enteredSomeV4Value || enteredSomeV6Value || interfaceName;
const getToggleDhcpButton = () => {
@@ -217,13 +212,13 @@ const Dhcp = () => {
const onClickDisable = () => dispatch(toggleDhcp({ enabled }));
const onClickEnable = () => {
const values = {
enabled,
interface_name,
v4: enteredSomeV4Value ? v4 : {},
v6: enteredSomeV6Value ? v6 : {},
};
dispatch(toggleDhcp(values));
dispatch(
toggleDhcp({
enabled,
interface_name,
...omitEmptySections({ v4, v6: v6Values }),
}),
);
};
return (
@@ -268,7 +263,7 @@ const Dhcp = () => {
const inputtedIPv4values = ipv4Config.gateway_ip && ipv4Config.subnet_mask;
const isEmptyConfig = !Object.values(ipv4Config).some(Boolean);
const isEmptyConfig = !isSectionFilled(ipv4Config);
const disabledLeasesButton = Boolean(
!isInterfaceIncludesIpv4 || isEmptyConfig || processingConfig || !inputtedIPv4values,
);
+12 -10
View File
@@ -3,21 +3,20 @@ export const R_URL_REQUIRES_PROTOCOL = /^https?:\/\/[^/\s]+(\/.*)?$/;
// matches hostname or *.wildcard
export const R_HOST = /^(\*\.)?[\w.-]+$/;
export const R_IPV4 = /^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$/;
// Matches a canonical dotted-decimal IPv4 address: four octets, no leading
// zeros. Mirrors the spellings netip.ParseAddr accepts, so the form never
// accepts a value the server would refuse (e.g. 192.168.01.1).
export const R_IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
// Matches an IPv6 address without a zone ID. The zone ID is split off before
// the check, since netip.ParseAddr does not restrict its contents.
export const R_IPV6 =
/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
export const R_CIDR =
/^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$/;
/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
export const R_MAC =
/^((([a-fA-F0-9][a-fA-F0-9]+[-:]){5})([a-fA-F0-9]{2})$)|^((([a-fA-F0-9][a-fA-F0-9]+[-:]){7})([a-fA-F0-9]{2})$)|^([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]+[.]){2}([a-fA-F0-9]{4})$|^([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]+[.]){3}([a-fA-F0-9]{4})$/;
/^(([a-fA-F0-9]{2}:){5}[a-fA-F0-9]{2})$|^(([a-fA-F0-9]{2}-){5}[a-fA-F0-9]{2})$|^(([a-fA-F0-9]{2}:){7}[a-fA-F0-9]{2})$|^(([a-fA-F0-9]{2}-){7}[a-fA-F0-9]{2})$|^(([a-fA-F0-9]{4}\.){2}[a-fA-F0-9]{4})$|^(([a-fA-F0-9]{4}\.){3}[a-fA-F0-9]{4})$/;
export const R_MAC_WITHOUT_COLON = /^([a-fA-F0-9]{2}){5}([a-fA-F0-9]{2})$|^([a-fA-F0-9]{2}){7}([a-fA-F0-9]{2})$/;
export const R_CIDR_IPV6 =
/^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\/(12[0-8]|1[0-1][0-9]|[1-9][0-9]|[0-9]))$/;
export const R_DOMAIN = /^([a-zA-Z0-9][a-zA-Z0-9-_]*\.)*[a-zA-Z0-9]*[a-zA-Z0-9-_]*[[a-zA-Z0-9]+$/;
export const R_PATH_LAST_PART = /\/[^/]*$/;
@@ -28,7 +27,10 @@ export const R_UNIX_ABSOLUTE_PATH = /^(\/[^/\x00]+)+$/;
// eslint-disable-next-line no-control-regex
export const R_WIN_ABSOLUTE_PATH = /^([a-zA-Z]:)?(\\|\/)(?:[^\\/:*?"<>|\x00]+\\)*[^\\/:*?"<>|\x00]*$/;
export const R_CLIENT_ID = /^[a-z0-9-]{1,63}$/;
// Mirrors the backend's netutil.ValidateHostnameLabel: 1–63 lowercase
// alphanumerics and hyphens, but the first and last runes must be
// alphanumeric, so values like "-abc" or "abc-" are rejected by the server.
export const R_CLIENT_ID = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
export const R_IPV4_SUBNET = /^([0-9]|[1-2][0-9]|3[0-2])?$/;
+86 -3
View File
@@ -20,6 +20,8 @@ import {
FILTERED,
FILTERED_STATUS,
R_CLIENT_ID,
R_IPV4,
R_IPV6,
STANDARD_DNS_PORT,
STANDARD_HTTPS_PORT,
STANDARD_WEB_PORT,
@@ -554,6 +556,89 @@ export const isIpInCidr = (ip: any, cidr: any) => {
}
};
/**
* Matches a prefix length without leading zeros, as netip.ParsePrefix requires.
*/
const R_PREFIX_LENGTH = /^(0|[1-9]\d*)$/;
/**
* Checks that the value is an IP range in CIDR notation the way the backend
* parses it with netip.ParsePrefix. Both address families are supported,
* including IPv6 ranges with an embedded dotted-decimal IPv4 address. The
* extra checks keep out the spellings ipaddr.js accepts but netip rejects —
* zone IDs, prefix lengths with leading zeros, and non-canonical IPv4
* addresses — so the form never accepts a value the server would refuse.
*
* @param {string} value Value to check.
* @returns {boolean} True if the value is a valid CIDR range.
*/
export const isValidCidr = (value: string) => {
const slash = value.lastIndexOf('/');
if (slash <= 0) {
return false;
}
const addr = value.slice(0, slash);
const bits = value.slice(slash + 1);
// netip.ParsePrefix rejects IPv6 zones in a prefix and prefix lengths
// spelled with leading zeros.
if (addr.includes('%') || !R_PREFIX_LENGTH.test(bits)) {
return false;
}
const colon = addr.lastIndexOf(':');
if (colon === -1) {
// netip.ParseAddr requires canonical dotted-decimal IPv4, while
// ipaddr.js also takes octal, hexadecimal and single-number spellings.
if (!R_IPV4.test(addr)) {
return false;
}
} else {
// The same applies to the IPv4 tail of a mixed-notation IPv6 address.
const ipv4Part = addr.slice(colon + 1);
if (ipv4Part.includes('.') && !R_IPV4.test(ipv4Part)) {
return false;
}
}
try {
ipaddr.parseCIDR(value);
return true;
} catch (e) {
return false;
}
};
/**
* Checks that the value is an IPv6 address the way the backend parses it with
* netip.ParseAddr. The zone ID, if any, is everything after the first `%`,
* and its contents are not restricted, so `fe80::1%eth0/64` is an address
* with the zone `eth0/64` rather than a CIDR. A mixed-notation address with
* a dotted-decimal IPv4 tail must use a canonical IPv4 spelling there as
* well.
*
* @param {string} value Value to check.
* @returns {boolean} True if the value is a valid IPv6 address.
*/
export const isValidIpv6 = (value: string) => {
const zoneIdx = value.indexOf('%');
const addr = zoneIdx === -1 ? value : value.slice(0, zoneIdx);
// netip.ParseAddr requires a non-empty zone ID.
if (zoneIdx !== -1 && zoneIdx === value.length - 1) {
return false;
}
if (!R_IPV6.test(addr)) {
return false;
}
const tail = addr.slice(addr.lastIndexOf(':') + 1);
return !tail.includes('.') || R_IPV4.test(tail);
};
/**
*
* @param {string} subnetMask
@@ -665,9 +750,7 @@ export const formatElapsedMs = (elapsedMs: string, t: (key: string) => string) =
return elapsedMs;
}
const formattedValue = parsedElapsedMs < 1
? parsedElapsedMs.toFixed(2)
: Math.floor(parsedElapsedMs).toString();
const formattedValue = parsedElapsedMs < 1 ? parsedElapsedMs.toFixed(2) : Math.floor(parsedElapsedMs).toString();
return `${formattedValue} ${t('milliseconds_abbreviation')}`;
};
+152 -13
View File
@@ -2,11 +2,8 @@ import i18next from 'i18next';
import {
MAX_PORT,
R_CIDR,
R_CIDR_IPV6,
R_HOST,
R_IPV4,
R_IPV6,
R_MAC,
R_URL_REQUIRES_PROTOCOL,
STANDARD_WEB_PORT,
@@ -21,7 +18,7 @@ import {
import { ip4ToInt, isValidAbsolutePath } from './form';
import { isIpInCidr, parseSubnetMask } from './helpers';
import { isIpInCidr, isValidCidr, isValidIpv6, parseSubnetMask } from './helpers';
// Validation functions
// If the value is valid, the validation function should return undefined.
@@ -37,6 +34,131 @@ export const validateRequiredValue = (value: any) => {
return i18next.t('form_error_required');
};
/**
* Checks whether a DHCP form section has any values entered. DHCPv4 and
* DHCPv6 share a single react-hook-form instance, so "the user has not touched
* this section" is the condition both the section-aware validators and the
* save buttons key off. Values coming from the server must be normalized with
* `getDefaultV4Values` or `getDefaultV6Values` first, since the backend
* reports default values even for a section that is not configured.
*
* @param {object} sectionValues Values of one DHCP form section.
* @returns {boolean} True if at least one value is set.
*/
export const isSectionFilled = (sectionValues: any) =>
Boolean(sectionValues) && Object.values(sectionValues).some(Boolean);
/**
* Normalizes DHCPv4 form values coming from the server. A lease duration that
* is set while the rest of the section is empty must not make the section look
* filled in.
*
* @param {object} v4 Values of the DHCPv4 form section.
* @returns {object} Normalized values of the DHCPv4 form section.
*/
export const getDefaultV4Values = (v4: any) => {
if (!v4) {
return v4;
}
const emptyForm = Object.entries(v4).every(([key, value]) => key === 'lease_duration' || value === '');
if (emptyForm) {
return {
...v4,
lease_duration: undefined,
};
}
return v4;
};
/**
* Normalizes DHCPv6 form values coming from the server. Unlike DHCPv4, the
* backend keeps the default lease duration of a DHCPv6 section that has never
* been configured, while the section counts as configured only when its range
* start is set. Without this normalization `isSectionFilled` reports such a
* section as filled in, so it gets sent to the backend, which replaces the
* stored DHCPv6 configuration with a disabled one.
*
* @param {object} v6 Values of the DHCPv6 form section.
* @returns {object} Normalized values of the DHCPv6 form section.
*/
export const getDefaultV6Values = (v6: any) => {
if (!v6 || v6.range_start) {
return v6;
}
return {
...v6,
lease_duration: undefined,
};
};
/**
* Returns the DHCP form sections that have values, leaving out the untouched
* ones. The backend keeps the current configuration of a section that is
* omitted from the request, while an empty section is not a no-op: DHCPv4
* rejects it with a validation error and fails the whole request, and DHCPv6
* accepts it but replaces the stored server with a disabled one, silently
* wiping the current DHCPv6 configuration. A section the user has not filled
* in must therefore never be sent.
*
* @param {object} sections DHCP form sections, e.g. `{ v4, v6 }`.
* @returns {object} Payload with only the filled sections.
*/
export const omitEmptySections = <V4, V6>(sections: { v4?: V4; v6?: V6 }) => {
const payload: { v4?: V4; v6?: V6 } = {};
if (isSectionFilled(sections.v4)) {
payload.v4 = sections.v4;
}
if (isSectionFilled(sections.v6)) {
payload.v6 = sections.v6;
}
return payload;
};
/**
* Creates a `required` validator for one DHCP form section that enforces the
* value only when that section has any values entered. DHCPv4 and DHCPv6
* share a single react-hook-form instance, so an untouched section must not
* block saving the other one.
*
* @param {'v4' | 'v6'} section DHCP form section the field belongs to.
* @returns {Function} Validator for react-hook-form's `validate` rules.
*/
export const validateRequiredIfSectionFilled = (section: 'v4' | 'v6') => (value: any, allValues: any) => {
if (!isSectionFilled(allValues && allValues[section])) {
return undefined;
}
return validateRequiredValue(value);
};
/**
* Creates a `required` validator that enforces the value only when the given
* field of the same DHCP form section is filled. DHCPv6 counts as configured
* only when its range start is set, which is also what enables the DHCPv6
* server on the backend, so a lease duration left at its default must not
* require a range.
*
* @param {'v4' | 'v6'} section DHCP form section the field belongs to.
* @param {string} field Name of the field that marks the section as configured.
* @returns {Function} Validator for react-hook-form's `validate` rules.
*/
export const validateRequiredIfFilled = (section: 'v4' | 'v6', field: string) => (value: any, allValues: any) => {
const sibling = allValues && allValues[section] && allValues[section][field];
if (!sibling) {
return undefined;
}
return validateRequiredValue(value);
};
/**
* @returns {undefined|string}
* @param _
@@ -101,16 +223,27 @@ export const validateNotInRange = (value: any, allValues: any) => {
};
/**
* Validates the DHCPv4 subnet mask against the gateway. An untouched DHCPv4
* section is not an error: DHCPv4 and DHCPv6 share a single react-hook-form
* instance, so a section the user has not filled in must not block saving the
* other one.
*
* @returns {undefined|string}
* @param _
* @param allValues
*/
export const validateGatewaySubnetMask = (_: any, allValues: any) => {
if (!allValues || !allValues.v4 || !allValues.v4.subnet_mask || !allValues.v4.gateway_ip) {
const v4Values = allValues && allValues.v4;
if (!isSectionFilled(v4Values)) {
return undefined;
}
if (!v4Values.subnet_mask || !v4Values.gateway_ip) {
return i18next.t('gateway_or_subnet_invalid');
}
const { subnet_mask, gateway_ip } = allValues.v4;
const { subnet_mask, gateway_ip } = v4Values;
if (validateIpv4(gateway_ip)) {
return i18next.t('gateway_or_subnet_invalid');
@@ -125,7 +258,7 @@ export const validateGatewaySubnetMask = (_: any, allValues: any) => {
* @param allValues
*/
export const validateIpForGatewaySubnetMask = (value: any, allValues: any) => {
if (!allValues || !allValues.v4 || !value || !allValues.gateway_ip || !allValues.subnet_mask) {
if (!allValues || !allValues.v4 || !value || !allValues.v4.gateway_ip || !allValues.v4.subnet_mask) {
return undefined;
}
@@ -137,6 +270,10 @@ export const validateIpForGatewaySubnetMask = (value: any, allValues: any) => {
const subnetPrefix = parseSubnetMask(subnet_mask);
if (subnetPrefix === null) {
return undefined;
}
if (!isIpInCidr(value, `${gateway_ip}/${subnetPrefix}`)) {
return i18next.t('subnet_error');
}
@@ -153,14 +290,16 @@ export const validateClientId = (value: string) => {
return undefined;
}
const formattedValue = value.trim();
// The backend parses an identifier as an IP address first, then as a MAC
// address, a CIDR, and finally as a ClientID, so a value like
// `fe80::1%eth0/64` is an address with a zone ID rather than a CIDR.
if (
formattedValue &&
!(
R_IPV4.test(formattedValue) ||
R_IPV6.test(formattedValue) ||
isValidIpv6(formattedValue) ||
R_MAC.test(formattedValue) ||
R_CIDR.test(formattedValue) ||
R_CIDR_IPV6.test(formattedValue) ||
isValidCidr(formattedValue) ||
R_CLIENT_ID.test(formattedValue)
)
) {
@@ -204,7 +343,7 @@ export const validateServerName = (value: any) => {
* @returns {undefined|string}
*/
export const validateIpv6 = (value: any) => {
if (value && !R_IPV6.test(value)) {
if (value && !isValidIpv6(value)) {
return i18next.t('form_error_ip6_format');
}
return undefined;
@@ -215,7 +354,7 @@ export const validateIpv6 = (value: any) => {
* @returns {undefined|string}
*/
export const validateIp = (value: any) => {
if (value && !R_IPV4.test(value) && !R_IPV6.test(value)) {
if (value && !R_IPV4.test(value) && !isValidIpv6(value)) {
return i18next.t('form_error_ip_format');
}
return undefined;
@@ -301,7 +440,7 @@ export const validateDomain = (value: any) => {
* @returns {undefined|string}
*/
export const validateAnswer = (value: any) => {
if (value && !R_IPV4.test(value) && !R_IPV6.test(value) && !R_HOST.test(value)) {
if (value && !R_IPV4.test(value) && !isValidIpv6(value) && !R_HOST.test(value)) {
return i18next.t('form_error_answer_format');
}
return undefined;
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.27.1
require (
github.com/AdguardTeam/dnscrypt v0.0.3
github.com/AdguardTeam/dnsproxy v0.85.0
github.com/AdguardTeam/dnsproxy v0.86.0
github.com/AdguardTeam/golibs v0.35.16
github.com/AdguardTeam/urlfilter v0.23.4
github.com/NYTimes/gziphandler v1.1.1
+2 -2
View File
@@ -6,8 +6,8 @@ cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwS
cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk=
github.com/AdguardTeam/dnscrypt v0.0.3 h1:o2Zg+/CRCPS2lPobMzYs59aE3RNdMKfOBk34bPbbUbg=
github.com/AdguardTeam/dnscrypt v0.0.3/go.mod h1:qCFs51rLfNzEDZqb6nz1tocVLnEearJ1zng6O3I6ecA=
github.com/AdguardTeam/dnsproxy v0.85.0 h1:zi4cPvQfpb19cBJUFiFMJBktqSLAmc1X0thbR9r9yls=
github.com/AdguardTeam/dnsproxy v0.85.0/go.mod h1:6kCM5M/gtuQr/0BjtnReHlK6+N0BTee+WHOq3m1JNq4=
github.com/AdguardTeam/dnsproxy v0.86.0 h1:t9966vx4c3Wm6/9dhM4YI9L6uvIJ/EMNw9JpbEAyKYw=
github.com/AdguardTeam/dnsproxy v0.86.0/go.mod h1:6kCM5M/gtuQr/0BjtnReHlK6+N0BTee+WHOq3m1JNq4=
github.com/AdguardTeam/golibs v0.35.16 h1:e9dyk7jfzpxMcyVZ8RktKV6JMeiQ9MpEhp/Ahm3j/R4=
github.com/AdguardTeam/golibs v0.35.16/go.mod h1:lGsRwlo3R3ma5o8mgdDv3I+6DYTiCHfrhNyUamqhbK8=
github.com/AdguardTeam/urlfilter v0.23.4 h1:3cwt5xj7lpK2t3sWtr9WI8mmsKP+RtpkdnEy7lrn8cg=
+5 -4
View File
@@ -1,6 +1,7 @@
package aghtest
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
@@ -28,7 +29,7 @@ func NewExchangingUpstream(
ups = &dnsproxytest.Upstream{
OnAddress: func() (addr string) { return "upstream.example" },
OnClose: func() (err error) { return nil },
OnExchange: func(m *dns.Msg) (resp *dns.Msg, err error) {
OnExchange: func(_ context.Context, m *dns.Msg) (resp *dns.Msg, err error) {
resp = new(dns.Msg).SetReply(m)
if len(m.Question) == 0 {
@@ -177,7 +178,7 @@ func NewUpstream() (u *dnsproxytest.Upstream) {
return &dnsproxytest.Upstream{
OnAddress: func() (addr string) { return "upstream.example" },
OnClose: func() (err error) { return nil },
OnExchange: func(req *dns.Msg) (resp *dns.Msg, err error) {
OnExchange: func(_ context.Context, req *dns.Msg) (resp *dns.Msg, err error) {
panic(testutil.UnexpectedCall(req))
},
}
@@ -209,7 +210,7 @@ func NewBlockUpstream(hostname string, shouldBlock bool) (u *dnsproxytest.Upstre
return &dnsproxytest.Upstream{
OnAddress: func() (addr string) { return "sbpc.upstream.example" },
OnExchange: func(req *dns.Msg) (resp *dns.Msg, err error) {
OnExchange: func(_ context.Context, req *dns.Msg) (resp *dns.Msg, err error) {
resp = respTmpl.Copy()
resp.SetReply(req)
resp.Answer[0].(*dns.TXT).Hdr.Name = req.Question[0].Name
@@ -229,7 +230,7 @@ const ErrUpstream errors.Error = "test upstream error"
func NewErrorUpstream() (u *dnsproxytest.Upstream) {
return &dnsproxytest.Upstream{
OnAddress: func() (addr string) { return "error.upstream.example" },
OnExchange: func(_ *dns.Msg) (resp *dns.Msg, err error) {
OnExchange: func(_ context.Context, _ *dns.Msg) (resp *dns.Msg, err error) {
return nil, ErrUpstream
},
OnClose: func() (err error) { return nil },
+3 -3
View File
@@ -219,7 +219,7 @@ func (cv *upstreamConfigValidator) check(ctx context.Context, l *slog.Logger) {
func checkSrv(ctx context.Context, l *slog.Logger, res *upstreamResult, hc *healthchecker) {
defer slogutil.RecoverAndLog(ctx, l)
res.err = hc.check(res.server)
res.err = hc.check(ctx, res.server)
if res.err != nil && res.isSpecific {
res.err = domainSpecificTestError{Err: res.err}
}
@@ -411,7 +411,7 @@ type healthchecker struct {
}
// check exchanges with u and validates the response.
func (h *healthchecker) check(u upstream.Upstream) (err error) {
func (h *healthchecker) check(ctx context.Context, u upstream.Upstream) (err error) {
req := &dns.Msg{
Id: dns.Id(),
RecursionDesired: true,
@@ -422,7 +422,7 @@ func (h *healthchecker) check(u upstream.Upstream) (err error) {
}},
}
reply, err := u.Exchange(req)
reply, err := u.Exchange(ctx, req)
if err != nil {
return fmt.Errorf("couldn't communicate with upstream: %w", err)
} else if h.ansEmpty && len(reply.Answer) > 0 {
@@ -2,6 +2,7 @@ package dnsforward
import (
"cmp"
"context"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
@@ -295,7 +296,7 @@ func createGoogleATestMessage() *dns.Msg {
func newGoogleUpstream() (u upstream.Upstream) {
return &dnsproxytest.Upstream{
OnAddress: func() (addr string) { return "google.upstream.example" },
OnExchange: func(req *dns.Msg) (resp *dns.Msg, err error) {
OnExchange: func(_ context.Context, req *dns.Msg) (resp *dns.Msg, err error) {
return cmp.Or(
aghtest.MatchedResponse(req, dns.TypeA, googleDomainName, "8.8.8.8"),
new(dns.Msg).SetRcode(req, dns.RcodeNameError),
@@ -557,7 +558,9 @@ func TestDoQServer(t *testing.T) {
// Send the test message.
req := createGoogleATestMessage()
res, err := u.Exchange(req)
ctx := testutil.ContextWithTimeout(t, testTimeout)
res, err := u.Exchange(ctx, req)
require.NoError(t, err)
assertGoogleAResponse(t, res)
@@ -639,7 +642,7 @@ func TestSafeSearch(t *testing.T) {
pt := testutil.NewPanicT(t)
ups := aghtest.NewUpstream()
ups.OnExchange = func(req *dns.Msg) (resp *dns.Msg, err error) {
ups.OnExchange = func(_ context.Context, req *dns.Msg) (resp *dns.Msg, err error) {
assert.Equal(pt, googleSafeSearch, req.Question[0].Name)
return aghtest.MatchedResponse(req, dns.TypeA, googleSafeSearch, "1.2.3.4"), nil
@@ -812,7 +815,7 @@ func TestServerCustomClientUpstream(t *testing.T) {
)
ups := aghtest.NewUpstream()
ups.OnExchange = func(req *dns.Msg) (resp *dns.Msg, err error) {
ups.OnExchange = func(_ context.Context, req *dns.Msg) (resp *dns.Msg, err error) {
upsCalledCounter.Add(1)
return cmp.Or(
@@ -1330,7 +1333,7 @@ func TestRewrite(t *testing.T) {
}))
ups := aghtest.NewUpstream()
ups.OnExchange = func(req *dns.Msg) (resp *dns.Msg, err error) {
ups.OnExchange = func(_ context.Context, req *dns.Msg) (resp *dns.Msg, err error) {
return cmp.Or(
aghtest.MatchedResponse(req, dns.TypeA, "example.org", "4.3.2.1"),
new(dns.Msg).SetRcode(req, dns.RcodeNameError),
+1 -1
View File
@@ -122,7 +122,7 @@ func (c *Checker) Check(host string) (ok bool, err error) {
l.DebugContext(ctx, "checking", "question", question)
req := (&dns.Msg{}).SetQuestion(question, dns.TypeTXT)
resp, err := c.upstream.Exchange(req)
resp, err := c.upstream.Exchange(ctx, req)
if err != nil {
return false, fmt.Errorf("getting hashes: %w", err)
}
@@ -1,6 +1,7 @@
package hashprefix
import (
"context"
"crypto/sha256"
"encoding/hex"
"slices"
@@ -208,10 +209,10 @@ func TestChecker_Check(t *testing.T) {
var numReq int
onExchange := ups.OnExchange
ups.OnExchange = func(req *dns.Msg) (resp *dns.Msg, err error) {
ups.OnExchange = func(ctx context.Context, req *dns.Msg) (resp *dns.Msg, err error) {
numReq++
return onExchange(req)
return onExchange(ctx, req)
}
c.upstream = ups
+5 -2
View File
@@ -23,7 +23,6 @@ import (
"github.com/AdguardTeam/AdGuardHome/internal/filtering/rulelist"
"github.com/AdguardTeam/AdGuardHome/internal/querylog"
"github.com/AdguardTeam/AdGuardHome/internal/schedule"
"github.com/AdguardTeam/dnsproxy/fastip"
"github.com/AdguardTeam/dnsproxy/proxy"
"github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/logutil/slogutil"
@@ -41,6 +40,10 @@ const (
// userFilterDataDir is the name of the directory used to store users'
// FS-based rule lists.
userFilterDataDir = "userfilters"
// defaultFastestTimeout is the default timeout for proxy fastest upstream
// mode.
defaultFastestTimeout = 1 * time.Second
)
// osConfig contains OS-related configuration.
@@ -339,7 +342,7 @@ var config = &configuration{
}},
CacheOptimisticAnswerTTL: timeutil.Duration(30 * time.Second),
CacheOptimisticMaxAge: timeutil.Duration(12 * time.Hour),
FastestTimeout: timeutil.Duration(fastip.DefaultPingWaitTimeout),
FastestTimeout: timeutil.Duration(defaultFastestTimeout),
UpstreamTimeout: timeutil.Duration(dnsforward.DefaultTimeout),
// set default maximum concurrent queries to 300