Align DHCPv6 prefix tracking with code guidelines

This commit is contained in:
Omoeba 2026-04-11 18:31:16 -07:00
parent 2ebbeb2867
commit f70bb2ba2a
No known key found for this signature in database
GPG key ID: 3E6DB79EF9F6FD28
12 changed files with 1224 additions and 680 deletions

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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") }()

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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.

View file

@ -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
}

View file

@ -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) {