@@ -53,10 +89,12 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }
name="v6.range_start"
control={control}
rules={{
- validate: isInterfaceIncludesIpv6
+ validate: canConfigureV6
? {
ipv6: validateIpv6,
- required: validateRequiredValue,
+ required: isRequiredForPool
+ ? validateRequiredValue
+ : undefined,
}
: undefined,
}}
@@ -65,9 +103,19 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }
{...field}
type="text"
data-testid="v6_range_start"
+ label={
+ prefixSource === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE
+ ? t('dhcp_form_host_template_input')
+ : t('dhcp_form_range_start')
+ }
placeholder={t(ipv6placeholders.range_start)}
+ desc={
+ prefixSource === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE
+ ? t('dhcp_form_host_template_desc')
+ : undefined
+ }
error={fieldState.error?.message}
- disabled={!isInterfaceIncludesIpv6}
+ disabled={!canConfigureV6}
/>
)}
/>
@@ -100,9 +148,11 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }
name="v6.lease_duration"
control={control}
rules={{
- validate: isInterfaceIncludesIpv6
+ validate: canConfigureV6
? {
- required: validateRequiredValue,
+ required: isRequiredForPool
+ ? validateRequiredValue
+ : undefined,
}
: undefined,
}}
@@ -114,7 +164,7 @@ const FormDHCPv6 = ({ processingConfig, ipv6placeholders, interfaces, onSubmit }
label={t('dhcp_form_lease_title')}
placeholder={t(ipv6placeholders.lease_duration)}
error={fieldState.error?.message}
- disabled={!isInterfaceIncludesIpv6}
+ disabled={!canConfigureV6}
min={1}
max={UINT32_RANGE.MAX}
onChange={(e) => {
diff --git a/client/src/components/Settings/Dhcp/index.tsx b/client/src/components/Settings/Dhcp/index.tsx
index 2daf4e65556..2fd1208b06e 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,37 @@ const DEFAULT_V4_VALUES = {
};
const DEFAULT_V6_VALUES = {
+ prefix_source: DHCP_V6_PREFIX_SOURCE_VALUES.STATIC,
+ ra_slaac_only: false,
range_start: '',
range_end: '',
lease_duration: undefined,
};
+export const isInterfaceSLAACOnlyV6Config = (v6Config: IPv6FormValues) =>
+ v6Config?.prefix_source === DHCP_V6_PREFIX_SOURCE_VALUES.INTERFACE && Boolean(v6Config?.ra_slaac_only);
+
+// V6_CONFIG_METADATA_KEYS are the IPv6 form fields that describe how the rest
+// of the configuration should be interpreted, rather than being DHCPv6 inputs
+// themselves. They do not count as "meaningful" values on their own.
+const V6_CONFIG_METADATA_KEYS = new Set(['prefix_source', 'ra_slaac_only']);
+
+export const hasMeaningfulV6Value = (v6Config: IPv6FormValues) =>
+ isInterfaceSLAACOnlyV6Config(v6Config)
+ || Object.entries(v6Config || {}).some(
+ ([key, value]) => !V6_CONFIG_METADATA_KEYS.has(key) && Boolean(value),
+ );
+
+const isFilledV6Config = (v6Config: IPv6FormValues) =>
+ Object.entries(v6Config || {}).every(([key, value]) => (
+ V6_CONFIG_METADATA_KEYS.has(key)
+ || (
+ (key === 'range_start' || key === 'lease_duration')
+ && isInterfaceSLAACOnlyV6Config(v6Config)
+ )
+ || Boolean(value)
+ ));
+
const Dhcp = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
@@ -204,11 +232,11 @@ const Dhcp = () => {
const enteredSomeV4Value = Object.values(v4).some(Boolean);
- const enteredSomeV6Value = Object.values(v6).some(Boolean);
+ const enteredSomeV6Value = hasMeaningfulV6Value(v6);
const enteredSomeValue = enteredSomeV4Value || enteredSomeV6Value || interfaceName;
const getToggleDhcpButton = () => {
- const filledConfig = interface_name && (Object.values(v4).every(Boolean) || Object.values(v6).every(Boolean));
+ const filledConfig = interface_name && (Object.values(v4).every(Boolean) || isFilledV6Config(v6));
const className = classNames('btn btn-sm', {
'btn-gray': enabled,
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..8be96cb4011
--- /dev/null
+++ b/internal/aghnet/prefix.go
@@ -0,0 +1,169 @@
+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++ {
+ i, err = parseIfconfigIPv6AddrField(fields, i, &state, &preferred, &valid, &prefixBits)
+ if err != nil {
+ return IPv6AddrState{}, err
+ }
+ }
+
+ 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
+}
+
+// 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, parseErr := strconv.ParseUint(s, 10, 32)
+ if parseErr != nil {
+ return 0, parseErr
+ }
+
+ 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..cc8040e8d4c
--- /dev/null
+++ b/internal/aghnet/prefix_linux.go
@@ -0,0 +1,273 @@
+//go:build linux
+
+package aghnet
+
+import (
+ "context"
+ "encoding/binary"
+ "fmt"
+ "log/slog"
+ "net"
+ "net/netip"
+
+ "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 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,
+ _ 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)
+ }
+
+ conn, err := netlink.Dial(unix.NETLINK_ROUTE, nil)
+ if err != nil {
+ return nil, fmt.Errorf("dialing rtnetlink: %w", err)
+ }
+ defer func() { err = errors.WithDeferred(err, conn.Close()) }()
+
+ 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("querying rtnetlink addrs: %w", err)
+ }
+
+ return parseIPv6AddrStatesNetlink(msgs, iface.Index)
+}
+
+// parseIPv6AddrStatesNetlink parses IPv6 address state from netlink messages.
+func parseIPv6AddrStatesNetlink(
+ msgs []netlink.Message,
+ ifIndex int,
+) (states []IPv6AddrState, err error) {
+ for _, msg := range msgs {
+ state, done, ok, err := parseIPv6AddrStateMessage(msg, ifIndex)
+ if err != nil {
+ return nil, err
+ }
+ if done {
+ return states, nil
+ }
+ if ok {
+ states = append(states, state)
+ }
+ }
+
+ 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 unix.IfAddrmsg,
+ attrs []netlink.Attribute,
+) (state IPv6AddrState, ok bool, err error) {
+ var addr netip.Addr
+ var cache *ipv6AddrCacheInfo
+ flags := uint32(ifam.Flags)
+
+ for _, attr := range attrs {
+ addr, flags, cache, err = parseIPv6AddrStateNetlinkAttr(attr, addr, flags, cache)
+ if err != nil {
+ return IPv6AddrState{}, false, err
+ }
+ }
+
+ if !addr.IsValid() {
+ return IPv6AddrState{}, false, nil
+ }
+
+ preferred, valid := uint32(^uint32(0)), uint32(^uint32(0))
+ if cache != nil {
+ preferred = cache.preferredLifetimeSec
+ valid = cache.validLifetimeSec
+ }
+
+ 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
+}
+
+// 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 {
+ 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..37d1819cf07
--- /dev/null
+++ b/internal/aghnet/prefix_linux_internal_test.go
@@ -0,0 +1,47 @@
+//go:build linux
+
+package aghnet
+
+import (
+ "encoding/binary"
+ "net/netip"
+ "testing"
+
+ "github.com/mdlayher/netlink"
+ "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)
+
+ cacheBytes := make([]byte, unix.SizeofIfaCacheinfo)
+ binary.NativeEndian.PutUint32(cacheBytes[0:4], 600)
+ binary.NativeEndian.PutUint32(cacheBytes[4:8], 1200)
+
+ state, ok, err := parseIPv6AddrStateNetlink(unix.IfAddrmsg{
+ Family: unix.AF_INET6,
+ Prefixlen: 64,
+ }, []netlink.Attribute{{
+ Type: unix.IFA_ADDRESS,
+ Data: addr[:],
+ }, {
+ Type: unix.IFA_FLAGS,
+ Data: flags,
+ }, {
+ Type: unix.IFA_CACHEINFO,
+ Data: 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..ef800111e25 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,10 +268,18 @@ type V6ServerConf struct {
// The last allowed IP address ends with 0xff byte
RangeStart net.IP `yaml:"range_start" json:"range_start"`
- LeaseDuration uint32 `yaml:"lease_duration" json:"lease_duration"` // in seconds
+ // 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"`
- 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
+ // 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"`
+
+ // 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
@@ -265,3 +288,43 @@ type V6ServerConf struct {
// 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..da9753da44f 100644
--- a/internal/dhcpd/db.go
+++ b/internal/dhcpd/db.go
@@ -34,6 +34,41 @@ 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"`
+}
+
+// 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.
@@ -106,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)
@@ -126,49 +184,126 @@ 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)
+ 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
+ }
+
+ until, err := time.Parse(time.RFC3339, dp.ValidUntil)
if err != nil {
- return fmt.Errorf("resetting dhcpv6 leases: %w", err)
+ 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 {
+ leases, v6Meta = s.dbStoreV6(leases)
}
- if s.srv6 != nil {
- for _, l := range s.srv6.getLeasesRef() {
- leases = append(leases, fromLease(l))
- }
+ 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 writeDB(s.conf.dbFilePath, leases)
+ return 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 +313,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..b6125ad717e 100644
--- a/internal/dhcpd/http_unix.go
+++ b/internal/dhcpd/http_unix.go
@@ -49,18 +49,40 @@ func (j *v4ServerConfJSON) toServerConf() *V4ServerConf {
}
type v6ServerConfJSON struct {
- RangeStart netip.Addr `json:"range_start"`
- LeaseDuration uint32 `json:"lease_duration"`
-}
-
-func v6JSONToServerConf(j *v6ServerConfJSON) V6ServerConf {
- if j == nil {
- return V6ServerConf{}
+ RangeStart *netip.Addr `json:"range_start"`
+ LeaseDuration *uint32 `json:"lease_duration"`
+ 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 {
+ prefixSource := cur.NormalizedPrefixSource()
+ rangeStart := cur.RangeStart
+ leaseDuration := cur.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{
- RangeStart: j.RangeStart.AsSlice(),
- LeaseDuration: j.LeaseDuration,
+ RangeStart: rangeStart,
+ LeaseDuration: leaseDuration,
+ PrefixSource: prefixSource,
}
}
@@ -270,21 +292,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 +788,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..65b88387181 100644
--- a/internal/dhcpd/http_unix_internal_test.go
+++ b/internal/dhcpd/http_unix_internal_test.go
@@ -5,13 +5,18 @@ package dhcpd
import (
"bytes"
"encoding/json"
+ "net"
"net/http"
"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"
)
@@ -24,7 +29,7 @@ func defaultResponse() *dhcpStatusResponse {
resp := &dhcpStatusResponse{
V4: *conf4,
- V6: V6ServerConf{},
+ V6: V6ServerConf{PrefixSource: V6PrefixSourceStatic},
Leases: []*leaseDynamic{},
StaticLeases: []*leaseStatic{},
Enabled: true,
@@ -33,6 +38,163 @@ 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)
+}
+
+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)
+}
+
+// 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/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..09fd3bd5fac 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,83 @@ 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
- // stop is used to stop the packet sending loop.
- stop atomic.Value
+ // observe refreshes interface-derived RA state. It is nil for static mode.
+ observe raObserver
+
+ // 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
+ lastDigest raStateDigest
+
+ 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,106 +140,144 @@ 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]
+ // MTU option.
+ data[i] = 5
+ data[i+1] = 1
i += 4
- binary.BigEndian.PutUint32(data[i:], 3600) // Preferred Lifetime[4]
+ binary.BigEndian.PutUint32(data[i:], params.mtu)
i += 4
- binary.BigEndian.PutUint32(data[i:], 0) // Reserved[4]
- 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]
- 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
+ // Source Link-Layer Address option.
+ data[i] = 1
+ data[i+1] = byte(srcLLAOptLenValue)
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 {
+ ra.lastDigest = ra.state.digest(time.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")
+ }
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ ra.cancel = cancel
+ ra.wg.Add(1)
+ go ra.loop(ctx)
- params := icmpv6RA{
- managedAddressConfiguration: !ra.raSLAACOnly,
- otherConfiguration: !ra.raSLAACOnly,
- mtu: uint32(ra.iface.MTU),
- prefixLen: 64,
- recursiveDNSServer: ra.dnsIPAddr,
- sourceLinkLayerAddress: ra.iface.HardwareAddr,
+ 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() {
+ return ra.closeConn()
}
- 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
+ }
+
+ if ra.conn != nil {
+ if err = ra.closeConn(); err != nil {
+ return fmt.Errorf("closing previous icmp listener: %w", err)
+ }
}
- ipAndScope := ra.ipAddr.String() + "%" + ra.ifaceName
- ra.conn, err = icmp.ListenPacket("ip6:ipv6-icmp", ipAndScope)
+ 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 {
return fmt.Errorf("dhcpv6 ra: icmp.ListenPacket: %w", err)
}
defer func() {
if err != nil {
- err = errors.WithDeferred(err, ra.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)
}
@@ -273,39 +286,167 @@ 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.conn = newConn
+ 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()
+ change := ra.state.merge(obs, now)
+ if ra.onStateRefresh != nil {
+ ra.onStateRefresh(now, &ra.state)
+ }
+ ra.syncStateChange(now, &change)
+}
+
+// sendPacket rebuilds and sends the current Router Advertisement packet.
+func (ra *raCtx) sendPacket() {
+ now := time.Now()
+ ra.syncStateChange(now, nil)
+
+ 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 module.
+// 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 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, change *raActiveChange) {
+ digest := ra.state.digest(now)
+ changed := !sameRAStateDigest(ra.lastDigest, digest)
+ ra.lastDigest = digest
+
+ if !changed || ra.onActivePrefixChange == nil {
+ return
+ }
+
+ 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 c876a67ac20..2868c991fe2 100644
--- a/internal/dhcpd/routeradv_internal_test.go
+++ b/internal/dhcpd/routeradv_internal_test.go
@@ -1,8 +1,12 @@
package dhcpd
import (
- "net"
+ "context"
+ "encoding/binary"
+ "net/netip"
+ "slices"
"testing"
+ "time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
@@ -15,10 +19,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 +40,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 +48,284 @@ 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,
+ },
+ 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, slices.Clone(advertised))
+ }
+ ra.lastDigest = ra.state.digest(now)
+
+ ra.syncStateChange(now.Add(2*time.Second), nil)
+
+ 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_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(),
+ }
+
+ 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++
+ }
+
+ // 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), nil)
+ ra.syncStateChange(now.Add(3*time.Second), nil)
+ ra.syncStateChange(now.Add(5*time.Second), nil)
+
+ assert.Zero(t, fired)
+}
+
+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.lastDigest = ra.state.digest(now)
+
+ var notifications [][]prefixPIO
+ ra.onActivePrefixChange = func(_ *raPrefixSnapshot, advertised []prefixPIO) {
+ notifications = append(notifications, slices.Clone(advertised))
+ }
+
+ ra.state.merge(raObservation{
+ Active: &raPrefixSnapshot{
+ Prefix: netip.MustParsePrefix("2001:db8:1::/64"),
+ PreferredSec: 1700,
+ ValidSec: 3500,
},
- }, {
- 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: 1100,
+ }},
+ }, now.Add(time.Minute))
+
+ ra.syncStateChange(now.Add(time.Minute), nil)
+
+ require.Len(t, notifications, 1)
+ require.Len(t, notifications[0], 2)
+ assert.Equal(t, uint32(0), notifications[0][1].PreferredSec)
+}
+
+// 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,
},
- }}
- assert.Equal(t, wantOpts, raPkt.Options)
+ }, 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), 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), nil)
+ 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), nil)
+ ra.syncStateChange(now.Add(310*time.Second), nil)
+ assert.Len(t, notifications, 1)
+}
+
+// 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), nil)
+ ra.syncStateChange(now.Add(5*time.Second), nil)
+ ra.syncStateChange(now.Add(30*time.Second), nil)
+ assert.Zero(t, fired)
+}
+
+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())
+}
+
+// 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_internal_test.go b/internal/dhcpd/routeradv_state_internal_test.go
new file mode 100644
index 00000000000..2aec1bc8583
--- /dev/null
+++ b/internal/dhcpd/routeradv_state_internal_test.go
@@ -0,0 +1,471 @@
+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)
+}
+
+// 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/routeradvstate.go b/internal/dhcpd/routeradvstate.go
new file mode 100644
index 00000000000..a1622f82bd9
--- /dev/null
+++ b/internal/dhcpd/routeradvstate.go
@@ -0,0 +1,831 @@
+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
+}
+
+// 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, found := b.deprecated[pref]
+ if !found || other != digest {
+ return false
+ }
+ }
+
+ return true
+}
+
+// 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
+}
+
+// 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) 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
+
+ activeChanged = s.mergeActiveObservation(obs.Active, prev, now)
+
+ if s.active != nil {
+ delete(s.deprecated, s.active.prefix)
+ }
+
+ observedInactive := s.mergeInactiveObservations(obs.Inactive, prevActivePrefix, activeChanged, now)
+
+ s.deprecateMissingObservedInactivePrefixes(observedInactive, now)
+
+ s.evictExpired(now)
+
+ next := s.activeSnapshot(now)
+ return raActiveChange{
+ Changed: !sameActivePrefix(prev, next),
+ Active: next,
+ }
+}
+
+// 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
+ }
+
+ return activeChanged
+}
+
+// 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{}{}
+
+ 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)
+
+ return
+ }
+
+ s.deprecated[dep.Prefix] = reconcileTrackedPrefix(
+ s.deprecated[dep.Prefix],
+ raPrefixSnapshot{
+ Prefix: dep.Prefix,
+ PreferredSec: 0,
+ ValidSec: valid,
+ },
+ 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
+ }
+
+ if _, ok := observedInactive[pref]; !ok {
+ s.deprecateTrackedPrefix(pref, tracked, now)
+ }
+ }
+}
+
+// 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
+ }
+ valid = capDeprecatedLifetime(valid)
+
+ 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
+
+ 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) {
+ 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)
+ }
+
+ 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 {
+ if i == activeIdx {
+ active = &raPrefixSnapshot{
+ Prefix: pref.Prefix,
+ PreferredSec: pref.PreferredSec,
+ ValidSec: pref.ValidSec,
+ }
+
+ continue
+ }
+
+ inactive = append(inactive, pref)
+ }
+
+ slices.SortFunc(inactive, func(a, b raPrefixSnapshot) int {
+ return prefixCompare(a.Prefix, b.Prefix)
+ })
+
+ 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.
+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
+}
+
+// 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 {
+ 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/v6_unix.go b/internal/dhcpd/v6_unix.go
index c7e956f944a..39166a687fe 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,11 +127,19 @@ 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
+ }
+
+ // 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 && !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)
continue
@@ -111,6 +151,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 +195,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 +239,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 +304,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 +362,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 +372,35 @@ 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)
+}
+
+// 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 +436,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 +445,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 +474,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,13 +513,183 @@ 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)
+// dnsIPAddrs returns the current DHCPv6 DNS server addresses.
+func (s *v6Server) dnsIPAddrs() (addrs []net.IP) {
+ s.dnsIPAddrsMu.RLock()
+ defer s.dnsIPAddrsMu.RUnlock()
- s.leasesLock.Lock()
- s.conf.notify(LeaseChangedDBStore)
- s.leasesLock.Unlock()
- s.conf.notify(LeaseChangedAdded)
+ 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
+}
+
+// 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
+}
+
+// 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, found := b[pref]; !found || !other.Equal(until) {
+ return false
+ }
+ }
+
+ return true
}
// Check Client ID
@@ -467,26 +756,272 @@ 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
+// 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
+}
- switch msg.Type() {
- case dhcpv6.MessageTypeSolicit:
- //
+// snapshotLeaseCommitState captures the prefix-tracking state that lease
+// lifetime calculations depend on.
+func (s *v6Server) snapshotLeaseCommitState(
+ now time.Time,
+ lease *dhcpsvc.Lease,
+) (snapshot leaseCommitSnapshot) {
+ prefix := netip.PrefixFrom(lease.IP, raObservedPrefixBits).Masked()
+ snapshot.leaseTime = s.conf.leaseTime
+ snapshot.renewable = !lease.IsStatic && leasePrefixRenewable(s.renewablePrefixes, lease.IP)
+ validUntil, hasValidUntil := s.validUntilByPrefix[prefix]
+ snapshot.preferredUntil, snapshot.hasPreferredUntil = s.preferredUntilByPrefix[prefix]
+
+ snapshot.renewableLifetime = snapshot.leaseTime
+ if hasValidUntil {
+ capped := time.Duration(remainingUntil(now, validUntil)) * time.Second
+ snapshot.renewableLifetime = min(snapshot.renewableLifetime, capped)
+ }
- case dhcpv6.MessageTypeConfirm:
- lifetime = time.Until(lease.Expiry)
+ if hasValidUntil {
+ validForPrefix := time.Duration(remainingUntil(now, validUntil)) * time.Second
+ validForLease := max(time.Until(lease.Expiry), 0)
+ snapshot.deprecatedLifetime = min(validForLease, validForPrefix)
+ }
+ return snapshot
+}
+
+// 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 = snapshot.leaseTime
+ case snapshot.renewable:
+ lifetime = min(max(time.Until(lease.Expiry), 0), snapshot.renewableLifetime)
+ default:
+ lifetime = snapshot.deprecatedLifetime
+ }
case dhcpv6.MessageTypeRequest,
dhcpv6.MessageTypeRenew,
dhcpv6.MessageTypeRebind:
+ switch {
+ case lease.IsStatic:
+ lifetime = snapshot.leaseTime
+ case snapshot.renewable:
+ lifetime = snapshot.renewableLifetime
+ lease.Expiry = now.Add(lifetime)
+ shouldNotify = true
+ default:
+ lifetime = snapshot.deprecatedLifetime
+ }
+ default:
+ lifetime = snapshot.leaseTime
+ }
+
+ 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:
+ return lifetime
+ case !snapshot.renewable:
+ return 0
+ case snapshot.hasPreferredUntil:
+ preferredForPrefix := time.Duration(remainingUntil(now, snapshot.preferredUntil)) * time.Second
+ return min(lifetime, preferredForPrefix)
+ default:
+ 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
+}
+
+// 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
+}
+
+// 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)
+}
- if !lease.IsStatic {
- s.commitDynamicLease(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)
+ msgType := msg.Type()
+
+ for _, l := range s.leases {
+ if !bytes.Equal(mac, l.HWAddr) {
+ continue
+ }
+
+ if usable := s.exactUsableLease(msgType, reqIP, l); usable != nil {
+ return usable
+ }
+ 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(
+ 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
}
- return lifetime
+}
+
+// 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
@@ -515,7 +1050,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 {
@@ -541,7 +1076,7 @@ func (s *v6Server) process(msg *dhcpv6.Message, req, resp dhcpv6.DHCPv6) bool {
return false
}
- lifetime := s.commitLease(msg, lease)
+ lifetime, preferredLifetime := s.commitLease(msg, lease)
oia := &dhcpv6.OptIANA{
T1: lifetime / 2,
@@ -555,7 +1090,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 +1099,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)
@@ -579,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::*
@@ -613,38 +1185,20 @@ func (s *v6Server) packetHandler(conn net.PacketConn, peer net.Addr, req dhcpv6.
return
}
- var resp dhcpv6.DHCPv6
+ resp, err := newPacketResponse(msg)
+ if err != nil {
+ log.Error("dhcpv6: %s", err)
- 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
- }
- if err != nil {
- log.Error("dhcpv6: %s", err)
-
- return
- }
+ return
+ }
resp.AddOption(dhcpv6.OptServerID(s.sid))
- _ = s.process(msg, req, resp)
+ if !s.process(msg, req, resp) {
+ if !addProcessFailureStatus(msg.Type(), resp) && requiresProcessSuccess(msg.Type()) {
+ return
+ }
+ }
log.Debug("dhcpv6: sending: %s", resp.Summary())
@@ -656,8 +1210,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,62 +1264,485 @@ 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()
+ return s.ra.Init(initial)
}
-// Start starts the IPv6 DHCP server.
-func (s *v6Server) Start(ctx context.Context) (err error) {
- defer func() { err = errors.Annotate(err, "dhcpv6: %w") }()
+// 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
+ }
- if !s.conf.Enabled {
- return nil
+ s.setDNSIPAddrs(observedDNSIPAddrs(states))
+
+ obs = buildInterfaceRAObservation(states)
+ if !obs.SourceAddr.IsValid() {
+ obs.SourceAddr = pickStaticRASourceAddr(s.dnsIPAddrs())
+ obs.RDNSSAddr = obs.SourceAddr
}
- ifaceName := s.conf.InterfaceName
- iface, err := net.InterfaceByName(ifaceName)
- if err != nil {
- return fmt.Errorf("finding interface %s by name: %w", ifaceName, err)
+ return obs, nil
+}
+
+// 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)
+
+ return nil
}
- log.Debug("dhcpv6: starting...")
+ poolPrefix, ok := renewablePoolPrefix(active, advertised)
+ if !ok {
+ s.setTrackedRangeStart(nil, advertised)
- ok, err := s.configureDNSIPAddrs(ctx, iface)
+ return nil
+ }
+
+ ipStart, err := deriveTrackedRangeStart(s.conf.RangeStart, poolPrefix)
if err != nil {
- // Don't wrap the error, because it's informative enough as is.
return err
}
- if !ok {
- // No available IP addresses which may appear later.
- return nil
+ s.setTrackedRangeStart(ipStart, advertised)
+
+ 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) {
+ 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
+ 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))
+ 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
+}
+
+// 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))
+ switch {
+ case len(restored) > 0:
+ return prefixSetContainsAll(observedRenewable, restored)
+ case len(observedRenewable) > 0:
+ return false
+ default:
+ return true
+ }
+}
+
+// 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
+ }
+ }
+
+ 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
+ }
+
+ 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)
+ }
+}
+
+// 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{},
+ 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
+}
+
+// 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)
+ }
+}
+
+// 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",
+ )
+ }
+
+ 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.
+ obs, obsErr := s.observeRAState(ctx)
+ if obsErr != nil {
+ return raState{}, nil, fmt.Errorf("observing initial ipv6 prefix state: %w", obsErr)
+ }
+
+ 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)
+ }
+ }
+
+ 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)
+ }
+ }
+
+ 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
+ }
+
+ 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
+ }
+
+ if updateTrackedLeaseExpiry(validUntil, l) {
+ updated = true
+ }
+ }
+
+ 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.RASLAACOnly {
+ if !s.conf.NeedsDHCPv6Pool() {
log.Debug("not starting dhcpv6 server due to ra_slaac_only=true")
return nil
@@ -768,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()
@@ -792,26 +1841,88 @@ 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{}
+ 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()
+ 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 {
- return s, fmt.Errorf("dhcpv6: invalid range-start IP: %s", conf.RangeStart)
+ if err = validateV6CreateRangeStart(conf); err != nil {
+ return s, fmt.Errorf("dhcpv6: %w", err)
}
+ configureV6CreateRangeStart(&s.conf)
- 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)
+ if conf.NormalizedPrefixSource() == V6PrefixSourceStatic {
+ s.configureV6CreateStaticPrefix()
}
+ 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 b642eed7384..6f7b20f0d36 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,1096 @@ 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"))
+}
+
+// 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))
+ 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)
+ 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) {
+ 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, 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) {
+ 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, 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) {
+ 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, 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) {
+ 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, preferred := s.commitLease(msg, lease)
+ assert.Equal(t, 24*time.Hour, lifetime)
+ assert.Equal(t, 24*time.Hour, preferred)
+}
+
+func TestV6CommitLease_DeprecatedLeaseUsesZeroPreferredLifetime(t *testing.T) {
+ s := &v6Server{
+ conf: V6ServerConf{
+ 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{
+ IP: netip.MustParseAddr("2001:db8::10"),
+ HWAddr: net.HardwareAddr{0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa},
+ Expiry: time.Now().Add(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)
+}
+
+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, 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) {
+ 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_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{
+ 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)
+}
+
+// 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/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..1f71be01516 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,20 @@
'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'
+ 'enum':
+ - 'static'
+ - 'interface'
'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..65ee82bc3ac 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -2051,7 +2051,20 @@
'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'
+ 'enum':
+ - 'static'
+ - 'interface'
'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'