From 26bdcf6c3b43e0df14e5d9198885f26d2216f5dd Mon Sep 17 00:00:00 2001 From: Omoeba <38597972+Omoeba@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:31:28 -0700 Subject: [PATCH 1/3] Add interface-derived IPv6 prefix tracking --- AGHTechDoc.md | 7 +- CHANGELOG.md | 4 + client/src/__locales/en.json | 5 + .../components/Settings/Dhcp/FormDHCPv6.tsx | 77 +- client/src/components/Settings/Dhcp/index.tsx | 35 +- client/src/helpers/constants.ts | 10 + client/src/reducers/dhcp.ts | 20 +- internal/aghnet/prefix.go | 130 +++ internal/aghnet/prefix_darwin.go | 32 + .../aghnet/prefix_darwin_internal_test.go | 37 + internal/aghnet/prefix_freebsd.go | 32 + .../aghnet/prefix_freebsd_internal_test.go | 36 + internal/aghnet/prefix_internal_test.go | 39 + internal/aghnet/prefix_linux.go | 156 +++ internal/aghnet/prefix_linux_internal_test.go | 50 + internal/aghnet/prefix_openbsd.go | 32 + .../aghnet/prefix_openbsd_internal_test.go | 36 + internal/dhcpd/config.go | 67 +- internal/dhcpd/db.go | 80 +- internal/dhcpd/dhcpd.go | 4 +- internal/dhcpd/http_unix.go | 54 +- internal/dhcpd/http_unix_internal_test.go | 102 +- internal/dhcpd/migrate.go | 2 +- internal/dhcpd/routeradv.go | 497 ++++++---- internal/dhcpd/routeradv_internal_test.go | 220 ++++- internal/dhcpd/routeradv_state.go | 608 ++++++++++++ .../dhcpd/routeradv_state_internal_test.go | 334 +++++++ internal/dhcpd/v6_unix.go | 929 +++++++++++++++++- internal/dhcpd/v6_unix_internal_test.go | 834 ++++++++++++++++ internal/home/config.go | 1 + openapi/next.yaml | 12 +- openapi/openapi.yaml | 10 + 32 files changed, 4217 insertions(+), 275 deletions(-) create mode 100644 internal/aghnet/prefix.go create mode 100644 internal/aghnet/prefix_darwin.go create mode 100644 internal/aghnet/prefix_darwin_internal_test.go create mode 100644 internal/aghnet/prefix_freebsd.go create mode 100644 internal/aghnet/prefix_freebsd_internal_test.go create mode 100644 internal/aghnet/prefix_internal_test.go create mode 100644 internal/aghnet/prefix_linux.go create mode 100644 internal/aghnet/prefix_linux_internal_test.go create mode 100644 internal/aghnet/prefix_openbsd.go create mode 100644 internal/aghnet/prefix_openbsd_internal_test.go create mode 100644 internal/dhcpd/routeradv_state.go create mode 100644 internal/dhcpd/routeradv_state_internal_test.go diff --git a/AGHTechDoc.md b/AGHTechDoc.md index 902367e53c9..463cf1aa74a 100644 --- a/AGHTechDoc.md +++ b/AGHTechDoc.md @@ -491,9 +491,10 @@ Response: "lease_duration":60, }, "v6":{ + "prefix_source":"static", "range_start":"...", // if empty: DHCPv6 won't be enabled "lease_duration":60, - } + }, "leases":[ {"ip":"...","mac":"...","hostname":"...","expires":"..."} ... @@ -570,6 +571,7 @@ Request: "lease_duration":60, }, "v6":{ + "prefix_source":"static", "range_start":"...", "lease_duration":60, } @@ -758,6 +760,9 @@ 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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 39fe9dcaf7f..9443e44bfa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ 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 diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index 35a028ed26c..b46a45ef75a 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -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", diff --git a/client/src/components/Settings/Dhcp/FormDHCPv6.tsx b/client/src/components/Settings/Dhcp/FormDHCPv6.tsx index 41eba8c2d45..6794156452c 100644 --- a/client/src/components/Settings/Dhcp/FormDHCPv6.tsx +++ b/client/src/components/Settings/Dhcp/FormDHCPv6.tsx @@ -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 { Input } from '../../ui/Controls/Input'; +import { Select } from '../../ui/Controls/Select'; import { toNumber } from '../../../helpers/form'; type FormDHCPv6Props = { @@ -30,16 +35,54 @@ 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 isRASLAACOnly = watch('v6.ra_slaac_only'); + const canConfigureV6 = Boolean(isInterfaceIncludesIpv6) || prefixSource === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE; + const isInterfaceRASLAACOnly = ( + prefixSource === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE + && isRASLAACOnly + ); + const shouldRequireRangeStart = !isInterfaceRASLAACOnly; + const shouldRequireLeaseDuration = !isInterfaceRASLAACOnly; const formValues = watch('v6'); - const isEmptyConfig = !Object.values(formValues || {}).some(Boolean); + const isEmptyConfig = !Object.entries(formValues || {}).some( + ([key, value]) => key !== 'prefix_source' && Boolean(value), + ); 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 (
+
+
+ ( + + )} + /> +
+
+
@@ -53,10 +96,12 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit } name="v6.range_start" control={control} rules={{ - validate: isInterfaceIncludesIpv6 + validate: canConfigureV6 ? { ipv6: validateIpv6, - required: validateRequiredValue, + required: shouldRequireRangeStart + ? validateRequiredValue + : undefined, } : undefined, }} @@ -65,9 +110,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} /> )} /> @@ -100,9 +155,11 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit } name="v6.lease_duration" control={control} rules={{ - validate: isInterfaceIncludesIpv6 + validate: canConfigureV6 ? { - required: validateRequiredValue, + required: shouldRequireLeaseDuration + ? validateRequiredValue + : undefined, } : undefined, }} @@ -114,7 +171,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) => { diff --git a/client/src/components/Settings/Dhcp/index.tsx b/client/src/components/Settings/Dhcp/index.tsx index 2daf4e65556..774082dc109 100644 --- a/client/src/components/Settings/Dhcp/index.tsx +++ b/client/src/components/Settings/Dhcp/index.tsx @@ -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'; @@ -49,6 +49,8 @@ type IPv4FormValues = { } type IPv6FormValues = { + prefix_source?: string; + ra_slaac_only?: boolean; range_start?: string; range_end?: string; lease_duration?: number; @@ -84,11 +86,38 @@ 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, }; +const isInterfaceSLAACOnlyV6Config = (v6Config: IPv6FormValues) => ( + v6Config?.prefix_source === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE + && v6Config?.ra_slaac_only +); + +const hasMeaningfulV6Value = (v6Config: IPv6FormValues) => + isInterfaceSLAACOnlyV6Config(v6Config) + || ( + Object.entries(v6Config || {}).some(([key, value]) => ( + key !== 'prefix_source' + && key !== 'ra_slaac_only' + && Boolean(value) + ))); + +const isFilledV6Config = (v6Config: IPv6FormValues) => + Object.entries(v6Config || {}).every(([key, value]) => ( + key === 'prefix_source' + || key === 'ra_slaac_only' + || ( + (key === 'range_start' || key === 'lease_duration') + && isInterfaceSLAACOnlyV6Config(v6Config) + ) + || Boolean(value) + )); + const Dhcp = () => { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -204,11 +233,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, diff --git a/client/src/helpers/constants.ts b/client/src/helpers/constants.ts index d4e7c940954..fa246bef66b 100644 --- a/client/src/helpers/constants.ts +++ b/client/src/helpers/constants.ts @@ -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', diff --git a/client/src/reducers/dhcp.ts b/client/src/reducers/dhcp.ts index e3c8a703133..f7ec7dfe246 100644 --- a/client/src/reducers/dhcp.ts +++ b/client/src/reducers/dhcp.ts @@ -3,6 +3,7 @@ import { handleActions } from 'redux-actions'; import * as actions from '../actions'; import { enrichWithConcatenatedIpAddresses } from '../helpers/helpers'; +import { DHCP_V6_PREFIX_SOURCE_VALUES } from '../helpers/constants'; const dhcp = handleActions( { @@ -15,12 +16,22 @@ const dhcp = handleActions( processing: false, }), [actions.getDhcpStatusSuccess.toString()]: (state: any, { payload }: any) => { - const { static_leases: staticLeases, ...values } = payload; + const { static_leases: staticLeases, v4, v6, ...values } = payload; const newState = { ...state, staticLeases, processing: false, + v4: { + ...state.v4, + ...v4, + }, + v6: { + ...state.v6, + prefix_source: DHCP_V6_PREFIX_SOURCE_VALUES.STATIC, + ra_slaac_only: false, + ...v6, + }, ...values, }; @@ -117,7 +128,10 @@ const dhcp = handleActions( processingReset: false, enabled: false, v4: {}, - v6: {}, + v6: { + prefix_source: DHCP_V6_PREFIX_SOURCE_VALUES.STATIC, + ra_slaac_only: false, + }, interface_name: '', }), [actions.resetDhcpLeasesSuccess.toString()]: (state: any) => ({ @@ -215,6 +229,8 @@ const dhcp = handleActions( lease_duration: 0, }, v6: { + prefix_source: DHCP_V6_PREFIX_SOURCE_VALUES.STATIC, + ra_slaac_only: false, range_start: '', lease_duration: 0, }, diff --git a/internal/aghnet/prefix.go b/internal/aghnet/prefix.go new file mode 100644 index 00000000000..b7d2bbb1a98 --- /dev/null +++ b/internal/aghnet/prefix.go @@ -0,0 +1,130 @@ +package aghnet + +import ( + "bufio" + "bytes" + "fmt" + "math" + "net/netip" + "strconv" + "strings" +) + +// IPv6AddrState describes the current state of one IPv6 interface address. +type IPv6AddrState struct { + Addr netip.Addr + Prefix netip.Prefix + PreferredLifetimeSec uint32 + ValidLifetimeSec uint32 + Temporary bool + Tentative bool +} + +// parseIfconfigIPv6Addrs parses IPv6 interface state lines from ifconfig. +func parseIfconfigIPv6Addrs(out []byte) (states []IPv6AddrState, err error) { + s := bufio.NewScanner(bytes.NewReader(out)) + for s.Scan() { + fields := strings.Fields(s.Text()) + if len(fields) < 2 || !strings.EqualFold(fields[0], "inet6") { + continue + } + + state, parseErr := parseIfconfigIPv6Addr(fields) + if parseErr != nil { + return nil, parseErr + } + + states = append(states, state) + } + + return states, s.Err() +} + +// parseIfconfigIPv6Addr parses one `ifconfig` IPv6 address line. +func parseIfconfigIPv6Addr(fields []string) (state IPv6AddrState, err error) { + addr, err := parseIPv6AddrNoZone(fields[1]) + if err != nil { + return IPv6AddrState{}, fmt.Errorf("parsing addr %q: %w", fields[1], err) + } + + preferred := uint32(math.MaxUint32) + valid := uint32(math.MaxUint32) + prefixBits := -1 + + for i := 2; i < len(fields); i++ { + switch strings.ToLower(fields[i]) { + case "prefixlen": + i++ + if i >= len(fields) { + return IPv6AddrState{}, fmt.Errorf("missing prefixlen value in %q", strings.Join(fields, " ")) + } + + prefixBits, err = strconv.Atoi(fields[i]) + if err != nil { + return IPv6AddrState{}, fmt.Errorf("parsing prefixlen %q: %w", fields[i], err) + } + case "pltime": + i++ + if i >= len(fields) { + return IPv6AddrState{}, fmt.Errorf("missing pltime value in %q", strings.Join(fields, " ")) + } + + preferred, err = parseIPv6Lifetime(fields[i]) + if err != nil { + return IPv6AddrState{}, fmt.Errorf("parsing pltime %q: %w", fields[i], err) + } + case "vltime": + i++ + if i >= len(fields) { + return IPv6AddrState{}, fmt.Errorf("missing vltime value in %q", strings.Join(fields, " ")) + } + + valid, err = parseIPv6Lifetime(fields[i]) + if err != nil { + return IPv6AddrState{}, fmt.Errorf("parsing vltime %q: %w", fields[i], err) + } + case "temporary": + state.Temporary = true + case "tentative": + state.Tentative = true + } + } + + if prefixBits < 0 { + return IPv6AddrState{}, fmt.Errorf("missing prefixlen in %q", strings.Join(fields, " ")) + } + + return IPv6AddrState{ + Addr: addr, + Prefix: netip.PrefixFrom(addr, prefixBits).Masked(), + PreferredLifetimeSec: preferred, + ValidLifetimeSec: valid, + Temporary: state.Temporary, + Tentative: state.Tentative, + }, nil +} + +// parseIPv6Lifetime parses an IPv6 lifetime token from command output. +func parseIPv6Lifetime(s string) (sec uint32, err error) { + switch strings.ToLower(s) { + case "forever", "infinity", "infinite", "infty": + return math.MaxUint32, nil + default: + v, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return 0, err + } + + return uint32(v), nil + } +} + +// parseIPv6AddrNoZone parses an IPv6 address and removes the interface zone. +func parseIPv6AddrNoZone(s string) (addr netip.Addr, err error) { + addr, err = netip.ParseAddr(s) + if err != nil { + return netip.Addr{}, err + } + + return addr.WithZone(""), nil +} diff --git a/internal/aghnet/prefix_darwin.go b/internal/aghnet/prefix_darwin.go new file mode 100644 index 00000000000..cb3428b80cb --- /dev/null +++ b/internal/aghnet/prefix_darwin.go @@ -0,0 +1,32 @@ +//go:build darwin + +package aghnet + +import ( + "bytes" + "context" + "fmt" + "log/slog" + + "github.com/AdguardTeam/golibs/osutil/executil" +) + +// ObserveIPv6Addrs returns IPv6 interface address state for ifaceName. +func ObserveIPv6Addrs( + ctx context.Context, + _ *slog.Logger, + cmdCons executil.CommandConstructor, + ifaceName string, +) (states []IPv6AddrState, err error) { + stdout := &bytes.Buffer{} + err = executil.Run(ctx, cmdCons, &executil.CommandConfig{ + Path: "ifconfig", + Args: []string{"-L", ifaceName, "inet6"}, + Stdout: stdout, + }) + if err != nil { + return nil, fmt.Errorf("running ifconfig -L %s inet6: %w", ifaceName, err) + } + + return parseIfconfigIPv6Addrs(stdout.Bytes()) +} diff --git a/internal/aghnet/prefix_darwin_internal_test.go b/internal/aghnet/prefix_darwin_internal_test.go new file mode 100644 index 00000000000..8492c2e0cea --- /dev/null +++ b/internal/aghnet/prefix_darwin_internal_test.go @@ -0,0 +1,37 @@ +//go:build darwin + +package aghnet + +import ( + "testing" + + "github.com/AdguardTeam/AdGuardHome/internal/agh" + "github.com/AdguardTeam/golibs/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestObserveIPv6Addrs(t *testing.T) { + ctx := testutil.ContextWithTimeout(t, testTimeout) + states, err := ObserveIPv6Addrs( + ctx, + testLogger, + agh.NewCommandConstructor( + "ifconfig -L en0 inet6", + 0, + ` +en0: flags=8863 + inet6 fe80::1%en0 prefixlen 64 scopeid 0x8 pltime infty vltime infty + inet6 2001:db8::2 prefixlen 64 autoconf pltime 600 vltime 1200 +`, + nil, + ), + "en0", + ) + require.NoError(t, err) + require.Len(t, states, 2) + + assert.Equal(t, "fe80::1", states[0].Addr.String()) + assert.Equal(t, "2001:db8::/64", states[1].Prefix.String()) + assert.Equal(t, uint32(600), states[1].PreferredLifetimeSec) +} diff --git a/internal/aghnet/prefix_freebsd.go b/internal/aghnet/prefix_freebsd.go new file mode 100644 index 00000000000..4b1527062fa --- /dev/null +++ b/internal/aghnet/prefix_freebsd.go @@ -0,0 +1,32 @@ +//go:build freebsd + +package aghnet + +import ( + "bytes" + "context" + "fmt" + "log/slog" + + "github.com/AdguardTeam/golibs/osutil/executil" +) + +// ObserveIPv6Addrs returns IPv6 interface address state for ifaceName. +func ObserveIPv6Addrs( + ctx context.Context, + _ *slog.Logger, + cmdCons executil.CommandConstructor, + ifaceName string, +) (states []IPv6AddrState, err error) { + stdout := &bytes.Buffer{} + err = executil.Run(ctx, cmdCons, &executil.CommandConfig{ + Path: "ifconfig", + Args: []string{"-L", ifaceName, "inet6"}, + Stdout: stdout, + }) + if err != nil { + return nil, fmt.Errorf("running ifconfig -L %s inet6: %w", ifaceName, err) + } + + return parseIfconfigIPv6Addrs(stdout.Bytes()) +} diff --git a/internal/aghnet/prefix_freebsd_internal_test.go b/internal/aghnet/prefix_freebsd_internal_test.go new file mode 100644 index 00000000000..8ef4a45083e --- /dev/null +++ b/internal/aghnet/prefix_freebsd_internal_test.go @@ -0,0 +1,36 @@ +//go:build freebsd + +package aghnet + +import ( + "testing" + + "github.com/AdguardTeam/AdGuardHome/internal/agh" + "github.com/AdguardTeam/golibs/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestObserveIPv6Addrs(t *testing.T) { + ctx := testutil.ContextWithTimeout(t, testTimeout) + states, err := ObserveIPv6Addrs( + ctx, + testLogger, + agh.NewCommandConstructor( + "ifconfig -L em0 inet6", + 0, + ` +em0: flags=8863 + inet6 fe80::1%em0 prefixlen 64 scopeid 0x1 pltime infty vltime infty + inet6 2001:db8::2 prefixlen 64 autoconf temporary pltime 600 vltime 1200 +`, + nil, + ), + "em0", + ) + require.NoError(t, err) + require.Len(t, states, 2) + + assert.True(t, states[1].Temporary) + assert.Equal(t, uint32(1200), states[1].ValidLifetimeSec) +} diff --git a/internal/aghnet/prefix_internal_test.go b/internal/aghnet/prefix_internal_test.go new file mode 100644 index 00000000000..7e6ac3da6bf --- /dev/null +++ b/internal/aghnet/prefix_internal_test.go @@ -0,0 +1,39 @@ +package aghnet + +import ( + "math" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseIfconfigIPv6Addrs(t *testing.T) { + states, err := parseIfconfigIPv6Addrs([]byte(` +en0: flags=8863 + inet6 fe80::1%en0 prefixlen 64 scopeid 0x8 pltime infty vltime infty + inet6 2001:db8::100 prefixlen 64 autoconf temporary pltime 600 vltime 1200 + inet6 2001:db8:1::200 prefixlen 64 tentative pltime 30 vltime 60 +`)) + require.NoError(t, err) + + assert.Equal(t, []IPv6AddrState{{ + Addr: netip.MustParseAddr("fe80::1"), + Prefix: netip.MustParsePrefix("fe80::/64"), + PreferredLifetimeSec: math.MaxUint32, + ValidLifetimeSec: math.MaxUint32, + }, { + Addr: netip.MustParseAddr("2001:db8::100"), + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredLifetimeSec: 600, + ValidLifetimeSec: 1200, + Temporary: true, + }, { + Addr: netip.MustParseAddr("2001:db8:1::200"), + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredLifetimeSec: 30, + ValidLifetimeSec: 60, + Tentative: true, + }}, states) +} diff --git a/internal/aghnet/prefix_linux.go b/internal/aghnet/prefix_linux.go new file mode 100644 index 00000000000..35a8c312dcb --- /dev/null +++ b/internal/aghnet/prefix_linux.go @@ -0,0 +1,156 @@ +//go:build linux + +package aghnet + +import ( + "context" + "encoding/binary" + "fmt" + "log/slog" + "net" + "net/netip" + "syscall" + "unsafe" + + "github.com/AdguardTeam/golibs/osutil/executil" + "golang.org/x/sys/unix" +) + +// ObserveIPv6Addrs returns IPv6 interface address state for ifaceName. +func ObserveIPv6Addrs( + _ context.Context, + _ *slog.Logger, + _ executil.CommandConstructor, + ifaceName string, +) (states []IPv6AddrState, err error) { + iface, err := net.InterfaceByName(ifaceName) + if err != nil { + return nil, fmt.Errorf("finding interface %s: %w", ifaceName, err) + } + + rib, err := syscall.NetlinkRIB(syscall.RTM_GETADDR, syscall.AF_INET6) + if err != nil { + return nil, fmt.Errorf("querying rtnetlink addrs: %w", err) + } + + msgs, err := syscall.ParseNetlinkMessage(rib) + if err != nil { + return nil, fmt.Errorf("parsing rtnetlink addrs: %w", err) + } + + return parseIPv6AddrStatesNetlink(msgs, iface.Index) +} + +// parseIPv6AddrStatesNetlink parses IPv6 address state from netlink messages. +func parseIPv6AddrStatesNetlink( + msgs []syscall.NetlinkMessage, + ifIndex int, +) (states []IPv6AddrState, err error) { +loop: + for _, msg := range msgs { + switch msg.Header.Type { + case syscall.NLMSG_DONE: + break loop + case syscall.RTM_NEWADDR: + // Go on. + default: + continue + } + + if len(msg.Data) < syscall.SizeofIfAddrmsg { + return nil, fmt.Errorf("short ifaddrmsg payload") + } + + ifam := (*syscall.IfAddrmsg)(unsafe.Pointer(&msg.Data[0])) + if ifam.Family != syscall.AF_INET6 || int(ifam.Index) != ifIndex { + continue + } + + attrs, err := syscall.ParseNetlinkRouteAttr(&msg) + if err != nil { + return nil, fmt.Errorf("parsing route attrs: %w", err) + } + + state, ok, err := parseIPv6AddrStateNetlink(ifam, attrs) + if err != nil { + return nil, err + } else if ok { + states = append(states, state) + } + } + + return states, nil +} + +// parseIPv6AddrStateNetlink parses one IPv6 address state from the netlink +// message data. +func parseIPv6AddrStateNetlink( + ifam *syscall.IfAddrmsg, + attrs []syscall.NetlinkRouteAttr, +) (state IPv6AddrState, ok bool, err error) { + var addr netip.Addr + var cache *unix.IfaCacheinfo + flags := uint32(ifam.Flags) + + for _, attr := range attrs { + switch attr.Attr.Type { + case unix.IFA_LOCAL: + addr, err = parseIPv6AddrAttr(attr.Value) + if err != nil { + return IPv6AddrState{}, false, fmt.Errorf("parsing ifa_local: %w", err) + } + case unix.IFA_ADDRESS: + if addr.IsValid() { + continue + } + + addr, err = parseIPv6AddrAttr(attr.Value) + if err != nil { + return IPv6AddrState{}, false, fmt.Errorf("parsing ifa_address: %w", err) + } + case unix.IFA_FLAGS: + if len(attr.Value) < 4 { + return IPv6AddrState{}, false, fmt.Errorf("short ifa_flags attribute") + } + + flags = binary.NativeEndian.Uint32(attr.Value[:4]) + case unix.IFA_CACHEINFO: + if len(attr.Value) < unix.SizeofIfaCacheinfo { + return IPv6AddrState{}, false, fmt.Errorf("short ifa_cacheinfo attribute") + } + + cache = (*unix.IfaCacheinfo)(unsafe.Pointer(&attr.Value[0])) + } + } + + if !addr.IsValid() { + return IPv6AddrState{}, false, nil + } + + preferred, valid := uint32(^uint32(0)), uint32(^uint32(0)) + if cache != nil { + preferred = cache.Prefered + valid = cache.Valid + } + + return IPv6AddrState{ + Addr: addr, + Prefix: netip.PrefixFrom(addr, int(ifam.Prefixlen)).Masked(), + PreferredLifetimeSec: preferred, + ValidLifetimeSec: valid, + Temporary: flags&unix.IFA_F_TEMPORARY != 0, + Tentative: flags&unix.IFA_F_TENTATIVE != 0, + }, true, nil +} + +// parseIPv6AddrAttr parses one IPv6 address attribute. +func parseIPv6AddrAttr(b []byte) (addr netip.Addr, err error) { + if len(b) < net.IPv6len { + return netip.Addr{}, fmt.Errorf("short ipv6 address") + } + + var arr [net.IPv6len]byte + copy(arr[:], b[:net.IPv6len]) + + return netip.AddrFrom16(arr), nil +} diff --git a/internal/aghnet/prefix_linux_internal_test.go b/internal/aghnet/prefix_linux_internal_test.go new file mode 100644 index 00000000000..05129d3950a --- /dev/null +++ b/internal/aghnet/prefix_linux_internal_test.go @@ -0,0 +1,50 @@ +//go:build linux + +package aghnet + +import ( + "encoding/binary" + "net/netip" + "syscall" + "testing" + "unsafe" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func TestParseIPv6AddrStateNetlink(t *testing.T) { + addr := netip.MustParseAddr("2001:db8::1234").As16() + flags := make([]byte, 4) + binary.NativeEndian.PutUint32(flags, unix.IFA_F_TEMPORARY|unix.IFA_F_TENTATIVE) + + cache := unix.IfaCacheinfo{ + Prefered: 600, + Valid: 1200, + } + cacheBytes := *(*[unix.SizeofIfaCacheinfo]byte)(unsafe.Pointer(&cache)) + + state, ok, err := parseIPv6AddrStateNetlink(&syscall.IfAddrmsg{ + Family: syscall.AF_INET6, + Prefixlen: 64, + }, []syscall.NetlinkRouteAttr{{ + Attr: syscall.RtAttr{Type: unix.IFA_ADDRESS}, + Value: addr[:], + }, { + Attr: syscall.RtAttr{Type: unix.IFA_FLAGS}, + Value: flags, + }, { + Attr: syscall.RtAttr{Type: unix.IFA_CACHEINFO}, + Value: cacheBytes[:], + }}) + require.NoError(t, err) + require.True(t, ok) + + assert.Equal(t, netip.MustParseAddr("2001:db8::1234"), state.Addr) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), state.Prefix) + assert.Equal(t, uint32(600), state.PreferredLifetimeSec) + assert.Equal(t, uint32(1200), state.ValidLifetimeSec) + assert.True(t, state.Temporary) + assert.True(t, state.Tentative) +} diff --git a/internal/aghnet/prefix_openbsd.go b/internal/aghnet/prefix_openbsd.go new file mode 100644 index 00000000000..4a3da6d944f --- /dev/null +++ b/internal/aghnet/prefix_openbsd.go @@ -0,0 +1,32 @@ +//go:build openbsd + +package aghnet + +import ( + "bytes" + "context" + "fmt" + "log/slog" + + "github.com/AdguardTeam/golibs/osutil/executil" +) + +// ObserveIPv6Addrs returns IPv6 interface address state for ifaceName. +func ObserveIPv6Addrs( + ctx context.Context, + _ *slog.Logger, + cmdCons executil.CommandConstructor, + ifaceName string, +) (states []IPv6AddrState, err error) { + stdout := &bytes.Buffer{} + err = executil.Run(ctx, cmdCons, &executil.CommandConfig{ + Path: "ifconfig", + Args: []string{"-A", ifaceName, "inet6"}, + Stdout: stdout, + }) + if err != nil { + return nil, fmt.Errorf("running ifconfig -A %s inet6: %w", ifaceName, err) + } + + return parseIfconfigIPv6Addrs(stdout.Bytes()) +} diff --git a/internal/aghnet/prefix_openbsd_internal_test.go b/internal/aghnet/prefix_openbsd_internal_test.go new file mode 100644 index 00000000000..67a65ee7ed6 --- /dev/null +++ b/internal/aghnet/prefix_openbsd_internal_test.go @@ -0,0 +1,36 @@ +//go:build openbsd + +package aghnet + +import ( + "testing" + + "github.com/AdguardTeam/AdGuardHome/internal/agh" + "github.com/AdguardTeam/golibs/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestObserveIPv6Addrs(t *testing.T) { + ctx := testutil.ContextWithTimeout(t, testTimeout) + states, err := ObserveIPv6Addrs( + ctx, + testLogger, + agh.NewCommandConstructor( + "ifconfig -A em0 inet6", + 0, + ` +em0: flags=8843 + inet6 fe80::1%em0 prefixlen 64 scopeid 0x1 pltime infty vltime infty + inet6 2001:db8::2 prefixlen 64 autoconf tentative pltime 30 vltime 60 +`, + nil, + ), + "em0", + ) + require.NoError(t, err) + require.Len(t, states, 2) + + assert.True(t, states[1].Tentative) + assert.Equal(t, uint32(30), states[1].PreferredLifetimeSec) +} diff --git a/internal/dhcpd/config.go b/internal/dhcpd/config.go index 8e9400a9e36..1110cf7877e 100644 --- a/internal/dhcpd/config.go +++ b/internal/dhcpd/config.go @@ -240,12 +240,27 @@ func (c *V4ServerConf) Validate() (err error) { return nil } +// V6PrefixSource is the source of the IPv6 prefix used for Router +// Advertisements and, when DHCPv6 is enabled, the dynamic lease pool. +type V6PrefixSource string + +// V6PrefixSource values. +const ( + V6PrefixSourceStatic V6PrefixSource = "static" + V6PrefixSourceInterface V6PrefixSource = "interface" +) + // V6ServerConf - server configuration type V6ServerConf struct { // Logger is used for logging the operation of the DHCPv6 server. It must // not be nil. Logger *slog.Logger `yaml:"-" json:"-"` + // CommandConstructor is used to run external commands required to observe + // IPv6 interface state on platforms without a native API. It must not be + // nil. + CommandConstructor executil.CommandConstructor `yaml:"-" json:"-"` + Enabled bool `yaml:"-" json:"-"` InterfaceName string `yaml:"-" json:"-"` @@ -253,15 +268,63 @@ type V6ServerConf struct { // The last allowed IP address ends with 0xff byte RangeStart net.IP `yaml:"range_start" json:"range_start"` + // PrefixSource defines how Router Advertisement prefixes should be chosen. + // If it is empty, the configured static prefix semantics are used. + PrefixSource V6PrefixSource `yaml:"prefix_source" json:"prefix_source"` + LeaseDuration uint32 `yaml:"lease_duration" json:"lease_duration"` // in seconds - RASLAACOnly bool `yaml:"ra_slaac_only" json:"-"` // send ICMPv6.RA packets without MO flags - RAAllowSLAAC bool `yaml:"ra_allow_slaac" json:"-"` // send ICMPv6.RA packets with MO flags + RASLAACOnly bool `yaml:"ra_slaac_only" json:"ra_slaac_only"` // send ICMPv6.RA packets without MO flags + RAAllowSLAAC bool `yaml:"ra_allow_slaac" json:"-"` // send ICMPv6.RA packets with MO flags ipStart net.IP // starting IP address for dynamic leases leaseTime time.Duration // the time during which a dynamic lease is considered valid dnsIPAddrs []net.IP // IPv6 addresses to return to DHCP clients as DNS server addresses + // skipDeprecatedLeaseRestore disables one-time restoration of deprecated + // prefixes from saved dynamic leases on startup. + skipDeprecatedLeaseRestore bool `yaml:"-" json:"-"` + // Server calls this function when leases data changes notify func(uint32) } + +// NormalizedPrefixSource returns the configured prefix source or the default +// static mode if it is empty. +func (c V6ServerConf) NormalizedPrefixSource() (src V6PrefixSource) { + if c.PrefixSource == "" { + return V6PrefixSourceStatic + } + + return c.PrefixSource +} + +// NeedsDHCPv6Pool reports whether DHCPv6 address allocation requires a dynamic +// pool. +func (c V6ServerConf) NeedsDHCPv6Pool() (ok bool) { + return !c.RASLAACOnly +} + +// IsConfiguredForEnable reports whether c contains the minimum fields needed +// to enable DHCPv6 and/or Router Advertisements for the selected prefix mode. +func (c V6ServerConf) IsConfiguredForEnable() (ok bool) { + switch c.NormalizedPrefixSource() { + case V6PrefixSourceStatic: + return len(c.RangeStart) != 0 + case V6PrefixSourceInterface: + return !c.NeedsDHCPv6Pool() || len(c.RangeStart) != 0 + default: + return false + } +} + +// ValidatePrefixSource returns an error if c contains an unknown IPv6 prefix +// source. +func (c V6ServerConf) ValidatePrefixSource() (err error) { + switch c.NormalizedPrefixSource() { + case V6PrefixSourceStatic, V6PrefixSourceInterface: + return nil + default: + return fmt.Errorf("unknown prefix source %q", c.PrefixSource) + } +} diff --git a/internal/dhcpd/db.go b/internal/dhcpd/db.go index d3ca53bf95b..eb84d2a5595 100644 --- a/internal/dhcpd/db.go +++ b/internal/dhcpd/db.go @@ -34,6 +34,21 @@ type dataLeases struct { // Leases is the list containing stored DHCP leases. Leases []*dbLease `json:"leases"` + + // V6Meta contains persisted IPv6 prefix-tracking metadata. + V6Meta *dataLeasesV6Meta `json:"v6_meta,omitempty"` +} + +// dataLeasesV6Meta is the persisted IPv6 prefix-tracking metadata. +type dataLeasesV6Meta struct { + Deprecated []*dbDeprecatedPrefix `json:"deprecated_prefixes,omitempty"` + Renewable []netip.Prefix `json:"renewable_prefixes,omitempty"` +} + +// dbDeprecatedPrefix is one persisted deprecated IPv6 prefix and its expiry. +type dbDeprecatedPrefix struct { + Prefix netip.Prefix `json:"prefix"` + ValidUntil string `json:"valid_until"` } // dbLease is the structure of stored lease. @@ -136,6 +151,32 @@ func (s *server) dbLoad() (err error) { if err != nil { return fmt.Errorf("resetting dhcpv6 leases: %w", err) } + if dl.V6Meta != nil { + if srv6, ok := s.srv6.(*v6Server); ok { + renewable := map[netip.Prefix]struct{}{} + for _, pref := range dl.V6Meta.Renewable { + renewable[pref] = struct{}{} + } + + deprecated := map[netip.Prefix]time.Time{} + for _, dp := range dl.V6Meta.Deprecated { + if dp == nil { + continue + } + + until, parseErr := time.Parse(time.RFC3339, dp.ValidUntil) + if parseErr != nil { + log.Info("dhcp: invalid v6 deprecated prefix %s: %s", dp.Prefix, parseErr) + + continue + } + + deprecated[dp.Prefix] = until + } + + srv6.setRestoredPrefixMeta(renewable, deprecated) + } + } } log.Info( @@ -153,22 +194,52 @@ func (s *server) dbStore() (err error) { // Use an empty slice here as opposed to nil so that it doesn't write // "null" into the database file if leases are empty. leases := []*dbLease{} + var v6Meta *dataLeasesV6Meta for _, l := range s.srv4.getLeasesRef() { leases = append(leases, fromLease(l)) } if s.srv6 != nil { - for _, l := range s.srv6.getLeasesRef() { - leases = append(leases, fromLease(l)) + if srv6, ok := s.srv6.(*v6Server); ok { + leases6, renewable, deprecated := srv6.dbSnapshot(time.Now()) + for _, l := range leases6 { + leases = append(leases, fromLease(l)) + } + if len(renewable) > 0 || len(deprecated) > 0 { + v6Meta = &dataLeasesV6Meta{ + Renewable: make([]netip.Prefix, 0, len(renewable)), + } + for pref := range renewable { + v6Meta.Renewable = append(v6Meta.Renewable, pref) + } + slices.SortFunc(v6Meta.Renewable, prefixCompare) + + if len(deprecated) > 0 { + v6Meta.Deprecated = make([]*dbDeprecatedPrefix, 0, len(deprecated)) + for pref, until := range deprecated { + v6Meta.Deprecated = append(v6Meta.Deprecated, &dbDeprecatedPrefix{ + Prefix: pref, + ValidUntil: until.Format(time.RFC3339), + }) + } + slices.SortFunc(v6Meta.Deprecated, func(a, b *dbDeprecatedPrefix) int { + return prefixCompare(a.Prefix, b.Prefix) + }) + } + } + } else { + for _, l := range s.srv6.getLeasesRef() { + leases = append(leases, fromLease(l)) + } } } - return writeDB(s.conf.dbFilePath, leases) + return writeDB(s.conf.dbFilePath, leases, v6Meta) } // writeDB writes leases to file at path. -func writeDB(path string, leases []*dbLease) (err error) { +func writeDB(path string, leases []*dbLease, v6Meta *dataLeasesV6Meta) (err error) { defer func() { err = errors.Annotate(err, "writing db: %w") }() slices.SortFunc(leases, func(a, b *dbLease) (res int) { @@ -178,6 +249,7 @@ func writeDB(path string, leases []*dbLease) (err error) { dl := &dataLeases{ Version: dataVersion, Leases: leases, + V6Meta: v6Meta, } buf, err := json.Marshal(dl) diff --git a/internal/dhcpd/dhcpd.go b/internal/dhcpd/dhcpd.go index ec385072ac1..b7974f18c82 100644 --- a/internal/dhcpd/dhcpd.go +++ b/internal/dhcpd/dhcpd.go @@ -181,9 +181,11 @@ func (s *server) setServers( v6conf := conf.Conf6 v6conf.Logger = s.conf.Logger.With("ip_version", "6") + v6conf.CommandConstructor = s.conf.CommandConstructor v6conf.InterfaceName = s.conf.InterfaceName v6conf.notify = s.onNotify - v6conf.Enabled = s.conf.Enabled && len(v6conf.RangeStart) != 0 + v6conf.PrefixSource = v6conf.NormalizedPrefixSource() + v6conf.Enabled = s.conf.Enabled && v6conf.IsConfiguredForEnable() s.srv6, err = v6Create(v6conf) if err != nil { diff --git a/internal/dhcpd/http_unix.go b/internal/dhcpd/http_unix.go index 165a67c60d4..cf246945a5a 100644 --- a/internal/dhcpd/http_unix.go +++ b/internal/dhcpd/http_unix.go @@ -49,18 +49,37 @@ func (j *v4ServerConfJSON) toServerConf() *V4ServerConf { } type v6ServerConfJSON struct { - RangeStart netip.Addr `json:"range_start"` - LeaseDuration uint32 `json:"lease_duration"` + RangeStart *netip.Addr `json:"range_start"` + LeaseDuration *uint32 `json:"lease_duration"` + PrefixSource *V6PrefixSource `json:"prefix_source"` } -func v6JSONToServerConf(j *v6ServerConfJSON) V6ServerConf { +func v6JSONToServerConf(j *v6ServerConfJSON, cur V6ServerConf) V6ServerConf { if j == nil { - return V6ServerConf{} + cur.PrefixSource = cur.NormalizedPrefixSource() + + return cur + } + + prefixSource := cur.NormalizedPrefixSource() + if j.PrefixSource != nil { + prefixSource = *j.PrefixSource + } + + rangeStart := cur.RangeStart + if j.RangeStart != nil { + rangeStart = j.RangeStart.AsSlice() + } + + leaseDuration := cur.LeaseDuration + if j.LeaseDuration != nil { + leaseDuration = *j.LeaseDuration } return V6ServerConf{ - RangeStart: j.RangeStart.AsSlice(), - LeaseDuration: j.LeaseDuration, + RangeStart: rangeStart, + LeaseDuration: leaseDuration, + PrefixSource: prefixSource, } } @@ -270,21 +289,24 @@ func (s *server) handleDHCPSetConfigV6( return nil, false, nil } - v6Conf := v6JSONToServerConf(conf.V6) - v6Conf.Enabled = conf.Enabled == aghalg.NBTrue - if len(v6Conf.RangeStart) == 0 { - v6Conf.Enabled = false + currentConf := s.conf.Conf6 + if s.srv6 != nil { + s.srv6.WriteDiskConfig6(¤tConf) } + v6Conf := v6JSONToServerConf(conf.V6, currentConf) + // Don't overwrite the RA/SLAAC settings from the config file. // // TODO(a.garipov): Perhaps include them into the request to allow // changing them from the HTTP API? - v6Conf.RASLAACOnly = s.conf.Conf6.RASLAACOnly - v6Conf.RAAllowSLAAC = s.conf.Conf6.RAAllowSLAAC + v6Conf.RASLAACOnly = currentConf.RASLAACOnly + v6Conf.RAAllowSLAAC = currentConf.RAAllowSLAAC + v6Conf.Enabled = conf.Enabled == aghalg.NBTrue && v6Conf.IsConfiguredForEnable() enabled = v6Conf.Enabled v6Conf.InterfaceName = conf.InterfaceName + v6Conf.CommandConstructor = s.conf.CommandConstructor v6Conf.Logger = s.conf.Logger.With("ip_version", "6") v6Conf.notify = s.onNotify @@ -763,9 +785,11 @@ func (s *server) handleReset(w http.ResponseWriter, r *http.Request) { s.srv4, _ = v4Create(v4conf) v6conf := V6ServerConf{ - Logger: s.conf.Logger.With("ip_version", "6"), - LeaseDuration: DefaultDHCPLeaseTTL, - notify: s.onNotify, + Logger: s.conf.Logger.With("ip_version", "6"), + CommandConstructor: s.conf.CommandConstructor, + LeaseDuration: DefaultDHCPLeaseTTL, + PrefixSource: V6PrefixSourceStatic, + notify: s.onNotify, } s.srv6, _ = v6Create(v6conf) diff --git a/internal/dhcpd/http_unix_internal_test.go b/internal/dhcpd/http_unix_internal_test.go index 059e40ac994..d4c39bd2a41 100644 --- a/internal/dhcpd/http_unix_internal_test.go +++ b/internal/dhcpd/http_unix_internal_test.go @@ -5,12 +5,15 @@ package dhcpd import ( "bytes" "encoding/json" + "net" "net/http" "net/http/httptest" "net/netip" "testing" "github.com/AdguardTeam/AdGuardHome/internal/agh" + "github.com/AdguardTeam/AdGuardHome/internal/aghalg" + "github.com/AdguardTeam/golibs/osutil/executil" "github.com/AdguardTeam/golibs/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,7 +27,7 @@ func defaultResponse() *dhcpStatusResponse { resp := &dhcpStatusResponse{ V4: *conf4, - V6: V6ServerConf{}, + V6: V6ServerConf{PrefixSource: V6PrefixSourceStatic}, Leases: []*leaseDynamic{}, StaticLeases: []*leaseStatic{}, Enabled: true, @@ -33,6 +36,103 @@ func defaultResponse() *dhcpStatusResponse { return resp } +func TestV6JSONToServerConf_PrefixSource(t *testing.T) { + current := V6ServerConf{PrefixSource: V6PrefixSourceInterface} + rangeStart := netip.MustParseAddr("2001:db8::42") + leaseDuration := uint32(1800) + got := v6JSONToServerConf(&v6ServerConfJSON{ + RangeStart: &rangeStart, + LeaseDuration: &leaseDuration, + }, current) + + assert.Equal(t, V6PrefixSourceInterface, got.PrefixSource) + assert.Equal(t, net.ParseIP("2001:db8::42"), got.RangeStart) +} + +func TestServer_HandleDHCPSetConfigV6_InterfaceRASLAACOnly(t *testing.T) { + s := &server{ + conf: &ServerConfig{ + Logger: testLogger, + CommandConstructor: executil.EmptyCommandConstructor{}, + Conf6: V6ServerConf{ + PrefixSource: V6PrefixSourceInterface, + RASLAACOnly: true, + }, + }, + } + + srv6, enabled, err := s.handleDHCPSetConfigV6(&dhcpServerConfigJSON{ + V6: &v6ServerConfJSON{}, + InterfaceName: "en0", + Enabled: aghalg.NBTrue, + }) + require.NoError(t, err) + assert.True(t, enabled) + + srv, ok := srv6.(*v6Server) + require.True(t, ok) + assert.Equal(t, V6PrefixSourceInterface, srv.conf.PrefixSource) + assert.True(t, srv.conf.Enabled) + assert.Nil(t, srv.conf.RangeStart) +} + +func TestServer_HandleDHCPSetConfigV6_PreservesLivePrefixSource(t *testing.T) { + currentSrv, err := v6Create(V6ServerConf{ + Enabled: true, + PrefixSource: V6PrefixSourceInterface, + RangeStart: net.ParseIP("2001:db8::10"), + notify: notify6, + }) + require.NoError(t, err) + + s := &server{ + srv6: currentSrv, + conf: &ServerConfig{ + Logger: testLogger, + CommandConstructor: executil.EmptyCommandConstructor{}, + Conf6: V6ServerConf{ + PrefixSource: V6PrefixSourceStatic, + RangeStart: net.ParseIP("2001:db8::10"), + }, + }, + } + + rangeStart := netip.MustParseAddr("2001:db8::10") + leaseDuration := uint32(1800) + srv6, enabled, err := s.handleDHCPSetConfigV6(&dhcpServerConfigJSON{ + V6: &v6ServerConfJSON{ + RangeStart: &rangeStart, + LeaseDuration: &leaseDuration, + }, + InterfaceName: "en0", + Enabled: aghalg.NBTrue, + }) + require.NoError(t, err) + assert.True(t, enabled) + + srv, ok := srv6.(*v6Server) + require.True(t, ok) + assert.Equal(t, V6PrefixSourceInterface, srv.conf.PrefixSource) + assert.False(t, srv.conf.skipDeprecatedLeaseRestore) +} + +func TestV6JSONToServerConf_PreservesOmittedFields(t *testing.T) { + current := V6ServerConf{ + RangeStart: net.ParseIP("2001:db8::10"), + LeaseDuration: 7200, + PrefixSource: V6PrefixSourceStatic, + } + prefixSource := V6PrefixSourceInterface + + got := v6JSONToServerConf(&v6ServerConfJSON{ + PrefixSource: &prefixSource, + }, current) + + assert.Equal(t, net.ParseIP("2001:db8::10"), got.RangeStart) + assert.Equal(t, uint32(7200), got.LeaseDuration) + assert.Equal(t, V6PrefixSourceInterface, got.PrefixSource) +} + // handleLease is the helper function that calls handler with provided static // lease as body and returns modified response recorder. func handleLease(tb testing.TB, lease *leaseStatic, handler http.HandlerFunc) (w *httptest.ResponseRecorder) { diff --git a/internal/dhcpd/migrate.go b/internal/dhcpd/migrate.go index 436ba2dcdee..196a77cbb22 100644 --- a/internal/dhcpd/migrate.go +++ b/internal/dhcpd/migrate.go @@ -95,7 +95,7 @@ func migrateDB(conf *ServerConfig) (err error) { }) } - err = writeDB(dataDirPath, leases) + err = writeDB(dataDirPath, leases, nil) if err != nil { // Don't wrap the error since an annotation deferred already. return err diff --git a/internal/dhcpd/routeradv.go b/internal/dhcpd/routeradv.go index 5aad490111b..baa203b3c57 100644 --- a/internal/dhcpd/routeradv.go +++ b/internal/dhcpd/routeradv.go @@ -1,11 +1,13 @@ package dhcpd import ( + "context" "encoding/binary" "fmt" "net" + "net/netip" "slices" - "sync/atomic" + "sync" "time" "github.com/AdguardTeam/golibs/errors" @@ -15,6 +17,18 @@ import ( "golang.org/x/net/ipv6" ) +const ( + defaultRARouterLifetimeSeconds = uint16(1800) + defaultRAObservePeriod = 5 * time.Second +) + +// raObserver refreshes IPv6 Router Advertisement state from interface data. +type raObserver func(ctx context.Context) (obs raObservation, err error) + +// raActivePrefixHandler is called when the current active prefix or the set of +// advertised prefixes changes. +type raActivePrefixHandler func(active *raPrefixSnapshot, advertised []prefixPIO) + // raCtx is a context for the Router Advertisement logic. type raCtx struct { // raAllowSLAAC is used to determine if the ICMP Router Advertisement @@ -31,17 +45,6 @@ type raCtx struct { // messages aren't sent. raSLAACOnly bool - // ipAddr is an IP address used within the Source Link-Layer Address option. - // See RFC 4861, section 4.6.1. - ipAddr net.IP - - // dnsIPAddr is an IP address used within the DNS Server option. - dnsIPAddr net.IP - - // prefixIPAddr is an IP address used within the Prefix Information option. - // See RFC 4861, section 4.6.2. - prefixIPAddr net.IP - // ifaceName is the name of the interface used as a scope of the IP // addresses. ifaceName string @@ -52,111 +55,84 @@ type raCtx struct { // packetSendPeriod is the interval between sending the ICMPv6 packets. packetSendPeriod time.Duration + // observePeriod is the interval between refreshing interface-derived RA + // state. + observePeriod time.Duration + // conn is the ICMPv6 socket. conn *icmp.PacketConn + // connSourceAddr is the source address currently bound to conn. + connSourceAddr netip.Addr + + // observe refreshes interface-derived RA state. It is nil for static mode. + observe raObserver - // stop is used to stop the packet sending loop. - stop atomic.Value + // onActivePrefixChange applies runtime changes on active-prefix updates. + onActivePrefixChange raActivePrefixHandler + + // onStateRefresh applies extra state reconciliation after a successful + // observation merge and before change detection. + onStateRefresh func(now time.Time, st *raState) + + state raState + lastActiveSnapshot *raPrefixSnapshot + lastAdvertised []prefixPIO + + cancel func() + wg sync.WaitGroup } +// icmpv6RA describes the contents of one Router Advertisement packet. type icmpv6RA struct { managedAddressConfiguration bool otherConfiguration bool - prefix net.IP - prefixLen int + prefixes []prefixPIO sourceLinkLayerAddress net.HardwareAddr - recursiveDNSServer net.IP + recursiveDNSServer netip.Addr mtu uint32 } // hwAddrToLinkLayerAddr clones the hardware address and returns it as a byte // slice suitable for the Source Link-Layer Address option in the ICMPv6 // Router Advertisement packet. -// -// TODO(e.burkov): Check if it's safe to use the original slice. func hwAddrToLinkLayerAddr(hwa net.HardwareAddr) (lla []byte, err error) { err = netutil.ValidateMAC(hwa) if err != nil { - // Don't wrap the error, because it already contains enough - // context. return nil, err } return slices.Clone(hwa), nil } -// Create an ICMPv6.RouterAdvertisement packet with all necessary options. -// Data scheme: -// -// ICMPv6: -// - type[1] -// - code[1] -// - chksum[2] -// - body (RouterAdvertisement): -// - Cur Hop Limit[1] -// - Flags[1]: MO...... -// - Router Lifetime[2] -// - Reachable Time[4] -// - Retrans Timer[4] -// - Option=Prefix Information(3): -// - Type[1] -// - Length * 8bytes[1] -// - Prefix Length[1] -// - Flags[1]: LA...... -// - Valid Lifetime[4] -// - Preferred Lifetime[4] -// - Reserved[4] -// - Prefix[16] -// - Option=MTU(5): -// - Type[1] -// - Length * 8bytes[1] -// - Reserved[2] -// - MTU[4] -// - Option=Source link-layer address(1): -// - Link-Layer Address[8/24] -// - Option=Recursive DNS Server(25): -// - Type[1] -// - Length * 8bytes[1] -// - Reserved[2] -// - Lifetime[4] -// - Addresses of IPv6 Recursive DNS Servers[16] -// -// TODO(a.garipov): Replace with an existing implementation from a dependency. +// createICMPv6RAPacket creates an ICMPv6 Router Advertisement packet with the +// supplied prefix and DNS options. func createICMPv6RAPacket(params icmpv6RA) (data []byte, err error) { lla, err := hwAddrToLinkLayerAddr(params.sourceLinkLayerAddress) if err != nil { return nil, fmt.Errorf("converting source link-layer address: %w", err) } - // Calculate length of the source link-layer address option. As per RFC - // 4861, section 4.6.1, the length should be in units of 8 octets, including - // the type and length fields. - // - // See https://datatracker.ietf.org/doc/html/rfc4861#section-4.6.1. srcLLAOptLen := len(lla) + 2 - // Make sure the value is rounded up to the nearest multiple of 8. srcLLAOptLenValue := (srcLLAOptLen + 7) / 8 srcLLAPadLen := srcLLAOptLenValue*8 - srcLLAOptLen - // TODO(a.garipov): Don't use a magic constant here. Refactor the code - // and make all constants named instead of all those comments. - data = make([]byte, 80+srcLLAOptLen+srcLLAPadLen) - i := 0 + dataLen := 16 + len(params.prefixes)*32 + 8 + srcLLAOptLen + srcLLAPadLen + if params.recursiveDNSServer.IsValid() { + dataLen += 24 + } - // ICMPv6: + data = make([]byte, dataLen) + i := 0 - data[i] = 134 // type - data[i+1] = 0 // code - data[i+2] = 0 // chksum - data[i+3] = 0 + // ICMPv6 header. + data[i] = 134 + data[i+1] = 0 i += 4 - // RouterAdvertisement: - - data[i] = 64 // Cur Hop Limit[1] + // Router Advertisement header. + data[i] = 64 i++ - data[i] = 0 // Flags[1]: MO...... if params.managedAddressConfiguration { data[i] |= 0x80 } @@ -165,93 +141,122 @@ func createICMPv6RAPacket(params icmpv6RA) (data []byte, err error) { } i++ - binary.BigEndian.PutUint16(data[i:], 1800) // Router Lifetime[2] + binary.BigEndian.PutUint16(data[i:], defaultRARouterLifetimeSeconds) i += 2 - binary.BigEndian.PutUint32(data[i:], 0) // Reachable Time[4] - i += 4 - binary.BigEndian.PutUint32(data[i:], 0) // Retrans Timer[4] - i += 4 - - // Option=Prefix Information: + i += 8 + + for _, prefix := range params.prefixes { + data[i] = 3 + data[i+1] = 4 + i += 2 + + data[i] = byte(prefix.Prefix.Bits()) + i++ + data[i] = 0xc0 + i++ + + binary.BigEndian.PutUint32(data[i:], prefix.ValidSec) + i += 4 + binary.BigEndian.PutUint32(data[i:], prefix.PreferredSec) + i += 4 + i += 4 + + addr := prefix.Prefix.Masked().Addr().As16() + copy(data[i:], addr[:]) + i += len(addr) + } - data[i] = 3 // Type - data[i+1] = 4 // Length - i += 2 - data[i] = byte(params.prefixLen) // Prefix Length[1] - i++ - data[i] = 0xc0 // Flags[1] - i++ - binary.BigEndian.PutUint32(data[i:], 3600) // Valid Lifetime[4] - i += 4 - binary.BigEndian.PutUint32(data[i:], 3600) // Preferred Lifetime[4] + // MTU option. + data[i] = 5 + data[i+1] = 1 i += 4 - binary.BigEndian.PutUint32(data[i:], 0) // Reserved[4] + binary.BigEndian.PutUint32(data[i:], params.mtu) i += 4 - copy(data[i:], params.prefix[:8]) // Prefix[16] - binary.BigEndian.PutUint32(data[i+8:], 0) - binary.BigEndian.PutUint32(data[i+12:], 0) - i += 16 - // Option=MTU: - - data[i] = 5 // Type - data[i+1] = 1 // Length - i += 2 - binary.BigEndian.PutUint16(data[i:], 0) // Reserved[2] + // Source Link-Layer Address option. + data[i] = 1 + data[i+1] = byte(srcLLAOptLenValue) i += 2 - binary.BigEndian.PutUint32(data[i:], params.mtu) // MTU[4] - i += 4 - - // Option=Source link-layer address: - - data[i] = 1 // Type - data[i+1] = byte(srcLLAOptLenValue) // Length - i += 2 - copy(data[i:], lla) // Link-Layer Address[8/24] + copy(data[i:], lla) i += len(lla) + srcLLAPadLen - // Option=Recursive DNS Server: + if params.recursiveDNSServer.IsValid() { + data[i] = 25 + data[i+1] = 3 + i += 4 + binary.BigEndian.PutUint32(data[i:], defaultRARDNSSLifetimeSeconds) + i += 4 - data[i] = 25 // Type - data[i+1] = 3 // Length - i += 2 - binary.BigEndian.PutUint16(data[i:], 0) // Reserved[2] - i += 2 - binary.BigEndian.PutUint32(data[i:], 3600) // Lifetime[4] - i += 4 - copy(data[i:], params.recursiveDNSServer) // Addresses of IPv6 Recursive DNS Servers[16] + addr := params.recursiveDNSServer.As16() + copy(data[i:], addr[:]) + } return data, nil } -// Init initializes RA module. -func (ra *raCtx) Init() (err error) { - ra.stop.Store(0) +// sendingEnabled reports whether RA packets should be transmitted. +func (ra *raCtx) sendingEnabled() (ok bool) { + return ra.raAllowSLAAC || ra.raSLAACOnly +} + +// Init initializes the Router Advertisement state loop. +func (ra *raCtx) Init(initial raState) (err error) { + ra.state = initial ra.conn = nil - if !ra.raAllowSLAAC && !ra.raSLAACOnly { + now := time.Now() + ra.lastActiveSnapshot = clonePrefixSnapshot(initial.activeSnapshot(now)) + ra.lastAdvertised = clonePIOs(initial.pios(now)) + + if !ra.sendingEnabled() && ra.observe == nil { return nil } - log.Debug("dhcpv6 ra: source IP address: %s DNS IP address: %s", ra.ipAddr, ra.dnsIPAddr) + if ra.sendingEnabled() { + sourceAddr, _ := ra.state.sourceAndRDNSS() + err = ra.ensureConn(sourceAddr) + if err != nil { + return err + } else if ra.conn == nil && ra.observe == nil { + return fmt.Errorf("dhcpv6 ra: no source address") + } + } - params := icmpv6RA{ - managedAddressConfiguration: !ra.raSLAACOnly, - otherConfiguration: !ra.raSLAACOnly, - mtu: uint32(ra.iface.MTU), - prefixLen: 64, - recursiveDNSServer: ra.dnsIPAddr, - sourceLinkLayerAddress: ra.iface.HardwareAddr, + ctx, cancel := context.WithCancel(context.Background()) + ra.cancel = cancel + ra.wg.Add(1) + go ra.loop(ctx) + + return nil +} + +// ensureConn initializes or refreshes the ICMPv6 socket used to send Router +// Advertisements. +func (ra *raCtx) ensureConn(sourceAddr netip.Addr) (err error) { + if !sourceAddr.IsValid() { + if ra.conn != nil { + err = ra.conn.Close() + } + + ra.conn = nil + ra.connSourceAddr = netip.Addr{} + + return err } - params.prefix = make([]byte, 16) - copy(params.prefix, ra.prefixIPAddr[:8]) // /64 - var data []byte - data, err = createICMPv6RAPacket(params) - if err != nil { - return fmt.Errorf("creating packet: %w", err) + if ra.conn != nil && ra.connSourceAddr == sourceAddr { + return nil } - ipAndScope := ra.ipAddr.String() + "%" + ra.ifaceName + if ra.conn != nil { + err = ra.conn.Close() + ra.conn = nil + ra.connSourceAddr = netip.Addr{} + if err != nil { + return fmt.Errorf("closing previous icmp listener: %w", err) + } + } + + ipAndScope := sourceAddr.String() + "%" + ra.ifaceName ra.conn, err = icmp.ListenPacket("ip6:ipv6-icmp", ipAndScope) if err != nil { return fmt.Errorf("dhcpv6 ra: icmp.ListenPacket: %w", err) @@ -259,12 +264,11 @@ func (ra *raCtx) Init() (err error) { defer func() { if err != nil { - err = errors.WithDeferred(err, ra.Close()) + err = errors.WithDeferred(err, ra.conn.Close()) } }() con6 := ra.conn.IPv6PacketConn() - if err = con6.SetHopLimit(255); err != nil { return fmt.Errorf("dhcpv6 ra: SetHopLimit: %w", err) } @@ -273,39 +277,202 @@ func (ra *raCtx) Init() (err error) { return fmt.Errorf("dhcpv6 ra: SetMulticastHopLimit: %w", err) } - msg := &ipv6.ControlMessage{ - HopLimit: 255, - Src: ra.ipAddr, - IfIndex: ra.iface.Index, + ra.connSourceAddr = sourceAddr + + return nil +} + +// loop runs the Router Advertisement send and observation ticks. +func (ra *raCtx) loop(ctx context.Context) { + defer ra.wg.Done() + + var sendTicker *time.Ticker + if ra.sendingEnabled() { + sendTicker = time.NewTicker(ra.packetSendPeriod) + defer sendTicker.Stop() } - addr := &net.UDPAddr{ - IP: net.ParseIP("ff02::1"), + + var observeTicker *time.Ticker + if ra.observe != nil { + period := ra.observePeriod + if period <= 0 { + period = defaultRAObservePeriod + } + + observeTicker = time.NewTicker(period) + defer observeTicker.Stop() } - go func() { - log.Debug("dhcpv6 ra: starting to send periodic RouterAdvertisement packets") - for ra.stop.Load() == 0 { - _, err = con6.WriteTo(data, msg, addr) - if err != nil { - log.Error("dhcpv6 ra: WriteTo: %s", err) - } - time.Sleep(ra.packetSendPeriod) + log.Debug("dhcpv6 ra: starting router advertisement loop") + for { + select { + case <-ctx.Done(): + log.Debug("dhcpv6 ra: loop exit") + + return + case <-tickerC(sendTicker): + ra.sendPacket() + case <-tickerC(observeTicker): + ra.refresh(ctx) } - log.Debug("dhcpv6 ra: loop exit") - }() + } +} - return nil +// refresh updates the interface-derived RA state. +func (ra *raCtx) refresh(ctx context.Context) { + if ra.observe == nil { + return + } + + obs, err := ra.observe(ctx) + if err != nil { + log.Error("dhcpv6 ra: refreshing prefix state: %s", err) + + return + } + + now := time.Now() + _ = ra.state.merge(obs, now) + if ra.onStateRefresh != nil { + ra.onStateRefresh(now, &ra.state) + } + ra.syncStateChange(now, true) } -// Close closes the module. +// sendPacket rebuilds and sends the current Router Advertisement packet. +func (ra *raCtx) sendPacket() { + now := time.Now() + ra.syncStateChange(now, false) + + sourceAddr, rdnssAddr := ra.state.sourceAndRDNSS() + err := ra.ensureConn(sourceAddr) + if err != nil { + log.Error("dhcpv6 ra: opening listener: %s", err) + + return + } else if ra.conn == nil { + return + } + + if !sourceAddr.IsValid() { + return + } + + pios := ra.state.pios(now) + if len(pios) == 0 { + return + } + + pkt, err := createICMPv6RAPacket(icmpv6RA{ + managedAddressConfiguration: !ra.raSLAACOnly, + otherConfiguration: !ra.raSLAACOnly, + prefixes: pios, + recursiveDNSServer: rdnssAddr, + sourceLinkLayerAddress: ra.iface.HardwareAddr, + mtu: uint32(ra.iface.MTU), + }) + if err != nil { + log.Error("dhcpv6 ra: creating packet: %s", err) + + return + } + + msg := &ipv6.ControlMessage{ + HopLimit: 255, + Src: net.IP(sourceAddr.AsSlice()), + IfIndex: ra.iface.Index, + } + addr := &net.UDPAddr{IP: net.ParseIP("ff02::1")} + _, err = ra.conn.IPv6PacketConn().WriteTo(pkt, msg, addr) + if err != nil { + log.Error("dhcpv6 ra: WriteTo: %s", err) + } +} + +// Close closes the Router Advertisement module. func (ra *raCtx) Close() (err error) { log.Debug("dhcpv6 ra: closing") - ra.stop.Store(1) + if ra.cancel != nil { + ra.cancel() + ra.wg.Wait() + ra.cancel = nil + } if ra.conn != nil { - return ra.conn.Close() + err = ra.conn.Close() + ra.conn = nil + ra.connSourceAddr = netip.Addr{} } - return nil + return err +} + +// tickerC safely returns the channel for t. +func tickerC(t *time.Ticker) (c <-chan time.Time) { + if t == nil { + return nil + } + + return t.C +} + +// syncStateChange updates the DHCPv6-facing state if the active prefix or the +// advertised prefix set changed. When compareLifetimes is true, preferred and +// valid lifetime changes are treated as state changes as well. +func (ra *raCtx) syncStateChange(now time.Time, compareLifetimes bool) { + active := ra.state.activeSnapshot(now) + advertised := ra.state.pios(now) + changed := !sameActivePrefix(ra.lastActiveSnapshot, active) || + !sameAdvertisedPIOSet(ra.lastAdvertised, advertised, compareLifetimes) + + ra.lastActiveSnapshot = clonePrefixSnapshot(active) + ra.lastAdvertised = clonePIOs(advertised) + + if !changed || ra.onActivePrefixChange == nil { + return + } + + ra.onActivePrefixChange(active, advertised) +} + +// sameAdvertisedPIOSet reports whether a and b advertise the same set of +// prefixes. When compareLifetimes is true, preferred and valid lifetime +// changes are also treated as differences. +func sameAdvertisedPIOSet(a, b []prefixPIO, compareLifetimes bool) (ok bool) { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i].Prefix != b[i].Prefix { + return false + } + if compareLifetimes && + (a[i].PreferredSec != b[i].PreferredSec || a[i].ValidSec != b[i].ValidSec) { + return false + } + } + + return true +} + +// clonePrefixSnapshot returns a shallow clone of snap. +func clonePrefixSnapshot(snap *raPrefixSnapshot) (cloned *raPrefixSnapshot) { + if snap == nil { + return nil + } + + clone := *snap + + return &clone +} + +// clonePIOs returns a shallow clone of pios. +func clonePIOs(pios []prefixPIO) (cloned []prefixPIO) { + if pios == nil { + return nil + } + + return slices.Clone(pios) } diff --git a/internal/dhcpd/routeradv_internal_test.go b/internal/dhcpd/routeradv_internal_test.go index c876a67ac20..7cfbdc82196 100644 --- a/internal/dhcpd/routeradv_internal_test.go +++ b/internal/dhcpd/routeradv_internal_test.go @@ -1,8 +1,11 @@ package dhcpd import ( - "net" + "context" + "encoding/binary" + "net/netip" "testing" + "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" @@ -15,10 +18,17 @@ func TestCreateICMPv6RAPacket(t *testing.T) { managedAddressConfiguration: false, otherConfiguration: true, mtu: 1500, - prefix: net.ParseIP("1234::"), - prefixLen: 64, - recursiveDNSServer: net.ParseIP("fe80::800:27ff:fe00:0"), - sourceLinkLayerAddress: []byte{0x0A, 0x00, 0x27, 0x00, 0x00, 0x00}, + prefixes: []prefixPIO{{ + Prefix: netip.MustParsePrefix("1234::/64"), + PreferredSec: 1200, + ValidSec: 3600, + }, { + Prefix: netip.MustParsePrefix("2001:db8:abcd::/60"), + PreferredSec: 0, + ValidSec: 1800, + }}, + recursiveDNSServer: netip.MustParseAddr("fe80::800:27ff:fe00:0"), + sourceLinkLayerAddress: []byte{0x0A, 0x00, 0x27, 0x00, 0x00, 0x00}, } pkt, err := createICMPv6RAPacket(raConf) @@ -29,6 +39,7 @@ func TestCreateICMPv6RAPacket(t *testing.T) { require.NoError(t, err) require.Equal(t, layers.LayerTypeICMPv6RouterAdvertisement, icmpPkt.NextLayerType()) + raPkt := &layers.ICMPv6RouterAdvertisement{} err = raPkt.DecodeFromBytes(icmpPkt.LayerPayload(), gopacket.NilDecodeFeedback) require.NoError(t, err) @@ -36,28 +47,185 @@ func TestCreateICMPv6RAPacket(t *testing.T) { assert.Equal(t, raConf.managedAddressConfiguration, raPkt.ManagedAddressConfig()) assert.Equal(t, raConf.otherConfiguration, raPkt.OtherConfig()) - wantOpts := layers.ICMPv6Options{{ - Type: layers.ICMPv6OptPrefixInfo, - Data: []uint8{ - 0x40, 0xC0, 0x00, 0x00, 0x0E, 0x10, 0x00, 0x00, - 0x0E, 0x10, 0x00, 0x00, 0x00, 0x00, 0x12, 0x34, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + require.Len(t, raPkt.Options, 5) + + opt := raPkt.Options[0] + require.Equal(t, layers.ICMPv6OptPrefixInfo, opt.Type) + assert.Equal(t, byte(64), opt.Data[0]) + assert.Equal(t, byte(0xc0), opt.Data[1]) + assert.Equal(t, uint32(3600), binary.BigEndian.Uint32(opt.Data[2:6])) + assert.Equal(t, uint32(1200), binary.BigEndian.Uint32(opt.Data[6:10])) + assert.Equal(t, netip.MustParsePrefix("1234::/64").Addr().As16(), [16]byte(opt.Data[14:30])) + + opt = raPkt.Options[1] + require.Equal(t, layers.ICMPv6OptPrefixInfo, opt.Type) + assert.Equal(t, byte(60), opt.Data[0]) + assert.Equal(t, byte(0xc0), opt.Data[1]) + assert.Equal(t, uint32(1800), binary.BigEndian.Uint32(opt.Data[2:6])) + assert.Equal(t, uint32(0), binary.BigEndian.Uint32(opt.Data[6:10])) + assert.Equal(t, netip.MustParsePrefix("2001:db8:abcd::/60").Masked().Addr().As16(), [16]byte(opt.Data[14:30])) + + opt = raPkt.Options[2] + require.Equal(t, layers.ICMPv6OptMTU, opt.Type) + assert.Equal(t, uint32(1500), binary.BigEndian.Uint32(opt.Data[2:6])) + + opt = raPkt.Options[3] + require.Equal(t, layers.ICMPv6OptSourceAddress, opt.Type) + assert.Equal(t, []byte{0x0A, 0x00, 0x27, 0x00, 0x00, 0x00}, opt.Data) + + opt = raPkt.Options[4] + require.Equal(t, layers.ICMPv6Opt(25), opt.Type) + assert.Equal(t, uint32(defaultRARDNSSLifetimeSeconds), binary.BigEndian.Uint32(opt.Data[2:6])) + assert.Equal(t, netip.MustParseAddr("fe80::800:27ff:fe00:0").As16(), [16]byte(opt.Data[6:22])) +} + +func TestRACtxSyncStateChange_DeprecatedExpiry(t *testing.T) { + now := time.Unix(100, 0) + ra := &raCtx{ + state: newObservedRAState(), + } + + ra.state.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 3600, }, - }, { - Type: layers.ICMPv6OptMTU, - Data: []uint8{0x00, 0x00, 0x00, 0x00, 0x05, 0xDC}, - }, { - Type: layers.ICMPv6OptSourceAddress, - Data: []uint8{0x0A, 0x00, 0x27, 0x00, 0x00, 0x0}, - }, { - // Package layers declares no constant for Recursive DNS Server option. - Type: layers.ICMPv6Opt(25), - Data: []uint8{ - 0x00, 0x00, 0x00, 0x00, 0x0E, 0x10, 0xFE, 0x80, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, - 0x27, 0xFF, 0xFE, 0x00, 0x00, 0x00, + Inactive: []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 1, + }}, + }, now) + + var notifications [][]prefixPIO + ra.onActivePrefixChange = func(_ *raPrefixSnapshot, advertised []prefixPIO) { + notifications = append(notifications, clonePIOs(advertised)) + } + ra.lastActiveSnapshot = clonePrefixSnapshot(ra.state.activeSnapshot(now)) + ra.lastAdvertised = clonePIOs(ra.state.pios(now)) + + ra.syncStateChange(now.Add(2*time.Second), false) + + require.Len(t, notifications, 1) + require.Len(t, notifications[0], 1) + assert.Equal(t, netip.MustParsePrefix("2001:db8:1::/64"), notifications[0][0].Prefix) +} + +func TestRACtxSyncStateChange_RefreshesCachedLifetimesWithoutCallback(t *testing.T) { + now := time.Unix(100, 0) + ra := &raCtx{ + state: newObservedRAState(), + } + + ra.state.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 3600, }, + Inactive: []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 10, + }}, + }, now) + ra.lastActiveSnapshot = clonePrefixSnapshot(ra.state.activeSnapshot(now)) + ra.lastAdvertised = clonePIOs(ra.state.pios(now)) + ra.onActivePrefixChange = nil + + ra.syncStateChange(now.Add(3*time.Second), false) + + require.Len(t, ra.lastAdvertised, 2) + assert.Equal(t, uint32(7), ra.lastAdvertised[1].ValidSec) +} + +func TestRACtxSyncStateChange_DeprecatingPrefixTriggersCallback(t *testing.T) { + now := time.Unix(120, 0) + ra := &raCtx{ + state: newObservedRAState(), + } + + ra.state.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }, + Inactive: []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 300, + ValidSec: 1200, + }}, + }, now) + ra.lastActiveSnapshot = clonePrefixSnapshot(ra.state.activeSnapshot(now)) + ra.lastAdvertised = clonePIOs(ra.state.pios(now)) + + var notifications [][]prefixPIO + ra.onActivePrefixChange = func(_ *raPrefixSnapshot, advertised []prefixPIO) { + notifications = append(notifications, clonePIOs(advertised)) + } + + ra.state.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1700, + ValidSec: 3500, + }, + Inactive: []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 1100, + }}, + }, now.Add(time.Minute)) + + ra.syncStateChange(now.Add(time.Minute), true) + + require.Len(t, notifications, 1) + require.Len(t, notifications[0], 2) + assert.Equal(t, uint32(0), notifications[0][1].PreferredSec) +} + +func TestSameAdvertisedPIOSet_PreferredLifetimeChange(t *testing.T) { + a := []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }} + b := []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 900, + ValidSec: 3600, }} - assert.Equal(t, wantOpts, raPkt.Options) + + assert.False(t, sameAdvertisedPIOSet(a, b, true)) + assert.True(t, sameAdvertisedPIOSet(a, b, false)) +} + +func TestSameAdvertisedPIOSet_ValidLifetimeChange(t *testing.T) { + a := []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 1200, + }} + b := []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 900, + }} + + assert.False(t, sameAdvertisedPIOSet(a, b, true)) + assert.True(t, sameAdvertisedPIOSet(a, b, false)) +} + +func TestRACtxInit_AllowsNoSourceWhenObserving(t *testing.T) { + ra := &raCtx{ + raAllowSLAAC: true, + packetSendPeriod: time.Second, + observe: func(context.Context) (raObservation, error) { return raObservation{}, nil }, + } + + err := ra.Init(newObservedRAState()) + require.NoError(t, err) + require.NoError(t, ra.Close()) } diff --git a/internal/dhcpd/routeradv_state.go b/internal/dhcpd/routeradv_state.go new file mode 100644 index 00000000000..4ada88cfb7f --- /dev/null +++ b/internal/dhcpd/routeradv_state.go @@ -0,0 +1,608 @@ +package dhcpd + +import ( + "cmp" + "math" + "net" + "net/netip" + "slices" + "time" + + "github.com/AdguardTeam/AdGuardHome/internal/aghnet" +) + +const ( + defaultRAPrefixLifetimeSeconds = uint32(3600) + defaultRARDNSSLifetimeSeconds = uint32(3600) + + raObservedPrefixBits = 64 + raDeprecatedLifetimeCap = 2 * time.Hour + raDeprecatedLifetimeCapSecs = uint32(raDeprecatedLifetimeCap / time.Second) +) + +// prefixPIO describes the Prefix Information Option to encode into an RA +// packet. +type prefixPIO struct { + Prefix netip.Prefix + PreferredSec uint32 + ValidSec uint32 +} + +// raPrefixSnapshot is the observed state of one advertised IPv6 prefix. +type raPrefixSnapshot struct { + Prefix netip.Prefix + PreferredSec uint32 + ValidSec uint32 +} + +// raObservation is the current Router Advertisement input derived from either +// a static configuration or interface state. +type raObservation struct { + SourceAddr netip.Addr + RDNSSAddr netip.Addr + Active *raPrefixSnapshot + Inactive []raPrefixSnapshot +} + +// raPrefixOrigin describes where a tracked prefix came from. +type raPrefixOrigin uint8 + +// raPrefixOrigin values. +const ( + raPrefixOriginStaticConfigured raPrefixOrigin = iota + raPrefixOriginObservedActive + raPrefixOriginObservedInactive + raPrefixOriginDeprecated +) + +// trackedPrefix keeps either a fixed-lifetime configured prefix or a prefix +// whose remaining lifetimes count down from observed values. +type trackedPrefix struct { + prefix netip.Prefix + origin raPrefixOrigin + + fixedPreferredSec uint32 + fixedValidSec uint32 + + preferredUntil time.Time + validUntil time.Time +} + +// raState is the current runtime Router Advertisement state. +type raState struct { + sourceAddr netip.Addr + rdnssAddr netip.Addr + active *trackedPrefix + deprecated map[netip.Prefix]*trackedPrefix +} + +// raActiveChange describes an active-prefix transition. +type raActiveChange struct { + Changed bool + Active *raPrefixSnapshot +} + +// newObservedRAState returns a new empty RA state for interface-derived +// observations. +func newObservedRAState() (st raState) { + return raState{ + deprecated: map[netip.Prefix]*trackedPrefix{}, + } +} + +// newStaticRAState returns a new state keeping the current fixed-lifetime +// semantics for the configured static prefix. +func newStaticRAState(obs raObservation) (st raState) { + st = newObservedRAState() + st.sourceAddr = obs.SourceAddr + st.rdnssAddr = obs.RDNSSAddr + if obs.Active != nil { + st.active = newTrackedPrefix(*obs.Active, raPrefixOriginStaticConfigured, time.Time{}) + } + + return st +} + +// merge merges a fresh interface observation into s and reports whether the +// active prefix changed. +func (s *raState) merge(obs raObservation, now time.Time) (change raActiveChange) { + if s.deprecated == nil { + s.deprecated = map[netip.Prefix]*trackedPrefix{} + } + + prev := s.activeSnapshot(now) + prevActivePrefix := netip.Prefix{} + activeChanged := false + if prev != nil { + prevActivePrefix = prev.Prefix + } + + s.sourceAddr = obs.SourceAddr + s.rdnssAddr = obs.RDNSSAddr + + switch { + case obs.Active != nil && s.active != nil && s.active.prefix == obs.Active.Prefix: + s.active = newTrackedPrefix(*obs.Active, raPrefixOriginObservedActive, now) + case obs.Active != nil: + s.moveActiveToDeprecated(now) + s.active = newTrackedPrefix(*obs.Active, raPrefixOriginObservedActive, now) + activeChanged = prev != nil && prev.Prefix != obs.Active.Prefix + case obs.Active == nil: + s.moveActiveToDeprecated(now) + s.active = nil + activeChanged = prev != nil + } + + if s.active != nil { + delete(s.deprecated, s.active.prefix) + } + + observedInactive := map[netip.Prefix]struct{}{} + + for _, dep := range obs.Inactive { + if s.active != nil && dep.Prefix == s.active.prefix { + continue + } + if activeChanged && dep.Prefix == prevActivePrefix && dep.PreferredSec == 0 { + valid := dep.ValidSec + if valid > raDeprecatedLifetimeCapSecs || valid == math.MaxUint32 { + valid = raDeprecatedLifetimeCapSecs + } + if valid == 0 { + delete(s.deprecated, dep.Prefix) + + continue + } + + s.deprecated[dep.Prefix] = newTrackedPrefix(raPrefixSnapshot{ + Prefix: dep.Prefix, + PreferredSec: 0, + ValidSec: valid, + }, raPrefixOriginDeprecated, now) + + continue + } + + if dep.PreferredSec > 0 { + observedInactive[dep.Prefix] = struct{}{} + s.deprecated[dep.Prefix] = newTrackedPrefix(dep, raPrefixOriginObservedInactive, now) + + continue + } + + valid := dep.ValidSec + if valid > raDeprecatedLifetimeCapSecs || valid == math.MaxUint32 { + valid = raDeprecatedLifetimeCapSecs + } + if valid == 0 { + delete(s.deprecated, dep.Prefix) + + continue + } + + s.deprecated[dep.Prefix] = newTrackedPrefix(raPrefixSnapshot{ + Prefix: dep.Prefix, + PreferredSec: 0, + ValidSec: valid, + }, raPrefixOriginDeprecated, now) + } + + for pref, tracked := range s.deprecated { + if tracked.origin != raPrefixOriginObservedInactive { + continue + } + + if _, ok := observedInactive[pref]; !ok { + s.deprecateTrackedPrefix(pref, tracked, now) + } + } + + s.evictExpired(now) + + next := s.activeSnapshot(now) + change.Changed = !sameActivePrefix(prev, next) + change.Active = next + + return change +} + +// deprecateTrackedPrefix converts a tracked prefix into a deprecated one using +// its remaining valid lifetime bounded by the standard two-hour cap. +func (s *raState) deprecateTrackedPrefix(pref netip.Prefix, tracked *trackedPrefix, now time.Time) { + _, valid, expired := tracked.remaining(now) + if expired || valid == 0 { + delete(s.deprecated, pref) + + return + } + if valid > raDeprecatedLifetimeCapSecs || valid == math.MaxUint32 { + valid = raDeprecatedLifetimeCapSecs + } + + s.deprecated[pref] = newTrackedPrefix(raPrefixSnapshot{ + Prefix: pref, + PreferredSec: 0, + ValidSec: valid, + }, raPrefixOriginDeprecated, now) +} + +// sourceAndRDNSS returns the addresses currently used for RA and RDNSS. +func (s *raState) sourceAndRDNSS() (source, rdnss netip.Addr) { + return s.sourceAddr, s.rdnssAddr +} + +// activeSnapshot returns the active prefix snapshot at now. +func (s *raState) activeSnapshot(now time.Time) (snap *raPrefixSnapshot) { + if s.active == nil { + return nil + } + + return s.active.snapshot(now) +} + +// pios returns the currently advertised prefix options in the correct order. +func (s *raState) pios(now time.Time) (pios []prefixPIO) { + s.evictExpired(now) + + if active := s.active.snapshot(now); active != nil { + pios = append(pios, prefixPIO{ + Prefix: active.Prefix, + PreferredSec: active.PreferredSec, + ValidSec: active.ValidSec, + }) + } + + deprecated := make([]prefixPIO, 0, len(s.deprecated)) + for _, p := range s.deprecated { + snap := p.snapshot(now) + if snap == nil { + continue + } + + deprecated = append(deprecated, prefixPIO{ + Prefix: snap.Prefix, + PreferredSec: snap.PreferredSec, + ValidSec: snap.ValidSec, + }) + } + + slices.SortFunc(deprecated, func(a, b prefixPIO) int { + return prefixCompare(a.Prefix, b.Prefix) + }) + + return append(pios, deprecated...) +} + +// buildInterfaceRAObservation selects the current RA source address and active +// and deprecated prefixes from raw interface address state. +func buildInterfaceRAObservation(states []aghnet.IPv6AddrState) (obs raObservation) { + obs.SourceAddr = pickRASourceAddr(states) + obs.RDNSSAddr = obs.SourceAddr + + grouped := map[netip.Prefix][]aghnet.IPv6AddrState{} + for _, st := range states { + if !isEligibleRAPrefixState(st) { + continue + } + + pref := st.Prefix.Masked() + grouped[pref] = append(grouped[pref], st) + } + + prefixes := make([]raPrefixSnapshot, 0, len(grouped)) + for pref, group := range grouped { + snap, ok := collapsePrefixGroup(pref, group) + if !ok { + continue + } + + prefixes = append(prefixes, snap) + } + + activeIdx := -1 + for i, pref := range prefixes { + if pref.PreferredSec == 0 { + continue + } + + if activeIdx == -1 || betterActivePrefix(pref, prefixes[activeIdx]) { + activeIdx = i + } + } + + for i, pref := range prefixes { + switch { + case i == activeIdx: + obs.Active = &raPrefixSnapshot{ + Prefix: pref.Prefix, + PreferredSec: pref.PreferredSec, + ValidSec: pref.ValidSec, + } + default: + obs.Inactive = append(obs.Inactive, pref) + } + } + + slices.SortFunc(obs.Inactive, func(a, b raPrefixSnapshot) int { + return prefixCompare(a.Prefix, b.Prefix) + }) + + return obs +} + +// buildStaticRAObservation returns the configured static prefix observation. +func buildStaticRAObservation(dnsIPAddrs []net.IP, rangeStart net.IP) (obs raObservation) { + addr := pickStaticRASourceAddr(dnsIPAddrs) + obs.SourceAddr = addr + obs.RDNSSAddr = addr + + prefixAddr, ok := netip.AddrFromSlice(rangeStart) + if !ok { + return obs + } + + obs.Active = &raPrefixSnapshot{ + Prefix: netip.PrefixFrom(prefixAddr, raObservedPrefixBits).Masked(), + PreferredSec: defaultRAPrefixLifetimeSeconds, + ValidSec: defaultRAPrefixLifetimeSeconds, + } + + return obs +} + +// pickStaticRASourceAddr selects the source/RDNSS address from the interface +// address list returned by IfaceDNSIPAddrs. +func pickStaticRASourceAddr(addrs []net.IP) (addr netip.Addr) { + var fallback netip.Addr + for _, ip := range addrs { + a, ok := netip.AddrFromSlice(ip) + if !ok || !a.Is6() { + continue + } + + a = a.WithZone("") + if a.IsLinkLocalUnicast() { + return a + } else if !fallback.IsValid() { + fallback = a + } + } + + return fallback +} + +// pickRASourceAddr chooses the preferred RA source address from interface +// observations. +func pickRASourceAddr(states []aghnet.IPv6AddrState) (addr netip.Addr) { + var linkLocal []netip.Addr + var fallback []netip.Addr + + for _, st := range states { + if !st.Addr.IsValid() || !st.Addr.Is6() || st.Tentative { + continue + } + + addr = st.Addr.WithZone("") + if addr.IsLinkLocalUnicast() { + linkLocal = append(linkLocal, addr) + } else if !st.Temporary { + fallback = append(fallback, addr) + } + } + + slices.SortFunc(linkLocal, func(a, b netip.Addr) int { return a.Compare(b) }) + slices.SortFunc(fallback, func(a, b netip.Addr) int { return a.Compare(b) }) + + switch { + case len(linkLocal) > 0: + return linkLocal[0] + case len(fallback) > 0: + return fallback[0] + default: + return netip.Addr{} + } +} + +// isEligibleRAPrefixState reports whether st may be used to derive a Prefix +// Information Option. +func isEligibleRAPrefixState(st aghnet.IPv6AddrState) (ok bool) { + switch { + case !st.Addr.IsValid(), + !st.Addr.Is6(), + !st.Addr.IsGlobalUnicast(), + st.Tentative, + st.ValidLifetimeSec == 0, + !st.Prefix.IsValid(), + st.Prefix.Bits() != raObservedPrefixBits: + return false + default: + return true + } +} + +// collapsePrefixGroup collapses multiple addresses belonging to the same +// prefix into one snapshot by taking the longest remaining preferred and valid +// lifetimes observed for that /64. +func collapsePrefixGroup( + prefix netip.Prefix, + group []aghnet.IPv6AddrState, +) (snap raPrefixSnapshot, ok bool) { + if len(group) == 0 { + return raPrefixSnapshot{}, false + } + + preferredSec := group[0].PreferredLifetimeSec + validSec := group[0].ValidLifetimeSec + for _, st := range group[1:] { + preferredSec = max(preferredSec, st.PreferredLifetimeSec) + validSec = max(validSec, st.ValidLifetimeSec) + } + + return raPrefixSnapshot{ + Prefix: prefix, + PreferredSec: preferredSec, + ValidSec: validSec, + }, true +} + +// betterActivePrefix reports whether a should be preferred over b as the +// active interface-derived prefix. +func betterActivePrefix(a, b raPrefixSnapshot) (ok bool) { + aFinite := a.PreferredSec != math.MaxUint32 + bFinite := b.PreferredSec != math.MaxUint32 + switch { + case aFinite != bFinite: + return aFinite + case a.PreferredSec != b.PreferredSec: + return a.PreferredSec > b.PreferredSec + case a.ValidSec != b.ValidSec: + return a.ValidSec > b.ValidSec + default: + return prefixCompare(a.Prefix, b.Prefix) < 0 + } +} + +// newTrackedPrefix returns a tracked prefix built from snap. +func newTrackedPrefix( + snap raPrefixSnapshot, + origin raPrefixOrigin, + now time.Time, +) (tracked *trackedPrefix) { + tracked = &trackedPrefix{ + prefix: snap.Prefix, + origin: origin, + } + + switch origin { + case raPrefixOriginStaticConfigured: + tracked.fixedPreferredSec = snap.PreferredSec + tracked.fixedValidSec = snap.ValidSec + default: + tracked.preferredUntil = deadlineFromRemaining(now, snap.PreferredSec) + tracked.validUntil = deadlineFromRemaining(now, snap.ValidSec) + } + + return tracked +} + +// snapshot returns the current remaining lifetimes for p. +func (p *trackedPrefix) snapshot(now time.Time) (snap *raPrefixSnapshot) { + if p == nil { + return nil + } + + preferred, valid, expired := p.remaining(now) + if expired { + return nil + } + + return &raPrefixSnapshot{ + Prefix: p.prefix, + PreferredSec: preferred, + ValidSec: valid, + } +} + +// remaining returns the current remaining lifetimes for p. +func (p *trackedPrefix) remaining(now time.Time) (preferred, valid uint32, expired bool) { + if p.origin == raPrefixOriginStaticConfigured { + if p.fixedValidSec == 0 { + return 0, 0, true + } + + return p.fixedPreferredSec, p.fixedValidSec, false + } + + preferred = remainingUntil(now, p.preferredUntil) + valid = remainingUntil(now, p.validUntil) + + return preferred, valid, valid == 0 +} + +// moveActiveToDeprecated converts the current active prefix into a deprecated +// one bounded by RFC 4862's two-hour valid lifetime rule. +func (s *raState) moveActiveToDeprecated(now time.Time) { + if s.active == nil { + return + } + + _, valid, expired := s.active.remaining(now) + if expired { + return + } + + if valid > raDeprecatedLifetimeCapSecs || valid == math.MaxUint32 { + valid = raDeprecatedLifetimeCapSecs + } + if valid == 0 { + return + } + + s.deprecated[s.active.prefix] = newTrackedPrefix(raPrefixSnapshot{ + Prefix: s.active.prefix, + PreferredSec: 0, + ValidSec: valid, + }, raPrefixOriginDeprecated, now) +} + +// evictExpired removes prefixes whose valid lifetimes have expired. +func (s *raState) evictExpired(now time.Time) { + if s.active != nil { + if _, _, expired := s.active.remaining(now); expired { + s.active = nil + } + } + + for pref, dep := range s.deprecated { + if _, _, expired := dep.remaining(now); expired { + delete(s.deprecated, pref) + } + } +} + +// deadlineFromRemaining returns the deadline corresponding to the remaining +// lifetime. +func deadlineFromRemaining(now time.Time, sec uint32) (deadline time.Time) { + switch sec { + case 0: + return now + case math.MaxUint32: + return time.Time{} + default: + return now.Add(time.Duration(sec) * time.Second) + } +} + +// remainingUntil returns the remaining lifetime until deadline. +func remainingUntil(now, deadline time.Time) (sec uint32) { + switch { + case deadline.IsZero(): + return math.MaxUint32 + case !deadline.After(now): + return 0 + default: + return uint32(deadline.Sub(now) / time.Second) + } +} + +// prefixCompare lexically compares IPv6 prefixes. +func prefixCompare(a, b netip.Prefix) (res int) { + if res = a.Addr().Compare(b.Addr()); res != 0 { + return res + } + + return cmp.Compare(a.Bits(), b.Bits()) +} + +// sameActivePrefix reports whether a and b describe the same currently active +// prefix. +func sameActivePrefix(a, b *raPrefixSnapshot) (ok bool) { + switch { + case a == nil && b == nil: + return true + case a == nil || b == nil: + return false + default: + return a.Prefix == b.Prefix + } +} diff --git a/internal/dhcpd/routeradv_state_internal_test.go b/internal/dhcpd/routeradv_state_internal_test.go new file mode 100644 index 00000000000..e2d9fe4b1a5 --- /dev/null +++ b/internal/dhcpd/routeradv_state_internal_test.go @@ -0,0 +1,334 @@ +package dhcpd + +import ( + "math" + "net/netip" + "testing" + "time" + + "github.com/AdguardTeam/AdGuardHome/internal/aghnet" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildInterfaceRAObservation(t *testing.T) { + obs := buildInterfaceRAObservation([]aghnet.IPv6AddrState{{ + Addr: netip.MustParseAddr("2001:db8::10"), + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredLifetimeSec: math.MaxUint32, + ValidLifetimeSec: math.MaxUint32, + }, { + Addr: netip.MustParseAddr("fe80::1"), + Prefix: netip.MustParsePrefix("fe80::/64"), + PreferredLifetimeSec: math.MaxUint32, + ValidLifetimeSec: math.MaxUint32, + }, { + Addr: netip.MustParseAddr("2001:db8:1::20"), + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredLifetimeSec: 0, + ValidLifetimeSec: 900, + }, { + Addr: netip.MustParseAddr("2001:db8:2::30"), + Prefix: netip.MustParsePrefix("2001:db8:2::/64"), + PreferredLifetimeSec: 1200, + ValidLifetimeSec: 3600, + Temporary: true, + }}) + + require.NotNil(t, obs.Active) + assert.Equal(t, netip.MustParseAddr("fe80::1"), obs.SourceAddr) + assert.Equal(t, netip.MustParseAddr("fe80::1"), obs.RDNSSAddr) + assert.Equal(t, netip.MustParsePrefix("2001:db8:2::/64"), obs.Active.Prefix) + assert.Equal(t, []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: math.MaxUint32, + ValidSec: math.MaxUint32, + }, { + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 0, + ValidSec: 900, + }}, obs.Inactive) +} + +func TestBuildInterfaceRAObservation_OverlapPrefersLongerPreferredLifetime(t *testing.T) { + obs := buildInterfaceRAObservation([]aghnet.IPv6AddrState{{ + Addr: netip.MustParseAddr("2001:db8::10"), + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredLifetimeSec: 300, + ValidLifetimeSec: 7200, + }, { + Addr: netip.MustParseAddr("2001:db8:1::10"), + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredLifetimeSec: 1800, + ValidLifetimeSec: 3600, + }}) + + require.NotNil(t, obs.Active) + assert.Equal(t, netip.MustParsePrefix("2001:db8:1::/64"), obs.Active.Prefix) + assert.Equal(t, []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 300, + ValidSec: 7200, + }}, obs.Inactive) +} + +func TestBuildInterfaceRAObservation_TemporaryOnlyPrefix(t *testing.T) { + obs := buildInterfaceRAObservation([]aghnet.IPv6AddrState{{ + Addr: netip.MustParseAddr("fe80::1"), + Prefix: netip.MustParsePrefix("fe80::/64"), + PreferredLifetimeSec: math.MaxUint32, + ValidLifetimeSec: math.MaxUint32, + }, { + Addr: netip.MustParseAddr("2001:db8::20"), + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredLifetimeSec: 1200, + ValidLifetimeSec: 3600, + Temporary: true, + }}) + + require.NotNil(t, obs.Active) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), obs.Active.Prefix) + assert.Empty(t, obs.Inactive) +} + +func TestBuildInterfaceRAObservation_PrefersLongestLifetimeAcrossAddressKinds(t *testing.T) { + obs := buildInterfaceRAObservation([]aghnet.IPv6AddrState{{ + Addr: netip.MustParseAddr("2001:db8::10"), + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredLifetimeSec: 0, + ValidLifetimeSec: 900, + }, { + Addr: netip.MustParseAddr("2001:db8::20"), + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredLifetimeSec: 1200, + ValidLifetimeSec: 3600, + Temporary: true, + }}) + + require.NotNil(t, obs.Active) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), obs.Active.Prefix) + assert.Equal(t, uint32(1200), obs.Active.PreferredSec) + assert.Equal(t, uint32(3600), obs.Active.ValidSec) +} + +func TestRAStateMerge_OverlappingOldActiveStaysPreferredIfObservedPreferred(t *testing.T) { + now := time.Unix(75, 0) + st := newObservedRAState() + + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 1800, + ValidSec: 7200, + }, + }, now) + + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 7200, + }, + Inactive: []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 900, + ValidSec: 3600, + }}, + }, now.Add(time.Minute)) + + pios := st.pios(now.Add(time.Minute)) + require.Len(t, pios, 2) + assert.Equal(t, netip.MustParsePrefix("2001:db8:1::/64"), pios[0].Prefix) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), pios[1].Prefix) + assert.Equal(t, uint32(900), pios[1].PreferredSec) + assert.Equal(t, uint32(3600), pios[1].ValidSec) +} + +func TestRAStateMerge_OverlappingOldActiveBecomesDeprecatedWhenObservedDeprecated(t *testing.T) { + now := time.Unix(75, 0) + st := newObservedRAState() + + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 1800, + ValidSec: 7200, + }, + }, now) + + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 7200, + }, + Inactive: []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 3600, + }}, + }, now.Add(time.Minute)) + + pios := st.pios(now.Add(time.Minute)) + require.Len(t, pios, 2) + assert.Equal(t, netip.MustParsePrefix("2001:db8:1::/64"), pios[0].Prefix) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), pios[1].Prefix) + assert.Equal(t, uint32(0), pios[1].PreferredSec) + assert.Equal(t, uint32(3600), pios[1].ValidSec) +} + +func TestRAStateMergeTracksPrefixTransitions(t *testing.T) { + now := time.Unix(100, 0) + st := newObservedRAState() + + change := st.merge(raObservation{ + SourceAddr: netip.MustParseAddr("fe80::1"), + RDNSSAddr: netip.MustParseAddr("fe80::1"), + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 1800, + ValidSec: 7200, + }, + }, now) + require.True(t, change.Changed) + require.NotNil(t, change.Active) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), change.Active.Prefix) + + change = st.merge(raObservation{ + SourceAddr: netip.MustParseAddr("fe80::1"), + RDNSSAddr: netip.MustParseAddr("fe80::1"), + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 1200, + ValidSec: 7000, + }, + }, now.Add(time.Minute)) + assert.False(t, change.Changed) + + change = st.merge(raObservation{ + SourceAddr: netip.MustParseAddr("fe80::1"), + RDNSSAddr: netip.MustParseAddr("fe80::1"), + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 900, + ValidSec: 3600, + }, + }, now.Add(2*time.Minute)) + require.True(t, change.Changed) + require.NotNil(t, change.Active) + assert.Equal(t, netip.MustParsePrefix("2001:db8:1::/64"), change.Active.Prefix) + + pios := st.pios(now.Add(2 * time.Minute)) + require.Len(t, pios, 2) + assert.Equal(t, netip.MustParsePrefix("2001:db8:1::/64"), pios[0].Prefix) + assert.Equal(t, uint32(900), pios[0].PreferredSec) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), pios[1].Prefix) + assert.Equal(t, uint32(0), pios[1].PreferredSec) + assert.Equal(t, uint32(6940), pios[1].ValidSec) + + change = st.merge(raObservation{ + SourceAddr: netip.MustParseAddr("fe80::1"), + RDNSSAddr: netip.MustParseAddr("fe80::1"), + }, now.Add(3*time.Minute)) + require.True(t, change.Changed) + assert.Nil(t, change.Active) + + pios = st.pios(now.Add(3 * time.Minute)) + require.Len(t, pios, 2) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), pios[0].Prefix) + assert.Equal(t, uint32(6880), pios[0].ValidSec) + assert.Equal(t, netip.MustParsePrefix("2001:db8:1::/64"), pios[1].Prefix) + assert.Equal(t, uint32(3540), pios[1].ValidSec) +} + +func TestRAStateMergeReappearingPrefix(t *testing.T) { + now := time.Unix(50, 0) + st := newObservedRAState() + + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 1800, + ValidSec: 7200, + }, + }, now) + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 900, + ValidSec: 3600, + }, + }, now.Add(time.Minute)) + + change := st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 800, + ValidSec: 3500, + }, + }, now.Add(2*time.Minute)) + require.True(t, change.Changed) + require.NotNil(t, change.Active) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), change.Active.Prefix) + + pios := st.pios(now.Add(2 * time.Minute)) + require.Len(t, pios, 2) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), pios[0].Prefix) + assert.Equal(t, netip.MustParsePrefix("2001:db8:1::/64"), pios[1].Prefix) +} + +func TestRAStateMerge_PreservesObservedInactivePrefix(t *testing.T) { + now := time.Unix(200, 0) + st := newObservedRAState() + + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 7200, + }, + Inactive: []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("fd00::/64"), + PreferredSec: 900, + ValidSec: 3600, + }}, + }, now) + + pios := st.pios(now) + require.Len(t, pios, 2) + assert.Equal(t, netip.MustParsePrefix("2001:db8:1::/64"), pios[0].Prefix) + assert.Equal(t, netip.MustParsePrefix("fd00::/64"), pios[1].Prefix) + assert.Equal(t, uint32(900), pios[1].PreferredSec) +} + +func TestRAStateMerge_DisappearingInactiveBecomesDeprecated(t *testing.T) { + now := time.Unix(300, 0) + st := newObservedRAState() + + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 7200, + }, + Inactive: []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("fd00::/64"), + PreferredSec: 900, + ValidSec: 3600, + }}, + }, now) + + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1700, + ValidSec: 7100, + }, + }, now.Add(time.Minute)) + + pios := st.pios(now.Add(time.Minute)) + require.Len(t, pios, 2) + assert.Equal(t, netip.MustParsePrefix("fd00::/64"), pios[1].Prefix) + assert.Equal(t, uint32(0), pios[1].PreferredSec) + assert.Equal(t, uint32(3540), pios[1].ValidSec) +} diff --git a/internal/dhcpd/v6_unix.go b/internal/dhcpd/v6_unix.go index c7e956f944a..8c47c5315e0 100644 --- a/internal/dhcpd/v6_unix.go +++ b/internal/dhcpd/v6_unix.go @@ -6,8 +6,10 @@ import ( "bytes" "context" "fmt" + "maps" "net" "net/netip" + "slices" "sync" "time" @@ -33,9 +35,17 @@ type v6Server struct { sid dhcpv6.DUID srv *server6.Server - leases []*dhcpsvc.Lease - leasesLock sync.Mutex - ipAddrs [256]byte + leases []*dhcpsvc.Lease + leasesLock sync.Mutex + ipAddrs [256]byte + dnsIPAddrsMu sync.RWMutex + advertisedPrefixes map[netip.Prefix]struct{} + renewablePrefixes map[netip.Prefix]struct{} + preferredUntilByPrefix map[netip.Prefix]time.Time + validUntilByPrefix map[netip.Prefix]time.Time + restoredRenewable map[netip.Prefix]struct{} + restoredDeprecated map[netip.Prefix]time.Time + persistRestoredMeta bool } // WriteDiskConfig4 - write configuration @@ -44,7 +54,29 @@ func (s *v6Server) WriteDiskConfig4(c *V4ServerConf) { // WriteDiskConfig6 - write configuration func (s *v6Server) WriteDiskConfig6(c *V6ServerConf) { - *c = s.conf + *c = V6ServerConf{ + Logger: s.conf.Logger, + CommandConstructor: s.conf.CommandConstructor, + Enabled: s.conf.Enabled, + InterfaceName: s.conf.InterfaceName, + RangeStart: bytes.Clone(s.conf.RangeStart), + PrefixSource: s.conf.PrefixSource, + LeaseDuration: s.conf.LeaseDuration, + RASLAACOnly: s.conf.RASLAACOnly, + RAAllowSLAAC: s.conf.RAAllowSLAAC, + leaseTime: s.conf.leaseTime, + notify: s.conf.notify, + } + + s.leasesLock.Lock() + c.ipStart = bytes.Clone(s.conf.ipStart) + s.leasesLock.Unlock() + + s.dnsIPAddrsMu.RLock() + c.dnsIPAddrs = slices.Clone(s.conf.dnsIPAddrs) + s.dnsIPAddrsMu.RUnlock() + + c.PrefixSource = c.NormalizedPrefixSource() } // Return TRUE if IP address is within range [start..0xff] @@ -95,10 +127,15 @@ func (s *v6Server) ResetLeases(leases []*dhcpsvc.Lease) (err error) { s.leasesLock.Lock() defer s.leasesLock.Unlock() + if len(leases) == 0 { + s.restoredRenewable = nil + s.restoredDeprecated = nil + } + s.leases = nil for _, l := range leases { ip := net.IP(l.IP.AsSlice()) - if !l.IsStatic && !ip6InRange(s.conf.ipStart, ip) { + if !l.IsStatic && !s.keepInterfaceLeaseOnReset(l.IP, ip) { log.Debug("dhcpv6: skipping a lease with IP %v: not within current IP range", l.IP) @@ -111,6 +148,20 @@ func (s *v6Server) ResetLeases(leases []*dhcpsvc.Lease) (err error) { return nil } +// keepInterfaceLeaseOnReset reports whether a dynamic lease should be kept +// while rebuilding in-memory state from disk. +func (s *v6Server) keepInterfaceLeaseOnReset(ip netip.Addr, raw net.IP) (ok bool) { + if s.conf.NormalizedPrefixSource() != V6PrefixSourceInterface { + return ip6InRange(s.conf.ipStart, raw) + } + + if len(s.advertisedPrefixes) == 0 { + return true + } + + return leasePrefixAdvertised(s.advertisedPrefixes, ip) +} + // GetLeases returns the list of current DHCP leases. It is safe for concurrent // use. func (s *v6Server) GetLeases(flags GetLeasesFlags) (leases []*dhcpsvc.Lease) { @@ -141,6 +192,26 @@ func (s *v6Server) getLeasesRef() []*dhcpsvc.Lease { return s.leases } +// dbSnapshot returns a consistent snapshot of DHCPv6 leases and persisted +// prefix-tracking metadata. +func (s *v6Server) dbSnapshot(now time.Time) ( + leases []*dhcpsvc.Lease, + renewable map[netip.Prefix]struct{}, + deprecated map[netip.Prefix]time.Time, +) { + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + leases = make([]*dhcpsvc.Lease, 0, len(s.leases)) + for _, l := range s.leases { + leases = append(leases, l.Clone()) + } + + renewable, deprecated = s.deprecatedPrefixMetaLocked(now) + + return leases, renewable, deprecated +} + // FindMACbyIP implements the [Interface] for *v6Server. func (s *v6Server) FindMACbyIP(ip netip.Addr) (mac net.HardwareAddr) { now := time.Now() @@ -165,8 +236,7 @@ func (s *v6Server) FindMACbyIP(ip netip.Addr) (mac net.HardwareAddr) { // Remove (swap) lease by index func (s *v6Server) leaseRemoveSwapByIndex(i int) { - leaseIP := s.leases[i].IP.As16() - s.ipAddrs[leaseIP[15]] = 0 + s.unmarkLeaseOccupied(s.leases[i]) log.Debug("dhcpv6: removed lease %s", s.leases[i].HWAddr) n := len(s.leases) @@ -231,9 +301,9 @@ func (s *v6Server) AddStaticLease(l *dhcpsvc.Lease) (err error) { } s.addLease(l) - s.conf.notify(LeaseChangedDBStore) s.leasesLock.Unlock() + s.conf.notify(LeaseChangedDBStore) s.conf.notify(LeaseChangedAddedStatic) return nil @@ -289,8 +359,9 @@ func (s *v6Server) RemoveStaticLease(l *dhcpsvc.Lease) (err error) { s.leasesLock.Unlock() return err } - s.conf.notify(LeaseChangedDBStore) s.leasesLock.Unlock() + + s.conf.notify(LeaseChangedDBStore) s.conf.notify(LeaseChangedRemovedStatic) return nil } @@ -298,9 +369,43 @@ func (s *v6Server) RemoveStaticLease(l *dhcpsvc.Lease) (err error) { // Add a lease func (s *v6Server) addLease(l *dhcpsvc.Lease) { s.leases = append(s.leases, l) + s.markLeaseOccupied(l) + log.Debug("dhcpv6: added lease %s <-> %s", l.IP, l.HWAddr) +} + +// ipInCurrentPool reports whether ip belongs to the currently active pool. +func (s *v6Server) ipInCurrentPool(ip netip.Addr) (ok bool) { + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + return s.ipInCurrentPoolLocked(ip) +} + +// ipInCurrentPoolLocked reports whether ip belongs to the currently active +// pool. s.leasesLock must be held. +func (s *v6Server) ipInCurrentPoolLocked(ip netip.Addr) (ok bool) { + return ip.Is6() && ip6InRange(s.conf.ipStart, net.IP(ip.AsSlice())) +} + +// markLeaseOccupied updates the dynamic-pool occupancy bitmap for l. +func (s *v6Server) markLeaseOccupied(l *dhcpsvc.Lease) { + if !s.ipInCurrentPoolLocked(l.IP) { + return + } + ip := l.IP.As16() s.ipAddrs[ip[15]] = 1 - log.Debug("dhcpv6: added lease %s <-> %s", l.IP, l.HWAddr) +} + +// unmarkLeaseOccupied updates the dynamic-pool occupancy bitmap after l is +// removed. +func (s *v6Server) unmarkLeaseOccupied(l *dhcpsvc.Lease) { + if !s.ipInCurrentPoolLocked(l.IP) { + return + } + + ip := l.IP.As16() + s.ipAddrs[ip[15]] = 0 } // Remove a lease with the same properties @@ -336,7 +441,7 @@ func (s *v6Server) findLease(mac net.HardwareAddr) (lease *dhcpsvc.Lease) { func (s *v6Server) findExpiredLease() int { now := time.Now().Unix() for i, lease := range s.leases { - if !lease.IsStatic && lease.Expiry.Unix() <= now { + if !lease.IsStatic && s.ipInCurrentPoolLocked(lease.IP) && lease.Expiry.Unix() <= now { return i } } @@ -345,6 +450,10 @@ func (s *v6Server) findExpiredLease() int { // Get next free IP func (s *v6Server) findFreeIP() net.IP { + if len(s.conf.ipStart) != net.IPv6len { + return nil + } + for i := s.conf.ipStart[15]; ; i++ { if s.ipAddrs[i] == 0 { ip := make([]byte, 16) @@ -370,6 +479,21 @@ func (s *v6Server) reserveLease(mac net.HardwareAddr) *dhcpsvc.Lease { s.leasesLock.Lock() defer s.leasesLock.Unlock() + if len(s.conf.ipStart) != net.IPv6len { + return nil + } + + for i := 0; i < len(s.leases); i++ { + if s.leases[i].IsStatic || + !bytes.Equal(s.leases[i].HWAddr, mac) || + !s.ipInCurrentPoolLocked(s.leases[i].IP) { + continue + } + + s.leaseRemoveSwapByIndex(i) + i-- + } + ip := s.findFreeIP() if ip == nil { i := s.findExpiredLease() @@ -394,15 +518,214 @@ func (s *v6Server) reserveLease(mac net.HardwareAddr) *dhcpsvc.Lease { return &l } -func (s *v6Server) commitDynamicLease(l *dhcpsvc.Lease) { - l.Expiry = time.Now().Add(s.conf.leaseTime) +func (s *v6Server) commitDynamicLease(l *dhcpsvc.Lease, lifetime time.Duration) { + l.Expiry = time.Now().Add(lifetime) - s.leasesLock.Lock() s.conf.notify(LeaseChangedDBStore) - s.leasesLock.Unlock() s.conf.notify(LeaseChangedAdded) } +// dnsIPAddrs returns the current DHCPv6 DNS server addresses. +func (s *v6Server) dnsIPAddrs() (addrs []net.IP) { + s.dnsIPAddrsMu.RLock() + defer s.dnsIPAddrsMu.RUnlock() + + if len(s.conf.dnsIPAddrs) == 0 { + return nil + } + + return slices.Clone(s.conf.dnsIPAddrs) +} + +// setDNSIPAddrs updates the current DHCPv6 DNS server addresses. +func (s *v6Server) setDNSIPAddrs(addrs []net.IP) { + s.dnsIPAddrsMu.Lock() + defer s.dnsIPAddrsMu.Unlock() + + s.conf.dnsIPAddrs = slices.Clone(addrs) +} + +// observedDNSIPAddrs converts observed IPv6 interface state into the DNS +// addresses returned by DHCPv6 replies. +func observedDNSIPAddrs(states []aghnet.IPv6AddrState) (addrs []net.IP) { + for _, st := range states { + if !st.Addr.IsValid() || !st.Addr.Is6() || st.Tentative { + continue + } + if !st.Addr.IsLinkLocalUnicast() && st.PreferredLifetimeSec == 0 { + continue + } + + addrs = append(addrs, net.IP(st.Addr.AsSlice())) + } + + switch len(addrs) { + case 0: + return nil + case 1: + return append(addrs, slices.Clone(addrs[0])) + default: + return addrs + } +} + +// advertisedLeasePrefixes returns the set of /64 prefixes currently +// advertised in Router Advertisements. +func advertisedLeasePrefixes(advertised []prefixPIO) (prefixes map[netip.Prefix]struct{}) { + prefixes = make(map[netip.Prefix]struct{}, len(advertised)) + for _, p := range advertised { + prefixes[p.Prefix.Masked()] = struct{}{} + } + + return prefixes +} + +// leasePrefixAdvertised reports whether ip belongs to one of the advertised +// /64 prefixes. +func leasePrefixAdvertised(prefixes map[netip.Prefix]struct{}, ip netip.Addr) (ok bool) { + if !ip.Is6() { + return false + } + + _, ok = prefixes[netip.PrefixFrom(ip, raObservedPrefixBits).Masked()] + + return ok +} + +// samePrefixSet reports whether a and b contain the same prefixes. +func samePrefixSet(a, b map[netip.Prefix]struct{}) (ok bool) { + if len(a) != len(b) { + return false + } + + for pref := range a { + if _, ok = b[pref]; !ok { + return false + } + } + + return true +} + +// prefixSetContainsAll reports whether haystack contains every prefix from +// needle. +func prefixSetContainsAll(haystack, needle map[netip.Prefix]struct{}) (ok bool) { + for pref := range needle { + if _, ok = haystack[pref]; !ok { + return false + } + } + + return true +} + +// renewableLeasePrefixes returns the set of /64 prefixes currently advertised +// with a non-zero preferred lifetime. +func renewableLeasePrefixes(advertised []prefixPIO) (prefixes map[netip.Prefix]struct{}) { + prefixes = make(map[netip.Prefix]struct{}, len(advertised)) + for _, p := range advertised { + if p.PreferredSec == 0 { + continue + } + + prefixes[p.Prefix.Masked()] = struct{}{} + } + + return prefixes +} + +// validUntilByPrefix returns the valid-lifetime deadlines for advertised /64 +// prefixes. +func validUntilByPrefix(advertised []prefixPIO, now time.Time) (deadlines map[netip.Prefix]time.Time) { + deadlines = make(map[netip.Prefix]time.Time, len(advertised)) + for _, p := range advertised { + deadlines[p.Prefix.Masked()] = deadlineFromRemaining(now, p.ValidSec) + } + + return deadlines +} + +// refreshDeadlineMap updates absolute prefix deadlines while preserving +// existing deadlines when the remaining lifetime has not changed. +func refreshDeadlineMap( + existing map[netip.Prefix]time.Time, + advertised []prefixPIO, + now time.Time, + lifetime func(prefixPIO) uint32, +) (deadlines map[netip.Prefix]time.Time) { + deadlines = make(map[netip.Prefix]time.Time, len(advertised)) + for _, p := range advertised { + pref := p.Prefix.Masked() + target := lifetime(p) + if until, ok := existing[pref]; ok && remainingUntil(now, until) == target { + deadlines[pref] = until + + continue + } + + deadlines[pref] = deadlineFromRemaining(now, target) + } + + return deadlines +} + +// preferredUntilByPrefix returns the preferred-lifetime deadlines for +// advertised /64 prefixes. +func preferredUntilByPrefix(advertised []prefixPIO, now time.Time) (deadlines map[netip.Prefix]time.Time) { + deadlines = make(map[netip.Prefix]time.Time, len(advertised)) + for _, p := range advertised { + deadlines[p.Prefix.Masked()] = deadlineFromRemaining(now, p.PreferredSec) + } + + return deadlines +} + +// leasePrefixRenewable reports whether ip belongs to an advertised prefix with +// a non-zero preferred lifetime. +func leasePrefixRenewable(prefixes map[netip.Prefix]struct{}, ip netip.Addr) (ok bool) { + return leasePrefixAdvertised(prefixes, ip) +} + +// deprecatedMetaFrom returns the persisted deprecated-prefix metadata derived +// from the current tracked state. +func deprecatedMetaFrom( + now time.Time, + renewable map[netip.Prefix]struct{}, + advertised map[netip.Prefix]struct{}, + validUntil map[netip.Prefix]time.Time, +) (deprecated map[netip.Prefix]time.Time) { + deprecated = map[netip.Prefix]time.Time{} + for pref := range advertised { + if _, ok := renewable[pref]; ok { + continue + } + + until, ok := validUntil[pref] + if !ok || !until.After(now) { + continue + } + + deprecated[pref] = until + } + + return deprecated +} + +// sameDeadlineMap reports whether a and b contain the same deadlines. +func sameDeadlineMap(a, b map[netip.Prefix]time.Time) (ok bool) { + if len(a) != len(b) { + return false + } + + for pref, until := range a { + if other, ok := b[pref]; !ok || !other.Equal(until) { + return false + } + } + + return true +} + // Check Client ID func (s *v6Server) checkCID(msg *dhcpv6.Message) error { if msg.Options.ClientID() == nil { @@ -476,19 +799,178 @@ func (s *v6Server) commitLease(msg *dhcpv6.Message, lease *dhcpsvc.Lease) time.D // case dhcpv6.MessageTypeConfirm: - lifetime = time.Until(lease.Expiry) + switch { + case lease.IsStatic: + lifetime = s.conf.leaseTime + case s.leaseIsRenewable(lease.IP): + lifetime = min(max(time.Until(lease.Expiry), 0), s.renewableLeaseLifetime(lease.IP)) + default: + lifetime = s.deprecatedLeaseLifetime(lease.IP, lease.Expiry) + } case dhcpv6.MessageTypeRequest, dhcpv6.MessageTypeRenew, dhcpv6.MessageTypeRebind: if !lease.IsStatic { - s.commitDynamicLease(lease) + if s.leaseIsRenewable(lease.IP) { + lifetime = s.renewableLeaseLifetime(lease.IP) + s.commitDynamicLease(lease, lifetime) + } else { + lifetime = s.deprecatedLeaseLifetime(lease.IP, lease.Expiry) + } } } return lifetime } +// requestedIP returns the IPv6 address requested by msg, if any. +func requestedIP(msg *dhcpv6.Message) (ip netip.Addr) { + oia := msg.Options.OneIANA() + if oia == nil { + return netip.Addr{} + } + + oiaAddr := oia.Options.OneAddress() + if oiaAddr == nil { + return netip.Addr{} + } + + addr, ok := netip.AddrFromSlice(oiaAddr.IPv6Addr) + if !ok { + return netip.Addr{} + } + + return addr +} + +// findUsableLease returns a lease that should be served to mac for msg. +func (s *v6Server) findUsableLease(msg *dhcpv6.Message, mac net.HardwareAddr) (lease *dhcpsvc.Lease) { + reqIP := requestedIP(msg) + msgType := msg.Type() + + for _, l := range s.leases { + if !bytes.Equal(mac, l.HWAddr) { + continue + } + + if l.IsStatic { + if reqIP.IsValid() && l.IP == reqIP { + return l + } else if lease == nil { + lease = l + } + + continue + } + + if reqIP.IsValid() && l.IP == reqIP { + if (s.ipInCurrentPoolLocked(l.IP) && leaseNotExpired(l)) || + (canServeDeprecatedLease(msgType, l.IP, s.advertisedPrefixes) && leaseNotExpired(l)) { + return l + } + } else if lease == nil && s.ipInCurrentPoolLocked(l.IP) && leaseNotExpired(l) { + lease = l + } + } + + return lease +} + +// canServeDeprecatedLease reports whether a deprecated dynamic lease for ip may +// still be served for msgType while its prefix remains advertised. +func canServeDeprecatedLease( + msgType dhcpv6.MessageType, + ip netip.Addr, + advertisedPrefixes map[netip.Prefix]struct{}, +) (ok bool) { + switch msgType { + case dhcpv6.MessageTypeConfirm, + dhcpv6.MessageTypeRequest, + dhcpv6.MessageTypeRenew, + dhcpv6.MessageTypeRebind: + return leasePrefixAdvertised(advertisedPrefixes, ip) + default: + return false + } +} + +// leaseIsRenewable reports whether a dynamic lease on ip should be refreshed +// with the full DHCP lifetime. +func (s *v6Server) leaseIsRenewable(ip netip.Addr) (ok bool) { + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + return leasePrefixRenewable(s.renewablePrefixes, ip) +} + +// renewableLeaseLifetime returns the lifetime to grant to a renewable lease on +// ip. +func (s *v6Server) renewableLeaseLifetime(ip netip.Addr) (lifetime time.Duration) { + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + pref := netip.PrefixFrom(ip, raObservedPrefixBits).Masked() + until, ok := s.validUntilByPrefix[pref] + if !ok { + return s.conf.leaseTime + } + + validForPrefix := time.Duration(remainingUntil(time.Now(), until)) * time.Second + + return min(s.conf.leaseTime, validForPrefix) +} + +// deprecatedLeaseLifetime returns the remaining valid lifetime for a +// non-renewable lease on ip, capped by the currently advertised prefix +// lifetime. +func (s *v6Server) deprecatedLeaseLifetime(ip netip.Addr, leaseExpiry time.Time) (lifetime time.Duration) { + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + pref := netip.PrefixFrom(ip, raObservedPrefixBits).Masked() + until, ok := s.validUntilByPrefix[pref] + if !ok { + return 0 + } + + validForPrefix := time.Duration(remainingUntil(time.Now(), until)) * time.Second + validForLease := max(time.Until(leaseExpiry), 0) + + return min(validForLease, validForPrefix) +} + +// preferredLeaseLifetime returns the preferred lifetime to encode in DHCPv6 +// replies for lease. +func (s *v6Server) preferredLeaseLifetime(lease *dhcpsvc.Lease, validLifetime time.Duration) time.Duration { + if !lease.IsStatic && !s.leaseIsRenewable(lease.IP) { + return 0 + } + + if lease.IsStatic { + return validLifetime + } + + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + pref := netip.PrefixFrom(lease.IP, raObservedPrefixBits).Masked() + until, ok := s.preferredUntilByPrefix[pref] + if !ok { + return validLifetime + } + + preferredForPrefix := time.Duration(remainingUntil(time.Now(), until)) * time.Second + + return min(validLifetime, preferredForPrefix) +} + +// leaseNotExpired reports whether lease is still valid at the time of the +// request. Zero expiries are treated as not yet committed. +func leaseNotExpired(lease *dhcpsvc.Lease) (ok bool) { + return lease.Expiry.IsZero() || lease.Expiry.After(time.Now()) +} + // Find a lease associated with MAC and prepare response func (s *v6Server) process(msg *dhcpv6.Message, req, resp dhcpv6.DHCPv6) bool { switch msg.Type() { @@ -515,7 +997,7 @@ func (s *v6Server) process(msg *dhcpv6.Message, req, resp dhcpv6.DHCPv6) bool { s.leasesLock.Lock() defer s.leasesLock.Unlock() - lease = s.findLease(mac) + lease = s.findUsableLease(msg, mac) }() if lease == nil { @@ -542,6 +1024,7 @@ func (s *v6Server) process(msg *dhcpv6.Message, req, resp dhcpv6.DHCPv6) bool { } lifetime := s.commitLease(msg, lease) + preferredLifetime := s.preferredLeaseLifetime(lease, lifetime) oia := &dhcpv6.OptIANA{ T1: lifetime / 2, @@ -555,7 +1038,7 @@ func (s *v6Server) process(msg *dhcpv6.Message, req, resp dhcpv6.DHCPv6) bool { } oiaAddr := &dhcpv6.OptIAAddress{ IPv6Addr: net.IP(lease.IP.AsSlice()), - PreferredLifetime: lifetime, + PreferredLifetime: preferredLifetime, ValidLifetime: lifetime, } oia.Options = dhcpv6.IdentityOptions{ @@ -564,7 +1047,7 @@ func (s *v6Server) process(msg *dhcpv6.Message, req, resp dhcpv6.DHCPv6) bool { resp.AddOption(oia) if msg.IsOptionRequested(dhcpv6.OptionDNSRecursiveNameServer) { - resp.UpdateOption(dhcpv6.OptDNS(s.conf.dnsIPAddrs...)) + resp.UpdateOption(dhcpv6.OptDNS(s.dnsIPAddrs()...)) } fqdn := msg.GetOneOption(dhcpv6.OptionFQDN) @@ -644,7 +1127,16 @@ func (s *v6Server) packetHandler(conn net.PacketConn, peer net.Addr, req dhcpv6. resp.AddOption(dhcpv6.OptServerID(s.sid)) - _ = s.process(msg, req, resp) + if !s.process(msg, req, resp) { + if code, text, ok := replyStatusForProcessFailure(msg.Type()); ok { + resp.AddOption(&dhcpv6.OptStatusCode{ + StatusCode: code, + StatusMessage: text, + }) + } else if requiresProcessSuccess(msg.Type()) { + return + } + } log.Debug("dhcpv6: sending: %s", resp.Summary()) @@ -656,8 +1148,40 @@ func (s *v6Server) packetHandler(conn net.PacketConn, peer net.Addr, req dhcpv6. } } +// requiresProcessSuccess reports whether msgType requires a usable lease +// before it is safe to send a reply. +func requiresProcessSuccess(msgType dhcpv6.MessageType) (ok bool) { + switch msgType { + case dhcpv6.MessageTypeSolicit, + dhcpv6.MessageTypeRequest, + dhcpv6.MessageTypeConfirm, + dhcpv6.MessageTypeRenew, + dhcpv6.MessageTypeRebind: + return true + default: + return false + } +} + +// replyStatusForProcessFailure maps a failed lease-processing path to the +// DHCPv6 status code a client can use to recover. +func replyStatusForProcessFailure(msgType dhcpv6.MessageType) (code iana.StatusCode, text string, ok bool) { + switch msgType { + case dhcpv6.MessageTypeSolicit: + return iana.StatusNoAddrsAvail, iana.StatusNoAddrsAvail.String(), true + case dhcpv6.MessageTypeConfirm: + return iana.StatusNotOnLink, iana.StatusNotOnLink.String(), true + case dhcpv6.MessageTypeRequest, + dhcpv6.MessageTypeRenew, + dhcpv6.MessageTypeRebind: + return iana.StatusNoBinding, iana.StatusNoBinding.String(), true + default: + return 0, "", false + } +} + // configureDNSIPAddrs updates v6Server configuration with the slice of DNS IP -// addresses of provided interface iface. Initializes RA module. +// addresses of provided interface iface. func (s *v6Server) configureDNSIPAddrs( ctx context.Context, iface *net.Interface, @@ -678,31 +1202,274 @@ func (s *v6Server) configureDNSIPAddrs( return false, nil } - s.conf.dnsIPAddrs = dnsIPAddrs + s.setDNSIPAddrs(dnsIPAddrs) - return true, s.initRA(iface) + return true, nil } -// initRA initializes RA module. -func (s *v6Server) initRA(iface *net.Interface) (err error) { - // Choose the source IP address - should be link-local-unicast. - s.ra.ipAddr = s.conf.dnsIPAddrs[0] - for _, ip := range s.conf.dnsIPAddrs { - if ip.IsLinkLocalUnicast() { - s.ra.ipAddr = ip - break - } - } - +// initRA initializes the Router Advertisement state loop. +func (s *v6Server) initRA( + iface *net.Interface, + initial raState, + observe raObserver, +) (err error) { s.ra.raAllowSLAAC = s.conf.RAAllowSLAAC s.ra.raSLAACOnly = s.conf.RASLAACOnly - s.ra.dnsIPAddr = s.ra.ipAddr - s.ra.prefixIPAddr = s.conf.ipStart + s.ra.observe = observe s.ra.ifaceName = s.conf.InterfaceName s.ra.iface = iface s.ra.packetSendPeriod = 1 * time.Second + s.ra.observePeriod = defaultRAObservePeriod + + return s.ra.Init(initial) +} + +// observeRAState refreshes the current interface-derived Router Advertisement +// observation. +func (s *v6Server) observeRAState(ctx context.Context) (obs raObservation, err error) { + states, err := aghnet.ObserveIPv6Addrs( + ctx, + s.conf.Logger, + s.conf.CommandConstructor, + s.conf.InterfaceName, + ) + if err != nil { + return raObservation{}, err + } + + s.setDNSIPAddrs(observedDNSIPAddrs(states)) + + obs = buildInterfaceRAObservation(states) + if !obs.SourceAddr.IsValid() { + obs.SourceAddr = pickStaticRASourceAddr(s.dnsIPAddrs()) + obs.RDNSSAddr = obs.SourceAddr + } + + return obs, nil +} + +// trackedPrefixChanged updates the effective DHCPv6 pool start from active. +func (s *v6Server) trackedPrefixChanged(active *raPrefixSnapshot, advertised []prefixPIO) (err error) { + if !s.conf.NeedsDHCPv6Pool() { + s.setTrackedRangeStart(nil, advertised) + + return nil + } + + if active == nil { + s.setTrackedRangeStart(nil, advertised) + + return nil + } - return s.ra.Init() + ipStart, err := deriveTrackedRangeStart(s.conf.RangeStart, active.Prefix) + if err != nil { + return err + } + + s.setTrackedRangeStart(ipStart, advertised) + + return nil +} + +// setTrackedRangeStart updates the effective DHCPv6 pool start and removes +// dynamic leases whose prefixes are no longer advertised. +func (s *v6Server) setTrackedRangeStart(ipStart net.IP, advertised []prefixPIO) { + s.leasesLock.Lock() + + now := time.Now() + oldDeprecated := deprecatedMetaFrom( + now, + s.renewablePrefixes, + s.advertisedPrefixes, + s.validUntilByPrefix, + ) + oldRenewable := maps.Clone(s.renewablePrefixes) + keepPrefixes := advertisedLeasePrefixes(advertised) + renewable := renewableLeasePrefixes(advertised) + preferredUntil := refreshDeadlineMap(s.preferredUntilByPrefix, advertised, now, func(p prefixPIO) uint32 { + return p.PreferredSec + }) + validUntil := refreshDeadlineMap(s.validUntilByPrefix, advertised, now, func(p prefixPIO) uint32 { + return p.ValidSec + }) + s.conf.ipStart = bytes.Clone(ipStart) + s.advertisedPrefixes = keepPrefixes + s.renewablePrefixes = renewable + s.preferredUntilByPrefix = preferredUntil + s.validUntilByPrefix = validUntil + s.ipAddrs = [256]byte{} + + activePrefix := netip.Prefix{} + if len(ipStart) == net.IPv6len { + if addr, ok := netip.AddrFromSlice(ipStart); ok { + activePrefix = netip.PrefixFrom(addr, raObservedPrefixBits).Masked() + } + } + + removed := 0 + updated := false + leases := s.leases[:0] + for _, l := range s.leases { + if !l.IsStatic { + pref := netip.PrefixFrom(l.IP, raObservedPrefixBits).Masked() + if !leasePrefixAdvertised(keepPrefixes, l.IP) || + (activePrefix.IsValid() && pref == activePrefix && !ip6InRange(ipStart, net.IP(l.IP.AsSlice()))) { + removed++ + + continue + } + + if until, ok := validUntil[pref]; ok && (l.Expiry.IsZero() || l.Expiry.After(until)) { + l.Expiry = until + updated = true + } + } + + leases = append(leases, l) + s.markLeaseOccupied(l) + } + + s.leases = leases + newDeprecated := deprecatedMetaFrom(now, renewable, keepPrefixes, validUntil) + metadataChanged := (len(oldDeprecated) > 0 || len(newDeprecated) > 0) && + (!samePrefixSet(oldRenewable, renewable) || !sameDeadlineMap(oldDeprecated, newDeprecated)) + s.leasesLock.Unlock() + + if (removed > 0 || updated || metadataChanged) && s.conf.notify != nil { + s.conf.notify(LeaseChangedDBStore) + } +} + +// deriveTrackedRangeStart returns the effective DHCPv6 pool start for the +// current observed /64 prefix while preserving the configured host bits from +// template. +func deriveTrackedRangeStart(template net.IP, observedPrefix netip.Prefix) (ipStart net.IP, err error) { + if template == nil || template.To16() == nil { + return nil, fmt.Errorf("invalid range-start IP: %s", template) + } + if !observedPrefix.IsValid() || observedPrefix.Bits() != raObservedPrefixBits { + return nil, fmt.Errorf("invalid observed prefix: %s", observedPrefix) + } + + addr := observedPrefix.Masked().Addr().As16() + ipStart = bytes.Clone(template.To16()) + copy(ipStart[:8], addr[:8]) + + return ipStart, nil +} + +// hasStaticV6Leases reports whether the current lease set contains IPv6 static +// leases. +func (s *v6Server) hasStaticV6Leases() (ok bool) { + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + for _, l := range s.leases { + if l.IsStatic && l.IP.Is6() { + return true + } + } + + return false +} + +// restoreDeprecatedPrefixes seeds initial deprecated prefixes from persisted +// metadata whose renewable prefixes still match the currently observed +// interface state. +func (s *v6Server) restoreDeprecatedPrefixes(now time.Time, st *raState) { + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + s.persistRestoredMeta = false + + if len(s.restoredDeprecated) == 0 { + return + } + + observedRenewable := renewableLeasePrefixes(st.pios(now)) + if len(s.restoredRenewable) > 0 { + if !prefixSetContainsAll(observedRenewable, s.restoredRenewable) { + return + } + } else if len(observedRenewable) > 0 { + return + } else if len(st.pios(now)) == 0 { + return + } + + advertised := advertisedLeasePrefixes(st.pios(now)) + for pref, until := range s.restoredDeprecated { + if _, ok := advertised[pref]; ok { + continue + } + + if !until.After(now) { + continue + } + + valid := uint32(until.Sub(now) / time.Second) + if valid > raDeprecatedLifetimeCapSecs { + valid = raDeprecatedLifetimeCapSecs + } + + st.deprecated[pref] = newTrackedPrefix(raPrefixSnapshot{ + Prefix: pref, + PreferredSec: 0, + ValidSec: valid, + }, raPrefixOriginDeprecated, now) + } +} + +// setRestoredPrefixMeta stores deprecated-prefix metadata loaded from disk. +func (s *v6Server) setRestoredPrefixMeta( + renewable map[netip.Prefix]struct{}, + deprecated map[netip.Prefix]time.Time, +) { + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + s.restoredRenewable = maps.Clone(renewable) + s.restoredDeprecated = maps.Clone(deprecated) + s.persistRestoredMeta = len(s.restoredRenewable) > 0 || len(s.restoredDeprecated) > 0 +} + +// deprecatedPrefixMeta returns persisted metadata for currently tracked +// interface-derived prefixes. +func (s *v6Server) deprecatedPrefixMeta(now time.Time) (renewable map[netip.Prefix]struct{}, deprecated map[netip.Prefix]time.Time) { + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + return s.deprecatedPrefixMetaLocked(now) +} + +// deprecatedPrefixMetaLocked returns persisted metadata for currently tracked +// interface-derived prefixes. s.leasesLock must be held. +func (s *v6Server) deprecatedPrefixMetaLocked(now time.Time) (renewable map[netip.Prefix]struct{}, deprecated map[netip.Prefix]time.Time) { + if s.conf.NormalizedPrefixSource() != V6PrefixSourceInterface { + return nil, nil + } + + renewable = maps.Clone(s.renewablePrefixes) + deprecated = map[netip.Prefix]time.Time{} + for pref := range s.advertisedPrefixes { + if _, ok := s.renewablePrefixes[pref]; ok { + continue + } + + until, ok := s.validUntilByPrefix[pref] + if !ok || !until.After(now) { + continue + } + + deprecated[pref] = until + } + + if s.persistRestoredMeta && len(renewable) == 0 && len(deprecated) == 0 { + return maps.Clone(s.restoredRenewable), maps.Clone(s.restoredDeprecated) + } + + return renewable, deprecated } // Start starts the IPv6 DHCP server. @@ -728,12 +1495,66 @@ func (s *v6Server) Start(ctx context.Context) (err error) { } if !ok { - // No available IP addresses which may appear later. - return nil + if s.conf.NormalizedPrefixSource() != V6PrefixSourceInterface { + // No available IP addresses which may appear later. + return nil + } + } + + var ( + initial raState + observe raObserver + ) + + switch s.conf.NormalizedPrefixSource() { + case V6PrefixSourceStatic: + initial = newStaticRAState(buildStaticRAObservation(s.dnsIPAddrs(), s.conf.ipStart)) + s.ra.onStateRefresh = nil + s.ra.onActivePrefixChange = nil + case V6PrefixSourceInterface: + if s.hasStaticV6Leases() { + s.conf.Logger.WarnContext( + ctx, + "dhcpv6: interface-derived prefix tracking does not rewrite literal static IPv6 leases", + ) + } + + initial = newObservedRAState() + + obs, obsErr := s.observeRAState(ctx) + if obsErr != nil { + return fmt.Errorf("observing initial ipv6 prefix state: %w", obsErr) + } else { + now := time.Now() + initial.merge(obs, now) + s.restoreDeprecatedPrefixes(now, &initial) + if pios := initial.pios(now); len(pios) > 0 { + if err = s.trackedPrefixChanged(initial.activeSnapshot(now), pios); err != nil { + return fmt.Errorf("updating tracked range start: %w", err) + } + } + } + + observe = s.observeRAState + s.ra.onStateRefresh = func(now time.Time, st *raState) { + s.restoreDeprecatedPrefixes(now, st) + } + s.ra.onActivePrefixChange = func(active *raPrefixSnapshot, advertised []prefixPIO) { + if activeErr := s.trackedPrefixChanged(active, advertised); activeErr != nil { + log.Error("dhcpv6: updating tracked pool: %s", activeErr) + } + } + default: + return fmt.Errorf("unsupported prefix source %q", s.conf.PrefixSource) + } + + err = s.initRA(iface, initial, observe) + if err != nil { + return err } // Don't initialize DHCPv6 server if we must force the clients to use SLAAC. - if s.conf.RASLAACOnly { + if !s.conf.NeedsDHCPv6Pool() { log.Debug("not starting dhcpv6 server due to ra_slaac_only=true") return nil @@ -795,16 +1616,38 @@ func (s *v6Server) Stop() (err error) { // Create DHCPv6 server func v6Create(conf V6ServerConf) (DHCPServer, error) { s := &v6Server{} + conf.PrefixSource = conf.NormalizedPrefixSource() s.conf = conf + err := conf.ValidatePrefixSource() + if err != nil { + return s, fmt.Errorf("dhcpv6: %w", err) + } + if !conf.Enabled { return s, nil } - s.conf.ipStart = conf.RangeStart - if s.conf.ipStart == nil || s.conf.ipStart.To16() == nil { + needsConfiguredRange := conf.NormalizedPrefixSource() == V6PrefixSourceStatic || conf.NeedsDHCPv6Pool() + if needsConfiguredRange && (conf.RangeStart == nil || conf.RangeStart.To16() == nil) { return s, fmt.Errorf("dhcpv6: invalid range-start IP: %s", conf.RangeStart) } + if len(conf.RangeStart) != 0 { + if conf.RangeStart.To16() == nil { + return s, fmt.Errorf("dhcpv6: invalid range-start IP: %s", conf.RangeStart) + } + + s.conf.RangeStart = bytes.Clone(conf.RangeStart.To16()) + } + + if conf.NormalizedPrefixSource() == V6PrefixSourceStatic { + s.conf.ipStart = bytes.Clone(s.conf.RangeStart) + if addr, ok := netip.AddrFromSlice(s.conf.ipStart); ok { + prefix := netip.PrefixFrom(addr, raObservedPrefixBits).Masked() + s.advertisedPrefixes = map[netip.Prefix]struct{}{prefix: {}} + s.renewablePrefixes = map[netip.Prefix]struct{}{prefix: {}} + } + } if conf.LeaseDuration == 0 { s.conf.leaseTime = timeutil.Day diff --git a/internal/dhcpd/v6_unix_internal_test.go b/internal/dhcpd/v6_unix_internal_test.go index b642eed7384..72a154aea08 100644 --- a/internal/dhcpd/v6_unix_internal_test.go +++ b/internal/dhcpd/v6_unix_internal_test.go @@ -3,11 +3,13 @@ package dhcpd import ( + "math" "net" "net/netip" "testing" "time" + "github.com/AdguardTeam/AdGuardHome/internal/aghnet" "github.com/AdguardTeam/AdGuardHome/internal/dhcpsvc" "github.com/insomniacslk/dhcp/dhcpv6" "github.com/insomniacslk/dhcp/iana" @@ -380,3 +382,835 @@ func TestV6_FindMACbyIP(t *testing.T) { }) } } + +func TestDeriveTrackedRangeStart(t *testing.T) { + got, err := deriveTrackedRangeStart( + net.ParseIP("fd00::1234:5678:9abc:de00"), + netip.MustParsePrefix("2001:db8:1::/64"), + ) + require.NoError(t, err) + + assert.Equal(t, net.ParseIP("2001:db8:1::1234:5678:9abc:de00"), got) +} + +func TestV6SetTrackedRangeStart(t *testing.T) { + var notified []uint32 + + s := &v6Server{ + conf: V6ServerConf{ + notify: func(flags uint32) { + notified = append(notified, flags) + }, + }, + leases: []*dhcpsvc.Lease{{ + IP: netip.MustParseAddr("2001:db8:1::10"), + HWAddr: net.HardwareAddr{0x10, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa}, + Expiry: time.Now().Add(30 * time.Minute), + }, { + IP: netip.MustParseAddr("2001:db8:2::10"), + HWAddr: net.HardwareAddr{0x20, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa}, + Expiry: time.Now().Add(30 * time.Minute), + }, { + IP: netip.MustParseAddr("2001:db8:ffff::42"), + HWAddr: net.HardwareAddr{0x30, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa}, + IsStatic: true, + }}, + } + + s.setTrackedRangeStart(net.ParseIP("2001:db8:1::10"), []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }, { + Prefix: netip.MustParsePrefix("2001:db8:2::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }}) + + require.Len(t, s.leases, 3) + assert.Empty(t, notified) + assert.Equal(t, byte(1), s.ipAddrs[0x10]) + assert.Equal(t, byte(0), s.ipAddrs[0x42]) + + s.setTrackedRangeStart(net.ParseIP("2001:db8:1::10"), []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }}) + + require.Len(t, s.leases, 2) + assert.Equal(t, []uint32{LeaseChangedDBStore}, notified) +} + +func TestV6Create_InterfacePrefixSource(t *testing.T) { + t.Run("ra_only_without_template", func(t *testing.T) { + srv, err := v6Create(V6ServerConf{ + Enabled: true, + PrefixSource: V6PrefixSourceInterface, + RASLAACOnly: true, + notify: notify6, + }) + require.NoError(t, err) + + s, ok := srv.(*v6Server) + require.True(t, ok) + assert.Nil(t, s.conf.ipStart) + }) + + t.Run("dhcp_pool_requires_template", func(t *testing.T) { + _, err := v6Create(V6ServerConf{ + Enabled: true, + PrefixSource: V6PrefixSourceInterface, + notify: notify6, + }) + require.Error(t, err) + }) +} + +func TestV6TrackedPrefixChanged_SLAACOnlyUpdatesMetadata(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + PrefixSource: V6PrefixSourceInterface, + RASLAACOnly: true, + notify: notify6, + }, + } + + err := s.trackedPrefixChanged(&raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }, []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }}) + require.NoError(t, err) + assert.Nil(t, s.conf.ipStart) + assert.Contains(t, s.advertisedPrefixes, netip.MustParsePrefix("2001:db8::/64")) + assert.Contains(t, s.renewablePrefixes, netip.MustParsePrefix("2001:db8::/64")) +} + +func TestRequiresProcessSuccess(t *testing.T) { + assert.True(t, requiresProcessSuccess(dhcpv6.MessageTypeSolicit)) + assert.True(t, requiresProcessSuccess(dhcpv6.MessageTypeRenew)) + assert.False(t, requiresProcessSuccess(dhcpv6.MessageTypeRelease)) + assert.False(t, requiresProcessSuccess(dhcpv6.MessageTypeInformationRequest)) +} + +func TestReplyStatusForProcessFailure(t *testing.T) { + code, msg, ok := replyStatusForProcessFailure(dhcpv6.MessageTypeConfirm) + require.True(t, ok) + assert.Equal(t, iana.StatusNotOnLink, code) + assert.Equal(t, iana.StatusNotOnLink.String(), msg) + + code, msg, ok = replyStatusForProcessFailure(dhcpv6.MessageTypeRenew) + require.True(t, ok) + assert.Equal(t, iana.StatusNoBinding, code) + assert.Equal(t, iana.StatusNoBinding.String(), msg) + + _, _, ok = replyStatusForProcessFailure(dhcpv6.MessageTypeRelease) + assert.False(t, ok) +} + +func TestV6Create_StaticPrefixSeedsRenewablePrefixes(t *testing.T) { + srv, err := v6Create(V6ServerConf{ + Enabled: true, + PrefixSource: V6PrefixSourceStatic, + RangeStart: net.ParseIP("2001:db8::10"), + notify: notify6, + }) + require.NoError(t, err) + + s, ok := srv.(*v6Server) + require.True(t, ok) + prefix := netip.MustParsePrefix("2001:db8::/64") + _, ok = s.renewablePrefixes[prefix] + assert.True(t, ok) + _, ok = s.advertisedPrefixes[prefix] + assert.True(t, ok) +} + +func TestV6ResetLeases_PreservesAdvertisedInterfacePrefixes(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + PrefixSource: V6PrefixSourceInterface, + ipStart: net.ParseIP("2001:db8:1::10"), + }, + advertisedPrefixes: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8::/64"): {}, + netip.MustParsePrefix("2001:db8:1::/64"): {}, + }, + } + + err := s.ResetLeases([]*dhcpsvc.Lease{{ + IP: netip.MustParseAddr("2001:db8::10"), + HWAddr: net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa}, + }, { + IP: netip.MustParseAddr("2001:db8:1::10"), + HWAddr: net.HardwareAddr{0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb}, + }, { + IP: netip.MustParseAddr("2001:db8:2::10"), + HWAddr: net.HardwareAddr{0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc}, + }}) + require.NoError(t, err) + + require.Len(t, s.leases, 2) + assert.Contains(t, []netip.Addr{s.leases[0].IP, s.leases[1].IP}, netip.MustParseAddr("2001:db8::10")) + assert.Contains(t, []netip.Addr{s.leases[0].IP, s.leases[1].IP}, netip.MustParseAddr("2001:db8:1::10")) +} + +func TestObservedDNSIPAddrs(t *testing.T) { + addrs := observedDNSIPAddrs([]aghnet.IPv6AddrState{{ + Addr: netip.MustParseAddr("fe80::1"), + PreferredLifetimeSec: math.MaxUint32, + ValidLifetimeSec: math.MaxUint32, + }, { + Addr: netip.MustParseAddr("2001:db8::10"), + PreferredLifetimeSec: 1800, + ValidLifetimeSec: 3600, + }, { + Addr: netip.MustParseAddr("2001:db8::20"), + Tentative: true, + }}) + + require.Len(t, addrs, 2) + assert.Equal(t, net.ParseIP("fe80::1"), addrs[0]) + assert.Equal(t, net.ParseIP("2001:db8::10"), addrs[1]) +} + +func TestObservedDNSIPAddrs_FiltersDeprecatedGlobals(t *testing.T) { + addrs := observedDNSIPAddrs([]aghnet.IPv6AddrState{{ + Addr: netip.MustParseAddr("fe80::1"), + PreferredLifetimeSec: math.MaxUint32, + ValidLifetimeSec: math.MaxUint32, + }, { + Addr: netip.MustParseAddr("2001:db8::10"), + PreferredLifetimeSec: 0, + ValidLifetimeSec: 3600, + }, { + Addr: netip.MustParseAddr("2001:db8::20"), + PreferredLifetimeSec: 1200, + ValidLifetimeSec: 3600, + }}) + + require.Len(t, addrs, 2) + assert.Equal(t, net.ParseIP("fe80::1"), addrs[0]) + assert.Equal(t, net.ParseIP("2001:db8::20"), addrs[1]) +} + +func TestV6FindUsableLease_PrefersCurrentPoolWithoutRequestedDeprecatedIP(t *testing.T) { + mac := net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa} + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8:1::10"), + }, + advertisedPrefixes: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8::/64"): {}, + netip.MustParsePrefix("2001:db8:1::/64"): {}, + }, + leases: []*dhcpsvc.Lease{{ + IP: netip.MustParseAddr("2001:db8::10"), + HWAddr: mac, + }, { + IP: netip.MustParseAddr("2001:db8:1::10"), + HWAddr: mac, + }}, + } + + msg, err := dhcpv6.NewMessage() + require.NoError(t, err) + msg.MessageType = dhcpv6.MessageTypeRenew + + lease := s.findUsableLease(msg, mac) + require.NotNil(t, lease) + assert.Equal(t, netip.MustParseAddr("2001:db8:1::10"), lease.IP) +} + +func TestV6FindUsableLease_MatchesRequestedDeprecatedLease(t *testing.T) { + mac := net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa} + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8:1::10"), + }, + advertisedPrefixes: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8::/64"): {}, + netip.MustParsePrefix("2001:db8:1::/64"): {}, + }, + leases: []*dhcpsvc.Lease{{ + IP: netip.MustParseAddr("2001:db8::10"), + HWAddr: mac, + }, { + IP: netip.MustParseAddr("2001:db8:1::10"), + HWAddr: mac, + }}, + } + + msg, err := dhcpv6.NewMessage() + require.NoError(t, err) + msg.MessageType = dhcpv6.MessageTypeRenew + msg.AddOption(&dhcpv6.OptIANA{ + Options: dhcpv6.IdentityOptions{ + Options: []dhcpv6.Option{&dhcpv6.OptIAAddress{ + IPv6Addr: net.ParseIP("2001:db8::10"), + }}, + }, + }) + + lease := s.findUsableLease(msg, mac) + require.NotNil(t, lease) + assert.Equal(t, netip.MustParseAddr("2001:db8::10"), lease.IP) +} + +func TestV6FindUsableLease_MatchesRequestedDeprecatedLeaseOnRequest(t *testing.T) { + mac := net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa} + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8:1::10"), + }, + advertisedPrefixes: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8::/64"): {}, + netip.MustParsePrefix("2001:db8:1::/64"): {}, + }, + leases: []*dhcpsvc.Lease{{ + IP: netip.MustParseAddr("2001:db8::10"), + HWAddr: mac, + Expiry: time.Now().Add(10 * time.Minute), + }}, + } + + msg, err := dhcpv6.NewMessage() + require.NoError(t, err) + msg.MessageType = dhcpv6.MessageTypeRequest + msg.AddOption(&dhcpv6.OptIANA{ + Options: dhcpv6.IdentityOptions{ + Options: []dhcpv6.Option{&dhcpv6.OptIAAddress{ + IPv6Addr: net.ParseIP("2001:db8::10"), + }}, + }, + }) + + lease := s.findUsableLease(msg, mac) + require.NotNil(t, lease) + assert.Equal(t, netip.MustParseAddr("2001:db8::10"), lease.IP) +} + +func TestV6FindUsableLease_SkipsExpiredDeprecatedLease(t *testing.T) { + mac := net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa} + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8:1::10"), + }, + advertisedPrefixes: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8::/64"): {}, + netip.MustParsePrefix("2001:db8:1::/64"): {}, + }, + leases: []*dhcpsvc.Lease{{ + IP: netip.MustParseAddr("2001:db8::10"), + HWAddr: mac, + Expiry: time.Now().Add(-time.Minute), + }, { + IP: netip.MustParseAddr("2001:db8:1::10"), + HWAddr: mac, + Expiry: time.Now().Add(10 * time.Minute), + }}, + } + + msg, err := dhcpv6.NewMessage() + require.NoError(t, err) + msg.MessageType = dhcpv6.MessageTypeRenew + msg.AddOption(&dhcpv6.OptIANA{ + Options: dhcpv6.IdentityOptions{ + Options: []dhcpv6.Option{&dhcpv6.OptIAAddress{ + IPv6Addr: net.ParseIP("2001:db8::10"), + }}, + }, + }) + + lease := s.findUsableLease(msg, mac) + require.NotNil(t, lease) + assert.Equal(t, netip.MustParseAddr("2001:db8:1::10"), lease.IP) +} + +func TestV6ReserveLease_PreservesDeprecatedLeaseForSameMAC(t *testing.T) { + mac := net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa} + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8:1::10"), + }, + leases: []*dhcpsvc.Lease{{ + IP: netip.MustParseAddr("2001:db8::10"), + HWAddr: mac, + Expiry: time.Now().Add(10 * time.Minute), + }}, + } + + lease := s.reserveLease(mac) + require.NotNil(t, lease) + assert.Equal(t, netip.MustParseAddr("2001:db8:1::10"), lease.IP) + require.Len(t, s.leases, 2) + assert.Contains(t, []netip.Addr{s.leases[0].IP, s.leases[1].IP}, netip.MustParseAddr("2001:db8::10")) + assert.Contains(t, []netip.Addr{s.leases[0].IP, s.leases[1].IP}, netip.MustParseAddr("2001:db8:1::10")) +} + +func TestV6CommitLease_DeprecatedDynamicLeaseKeepsRemainingLifetime(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8:1::10"), + leaseTime: time.Hour, + }, + validUntilByPrefix: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): time.Now().Add(10 * time.Minute), + }, + } + + lease := &dhcpsvc.Lease{ + IP: netip.MustParseAddr("2001:db8::10"), + Expiry: time.Now().Add(10 * time.Minute), + } + + msg, err := dhcpv6.NewMessage() + require.NoError(t, err) + msg.MessageType = dhcpv6.MessageTypeRenew + + lifetime := s.commitLease(msg, lease) + assert.Greater(t, lifetime, 9*time.Minute) + assert.LessOrEqual(t, lifetime, 10*time.Minute) +} + +func TestV6CommitLease_DeprecatedLeaseCappedByPrefixValidLifetime(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8:1::10"), + leaseTime: time.Hour, + }, + validUntilByPrefix: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): time.Now().Add(2 * time.Minute), + }, + } + + lease := &dhcpsvc.Lease{ + IP: netip.MustParseAddr("2001:db8::10"), + Expiry: time.Now().Add(24 * time.Hour), + } + + msg, err := dhcpv6.NewMessage() + require.NoError(t, err) + msg.MessageType = dhcpv6.MessageTypeRenew + + lifetime := s.commitLease(msg, lease) + assert.Greater(t, lifetime, time.Minute) + assert.LessOrEqual(t, lifetime, 2*time.Minute) +} + +func TestV6CommitLease_DeprecatedConfirmCappedByPrefixValidLifetime(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8:1::10"), + leaseTime: time.Hour, + }, + validUntilByPrefix: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): time.Now().Add(90 * time.Second), + }, + } + + lease := &dhcpsvc.Lease{ + IP: netip.MustParseAddr("2001:db8::10"), + Expiry: time.Now().Add(24 * time.Hour), + } + + msg, err := dhcpv6.NewMessage() + require.NoError(t, err) + msg.MessageType = dhcpv6.MessageTypeConfirm + + lifetime := s.commitLease(msg, lease) + assert.Greater(t, lifetime, time.Minute) + assert.LessOrEqual(t, lifetime, 90*time.Second) +} + +func TestV6CommitLease_StaticConfirmUsesConfiguredLeaseTime(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + leaseTime: 24 * time.Hour, + }, + } + + lease := &dhcpsvc.Lease{ + IP: netip.MustParseAddr("2001:db8::10"), + HWAddr: net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa}, + IsStatic: true, + } + + msg, err := dhcpv6.NewMessage() + require.NoError(t, err) + msg.MessageType = dhcpv6.MessageTypeConfirm + + lifetime := s.commitLease(msg, lease) + assert.Equal(t, 24*time.Hour, lifetime) +} + +func TestV6PreferredLeaseLifetime_DeprecatedLeaseUsesZeroPreferredLifetime(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8:1::10"), + }, + advertisedPrefixes: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8::/64"): {}, + }, + } + + lease := &dhcpsvc.Lease{ + IP: netip.MustParseAddr("2001:db8::10"), + HWAddr: net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa}, + Expiry: time.Now().Add(10 * time.Minute), + } + + preferred := s.preferredLeaseLifetime(lease, 10*time.Minute) + assert.Equal(t, time.Duration(0), preferred) +} + +func TestV6CommitLease_SecondaryRenewablePrefixGetsFullLifetime(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + leaseTime: time.Hour, + notify: notify6, + }, + validUntilByPrefix: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8:2::/64"): time.Now().Add(30 * time.Minute), + }, + renewablePrefixes: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8:2::/64"): {}, + }, + } + + lease := &dhcpsvc.Lease{ + IP: netip.MustParseAddr("2001:db8:2::10"), + Expiry: time.Now().Add(10 * time.Minute), + } + + msg, err := dhcpv6.NewMessage() + require.NoError(t, err) + msg.MessageType = dhcpv6.MessageTypeRenew + + lifetime := s.commitLease(msg, lease) + assert.Greater(t, lifetime, 29*time.Minute) + assert.LessOrEqual(t, lifetime, 30*time.Minute) +} + +func TestV6RestoreDeprecatedPrefixes(t *testing.T) { + now := time.Unix(400, 0) + s := &v6Server{ + restoredRenewable: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8:1::/64"): {}, + }, + restoredDeprecated: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): now.Add(90 * time.Minute), + }, + } + + st := newObservedRAState() + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 7200, + }, + }, now) + + s.restoreDeprecatedPrefixes(now, &st) + + pios := st.pios(now) + require.Len(t, pios, 2) + assert.Equal(t, netip.MustParsePrefix("2001:db8:1::/64"), pios[0].Prefix) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), pios[1].Prefix) + assert.Equal(t, uint32(0), pios[1].PreferredSec) + assert.Equal(t, uint32(5400), pios[1].ValidSec) +} + +func TestV6RestoreDeprecatedPrefixes_IgnoresUnrelatedMetadata(t *testing.T) { + now := time.Unix(400, 0) + s := &v6Server{ + restoredRenewable: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8:1::/64"): {}, + }, + restoredDeprecated: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): now.Add(90 * time.Minute), + }, + } + + st := newObservedRAState() + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:2::/64"), + PreferredSec: 1800, + ValidSec: 7200, + }, + }, now) + + s.restoreDeprecatedPrefixes(now, &st) + + pios := st.pios(now) + require.Len(t, pios, 1) + assert.Equal(t, netip.MustParsePrefix("2001:db8:2::/64"), pios[0].Prefix) +} + +func TestV6RestoreDeprecatedPrefixes_RestoresAfterDelayedOverlap(t *testing.T) { + now := time.Unix(400, 0) + s := &v6Server{ + restoredRenewable: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8:1::/64"): {}, + }, + restoredDeprecated: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): now.Add(90 * time.Minute), + }, + } + + st := newObservedRAState() + st.merge(raObservation{}, now) + s.restoreDeprecatedPrefixes(now, &st) + require.Empty(t, st.pios(now)) + + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 7200, + }, + }, now.Add(time.Minute)) + s.restoreDeprecatedPrefixes(now.Add(time.Minute), &st) + + pios := st.pios(now.Add(time.Minute)) + require.Len(t, pios, 2) + assert.Equal(t, netip.MustParsePrefix("2001:db8:1::/64"), pios[0].Prefix) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), pios[1].Prefix) +} + +func TestV6RestoreDeprecatedPrefixes_WithoutRenewablePrefixesNeedsObservedPrefixState(t *testing.T) { + now := time.Unix(400, 0) + s := &v6Server{ + restoredDeprecated: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): now.Add(30 * time.Minute), + }, + } + + st := newObservedRAState() + st.merge(raObservation{}, now) + + s.restoreDeprecatedPrefixes(now, &st) + + pios := st.pios(now) + require.Empty(t, pios) +} + +func TestV6RestoreDeprecatedPrefixes_RequiresFullRenewableMatch(t *testing.T) { + now := time.Unix(400, 0) + s := &v6Server{ + restoredRenewable: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("fd00::/64"): {}, + netip.MustParsePrefix("2001:db8:1::/64"): {}, + }, + restoredDeprecated: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): now.Add(30 * time.Minute), + }, + } + + st := newObservedRAState() + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("fd00::/64"), + PreferredSec: 1800, + ValidSec: 7200, + }, + }, now) + + s.restoreDeprecatedPrefixes(now, &st) + + pios := st.pios(now) + require.Len(t, pios, 1) + assert.Equal(t, netip.MustParsePrefix("fd00::/64"), pios[0].Prefix) +} + +func TestV6DeprecatedPrefixMeta_FallsBackToRestoredMetadata(t *testing.T) { + now := time.Unix(500, 0) + s := &v6Server{ + conf: V6ServerConf{ + PrefixSource: V6PrefixSourceInterface, + }, + restoredRenewable: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8:1::/64"): {}, + }, + restoredDeprecated: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): now.Add(30 * time.Minute), + }, + persistRestoredMeta: true, + } + + renewable, deprecated := s.deprecatedPrefixMeta(now) + assert.Equal(t, map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8:1::/64"): {}, + }, renewable) + assert.Equal(t, map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): now.Add(30 * time.Minute), + }, deprecated) +} + +func TestV6DeprecatedPrefixMeta_DropsFallbackAfterObservation(t *testing.T) { + now := time.Unix(500, 0) + s := &v6Server{ + conf: V6ServerConf{ + PrefixSource: V6PrefixSourceInterface, + }, + restoredRenewable: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8:1::/64"): {}, + }, + restoredDeprecated: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): now.Add(30 * time.Minute), + }, + persistRestoredMeta: true, + } + + st := newObservedRAState() + st.merge(raObservation{}, now) + s.restoreDeprecatedPrefixes(now, &st) + + renewable, deprecated := s.deprecatedPrefixMeta(now) + assert.Empty(t, renewable) + assert.Empty(t, deprecated) +} + +func TestV6SetTrackedRangeStart_RefreshesValidUntilForSamePrefixes(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8:1::10"), + }, + advertisedPrefixes: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8::/64"): {}, + }, + renewablePrefixes: map[netip.Prefix]struct{}{}, + } + + s.setTrackedRangeStart(net.ParseIP("2001:db8:1::10"), []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 600, + }}) + firstDeadline := s.validUntilByPrefix[netip.MustParsePrefix("2001:db8::/64")] + + s.setTrackedRangeStart(net.ParseIP("2001:db8:1::10"), []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 120, + }}) + secondDeadline := s.validUntilByPrefix[netip.MustParsePrefix("2001:db8::/64")] + + assert.True(t, secondDeadline.Before(firstDeadline.Add(-4*time.Minute))) +} + +func TestV6SetTrackedRangeStart_ClampsDeprecatedLeaseExpiry(t *testing.T) { + now := time.Now() + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8:1::10"), + notify: notify6, + }, + leases: []*dhcpsvc.Lease{{ + IP: netip.MustParseAddr("2001:db8::10"), + HWAddr: net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa}, + Expiry: now.Add(24 * time.Hour), + }}, + } + + s.setTrackedRangeStart(net.ParseIP("2001:db8:1::10"), []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 120, + }, { + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }}) + + require.Len(t, s.leases, 1) + assert.LessOrEqual(t, time.Until(s.leases[0].Expiry), 2*time.Minute) + assert.Greater(t, time.Until(s.leases[0].Expiry), time.Minute) +} + +func TestV6SetTrackedRangeStart_MetadataOnlyChangeNotifiesDBStore(t *testing.T) { + var notified []uint32 + + s := &v6Server{ + conf: V6ServerConf{ + PrefixSource: V6PrefixSourceInterface, + RASLAACOnly: true, + notify: func(flags uint32) { + notified = append(notified, flags) + }, + }, + } + + s.setTrackedRangeStart(nil, []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 300, + }}) + + assert.Equal(t, []uint32{LeaseChangedDBStore}, notified) +} + +func TestV6SetTrackedRangeStart_ClampsExpiryWhenLifetimesShrinkInPlace(t *testing.T) { + now := time.Now() + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8:1::10"), + notify: notify6, + }, + advertisedPrefixes: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8::/64"): {}, + }, + renewablePrefixes: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8::/64"): {}, + }, + leases: []*dhcpsvc.Lease{{ + IP: netip.MustParseAddr("2001:db8::10"), + HWAddr: net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa}, + Expiry: now.Add(24 * time.Hour), + }}, + } + + s.setTrackedRangeStart(net.ParseIP("2001:db8:1::10"), []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 300, + ValidSec: 300, + }}) + + require.Len(t, s.leases, 1) + assert.LessOrEqual(t, time.Until(s.leases[0].Expiry), 5*time.Minute) + assert.Greater(t, time.Until(s.leases[0].Expiry), 4*time.Minute) +} + +func TestV6SetTrackedRangeStart_FiltersLeasesBelowNewHostTemplate(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8::10"), + notify: notify6, + }, + leases: []*dhcpsvc.Lease{{ + IP: netip.MustParseAddr("2001:db8::20"), + HWAddr: net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa}, + Expiry: time.Now().Add(time.Hour), + }, { + IP: netip.MustParseAddr("2001:db8::90"), + HWAddr: net.HardwareAddr{0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb}, + Expiry: time.Now().Add(time.Hour), + }}, + } + + s.setTrackedRangeStart(net.ParseIP("2001:db8::80"), []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }}) + + require.Len(t, s.leases, 1) + assert.Equal(t, netip.MustParseAddr("2001:db8::90"), s.leases[0].IP) +} diff --git a/internal/home/config.go b/internal/home/config.go index 1c56ff00ebc..5aed35e5476 100644 --- a/internal/home/config.go +++ b/internal/home/config.go @@ -595,6 +595,7 @@ var config = &configuration{ }, Conf6: dhcpd.V6ServerConf{ LeaseDuration: dhcpd.DefaultDHCPLeaseTTL, + PrefixSource: dhcpd.V6PrefixSourceStatic, }, }, Clients: &clientsConfig{ diff --git a/openapi/next.yaml b/openapi/next.yaml index a7fe8ebede7..4bae9e71085 100644 --- a/openapi/next.yaml +++ b/openapi/next.yaml @@ -2021,6 +2021,7 @@ 'ipv4_range_end': '192.168.1.101' 'ipv4_range_start': '192.168.1.2' 'ipv4_subnet_mask': '255.255.255.0' + 'ipv6_prefix_source': 'static' 'ipv6_range_start': '2001:db8::1' 'ipv6_lease_duration': 86400000 'required': @@ -2037,6 +2038,7 @@ 'ipv4_range_end': '192.168.1.101' 'ipv4_range_start': '192.168.1.2' 'ipv4_subnet_mask': '255.255.255.0' + 'ipv6_prefix_source': 'static' 'properties': 'enabled': 'description': > @@ -2070,9 +2072,17 @@ 'description': > The duration of the IPv6 lease, in milliseconds. 'type': 'number' + 'ipv6_prefix_source': + 'description': > + The source of the IPv6 prefix. `static` keeps + `ipv6_range_start` as the configured prefix. `interface` derives + the prefix from the network interface and treats + `ipv6_range_start` as a host template for the dynamic pool. + 'type': 'string' 'ipv6_range_start': 'description': > - The start of the IPv6 addresses to serve to clients. + The start of the IPv6 addresses to serve to clients, or a host + template when `ipv6_prefix_source` is `interface`. 'type': 'string' 'type': 'object' diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 10bad65fbb0..0738845d247 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2051,7 +2051,17 @@ 'DhcpConfigV6': 'type': 'object' 'properties': + 'prefix_source': + 'description': > + The source of the IPv6 prefix. `static` keeps `range_start` as the + configured prefix. `interface` derives the prefix from the network + interface and treats `range_start` as a host template for the + dynamic pool. + 'type': 'string' 'range_start': + 'description': > + The first IPv6 address for dynamic leases, or a host template when + `prefix_source` is `interface`. 'type': 'string' 'lease_duration': 'type': 'integer' From 2ebbeb2867b2f63155c56af3ad555ec57cbda2e4 Mon Sep 17 00:00:00 2001 From: Omoeba <38597972+Omoeba@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:49:16 -0700 Subject: [PATCH 2/3] Refine DHCPv6 interface prefix tracking --- .../components/Settings/Dhcp/FormDHCPv6.tsx | 19 +- client/src/components/Settings/Dhcp/index.tsx | 25 +- internal/aghnet/prefix_linux.go | 10 + internal/dhcpd/config.go | 4 - internal/dhcpd/http_unix.go | 35 +- internal/dhcpd/http_unix_internal_test.go | 64 +++- internal/dhcpd/routeradv.go | 87 ++--- internal/dhcpd/routeradv_internal_test.go | 184 +++++++--- internal/dhcpd/routeradv_state.go | 174 +++++++++- .../dhcpd/routeradv_state_internal_test.go | 137 ++++++++ internal/dhcpd/v6_unix.go | 318 ++++++++++-------- internal/dhcpd/v6_unix_internal_test.go | 276 ++++++++++++++- openapi/next.yaml | 3 + openapi/openapi.yaml | 3 + 14 files changed, 1024 insertions(+), 315 deletions(-) diff --git a/client/src/components/Settings/Dhcp/FormDHCPv6.tsx b/client/src/components/Settings/Dhcp/FormDHCPv6.tsx index 6794156452c..bdcb2f1db67 100644 --- a/client/src/components/Settings/Dhcp/FormDHCPv6.tsx +++ b/client/src/components/Settings/Dhcp/FormDHCPv6.tsx @@ -8,7 +8,7 @@ import { 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'; @@ -36,19 +36,12 @@ 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 isRASLAACOnly = watch('v6.ra_slaac_only'); const canConfigureV6 = Boolean(isInterfaceIncludesIpv6) || prefixSource === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE; - const isInterfaceRASLAACOnly = ( - prefixSource === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE - && isRASLAACOnly - ); - const shouldRequireRangeStart = !isInterfaceRASLAACOnly; - const shouldRequireLeaseDuration = !isInterfaceRASLAACOnly; const formValues = watch('v6'); - const isEmptyConfig = !Object.entries(formValues || {}).some( - ([key, value]) => key !== 'prefix_source' && Boolean(value), - ); + const isInterfaceRASLAACOnly = isInterfaceSLAACOnlyV6Config(formValues); + const isRequiredForPool = !isInterfaceRASLAACOnly; + const isEmptyConfig = !hasMeaningfulV6Value(formValues); const isDisabled = useMemo(() => { return isSubmitting || !isValid || processingConfig || !canConfigureV6 || isEmptyConfig; @@ -99,7 +92,7 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit } validate: canConfigureV6 ? { ipv6: validateIpv6, - required: shouldRequireRangeStart + required: isRequiredForPool ? validateRequiredValue : undefined, } @@ -157,7 +150,7 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit } rules={{ validate: canConfigureV6 ? { - required: shouldRequireLeaseDuration + required: isRequiredForPool ? validateRequiredValue : undefined, } diff --git a/client/src/components/Settings/Dhcp/index.tsx b/client/src/components/Settings/Dhcp/index.tsx index 774082dc109..2fd1208b06e 100644 --- a/client/src/components/Settings/Dhcp/index.tsx +++ b/client/src/components/Settings/Dhcp/index.tsx @@ -93,24 +93,23 @@ const DEFAULT_V6_VALUES = { lease_duration: undefined, }; -const isInterfaceSLAACOnlyV6Config = (v6Config: IPv6FormValues) => ( - v6Config?.prefix_source === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE - && v6Config?.ra_slaac_only -); +export const isInterfaceSLAACOnlyV6Config = (v6Config: IPv6FormValues) => + v6Config?.prefix_source === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE && Boolean(v6Config?.ra_slaac_only); -const hasMeaningfulV6Value = (v6Config: IPv6FormValues) => +// 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]) => ( - key !== 'prefix_source' - && key !== 'ra_slaac_only' - && Boolean(value) - ))); + || Object.entries(v6Config || {}).some( + ([key, value]) => !V6_CONFIG_METADATA_KEYS.has(key) && Boolean(value), + ); const isFilledV6Config = (v6Config: IPv6FormValues) => Object.entries(v6Config || {}).every(([key, value]) => ( - key === 'prefix_source' - || key === 'ra_slaac_only' + V6_CONFIG_METADATA_KEYS.has(key) || ( (key === 'range_start' || key === 'lease_duration') && isInterfaceSLAACOnlyV6Config(v6Config) diff --git a/internal/aghnet/prefix_linux.go b/internal/aghnet/prefix_linux.go index 35a8c312dcb..718bc1f4ab0 100644 --- a/internal/aghnet/prefix_linux.go +++ b/internal/aghnet/prefix_linux.go @@ -17,6 +17,16 @@ import ( ) // ObserveIPv6Addrs returns IPv6 interface address state for ifaceName. +// +// ctx is accepted to match the BSD implementations (which run ifconfig under +// an [executil.CommandConstructor] and can be cancelled) but is not honored +// here: syscall.NetlinkRIB is synchronous and uncancellable from outside the +// call. Wrapping it in a goroutine that selects on ctx.Done() would only +// hide a stuck kernel from the caller while leaking the blocked goroutine on +// every retry, which is strictly worse than failing fast on the caller side +// and letting the operator notice a genuinely broken environment. +// rtnetlink responds in microseconds under normal conditions, so the lack of +// cancellation is acceptable in practice. func ObserveIPv6Addrs( _ context.Context, _ *slog.Logger, diff --git a/internal/dhcpd/config.go b/internal/dhcpd/config.go index 1110cf7877e..a8aa5b4697d 100644 --- a/internal/dhcpd/config.go +++ b/internal/dhcpd/config.go @@ -281,10 +281,6 @@ type V6ServerConf struct { leaseTime time.Duration // the time during which a dynamic lease is considered valid dnsIPAddrs []net.IP // IPv6 addresses to return to DHCP clients as DNS server addresses - // skipDeprecatedLeaseRestore disables one-time restoration of deprecated - // prefixes from saved dynamic leases on startup. - skipDeprecatedLeaseRestore bool `yaml:"-" json:"-"` - // Server calls this function when leases data changes notify func(uint32) } diff --git a/internal/dhcpd/http_unix.go b/internal/dhcpd/http_unix.go index cf246945a5a..b6125ad717e 100644 --- a/internal/dhcpd/http_unix.go +++ b/internal/dhcpd/http_unix.go @@ -54,26 +54,29 @@ type v6ServerConfJSON struct { PrefixSource *V6PrefixSource `json:"prefix_source"` } +// v6JSONToServerConf returns a fresh [V6ServerConf] that holds only the +// user-facing DHCPv6 configuration. Fields absent from j fall back to the +// corresponding values in cur; internal runtime state (ipStart, dnsIPAddrs, +// leaseTime) is deliberately *not* carried over because cur is typically the +// live server configuration returned by [v6Server.WriteDiskConfig6], and +// reusing those stale values in a newly-created server would cause +// interface-mode DHCPv6 to hand out leases from the previous prefix until the +// next observation tick. func v6JSONToServerConf(j *v6ServerConfJSON, cur V6ServerConf) V6ServerConf { - if j == nil { - cur.PrefixSource = cur.NormalizedPrefixSource() - - return cur - } - prefixSource := cur.NormalizedPrefixSource() - if j.PrefixSource != nil { - prefixSource = *j.PrefixSource - } - rangeStart := cur.RangeStart - if j.RangeStart != nil { - rangeStart = j.RangeStart.AsSlice() - } - leaseDuration := cur.LeaseDuration - if j.LeaseDuration != nil { - leaseDuration = *j.LeaseDuration + + if j != nil { + if j.PrefixSource != nil { + prefixSource = *j.PrefixSource + } + if j.RangeStart != nil { + rangeStart = j.RangeStart.AsSlice() + } + if j.LeaseDuration != nil { + leaseDuration = *j.LeaseDuration + } } return V6ServerConf{ diff --git a/internal/dhcpd/http_unix_internal_test.go b/internal/dhcpd/http_unix_internal_test.go index d4c39bd2a41..65b88387181 100644 --- a/internal/dhcpd/http_unix_internal_test.go +++ b/internal/dhcpd/http_unix_internal_test.go @@ -10,11 +10,13 @@ import ( "net/http/httptest" "net/netip" "testing" + "time" "github.com/AdguardTeam/AdGuardHome/internal/agh" "github.com/AdguardTeam/AdGuardHome/internal/aghalg" "github.com/AdguardTeam/golibs/osutil/executil" "github.com/AdguardTeam/golibs/testutil" + "github.com/AdguardTeam/golibs/timeutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -113,7 +115,6 @@ func TestServer_HandleDHCPSetConfigV6_PreservesLivePrefixSource(t *testing.T) { srv, ok := srv6.(*v6Server) require.True(t, ok) assert.Equal(t, V6PrefixSourceInterface, srv.conf.PrefixSource) - assert.False(t, srv.conf.skipDeprecatedLeaseRestore) } func TestV6JSONToServerConf_PreservesOmittedFields(t *testing.T) { @@ -133,6 +134,67 @@ func TestV6JSONToServerConf_PreservesOmittedFields(t *testing.T) { assert.Equal(t, V6PrefixSourceInterface, got.PrefixSource) } +// TestV6JSONToServerConf_DropsRuntimeState is the regression test for a bug +// where the merge flow carried live runtime state (ipStart, dnsIPAddrs, +// leaseTime) from [v6Server.WriteDiskConfig6] into the next server. For +// interface mode with a transient observation failure, the new server would +// otherwise keep serving leases and DNS from the stale prefix until the +// observe ticker recovered. +func TestV6JSONToServerConf_DropsRuntimeState(t *testing.T) { + current := V6ServerConf{ + RangeStart: net.ParseIP("2001:db8::10"), + LeaseDuration: 7200, + PrefixSource: V6PrefixSourceInterface, + + // Runtime-only state that a live server would copy out via + // WriteDiskConfig6. + ipStart: net.ParseIP("2001:db8::aa"), + dnsIPAddrs: []net.IP{net.ParseIP("fe80::1")}, + leaseTime: 42 * time.Second, + } + + got := v6JSONToServerConf(nil, current) + + assert.Nil(t, got.ipStart) + assert.Nil(t, got.dnsIPAddrs) + assert.Equal(t, time.Duration(0), got.leaseTime) + // But the user-facing fields still pass through. + assert.Equal(t, net.ParseIP("2001:db8::10"), got.RangeStart) + assert.Equal(t, uint32(7200), got.LeaseDuration) + assert.Equal(t, V6PrefixSourceInterface, got.PrefixSource) +} + +// TestV6Create_ClearsRuntimeStateFromInputConf is the defense-in-depth +// regression test for the same bug at the v6Create boundary: even if a caller +// passes a V6ServerConf that still carries runtime fields, the constructed +// server must start from a clean slate so stale lease pools and DNS data do +// not leak across server recreations. +func TestV6Create_ClearsRuntimeStateFromInputConf(t *testing.T) { + srv, err := v6Create(V6ServerConf{ + Enabled: true, + PrefixSource: V6PrefixSourceInterface, + RangeStart: net.ParseIP("2001:db8::10"), + notify: notify6, + + // Deliberately poisoned runtime state from a "previous" server. + ipStart: net.ParseIP("2001:db8:dead::beef"), + dnsIPAddrs: []net.IP{net.ParseIP("fe80::dead")}, + leaseTime: 42 * time.Second, + }) + require.NoError(t, err) + + s, ok := srv.(*v6Server) + require.True(t, ok) + + // Interface mode must not inherit an ipStart; Start() will derive it + // from the first successful observation instead. + assert.Nil(t, s.conf.ipStart) + assert.Nil(t, s.conf.dnsIPAddrs) + // leaseTime is always recomputed from LeaseDuration in v6Create (the + // default when LeaseDuration==0 is one day). + assert.Equal(t, timeutil.Day, s.conf.leaseTime) +} + // handleLease is the helper function that calls handler with provided static // lease as body and returns modified response recorder. func handleLease(tb testing.TB, lease *leaseStatic, handler http.HandlerFunc) (w *httptest.ResponseRecorder) { diff --git a/internal/dhcpd/routeradv.go b/internal/dhcpd/routeradv.go index baa203b3c57..ae7efd914d5 100644 --- a/internal/dhcpd/routeradv.go +++ b/internal/dhcpd/routeradv.go @@ -74,9 +74,8 @@ type raCtx struct { // observation merge and before change detection. onStateRefresh func(now time.Time, st *raState) - state raState - lastActiveSnapshot *raPrefixSnapshot - lastAdvertised []prefixPIO + state raState + lastDigest raStateDigest cancel func() wg sync.WaitGroup @@ -203,9 +202,7 @@ func (ra *raCtx) sendingEnabled() (ok bool) { func (ra *raCtx) Init(initial raState) (err error) { ra.state = initial ra.conn = nil - now := time.Now() - ra.lastActiveSnapshot = clonePrefixSnapshot(initial.activeSnapshot(now)) - ra.lastAdvertised = clonePIOs(initial.pios(now)) + ra.lastDigest = ra.state.digest(time.Now()) if !ra.sendingEnabled() && ra.observe == nil { return nil @@ -257,18 +254,22 @@ func (ra *raCtx) ensureConn(sourceAddr netip.Addr) (err error) { } ipAndScope := sourceAddr.String() + "%" + ra.ifaceName - ra.conn, err = icmp.ListenPacket("ip6:ipv6-icmp", ipAndScope) + newConn, err := icmp.ListenPacket("ip6:ipv6-icmp", ipAndScope) if err != nil { return fmt.Errorf("dhcpv6 ra: icmp.ListenPacket: %w", err) } defer func() { if err != nil { - err = errors.WithDeferred(err, ra.conn.Close()) + // Close the new listener and make sure it is not observable + // through ra.conn after the error path runs. + err = errors.WithDeferred(err, newConn.Close()) + ra.conn = nil + ra.connSourceAddr = netip.Addr{} } }() - con6 := ra.conn.IPv6PacketConn() + con6 := newConn.IPv6PacketConn() if err = con6.SetHopLimit(255); err != nil { return fmt.Errorf("dhcpv6 ra: SetHopLimit: %w", err) } @@ -277,6 +278,7 @@ func (ra *raCtx) ensureConn(sourceAddr netip.Addr) (err error) { return fmt.Errorf("dhcpv6 ra: SetMulticastHopLimit: %w", err) } + ra.conn = newConn ra.connSourceAddr = sourceAddr return nil @@ -336,13 +338,13 @@ func (ra *raCtx) refresh(ctx context.Context) { if ra.onStateRefresh != nil { ra.onStateRefresh(now, &ra.state) } - ra.syncStateChange(now, true) + ra.syncStateChange(now) } // sendPacket rebuilds and sends the current Router Advertisement packet. func (ra *raCtx) sendPacket() { now := time.Now() - ra.syncStateChange(now, false) + ra.syncStateChange(now) sourceAddr, rdnssAddr := ra.state.sourceAndRDNSS() err := ra.ensureConn(sourceAddr) @@ -417,62 +419,21 @@ func tickerC(t *time.Ticker) (c <-chan time.Time) { return t.C } -// syncStateChange updates the DHCPv6-facing state if the active prefix or the -// advertised prefix set changed. When compareLifetimes is true, preferred and -// valid lifetime changes are treated as state changes as well. -func (ra *raCtx) syncStateChange(now time.Time, compareLifetimes bool) { - active := ra.state.activeSnapshot(now) - advertised := ra.state.pios(now) - changed := !sameActivePrefix(ra.lastActiveSnapshot, active) || - !sameAdvertisedPIOSet(ra.lastAdvertised, advertised, compareLifetimes) - - ra.lastActiveSnapshot = clonePrefixSnapshot(active) - ra.lastAdvertised = clonePIOs(advertised) +// syncStateChange notifies the DHCPv6-facing callback when the underlying RA +// state has changed in a way that is not just the ordinary countdown of +// elapsed time. The comparison uses a deadline-based digest of the tracked +// prefixes, so repeated polls that only observe the kernel counting lifetimes +// down do not spuriously fire the callback. +func (ra *raCtx) syncStateChange(now time.Time) { + digest := ra.state.digest(now) + changed := !sameRAStateDigest(ra.lastDigest, digest) + ra.lastDigest = digest if !changed || ra.onActivePrefixChange == nil { return } + active := ra.state.activeSnapshot(now) + advertised := ra.state.pios(now) ra.onActivePrefixChange(active, advertised) } - -// sameAdvertisedPIOSet reports whether a and b advertise the same set of -// prefixes. When compareLifetimes is true, preferred and valid lifetime -// changes are also treated as differences. -func sameAdvertisedPIOSet(a, b []prefixPIO, compareLifetimes bool) (ok bool) { - if len(a) != len(b) { - return false - } - - for i := range a { - if a[i].Prefix != b[i].Prefix { - return false - } - if compareLifetimes && - (a[i].PreferredSec != b[i].PreferredSec || a[i].ValidSec != b[i].ValidSec) { - return false - } - } - - return true -} - -// clonePrefixSnapshot returns a shallow clone of snap. -func clonePrefixSnapshot(snap *raPrefixSnapshot) (cloned *raPrefixSnapshot) { - if snap == nil { - return nil - } - - clone := *snap - - return &clone -} - -// clonePIOs returns a shallow clone of pios. -func clonePIOs(pios []prefixPIO) (cloned []prefixPIO) { - if pios == nil { - return nil - } - - return slices.Clone(pios) -} diff --git a/internal/dhcpd/routeradv_internal_test.go b/internal/dhcpd/routeradv_internal_test.go index 7cfbdc82196..f9b62105a30 100644 --- a/internal/dhcpd/routeradv_internal_test.go +++ b/internal/dhcpd/routeradv_internal_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/binary" "net/netip" + "slices" "testing" "time" @@ -100,19 +101,21 @@ func TestRACtxSyncStateChange_DeprecatedExpiry(t *testing.T) { var notifications [][]prefixPIO ra.onActivePrefixChange = func(_ *raPrefixSnapshot, advertised []prefixPIO) { - notifications = append(notifications, clonePIOs(advertised)) + notifications = append(notifications, slices.Clone(advertised)) } - ra.lastActiveSnapshot = clonePrefixSnapshot(ra.state.activeSnapshot(now)) - ra.lastAdvertised = clonePIOs(ra.state.pios(now)) + ra.lastDigest = ra.state.digest(now) - ra.syncStateChange(now.Add(2*time.Second), false) + ra.syncStateChange(now.Add(2 * time.Second)) require.Len(t, notifications, 1) require.Len(t, notifications[0], 1) assert.Equal(t, netip.MustParsePrefix("2001:db8:1::/64"), notifications[0][0].Prefix) } -func TestRACtxSyncStateChange_RefreshesCachedLifetimesWithoutCallback(t *testing.T) { +func TestRACtxSyncStateChange_StableStateDoesNotFireCallback(t *testing.T) { + // Countdown on a stable kernel-observed prefix must not trigger the + // downstream callback: the RA loop polls every few seconds but nothing + // is actually changing, so v6Server should not see any transitions. now := time.Unix(100, 0) ra := &raCtx{ state: newObservedRAState(), @@ -127,17 +130,23 @@ func TestRACtxSyncStateChange_RefreshesCachedLifetimesWithoutCallback(t *testing Inactive: []raPrefixSnapshot{{ Prefix: netip.MustParsePrefix("2001:db8::/64"), PreferredSec: 0, - ValidSec: 10, + ValidSec: 1200, }}, }, now) - ra.lastActiveSnapshot = clonePrefixSnapshot(ra.state.activeSnapshot(now)) - ra.lastAdvertised = clonePIOs(ra.state.pios(now)) - ra.onActivePrefixChange = nil + ra.lastDigest = ra.state.digest(now) + + var fired int + ra.onActivePrefixChange = func(_ *raPrefixSnapshot, _ []prefixPIO) { + fired++ + } - ra.syncStateChange(now.Add(3*time.Second), false) + // Three ticks without any new observation: only elapsed time changed, + // so the digest must be unchanged and the callback must not fire. + ra.syncStateChange(now.Add(1 * time.Second)) + ra.syncStateChange(now.Add(3 * time.Second)) + ra.syncStateChange(now.Add(5 * time.Second)) - require.Len(t, ra.lastAdvertised, 2) - assert.Equal(t, uint32(7), ra.lastAdvertised[1].ValidSec) + assert.Zero(t, fired) } func TestRACtxSyncStateChange_DeprecatingPrefixTriggersCallback(t *testing.T) { @@ -158,12 +167,11 @@ func TestRACtxSyncStateChange_DeprecatingPrefixTriggersCallback(t *testing.T) { ValidSec: 1200, }}, }, now) - ra.lastActiveSnapshot = clonePrefixSnapshot(ra.state.activeSnapshot(now)) - ra.lastAdvertised = clonePIOs(ra.state.pios(now)) + ra.lastDigest = ra.state.digest(now) var notifications [][]prefixPIO ra.onActivePrefixChange = func(_ *raPrefixSnapshot, advertised []prefixPIO) { - notifications = append(notifications, clonePIOs(advertised)) + notifications = append(notifications, slices.Clone(advertised)) } ra.state.merge(raObservation{ @@ -179,43 +187,100 @@ func TestRACtxSyncStateChange_DeprecatingPrefixTriggersCallback(t *testing.T) { }}, }, now.Add(time.Minute)) - ra.syncStateChange(now.Add(time.Minute), true) + ra.syncStateChange(now.Add(time.Minute)) require.Len(t, notifications, 1) require.Len(t, notifications[0], 2) assert.Equal(t, uint32(0), notifications[0][1].PreferredSec) } -func TestSameAdvertisedPIOSet_PreferredLifetimeChange(t *testing.T) { - a := []prefixPIO{{ - Prefix: netip.MustParsePrefix("2001:db8::/64"), - PreferredSec: 1800, - ValidSec: 3600, - }} - b := []prefixPIO{{ - Prefix: netip.MustParsePrefix("2001:db8::/64"), - PreferredSec: 900, - ValidSec: 3600, - }} - - assert.False(t, sameAdvertisedPIOSet(a, b, true)) - assert.True(t, sameAdvertisedPIOSet(a, b, false)) +// TestRACtxSyncStateChange_PreferredExpiryTriggersCallback is a regression +// test for a bug where a prefix whose preferred lifetime counts down from >0 +// to 0 would not fire onActivePrefixChange. The digest compared absolute +// deadlines, which stay constant during a natural countdown, so the +// transition from renewable to non-renewable was invisible to the downstream +// reconciliation path — v6Server.renewablePrefixes would stay stale and +// commitLease would keep refreshing leases on a deprecated prefix until the +// next kernel observation happened to report preferred=0. +// +// The digest now carries a time-derived preferredExpired boolean that flips +// exactly once at the transition, so the callback fires between polls. +func TestRACtxSyncStateChange_PreferredExpiryTriggersCallback(t *testing.T) { + now := time.Unix(100, 0) + ra := &raCtx{ + state: newObservedRAState(), + } + + ra.state.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 300, + ValidSec: 3600, + }, + }, now) + ra.lastDigest = ra.state.digest(now) + + var notifications [][]prefixPIO + ra.onActivePrefixChange = func(_ *raPrefixSnapshot, advertised []prefixPIO) { + notifications = append(notifications, slices.Clone(advertised)) + } + + // While the preferred countdown still has time remaining no callback + // should fire. + ra.syncStateChange(now.Add(299 * time.Second)) + assert.Empty(t, notifications) + + // At the moment the preferred lifetime hits zero the callback must + // fire so v6Server can drop the prefix from its renewable set. + ra.syncStateChange(now.Add(301 * time.Second)) + require.Len(t, notifications, 1) + require.Len(t, notifications[0], 1) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), notifications[0][0].Prefix) + assert.Equal(t, uint32(0), notifications[0][0].PreferredSec) + + // But subsequent ticks past the expiry must not re-fire: the state + // digest has settled at preferredExpired=true and stays that way. + ra.syncStateChange(now.Add(305 * time.Second)) + ra.syncStateChange(now.Add(310 * time.Second)) + assert.Len(t, notifications, 1) } -func TestSameAdvertisedPIOSet_ValidLifetimeChange(t *testing.T) { - a := []prefixPIO{{ - Prefix: netip.MustParsePrefix("2001:db8::/64"), - PreferredSec: 0, - ValidSec: 1200, - }} - b := []prefixPIO{{ - Prefix: netip.MustParsePrefix("2001:db8::/64"), - PreferredSec: 0, - ValidSec: 900, - }} - - assert.False(t, sameAdvertisedPIOSet(a, b, true)) - assert.True(t, sameAdvertisedPIOSet(a, b, false)) +// TestRACtxSyncStateChange_PreferredExpiryOnDeprecatedEntryStillQuiescent +// guards against the opposite failure: entries that are *already* deprecated +// (origin raPrefixOriginDeprecated, preferred=0 from the start) must not +// cause repeated callbacks just because their preferredExpired flag is true. +func TestRACtxSyncStateChange_PreferredExpiryOnDeprecatedEntryStillQuiescent(t *testing.T) { + now := time.Unix(100, 0) + ra := &raCtx{ + state: newObservedRAState(), + } + + // Inactive entry with preferred=0 starts as origin=Deprecated. + ra.state.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }, + Inactive: []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 1200, + }}, + }, now) + ra.lastDigest = ra.state.digest(now) + + var fired int + ra.onActivePrefixChange = func(_ *raPrefixSnapshot, _ []prefixPIO) { + fired++ + } + + // Several steady-state ticks across the valid countdown. No kernel + // state changed, so nothing should fire. + ra.syncStateChange(now.Add(1 * time.Second)) + ra.syncStateChange(now.Add(5 * time.Second)) + ra.syncStateChange(now.Add(30 * time.Second)) + assert.Zero(t, fired) } func TestRACtxInit_AllowsNoSourceWhenObserving(t *testing.T) { @@ -229,3 +294,34 @@ func TestRACtxInit_AllowsNoSourceWhenObserving(t *testing.T) { require.NoError(t, err) require.NoError(t, ra.Close()) } + +// TestRACtxEnsureConn_ClearsConnOnSetupFailure is a regression test for a bug +// where a partially-initialized icmp listener was closed by the error-path +// defer but ra.conn was left pointing to it. The next call into ensureConn +// would then observe a non-nil but closed conn and attempt to use or re-close +// it, permanently stalling the RA loop. +// +// The scenario is hard to force without actually raising an error from a real +// icmp listener, so this test exercises the invariant directly: after an +// ensureConn failure the connection fields must be cleared so a subsequent +// call can attempt a fresh listen. +func TestRACtxEnsureConn_ClearsConnOnSetupFailure(t *testing.T) { + ra := &raCtx{ + // No valid interface name means icmp.ListenPacket will fail when + // ensureConn is called with a valid source address, triggering + // the error path we care about. + ifaceName: "", + } + + err := ra.ensureConn(netip.MustParseAddr("fe80::1")) + require.Error(t, err) + assert.Nil(t, ra.conn) + assert.False(t, ra.connSourceAddr.IsValid()) + + // A subsequent call with an invalid source address must not try to + // close a stale conn and must leave the fields cleared. + err = ra.ensureConn(netip.Addr{}) + require.NoError(t, err) + assert.Nil(t, ra.conn) + assert.False(t, ra.connSourceAddr.IsValid()) +} diff --git a/internal/dhcpd/routeradv_state.go b/internal/dhcpd/routeradv_state.go index 4ada88cfb7f..55507ec76da 100644 --- a/internal/dhcpd/routeradv_state.go +++ b/internal/dhcpd/routeradv_state.go @@ -82,6 +82,99 @@ type raActiveChange struct { Active *raPrefixSnapshot } +// raStateDigest is a lifetime-agnostic fingerprint of raState used to detect +// changes that are not just the ordinary countdown of elapsed time. Two +// observations that produce equal digests describe the same kernel state and +// should not trigger downstream reconciliation. +type raStateDigest struct { + sourceAddr netip.Addr + rdnssAddr netip.Addr + active *trackedPrefixDigest + deprecated map[netip.Prefix]trackedPrefixDigest +} + +// trackedPrefixDigest is the deadline-based fingerprint of one trackedPrefix. +// +// preferredExpired is a time-derived boolean that flips from false to true +// exactly once during a natural countdown — the moment the prefix's preferred +// lifetime reaches zero. Without it, a prefix whose preferred lifetime +// counts down while its absolute preferredUntil deadline stays put would +// produce identical digests before and after it becomes non-renewable, so +// downstream reconciliation (which rebuilds the v6Server renewablePrefixes +// set) would never fire for that transition. +type trackedPrefixDigest struct { + prefix netip.Prefix + origin raPrefixOrigin + fixedPreferredSec uint32 + fixedValidSec uint32 + preferredUntil time.Time + validUntil time.Time + preferredExpired bool +} + +// digestTrackedPrefix returns the digest for tp at now. +func digestTrackedPrefix(tp *trackedPrefix, now time.Time) trackedPrefixDigest { + preferred, _, _ := tp.remaining(now) + + return trackedPrefixDigest{ + prefix: tp.prefix, + origin: tp.origin, + fixedPreferredSec: tp.fixedPreferredSec, + fixedValidSec: tp.fixedValidSec, + preferredUntil: tp.preferredUntil, + validUntil: tp.validUntil, + preferredExpired: preferred == 0, + } +} + +// digest returns the current state digest after evicting prefixes whose +// lifetimes have already expired at now. +func (s *raState) digest(now time.Time) (d raStateDigest) { + s.evictExpired(now) + + d.sourceAddr = s.sourceAddr + d.rdnssAddr = s.rdnssAddr + if s.active != nil { + tmp := digestTrackedPrefix(s.active, now) + d.active = &tmp + } + + d.deprecated = make(map[netip.Prefix]trackedPrefixDigest, len(s.deprecated)) + for pref, tp := range s.deprecated { + d.deprecated[pref] = digestTrackedPrefix(tp, now) + } + + return d +} + +// sameRAStateDigest reports whether a and b describe the same state. +func sameRAStateDigest(a, b raStateDigest) (ok bool) { + if a.sourceAddr != b.sourceAddr || a.rdnssAddr != b.rdnssAddr { + return false + } + + if (a.active == nil) != (b.active == nil) { + return false + } + + if a.active != nil && *a.active != *b.active { + return false + } + + if len(a.deprecated) != len(b.deprecated) { + return false + } + + for pref, digest := range a.deprecated { + other, ok := b.deprecated[pref] + if !ok || other != digest { + return false + } + } + + return true +} + // newObservedRAState returns a new empty RA state for interface-derived // observations. func newObservedRAState() (st raState) { @@ -122,7 +215,7 @@ func (s *raState) merge(obs raObservation, now time.Time) (change raActiveChange switch { case obs.Active != nil && s.active != nil && s.active.prefix == obs.Active.Prefix: - s.active = newTrackedPrefix(*obs.Active, raPrefixOriginObservedActive, now) + s.active = reconcileTrackedPrefix(s.active, *obs.Active, raPrefixOriginObservedActive, now) case obs.Active != nil: s.moveActiveToDeprecated(now) s.active = newTrackedPrefix(*obs.Active, raPrefixOriginObservedActive, now) @@ -154,18 +247,28 @@ func (s *raState) merge(obs raObservation, now time.Time) (change raActiveChange continue } - s.deprecated[dep.Prefix] = newTrackedPrefix(raPrefixSnapshot{ - Prefix: dep.Prefix, - PreferredSec: 0, - ValidSec: valid, - }, raPrefixOriginDeprecated, now) + s.deprecated[dep.Prefix] = reconcileTrackedPrefix( + s.deprecated[dep.Prefix], + raPrefixSnapshot{ + Prefix: dep.Prefix, + PreferredSec: 0, + ValidSec: valid, + }, + raPrefixOriginDeprecated, + now, + ) continue } if dep.PreferredSec > 0 { observedInactive[dep.Prefix] = struct{}{} - s.deprecated[dep.Prefix] = newTrackedPrefix(dep, raPrefixOriginObservedInactive, now) + s.deprecated[dep.Prefix] = reconcileTrackedPrefix( + s.deprecated[dep.Prefix], + dep, + raPrefixOriginObservedInactive, + now, + ) continue } @@ -180,11 +283,16 @@ func (s *raState) merge(obs raObservation, now time.Time) (change raActiveChange continue } - s.deprecated[dep.Prefix] = newTrackedPrefix(raPrefixSnapshot{ - Prefix: dep.Prefix, - PreferredSec: 0, - ValidSec: valid, - }, raPrefixOriginDeprecated, now) + s.deprecated[dep.Prefix] = reconcileTrackedPrefix( + s.deprecated[dep.Prefix], + raPrefixSnapshot{ + Prefix: dep.Prefix, + PreferredSec: 0, + ValidSec: valid, + }, + raPrefixOriginDeprecated, + now, + ) } for pref, tracked := range s.deprecated { @@ -485,6 +593,48 @@ func newTrackedPrefix( return tracked } +// reconcileTrackedPrefix returns existing when its current countdown is +// consistent with the freshly observed snap; otherwise it builds a new +// tracked prefix from snap. The consistency check tolerates a one-second +// slack in either direction so that sub-second drift between the wall clock +// of successive polls and the kernel's integer-second countdown does not +// perturb the absolute deadlines and trip the state digest. +func reconcileTrackedPrefix( + existing *trackedPrefix, + snap raPrefixSnapshot, + origin raPrefixOrigin, + now time.Time, +) (tracked *trackedPrefix) { + if existing != nil && existing.prefix == snap.Prefix && existing.origin == origin { + preferred, valid, _ := existing.remaining(now) + if lifetimesConsistent(preferred, snap.PreferredSec) && + lifetimesConsistent(valid, snap.ValidSec) { + return existing + } + } + + return newTrackedPrefix(snap, origin, now) +} + +// lifetimesConsistent reports whether an existing tracked-prefix remaining +// lifetime and a freshly observed one describe the same kernel state. Both +// "infinity" values must match exactly, because an infinite lifetime is a +// qualitatively different state from any finite one; finite values may differ +// by at most one second to absorb sub-second polling jitter. +func lifetimesConsistent(existing, observed uint32) (ok bool) { + if existing == observed { + return true + } + if existing == math.MaxUint32 || observed == math.MaxUint32 { + return false + } + if existing+1 == observed || observed+1 == existing { + return true + } + + return false +} + // snapshot returns the current remaining lifetimes for p. func (p *trackedPrefix) snapshot(now time.Time) (snap *raPrefixSnapshot) { if p == nil { diff --git a/internal/dhcpd/routeradv_state_internal_test.go b/internal/dhcpd/routeradv_state_internal_test.go index e2d9fe4b1a5..2aec1bc8583 100644 --- a/internal/dhcpd/routeradv_state_internal_test.go +++ b/internal/dhcpd/routeradv_state_internal_test.go @@ -332,3 +332,140 @@ func TestRAStateMerge_DisappearingInactiveBecomesDeprecated(t *testing.T) { assert.Equal(t, uint32(0), pios[1].PreferredSec) assert.Equal(t, uint32(3540), pios[1].ValidSec) } + +// TestRAStateMerge_PreservesDeadlinesUnderSubSecondJitter is the regression +// test for a bug where raState.merge would rebuild trackedPrefix deadlines +// from "now + observed_seconds" on every observation. When "now" advances by +// a fractional second between polls, the absolute deadlines drifted even +// though the kernel state was identical, which made the state digest look +// changed and fired the v6Server reconciliation callback on every tick. +// +// The fix reconciles the existing tracked prefix against the fresh +// observation: when the remaining lifetimes are consistent (allowing a +// one-second slack), the existing pointer is kept so deadlines stay stable. +func TestRAStateMerge_PreservesDeadlinesUnderSubSecondJitter(t *testing.T) { + st := newObservedRAState() + + // First poll at a non-whole-second offset. + t1 := time.Unix(100, 500_000_000) + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }, + Inactive: []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("fd00::/64"), + PreferredSec: 600, + ValidSec: 2400, + }}, + }, t1) + + firstActive := st.active + firstInactive := st.deprecated[netip.MustParsePrefix("fd00::/64")] + require.NotNil(t, firstActive) + require.NotNil(t, firstInactive) + + firstActivePreferredUntil := firstActive.preferredUntil + firstActiveValidUntil := firstActive.validUntil + firstInactivePreferredUntil := firstInactive.preferredUntil + firstInactiveValidUntil := firstInactive.validUntil + + // Second poll 5.3 wall-clock seconds later. The kernel has decremented + // its integer counters by 5 whole seconds so the remaining lifetimes + // are still consistent with the stored deadlines modulo the sub-second + // offset. + t2 := time.Unix(105, 800_000_000) + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 1795, + ValidSec: 3595, + }, + Inactive: []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("fd00::/64"), + PreferredSec: 595, + ValidSec: 2395, + }}, + }, t2) + + // Reconciliation must keep the same trackedPrefix instance and leave + // its deadlines untouched. + require.Same(t, firstActive, st.active) + require.Same(t, firstInactive, st.deprecated[netip.MustParsePrefix("fd00::/64")]) + assert.Equal(t, firstActivePreferredUntil, st.active.preferredUntil) + assert.Equal(t, firstActiveValidUntil, st.active.validUntil) + assert.Equal(t, firstInactivePreferredUntil, st.deprecated[netip.MustParsePrefix("fd00::/64")].preferredUntil) + assert.Equal(t, firstInactiveValidUntil, st.deprecated[netip.MustParsePrefix("fd00::/64")].validUntil) + + // And the full state digests must compare equal. + d1 := raStateDigest{ + sourceAddr: netip.Addr{}, + rdnssAddr: netip.Addr{}, + active: &trackedPrefixDigest{ + prefix: netip.MustParsePrefix("2001:db8::/64"), + origin: raPrefixOriginObservedActive, + preferredUntil: firstActivePreferredUntil, + validUntil: firstActiveValidUntil, + }, + deprecated: map[netip.Prefix]trackedPrefixDigest{ + netip.MustParsePrefix("fd00::/64"): { + prefix: netip.MustParsePrefix("fd00::/64"), + origin: raPrefixOriginObservedInactive, + preferredUntil: firstInactivePreferredUntil, + validUntil: firstInactiveValidUntil, + }, + }, + } + d2 := st.digest(t2) + assert.True(t, sameRAStateDigest(d1, d2)) +} + +// TestRAStateMerge_RealLifetimeChangeStillRebuildsDeadlines ensures that the +// slack in reconcileTrackedPrefix does not hide a real kernel state change +// that happens to also be a steady countdown — the previous poll's deadline +// must be replaced when the kernel report diverges by more than one second. +func TestRAStateMerge_RealLifetimeChangeStillRebuildsDeadlines(t *testing.T) { + st := newObservedRAState() + t1 := time.Unix(100, 0) + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }, + }, t1) + + firstActive := st.active + require.NotNil(t, firstActive) + + // 5 wall-clock seconds pass but the kernel reports the lifetime jumped + // *forward* — some upstream sent a fresh RA that extended the prefix. + // The trackedPrefix must be rebuilt so the new deadline takes effect. + t2 := time.Unix(105, 0) + st.merge(raObservation{ + Active: &raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 3000, + ValidSec: 7000, + }, + }, t2) + + assert.NotSame(t, firstActive, st.active) + assert.Equal(t, t2.Add(3000*time.Second), st.active.preferredUntil) + assert.Equal(t, t2.Add(7000*time.Second), st.active.validUntil) +} + +// TestLifetimesConsistent spot-checks the slack predicate that backs +// reconcileTrackedPrefix. +func TestLifetimesConsistent(t *testing.T) { + assert.True(t, lifetimesConsistent(1800, 1800)) + assert.True(t, lifetimesConsistent(1800, 1799)) + assert.True(t, lifetimesConsistent(1799, 1800)) + assert.False(t, lifetimesConsistent(1800, 1798)) + assert.False(t, lifetimesConsistent(1798, 1800)) + assert.False(t, lifetimesConsistent(math.MaxUint32, 1800)) + assert.False(t, lifetimesConsistent(1800, math.MaxUint32)) + assert.True(t, lifetimesConsistent(math.MaxUint32, math.MaxUint32)) + assert.True(t, lifetimesConsistent(0, 0)) +} diff --git a/internal/dhcpd/v6_unix.go b/internal/dhcpd/v6_unix.go index 8c47c5315e0..562c50862c4 100644 --- a/internal/dhcpd/v6_unix.go +++ b/internal/dhcpd/v6_unix.go @@ -132,11 +132,14 @@ func (s *v6Server) ResetLeases(leases []*dhcpsvc.Lease) (err error) { s.restoredDeprecated = nil } + // Clear the occupancy bitmap along with the lease slice so that + // addresses from leases being replaced do not stay marked as used. + // addLease below re-marks the surviving entries via markLeaseOccupied. s.leases = nil + s.ipAddrs = [256]byte{} for _, l := range leases { ip := net.IP(l.IP.AsSlice()) if !l.IsStatic && !s.keepInterfaceLeaseOnReset(l.IP, ip) { - log.Debug("dhcpv6: skipping a lease with IP %v: not within current IP range", l.IP) continue @@ -373,14 +376,6 @@ func (s *v6Server) addLease(l *dhcpsvc.Lease) { log.Debug("dhcpv6: added lease %s <-> %s", l.IP, l.HWAddr) } -// ipInCurrentPool reports whether ip belongs to the currently active pool. -func (s *v6Server) ipInCurrentPool(ip netip.Addr) (ok bool) { - s.leasesLock.Lock() - defer s.leasesLock.Unlock() - - return s.ipInCurrentPoolLocked(ip) -} - // ipInCurrentPoolLocked reports whether ip belongs to the currently active // pool. s.leasesLock must be held. func (s *v6Server) ipInCurrentPoolLocked(ip netip.Addr) (ok bool) { @@ -518,13 +513,6 @@ func (s *v6Server) reserveLease(mac net.HardwareAddr) *dhcpsvc.Lease { return &l } -func (s *v6Server) commitDynamicLease(l *dhcpsvc.Lease, lifetime time.Duration) { - l.Expiry = time.Now().Add(lifetime) - - s.conf.notify(LeaseChangedDBStore) - s.conf.notify(LeaseChangedAdded) -} - // dnsIPAddrs returns the current DHCPv6 DNS server addresses. func (s *v6Server) dnsIPAddrs() (addrs []net.IP) { s.dnsIPAddrsMu.RLock() @@ -634,17 +622,6 @@ func renewableLeasePrefixes(advertised []prefixPIO) (prefixes map[netip.Prefix]s return prefixes } -// validUntilByPrefix returns the valid-lifetime deadlines for advertised /64 -// prefixes. -func validUntilByPrefix(advertised []prefixPIO, now time.Time) (deadlines map[netip.Prefix]time.Time) { - deadlines = make(map[netip.Prefix]time.Time, len(advertised)) - for _, p := range advertised { - deadlines[p.Prefix.Masked()] = deadlineFromRemaining(now, p.ValidSec) - } - - return deadlines -} - // refreshDeadlineMap updates absolute prefix deadlines while preserving // existing deadlines when the remaining lifetime has not changed. func refreshDeadlineMap( @@ -669,17 +646,6 @@ func refreshDeadlineMap( return deadlines } -// preferredUntilByPrefix returns the preferred-lifetime deadlines for -// advertised /64 prefixes. -func preferredUntilByPrefix(advertised []prefixPIO, now time.Time) (deadlines map[netip.Prefix]time.Time) { - deadlines = make(map[netip.Prefix]time.Time, len(advertised)) - for _, p := range advertised { - deadlines[p.Prefix.Masked()] = deadlineFromRemaining(now, p.PreferredSec) - } - - return deadlines -} - // leasePrefixRenewable reports whether ip belongs to an advertised prefix with // a non-zero preferred lifetime. func leasePrefixRenewable(prefixes map[netip.Prefix]struct{}, ip netip.Addr) (ok bool) { @@ -790,38 +756,109 @@ func (s *v6Server) checkIA(msg *dhcpv6.Message, lease *dhcpsvc.Lease) error { return nil } -// Store lease in DB (if necessary) and return lease life time -func (s *v6Server) commitLease(msg *dhcpv6.Message, lease *dhcpsvc.Lease) time.Duration { - lifetime := s.conf.leaseTime +// commitLease computes the valid and preferred lifetimes to grant lease in a +// reply to msg. For Request/Renew/Rebind on renewable dynamic leases it also +// updates lease.Expiry and enqueues a lease-change notification. commitLease +// acquires s.leasesLock internally; callers must not hold it. +// +// All of the per-prefix lookups (renewability, valid-until, preferred-until) +// are read under a single critical section so that a concurrent observe tick +// cannot swap the state halfway through the computation. +func (s *v6Server) commitLease( + msg *dhcpv6.Message, + lease *dhcpsvc.Lease, +) (lifetime, preferredLifetime time.Duration) { + s.leasesLock.Lock() + lifetime, preferredLifetime, shouldNotify := s.commitLeaseLocked(time.Now(), msg, lease) + s.leasesLock.Unlock() - switch msg.Type() { - case dhcpv6.MessageTypeSolicit: - // + if shouldNotify { + s.conf.notify(LeaseChangedDBStore) + s.conf.notify(LeaseChangedAdded) + } + + return lifetime, preferredLifetime +} + +// commitLeaseLocked is the locked portion of commitLease. s.leasesLock must +// be held. +func (s *v6Server) commitLeaseLocked( + now time.Time, + msg *dhcpv6.Message, + lease *dhcpsvc.Lease, +) (lifetime, preferredLifetime time.Duration, shouldNotify bool) { + leaseTime := s.conf.leaseTime + + // Snapshot the prefix-tracking state that the computations below depend + // on so they all agree about which view of the prefix we are acting + // under. + prefix := netip.PrefixFrom(lease.IP, raObservedPrefixBits).Masked() + renewable := !lease.IsStatic && leasePrefixRenewable(s.renewablePrefixes, lease.IP) + validUntil, hasValidUntil := s.validUntilByPrefix[prefix] + preferredUntil, hasPreferredUntil := s.preferredUntilByPrefix[prefix] + + // renewableLifetime is the lifetime we would grant a fresh lease, + // capped by the prefix's remaining valid lifetime when tracking data is + // available. + renewableLifetime := leaseTime + if hasValidUntil { + capped := time.Duration(remainingUntil(now, validUntil)) * time.Second + renewableLifetime = min(renewableLifetime, capped) + } + + // deprecatedLifetime is the remaining lifetime we are willing to honor + // for an existing lease whose prefix is no longer renewable. + var deprecatedLifetime time.Duration + if hasValidUntil { + validForPrefix := time.Duration(remainingUntil(now, validUntil)) * time.Second + validForLease := max(time.Until(lease.Expiry), 0) + deprecatedLifetime = min(validForLease, validForPrefix) + } + + // Default valid lifetime used for Solicit and any non-special cases. + lifetime = leaseTime + switch msg.Type() { case dhcpv6.MessageTypeConfirm: switch { case lease.IsStatic: - lifetime = s.conf.leaseTime - case s.leaseIsRenewable(lease.IP): - lifetime = min(max(time.Until(lease.Expiry), 0), s.renewableLeaseLifetime(lease.IP)) + lifetime = leaseTime + case renewable: + lifetime = min(max(time.Until(lease.Expiry), 0), renewableLifetime) default: - lifetime = s.deprecatedLeaseLifetime(lease.IP, lease.Expiry) + lifetime = deprecatedLifetime } case dhcpv6.MessageTypeRequest, dhcpv6.MessageTypeRenew, dhcpv6.MessageTypeRebind: - if !lease.IsStatic { - if s.leaseIsRenewable(lease.IP) { - lifetime = s.renewableLeaseLifetime(lease.IP) - s.commitDynamicLease(lease, lifetime) - } else { - lifetime = s.deprecatedLeaseLifetime(lease.IP, lease.Expiry) - } + switch { + case lease.IsStatic: + lifetime = leaseTime + case renewable: + lifetime = renewableLifetime + lease.Expiry = now.Add(lifetime) + shouldNotify = true + default: + lifetime = deprecatedLifetime } } - return lifetime + + // Derive preferred lifetime from the same snapshot. + switch { + case lease.IsStatic: + preferredLifetime = lifetime + case !renewable: + preferredLifetime = 0 + case hasPreferredUntil: + preferredForPrefix := time.Duration(remainingUntil(now, preferredUntil)) * time.Second + preferredLifetime = min(lifetime, preferredForPrefix) + default: + preferredLifetime = lifetime + } + + return lifetime, preferredLifetime, shouldNotify } // requestedIP returns the IPv6 address requested by msg, if any. @@ -895,76 +932,6 @@ func canServeDeprecatedLease( } } -// leaseIsRenewable reports whether a dynamic lease on ip should be refreshed -// with the full DHCP lifetime. -func (s *v6Server) leaseIsRenewable(ip netip.Addr) (ok bool) { - s.leasesLock.Lock() - defer s.leasesLock.Unlock() - - return leasePrefixRenewable(s.renewablePrefixes, ip) -} - -// renewableLeaseLifetime returns the lifetime to grant to a renewable lease on -// ip. -func (s *v6Server) renewableLeaseLifetime(ip netip.Addr) (lifetime time.Duration) { - s.leasesLock.Lock() - defer s.leasesLock.Unlock() - - pref := netip.PrefixFrom(ip, raObservedPrefixBits).Masked() - until, ok := s.validUntilByPrefix[pref] - if !ok { - return s.conf.leaseTime - } - - validForPrefix := time.Duration(remainingUntil(time.Now(), until)) * time.Second - - return min(s.conf.leaseTime, validForPrefix) -} - -// deprecatedLeaseLifetime returns the remaining valid lifetime for a -// non-renewable lease on ip, capped by the currently advertised prefix -// lifetime. -func (s *v6Server) deprecatedLeaseLifetime(ip netip.Addr, leaseExpiry time.Time) (lifetime time.Duration) { - s.leasesLock.Lock() - defer s.leasesLock.Unlock() - - pref := netip.PrefixFrom(ip, raObservedPrefixBits).Masked() - until, ok := s.validUntilByPrefix[pref] - if !ok { - return 0 - } - - validForPrefix := time.Duration(remainingUntil(time.Now(), until)) * time.Second - validForLease := max(time.Until(leaseExpiry), 0) - - return min(validForLease, validForPrefix) -} - -// preferredLeaseLifetime returns the preferred lifetime to encode in DHCPv6 -// replies for lease. -func (s *v6Server) preferredLeaseLifetime(lease *dhcpsvc.Lease, validLifetime time.Duration) time.Duration { - if !lease.IsStatic && !s.leaseIsRenewable(lease.IP) { - return 0 - } - - if lease.IsStatic { - return validLifetime - } - - s.leasesLock.Lock() - defer s.leasesLock.Unlock() - - pref := netip.PrefixFrom(lease.IP, raObservedPrefixBits).Masked() - until, ok := s.preferredUntilByPrefix[pref] - if !ok { - return validLifetime - } - - preferredForPrefix := time.Duration(remainingUntil(time.Now(), until)) * time.Second - - return min(validLifetime, preferredForPrefix) -} - // leaseNotExpired reports whether lease is still valid at the time of the // request. Zero expiries are treated as not yet committed. func leaseNotExpired(lease *dhcpsvc.Lease) (ok bool) { @@ -1023,8 +990,7 @@ func (s *v6Server) process(msg *dhcpv6.Message, req, resp dhcpv6.DHCPv6) bool { return false } - lifetime := s.commitLease(msg, lease) - preferredLifetime := s.preferredLeaseLifetime(lease, lifetime) + lifetime, preferredLifetime := s.commitLease(msg, lease) oia := &dhcpv6.OptIANA{ T1: lifetime / 2, @@ -1249,6 +1215,15 @@ func (s *v6Server) observeRAState(ctx context.Context) (obs raObservation, err e } // trackedPrefixChanged updates the effective DHCPv6 pool start from active. +// +// An active prefix whose preferred lifetime has already reached zero is +// treated as unavailable for new leases. If another advertised prefix still +// has a non-zero preferred lifetime, the pool is moved there immediately; +// otherwise the pool is set to nil so new Solicit/Request pairs cannot reserve +// addresses on a prefix we would then have to answer with a zero-lifetime +// Reply. Existing leases on deprecated prefixes are still honored via the +// deprecated-lease path in [v6Server.findUsableLease] and +// [v6Server.commitLease]. func (s *v6Server) trackedPrefixChanged(active *raPrefixSnapshot, advertised []prefixPIO) (err error) { if !s.conf.NeedsDHCPv6Pool() { s.setTrackedRangeStart(nil, advertised) @@ -1256,13 +1231,14 @@ func (s *v6Server) trackedPrefixChanged(active *raPrefixSnapshot, advertised []p return nil } - if active == nil { + poolPrefix, ok := renewablePoolPrefix(active, advertised) + if !ok { s.setTrackedRangeStart(nil, advertised) return nil } - ipStart, err := deriveTrackedRangeStart(s.conf.RangeStart, active.Prefix) + ipStart, err := deriveTrackedRangeStart(s.conf.RangeStart, poolPrefix) if err != nil { return err } @@ -1272,6 +1248,21 @@ func (s *v6Server) trackedPrefixChanged(active *raPrefixSnapshot, advertised []p return nil } +// renewablePoolPrefix returns the prefix to use for new DHCPv6 allocations. +func renewablePoolPrefix(active *raPrefixSnapshot, advertised []prefixPIO) (prefix netip.Prefix, ok bool) { + if active != nil && active.PreferredSec > 0 { + return active.Prefix, true + } + + for _, pio := range advertised { + if pio.PreferredSec > 0 { + return pio.Prefix, true + } + } + + return netip.Prefix{}, false +} + // setTrackedRangeStart updates the effective DHCPv6 pool start and removes // dynamic leases whose prefixes are no longer advertised. func (s *v6Server) setTrackedRangeStart(ipStart net.IP, advertised []prefixPIO) { @@ -1293,12 +1284,12 @@ func (s *v6Server) setTrackedRangeStart(ipStart net.IP, advertised []prefixPIO) validUntil := refreshDeadlineMap(s.validUntilByPrefix, advertised, now, func(p prefixPIO) uint32 { return p.ValidSec }) + s.conf.ipStart = bytes.Clone(ipStart) s.advertisedPrefixes = keepPrefixes s.renewablePrefixes = renewable s.preferredUntilByPrefix = preferredUntil s.validUntilByPrefix = validUntil - s.ipAddrs = [256]byte{} activePrefix := netip.Prefix{} if len(ipStart) == net.IPv6len { @@ -1307,6 +1298,14 @@ func (s *v6Server) setTrackedRangeStart(ipStart net.IP, advertised []prefixPIO) } } + // Always clear and rebuild the occupancy bitmap from the surviving + // leases. Callers such as ResetLeases replace s.leases wholesale + // without touching s.ipAddrs, and an earlier version of this function + // that skipped the rebuild when ipStart happened to be unchanged + // left stale bits from dropped leases behind, which made the pool + // appear exhausted long after those addresses had been released. + s.ipAddrs = [256]byte{} + removed := 0 updated := false leases := s.leases[:0] @@ -1394,11 +1393,26 @@ func (s *v6Server) restoreDeprecatedPrefixes(now time.Time, st *raState) { } } else if len(observedRenewable) > 0 { return - } else if len(st.pios(now)) == 0 { - return } advertised := advertisedLeasePrefixes(st.pios(now)) + if len(advertised) == 0 { + return + } + + if len(s.restoredRenewable) == 0 { + overlap := false + for pref := range advertised { + if _, ok := s.restoredDeprecated[pref]; ok { + overlap = true + break + } + } + if !overlap { + return + } + } + for pref, until := range s.restoredDeprecated { if _, ok := advertised[pref]; ok { continue @@ -1521,17 +1535,28 @@ func (s *v6Server) Start(ctx context.Context) (err error) { initial = newObservedRAState() + // Fail fast on initial-observation errors in interface mode. + // The rest of the server depends on having at least one + // observed prefix to bootstrap ipStart, advertisedPrefixes and + // the deadline maps; without them reserveLease can't allocate + // addresses and findUsableLease can't renew existing leases, + // so swallowing the error here would bring DHCPv6 up as + // "enabled" while silently refusing to hand out or renew + // anything. Transient errors are rare compared to permanent + // environment misconfigurations (missing ifconfig, netlink + // denied, wrong interface, cancelled context) and surfacing + // them at Start() is how an operator notices. obs, obsErr := s.observeRAState(ctx) if obsErr != nil { return fmt.Errorf("observing initial ipv6 prefix state: %w", obsErr) - } else { - now := time.Now() - initial.merge(obs, now) - s.restoreDeprecatedPrefixes(now, &initial) - if pios := initial.pios(now); len(pios) > 0 { - if err = s.trackedPrefixChanged(initial.activeSnapshot(now), pios); err != nil { - return fmt.Errorf("updating tracked range start: %w", err) - } + } + + now := time.Now() + initial.merge(obs, now) + s.restoreDeprecatedPrefixes(now, &initial) + if pios := initial.pios(now); len(pios) > 0 { + if err = s.trackedPrefixChanged(initial.activeSnapshot(now), pios); err != nil { + return fmt.Errorf("updating tracked range start: %w", err) } } @@ -1617,6 +1642,17 @@ func (s *v6Server) Stop() (err error) { func v6Create(conf V6ServerConf) (DHCPServer, error) { s := &v6Server{} conf.PrefixSource = conf.NormalizedPrefixSource() + + // Defense in depth: clear internal runtime fields that may have been + // populated from a previously-running server (for example via + // [v6Server.WriteDiskConfig6]). The values are (re)initialized from + // the user-facing configuration below and at Start() time, so letting + // stale values through here would allow DHCPv6 to hand out leases + // from an old prefix if interface mode is still waiting for its first + // successful observation. + conf.ipStart = nil + conf.dnsIPAddrs = nil + conf.leaseTime = 0 s.conf = conf err := conf.ValidatePrefixSource() diff --git a/internal/dhcpd/v6_unix_internal_test.go b/internal/dhcpd/v6_unix_internal_test.go index 72a154aea08..524a98d2c57 100644 --- a/internal/dhcpd/v6_unix_internal_test.go +++ b/internal/dhcpd/v6_unix_internal_test.go @@ -491,6 +491,176 @@ func TestV6TrackedPrefixChanged_SLAACOnlyUpdatesMetadata(t *testing.T) { assert.Contains(t, s.renewablePrefixes, netip.MustParsePrefix("2001:db8::/64")) } +// TestV6TrackedPrefixChanged_PreferredExpiredActiveDisablesPool is a +// regression test for a bug where the preferredExpired digest fix would fire +// onActivePrefixChange with an active snapshot whose preferred lifetime had +// already reached zero. trackedPrefixChanged used to derive ipStart from +// that prefix and leave the DHCPv6 pool pointing at it, so new clients could +// reserve an address and then receive a Reply with a zero valid lifetime +// from commitLease. The fix treats PreferredSec==0 as "no pool" even when +// the prefix is still advertised. +func TestV6TrackedPrefixChanged_PreferredExpiredActiveDisablesPool(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + PrefixSource: V6PrefixSourceInterface, + RangeStart: net.ParseIP("fd00::1234:5678:9abc:de00"), + notify: notify6, + }, + } + + err := s.trackedPrefixChanged(&raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 3600, + }, []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 3600, + }}) + require.NoError(t, err) + + assert.Nil(t, s.conf.ipStart) + assert.Empty(t, s.renewablePrefixes) + assert.Contains(t, s.advertisedPrefixes, netip.MustParsePrefix("2001:db8::/64")) + // The valid-lifetime deadline is still tracked so existing leases can + // age out against it via the deprecated-lease path in commitLease. + assert.Contains(t, s.validUntilByPrefix, netip.MustParsePrefix("2001:db8::/64")) +} + +func TestV6TrackedPrefixChanged_PreferredExpiredActiveFallsBackToRenewablePrefix(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + PrefixSource: V6PrefixSourceInterface, + RangeStart: net.ParseIP("fd00::1234:5678:9abc:de00"), + notify: notify6, + }, + } + + err := s.trackedPrefixChanged(&raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 3600, + }, []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 3600, + }, { + Prefix: netip.MustParsePrefix("2001:db8:1::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }}) + require.NoError(t, err) + + assert.Equal(t, net.ParseIP("2001:db8:1::1234:5678:9abc:de00"), s.conf.ipStart) + assert.Contains(t, s.renewablePrefixes, netip.MustParsePrefix("2001:db8:1::/64")) +} + +// TestV6SetTrackedRangeStart_RebuildsBitmapWhenPrefixUnchanged is a +// regression test for a bug where setTrackedRangeStart would skip the +// ipAddrs rebuild when the tracked prefix had not moved. In that case, +// occupancy bits for dropped leases — including leases removed by a prior +// ResetLeases that also does not touch ipAddrs — stayed marked forever, +// making the pool appear exhausted. +func TestV6SetTrackedRangeStart_RebuildsBitmapWhenPrefixUnchanged(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8::10"), + notify: notify6, + }, + } + // Simulate stale bitmap state left behind by ResetLeases: an + // occupancy bit is set for 0x10 (the only lease) and for 0x20 (a + // lease that was removed from s.leases but whose bit never got + // cleared by a previous code path). + s.ipAddrs[0x10] = 1 + s.ipAddrs[0x20] = 1 + s.leases = []*dhcpsvc.Lease{{ + IP: netip.MustParseAddr("2001:db8::10"), + HWAddr: net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa}, + Expiry: time.Now().Add(time.Hour), + }} + + // setTrackedRangeStart with the same ipStart must still rebuild the + // bitmap from s.leases, clearing the stale 0x20 bit. + s.setTrackedRangeStart(net.ParseIP("2001:db8::10"), []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 1800, + ValidSec: 3600, + }}) + + assert.Equal(t, byte(1), s.ipAddrs[0x10]) + assert.Equal(t, byte(0), s.ipAddrs[0x20]) +} + +// TestV6ResetLeases_ClearsOccupancyBitmap guards against a latent bug where +// ResetLeases replaced s.leases but left s.ipAddrs untouched. After this +// sequence a subsequent reserveLease call must be able to allocate the +// previously-occupied slot. +func TestV6ResetLeases_ClearsOccupancyBitmap(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8::10"), + leaseTime: time.Hour, + notify: notify6, + }, + } + // Two existing dynamic leases plus their bitmap bits. + s.leases = []*dhcpsvc.Lease{{ + IP: netip.MustParseAddr("2001:db8::10"), + HWAddr: net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa}, + Expiry: time.Now().Add(time.Hour), + }, { + IP: netip.MustParseAddr("2001:db8::11"), + HWAddr: net.HardwareAddr{0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb}, + Expiry: time.Now().Add(time.Hour), + }} + s.ipAddrs[0x10] = 1 + s.ipAddrs[0x11] = 1 + + // ResetLeases replaces the set with empty. The old occupancy bits + // must not survive. + err := s.ResetLeases(nil) + require.NoError(t, err) + assert.Empty(t, s.leases) + assert.Equal(t, byte(0), s.ipAddrs[0x10]) + assert.Equal(t, byte(0), s.ipAddrs[0x11]) + + // And a fresh reserve must succeed rather than hit NoAddrsAvail due + // to stranded bits. + lease := s.reserveLease(net.HardwareAddr{0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc}) + require.NotNil(t, lease) + assert.Equal(t, netip.MustParseAddr("2001:db8::10"), lease.IP) +} + +// TestV6ReserveLease_FailsWhenPreferredExpiredActiveDisabledPool pairs with +// the previous test to make sure that, after the pool is disabled, +// reserveLease refuses to hand out a fresh address (so a Solicit from a new +// client returns NoAddrsAvail instead of a zero-lifetime lease). +func TestV6ReserveLease_FailsWhenPreferredExpiredActiveDisabledPool(t *testing.T) { + s := &v6Server{ + conf: V6ServerConf{ + PrefixSource: V6PrefixSourceInterface, + RangeStart: net.ParseIP("fd00::1234:5678:9abc:de00"), + leaseTime: time.Hour, + notify: notify6, + }, + } + + err := s.trackedPrefixChanged(&raPrefixSnapshot{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 3600, + }, []prefixPIO{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 3600, + }}) + require.NoError(t, err) + + lease := s.reserveLease(net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}) + assert.Nil(t, lease) +} + func TestRequiresProcessSuccess(t *testing.T) { assert.True(t, requiresProcessSuccess(dhcpv6.MessageTypeSolicit)) assert.True(t, requiresProcessSuccess(dhcpv6.MessageTypeRenew)) @@ -773,9 +943,10 @@ func TestV6CommitLease_DeprecatedDynamicLeaseKeepsRemainingLifetime(t *testing.T require.NoError(t, err) msg.MessageType = dhcpv6.MessageTypeRenew - lifetime := s.commitLease(msg, lease) + lifetime, preferred := s.commitLease(msg, lease) assert.Greater(t, lifetime, 9*time.Minute) assert.LessOrEqual(t, lifetime, 10*time.Minute) + assert.Equal(t, time.Duration(0), preferred) } func TestV6CommitLease_DeprecatedLeaseCappedByPrefixValidLifetime(t *testing.T) { @@ -798,9 +969,10 @@ func TestV6CommitLease_DeprecatedLeaseCappedByPrefixValidLifetime(t *testing.T) require.NoError(t, err) msg.MessageType = dhcpv6.MessageTypeRenew - lifetime := s.commitLease(msg, lease) + lifetime, preferred := s.commitLease(msg, lease) assert.Greater(t, lifetime, time.Minute) assert.LessOrEqual(t, lifetime, 2*time.Minute) + assert.Equal(t, time.Duration(0), preferred) } func TestV6CommitLease_DeprecatedConfirmCappedByPrefixValidLifetime(t *testing.T) { @@ -823,9 +995,10 @@ func TestV6CommitLease_DeprecatedConfirmCappedByPrefixValidLifetime(t *testing.T require.NoError(t, err) msg.MessageType = dhcpv6.MessageTypeConfirm - lifetime := s.commitLease(msg, lease) + lifetime, preferred := s.commitLease(msg, lease) assert.Greater(t, lifetime, time.Minute) assert.LessOrEqual(t, lifetime, 90*time.Second) + assert.Equal(t, time.Duration(0), preferred) } func TestV6CommitLease_StaticConfirmUsesConfiguredLeaseTime(t *testing.T) { @@ -845,18 +1018,23 @@ func TestV6CommitLease_StaticConfirmUsesConfiguredLeaseTime(t *testing.T) { require.NoError(t, err) msg.MessageType = dhcpv6.MessageTypeConfirm - lifetime := s.commitLease(msg, lease) + lifetime, preferred := s.commitLease(msg, lease) assert.Equal(t, 24*time.Hour, lifetime) + assert.Equal(t, 24*time.Hour, preferred) } -func TestV6PreferredLeaseLifetime_DeprecatedLeaseUsesZeroPreferredLifetime(t *testing.T) { +func TestV6CommitLease_DeprecatedLeaseUsesZeroPreferredLifetime(t *testing.T) { s := &v6Server{ conf: V6ServerConf{ - ipStart: net.ParseIP("2001:db8:1::10"), + ipStart: net.ParseIP("2001:db8:1::10"), + leaseTime: time.Hour, }, advertisedPrefixes: map[netip.Prefix]struct{}{ netip.MustParsePrefix("2001:db8::/64"): {}, }, + validUntilByPrefix: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): time.Now().Add(10 * time.Minute), + }, } lease := &dhcpsvc.Lease{ @@ -865,7 +1043,11 @@ func TestV6PreferredLeaseLifetime_DeprecatedLeaseUsesZeroPreferredLifetime(t *te Expiry: time.Now().Add(10 * time.Minute), } - preferred := s.preferredLeaseLifetime(lease, 10*time.Minute) + msg, err := dhcpv6.NewMessage() + require.NoError(t, err) + msg.MessageType = dhcpv6.MessageTypeRenew + + _, preferred := s.commitLease(msg, lease) assert.Equal(t, time.Duration(0), preferred) } @@ -892,9 +1074,10 @@ func TestV6CommitLease_SecondaryRenewablePrefixGetsFullLifetime(t *testing.T) { require.NoError(t, err) msg.MessageType = dhcpv6.MessageTypeRenew - lifetime := s.commitLease(msg, lease) + lifetime, preferred := s.commitLease(msg, lease) assert.Greater(t, lifetime, 29*time.Minute) assert.LessOrEqual(t, lifetime, 30*time.Minute) + assert.Equal(t, lifetime, preferred) } func TestV6RestoreDeprecatedPrefixes(t *testing.T) { @@ -1002,6 +1185,32 @@ func TestV6RestoreDeprecatedPrefixes_WithoutRenewablePrefixesNeedsObservedPrefix require.Empty(t, pios) } +func TestV6RestoreDeprecatedPrefixes_WithoutRenewablePrefixesNeedsDeprecatedOverlap(t *testing.T) { + now := time.Unix(400, 0) + s := &v6Server{ + restoredDeprecated: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): now.Add(30 * time.Minute), + netip.MustParsePrefix("2001:db8:1::/64"): now.Add(20 * time.Minute), + }, + } + + st := newObservedRAState() + st.merge(raObservation{ + Inactive: []raPrefixSnapshot{{ + Prefix: netip.MustParsePrefix("2001:db8::/64"), + PreferredSec: 0, + ValidSec: 1200, + }}, + }, now) + + s.restoreDeprecatedPrefixes(now, &st) + + pios := st.pios(now) + require.Len(t, pios, 2) + assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), pios[0].Prefix) + assert.Equal(t, netip.MustParsePrefix("2001:db8:1::/64"), pios[1].Prefix) +} + func TestV6RestoreDeprecatedPrefixes_RequiresFullRenewableMatch(t *testing.T) { now := time.Unix(400, 0) s := &v6Server{ @@ -1214,3 +1423,54 @@ func TestV6SetTrackedRangeStart_FiltersLeasesBelowNewHostTemplate(t *testing.T) require.Len(t, s.leases, 1) assert.Equal(t, netip.MustParseAddr("2001:db8::90"), s.leases[0].IP) } + +// TestV6CommitLease_SnapshotAfterLockDrop reproduces the TOCTOU window between +// commitLease's renewable-check and the subsequent lifetime computation that +// used to re-acquire the lock: a concurrent setTrackedRangeStart that removes +// the prefix before the second lookup must not see the lease handed a +// "renewable" lifetime for a no-longer-renewable prefix. +func TestV6CommitLease_SnapshotAfterLockDrop(t *testing.T) { + now := time.Now() + s := &v6Server{ + conf: V6ServerConf{ + ipStart: net.ParseIP("2001:db8::10"), + leaseTime: time.Hour, + notify: notify6, + }, + advertisedPrefixes: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8::/64"): {}, + }, + renewablePrefixes: map[netip.Prefix]struct{}{ + netip.MustParsePrefix("2001:db8::/64"): {}, + }, + validUntilByPrefix: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): now.Add(45 * time.Minute), + }, + preferredUntilByPrefix: map[netip.Prefix]time.Time{ + netip.MustParsePrefix("2001:db8::/64"): now.Add(30 * time.Minute), + }, + } + + lease := &dhcpsvc.Lease{ + IP: netip.MustParseAddr("2001:db8::10"), + HWAddr: net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa}, + Expiry: now.Add(10 * time.Minute), + } + + msg, err := dhcpv6.NewMessage() + require.NoError(t, err) + msg.MessageType = dhcpv6.MessageTypeRenew + + lifetime, preferred := s.commitLease(msg, lease) + // Capped by remaining valid lifetime of the prefix (~45m), not leaseTime (1h). + assert.Greater(t, lifetime, 44*time.Minute) + assert.LessOrEqual(t, lifetime, 45*time.Minute) + // Preferred lifetime comes from the same snapshot and is capped by the + // prefix's remaining preferred lifetime (~30m). + assert.Greater(t, preferred, 29*time.Minute) + assert.LessOrEqual(t, preferred, 30*time.Minute) + // The lease expiry must have been updated consistently with the + // returned lifetime (data race regression test for l.Expiry mutation + // without the lock). + assert.InDelta(t, lifetime.Seconds(), time.Until(lease.Expiry).Seconds(), 2.0) +} diff --git a/openapi/next.yaml b/openapi/next.yaml index 4bae9e71085..1f71be01516 100644 --- a/openapi/next.yaml +++ b/openapi/next.yaml @@ -2079,6 +2079,9 @@ the prefix from the network interface and treats `ipv6_range_start` as a host template for the dynamic pool. 'type': 'string' + 'enum': + - 'static' + - 'interface' 'ipv6_range_start': 'description': > The start of the IPv6 addresses to serve to clients, or a host diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 0738845d247..65ee82bc3ac 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2058,6 +2058,9 @@ interface and treats `range_start` as a host template for the dynamic pool. 'type': 'string' + 'enum': + - 'static' + - 'interface' 'range_start': 'description': > The first IPv6 address for dynamic leases, or a host template when From f70bb2ba2a7233ac32fb966164344df2f2a7c019 Mon Sep 17 00:00:00 2001 From: Omoeba <38597972+Omoeba@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:31:16 -0700 Subject: [PATCH 3/3] Align DHCPv6 prefix tracking with code guidelines --- AGHTechDoc.md | 94 +- CHANGELOG.md | 4 +- internal/aghnet/prefix.go | 115 ++- internal/aghnet/prefix_linux.go | 255 ++++-- internal/aghnet/prefix_linux_internal_test.go | 29 +- internal/dhcpd/config.go | 10 +- internal/dhcpd/db.go | 228 +++-- internal/dhcpd/routeradv.go | 47 +- internal/dhcpd/routeradv_internal_test.go | 30 +- .../{routeradv_state.go => routeradvstate.go} | 249 ++++-- internal/dhcpd/v6_unix.go | 804 +++++++++++------- internal/dhcpd/v6_unix_internal_test.go | 5 +- 12 files changed, 1207 insertions(+), 663 deletions(-) rename internal/dhcpd/{routeradv_state.go => routeradvstate.go} (79%) diff --git a/AGHTechDoc.md b/AGHTechDoc.md index 463cf1aa74a..706eb633498 100644 --- a/AGHTechDoc.md +++ b/AGHTechDoc.md @@ -481,29 +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":{ - "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":"..."} - ... - ] - } +```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 @@ -558,24 +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":{ - "prefix_source":"static", - "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: @@ -760,8 +765,11 @@ 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. +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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9443e44bfa5..af60684f4d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,9 @@ NOTE: Add new changes BELOW THIS COMMENT. ### 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. +- 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 diff --git a/internal/aghnet/prefix.go b/internal/aghnet/prefix.go index b7d2bbb1a98..8be96cb4011 100644 --- a/internal/aghnet/prefix.go +++ b/internal/aghnet/prefix.go @@ -52,41 +52,9 @@ func parseIfconfigIPv6Addr(fields []string) (state IPv6AddrState, err error) { prefixBits := -1 for i := 2; i < len(fields); i++ { - switch strings.ToLower(fields[i]) { - case "prefixlen": - i++ - if i >= len(fields) { - return IPv6AddrState{}, fmt.Errorf("missing prefixlen value in %q", strings.Join(fields, " ")) - } - - prefixBits, err = strconv.Atoi(fields[i]) - if err != nil { - return IPv6AddrState{}, fmt.Errorf("parsing prefixlen %q: %w", fields[i], err) - } - case "pltime": - i++ - if i >= len(fields) { - return IPv6AddrState{}, fmt.Errorf("missing pltime value in %q", strings.Join(fields, " ")) - } - - preferred, err = parseIPv6Lifetime(fields[i]) - if err != nil { - return IPv6AddrState{}, fmt.Errorf("parsing pltime %q: %w", fields[i], err) - } - case "vltime": - i++ - if i >= len(fields) { - return IPv6AddrState{}, fmt.Errorf("missing vltime value in %q", strings.Join(fields, " ")) - } - - valid, err = parseIPv6Lifetime(fields[i]) - if err != nil { - return IPv6AddrState{}, fmt.Errorf("parsing vltime %q: %w", fields[i], err) - } - case "temporary": - state.Temporary = true - case "tentative": - state.Tentative = true + i, err = parseIfconfigIPv6AddrField(fields, i, &state, &preferred, &valid, &prefixBits) + if err != nil { + return IPv6AddrState{}, err } } @@ -104,15 +72,86 @@ func parseIfconfigIPv6Addr(fields []string) (state IPv6AddrState, err error) { }, nil } +// parseIfconfigIPv6AddrField parses one token from an ifconfig IPv6 address +// line. +func parseIfconfigIPv6AddrField( + fields []string, + i int, + state *IPv6AddrState, + preferred, valid *uint32, + prefixBits *int, +) (next int, err error) { + switch strings.ToLower(fields[i]) { + case "prefixlen": + return parseIfconfigIPv6AddrInt(fields, i, "prefixlen", func(v int) { + *prefixBits = v + }) + case "pltime": + return parseIfconfigIPv6AddrLifetime(fields, i, "pltime", preferred) + case "vltime": + return parseIfconfigIPv6AddrLifetime(fields, i, "vltime", valid) + case "temporary": + state.Temporary = true + case "tentative": + state.Tentative = true + } + + return i, nil +} + +// parseIfconfigIPv6AddrInt parses one int token from an ifconfig IPv6 address +// line. +func parseIfconfigIPv6AddrInt( + fields []string, + i int, + name string, + set func(int), +) (next int, err error) { + i++ + if i >= len(fields) { + return i, fmt.Errorf("missing %s value in %q", name, strings.Join(fields, " ")) + } + + v, err := strconv.Atoi(fields[i]) + if err != nil { + return i, fmt.Errorf("parsing %s %q: %w", name, fields[i], err) + } + + set(v) + + return i, nil +} + +// parseIfconfigIPv6AddrLifetime parses one IPv6 lifetime token from an +// ifconfig IPv6 address line. +func parseIfconfigIPv6AddrLifetime( + fields []string, + i int, + name string, + lifetime *uint32, +) (next int, err error) { + i++ + if i >= len(fields) { + return i, fmt.Errorf("missing %s value in %q", name, strings.Join(fields, " ")) + } + + *lifetime, err = parseIPv6Lifetime(fields[i]) + if err != nil { + return i, fmt.Errorf("parsing %s %q: %w", name, fields[i], err) + } + + return i, nil +} + // parseIPv6Lifetime parses an IPv6 lifetime token from command output. func parseIPv6Lifetime(s string) (sec uint32, err error) { switch strings.ToLower(s) { case "forever", "infinity", "infinite", "infty": return math.MaxUint32, nil default: - v, err := strconv.ParseUint(s, 10, 32) - if err != nil { - return 0, err + v, parseErr := strconv.ParseUint(s, 10, 32) + if parseErr != nil { + return 0, parseErr } return uint32(v), nil diff --git a/internal/aghnet/prefix_linux.go b/internal/aghnet/prefix_linux.go index 718bc1f4ab0..cc8040e8d4c 100644 --- a/internal/aghnet/prefix_linux.go +++ b/internal/aghnet/prefix_linux.go @@ -9,24 +9,19 @@ import ( "log/slog" "net" "net/netip" - "syscall" - "unsafe" + "github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/osutil/executil" + "github.com/mdlayher/netlink" "golang.org/x/sys/unix" ) // ObserveIPv6Addrs returns IPv6 interface address state for ifaceName. // -// ctx is accepted to match the BSD implementations (which run ifconfig under -// an [executil.CommandConstructor] and can be cancelled) but is not honored -// here: syscall.NetlinkRIB is synchronous and uncancellable from outside the -// call. Wrapping it in a goroutine that selects on ctx.Done() would only -// hide a stuck kernel from the caller while leaking the blocked goroutine on -// every retry, which is strictly worse than failing fast on the caller side -// and letting the operator notice a genuinely broken environment. -// rtnetlink responds in microseconds under normal conditions, so the lack of -// cancellation is acceptable in practice. +// ctx is accepted to match the BSD implementations, which run ifconfig under +// an [executil.CommandConstructor] and can be canceled. Linux uses a netlink +// socket instead, so we still accept ctx for API compatibility but do not +// consult it while receiving the dump reply. func ObserveIPv6Addrs( _ context.Context, _ *slog.Logger, @@ -38,14 +33,21 @@ func ObserveIPv6Addrs( return nil, fmt.Errorf("finding interface %s: %w", ifaceName, err) } - rib, err := syscall.NetlinkRIB(syscall.RTM_GETADDR, syscall.AF_INET6) + conn, err := netlink.Dial(unix.NETLINK_ROUTE, nil) if err != nil { - return nil, fmt.Errorf("querying rtnetlink addrs: %w", err) + return nil, fmt.Errorf("dialing rtnetlink: %w", err) } + defer func() { err = errors.WithDeferred(err, conn.Close()) }() - msgs, err := syscall.ParseNetlinkMessage(rib) + msgs, err := conn.Execute(netlink.Message{ + Header: netlink.Header{ + Type: netlink.HeaderType(unix.RTM_GETADDR), + Flags: netlink.Request | netlink.Dump, + }, + Data: []byte{unix.AF_INET6}, + }) if err != nil { - return nil, fmt.Errorf("parsing rtnetlink addrs: %w", err) + return nil, fmt.Errorf("querying rtnetlink addrs: %w", err) } return parseIPv6AddrStatesNetlink(msgs, iface.Index) @@ -53,38 +55,18 @@ func ObserveIPv6Addrs( // parseIPv6AddrStatesNetlink parses IPv6 address state from netlink messages. func parseIPv6AddrStatesNetlink( - msgs []syscall.NetlinkMessage, + msgs []netlink.Message, ifIndex int, ) (states []IPv6AddrState, err error) { -loop: for _, msg := range msgs { - switch msg.Header.Type { - case syscall.NLMSG_DONE: - break loop - case syscall.RTM_NEWADDR: - // Go on. - default: - continue - } - - if len(msg.Data) < syscall.SizeofIfAddrmsg { - return nil, fmt.Errorf("short ifaddrmsg payload") - } - - ifam := (*syscall.IfAddrmsg)(unsafe.Pointer(&msg.Data[0])) - if ifam.Family != syscall.AF_INET6 || int(ifam.Index) != ifIndex { - continue - } - - attrs, err := syscall.ParseNetlinkRouteAttr(&msg) - if err != nil { - return nil, fmt.Errorf("parsing route attrs: %w", err) - } - - state, ok, err := parseIPv6AddrStateNetlink(ifam, attrs) + state, done, ok, err := parseIPv6AddrStateMessage(msg, ifIndex) if err != nil { return nil, err - } else if ok { + } + if done { + return states, nil + } + if ok { states = append(states, state) } } @@ -92,44 +74,53 @@ loop: return states, nil } +// parseIPv6AddrStateMessage parses one netlink message carrying IPv6 address +// state. +func parseIPv6AddrStateMessage( + msg netlink.Message, + ifIndex int, +) (state IPv6AddrState, done, ok bool, err error) { + switch msg.Header.Type { + case netlink.Done: + return IPv6AddrState{}, true, false, nil + case netlink.HeaderType(unix.RTM_NEWADDR): + // Go on. + default: + return IPv6AddrState{}, false, false, nil + } + + ifam, err := parseIfAddrmsg(msg.Data) + if err != nil { + return IPv6AddrState{}, false, false, err + } + if ifam.Family != unix.AF_INET6 || int(ifam.Index) != ifIndex { + return IPv6AddrState{}, false, false, nil + } + + attrs, err := netlink.UnmarshalAttributes(msg.Data[unix.SizeofIfAddrmsg:]) + if err != nil { + return IPv6AddrState{}, false, false, fmt.Errorf("parsing route attrs: %w", err) + } + + state, ok, err = parseIPv6AddrStateNetlink(ifam, attrs) + + return state, false, ok, err +} + // parseIPv6AddrStateNetlink parses one IPv6 address state from the netlink // message data. func parseIPv6AddrStateNetlink( - ifam *syscall.IfAddrmsg, - attrs []syscall.NetlinkRouteAttr, + ifam unix.IfAddrmsg, + attrs []netlink.Attribute, ) (state IPv6AddrState, ok bool, err error) { var addr netip.Addr - var cache *unix.IfaCacheinfo + var cache *ipv6AddrCacheInfo flags := uint32(ifam.Flags) for _, attr := range attrs { - switch attr.Attr.Type { - case unix.IFA_LOCAL: - addr, err = parseIPv6AddrAttr(attr.Value) - if err != nil { - return IPv6AddrState{}, false, fmt.Errorf("parsing ifa_local: %w", err) - } - case unix.IFA_ADDRESS: - if addr.IsValid() { - continue - } - - addr, err = parseIPv6AddrAttr(attr.Value) - if err != nil { - return IPv6AddrState{}, false, fmt.Errorf("parsing ifa_address: %w", err) - } - case unix.IFA_FLAGS: - if len(attr.Value) < 4 { - return IPv6AddrState{}, false, fmt.Errorf("short ifa_flags attribute") - } - - flags = binary.NativeEndian.Uint32(attr.Value[:4]) - case unix.IFA_CACHEINFO: - if len(attr.Value) < unix.SizeofIfaCacheinfo { - return IPv6AddrState{}, false, fmt.Errorf("short ifa_cacheinfo attribute") - } - - cache = (*unix.IfaCacheinfo)(unsafe.Pointer(&attr.Value[0])) + addr, flags, cache, err = parseIPv6AddrStateNetlinkAttr(attr, addr, flags, cache) + if err != nil { + return IPv6AddrState{}, false, err } } @@ -139,8 +130,8 @@ func parseIPv6AddrStateNetlink( preferred, valid := uint32(^uint32(0)), uint32(^uint32(0)) if cache != nil { - preferred = cache.Prefered - valid = cache.Valid + preferred = cache.preferredLifetimeSec + valid = cache.validLifetimeSec } return IPv6AddrState{ @@ -153,6 +144,122 @@ func parseIPv6AddrStateNetlink( }, true, nil } +// parseIPv6AddrStateNetlinkAttr parses one IPv6 address attribute from a +// netlink message. +func parseIPv6AddrStateNetlinkAttr( + attr netlink.Attribute, + addr netip.Addr, + flags uint32, + cache *ipv6AddrCacheInfo, +) (nextAddr netip.Addr, nextFlags uint32, nextCache *ipv6AddrCacheInfo, err error) { + switch attr.Type { + case unix.IFA_LOCAL: + return parseIPv6AddrStateLocalAttr(attr.Data, flags, cache) + case unix.IFA_ADDRESS: + return parseIPv6AddrStateAddressAttr(attr.Data, addr, flags, cache) + case unix.IFA_FLAGS: + return parseIPv6AddrStateFlagsAttr(attr.Data, addr, cache) + case unix.IFA_CACHEINFO: + return parseIPv6AddrStateCacheAttr(attr.Data, addr, flags) + default: + return addr, flags, cache, nil + } +} + +// parseIPv6AddrStateLocalAttr parses an IFA_LOCAL attribute. +func parseIPv6AddrStateLocalAttr( + data []byte, + flags uint32, + cache *ipv6AddrCacheInfo, +) (addr netip.Addr, nextFlags uint32, nextCache *ipv6AddrCacheInfo, err error) { + addr, err = parseIPv6AddrAttr(data) + if err != nil { + return netip.Addr{}, 0, nil, fmt.Errorf("parsing ifa_local: %w", err) + } + + return addr, flags, cache, nil +} + +// parseIPv6AddrStateAddressAttr parses an IFA_ADDRESS attribute. +func parseIPv6AddrStateAddressAttr( + data []byte, + addr netip.Addr, + flags uint32, + cache *ipv6AddrCacheInfo, +) (nextAddr netip.Addr, nextFlags uint32, nextCache *ipv6AddrCacheInfo, err error) { + if addr.IsValid() { + return addr, flags, cache, nil + } + + nextAddr, err = parseIPv6AddrAttr(data) + if err != nil { + return netip.Addr{}, 0, nil, fmt.Errorf("parsing ifa_address: %w", err) + } + + return nextAddr, flags, cache, nil +} + +// parseIPv6AddrStateFlagsAttr parses an IFA_FLAGS attribute. +func parseIPv6AddrStateFlagsAttr( + data []byte, + addr netip.Addr, + cache *ipv6AddrCacheInfo, +) (nextAddr netip.Addr, nextFlags uint32, nextCache *ipv6AddrCacheInfo, err error) { + if len(data) < 4 { + return netip.Addr{}, 0, nil, fmt.Errorf("short ifa_flags attribute") + } + + return addr, binary.NativeEndian.Uint32(data[:4]), cache, nil +} + +// parseIPv6AddrStateCacheAttr parses an IFA_CACHEINFO attribute. +func parseIPv6AddrStateCacheAttr( + data []byte, + addr netip.Addr, + flags uint32, +) (nextAddr netip.Addr, nextFlags uint32, nextCache *ipv6AddrCacheInfo, err error) { + ifaCacheInfo, err := parseIfaCacheinfo(data) + if err != nil { + return netip.Addr{}, 0, nil, err + } + + return addr, flags, &ifaCacheInfo, nil +} + +// ipv6AddrCacheInfo is the lifetime subset of Linux ifa_cacheinfo used by +// IPv6 address observation. +type ipv6AddrCacheInfo struct { + preferredLifetimeSec uint32 + validLifetimeSec uint32 +} + +// parseIfAddrmsg parses one Linux ifaddrmsg structure. +func parseIfAddrmsg(b []byte) (ifam unix.IfAddrmsg, err error) { + if len(b) < unix.SizeofIfAddrmsg { + return unix.IfAddrmsg{}, fmt.Errorf("short ifaddrmsg payload") + } + + return unix.IfAddrmsg{ + Family: b[0], + Prefixlen: b[1], + Flags: b[2], + Scope: b[3], + Index: binary.NativeEndian.Uint32(b[4:8]), + }, nil +} + +// parseIfaCacheinfo parses one Linux ifa_cacheinfo structure. +func parseIfaCacheinfo(b []byte) (cache ipv6AddrCacheInfo, err error) { + if len(b) < unix.SizeofIfaCacheinfo { + return ipv6AddrCacheInfo{}, fmt.Errorf("short ifa_cacheinfo attribute") + } + + return ipv6AddrCacheInfo{ + preferredLifetimeSec: binary.NativeEndian.Uint32(b[0:4]), + validLifetimeSec: binary.NativeEndian.Uint32(b[4:8]), + }, nil +} + // parseIPv6AddrAttr parses one IPv6 address attribute. func parseIPv6AddrAttr(b []byte) (addr netip.Addr, err error) { if len(b) < net.IPv6len { diff --git a/internal/aghnet/prefix_linux_internal_test.go b/internal/aghnet/prefix_linux_internal_test.go index 05129d3950a..37d1819cf07 100644 --- a/internal/aghnet/prefix_linux_internal_test.go +++ b/internal/aghnet/prefix_linux_internal_test.go @@ -5,10 +5,9 @@ package aghnet import ( "encoding/binary" "net/netip" - "syscall" "testing" - "unsafe" + "github.com/mdlayher/netlink" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/unix" @@ -19,24 +18,22 @@ func TestParseIPv6AddrStateNetlink(t *testing.T) { flags := make([]byte, 4) binary.NativeEndian.PutUint32(flags, unix.IFA_F_TEMPORARY|unix.IFA_F_TENTATIVE) - cache := unix.IfaCacheinfo{ - Prefered: 600, - Valid: 1200, - } - cacheBytes := *(*[unix.SizeofIfaCacheinfo]byte)(unsafe.Pointer(&cache)) + cacheBytes := make([]byte, unix.SizeofIfaCacheinfo) + binary.NativeEndian.PutUint32(cacheBytes[0:4], 600) + binary.NativeEndian.PutUint32(cacheBytes[4:8], 1200) - state, ok, err := parseIPv6AddrStateNetlink(&syscall.IfAddrmsg{ - Family: syscall.AF_INET6, + state, ok, err := parseIPv6AddrStateNetlink(unix.IfAddrmsg{ + Family: unix.AF_INET6, Prefixlen: 64, - }, []syscall.NetlinkRouteAttr{{ - Attr: syscall.RtAttr{Type: unix.IFA_ADDRESS}, - Value: addr[:], + }, []netlink.Attribute{{ + Type: unix.IFA_ADDRESS, + Data: addr[:], }, { - Attr: syscall.RtAttr{Type: unix.IFA_FLAGS}, - Value: flags, + Type: unix.IFA_FLAGS, + Data: flags, }, { - Attr: syscall.RtAttr{Type: unix.IFA_CACHEINFO}, - Value: cacheBytes[:], + Type: unix.IFA_CACHEINFO, + Data: cacheBytes, }}) require.NoError(t, err) require.True(t, ok) diff --git a/internal/dhcpd/config.go b/internal/dhcpd/config.go index a8aa5b4697d..ef800111e25 100644 --- a/internal/dhcpd/config.go +++ b/internal/dhcpd/config.go @@ -272,10 +272,14 @@ type V6ServerConf struct { // If it is empty, the configured static prefix semantics are used. PrefixSource V6PrefixSource `yaml:"prefix_source" json:"prefix_source"` - LeaseDuration uint32 `yaml:"lease_duration" json:"lease_duration"` // in seconds + // LeaseDuration is the DHCPv6 lease duration, in seconds. + LeaseDuration uint32 `yaml:"lease_duration" json:"lease_duration"` + + // RASLAACOnly sends ICMPv6 Router Advertisements without M or O flags. + RASLAACOnly bool `yaml:"ra_slaac_only" json:"ra_slaac_only"` - RASLAACOnly bool `yaml:"ra_slaac_only" json:"ra_slaac_only"` // send ICMPv6.RA packets without MO flags - RAAllowSLAAC bool `yaml:"ra_allow_slaac" json:"-"` // send ICMPv6.RA packets with MO flags + // RAAllowSLAAC sends ICMPv6 Router Advertisements with M and O flags. + RAAllowSLAAC bool `yaml:"ra_allow_slaac" json:"-"` ipStart net.IP // starting IP address for dynamic leases leaseTime time.Duration // the time during which a dynamic lease is considered valid diff --git a/internal/dhcpd/db.go b/internal/dhcpd/db.go index eb84d2a5595..da9753da44f 100644 --- a/internal/dhcpd/db.go +++ b/internal/dhcpd/db.go @@ -51,6 +51,26 @@ type dbDeprecatedPrefix struct { ValidUntil string `json:"valid_until"` } +// v6MetaRestorer restores persisted DHCPv6 prefix metadata into the running +// server implementation. +type v6MetaRestorer interface { + DHCPServer + setRestoredPrefixMeta( + renewable map[netip.Prefix]struct{}, + deprecated map[netip.Prefix]time.Time, + ) +} + +// v6Snapshotter returns the DHCPv6 leases and prefix metadata for persistence. +type v6Snapshotter interface { + DHCPServer + dbSnapshot(now time.Time) ( + leases []*dhcpsvc.Lease, + renewable map[netip.Prefix]struct{}, + deprecated map[netip.Prefix]time.Time, + ) +} + // dbLease is the structure of stored lease. type dbLease struct { Expiry string `json:"expires"` @@ -121,13 +141,36 @@ func (s *server) dbLoad() (err error) { return fmt.Errorf("decoding db: %w", err) } - leases := dl.Leases - leases4 := []*dhcpsvc.Lease{} - leases6 := []*dhcpsvc.Lease{} + leases4, leases6 := splitStoredLeases(dl.Leases) + + err = s.srv4.ResetLeases(leases4) + if err != nil { + return fmt.Errorf("resetting dhcpv4 leases: %w", err) + } + if s.srv6 != nil { + err = s.srv6.ResetLeases(leases6) + if err != nil { + return fmt.Errorf("resetting dhcpv6 leases: %w", err) + } + restoreLoadedV6Meta(s.srv6, dl.V6Meta) + } + + log.Info( + "dhcp: loaded leases v4:%d v6:%d total-read:%d from DB", + len(leases4), + len(leases6), + len(dl.Leases), + ) + + return nil +} + +// splitStoredLeases converts stored database leases into DHCPv4 and DHCPv6 +// leases. +func splitStoredLeases(leases []*dbLease) (leases4, leases6 []*dhcpsvc.Lease) { for _, l := range leases { - var lease *dhcpsvc.Lease - lease, err = l.toLease() + lease, err := l.toLease() if err != nil { log.Info("dhcp: invalid lease: %s", err) @@ -141,103 +184,124 @@ func (s *server) dbLoad() (err error) { } } - err = s.srv4.ResetLeases(leases4) - if err != nil { - return fmt.Errorf("resetting dhcpv4 leases: %w", err) + return leases4, leases6 +} + +// restoreLoadedV6Meta restores the persisted IPv6 prefix metadata into srv6. +func restoreLoadedV6Meta(srv6 DHCPServer, meta *dataLeasesV6Meta) { + if meta == nil { + return } - if s.srv6 != nil { - err = s.srv6.ResetLeases(leases6) - if err != nil { - return fmt.Errorf("resetting dhcpv6 leases: %w", err) + v6srv, ok := srv6.(v6MetaRestorer) + if !ok { + return + } + + renewable, deprecated := splitStoredV6Meta(meta) + v6srv.setRestoredPrefixMeta(renewable, deprecated) +} + +// splitStoredV6Meta converts stored IPv6 prefix metadata into the in-memory +// structures used by the DHCPv6 server. +func splitStoredV6Meta(meta *dataLeasesV6Meta) ( + renewable map[netip.Prefix]struct{}, + deprecated map[netip.Prefix]time.Time, +) { + renewable = map[netip.Prefix]struct{}{} + for _, pref := range meta.Renewable { + renewable[pref] = struct{}{} + } + + deprecated = map[netip.Prefix]time.Time{} + for _, dp := range meta.Deprecated { + if dp == nil { + continue } - if dl.V6Meta != nil { - if srv6, ok := s.srv6.(*v6Server); ok { - renewable := map[netip.Prefix]struct{}{} - for _, pref := range dl.V6Meta.Renewable { - renewable[pref] = struct{}{} - } - - deprecated := map[netip.Prefix]time.Time{} - for _, dp := range dl.V6Meta.Deprecated { - if dp == nil { - continue - } - - until, parseErr := time.Parse(time.RFC3339, dp.ValidUntil) - if parseErr != nil { - log.Info("dhcp: invalid v6 deprecated prefix %s: %s", dp.Prefix, parseErr) - - continue - } - - deprecated[dp.Prefix] = until - } - - srv6.setRestoredPrefixMeta(renewable, deprecated) - } + + until, err := time.Parse(time.RFC3339, dp.ValidUntil) + if err != nil { + log.Info("dhcp: invalid v6 deprecated prefix %s: %s", dp.Prefix, err) + + continue } - } - log.Info( - "dhcp: loaded leases v4:%d v6:%d total-read:%d from DB", - len(leases4), - len(leases6), - len(leases), - ) + deprecated[dp.Prefix] = until + } - return nil + return renewable, deprecated } // dbStore stores DHCP leases. func (s *server) dbStore() (err error) { // Use an empty slice here as opposed to nil so that it doesn't write // "null" into the database file if leases are empty. - leases := []*dbLease{} + leases := dbLeasesFromRef(s.srv4.getLeasesRef()) var v6Meta *dataLeasesV6Meta - for _, l := range s.srv4.getLeasesRef() { - leases = append(leases, fromLease(l)) - } - if s.srv6 != nil { - if srv6, ok := s.srv6.(*v6Server); ok { - leases6, renewable, deprecated := srv6.dbSnapshot(time.Now()) - for _, l := range leases6 { - leases = append(leases, fromLease(l)) - } - if len(renewable) > 0 || len(deprecated) > 0 { - v6Meta = &dataLeasesV6Meta{ - Renewable: make([]netip.Prefix, 0, len(renewable)), - } - for pref := range renewable { - v6Meta.Renewable = append(v6Meta.Renewable, pref) - } - slices.SortFunc(v6Meta.Renewable, prefixCompare) - - if len(deprecated) > 0 { - v6Meta.Deprecated = make([]*dbDeprecatedPrefix, 0, len(deprecated)) - for pref, until := range deprecated { - v6Meta.Deprecated = append(v6Meta.Deprecated, &dbDeprecatedPrefix{ - Prefix: pref, - ValidUntil: until.Format(time.RFC3339), - }) - } - slices.SortFunc(v6Meta.Deprecated, func(a, b *dbDeprecatedPrefix) int { - return prefixCompare(a.Prefix, b.Prefix) - }) - } - } - } else { - for _, l := range s.srv6.getLeasesRef() { - leases = append(leases, fromLease(l)) - } - } + leases, v6Meta = s.dbStoreV6(leases) } return writeDB(s.conf.dbFilePath, leases, v6Meta) } +// dbLeasesFromRef converts DHCP leases to database leases. +func dbLeasesFromRef(leases []*dhcpsvc.Lease) (dbLeases []*dbLease) { + dbLeases = make([]*dbLease, 0, len(leases)) + for _, l := range leases { + dbLeases = append(dbLeases, fromLease(l)) + } + + return dbLeases +} + +// dbStoreV6 adds DHCPv6 leases and prefix metadata to the database snapshot. +func (s *server) dbStoreV6(leases []*dbLease) (out []*dbLease, v6Meta *dataLeasesV6Meta) { + if srv6, ok := s.srv6.(v6Snapshotter); ok { + leases6, renewable, deprecated := srv6.dbSnapshot(time.Now()) + leases = append(leases, dbLeasesFromRef(leases6)...) + return leases, buildStoredV6Meta(renewable, deprecated) + } + + return append(leases, dbLeasesFromRef(s.srv6.getLeasesRef())...), nil +} + +// buildStoredV6Meta converts snapshot metadata into the persisted form. +func buildStoredV6Meta( + renewable map[netip.Prefix]struct{}, + deprecated map[netip.Prefix]time.Time, +) (v6Meta *dataLeasesV6Meta) { + if len(renewable) == 0 && len(deprecated) == 0 { + return nil + } + + v6Meta = &dataLeasesV6Meta{ + Renewable: make([]netip.Prefix, 0, len(renewable)), + } + for pref := range renewable { + v6Meta.Renewable = append(v6Meta.Renewable, pref) + } + slices.SortFunc(v6Meta.Renewable, prefixCompare) + + if len(deprecated) == 0 { + return v6Meta + } + + v6Meta.Deprecated = make([]*dbDeprecatedPrefix, 0, len(deprecated)) + for pref, until := range deprecated { + v6Meta.Deprecated = append(v6Meta.Deprecated, &dbDeprecatedPrefix{ + Prefix: pref, + ValidUntil: until.Format(time.RFC3339), + }) + } + slices.SortFunc(v6Meta.Deprecated, func(a, b *dbDeprecatedPrefix) int { + return prefixCompare(a.Prefix, b.Prefix) + }) + + return v6Meta +} + // writeDB writes leases to file at path. func writeDB(path string, leases []*dbLease, v6Meta *dataLeasesV6Meta) (err error) { defer func() { err = errors.Annotate(err, "writing db: %w") }() diff --git a/internal/dhcpd/routeradv.go b/internal/dhcpd/routeradv.go index ae7efd914d5..09fd3bd5fac 100644 --- a/internal/dhcpd/routeradv.go +++ b/internal/dhcpd/routeradv.go @@ -230,14 +230,7 @@ func (ra *raCtx) Init(initial raState) (err error) { // Advertisements. func (ra *raCtx) ensureConn(sourceAddr netip.Addr) (err error) { if !sourceAddr.IsValid() { - if ra.conn != nil { - err = ra.conn.Close() - } - - ra.conn = nil - ra.connSourceAddr = netip.Addr{} - - return err + return ra.closeConn() } if ra.conn != nil && ra.connSourceAddr == sourceAddr { @@ -245,14 +238,29 @@ func (ra *raCtx) ensureConn(sourceAddr netip.Addr) (err error) { } if ra.conn != nil { - err = ra.conn.Close() - ra.conn = nil - ra.connSourceAddr = netip.Addr{} - if err != nil { + if err = ra.closeConn(); err != nil { return fmt.Errorf("closing previous icmp listener: %w", err) } } + return ra.openConn(sourceAddr) +} + +// closeConn closes the current ICMPv6 socket and clears the tracked source +// address. +func (ra *raCtx) closeConn() (err error) { + if ra.conn != nil { + err = ra.conn.Close() + } + + ra.conn = nil + ra.connSourceAddr = netip.Addr{} + + return err +} + +// openConn opens a new ICMPv6 socket for the given source address. +func (ra *raCtx) openConn(sourceAddr netip.Addr) (err error) { ipAndScope := sourceAddr.String() + "%" + ra.ifaceName newConn, err := icmp.ListenPacket("ip6:ipv6-icmp", ipAndScope) if err != nil { @@ -334,17 +342,17 @@ func (ra *raCtx) refresh(ctx context.Context) { } now := time.Now() - _ = ra.state.merge(obs, now) + change := ra.state.merge(obs, now) if ra.onStateRefresh != nil { ra.onStateRefresh(now, &ra.state) } - ra.syncStateChange(now) + ra.syncStateChange(now, &change) } // sendPacket rebuilds and sends the current Router Advertisement packet. func (ra *raCtx) sendPacket() { now := time.Now() - ra.syncStateChange(now) + ra.syncStateChange(now, nil) sourceAddr, rdnssAddr := ra.state.sourceAndRDNSS() err := ra.ensureConn(sourceAddr) @@ -424,7 +432,7 @@ func tickerC(t *time.Ticker) (c <-chan time.Time) { // elapsed time. The comparison uses a deadline-based digest of the tracked // prefixes, so repeated polls that only observe the kernel counting lifetimes // down do not spuriously fire the callback. -func (ra *raCtx) syncStateChange(now time.Time) { +func (ra *raCtx) syncStateChange(now time.Time, change *raActiveChange) { digest := ra.state.digest(now) changed := !sameRAStateDigest(ra.lastDigest, digest) ra.lastDigest = digest @@ -433,7 +441,12 @@ func (ra *raCtx) syncStateChange(now time.Time) { return } - active := ra.state.activeSnapshot(now) + var active *raPrefixSnapshot + if change != nil && change.Changed { + active = change.Active + } else { + active = ra.state.activeSnapshot(now) + } advertised := ra.state.pios(now) ra.onActivePrefixChange(active, advertised) } diff --git a/internal/dhcpd/routeradv_internal_test.go b/internal/dhcpd/routeradv_internal_test.go index f9b62105a30..2868c991fe2 100644 --- a/internal/dhcpd/routeradv_internal_test.go +++ b/internal/dhcpd/routeradv_internal_test.go @@ -64,7 +64,11 @@ func TestCreateICMPv6RAPacket(t *testing.T) { assert.Equal(t, byte(0xc0), opt.Data[1]) assert.Equal(t, uint32(1800), binary.BigEndian.Uint32(opt.Data[2:6])) assert.Equal(t, uint32(0), binary.BigEndian.Uint32(opt.Data[6:10])) - assert.Equal(t, netip.MustParsePrefix("2001:db8:abcd::/60").Masked().Addr().As16(), [16]byte(opt.Data[14:30])) + assert.Equal( + t, + netip.MustParsePrefix("2001:db8:abcd::/60").Masked().Addr().As16(), + [16]byte(opt.Data[14:30]), + ) opt = raPkt.Options[2] require.Equal(t, layers.ICMPv6OptMTU, opt.Type) @@ -105,7 +109,7 @@ func TestRACtxSyncStateChange_DeprecatedExpiry(t *testing.T) { } ra.lastDigest = ra.state.digest(now) - ra.syncStateChange(now.Add(2 * time.Second)) + ra.syncStateChange(now.Add(2*time.Second), nil) require.Len(t, notifications, 1) require.Len(t, notifications[0], 1) @@ -142,9 +146,9 @@ func TestRACtxSyncStateChange_StableStateDoesNotFireCallback(t *testing.T) { // Three ticks without any new observation: only elapsed time changed, // so the digest must be unchanged and the callback must not fire. - ra.syncStateChange(now.Add(1 * time.Second)) - ra.syncStateChange(now.Add(3 * time.Second)) - ra.syncStateChange(now.Add(5 * time.Second)) + ra.syncStateChange(now.Add(1*time.Second), nil) + ra.syncStateChange(now.Add(3*time.Second), nil) + ra.syncStateChange(now.Add(5*time.Second), nil) assert.Zero(t, fired) } @@ -187,7 +191,7 @@ func TestRACtxSyncStateChange_DeprecatingPrefixTriggersCallback(t *testing.T) { }}, }, now.Add(time.Minute)) - ra.syncStateChange(now.Add(time.Minute)) + ra.syncStateChange(now.Add(time.Minute), nil) require.Len(t, notifications, 1) require.Len(t, notifications[0], 2) @@ -227,12 +231,12 @@ func TestRACtxSyncStateChange_PreferredExpiryTriggersCallback(t *testing.T) { // While the preferred countdown still has time remaining no callback // should fire. - ra.syncStateChange(now.Add(299 * time.Second)) + ra.syncStateChange(now.Add(299*time.Second), nil) assert.Empty(t, notifications) // At the moment the preferred lifetime hits zero the callback must // fire so v6Server can drop the prefix from its renewable set. - ra.syncStateChange(now.Add(301 * time.Second)) + ra.syncStateChange(now.Add(301*time.Second), nil) require.Len(t, notifications, 1) require.Len(t, notifications[0], 1) assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), notifications[0][0].Prefix) @@ -240,8 +244,8 @@ func TestRACtxSyncStateChange_PreferredExpiryTriggersCallback(t *testing.T) { // But subsequent ticks past the expiry must not re-fire: the state // digest has settled at preferredExpired=true and stays that way. - ra.syncStateChange(now.Add(305 * time.Second)) - ra.syncStateChange(now.Add(310 * time.Second)) + ra.syncStateChange(now.Add(305*time.Second), nil) + ra.syncStateChange(now.Add(310*time.Second), nil) assert.Len(t, notifications, 1) } @@ -277,9 +281,9 @@ func TestRACtxSyncStateChange_PreferredExpiryOnDeprecatedEntryStillQuiescent(t * // Several steady-state ticks across the valid countdown. No kernel // state changed, so nothing should fire. - ra.syncStateChange(now.Add(1 * time.Second)) - ra.syncStateChange(now.Add(5 * time.Second)) - ra.syncStateChange(now.Add(30 * time.Second)) + ra.syncStateChange(now.Add(1*time.Second), nil) + ra.syncStateChange(now.Add(5*time.Second), nil) + ra.syncStateChange(now.Add(30*time.Second), nil) assert.Zero(t, fired) } diff --git a/internal/dhcpd/routeradv_state.go b/internal/dhcpd/routeradvstate.go similarity index 79% rename from internal/dhcpd/routeradv_state.go rename to internal/dhcpd/routeradvstate.go index 55507ec76da..a1622f82bd9 100644 --- a/internal/dhcpd/routeradv_state.go +++ b/internal/dhcpd/routeradvstate.go @@ -96,12 +96,12 @@ type raStateDigest struct { // trackedPrefixDigest is the deadline-based fingerprint of one trackedPrefix. // // preferredExpired is a time-derived boolean that flips from false to true -// exactly once during a natural countdown — the moment the prefix's preferred +// exactly once during a natural countdown, the moment the prefix's preferred // lifetime reaches zero. Without it, a prefix whose preferred lifetime // counts down while its absolute preferredUntil deadline stays put would // produce identical digests before and after it becomes non-renewable, so -// downstream reconciliation (which rebuilds the v6Server renewablePrefixes -// set) would never fire for that transition. +// downstream reconciliation, which rebuilds the v6Server renewablePrefixes +// set, would never fire for that transition. type trackedPrefixDigest struct { prefix netip.Prefix origin raPrefixOrigin @@ -166,8 +166,8 @@ func sameRAStateDigest(a, b raStateDigest) (ok bool) { } for pref, digest := range a.deprecated { - other, ok := b.deprecated[pref] - if !ok || other != digest { + other, found := b.deprecated[pref] + if !found || other != digest { return false } } @@ -196,9 +196,19 @@ func newStaticRAState(obs raObservation) (st raState) { return st } +// capDeprecatedLifetime bounds a remaining valid lifetime by the standard +// two-hour cap used for deprecated prefixes. +func capDeprecatedLifetime(valid uint32) (capped uint32) { + if valid > raDeprecatedLifetimeCapSecs || valid == math.MaxUint32 { + return raDeprecatedLifetimeCapSecs + } + + return valid +} + // merge merges a fresh interface observation into s and reports whether the // active prefix changed. -func (s *raState) merge(obs raObservation, now time.Time) (change raActiveChange) { +func (s *raState) merge(obs raObservation, now time.Time) raActiveChange { if s.deprecated == nil { s.deprecated = map[netip.Prefix]*trackedPrefix{} } @@ -213,74 +223,83 @@ func (s *raState) merge(obs raObservation, now time.Time) (change raActiveChange s.sourceAddr = obs.SourceAddr s.rdnssAddr = obs.RDNSSAddr - switch { - case obs.Active != nil && s.active != nil && s.active.prefix == obs.Active.Prefix: - s.active = reconcileTrackedPrefix(s.active, *obs.Active, raPrefixOriginObservedActive, now) - case obs.Active != nil: - s.moveActiveToDeprecated(now) - s.active = newTrackedPrefix(*obs.Active, raPrefixOriginObservedActive, now) - activeChanged = prev != nil && prev.Prefix != obs.Active.Prefix - case obs.Active == nil: - s.moveActiveToDeprecated(now) - s.active = nil - activeChanged = prev != nil - } + activeChanged = s.mergeActiveObservation(obs.Active, prev, now) if s.active != nil { delete(s.deprecated, s.active.prefix) } - observedInactive := map[netip.Prefix]struct{}{} + observedInactive := s.mergeInactiveObservations(obs.Inactive, prevActivePrefix, activeChanged, now) - for _, dep := range obs.Inactive { - if s.active != nil && dep.Prefix == s.active.prefix { - continue - } - if activeChanged && dep.Prefix == prevActivePrefix && dep.PreferredSec == 0 { - valid := dep.ValidSec - if valid > raDeprecatedLifetimeCapSecs || valid == math.MaxUint32 { - valid = raDeprecatedLifetimeCapSecs - } - if valid == 0 { - delete(s.deprecated, dep.Prefix) + s.deprecateMissingObservedInactivePrefixes(observedInactive, now) - continue - } + s.evictExpired(now) - s.deprecated[dep.Prefix] = reconcileTrackedPrefix( - s.deprecated[dep.Prefix], - raPrefixSnapshot{ - Prefix: dep.Prefix, - PreferredSec: 0, - ValidSec: valid, - }, - raPrefixOriginDeprecated, - now, - ) + next := s.activeSnapshot(now) + return raActiveChange{ + Changed: !sameActivePrefix(prev, next), + Active: next, + } +} - continue - } +// mergeActiveObservation updates the active prefix from obs and reports +// whether the active prefix changed. +func (s *raState) mergeActiveObservation( + obs, prev *raPrefixSnapshot, + now time.Time, +) (activeChanged bool) { + switch { + case obs != nil && s.active != nil && s.active.prefix == obs.Prefix: + s.active = reconcileTrackedPrefix(s.active, *obs, raPrefixOriginObservedActive, now) + case obs != nil: + s.moveActiveToDeprecated(now) + s.active = newTrackedPrefix(*obs, raPrefixOriginObservedActive, now) + activeChanged = prev != nil && prev.Prefix != obs.Prefix + default: + s.moveActiveToDeprecated(now) + s.active = nil + activeChanged = prev != nil + } - if dep.PreferredSec > 0 { - observedInactive[dep.Prefix] = struct{}{} - s.deprecated[dep.Prefix] = reconcileTrackedPrefix( - s.deprecated[dep.Prefix], - dep, - raPrefixOriginObservedInactive, - now, - ) + return activeChanged +} - continue - } +// mergeInactiveObservations reconciles inactive prefix observations with the +// tracked deprecated prefixes and returns the set of prefixes that still +// appear as observed inactive. +func (s *raState) mergeInactiveObservations( + inactive []raPrefixSnapshot, + prevActivePrefix netip.Prefix, + activeChanged bool, + now time.Time, +) (observedInactive map[netip.Prefix]struct{}) { + observedInactive = map[netip.Prefix]struct{}{} - valid := dep.ValidSec - if valid > raDeprecatedLifetimeCapSecs || valid == math.MaxUint32 { - valid = raDeprecatedLifetimeCapSecs - } + for _, dep := range inactive { + s.mergeInactiveObservation(dep, prevActivePrefix, activeChanged, now, observedInactive) + } + + return observedInactive +} + +// mergeInactiveObservation reconciles one inactive prefix observation. +func (s *raState) mergeInactiveObservation( + dep raPrefixSnapshot, + prevActivePrefix netip.Prefix, + activeChanged bool, + now time.Time, + observedInactive map[netip.Prefix]struct{}, +) { + if s.active != nil && dep.Prefix == s.active.prefix { + return + } + + if activeChanged && dep.Prefix == prevActivePrefix && dep.PreferredSec == 0 { + valid := capDeprecatedLifetime(dep.ValidSec) if valid == 0 { delete(s.deprecated, dep.Prefix) - continue + return } s.deprecated[dep.Prefix] = reconcileTrackedPrefix( @@ -293,8 +312,47 @@ func (s *raState) merge(obs raObservation, now time.Time) (change raActiveChange raPrefixOriginDeprecated, now, ) + + return } + if dep.PreferredSec > 0 { + observedInactive[dep.Prefix] = struct{}{} + s.deprecated[dep.Prefix] = reconcileTrackedPrefix( + s.deprecated[dep.Prefix], + dep, + raPrefixOriginObservedInactive, + now, + ) + + return + } + + valid := capDeprecatedLifetime(dep.ValidSec) + if valid == 0 { + delete(s.deprecated, dep.Prefix) + + return + } + + s.deprecated[dep.Prefix] = reconcileTrackedPrefix( + s.deprecated[dep.Prefix], + raPrefixSnapshot{ + Prefix: dep.Prefix, + PreferredSec: 0, + ValidSec: valid, + }, + raPrefixOriginDeprecated, + now, + ) +} + +// deprecateMissingObservedInactivePrefixes converts any observed-inactive +// prefixes that are no longer present into deprecated prefixes. +func (s *raState) deprecateMissingObservedInactivePrefixes( + observedInactive map[netip.Prefix]struct{}, + now time.Time, +) { for pref, tracked := range s.deprecated { if tracked.origin != raPrefixOriginObservedInactive { continue @@ -304,14 +362,6 @@ func (s *raState) merge(obs raObservation, now time.Time) (change raActiveChange s.deprecateTrackedPrefix(pref, tracked, now) } } - - s.evictExpired(now) - - next := s.activeSnapshot(now) - change.Changed = !sameActivePrefix(prev, next) - change.Active = next - - return change } // deprecateTrackedPrefix converts a tracked prefix into a deprecated one using @@ -323,9 +373,7 @@ func (s *raState) deprecateTrackedPrefix(pref netip.Prefix, tracked *trackedPref return } - if valid > raDeprecatedLifetimeCapSecs || valid == math.MaxUint32 { - valid = raDeprecatedLifetimeCapSecs - } + valid = capDeprecatedLifetime(valid) s.deprecated[pref] = newTrackedPrefix(raPrefixSnapshot{ Prefix: pref, @@ -387,6 +435,15 @@ func buildInterfaceRAObservation(states []aghnet.IPv6AddrState) (obs raObservati obs.SourceAddr = pickRASourceAddr(states) obs.RDNSSAddr = obs.SourceAddr + prefixes := buildInterfaceRAPrefixSnapshots(states) + obs.Active, obs.Inactive = splitInterfaceRAPrefixSnapshots(prefixes) + + return obs +} + +// buildInterfaceRAPrefixSnapshots groups eligible interface states by prefix +// and collapses each group into a single snapshot. +func buildInterfaceRAPrefixSnapshots(states []aghnet.IPv6AddrState) (prefixes []raPrefixSnapshot) { grouped := map[netip.Prefix][]aghnet.IPv6AddrState{} for _, st := range states { if !isEligibleRAPrefixState(st) { @@ -397,7 +454,7 @@ func buildInterfaceRAObservation(states []aghnet.IPv6AddrState) (obs raObservati grouped[pref] = append(grouped[pref], st) } - prefixes := make([]raPrefixSnapshot, 0, len(grouped)) + prefixes = make([]raPrefixSnapshot, 0, len(grouped)) for pref, group := range grouped { snap, ok := collapsePrefixGroup(pref, group) if !ok { @@ -407,35 +464,51 @@ func buildInterfaceRAObservation(states []aghnet.IPv6AddrState) (obs raObservati prefixes = append(prefixes, snap) } - activeIdx := -1 - for i, pref := range prefixes { - if pref.PreferredSec == 0 { - continue - } - - if activeIdx == -1 || betterActivePrefix(pref, prefixes[activeIdx]) { - activeIdx = i - } - } + return prefixes +} +// splitInterfaceRAPrefixSnapshots selects the active snapshot and sorts the +// inactive ones. +func splitInterfaceRAPrefixSnapshots( + prefixes []raPrefixSnapshot, +) (active *raPrefixSnapshot, inactive []raPrefixSnapshot) { + activeIdx := selectInterfaceRAActivePrefixIndex(prefixes) for i, pref := range prefixes { - switch { - case i == activeIdx: - obs.Active = &raPrefixSnapshot{ + if i == activeIdx { + active = &raPrefixSnapshot{ Prefix: pref.Prefix, PreferredSec: pref.PreferredSec, ValidSec: pref.ValidSec, } - default: - obs.Inactive = append(obs.Inactive, pref) + + continue } + + inactive = append(inactive, pref) } - slices.SortFunc(obs.Inactive, func(a, b raPrefixSnapshot) int { + slices.SortFunc(inactive, func(a, b raPrefixSnapshot) int { return prefixCompare(a.Prefix, b.Prefix) }) - return obs + return active, inactive +} + +// selectInterfaceRAActivePrefixIndex reports the best active prefix index in +// prefixes, or -1 when none is suitable. +func selectInterfaceRAActivePrefixIndex(prefixes []raPrefixSnapshot) (activeIdx int) { + activeIdx = -1 + for i, pref := range prefixes { + if pref.PreferredSec == 0 { + continue + } + + if activeIdx == -1 || betterActivePrefix(pref, prefixes[activeIdx]) { + activeIdx = i + } + } + + return activeIdx } // buildStaticRAObservation returns the configured static prefix observation. diff --git a/internal/dhcpd/v6_unix.go b/internal/dhcpd/v6_unix.go index 562c50862c4..39166a687fe 100644 --- a/internal/dhcpd/v6_unix.go +++ b/internal/dhcpd/v6_unix.go @@ -684,7 +684,7 @@ func sameDeadlineMap(a, b map[netip.Prefix]time.Time) (ok bool) { } for pref, until := range a { - if other, ok := b[pref]; !ok || !other.Equal(until) { + if other, found := b[pref]; !found || !other.Equal(until) { return false } } @@ -756,107 +756,136 @@ func (s *v6Server) checkIA(msg *dhcpv6.Message, lease *dhcpsvc.Lease) error { return nil } -// commitLease computes the valid and preferred lifetimes to grant lease in a -// reply to msg. For Request/Renew/Rebind on renewable dynamic leases it also -// updates lease.Expiry and enqueues a lease-change notification. commitLease -// acquires s.leasesLock internally; callers must not hold it. -// -// All of the per-prefix lookups (renewability, valid-until, preferred-until) -// are read under a single critical section so that a concurrent observe tick -// cannot swap the state halfway through the computation. -func (s *v6Server) commitLease( - msg *dhcpv6.Message, - lease *dhcpsvc.Lease, -) (lifetime, preferredLifetime time.Duration) { - s.leasesLock.Lock() - lifetime, preferredLifetime, shouldNotify := s.commitLeaseLocked(time.Now(), msg, lease) - s.leasesLock.Unlock() - - if shouldNotify { - s.conf.notify(LeaseChangedDBStore) - s.conf.notify(LeaseChangedAdded) - } - - return lifetime, preferredLifetime +// leaseCommitSnapshot captures the prefix state used to compute lease +// lifetimes while holding s.leasesLock. +type leaseCommitSnapshot struct { + leaseTime time.Duration + renewable bool + renewableLifetime time.Duration + deprecatedLifetime time.Duration + preferredUntil time.Time + hasPreferredUntil bool } -// commitLeaseLocked is the locked portion of commitLease. s.leasesLock must -// be held. -func (s *v6Server) commitLeaseLocked( +// snapshotLeaseCommitState captures the prefix-tracking state that lease +// lifetime calculations depend on. +func (s *v6Server) snapshotLeaseCommitState( now time.Time, - msg *dhcpv6.Message, lease *dhcpsvc.Lease, -) (lifetime, preferredLifetime time.Duration, shouldNotify bool) { - leaseTime := s.conf.leaseTime - - // Snapshot the prefix-tracking state that the computations below depend - // on so they all agree about which view of the prefix we are acting - // under. +) (snapshot leaseCommitSnapshot) { prefix := netip.PrefixFrom(lease.IP, raObservedPrefixBits).Masked() - renewable := !lease.IsStatic && leasePrefixRenewable(s.renewablePrefixes, lease.IP) + snapshot.leaseTime = s.conf.leaseTime + snapshot.renewable = !lease.IsStatic && leasePrefixRenewable(s.renewablePrefixes, lease.IP) validUntil, hasValidUntil := s.validUntilByPrefix[prefix] - preferredUntil, hasPreferredUntil := s.preferredUntilByPrefix[prefix] + snapshot.preferredUntil, snapshot.hasPreferredUntil = s.preferredUntilByPrefix[prefix] - // renewableLifetime is the lifetime we would grant a fresh lease, - // capped by the prefix's remaining valid lifetime when tracking data is - // available. - renewableLifetime := leaseTime + snapshot.renewableLifetime = snapshot.leaseTime if hasValidUntil { capped := time.Duration(remainingUntil(now, validUntil)) * time.Second - renewableLifetime = min(renewableLifetime, capped) + snapshot.renewableLifetime = min(snapshot.renewableLifetime, capped) } - // deprecatedLifetime is the remaining lifetime we are willing to honor - // for an existing lease whose prefix is no longer renewable. - var deprecatedLifetime time.Duration if hasValidUntil { validForPrefix := time.Duration(remainingUntil(now, validUntil)) * time.Second validForLease := max(time.Until(lease.Expiry), 0) - deprecatedLifetime = min(validForLease, validForPrefix) + snapshot.deprecatedLifetime = min(validForLease, validForPrefix) } - // Default valid lifetime used for Solicit and any non-special cases. - lifetime = leaseTime + return snapshot +} - switch msg.Type() { +// commitLeaseLifetime returns the valid lifetime to use for msg. +func commitLeaseLifetime( + now time.Time, + msgType dhcpv6.MessageType, + lease *dhcpsvc.Lease, + snapshot leaseCommitSnapshot, +) (lifetime time.Duration, shouldNotify bool) { + switch msgType { case dhcpv6.MessageTypeConfirm: switch { case lease.IsStatic: - lifetime = leaseTime - case renewable: - lifetime = min(max(time.Until(lease.Expiry), 0), renewableLifetime) + lifetime = snapshot.leaseTime + case snapshot.renewable: + lifetime = min(max(time.Until(lease.Expiry), 0), snapshot.renewableLifetime) default: - lifetime = deprecatedLifetime + lifetime = snapshot.deprecatedLifetime } - case dhcpv6.MessageTypeRequest, dhcpv6.MessageTypeRenew, dhcpv6.MessageTypeRebind: - switch { case lease.IsStatic: - lifetime = leaseTime - case renewable: - lifetime = renewableLifetime + lifetime = snapshot.leaseTime + case snapshot.renewable: + lifetime = snapshot.renewableLifetime lease.Expiry = now.Add(lifetime) shouldNotify = true default: - lifetime = deprecatedLifetime + lifetime = snapshot.deprecatedLifetime } + default: + lifetime = snapshot.leaseTime } - // Derive preferred lifetime from the same snapshot. + return lifetime, shouldNotify +} + +// commitLeasePreferredLifetime returns the preferred lifetime to use for the +// reply. +func commitLeasePreferredLifetime( + now time.Time, + lifetime time.Duration, + lease *dhcpsvc.Lease, + snapshot leaseCommitSnapshot, +) (preferredLifetime time.Duration) { switch { case lease.IsStatic: - preferredLifetime = lifetime - case !renewable: - preferredLifetime = 0 - case hasPreferredUntil: - preferredForPrefix := time.Duration(remainingUntil(now, preferredUntil)) * time.Second - preferredLifetime = min(lifetime, preferredForPrefix) + return lifetime + case !snapshot.renewable: + return 0 + case snapshot.hasPreferredUntil: + preferredForPrefix := time.Duration(remainingUntil(now, snapshot.preferredUntil)) * time.Second + return min(lifetime, preferredForPrefix) default: - preferredLifetime = lifetime + return lifetime } +} + +// commitLease computes the valid and preferred lifetimes to grant lease in a +// reply to msg. For Request/Renew/Rebind on renewable dynamic leases it also +// updates lease.Expiry and enqueues a lease-change notification. commitLease +// acquires s.leasesLock internally; callers must not hold it. +// +// All of the per-prefix lookups (renewability, valid-until, preferred-until) +// are read under a single critical section so that a concurrent observe tick +// cannot swap the state halfway through the computation. +func (s *v6Server) commitLease( + msg *dhcpv6.Message, + lease *dhcpsvc.Lease, +) (lifetime, preferredLifetime time.Duration) { + s.leasesLock.Lock() + lifetime, preferredLifetime, shouldNotify := s.commitLeaseLocked(time.Now(), msg, lease) + s.leasesLock.Unlock() + + if shouldNotify { + s.conf.notify(LeaseChangedDBStore) + s.conf.notify(LeaseChangedAdded) + } + + return lifetime, preferredLifetime +} + +// commitLeaseLocked is the locked portion of commitLease. s.leasesLock must +// be held. +func (s *v6Server) commitLeaseLocked( + now time.Time, + msg *dhcpv6.Message, + lease *dhcpsvc.Lease, +) (lifetime, preferredLifetime time.Duration, shouldNotify bool) { + snapshot := s.snapshotLeaseCommitState(now, lease) + lifetime, shouldNotify = commitLeaseLifetime(now, msg.Type(), lease, snapshot) + preferredLifetime = commitLeasePreferredLifetime(now, lifetime, lease, snapshot) return lifetime, preferredLifetime, shouldNotify } @@ -881,6 +910,34 @@ func requestedIP(msg *dhcpv6.Message) (ip netip.Addr) { return addr } +// exactRequestedLease reports whether lease matches the requested IP. +func exactRequestedLease(reqIP netip.Addr, lease *dhcpsvc.Lease) (ok bool) { + return reqIP.IsValid() && lease.IP == reqIP +} + +// usableRequestedDynamicLease reports whether a dynamic lease may be served +// for the exact requested IP. +func usableRequestedDynamicLease( + msgType dhcpv6.MessageType, + reqIP netip.Addr, + inCurrentPool bool, + advertisedPrefixes map[netip.Prefix]struct{}, + lease *dhcpsvc.Lease, +) (ok bool) { + if !exactRequestedLease(reqIP, lease) { + return false + } + + return (inCurrentPool || canServeDeprecatedLease(msgType, lease.IP, advertisedPrefixes)) && + leaseNotExpired(lease) +} + +// usableFallbackLease reports whether lease may be reused when no exact match +// was found. +func (s *v6Server) usableFallbackLease(lease *dhcpsvc.Lease) (ok bool) { + return s.ipInCurrentPoolLocked(lease.IP) && leaseNotExpired(lease) +} + // findUsableLease returns a lease that should be served to mac for msg. func (s *v6Server) findUsableLease(msg *dhcpv6.Message, mac net.HardwareAddr) (lease *dhcpsvc.Lease) { reqIP := requestedIP(msg) @@ -891,29 +948,58 @@ func (s *v6Server) findUsableLease(msg *dhcpv6.Message, mac net.HardwareAddr) (l continue } - if l.IsStatic { - if reqIP.IsValid() && l.IP == reqIP { - return l - } else if lease == nil { - lease = l - } - - continue + if usable := s.exactUsableLease(msgType, reqIP, l); usable != nil { + return usable } - - if reqIP.IsValid() && l.IP == reqIP { - if (s.ipInCurrentPoolLocked(l.IP) && leaseNotExpired(l)) || - (canServeDeprecatedLease(msgType, l.IP, s.advertisedPrefixes) && leaseNotExpired(l)) { - return l - } - } else if lease == nil && s.ipInCurrentPoolLocked(l.IP) && leaseNotExpired(l) { - lease = l + if lease == nil { + lease = s.fallbackLease(l) } } return lease } +// exactUsableLease returns lease when the request explicitly targets it and the +// current server state still allows serving it. +func (s *v6Server) exactUsableLease( + msgType dhcpv6.MessageType, + reqIP netip.Addr, + lease *dhcpsvc.Lease, +) (usable *dhcpsvc.Lease) { + if lease.IsStatic { + if exactRequestedLease(reqIP, lease) { + return lease + } + + return nil + } + + if usableRequestedDynamicLease( + msgType, + reqIP, + s.ipInCurrentPoolLocked(lease.IP), + s.advertisedPrefixes, + lease, + ) { + return lease + } + + return nil +} + +// fallbackLease returns the best reusable lease candidate when no exact +// request-target match was found. +func (s *v6Server) fallbackLease(lease *dhcpsvc.Lease) (fallback *dhcpsvc.Lease) { + switch { + case lease.IsStatic: + return lease + case s.usableFallbackLease(lease): + return lease + default: + return nil + } +} + // canServeDeprecatedLease reports whether a deprecated dynamic lease for ip may // still be served for msgType while its prefix remains advertised. func canServeDeprecatedLease( @@ -1028,6 +1114,43 @@ func (s *v6Server) process(msg *dhcpv6.Message, req, resp dhcpv6.DHCPv6) bool { return true } +// newPacketResponse creates the base response for msg. +func newPacketResponse(msg *dhcpv6.Message) (resp dhcpv6.DHCPv6, err error) { + switch msg.Type() { + case dhcpv6.MessageTypeSolicit: + if msg.GetOneOption(dhcpv6.OptionRapidCommit) == nil { + return dhcpv6.NewAdvertiseFromSolicit(msg) + } + + return dhcpv6.NewReplyFromMessage(msg) + case dhcpv6.MessageTypeRequest, + dhcpv6.MessageTypeConfirm, + dhcpv6.MessageTypeRenew, + dhcpv6.MessageTypeRebind, + dhcpv6.MessageTypeRelease, + dhcpv6.MessageTypeInformationRequest: + return dhcpv6.NewReplyFromMessage(msg) + default: + return nil, fmt.Errorf("message type %d not supported", msg.Type()) + } +} + +// addProcessFailureStatus appends the recoverable status code for a failed +// lease-processing path. +func addProcessFailureStatus(msgType dhcpv6.MessageType, resp dhcpv6.DHCPv6) (ok bool) { + code, text, ok := replyStatusForProcessFailure(msgType) + if !ok { + return false + } + + resp.AddOption(&dhcpv6.OptStatusCode{ + StatusCode: code, + StatusMessage: text, + }) + + return true +} + // 1. // fe80::* (client) --(Solicit + ClientID+IANA())-> ff02::1:2 // server -(Advertise + ClientID+ServerID+IANA(IAAddress)> fe80::* @@ -1062,29 +1185,7 @@ func (s *v6Server) packetHandler(conn net.PacketConn, peer net.Addr, req dhcpv6. return } - var resp dhcpv6.DHCPv6 - - switch msg.Type() { - case dhcpv6.MessageTypeSolicit: - if msg.GetOneOption(dhcpv6.OptionRapidCommit) == nil { - resp, err = dhcpv6.NewAdvertiseFromSolicit(msg) - - break - } - - resp, err = dhcpv6.NewReplyFromMessage(msg) - case dhcpv6.MessageTypeRequest, - dhcpv6.MessageTypeConfirm, - dhcpv6.MessageTypeRenew, - dhcpv6.MessageTypeRebind, - dhcpv6.MessageTypeRelease, - dhcpv6.MessageTypeInformationRequest: - resp, err = dhcpv6.NewReplyFromMessage(msg) - default: - log.Error("dhcpv6: message type %d not supported", msg.Type()) - - return - } + resp, err := newPacketResponse(msg) if err != nil { log.Error("dhcpv6: %s", err) @@ -1094,12 +1195,7 @@ func (s *v6Server) packetHandler(conn net.PacketConn, peer net.Addr, req dhcpv6. resp.AddOption(dhcpv6.OptServerID(s.sid)) if !s.process(msg, req, resp) { - if code, text, ok := replyStatusForProcessFailure(msg.Type()); ok { - resp.AddOption(&dhcpv6.OptStatusCode{ - StatusCode: code, - StatusMessage: text, - }) - } else if requiresProcessSuccess(msg.Type()) { + if !addProcessFailureStatus(msg.Type(), resp) && requiresProcessSuccess(msg.Type()) { return } } @@ -1218,13 +1314,16 @@ func (s *v6Server) observeRAState(ctx context.Context) (obs raObservation, err e // // An active prefix whose preferred lifetime has already reached zero is // treated as unavailable for new leases. If another advertised prefix still -// has a non-zero preferred lifetime, the pool is moved there immediately; -// otherwise the pool is set to nil so new Solicit/Request pairs cannot reserve -// addresses on a prefix we would then have to answer with a zero-lifetime -// Reply. Existing leases on deprecated prefixes are still honored via the -// deprecated-lease path in [v6Server.findUsableLease] and +// has a non-zero preferred lifetime, the pool is moved there immediately. +// Otherwise, the pool is set to nil so new Solicit/Request pairs cannot +// reserve addresses on a prefix we would then have to answer with a +// zero-lifetime Reply. Existing leases on deprecated prefixes are still +// honored via the deprecated-lease path in [v6Server.findUsableLease] and // [v6Server.commitLease]. -func (s *v6Server) trackedPrefixChanged(active *raPrefixSnapshot, advertised []prefixPIO) (err error) { +func (s *v6Server) trackedPrefixChanged( + active *raPrefixSnapshot, + advertised []prefixPIO, +) (err error) { if !s.conf.NeedsDHCPv6Pool() { s.setTrackedRangeStart(nil, advertised) @@ -1290,46 +1389,7 @@ func (s *v6Server) setTrackedRangeStart(ipStart net.IP, advertised []prefixPIO) s.renewablePrefixes = renewable s.preferredUntilByPrefix = preferredUntil s.validUntilByPrefix = validUntil - - activePrefix := netip.Prefix{} - if len(ipStart) == net.IPv6len { - if addr, ok := netip.AddrFromSlice(ipStart); ok { - activePrefix = netip.PrefixFrom(addr, raObservedPrefixBits).Masked() - } - } - - // Always clear and rebuild the occupancy bitmap from the surviving - // leases. Callers such as ResetLeases replace s.leases wholesale - // without touching s.ipAddrs, and an earlier version of this function - // that skipped the rebuild when ipStart happened to be unchanged - // left stale bits from dropped leases behind, which made the pool - // appear exhausted long after those addresses had been released. - s.ipAddrs = [256]byte{} - - removed := 0 - updated := false - leases := s.leases[:0] - for _, l := range s.leases { - if !l.IsStatic { - pref := netip.PrefixFrom(l.IP, raObservedPrefixBits).Masked() - if !leasePrefixAdvertised(keepPrefixes, l.IP) || - (activePrefix.IsValid() && pref == activePrefix && !ip6InRange(ipStart, net.IP(l.IP.AsSlice()))) { - removed++ - - continue - } - - if until, ok := validUntil[pref]; ok && (l.Expiry.IsZero() || l.Expiry.After(until)) { - l.Expiry = until - updated = true - } - } - - leases = append(leases, l) - s.markLeaseOccupied(l) - } - - s.leases = leases + removed, updated := s.retainTrackedLeases(ipStart, keepPrefixes, validUntil) newDeprecated := deprecatedMetaFrom(now, renewable, keepPrefixes, validUntil) metadataChanged := (len(oldDeprecated) > 0 || len(newDeprecated) > 0) && (!samePrefixSet(oldRenewable, renewable) || !sameDeadlineMap(oldDeprecated, newDeprecated)) @@ -1373,47 +1433,48 @@ func (s *v6Server) hasStaticV6Leases() (ok bool) { return false } -// restoreDeprecatedPrefixes seeds initial deprecated prefixes from persisted -// metadata whose renewable prefixes still match the currently observed -// interface state. -func (s *v6Server) restoreDeprecatedPrefixes(now time.Time, st *raState) { - s.leasesLock.Lock() - defer s.leasesLock.Unlock() - - s.persistRestoredMeta = false - - if len(s.restoredDeprecated) == 0 { - return - } - +// restoredPrefixesMatchObserved reports whether the persisted renewable +// prefixes still match the currently observed interface state. +func restoredPrefixesMatchObserved( + now time.Time, + st *raState, + restored map[netip.Prefix]struct{}, +) (ok bool) { observedRenewable := renewableLeasePrefixes(st.pios(now)) - if len(s.restoredRenewable) > 0 { - if !prefixSetContainsAll(observedRenewable, s.restoredRenewable) { - return - } - } else if len(observedRenewable) > 0 { - return - } - - advertised := advertisedLeasePrefixes(st.pios(now)) - if len(advertised) == 0 { - return + switch { + case len(restored) > 0: + return prefixSetContainsAll(observedRenewable, restored) + case len(observedRenewable) > 0: + return false + default: + return true } +} - if len(s.restoredRenewable) == 0 { - overlap := false - for pref := range advertised { - if _, ok := s.restoredDeprecated[pref]; ok { - overlap = true - break - } - } - if !overlap { - return +// restoredDeprecatedPrefixOverlap reports whether any persisted deprecated +// prefix is still advertised. +func restoredDeprecatedPrefixOverlap( + advertised map[netip.Prefix]struct{}, + restored map[netip.Prefix]time.Time, +) (ok bool) { + for pref := range advertised { + if _, found := restored[pref]; found { + return true } } - for pref, until := range s.restoredDeprecated { + return false +} + +// restoreDeprecatedPrefixEntries seeds the tracked state with persisted +// deprecated prefixes that are no longer advertised. +func restoreDeprecatedPrefixEntries( + st *raState, + now time.Time, + advertised map[netip.Prefix]struct{}, + restored map[netip.Prefix]time.Time, +) { + for pref, until := range restored { if _, ok := advertised[pref]; ok { continue } @@ -1435,6 +1496,36 @@ func (s *v6Server) restoreDeprecatedPrefixes(now time.Time, st *raState) { } } +// restoreDeprecatedPrefixes seeds initial deprecated prefixes from persisted +// metadata whose renewable prefixes still match the currently observed +// interface state. +func (s *v6Server) restoreDeprecatedPrefixes(now time.Time, st *raState) { + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + s.persistRestoredMeta = false + + if len(s.restoredDeprecated) == 0 { + return + } + + if !restoredPrefixesMatchObserved(now, st, s.restoredRenewable) { + return + } + + advertised := advertisedLeasePrefixes(st.pios(now)) + if len(advertised) == 0 { + return + } + + if len(s.restoredRenewable) == 0 && + !restoredDeprecatedPrefixOverlap(advertised, s.restoredDeprecated) { + return + } + + restoreDeprecatedPrefixEntries(st, now, advertised, s.restoredDeprecated) +} + // setRestoredPrefixMeta stores deprecated-prefix metadata loaded from disk. func (s *v6Server) setRestoredPrefixMeta( renewable map[netip.Prefix]struct{}, @@ -1450,7 +1541,12 @@ func (s *v6Server) setRestoredPrefixMeta( // deprecatedPrefixMeta returns persisted metadata for currently tracked // interface-derived prefixes. -func (s *v6Server) deprecatedPrefixMeta(now time.Time) (renewable map[netip.Prefix]struct{}, deprecated map[netip.Prefix]time.Time) { +func (s *v6Server) deprecatedPrefixMeta( + now time.Time, +) ( + renewable map[netip.Prefix]struct{}, + deprecated map[netip.Prefix]time.Time, +) { s.leasesLock.Lock() defer s.leasesLock.Unlock() @@ -1486,98 +1582,165 @@ func (s *v6Server) deprecatedPrefixMetaLocked(now time.Time) (renewable map[neti return renewable, deprecated } -// Start starts the IPv6 DHCP server. -func (s *v6Server) Start(ctx context.Context) (err error) { - defer func() { err = errors.Annotate(err, "dhcpv6: %w") }() - - if !s.conf.Enabled { - return nil +// startPrefixSourceState initializes the RA state and callbacks for the +// configured prefix source. +func (s *v6Server) startPrefixSourceState( + ctx context.Context, +) (initial raState, observe raObserver, err error) { + switch s.conf.NormalizedPrefixSource() { + case V6PrefixSourceStatic: + initial = newStaticRAState(buildStaticRAObservation(s.dnsIPAddrs(), s.conf.ipStart)) + s.ra.onStateRefresh = nil + s.ra.onActivePrefixChange = nil + return initial, nil, nil + case V6PrefixSourceInterface: + return s.startInterfacePrefixTracking(ctx) + default: + return raState{}, nil, fmt.Errorf("unsupported prefix source %q", s.conf.PrefixSource) } +} - ifaceName := s.conf.InterfaceName - iface, err := net.InterfaceByName(ifaceName) - if err != nil { - return fmt.Errorf("finding interface %s by name: %w", ifaceName, err) +// startInterfacePrefixTracking initializes interface-derived prefix tracking. +func (s *v6Server) startInterfacePrefixTracking( + ctx context.Context, +) (initial raState, observe raObserver, err error) { + if s.hasStaticV6Leases() { + s.conf.Logger.WarnContext( + ctx, + "dhcpv6: interface-derived prefix tracking does not rewrite literal static IPv6 leases", + ) } - log.Debug("dhcpv6: starting...") + initial = newObservedRAState() - ok, err := s.configureDNSIPAddrs(ctx, iface) - if err != nil { - // Don't wrap the error, because it's informative enough as is. - return err + // Fail fast on initial-observation errors in interface mode. The rest of + // the server depends on having at least one observed prefix to bootstrap + // ipStart, advertisedPrefixes and the deadline maps; without them + // reserveLease can't allocate addresses and findUsableLease can't renew + // existing leases, so swallowing the error here would bring DHCPv6 up as + // "enabled" while silently refusing to hand out or renew anything. + obs, obsErr := s.observeRAState(ctx) + if obsErr != nil { + return raState{}, nil, fmt.Errorf("observing initial ipv6 prefix state: %w", obsErr) } - if !ok { - if s.conf.NormalizedPrefixSource() != V6PrefixSourceInterface { - // No available IP addresses which may appear later. - return nil + now := time.Now() + initial.merge(obs, now) + s.restoreDeprecatedPrefixes(now, &initial) + if pios := initial.pios(now); len(pios) > 0 { + if err = s.trackedPrefixChanged(initial.activeSnapshot(now), pios); err != nil { + return raState{}, nil, fmt.Errorf("updating tracked range start: %w", err) } } - var ( - initial raState - observe raObserver - ) - - switch s.conf.NormalizedPrefixSource() { - case V6PrefixSourceStatic: - initial = newStaticRAState(buildStaticRAObservation(s.dnsIPAddrs(), s.conf.ipStart)) - s.ra.onStateRefresh = nil - s.ra.onActivePrefixChange = nil - case V6PrefixSourceInterface: - if s.hasStaticV6Leases() { - s.conf.Logger.WarnContext( - ctx, - "dhcpv6: interface-derived prefix tracking does not rewrite literal static IPv6 leases", - ) + observe = s.observeRAState + s.ra.onStateRefresh = func(now time.Time, st *raState) { + s.restoreDeprecatedPrefixes(now, st) + } + s.ra.onActivePrefixChange = func(active *raPrefixSnapshot, advertised []prefixPIO) { + if activeErr := s.trackedPrefixChanged(active, advertised); activeErr != nil { + log.Error("dhcpv6: updating tracked pool: %s", activeErr) } + } - initial = newObservedRAState() - - // Fail fast on initial-observation errors in interface mode. - // The rest of the server depends on having at least one - // observed prefix to bootstrap ipStart, advertisedPrefixes and - // the deadline maps; without them reserveLease can't allocate - // addresses and findUsableLease can't renew existing leases, - // so swallowing the error here would bring DHCPv6 up as - // "enabled" while silently refusing to hand out or renew - // anything. Transient errors are rare compared to permanent - // environment misconfigurations (missing ifconfig, netlink - // denied, wrong interface, cancelled context) and surfacing - // them at Start() is how an operator notices. - obs, obsErr := s.observeRAState(ctx) - if obsErr != nil { - return fmt.Errorf("observing initial ipv6 prefix state: %w", obsErr) - } + return initial, observe, nil +} + +// activeTrackedPrefix returns the prefix for the current tracked DHCPv6 pool. +func activeTrackedPrefix(ipStart net.IP) (prefix netip.Prefix) { + if len(ipStart) != net.IPv6len { + return netip.Prefix{} + } + + if addr, ok := netip.AddrFromSlice(ipStart); ok { + return netip.PrefixFrom(addr, raObservedPrefixBits).Masked() + } + + return netip.Prefix{} +} + +// shouldKeepTrackedLease reports whether l still belongs to the active tracked +// pool after the prefix transition. +func shouldKeepTrackedLease( + ipStart net.IP, + activePrefix netip.Prefix, + keepPrefixes map[netip.Prefix]struct{}, + l *dhcpsvc.Lease, +) (ok bool) { + if !leasePrefixAdvertised(keepPrefixes, l.IP) { + return false + } + + if activePrefix.IsValid() && + netip.PrefixFrom(l.IP, raObservedPrefixBits).Masked() == activePrefix && + !ip6InRange(ipStart, net.IP(l.IP.AsSlice())) { + return false + } + + return true +} + +// updateTrackedLeaseExpiry clamps l to the tracked prefix deadline when +// needed. +func updateTrackedLeaseExpiry( + validUntil map[netip.Prefix]time.Time, + l *dhcpsvc.Lease, +) (updated bool) { + pref := netip.PrefixFrom(l.IP, raObservedPrefixBits).Masked() + until, ok := validUntil[pref] + if !ok || (!l.Expiry.IsZero() && !l.Expiry.After(until)) { + return false + } - now := time.Now() - initial.merge(obs, now) - s.restoreDeprecatedPrefixes(now, &initial) - if pios := initial.pios(now); len(pios) > 0 { - if err = s.trackedPrefixChanged(initial.activeSnapshot(now), pios); err != nil { - return fmt.Errorf("updating tracked range start: %w", err) + l.Expiry = until + + return true +} + +// retainTrackedLeases rebuilds the in-memory lease slice for a tracked prefix +// transition. +func (s *v6Server) retainTrackedLeases( + ipStart net.IP, + keepPrefixes map[netip.Prefix]struct{}, + validUntil map[netip.Prefix]time.Time, +) (removed int, updated bool) { + activePrefix := activeTrackedPrefix(ipStart) + + // Always clear and rebuild the occupancy bitmap from the surviving + // leases. + s.ipAddrs = [256]byte{} + + leases := s.leases[:0] + for _, l := range s.leases { + if !l.IsStatic { + if !shouldKeepTrackedLease(ipStart, activePrefix, keepPrefixes, l) { + removed++ + + continue } - } - observe = s.observeRAState - s.ra.onStateRefresh = func(now time.Time, st *raState) { - s.restoreDeprecatedPrefixes(now, st) - } - s.ra.onActivePrefixChange = func(active *raPrefixSnapshot, advertised []prefixPIO) { - if activeErr := s.trackedPrefixChanged(active, advertised); activeErr != nil { - log.Error("dhcpv6: updating tracked pool: %s", activeErr) + if updateTrackedLeaseExpiry(validUntil, l) { + updated = true } } - default: - return fmt.Errorf("unsupported prefix source %q", s.conf.PrefixSource) - } - err = s.initRA(iface, initial, observe) - if err != nil { - return err + leases = append(leases, l) + s.markLeaseOccupied(l) } + s.leases = leases + + return removed, updated +} + +// skipStartAfterDNSConfig reports whether Start should return after the DNS +// address lookup without initializing RA state yet. +func skipStartAfterDNSConfig(ok bool, prefixSource V6PrefixSource) (skip bool) { + return !ok && prefixSource != V6PrefixSourceInterface +} + +// startDHCPv6Server initializes the DHCPv6 listener after RA state is ready. +func (s *v6Server) startDHCPv6Server(iface *net.Interface) (err error) { // Don't initialize DHCPv6 server if we must force the clients to use SLAAC. if !s.conf.NeedsDHCPv6Pool() { log.Debug("not starting dhcpv6 server due to ra_slaac_only=true") @@ -1614,6 +1777,46 @@ func (s *v6Server) Start(ctx context.Context) (err error) { return nil } +// Start starts the IPv6 DHCP server. +func (s *v6Server) Start(ctx context.Context) (err error) { + defer func() { err = errors.Annotate(err, "dhcpv6: %w") }() + + if !s.conf.Enabled { + return nil + } + + ifaceName := s.conf.InterfaceName + iface, err := net.InterfaceByName(ifaceName) + if err != nil { + return fmt.Errorf("finding interface %s by name: %w", ifaceName, err) + } + + log.Debug("dhcpv6: starting...") + + ok, err := s.configureDNSIPAddrs(ctx, iface) + if err != nil { + // Don't wrap the error, because it's informative enough as is. + return err + } + + if skipStartAfterDNSConfig(ok, s.conf.NormalizedPrefixSource()) { + // No available IP addresses which may appear later. + return nil + } + + initial, observe, err := s.startPrefixSourceState(ctx) + if err != nil { + return err + } + + err = s.initRA(iface, initial, observe) + if err != nil { + return err + } + + return s.startDHCPv6Server(iface) +} + // Stop - stop server func (s *v6Server) Stop() (err error) { err = s.ra.Close() @@ -1638,6 +1841,52 @@ func (s *v6Server) Stop() (err error) { return nil } +// validateV6CreateRangeStart checks whether conf has the range-start value the +// server setup needs. +func validateV6CreateRangeStart(conf V6ServerConf) (err error) { + needsConfiguredRange := conf.NormalizedPrefixSource() == V6PrefixSourceStatic || conf.NeedsDHCPv6Pool() + if needsConfiguredRange && (conf.RangeStart == nil || conf.RangeStart.To16() == nil) { + return fmt.Errorf("invalid range-start IP: %s", conf.RangeStart) + } + + if len(conf.RangeStart) != 0 && conf.RangeStart.To16() == nil { + return fmt.Errorf("invalid range-start IP: %s", conf.RangeStart) + } + + return nil +} + +// configureV6CreateRangeStart normalizes the configured range-start IP. +func configureV6CreateRangeStart(conf *V6ServerConf) { + if len(conf.RangeStart) == 0 { + return + } + + conf.RangeStart = bytes.Clone(conf.RangeStart.To16()) +} + +// configureV6CreateStaticPrefix seeds the tracked pool for static prefix +// source mode. +func (s *v6Server) configureV6CreateStaticPrefix() { + s.conf.ipStart = bytes.Clone(s.conf.RangeStart) + if addr, ok := netip.AddrFromSlice(s.conf.ipStart); ok { + prefix := netip.PrefixFrom(addr, raObservedPrefixBits).Masked() + s.advertisedPrefixes = map[netip.Prefix]struct{}{prefix: {}} + s.renewablePrefixes = map[netip.Prefix]struct{}{prefix: {}} + } +} + +// configureV6CreateLeaseDuration fills in the effective lease duration. +func (s *v6Server) configureV6CreateLeaseDuration(conf V6ServerConf) { + if conf.LeaseDuration == 0 { + s.conf.leaseTime = timeutil.Day + s.conf.LeaseDuration = uint32(s.conf.leaseTime.Seconds()) + return + } + + s.conf.leaseTime = time.Second * time.Duration(conf.LeaseDuration) +} + // Create DHCPv6 server func v6Create(conf V6ServerConf) (DHCPServer, error) { s := &v6Server{} @@ -1664,33 +1913,16 @@ func v6Create(conf V6ServerConf) (DHCPServer, error) { return s, nil } - needsConfiguredRange := conf.NormalizedPrefixSource() == V6PrefixSourceStatic || conf.NeedsDHCPv6Pool() - if needsConfiguredRange && (conf.RangeStart == nil || conf.RangeStart.To16() == nil) { - return s, fmt.Errorf("dhcpv6: invalid range-start IP: %s", conf.RangeStart) - } - if len(conf.RangeStart) != 0 { - if conf.RangeStart.To16() == nil { - return s, fmt.Errorf("dhcpv6: invalid range-start IP: %s", conf.RangeStart) - } - - s.conf.RangeStart = bytes.Clone(conf.RangeStart.To16()) + if err = validateV6CreateRangeStart(conf); err != nil { + return s, fmt.Errorf("dhcpv6: %w", err) } + configureV6CreateRangeStart(&s.conf) if conf.NormalizedPrefixSource() == V6PrefixSourceStatic { - s.conf.ipStart = bytes.Clone(s.conf.RangeStart) - if addr, ok := netip.AddrFromSlice(s.conf.ipStart); ok { - prefix := netip.PrefixFrom(addr, raObservedPrefixBits).Masked() - s.advertisedPrefixes = map[netip.Prefix]struct{}{prefix: {}} - s.renewablePrefixes = map[netip.Prefix]struct{}{prefix: {}} - } + s.configureV6CreateStaticPrefix() } - if conf.LeaseDuration == 0 { - s.conf.leaseTime = timeutil.Day - s.conf.LeaseDuration = uint32(s.conf.leaseTime.Seconds()) - } else { - s.conf.leaseTime = time.Second * time.Duration(conf.LeaseDuration) - } + s.configureV6CreateLeaseDuration(conf) return s, nil } diff --git a/internal/dhcpd/v6_unix_internal_test.go b/internal/dhcpd/v6_unix_internal_test.go index 524a98d2c57..6f7b20f0d36 100644 --- a/internal/dhcpd/v6_unix_internal_test.go +++ b/internal/dhcpd/v6_unix_internal_test.go @@ -726,8 +726,9 @@ func TestV6ResetLeases_PreservesAdvertisedInterfacePrefixes(t *testing.T) { require.NoError(t, err) require.Len(t, s.leases, 2) - assert.Contains(t, []netip.Addr{s.leases[0].IP, s.leases[1].IP}, netip.MustParseAddr("2001:db8::10")) - assert.Contains(t, []netip.Addr{s.leases[0].IP, s.leases[1].IP}, netip.MustParseAddr("2001:db8:1::10")) + gotLeases := []netip.Addr{s.leases[0].IP, s.leases[1].IP} + assert.Contains(t, gotLeases, netip.MustParseAddr("2001:db8::10")) + assert.Contains(t, gotLeases, netip.MustParseAddr("2001:db8:1::10")) } func TestObservedDNSIPAddrs(t *testing.T) {