Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 52 additions & 39 deletions AGHTechDoc.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,28 +481,32 @@ Response:
200 OK

{
"enabled":false,
"interface_name":"...",
"v4":{
"gateway_ip":"...",
"subnet_mask":"...",
"range_start":"...", // if empty: DHCPv4 won't be enabled
"range_end":"...",
"lease_duration":60,
},
"v6":{
"range_start":"...", // if empty: DHCPv6 won't be enabled
"lease_duration":60,
}
"leases":[
{"ip":"...","mac":"...","hostname":"...","expires":"..."}
...
],
"static_leases":[
{"ip":"...","mac":"...","hostname":"..."}
...
]
}
```none
{
"enabled":false,
"interface_name":"...",
"v4":{
"gateway_ip":"...",
"subnet_mask":"...",
"range_start":"...", // if empty: DHCPv4 won't be enabled
"range_end":"...",
"lease_duration":60,
},
"v6":{
"prefix_source":"static",
"range_start":"...", // if empty: DHCPv6 won't be enabled
"lease_duration":60,
},
"leases":[
{"ip":"...","mac":"...","hostname":"...","expires":"..."}
...
],
"static_leases":[
{"ip":"...","mac":"...","hostname":"..."}
...
]
}
```


### API: Check DHCP
Expand Down Expand Up @@ -557,23 +561,26 @@ If `static_ip.static` is:

Request:

POST /control/dhcp/set_config

{
"enabled":true,
"interface_name":"vboxnet0",
"v4":{
"gateway_ip":"192.169.56.1",
"subnet_mask":"255.255.255.0",
"range_start":"192.169.56.100",
"range_end":"192.169.56.200", // Note: first 3 octets must match "range_start"
"lease_duration":60,
},
"v6":{
"range_start":"...",
"lease_duration":60,
}
}
```none
POST /control/dhcp/set_config

{
"enabled":true,
"interface_name":"vboxnet0",
"v4":{
"gateway_ip":"192.169.56.1",
"subnet_mask":"255.255.255.0",
"range_start":"192.169.56.100",
"range_end":"192.169.56.200", // Note: first 3 octets must match "range_start"
"lease_duration":60,
},
"v6":{
"prefix_source":"static",
"range_start":"...",
"lease_duration":60,
}
}
```

Response:

Expand Down Expand Up @@ -758,6 +765,12 @@ Configuration:
* `ra_slaac_only:false; ra_allow_slaac:true`: use option #3.
Periodically send `ICMPv6.RouterAdvertisement(Flags=(Managed=true,Other=true))` packets.

For IPv6 prefix tracking, `prefix_source:static` keeps the current
legacy behavior. `prefix_source:interface` derives the advertised
prefix from the interface, keeps `range_start` as a host template for
the dynamic pool, and deprecates the previous prefix with preferred
lifetime `0` and a bounded valid lifetime when renumbering is observed.

ICMPv6.RouterAdvertisement packet description:

ICMPv6:
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ NOTE: Add new changes BELOW THIS COMMENT.

[go-1.26.2]: https://groups.google.com/g/golang-announce/c/0uYbvbPZRWU

### Added

- Opt-in IPv6 prefix tracking for DHCPv6 and Router Advertisements.
When enabled, AdGuard Home derives the advertised prefix from the
interface and deprecates the previous prefix during renumbering.

### Changed

#### Configuration changes
Expand Down
5 changes: 5 additions & 0 deletions client/src/__locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,13 @@
"dhcp_enable": "Enable DHCP server",
"dhcp_error": "AdGuard Home could not determine if there is another active DHCP server on the network",
"dhcp_form_gateway_input": "Gateway IP",
"dhcp_form_host_template_desc": "Used as the lower 64 bits of the dynamic pool when the prefix comes from the interface.",
"dhcp_form_host_template_input": "Host template",
"dhcp_form_lease_input": "Lease duration",
"dhcp_form_lease_title": "DHCP lease time (in seconds)",
"dhcp_form_prefix_source_interface": "Interface",
"dhcp_form_prefix_source_static": "Static",
"dhcp_form_prefix_source_title": "IPv6 prefix source",
"dhcp_form_range_end": "Range end",
"dhcp_form_range_start": "Range start",
"dhcp_form_range_title": "Range of IP addresses",
Expand Down
72 changes: 61 additions & 11 deletions client/src/components/Settings/Dhcp/FormDHCPv6.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import React, { useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';

import { UINT32_RANGE } from '../../../helpers/constants';
import {
DHCP_V6_PREFIX_SOURCE_OPTIONS,
DHCP_V6_PREFIX_SOURCE_VALUES,
UINT32_RANGE,
} from '../../../helpers/constants';
import { validateIpv6, validateRequiredValue } from '../../../helpers/validators';
import { DhcpFormValues } from '.';
import { DhcpFormValues, hasMeaningfulV6Value, isInterfaceSLAACOnlyV6Config } from '.';
import { Input } from '../../ui/Controls/Input';
import { Select } from '../../ui/Controls/Select';
import { toNumber } from '../../../helpers/form';

type FormDHCPv6Props = {
Expand All @@ -30,16 +35,47 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }

const interfaceName = watch('interface_name');
const isInterfaceIncludesIpv6 = interfaces?.[interfaceName]?.ipv6_addresses;
const prefixSource = watch('v6.prefix_source') ?? DHCP_V6_PREFIX_SOURCE_VALUES.STATIC;
const canConfigureV6 = Boolean(isInterfaceIncludesIpv6) || prefixSource === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE;

const formValues = watch('v6');
const isEmptyConfig = !Object.values(formValues || {}).some(Boolean);
const isInterfaceRASLAACOnly = isInterfaceSLAACOnlyV6Config(formValues);
const isRequiredForPool = !isInterfaceRASLAACOnly;
const isEmptyConfig = !hasMeaningfulV6Value(formValues);

const isDisabled = useMemo(() => {
return isSubmitting || !isValid || processingConfig || !isInterfaceIncludesIpv6 || isEmptyConfig;
}, [isSubmitting, isValid, processingConfig, isInterfaceIncludesIpv6, isEmptyConfig]);
return isSubmitting || !isValid || processingConfig || !canConfigureV6 || isEmptyConfig;
}, [isSubmitting, isValid, processingConfig, canConfigureV6, isEmptyConfig]);

return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="row">
<div className="col-lg-6 form__group form__group--settings">
<Controller
name="v6.prefix_source"
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value ?? DHCP_V6_PREFIX_SOURCE_VALUES.STATIC}
label={t('dhcp_form_prefix_source_title')}
data-testid="v6_prefix_source"
disabled={!interfaceName}>
{DHCP_V6_PREFIX_SOURCE_OPTIONS.map((value) => (
<option key={value} value={value}>
{t(
value === DHCP_V6_PREFIX_SOURCE_VALUES.STATIC
? 'dhcp_form_prefix_source_static'
: 'dhcp_form_prefix_source_interface',
)}
</option>
))}
</Select>
)}
/>
</div>
</div>

<div className="row">
<div className="col-lg-6">
<div className="form__group mb-0">
Expand All @@ -53,10 +89,12 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }
name="v6.range_start"
control={control}
rules={{
validate: isInterfaceIncludesIpv6
validate: canConfigureV6
? {
ipv6: validateIpv6,
required: validateRequiredValue,
required: isRequiredForPool
? validateRequiredValue
: undefined,
}
: undefined,
}}
Expand All @@ -65,9 +103,19 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }
{...field}
type="text"
data-testid="v6_range_start"
label={
prefixSource === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE
? t('dhcp_form_host_template_input')
: t('dhcp_form_range_start')
}
placeholder={t(ipv6placeholders.range_start)}
desc={
prefixSource === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE
? t('dhcp_form_host_template_desc')
: undefined
}
error={fieldState.error?.message}
disabled={!isInterfaceIncludesIpv6}
disabled={!canConfigureV6}
/>
)}
/>
Expand Down Expand Up @@ -100,9 +148,11 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }
name="v6.lease_duration"
control={control}
rules={{
validate: isInterfaceIncludesIpv6
validate: canConfigureV6
? {
required: validateRequiredValue,
required: isRequiredForPool
? validateRequiredValue
: undefined,
}
: undefined,
}}
Expand All @@ -114,7 +164,7 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }
label={t('dhcp_form_lease_title')}
placeholder={t(ipv6placeholders.lease_duration)}
error={fieldState.error?.message}
disabled={!isInterfaceIncludesIpv6}
disabled={!canConfigureV6}
min={1}
max={UINT32_RANGE.MAX}
onChange={(e) => {
Expand Down
34 changes: 31 additions & 3 deletions client/src/components/Settings/Dhcp/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import classNames from 'classnames';

import { FormProvider, useForm } from 'react-hook-form';
import { DHCP_DESCRIPTION_PLACEHOLDERS, STATUS_RESPONSE } from '../../../helpers/constants';
import { DHCP_DESCRIPTION_PLACEHOLDERS, DHCP_V6_PREFIX_SOURCE_VALUES, STATUS_RESPONSE } from '../../../helpers/constants';

import Leases from './Leases';

Expand Down Expand Up @@ -49,6 +49,8 @@ type IPv4FormValues = {
}

type IPv6FormValues = {
prefix_source?: string;
ra_slaac_only?: boolean;
range_start?: string;
range_end?: string;
lease_duration?: number;
Expand Down Expand Up @@ -84,11 +86,37 @@ const DEFAULT_V4_VALUES = {
};

const DEFAULT_V6_VALUES = {
prefix_source: DHCP_V6_PREFIX_SOURCE_VALUES.STATIC,
ra_slaac_only: false,
range_start: '',
range_end: '',
lease_duration: undefined,
};

export const isInterfaceSLAACOnlyV6Config = (v6Config: IPv6FormValues) =>
v6Config?.prefix_source === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE && Boolean(v6Config?.ra_slaac_only);

// V6_CONFIG_METADATA_KEYS are the IPv6 form fields that describe how the rest
// of the configuration should be interpreted, rather than being DHCPv6 inputs
// themselves. They do not count as "meaningful" values on their own.
const V6_CONFIG_METADATA_KEYS = new Set(['prefix_source', 'ra_slaac_only']);

export const hasMeaningfulV6Value = (v6Config: IPv6FormValues) =>
isInterfaceSLAACOnlyV6Config(v6Config)
|| Object.entries(v6Config || {}).some(
([key, value]) => !V6_CONFIG_METADATA_KEYS.has(key) && Boolean(value),
);

const isFilledV6Config = (v6Config: IPv6FormValues) =>
Object.entries(v6Config || {}).every(([key, value]) => (
V6_CONFIG_METADATA_KEYS.has(key)
|| (
(key === 'range_start' || key === 'lease_duration')
&& isInterfaceSLAACOnlyV6Config(v6Config)
)
|| Boolean(value)
));

const Dhcp = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
Expand Down Expand Up @@ -204,11 +232,11 @@ const Dhcp = () => {

const enteredSomeV4Value = Object.values(v4).some(Boolean);

const enteredSomeV6Value = Object.values(v6).some(Boolean);
const enteredSomeV6Value = hasMeaningfulV6Value(v6);
const enteredSomeValue = enteredSomeV4Value || enteredSomeV6Value || interfaceName;

const getToggleDhcpButton = () => {
const filledConfig = interface_name && (Object.values(v4).every(Boolean) || Object.values(v6).every(Boolean));
const filledConfig = interface_name && (Object.values(v4).every(Boolean) || isFilledV6Config(v6));

const className = classNames('btn btn-sm', {
'btn-gray': enabled,
Expand Down
10 changes: 10 additions & 0 deletions client/src/helpers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,16 @@ export const DHCP_VALUES_PLACEHOLDERS = {
},
};

export const DHCP_V6_PREFIX_SOURCE_VALUES = {
STATIC: 'static',
INTERFACE: 'interface',
} as const;

export const DHCP_V6_PREFIX_SOURCE_OPTIONS = [
DHCP_V6_PREFIX_SOURCE_VALUES.STATIC,
DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE,
] as const;

export const DHCP_DESCRIPTION_PLACEHOLDERS = {
ipv4: {
gateway_ip: 'dhcp_form_gateway_input',
Expand Down
Loading