From f70bb2ba2a7233ac32fb966164344df2f2a7c019 Mon Sep 17 00:00:00 2001 From: Omoeba <38597972+Omoeba@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:31:16 -0700 Subject: [PATCH] Align DHCPv6 prefix tracking with code guidelines --- AGHTechDoc.md | 92 +- CHANGELOG.md | 4 +- internal/aghnet/prefix.go | 115 ++- internal/aghnet/prefix_linux.go | 259 ++++-- internal/aghnet/prefix_linux_internal_test.go | 29 +- internal/dhcpd/config.go | 10 +- internal/dhcpd/db.go | 226 +++-- internal/dhcpd/routeradv.go | 47 +- internal/dhcpd/routeradv_internal_test.go | 30 +- .../{routeradv_state.go => routeradvstate.go} | 253 ++++-- internal/dhcpd/v6_unix.go | 834 +++++++++++------- internal/dhcpd/v6_unix_internal_test.go | 5 +- 12 files changed, 1224 insertions(+), 680 deletions(-) rename internal/dhcpd/{routeradv_state.go => routeradvstate.go} (79%) diff --git a/AGHTechDoc.md b/AGHTechDoc.md index 463cf1aa7..706eb6334 100644 --- a/AGHTechDoc.md +++ b/AGHTechDoc.md @@ -481,29 +481,32 @@ Response: 200 OK { - "enabled":false, - "interface_name":"...", - "v4":{ - "gateway_ip":"...", - "subnet_mask":"...", - "range_start":"...", // if empty: DHCPv4 won't be enabled - "range_end":"...", - "lease_duration":60, - }, - "v6":{ - "prefix_source":"static", - "range_start":"...", // if empty: DHCPv6 won't be enabled - "lease_duration":60, - }, - "leases":[ - {"ip":"...","mac":"...","hostname":"...","expires":"..."} - ... - ], - "static_leases":[ - {"ip":"...","mac":"...","hostname":"..."} - ... - ] - } +```none +{ + "enabled":false, + "interface_name":"...", + "v4":{ + "gateway_ip":"...", + "subnet_mask":"...", + "range_start":"...", // if empty: DHCPv4 won't be enabled + "range_end":"...", + "lease_duration":60, + }, + "v6":{ + "prefix_source":"static", + "range_start":"...", // if empty: DHCPv6 won't be enabled + "lease_duration":60, + }, + "leases":[ + {"ip":"...","mac":"...","hostname":"...","expires":"..."} + ... + ], + "static_leases":[ + {"ip":"...","mac":"...","hostname":"..."} + ... + ] +} +``` ### API: Check DHCP @@ -558,24 +561,26 @@ If `static_ip.static` is: Request: - POST /control/dhcp/set_config +```none +POST /control/dhcp/set_config - { - "enabled":true, - "interface_name":"vboxnet0", - "v4":{ - "gateway_ip":"192.169.56.1", - "subnet_mask":"255.255.255.0", - "range_start":"192.169.56.100", - "range_end":"192.169.56.200", // Note: first 3 octets must match "range_start" - "lease_duration":60, - }, - "v6":{ - "prefix_source":"static", - "range_start":"...", - "lease_duration":60, - } - } +{ +"enabled":true, +"interface_name":"vboxnet0", +"v4":{ + "gateway_ip":"192.169.56.1", + "subnet_mask":"255.255.255.0", + "range_start":"192.169.56.100", + "range_end":"192.169.56.200", // Note: first 3 octets must match "range_start" + "lease_duration":60, +}, +"v6":{ + "prefix_source":"static", + "range_start":"...", + "lease_duration":60, +} +} +``` Response: @@ -760,8 +765,11 @@ Configuration: * `ra_slaac_only:false; ra_allow_slaac:true`: use option #3. Periodically send `ICMPv6.RouterAdvertisement(Flags=(Managed=true,Other=true))` packets. -For IPv6 prefix tracking, `prefix_source:static` keeps the current legacy behavior. -`prefix_source:interface` derives the advertised prefix from the interface, keeps `range_start` as a host template for the dynamic pool, and deprecates the previous prefix with preferred lifetime `0` and a bounded valid lifetime when renumbering is observed. +For IPv6 prefix tracking, `prefix_source:static` keeps the current +legacy behavior. `prefix_source:interface` derives the advertised +prefix from the interface, keeps `range_start` as a host template for +the dynamic pool, and deprecates the previous prefix with preferred +lifetime `0` and a bounded valid lifetime when renumbering is observed. ICMPv6.RouterAdvertisement packet description: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9443e44bf..af60684f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,9 @@ NOTE: Add new changes BELOW THIS COMMENT. ### Added -- Opt-in IPv6 prefix tracking for DHCPv6 and Router Advertisements. When enabled, AdGuard Home derives the advertised prefix from the interface and deprecates the previous prefix during renumbering. +- Opt-in IPv6 prefix tracking for DHCPv6 and Router Advertisements. + When enabled, AdGuard Home derives the advertised prefix from the + interface and deprecates the previous prefix during renumbering. ### Changed diff --git a/internal/aghnet/prefix.go b/internal/aghnet/prefix.go index b7d2bbb1a..8be96cb40 100644 --- a/internal/aghnet/prefix.go +++ b/internal/aghnet/prefix.go @@ -52,41 +52,9 @@ func parseIfconfigIPv6Addr(fields []string) (state IPv6AddrState, err error) { prefixBits := -1 for i := 2; i < len(fields); i++ { - switch strings.ToLower(fields[i]) { - case "prefixlen": - i++ - if i >= len(fields) { - return IPv6AddrState{}, fmt.Errorf("missing prefixlen value in %q", strings.Join(fields, " ")) - } - - prefixBits, err = strconv.Atoi(fields[i]) - if err != nil { - return IPv6AddrState{}, fmt.Errorf("parsing prefixlen %q: %w", fields[i], err) - } - case "pltime": - i++ - if i >= len(fields) { - return IPv6AddrState{}, fmt.Errorf("missing pltime value in %q", strings.Join(fields, " ")) - } - - preferred, err = parseIPv6Lifetime(fields[i]) - if err != nil { - return IPv6AddrState{}, fmt.Errorf("parsing pltime %q: %w", fields[i], err) - } - case "vltime": - i++ - if i >= len(fields) { - return IPv6AddrState{}, fmt.Errorf("missing vltime value in %q", strings.Join(fields, " ")) - } - - valid, err = parseIPv6Lifetime(fields[i]) - if err != nil { - return IPv6AddrState{}, fmt.Errorf("parsing vltime %q: %w", fields[i], err) - } - case "temporary": - state.Temporary = true - case "tentative": - state.Tentative = true + i, err = parseIfconfigIPv6AddrField(fields, i, &state, &preferred, &valid, &prefixBits) + if err != nil { + return IPv6AddrState{}, err } } @@ -104,15 +72,86 @@ func parseIfconfigIPv6Addr(fields []string) (state IPv6AddrState, err error) { }, nil } +// parseIfconfigIPv6AddrField parses one token from an ifconfig IPv6 address +// line. +func parseIfconfigIPv6AddrField( + fields []string, + i int, + state *IPv6AddrState, + preferred, valid *uint32, + prefixBits *int, +) (next int, err error) { + switch strings.ToLower(fields[i]) { + case "prefixlen": + return parseIfconfigIPv6AddrInt(fields, i, "prefixlen", func(v int) { + *prefixBits = v + }) + case "pltime": + return parseIfconfigIPv6AddrLifetime(fields, i, "pltime", preferred) + case "vltime": + return parseIfconfigIPv6AddrLifetime(fields, i, "vltime", valid) + case "temporary": + state.Temporary = true + case "tentative": + state.Tentative = true + } + + return i, nil +} + +// parseIfconfigIPv6AddrInt parses one int token from an ifconfig IPv6 address +// line. +func parseIfconfigIPv6AddrInt( + fields []string, + i int, + name string, + set func(int), +) (next int, err error) { + i++ + if i >= len(fields) { + return i, fmt.Errorf("missing %s value in %q", name, strings.Join(fields, " ")) + } + + v, err := strconv.Atoi(fields[i]) + if err != nil { + return i, fmt.Errorf("parsing %s %q: %w", name, fields[i], err) + } + + set(v) + + return i, nil +} + +// parseIfconfigIPv6AddrLifetime parses one IPv6 lifetime token from an +// ifconfig IPv6 address line. +func parseIfconfigIPv6AddrLifetime( + fields []string, + i int, + name string, + lifetime *uint32, +) (next int, err error) { + i++ + if i >= len(fields) { + return i, fmt.Errorf("missing %s value in %q", name, strings.Join(fields, " ")) + } + + *lifetime, err = parseIPv6Lifetime(fields[i]) + if err != nil { + return i, fmt.Errorf("parsing %s %q: %w", name, fields[i], err) + } + + return i, nil +} + // parseIPv6Lifetime parses an IPv6 lifetime token from command output. func parseIPv6Lifetime(s string) (sec uint32, err error) { switch strings.ToLower(s) { case "forever", "infinity", "infinite", "infty": return math.MaxUint32, nil default: - v, err := strconv.ParseUint(s, 10, 32) - if err != nil { - return 0, err + v, parseErr := strconv.ParseUint(s, 10, 32) + if parseErr != nil { + return 0, parseErr } return uint32(v), nil diff --git a/internal/aghnet/prefix_linux.go b/internal/aghnet/prefix_linux.go index 718bc1f4a..cc8040e8d 100644 --- a/internal/aghnet/prefix_linux.go +++ b/internal/aghnet/prefix_linux.go @@ -9,24 +9,19 @@ import ( "log/slog" "net" "net/netip" - "syscall" - "unsafe" + "github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/osutil/executil" + "github.com/mdlayher/netlink" "golang.org/x/sys/unix" ) // ObserveIPv6Addrs returns IPv6 interface address state for ifaceName. // -// ctx is accepted to match the BSD implementations (which run ifconfig under -// an [executil.CommandConstructor] and can be cancelled) but is not honored -// here: syscall.NetlinkRIB is synchronous and uncancellable from outside the -// call. Wrapping it in a goroutine that selects on ctx.Done() would only -// hide a stuck kernel from the caller while leaking the blocked goroutine on -// every retry, which is strictly worse than failing fast on the caller side -// and letting the operator notice a genuinely broken environment. -// rtnetlink responds in microseconds under normal conditions, so the lack of -// cancellation is acceptable in practice. +// ctx is accepted to match the BSD implementations, which run ifconfig under +// an [executil.CommandConstructor] and can be canceled. Linux uses a netlink +// socket instead, so we still accept ctx for API compatibility but do not +// consult it while receiving the dump reply. func ObserveIPv6Addrs( _ context.Context, _ *slog.Logger, @@ -38,53 +33,40 @@ func ObserveIPv6Addrs( return nil, fmt.Errorf("finding interface %s: %w", ifaceName, err) } - rib, err := syscall.NetlinkRIB(syscall.RTM_GETADDR, syscall.AF_INET6) + conn, err := netlink.Dial(unix.NETLINK_ROUTE, nil) + if err != nil { + return nil, fmt.Errorf("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) } - msgs, err := syscall.ParseNetlinkMessage(rib) - if err != nil { - return nil, fmt.Errorf("parsing rtnetlink addrs: %w", err) - } - return parseIPv6AddrStatesNetlink(msgs, iface.Index) } // parseIPv6AddrStatesNetlink parses IPv6 address state from netlink messages. func parseIPv6AddrStatesNetlink( - msgs []syscall.NetlinkMessage, + msgs []netlink.Message, ifIndex int, ) (states []IPv6AddrState, err error) { -loop: for _, msg := range msgs { - switch msg.Header.Type { - case syscall.NLMSG_DONE: - break loop - case syscall.RTM_NEWADDR: - // Go on. - default: - continue - } - - if len(msg.Data) < syscall.SizeofIfAddrmsg { - return nil, fmt.Errorf("short ifaddrmsg payload") - } - - ifam := (*syscall.IfAddrmsg)(unsafe.Pointer(&msg.Data[0])) - if ifam.Family != syscall.AF_INET6 || int(ifam.Index) != ifIndex { - continue - } - - attrs, err := syscall.ParseNetlinkRouteAttr(&msg) - if err != nil { - return nil, fmt.Errorf("parsing route attrs: %w", err) - } - - state, ok, err := parseIPv6AddrStateNetlink(ifam, attrs) + state, done, ok, err := parseIPv6AddrStateMessage(msg, ifIndex) if err != nil { return nil, err - } else if ok { + } + if done { + return states, nil + } + if ok { states = append(states, state) } } @@ -92,44 +74,53 @@ loop: return states, nil } +// parseIPv6AddrStateMessage parses one netlink message carrying IPv6 address +// state. +func parseIPv6AddrStateMessage( + msg netlink.Message, + ifIndex int, +) (state IPv6AddrState, done, ok bool, err error) { + switch msg.Header.Type { + case netlink.Done: + return IPv6AddrState{}, true, false, nil + case netlink.HeaderType(unix.RTM_NEWADDR): + // Go on. + default: + return IPv6AddrState{}, false, false, nil + } + + ifam, err := parseIfAddrmsg(msg.Data) + if err != nil { + return IPv6AddrState{}, false, false, err + } + if ifam.Family != unix.AF_INET6 || int(ifam.Index) != ifIndex { + return IPv6AddrState{}, false, false, nil + } + + attrs, err := netlink.UnmarshalAttributes(msg.Data[unix.SizeofIfAddrmsg:]) + if err != nil { + return IPv6AddrState{}, false, false, fmt.Errorf("parsing route attrs: %w", err) + } + + state, ok, err = parseIPv6AddrStateNetlink(ifam, attrs) + + return state, false, ok, err +} + // parseIPv6AddrStateNetlink parses one IPv6 address state from the netlink // message data. func parseIPv6AddrStateNetlink( - ifam *syscall.IfAddrmsg, - attrs []syscall.NetlinkRouteAttr, + ifam unix.IfAddrmsg, + attrs []netlink.Attribute, ) (state IPv6AddrState, ok bool, err error) { var addr netip.Addr - var cache *unix.IfaCacheinfo + var cache *ipv6AddrCacheInfo flags := uint32(ifam.Flags) for _, attr := range attrs { - switch attr.Attr.Type { - case unix.IFA_LOCAL: - addr, err = parseIPv6AddrAttr(attr.Value) - if err != nil { - return IPv6AddrState{}, false, fmt.Errorf("parsing ifa_local: %w", err) - } - case unix.IFA_ADDRESS: - if addr.IsValid() { - continue - } - - addr, err = parseIPv6AddrAttr(attr.Value) - if err != nil { - return IPv6AddrState{}, false, fmt.Errorf("parsing ifa_address: %w", err) - } - case unix.IFA_FLAGS: - if len(attr.Value) < 4 { - return IPv6AddrState{}, false, fmt.Errorf("short ifa_flags attribute") - } - - flags = binary.NativeEndian.Uint32(attr.Value[:4]) - case unix.IFA_CACHEINFO: - if len(attr.Value) < unix.SizeofIfaCacheinfo { - return IPv6AddrState{}, false, fmt.Errorf("short ifa_cacheinfo attribute") - } - - cache = (*unix.IfaCacheinfo)(unsafe.Pointer(&attr.Value[0])) + addr, flags, cache, err = parseIPv6AddrStateNetlinkAttr(attr, addr, flags, cache) + if err != nil { + return IPv6AddrState{}, false, err } } @@ -139,8 +130,8 @@ func parseIPv6AddrStateNetlink( preferred, valid := uint32(^uint32(0)), uint32(^uint32(0)) if cache != nil { - preferred = cache.Prefered - valid = cache.Valid + preferred = cache.preferredLifetimeSec + valid = cache.validLifetimeSec } return IPv6AddrState{ @@ -153,6 +144,122 @@ func parseIPv6AddrStateNetlink( }, true, nil } +// parseIPv6AddrStateNetlinkAttr parses one IPv6 address attribute from a +// netlink message. +func parseIPv6AddrStateNetlinkAttr( + attr netlink.Attribute, + addr netip.Addr, + flags uint32, + cache *ipv6AddrCacheInfo, +) (nextAddr netip.Addr, nextFlags uint32, nextCache *ipv6AddrCacheInfo, err error) { + switch attr.Type { + case unix.IFA_LOCAL: + return parseIPv6AddrStateLocalAttr(attr.Data, flags, cache) + case unix.IFA_ADDRESS: + return parseIPv6AddrStateAddressAttr(attr.Data, addr, flags, cache) + case unix.IFA_FLAGS: + return parseIPv6AddrStateFlagsAttr(attr.Data, addr, cache) + case unix.IFA_CACHEINFO: + return parseIPv6AddrStateCacheAttr(attr.Data, addr, flags) + default: + return addr, flags, cache, nil + } +} + +// parseIPv6AddrStateLocalAttr parses an IFA_LOCAL attribute. +func parseIPv6AddrStateLocalAttr( + data []byte, + flags uint32, + cache *ipv6AddrCacheInfo, +) (addr netip.Addr, nextFlags uint32, nextCache *ipv6AddrCacheInfo, err error) { + addr, err = parseIPv6AddrAttr(data) + if err != nil { + return netip.Addr{}, 0, nil, fmt.Errorf("parsing ifa_local: %w", err) + } + + return addr, flags, cache, nil +} + +// parseIPv6AddrStateAddressAttr parses an IFA_ADDRESS attribute. +func parseIPv6AddrStateAddressAttr( + data []byte, + addr netip.Addr, + flags uint32, + cache *ipv6AddrCacheInfo, +) (nextAddr netip.Addr, nextFlags uint32, nextCache *ipv6AddrCacheInfo, err error) { + if addr.IsValid() { + return addr, flags, cache, nil + } + + nextAddr, err = parseIPv6AddrAttr(data) + if err != nil { + return netip.Addr{}, 0, nil, fmt.Errorf("parsing ifa_address: %w", err) + } + + return nextAddr, flags, cache, nil +} + +// parseIPv6AddrStateFlagsAttr parses an IFA_FLAGS attribute. +func parseIPv6AddrStateFlagsAttr( + data []byte, + addr netip.Addr, + cache *ipv6AddrCacheInfo, +) (nextAddr netip.Addr, nextFlags uint32, nextCache *ipv6AddrCacheInfo, err error) { + if len(data) < 4 { + return netip.Addr{}, 0, nil, fmt.Errorf("short ifa_flags attribute") + } + + return addr, binary.NativeEndian.Uint32(data[:4]), cache, nil +} + +// parseIPv6AddrStateCacheAttr parses an IFA_CACHEINFO attribute. +func parseIPv6AddrStateCacheAttr( + data []byte, + addr netip.Addr, + flags uint32, +) (nextAddr netip.Addr, nextFlags uint32, nextCache *ipv6AddrCacheInfo, err error) { + ifaCacheInfo, err := parseIfaCacheinfo(data) + if err != nil { + return netip.Addr{}, 0, nil, err + } + + return addr, flags, &ifaCacheInfo, nil +} + +// ipv6AddrCacheInfo is the lifetime subset of Linux ifa_cacheinfo used by +// IPv6 address observation. +type ipv6AddrCacheInfo struct { + preferredLifetimeSec uint32 + validLifetimeSec uint32 +} + +// parseIfAddrmsg parses one Linux ifaddrmsg structure. +func parseIfAddrmsg(b []byte) (ifam unix.IfAddrmsg, err error) { + if len(b) < unix.SizeofIfAddrmsg { + return unix.IfAddrmsg{}, fmt.Errorf("short ifaddrmsg payload") + } + + return unix.IfAddrmsg{ + Family: b[0], + Prefixlen: b[1], + Flags: b[2], + Scope: b[3], + Index: binary.NativeEndian.Uint32(b[4:8]), + }, nil +} + +// parseIfaCacheinfo parses one Linux ifa_cacheinfo structure. +func parseIfaCacheinfo(b []byte) (cache ipv6AddrCacheInfo, err error) { + if len(b) < unix.SizeofIfaCacheinfo { + return ipv6AddrCacheInfo{}, fmt.Errorf("short ifa_cacheinfo attribute") + } + + return ipv6AddrCacheInfo{ + preferredLifetimeSec: binary.NativeEndian.Uint32(b[0:4]), + validLifetimeSec: binary.NativeEndian.Uint32(b[4:8]), + }, nil +} + // parseIPv6AddrAttr parses one IPv6 address attribute. func parseIPv6AddrAttr(b []byte) (addr netip.Addr, err error) { if len(b) < net.IPv6len { diff --git a/internal/aghnet/prefix_linux_internal_test.go b/internal/aghnet/prefix_linux_internal_test.go index 05129d395..37d1819cf 100644 --- a/internal/aghnet/prefix_linux_internal_test.go +++ b/internal/aghnet/prefix_linux_internal_test.go @@ -5,10 +5,9 @@ package aghnet import ( "encoding/binary" "net/netip" - "syscall" "testing" - "unsafe" + "github.com/mdlayher/netlink" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/unix" @@ -19,24 +18,22 @@ func TestParseIPv6AddrStateNetlink(t *testing.T) { flags := make([]byte, 4) binary.NativeEndian.PutUint32(flags, unix.IFA_F_TEMPORARY|unix.IFA_F_TENTATIVE) - cache := unix.IfaCacheinfo{ - Prefered: 600, - Valid: 1200, - } - cacheBytes := *(*[unix.SizeofIfaCacheinfo]byte)(unsafe.Pointer(&cache)) + cacheBytes := make([]byte, unix.SizeofIfaCacheinfo) + binary.NativeEndian.PutUint32(cacheBytes[0:4], 600) + binary.NativeEndian.PutUint32(cacheBytes[4:8], 1200) - state, ok, err := parseIPv6AddrStateNetlink(&syscall.IfAddrmsg{ - Family: syscall.AF_INET6, + state, ok, err := parseIPv6AddrStateNetlink(unix.IfAddrmsg{ + Family: unix.AF_INET6, Prefixlen: 64, - }, []syscall.NetlinkRouteAttr{{ - Attr: syscall.RtAttr{Type: unix.IFA_ADDRESS}, - Value: addr[:], + }, []netlink.Attribute{{ + Type: unix.IFA_ADDRESS, + Data: addr[:], }, { - Attr: syscall.RtAttr{Type: unix.IFA_FLAGS}, - Value: flags, + Type: unix.IFA_FLAGS, + Data: flags, }, { - Attr: syscall.RtAttr{Type: unix.IFA_CACHEINFO}, - Value: cacheBytes[:], + Type: unix.IFA_CACHEINFO, + Data: cacheBytes, }}) require.NoError(t, err) require.True(t, ok) diff --git a/internal/dhcpd/config.go b/internal/dhcpd/config.go index a8aa5b469..ef800111e 100644 --- a/internal/dhcpd/config.go +++ b/internal/dhcpd/config.go @@ -272,10 +272,14 @@ type V6ServerConf struct { // If it is empty, the configured static prefix semantics are used. PrefixSource V6PrefixSource `yaml:"prefix_source" json:"prefix_source"` - LeaseDuration uint32 `yaml:"lease_duration" json:"lease_duration"` // in seconds + // LeaseDuration is the DHCPv6 lease duration, in seconds. + LeaseDuration uint32 `yaml:"lease_duration" json:"lease_duration"` - RASLAACOnly bool `yaml:"ra_slaac_only" json:"ra_slaac_only"` // send ICMPv6.RA packets without MO flags - RAAllowSLAAC bool `yaml:"ra_allow_slaac" json:"-"` // send ICMPv6.RA packets with MO flags + // 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 diff --git a/internal/dhcpd/db.go b/internal/dhcpd/db.go index eb84d2a55..da9753da4 100644 --- a/internal/dhcpd/db.go +++ b/internal/dhcpd/db.go @@ -51,6 +51,26 @@ type dbDeprecatedPrefix struct { ValidUntil string `json:"valid_until"` } +// v6MetaRestorer restores persisted DHCPv6 prefix metadata into the running +// server implementation. +type v6MetaRestorer interface { + DHCPServer + setRestoredPrefixMeta( + renewable map[netip.Prefix]struct{}, + deprecated map[netip.Prefix]time.Time, + ) +} + +// v6Snapshotter returns the DHCPv6 leases and prefix metadata for persistence. +type v6Snapshotter interface { + DHCPServer + dbSnapshot(now time.Time) ( + leases []*dhcpsvc.Lease, + renewable map[netip.Prefix]struct{}, + deprecated map[netip.Prefix]time.Time, + ) +} + // dbLease is the structure of stored lease. type dbLease struct { Expiry string `json:"expires"` @@ -121,13 +141,36 @@ func (s *server) dbLoad() (err error) { return fmt.Errorf("decoding db: %w", err) } - leases := dl.Leases - leases4 := []*dhcpsvc.Lease{} - leases6 := []*dhcpsvc.Lease{} + leases4, leases6 := splitStoredLeases(dl.Leases) + err = s.srv4.ResetLeases(leases4) + if err != nil { + return fmt.Errorf("resetting dhcpv4 leases: %w", err) + } + + if s.srv6 != nil { + err = s.srv6.ResetLeases(leases6) + if err != nil { + return fmt.Errorf("resetting dhcpv6 leases: %w", err) + } + restoreLoadedV6Meta(s.srv6, dl.V6Meta) + } + + log.Info( + "dhcp: loaded leases v4:%d v6:%d total-read:%d from DB", + len(leases4), + len(leases6), + len(dl.Leases), + ) + + return nil +} + +// splitStoredLeases converts stored database leases into DHCPv4 and DHCPv6 +// leases. +func splitStoredLeases(leases []*dbLease) (leases4, leases6 []*dhcpsvc.Lease) { for _, l := range leases { - var lease *dhcpsvc.Lease - lease, err = l.toLease() + lease, err := l.toLease() if err != nil { log.Info("dhcp: invalid lease: %s", err) @@ -141,103 +184,124 @@ func (s *server) dbLoad() (err error) { } } - err = s.srv4.ResetLeases(leases4) - if err != nil { - return fmt.Errorf("resetting dhcpv4 leases: %w", err) + return leases4, leases6 +} + +// restoreLoadedV6Meta restores the persisted IPv6 prefix metadata into srv6. +func restoreLoadedV6Meta(srv6 DHCPServer, meta *dataLeasesV6Meta) { + if meta == nil { + return } - if s.srv6 != nil { - err = s.srv6.ResetLeases(leases6) + 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 } - if dl.V6Meta != nil { - if srv6, ok := s.srv6.(*v6Server); ok { - renewable := map[netip.Prefix]struct{}{} - for _, pref := range dl.V6Meta.Renewable { - renewable[pref] = struct{}{} - } - deprecated := map[netip.Prefix]time.Time{} - for _, dp := range dl.V6Meta.Deprecated { - if dp == nil { - continue - } - - until, parseErr := time.Parse(time.RFC3339, dp.ValidUntil) - if parseErr != nil { - log.Info("dhcp: invalid v6 deprecated prefix %s: %s", dp.Prefix, parseErr) - - continue - } - - deprecated[dp.Prefix] = until - } - - srv6.setRestoredPrefixMeta(renewable, deprecated) - } - } + deprecated[dp.Prefix] = until } - log.Info( - "dhcp: loaded leases v4:%d v6:%d total-read:%d from DB", - len(leases4), - len(leases6), - len(leases), - ) - - return nil + return renewable, deprecated } // dbStore stores DHCP leases. func (s *server) dbStore() (err error) { // Use an empty slice here as opposed to nil so that it doesn't write // "null" into the database file if leases are empty. - leases := []*dbLease{} + leases := dbLeasesFromRef(s.srv4.getLeasesRef()) var v6Meta *dataLeasesV6Meta - for _, l := range s.srv4.getLeasesRef() { - leases = append(leases, fromLease(l)) - } - if s.srv6 != nil { - if srv6, ok := s.srv6.(*v6Server); ok { - leases6, renewable, deprecated := srv6.dbSnapshot(time.Now()) - for _, l := range leases6 { - leases = append(leases, fromLease(l)) - } - if len(renewable) > 0 || len(deprecated) > 0 { - v6Meta = &dataLeasesV6Meta{ - Renewable: make([]netip.Prefix, 0, len(renewable)), - } - for pref := range renewable { - v6Meta.Renewable = append(v6Meta.Renewable, pref) - } - slices.SortFunc(v6Meta.Renewable, prefixCompare) - - if len(deprecated) > 0 { - v6Meta.Deprecated = make([]*dbDeprecatedPrefix, 0, len(deprecated)) - for pref, until := range deprecated { - v6Meta.Deprecated = append(v6Meta.Deprecated, &dbDeprecatedPrefix{ - Prefix: pref, - ValidUntil: until.Format(time.RFC3339), - }) - } - slices.SortFunc(v6Meta.Deprecated, func(a, b *dbDeprecatedPrefix) int { - return prefixCompare(a.Prefix, b.Prefix) - }) - } - } - } else { - for _, l := range s.srv6.getLeasesRef() { - leases = append(leases, fromLease(l)) - } - } + leases, v6Meta = s.dbStoreV6(leases) } return writeDB(s.conf.dbFilePath, leases, v6Meta) } +// dbLeasesFromRef converts DHCP leases to database leases. +func dbLeasesFromRef(leases []*dhcpsvc.Lease) (dbLeases []*dbLease) { + dbLeases = make([]*dbLease, 0, len(leases)) + for _, l := range leases { + dbLeases = append(dbLeases, fromLease(l)) + } + + return dbLeases +} + +// dbStoreV6 adds DHCPv6 leases and prefix metadata to the database snapshot. +func (s *server) dbStoreV6(leases []*dbLease) (out []*dbLease, v6Meta *dataLeasesV6Meta) { + if srv6, ok := s.srv6.(v6Snapshotter); ok { + leases6, renewable, deprecated := srv6.dbSnapshot(time.Now()) + leases = append(leases, dbLeasesFromRef(leases6)...) + return leases, buildStoredV6Meta(renewable, deprecated) + } + + return append(leases, dbLeasesFromRef(s.srv6.getLeasesRef())...), nil +} + +// buildStoredV6Meta converts snapshot metadata into the persisted form. +func buildStoredV6Meta( + renewable map[netip.Prefix]struct{}, + deprecated map[netip.Prefix]time.Time, +) (v6Meta *dataLeasesV6Meta) { + if len(renewable) == 0 && len(deprecated) == 0 { + return nil + } + + v6Meta = &dataLeasesV6Meta{ + Renewable: make([]netip.Prefix, 0, len(renewable)), + } + for pref := range renewable { + v6Meta.Renewable = append(v6Meta.Renewable, pref) + } + slices.SortFunc(v6Meta.Renewable, prefixCompare) + + if len(deprecated) == 0 { + return v6Meta + } + + v6Meta.Deprecated = make([]*dbDeprecatedPrefix, 0, len(deprecated)) + for pref, until := range deprecated { + v6Meta.Deprecated = append(v6Meta.Deprecated, &dbDeprecatedPrefix{ + Prefix: pref, + ValidUntil: until.Format(time.RFC3339), + }) + } + slices.SortFunc(v6Meta.Deprecated, func(a, b *dbDeprecatedPrefix) int { + return prefixCompare(a.Prefix, b.Prefix) + }) + + return v6Meta +} + // writeDB writes leases to file at path. func writeDB(path string, leases []*dbLease, v6Meta *dataLeasesV6Meta) (err error) { defer func() { err = errors.Annotate(err, "writing db: %w") }() diff --git a/internal/dhcpd/routeradv.go b/internal/dhcpd/routeradv.go index ae7efd914..09fd3bd5f 100644 --- a/internal/dhcpd/routeradv.go +++ b/internal/dhcpd/routeradv.go @@ -230,14 +230,7 @@ func (ra *raCtx) Init(initial raState) (err error) { // Advertisements. func (ra *raCtx) ensureConn(sourceAddr netip.Addr) (err error) { if !sourceAddr.IsValid() { - if ra.conn != nil { - err = ra.conn.Close() - } - - ra.conn = nil - ra.connSourceAddr = netip.Addr{} - - return err + return ra.closeConn() } if ra.conn != nil && ra.connSourceAddr == sourceAddr { @@ -245,14 +238,29 @@ func (ra *raCtx) ensureConn(sourceAddr netip.Addr) (err error) { } if ra.conn != nil { - err = ra.conn.Close() - ra.conn = nil - ra.connSourceAddr = netip.Addr{} - if err != nil { + if err = ra.closeConn(); err != nil { return fmt.Errorf("closing previous icmp listener: %w", err) } } + return ra.openConn(sourceAddr) +} + +// closeConn closes the current ICMPv6 socket and clears the tracked source +// address. +func (ra *raCtx) closeConn() (err error) { + if ra.conn != nil { + err = ra.conn.Close() + } + + ra.conn = nil + ra.connSourceAddr = netip.Addr{} + + return err +} + +// openConn opens a new ICMPv6 socket for the given source address. +func (ra *raCtx) openConn(sourceAddr netip.Addr) (err error) { ipAndScope := sourceAddr.String() + "%" + ra.ifaceName newConn, err := icmp.ListenPacket("ip6:ipv6-icmp", ipAndScope) if err != nil { @@ -334,17 +342,17 @@ func (ra *raCtx) refresh(ctx context.Context) { } now := time.Now() - _ = ra.state.merge(obs, now) + change := ra.state.merge(obs, now) if ra.onStateRefresh != nil { ra.onStateRefresh(now, &ra.state) } - ra.syncStateChange(now) + ra.syncStateChange(now, &change) } // sendPacket rebuilds and sends the current Router Advertisement packet. func (ra *raCtx) sendPacket() { now := time.Now() - ra.syncStateChange(now) + ra.syncStateChange(now, nil) sourceAddr, rdnssAddr := ra.state.sourceAndRDNSS() err := ra.ensureConn(sourceAddr) @@ -424,7 +432,7 @@ func tickerC(t *time.Ticker) (c <-chan time.Time) { // elapsed time. The comparison uses a deadline-based digest of the tracked // prefixes, so repeated polls that only observe the kernel counting lifetimes // down do not spuriously fire the callback. -func (ra *raCtx) syncStateChange(now time.Time) { +func (ra *raCtx) syncStateChange(now time.Time, change *raActiveChange) { digest := ra.state.digest(now) changed := !sameRAStateDigest(ra.lastDigest, digest) ra.lastDigest = digest @@ -433,7 +441,12 @@ func (ra *raCtx) syncStateChange(now time.Time) { return } - active := ra.state.activeSnapshot(now) + var active *raPrefixSnapshot + if change != nil && change.Changed { + active = change.Active + } else { + active = ra.state.activeSnapshot(now) + } advertised := ra.state.pios(now) ra.onActivePrefixChange(active, advertised) } diff --git a/internal/dhcpd/routeradv_internal_test.go b/internal/dhcpd/routeradv_internal_test.go index f9b62105a..2868c991f 100644 --- a/internal/dhcpd/routeradv_internal_test.go +++ b/internal/dhcpd/routeradv_internal_test.go @@ -64,7 +64,11 @@ func TestCreateICMPv6RAPacket(t *testing.T) { assert.Equal(t, byte(0xc0), opt.Data[1]) assert.Equal(t, uint32(1800), binary.BigEndian.Uint32(opt.Data[2:6])) assert.Equal(t, uint32(0), binary.BigEndian.Uint32(opt.Data[6:10])) - assert.Equal(t, netip.MustParsePrefix("2001:db8:abcd::/60").Masked().Addr().As16(), [16]byte(opt.Data[14:30])) + assert.Equal( + t, + netip.MustParsePrefix("2001:db8:abcd::/60").Masked().Addr().As16(), + [16]byte(opt.Data[14:30]), + ) opt = raPkt.Options[2] require.Equal(t, layers.ICMPv6OptMTU, opt.Type) @@ -105,7 +109,7 @@ func TestRACtxSyncStateChange_DeprecatedExpiry(t *testing.T) { } ra.lastDigest = ra.state.digest(now) - ra.syncStateChange(now.Add(2 * time.Second)) + ra.syncStateChange(now.Add(2*time.Second), nil) require.Len(t, notifications, 1) require.Len(t, notifications[0], 1) @@ -142,9 +146,9 @@ func TestRACtxSyncStateChange_StableStateDoesNotFireCallback(t *testing.T) { // Three ticks without any new observation: only elapsed time changed, // so the digest must be unchanged and the callback must not fire. - ra.syncStateChange(now.Add(1 * time.Second)) - ra.syncStateChange(now.Add(3 * time.Second)) - ra.syncStateChange(now.Add(5 * time.Second)) + ra.syncStateChange(now.Add(1*time.Second), nil) + ra.syncStateChange(now.Add(3*time.Second), nil) + ra.syncStateChange(now.Add(5*time.Second), nil) assert.Zero(t, fired) } @@ -187,7 +191,7 @@ func TestRACtxSyncStateChange_DeprecatingPrefixTriggersCallback(t *testing.T) { }}, }, now.Add(time.Minute)) - ra.syncStateChange(now.Add(time.Minute)) + ra.syncStateChange(now.Add(time.Minute), nil) require.Len(t, notifications, 1) require.Len(t, notifications[0], 2) @@ -227,12 +231,12 @@ func TestRACtxSyncStateChange_PreferredExpiryTriggersCallback(t *testing.T) { // While the preferred countdown still has time remaining no callback // should fire. - ra.syncStateChange(now.Add(299 * time.Second)) + ra.syncStateChange(now.Add(299*time.Second), nil) assert.Empty(t, notifications) // At the moment the preferred lifetime hits zero the callback must // fire so v6Server can drop the prefix from its renewable set. - ra.syncStateChange(now.Add(301 * time.Second)) + ra.syncStateChange(now.Add(301*time.Second), nil) require.Len(t, notifications, 1) require.Len(t, notifications[0], 1) assert.Equal(t, netip.MustParsePrefix("2001:db8::/64"), notifications[0][0].Prefix) @@ -240,8 +244,8 @@ func TestRACtxSyncStateChange_PreferredExpiryTriggersCallback(t *testing.T) { // But subsequent ticks past the expiry must not re-fire: the state // digest has settled at preferredExpired=true and stays that way. - ra.syncStateChange(now.Add(305 * time.Second)) - ra.syncStateChange(now.Add(310 * time.Second)) + ra.syncStateChange(now.Add(305*time.Second), nil) + ra.syncStateChange(now.Add(310*time.Second), nil) assert.Len(t, notifications, 1) } @@ -277,9 +281,9 @@ func TestRACtxSyncStateChange_PreferredExpiryOnDeprecatedEntryStillQuiescent(t * // Several steady-state ticks across the valid countdown. No kernel // state changed, so nothing should fire. - ra.syncStateChange(now.Add(1 * time.Second)) - ra.syncStateChange(now.Add(5 * time.Second)) - ra.syncStateChange(now.Add(30 * time.Second)) + ra.syncStateChange(now.Add(1*time.Second), nil) + ra.syncStateChange(now.Add(5*time.Second), nil) + ra.syncStateChange(now.Add(30*time.Second), nil) assert.Zero(t, fired) } diff --git a/internal/dhcpd/routeradv_state.go b/internal/dhcpd/routeradvstate.go similarity index 79% rename from internal/dhcpd/routeradv_state.go rename to internal/dhcpd/routeradvstate.go index 55507ec76..a1622f82b 100644 --- a/internal/dhcpd/routeradv_state.go +++ b/internal/dhcpd/routeradvstate.go @@ -96,12 +96,12 @@ type raStateDigest struct { // trackedPrefixDigest is the deadline-based fingerprint of one trackedPrefix. // // preferredExpired is a time-derived boolean that flips from false to true -// exactly once during a natural countdown — the moment the prefix's preferred +// exactly once during a natural countdown, the moment the prefix's preferred // lifetime reaches zero. Without it, a prefix whose preferred lifetime // counts down while its absolute preferredUntil deadline stays put would // produce identical digests before and after it becomes non-renewable, so -// downstream reconciliation (which rebuilds the v6Server renewablePrefixes -// set) would never fire for that transition. +// downstream reconciliation, which rebuilds the v6Server renewablePrefixes +// set, would never fire for that transition. type trackedPrefixDigest struct { prefix netip.Prefix origin raPrefixOrigin @@ -166,8 +166,8 @@ func sameRAStateDigest(a, b raStateDigest) (ok bool) { } for pref, digest := range a.deprecated { - other, ok := b.deprecated[pref] - if !ok || other != digest { + other, found := b.deprecated[pref] + if !found || other != digest { return false } } @@ -196,9 +196,19 @@ func newStaticRAState(obs raObservation) (st raState) { return st } +// capDeprecatedLifetime bounds a remaining valid lifetime by the standard +// two-hour cap used for deprecated prefixes. +func capDeprecatedLifetime(valid uint32) (capped uint32) { + if valid > raDeprecatedLifetimeCapSecs || valid == math.MaxUint32 { + return raDeprecatedLifetimeCapSecs + } + + return valid +} + // merge merges a fresh interface observation into s and reports whether the // active prefix changed. -func (s *raState) merge(obs raObservation, now time.Time) (change raActiveChange) { +func (s *raState) merge(obs raObservation, now time.Time) raActiveChange { if s.deprecated == nil { s.deprecated = map[netip.Prefix]*trackedPrefix{} } @@ -213,74 +223,83 @@ func (s *raState) merge(obs raObservation, now time.Time) (change raActiveChange s.sourceAddr = obs.SourceAddr s.rdnssAddr = obs.RDNSSAddr - switch { - case obs.Active != nil && s.active != nil && s.active.prefix == obs.Active.Prefix: - s.active = reconcileTrackedPrefix(s.active, *obs.Active, raPrefixOriginObservedActive, now) - case obs.Active != nil: - s.moveActiveToDeprecated(now) - s.active = newTrackedPrefix(*obs.Active, raPrefixOriginObservedActive, now) - activeChanged = prev != nil && prev.Prefix != obs.Active.Prefix - case obs.Active == nil: - s.moveActiveToDeprecated(now) - s.active = nil - activeChanged = prev != nil - } + activeChanged = s.mergeActiveObservation(obs.Active, prev, now) if s.active != nil { delete(s.deprecated, s.active.prefix) } - observedInactive := map[netip.Prefix]struct{}{} + observedInactive := s.mergeInactiveObservations(obs.Inactive, prevActivePrefix, activeChanged, now) - for _, dep := range obs.Inactive { - if s.active != nil && dep.Prefix == s.active.prefix { - continue - } - if activeChanged && dep.Prefix == prevActivePrefix && dep.PreferredSec == 0 { - valid := dep.ValidSec - if valid > raDeprecatedLifetimeCapSecs || valid == math.MaxUint32 { - valid = raDeprecatedLifetimeCapSecs - } - if valid == 0 { - delete(s.deprecated, dep.Prefix) + s.deprecateMissingObservedInactivePrefixes(observedInactive, now) - continue - } + s.evictExpired(now) - s.deprecated[dep.Prefix] = reconcileTrackedPrefix( - s.deprecated[dep.Prefix], - raPrefixSnapshot{ - Prefix: dep.Prefix, - PreferredSec: 0, - ValidSec: valid, - }, - raPrefixOriginDeprecated, - now, - ) + next := s.activeSnapshot(now) + return raActiveChange{ + Changed: !sameActivePrefix(prev, next), + Active: next, + } +} - continue - } +// mergeActiveObservation updates the active prefix from obs and reports +// whether the active prefix changed. +func (s *raState) mergeActiveObservation( + obs, prev *raPrefixSnapshot, + now time.Time, +) (activeChanged bool) { + switch { + case obs != nil && s.active != nil && s.active.prefix == obs.Prefix: + s.active = reconcileTrackedPrefix(s.active, *obs, raPrefixOriginObservedActive, now) + case obs != nil: + s.moveActiveToDeprecated(now) + s.active = newTrackedPrefix(*obs, raPrefixOriginObservedActive, now) + activeChanged = prev != nil && prev.Prefix != obs.Prefix + default: + s.moveActiveToDeprecated(now) + s.active = nil + activeChanged = prev != nil + } - if dep.PreferredSec > 0 { - observedInactive[dep.Prefix] = struct{}{} - s.deprecated[dep.Prefix] = reconcileTrackedPrefix( - s.deprecated[dep.Prefix], - dep, - raPrefixOriginObservedInactive, - now, - ) + return activeChanged +} - continue - } +// mergeInactiveObservations reconciles inactive prefix observations with the +// tracked deprecated prefixes and returns the set of prefixes that still +// appear as observed inactive. +func (s *raState) mergeInactiveObservations( + inactive []raPrefixSnapshot, + prevActivePrefix netip.Prefix, + activeChanged bool, + now time.Time, +) (observedInactive map[netip.Prefix]struct{}) { + observedInactive = map[netip.Prefix]struct{}{} - valid := dep.ValidSec - if valid > raDeprecatedLifetimeCapSecs || valid == math.MaxUint32 { - valid = raDeprecatedLifetimeCapSecs - } + for _, dep := range inactive { + s.mergeInactiveObservation(dep, prevActivePrefix, activeChanged, now, observedInactive) + } + + return observedInactive +} + +// mergeInactiveObservation reconciles one inactive prefix observation. +func (s *raState) mergeInactiveObservation( + dep raPrefixSnapshot, + prevActivePrefix netip.Prefix, + activeChanged bool, + now time.Time, + observedInactive map[netip.Prefix]struct{}, +) { + if s.active != nil && dep.Prefix == s.active.prefix { + return + } + + if activeChanged && dep.Prefix == prevActivePrefix && dep.PreferredSec == 0 { + valid := capDeprecatedLifetime(dep.ValidSec) if valid == 0 { delete(s.deprecated, dep.Prefix) - continue + return } s.deprecated[dep.Prefix] = reconcileTrackedPrefix( @@ -293,8 +312,47 @@ func (s *raState) merge(obs raObservation, now time.Time) (change raActiveChange raPrefixOriginDeprecated, now, ) + + return } + if dep.PreferredSec > 0 { + observedInactive[dep.Prefix] = struct{}{} + s.deprecated[dep.Prefix] = reconcileTrackedPrefix( + s.deprecated[dep.Prefix], + dep, + raPrefixOriginObservedInactive, + now, + ) + + return + } + + valid := capDeprecatedLifetime(dep.ValidSec) + if valid == 0 { + delete(s.deprecated, dep.Prefix) + + return + } + + s.deprecated[dep.Prefix] = reconcileTrackedPrefix( + s.deprecated[dep.Prefix], + raPrefixSnapshot{ + Prefix: dep.Prefix, + PreferredSec: 0, + ValidSec: valid, + }, + raPrefixOriginDeprecated, + now, + ) +} + +// deprecateMissingObservedInactivePrefixes converts any observed-inactive +// prefixes that are no longer present into deprecated prefixes. +func (s *raState) deprecateMissingObservedInactivePrefixes( + observedInactive map[netip.Prefix]struct{}, + now time.Time, +) { for pref, tracked := range s.deprecated { if tracked.origin != raPrefixOriginObservedInactive { continue @@ -304,14 +362,6 @@ func (s *raState) merge(obs raObservation, now time.Time) (change raActiveChange s.deprecateTrackedPrefix(pref, tracked, now) } } - - s.evictExpired(now) - - next := s.activeSnapshot(now) - change.Changed = !sameActivePrefix(prev, next) - change.Active = next - - return change } // deprecateTrackedPrefix converts a tracked prefix into a deprecated one using @@ -323,9 +373,7 @@ func (s *raState) deprecateTrackedPrefix(pref netip.Prefix, tracked *trackedPref return } - if valid > raDeprecatedLifetimeCapSecs || valid == math.MaxUint32 { - valid = raDeprecatedLifetimeCapSecs - } + valid = capDeprecatedLifetime(valid) s.deprecated[pref] = newTrackedPrefix(raPrefixSnapshot{ Prefix: pref, @@ -387,6 +435,15 @@ func buildInterfaceRAObservation(states []aghnet.IPv6AddrState) (obs raObservati obs.SourceAddr = pickRASourceAddr(states) obs.RDNSSAddr = obs.SourceAddr + prefixes := buildInterfaceRAPrefixSnapshots(states) + obs.Active, obs.Inactive = splitInterfaceRAPrefixSnapshots(prefixes) + + return obs +} + +// buildInterfaceRAPrefixSnapshots groups eligible interface states by prefix +// and collapses each group into a single snapshot. +func buildInterfaceRAPrefixSnapshots(states []aghnet.IPv6AddrState) (prefixes []raPrefixSnapshot) { grouped := map[netip.Prefix][]aghnet.IPv6AddrState{} for _, st := range states { if !isEligibleRAPrefixState(st) { @@ -397,7 +454,7 @@ func buildInterfaceRAObservation(states []aghnet.IPv6AddrState) (obs raObservati grouped[pref] = append(grouped[pref], st) } - prefixes := make([]raPrefixSnapshot, 0, len(grouped)) + prefixes = make([]raPrefixSnapshot, 0, len(grouped)) for pref, group := range grouped { snap, ok := collapsePrefixGroup(pref, group) if !ok { @@ -407,7 +464,40 @@ func buildInterfaceRAObservation(states []aghnet.IPv6AddrState) (obs raObservati prefixes = append(prefixes, snap) } - activeIdx := -1 + 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 @@ -418,24 +508,7 @@ func buildInterfaceRAObservation(states []aghnet.IPv6AddrState) (obs raObservati } } - for i, pref := range prefixes { - switch { - case i == activeIdx: - obs.Active = &raPrefixSnapshot{ - Prefix: pref.Prefix, - PreferredSec: pref.PreferredSec, - ValidSec: pref.ValidSec, - } - default: - obs.Inactive = append(obs.Inactive, pref) - } - } - - slices.SortFunc(obs.Inactive, func(a, b raPrefixSnapshot) int { - return prefixCompare(a.Prefix, b.Prefix) - }) - - return obs + return activeIdx } // buildStaticRAObservation returns the configured static prefix observation. diff --git a/internal/dhcpd/v6_unix.go b/internal/dhcpd/v6_unix.go index 562c50862..39166a687 100644 --- a/internal/dhcpd/v6_unix.go +++ b/internal/dhcpd/v6_unix.go @@ -684,7 +684,7 @@ func sameDeadlineMap(a, b map[netip.Prefix]time.Time) (ok bool) { } for pref, until := range a { - if other, ok := b[pref]; !ok || !other.Equal(until) { + if other, found := b[pref]; !found || !other.Equal(until) { return false } } @@ -756,6 +756,102 @@ func (s *v6Server) checkIA(msg *dhcpv6.Message, lease *dhcpsvc.Lease) error { return nil } +// 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 +} + +// 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) + } + + 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 @@ -787,76 +883,9 @@ func (s *v6Server) commitLeaseLocked( msg *dhcpv6.Message, lease *dhcpsvc.Lease, ) (lifetime, preferredLifetime time.Duration, shouldNotify bool) { - leaseTime := s.conf.leaseTime - - // Snapshot the prefix-tracking state that the computations below depend - // on so they all agree about which view of the prefix we are acting - // under. - prefix := netip.PrefixFrom(lease.IP, raObservedPrefixBits).Masked() - renewable := !lease.IsStatic && leasePrefixRenewable(s.renewablePrefixes, lease.IP) - validUntil, hasValidUntil := s.validUntilByPrefix[prefix] - preferredUntil, hasPreferredUntil := s.preferredUntilByPrefix[prefix] - - // renewableLifetime is the lifetime we would grant a fresh lease, - // capped by the prefix's remaining valid lifetime when tracking data is - // available. - renewableLifetime := leaseTime - if hasValidUntil { - capped := time.Duration(remainingUntil(now, validUntil)) * time.Second - renewableLifetime = min(renewableLifetime, capped) - } - - // deprecatedLifetime is the remaining lifetime we are willing to honor - // for an existing lease whose prefix is no longer renewable. - var deprecatedLifetime time.Duration - if hasValidUntil { - validForPrefix := time.Duration(remainingUntil(now, validUntil)) * time.Second - validForLease := max(time.Until(lease.Expiry), 0) - deprecatedLifetime = min(validForLease, validForPrefix) - } - - // Default valid lifetime used for Solicit and any non-special cases. - lifetime = leaseTime - - switch msg.Type() { - case dhcpv6.MessageTypeConfirm: - switch { - case lease.IsStatic: - lifetime = leaseTime - case renewable: - lifetime = min(max(time.Until(lease.Expiry), 0), renewableLifetime) - default: - lifetime = deprecatedLifetime - } - - case dhcpv6.MessageTypeRequest, - dhcpv6.MessageTypeRenew, - dhcpv6.MessageTypeRebind: - - switch { - case lease.IsStatic: - lifetime = leaseTime - case renewable: - lifetime = renewableLifetime - lease.Expiry = now.Add(lifetime) - shouldNotify = true - default: - lifetime = deprecatedLifetime - } - } - - // Derive preferred lifetime from the same snapshot. - switch { - case lease.IsStatic: - preferredLifetime = lifetime - case !renewable: - preferredLifetime = 0 - case hasPreferredUntil: - preferredForPrefix := time.Duration(remainingUntil(now, preferredUntil)) * time.Second - preferredLifetime = min(lifetime, preferredForPrefix) - default: - preferredLifetime = lifetime - } + snapshot := s.snapshotLeaseCommitState(now, lease) + lifetime, shouldNotify = commitLeaseLifetime(now, msg.Type(), lease, snapshot) + preferredLifetime = commitLeasePreferredLifetime(now, lifetime, lease, snapshot) return lifetime, preferredLifetime, shouldNotify } @@ -881,6 +910,34 @@ func requestedIP(msg *dhcpv6.Message) (ip netip.Addr) { return addr } +// exactRequestedLease reports whether lease matches the requested IP. +func exactRequestedLease(reqIP netip.Addr, lease *dhcpsvc.Lease) (ok bool) { + return reqIP.IsValid() && lease.IP == reqIP +} + +// usableRequestedDynamicLease reports whether a dynamic lease may be served +// for the exact requested IP. +func usableRequestedDynamicLease( + msgType dhcpv6.MessageType, + reqIP netip.Addr, + inCurrentPool bool, + advertisedPrefixes map[netip.Prefix]struct{}, + lease *dhcpsvc.Lease, +) (ok bool) { + if !exactRequestedLease(reqIP, lease) { + return false + } + + return (inCurrentPool || canServeDeprecatedLease(msgType, lease.IP, advertisedPrefixes)) && + leaseNotExpired(lease) +} + +// usableFallbackLease reports whether lease may be reused when no exact match +// was found. +func (s *v6Server) usableFallbackLease(lease *dhcpsvc.Lease) (ok bool) { + return s.ipInCurrentPoolLocked(lease.IP) && leaseNotExpired(lease) +} + // findUsableLease returns a lease that should be served to mac for msg. func (s *v6Server) findUsableLease(msg *dhcpv6.Message, mac net.HardwareAddr) (lease *dhcpsvc.Lease) { reqIP := requestedIP(msg) @@ -891,29 +948,58 @@ func (s *v6Server) findUsableLease(msg *dhcpv6.Message, mac net.HardwareAddr) (l continue } - if l.IsStatic { - if reqIP.IsValid() && l.IP == reqIP { - return l - } else if lease == nil { - lease = l - } - - continue + if usable := s.exactUsableLease(msgType, reqIP, l); usable != nil { + return usable } - - if reqIP.IsValid() && l.IP == reqIP { - if (s.ipInCurrentPoolLocked(l.IP) && leaseNotExpired(l)) || - (canServeDeprecatedLease(msgType, l.IP, s.advertisedPrefixes) && leaseNotExpired(l)) { - return l - } - } else if lease == nil && s.ipInCurrentPoolLocked(l.IP) && leaseNotExpired(l) { - lease = l + if lease == nil { + lease = s.fallbackLease(l) } } return lease } +// exactUsableLease returns lease when the request explicitly targets it and the +// current server state still allows serving it. +func (s *v6Server) exactUsableLease( + msgType dhcpv6.MessageType, + reqIP netip.Addr, + lease *dhcpsvc.Lease, +) (usable *dhcpsvc.Lease) { + if lease.IsStatic { + if exactRequestedLease(reqIP, lease) { + return lease + } + + return nil + } + + if usableRequestedDynamicLease( + msgType, + reqIP, + s.ipInCurrentPoolLocked(lease.IP), + s.advertisedPrefixes, + lease, + ) { + return lease + } + + return nil +} + +// fallbackLease returns the best reusable lease candidate when no exact +// request-target match was found. +func (s *v6Server) fallbackLease(lease *dhcpsvc.Lease) (fallback *dhcpsvc.Lease) { + switch { + case lease.IsStatic: + return lease + case s.usableFallbackLease(lease): + return lease + default: + return nil + } +} + // canServeDeprecatedLease reports whether a deprecated dynamic lease for ip may // still be served for msgType while its prefix remains advertised. func canServeDeprecatedLease( @@ -1028,6 +1114,43 @@ func (s *v6Server) process(msg *dhcpv6.Message, req, resp dhcpv6.DHCPv6) bool { return true } +// newPacketResponse creates the base response for msg. +func newPacketResponse(msg *dhcpv6.Message) (resp dhcpv6.DHCPv6, err error) { + switch msg.Type() { + case dhcpv6.MessageTypeSolicit: + if msg.GetOneOption(dhcpv6.OptionRapidCommit) == nil { + return dhcpv6.NewAdvertiseFromSolicit(msg) + } + + return dhcpv6.NewReplyFromMessage(msg) + case dhcpv6.MessageTypeRequest, + dhcpv6.MessageTypeConfirm, + dhcpv6.MessageTypeRenew, + dhcpv6.MessageTypeRebind, + dhcpv6.MessageTypeRelease, + dhcpv6.MessageTypeInformationRequest: + return dhcpv6.NewReplyFromMessage(msg) + default: + return nil, fmt.Errorf("message type %d not supported", msg.Type()) + } +} + +// addProcessFailureStatus appends the recoverable status code for a failed +// lease-processing path. +func addProcessFailureStatus(msgType dhcpv6.MessageType, resp dhcpv6.DHCPv6) (ok bool) { + code, text, ok := replyStatusForProcessFailure(msgType) + if !ok { + return false + } + + resp.AddOption(&dhcpv6.OptStatusCode{ + StatusCode: code, + StatusMessage: text, + }) + + return true +} + // 1. // fe80::* (client) --(Solicit + ClientID+IANA())-> ff02::1:2 // server -(Advertise + ClientID+ServerID+IANA(IAAddress)> fe80::* @@ -1062,29 +1185,7 @@ func (s *v6Server) packetHandler(conn net.PacketConn, peer net.Addr, req dhcpv6. return } - var resp dhcpv6.DHCPv6 - - switch msg.Type() { - case dhcpv6.MessageTypeSolicit: - if msg.GetOneOption(dhcpv6.OptionRapidCommit) == nil { - resp, err = dhcpv6.NewAdvertiseFromSolicit(msg) - - break - } - - resp, err = dhcpv6.NewReplyFromMessage(msg) - case dhcpv6.MessageTypeRequest, - dhcpv6.MessageTypeConfirm, - dhcpv6.MessageTypeRenew, - dhcpv6.MessageTypeRebind, - dhcpv6.MessageTypeRelease, - dhcpv6.MessageTypeInformationRequest: - resp, err = dhcpv6.NewReplyFromMessage(msg) - default: - log.Error("dhcpv6: message type %d not supported", msg.Type()) - - return - } + resp, err := newPacketResponse(msg) if err != nil { log.Error("dhcpv6: %s", err) @@ -1094,12 +1195,7 @@ func (s *v6Server) packetHandler(conn net.PacketConn, peer net.Addr, req dhcpv6. resp.AddOption(dhcpv6.OptServerID(s.sid)) if !s.process(msg, req, resp) { - if code, text, ok := replyStatusForProcessFailure(msg.Type()); ok { - resp.AddOption(&dhcpv6.OptStatusCode{ - StatusCode: code, - StatusMessage: text, - }) - } else if requiresProcessSuccess(msg.Type()) { + if !addProcessFailureStatus(msg.Type(), resp) && requiresProcessSuccess(msg.Type()) { return } } @@ -1218,13 +1314,16 @@ func (s *v6Server) observeRAState(ctx context.Context) (obs raObservation, err e // // An active prefix whose preferred lifetime has already reached zero is // treated as unavailable for new leases. If another advertised prefix still -// has a non-zero preferred lifetime, the pool is moved there immediately; -// otherwise the pool is set to nil so new Solicit/Request pairs cannot reserve -// addresses on a prefix we would then have to answer with a zero-lifetime -// Reply. Existing leases on deprecated prefixes are still honored via the -// deprecated-lease path in [v6Server.findUsableLease] and +// has a non-zero preferred lifetime, the pool is moved there immediately. +// Otherwise, the pool is set to nil so new Solicit/Request pairs cannot +// reserve addresses on a prefix we would then have to answer with a +// zero-lifetime Reply. Existing leases on deprecated prefixes are still +// honored via the deprecated-lease path in [v6Server.findUsableLease] and // [v6Server.commitLease]. -func (s *v6Server) trackedPrefixChanged(active *raPrefixSnapshot, advertised []prefixPIO) (err error) { +func (s *v6Server) trackedPrefixChanged( + active *raPrefixSnapshot, + advertised []prefixPIO, +) (err error) { if !s.conf.NeedsDHCPv6Pool() { s.setTrackedRangeStart(nil, advertised) @@ -1290,46 +1389,7 @@ func (s *v6Server) setTrackedRangeStart(ipStart net.IP, advertised []prefixPIO) s.renewablePrefixes = renewable s.preferredUntilByPrefix = preferredUntil s.validUntilByPrefix = validUntil - - activePrefix := netip.Prefix{} - if len(ipStart) == net.IPv6len { - if addr, ok := netip.AddrFromSlice(ipStart); ok { - activePrefix = netip.PrefixFrom(addr, raObservedPrefixBits).Masked() - } - } - - // Always clear and rebuild the occupancy bitmap from the surviving - // leases. Callers such as ResetLeases replace s.leases wholesale - // without touching s.ipAddrs, and an earlier version of this function - // that skipped the rebuild when ipStart happened to be unchanged - // left stale bits from dropped leases behind, which made the pool - // appear exhausted long after those addresses had been released. - s.ipAddrs = [256]byte{} - - removed := 0 - updated := false - leases := s.leases[:0] - for _, l := range s.leases { - if !l.IsStatic { - pref := netip.PrefixFrom(l.IP, raObservedPrefixBits).Masked() - if !leasePrefixAdvertised(keepPrefixes, l.IP) || - (activePrefix.IsValid() && pref == activePrefix && !ip6InRange(ipStart, net.IP(l.IP.AsSlice()))) { - removed++ - - continue - } - - if until, ok := validUntil[pref]; ok && (l.Expiry.IsZero() || l.Expiry.After(until)) { - l.Expiry = until - updated = true - } - } - - leases = append(leases, l) - s.markLeaseOccupied(l) - } - - s.leases = leases + removed, updated := s.retainTrackedLeases(ipStart, keepPrefixes, validUntil) newDeprecated := deprecatedMetaFrom(now, renewable, keepPrefixes, validUntil) metadataChanged := (len(oldDeprecated) > 0 || len(newDeprecated) > 0) && (!samePrefixSet(oldRenewable, renewable) || !sameDeadlineMap(oldDeprecated, newDeprecated)) @@ -1373,47 +1433,48 @@ func (s *v6Server) hasStaticV6Leases() (ok bool) { return false } -// restoreDeprecatedPrefixes seeds initial deprecated prefixes from persisted -// metadata whose renewable prefixes still match the currently observed -// interface state. -func (s *v6Server) restoreDeprecatedPrefixes(now time.Time, st *raState) { - s.leasesLock.Lock() - defer s.leasesLock.Unlock() - - s.persistRestoredMeta = false - - if len(s.restoredDeprecated) == 0 { - return - } - +// restoredPrefixesMatchObserved reports whether the persisted renewable +// prefixes still match the currently observed interface state. +func restoredPrefixesMatchObserved( + now time.Time, + st *raState, + restored map[netip.Prefix]struct{}, +) (ok bool) { observedRenewable := renewableLeasePrefixes(st.pios(now)) - if len(s.restoredRenewable) > 0 { - if !prefixSetContainsAll(observedRenewable, s.restoredRenewable) { - return - } - } else if len(observedRenewable) > 0 { - return + switch { + case len(restored) > 0: + return prefixSetContainsAll(observedRenewable, restored) + case len(observedRenewable) > 0: + return false + default: + return true } +} - advertised := advertisedLeasePrefixes(st.pios(now)) - if len(advertised) == 0 { - return - } - - if len(s.restoredRenewable) == 0 { - overlap := false - for pref := range advertised { - if _, ok := s.restoredDeprecated[pref]; ok { - overlap = true - break - } - } - if !overlap { - return +// restoredDeprecatedPrefixOverlap reports whether any persisted deprecated +// prefix is still advertised. +func restoredDeprecatedPrefixOverlap( + advertised map[netip.Prefix]struct{}, + restored map[netip.Prefix]time.Time, +) (ok bool) { + for pref := range advertised { + if _, found := restored[pref]; found { + return true } } - for pref, until := range s.restoredDeprecated { + return false +} + +// restoreDeprecatedPrefixEntries seeds the tracked state with persisted +// deprecated prefixes that are no longer advertised. +func restoreDeprecatedPrefixEntries( + st *raState, + now time.Time, + advertised map[netip.Prefix]struct{}, + restored map[netip.Prefix]time.Time, +) { + for pref, until := range restored { if _, ok := advertised[pref]; ok { continue } @@ -1435,6 +1496,36 @@ func (s *v6Server) restoreDeprecatedPrefixes(now time.Time, st *raState) { } } +// restoreDeprecatedPrefixes seeds initial deprecated prefixes from persisted +// metadata whose renewable prefixes still match the currently observed +// interface state. +func (s *v6Server) restoreDeprecatedPrefixes(now time.Time, st *raState) { + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + s.persistRestoredMeta = false + + if len(s.restoredDeprecated) == 0 { + return + } + + if !restoredPrefixesMatchObserved(now, st, s.restoredRenewable) { + return + } + + advertised := advertisedLeasePrefixes(st.pios(now)) + if len(advertised) == 0 { + return + } + + if len(s.restoredRenewable) == 0 && + !restoredDeprecatedPrefixOverlap(advertised, s.restoredDeprecated) { + return + } + + restoreDeprecatedPrefixEntries(st, now, advertised, s.restoredDeprecated) +} + // setRestoredPrefixMeta stores deprecated-prefix metadata loaded from disk. func (s *v6Server) setRestoredPrefixMeta( renewable map[netip.Prefix]struct{}, @@ -1450,7 +1541,12 @@ func (s *v6Server) setRestoredPrefixMeta( // deprecatedPrefixMeta returns persisted metadata for currently tracked // interface-derived prefixes. -func (s *v6Server) deprecatedPrefixMeta(now time.Time) (renewable map[netip.Prefix]struct{}, deprecated map[netip.Prefix]time.Time) { +func (s *v6Server) deprecatedPrefixMeta( + now time.Time, +) ( + renewable map[netip.Prefix]struct{}, + deprecated map[netip.Prefix]time.Time, +) { s.leasesLock.Lock() defer s.leasesLock.Unlock() @@ -1486,98 +1582,165 @@ func (s *v6Server) deprecatedPrefixMetaLocked(now time.Time) (renewable map[neti return renewable, deprecated } -// Start starts the IPv6 DHCP server. -func (s *v6Server) Start(ctx context.Context) (err error) { - defer func() { err = errors.Annotate(err, "dhcpv6: %w") }() - - if !s.conf.Enabled { - return nil - } - - 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 !ok { - if s.conf.NormalizedPrefixSource() != V6PrefixSourceInterface { - // No available IP addresses which may appear later. - return nil - } - } - - var ( - initial raState - observe raObserver - ) - +// 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: - 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. Transient errors are rare compared to permanent - // environment misconfigurations (missing ifconfig, netlink - // denied, wrong interface, cancelled context) and surfacing - // them at Start() is how an operator notices. - obs, obsErr := s.observeRAState(ctx) - if obsErr != nil { - return fmt.Errorf("observing initial ipv6 prefix state: %w", obsErr) - } - - now := time.Now() - initial.merge(obs, now) - s.restoreDeprecatedPrefixes(now, &initial) - if pios := initial.pios(now); len(pios) > 0 { - if err = s.trackedPrefixChanged(initial.activeSnapshot(now), pios); err != nil { - return fmt.Errorf("updating tracked range start: %w", err) - } - } - - observe = s.observeRAState - s.ra.onStateRefresh = func(now time.Time, st *raState) { - s.restoreDeprecatedPrefixes(now, st) - } - s.ra.onActivePrefixChange = func(active *raPrefixSnapshot, advertised []prefixPIO) { - if activeErr := s.trackedPrefixChanged(active, advertised); activeErr != nil { - log.Error("dhcpv6: updating tracked pool: %s", activeErr) - } - } + return s.startInterfacePrefixTracking(ctx) default: - return fmt.Errorf("unsupported prefix source %q", s.conf.PrefixSource) + 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", + ) } - err = s.initRA(iface, initial, observe) - if err != nil { - return err + 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.NeedsDHCPv6Pool() { log.Debug("not starting dhcpv6 server due to ra_slaac_only=true") @@ -1614,6 +1777,46 @@ func (s *v6Server) Start(ctx context.Context) (err error) { return nil } +// Start starts the IPv6 DHCP server. +func (s *v6Server) Start(ctx context.Context) (err error) { + defer func() { err = errors.Annotate(err, "dhcpv6: %w") }() + + if !s.conf.Enabled { + return nil + } + + ifaceName := s.conf.InterfaceName + iface, err := net.InterfaceByName(ifaceName) + if err != nil { + return fmt.Errorf("finding interface %s by name: %w", ifaceName, err) + } + + log.Debug("dhcpv6: starting...") + + ok, err := s.configureDNSIPAddrs(ctx, iface) + if err != nil { + // Don't wrap the error, because it's informative enough as is. + return err + } + + if skipStartAfterDNSConfig(ok, s.conf.NormalizedPrefixSource()) { + // No available IP addresses which may appear later. + return nil + } + + initial, observe, err := s.startPrefixSourceState(ctx) + if err != nil { + return err + } + + err = s.initRA(iface, initial, observe) + if err != nil { + return err + } + + return s.startDHCPv6Server(iface) +} + // Stop - stop server func (s *v6Server) Stop() (err error) { err = s.ra.Close() @@ -1638,6 +1841,52 @@ func (s *v6Server) Stop() (err error) { return nil } +// validateV6CreateRangeStart checks whether conf has the range-start value the +// server setup needs. +func validateV6CreateRangeStart(conf V6ServerConf) (err error) { + needsConfiguredRange := conf.NormalizedPrefixSource() == V6PrefixSourceStatic || conf.NeedsDHCPv6Pool() + if needsConfiguredRange && (conf.RangeStart == nil || conf.RangeStart.To16() == nil) { + return fmt.Errorf("invalid range-start IP: %s", conf.RangeStart) + } + + if len(conf.RangeStart) != 0 && conf.RangeStart.To16() == nil { + return fmt.Errorf("invalid range-start IP: %s", conf.RangeStart) + } + + return nil +} + +// configureV6CreateRangeStart normalizes the configured range-start IP. +func configureV6CreateRangeStart(conf *V6ServerConf) { + if len(conf.RangeStart) == 0 { + return + } + + conf.RangeStart = bytes.Clone(conf.RangeStart.To16()) +} + +// configureV6CreateStaticPrefix seeds the tracked pool for static prefix +// source mode. +func (s *v6Server) configureV6CreateStaticPrefix() { + s.conf.ipStart = bytes.Clone(s.conf.RangeStart) + if addr, ok := netip.AddrFromSlice(s.conf.ipStart); ok { + prefix := netip.PrefixFrom(addr, raObservedPrefixBits).Masked() + s.advertisedPrefixes = map[netip.Prefix]struct{}{prefix: {}} + s.renewablePrefixes = map[netip.Prefix]struct{}{prefix: {}} + } +} + +// configureV6CreateLeaseDuration fills in the effective lease duration. +func (s *v6Server) configureV6CreateLeaseDuration(conf V6ServerConf) { + if conf.LeaseDuration == 0 { + s.conf.leaseTime = timeutil.Day + s.conf.LeaseDuration = uint32(s.conf.leaseTime.Seconds()) + return + } + + s.conf.leaseTime = time.Second * time.Duration(conf.LeaseDuration) +} + // Create DHCPv6 server func v6Create(conf V6ServerConf) (DHCPServer, error) { s := &v6Server{} @@ -1664,33 +1913,16 @@ func v6Create(conf V6ServerConf) (DHCPServer, error) { return s, nil } - needsConfiguredRange := conf.NormalizedPrefixSource() == V6PrefixSourceStatic || conf.NeedsDHCPv6Pool() - if needsConfiguredRange && (conf.RangeStart == nil || conf.RangeStart.To16() == nil) { - return s, fmt.Errorf("dhcpv6: invalid range-start IP: %s", conf.RangeStart) - } - if len(conf.RangeStart) != 0 { - if conf.RangeStart.To16() == nil { - return s, fmt.Errorf("dhcpv6: invalid range-start IP: %s", conf.RangeStart) - } - - s.conf.RangeStart = bytes.Clone(conf.RangeStart.To16()) + if err = validateV6CreateRangeStart(conf); err != nil { + return s, fmt.Errorf("dhcpv6: %w", err) } + configureV6CreateRangeStart(&s.conf) if conf.NormalizedPrefixSource() == V6PrefixSourceStatic { - s.conf.ipStart = bytes.Clone(s.conf.RangeStart) - if addr, ok := netip.AddrFromSlice(s.conf.ipStart); ok { - prefix := netip.PrefixFrom(addr, raObservedPrefixBits).Masked() - s.advertisedPrefixes = map[netip.Prefix]struct{}{prefix: {}} - s.renewablePrefixes = map[netip.Prefix]struct{}{prefix: {}} - } + s.configureV6CreateStaticPrefix() } - if conf.LeaseDuration == 0 { - s.conf.leaseTime = timeutil.Day - s.conf.LeaseDuration = uint32(s.conf.leaseTime.Seconds()) - } else { - s.conf.leaseTime = time.Second * time.Duration(conf.LeaseDuration) - } + s.configureV6CreateLeaseDuration(conf) return s, nil } diff --git a/internal/dhcpd/v6_unix_internal_test.go b/internal/dhcpd/v6_unix_internal_test.go index 524a98d2c..6f7b20f0d 100644 --- a/internal/dhcpd/v6_unix_internal_test.go +++ b/internal/dhcpd/v6_unix_internal_test.go @@ -726,8 +726,9 @@ func TestV6ResetLeases_PreservesAdvertisedInterfacePrefixes(t *testing.T) { require.NoError(t, err) require.Len(t, s.leases, 2) - assert.Contains(t, []netip.Addr{s.leases[0].IP, s.leases[1].IP}, netip.MustParseAddr("2001:db8::10")) - assert.Contains(t, []netip.Addr{s.leases[0].IP, s.leases[1].IP}, netip.MustParseAddr("2001:db8:1::10")) + gotLeases := []netip.Addr{s.leases[0].IP, s.leases[1].IP} + assert.Contains(t, gotLeases, netip.MustParseAddr("2001:db8::10")) + assert.Contains(t, gotLeases, netip.MustParseAddr("2001:db8:1::10")) } func TestObservedDNSIPAddrs(t *testing.T) {