mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2026-10-12 02:01:26 +00:00
AGH-179 fix client v1 DHCPv4 save, subnet and IPv6 CIDR validation
This commit is contained in:
@@ -1 +1,2 @@
|
||||
.twosky.json
|
||||
CHANGELOG.md
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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])?$/;
|
||||
|
||||
|
||||
@@ -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')}`;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user