feat(ipam): DHCP range from dynamic entries, DNS for dynamic entries

ADR 0025: dynamic entries publish A/PTR records and form the DHCP range of
their network; pools are no longer part of the network. Dynamic entries
need DHCP, IPv4 and must not be the gateway. Ranges() merges addresses into
runs of consecutive addresses, utilization counts dynamic entries, the
address map loses the pool state, previews report entering and leaving the
DHCP range by type. Helpers for the IP list and range operations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sir_melonchen
2026-10-16 17:57:11 +02:00
co-authored by Claude Opus 5.5
parent 7b0f096f87
commit 6473ab7b1d
7 changed files with 285 additions and 141 deletions
+3 -7
View File
@@ -63,18 +63,14 @@ var (
ErrIPFamily = newErr("ip_family", "address family does not match the network")
ErrIPNetworkAddress = newErr("ip_is_network_address", "the network address cannot be used")
ErrIPBroadcast = newErr("ip_is_broadcast_address", "the broadcast address cannot be used")
ErrGatewayInPool = newErr("gateway_in_pool", "the gateway must not be inside a DHCP pool")
ErrPoolOrder = newErr("pool_order", "pool start must not be after pool end")
ErrPoolOutside = newErr("pool_outside_network", "pool is not inside the network")
ErrPoolOverlap = newErr("pool_overlap", "pools overlap")
ErrPoolsNeedDHCP = newErr("pools_need_dhcp", "pools require DHCP to be enabled for the network")
ErrPoolIPv6 = newErr("pool_ipv6_unsupported", "DHCP pools are only supported for IPv4 (DHCPv6 follows later)")
ErrMACRequired = newErr("mac_required", "a MAC address is required for manual DHCP reservations")
ErrMACNotAllowed = newErr("mac_not_allowed", "reserved addresses cannot have a MAC address")
ErrDHCPDisabled = newErr("dhcp_disabled", "DHCP is not enabled for this network")
ErrDynamicOutsidePool = newErr("dynamic_outside_pool", "dynamic addresses must be inside a DHCP pool")
ErrInvalidType = newErr("invalid_type", "unknown address type")
ErrHostnameNeedsDomain = newErr("hostname_needs_domain", "DNS records need a domain on the network")
// ADR 0025: dynamic entries form the DHCP range.
ErrDynamicIsGateway = newErr("dynamic_is_gateway", "the gateway address cannot be a dynamic DHCP address")
ErrDynamicIPv6 = newErr("dynamic_ipv6_unsupported", "dynamic DHCP addresses are only supported for IPv4 (DHCPv6 follows later)")
)
// Errors is a list of validation errors.
+60 -8
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
// Package ipam contains the IP address management domain: networks, DHCP
// pools and addresses, their validation and derived views (utilization,
// ranges and addresses, their validation and derived views (utilization,
// address map, effective pools, change preview). Everything here is pure:
// no database, no clock.
package ipam
@@ -9,6 +9,7 @@ package ipam
import (
"encoding/binary"
"net/netip"
"slices"
"time"
)
@@ -23,10 +24,15 @@ const (
TypeReserved AddressType = "reserved"
// TypeManualDHCP is a Kea host reservation (MAC → IP) plus A/PTR.
TypeManualDHCP AddressType = "manual_dhcp"
// TypeDynamic is a pool address, usually discovered from a lease.
// TypeDynamic is an address of the DHCP range (no MAC) plus A/PTR
// (ADR 0025): the dynamic entries of a network form its Kea pools.
TypeDynamic AddressType = "dynamic"
)
// DynamicHostnamePrefix starts the random host names the server gives
// dynamic entries without a name (ADR 0025).
const DynamicHostnamePrefix = "dyn-"
// Valid reports whether t is a known type.
func (t AddressType) Valid() bool {
switch t {
@@ -36,8 +42,12 @@ func (t AddressType) Valid() bool {
return false
}
// PublishesDNS reports whether addresses of this type get A/PTR records.
func (t AddressType) PublishesDNS() bool { return t == TypeStatic || t == TypeManualDHCP }
// PublishesDNS reports whether addresses of this type get A/PTR records:
// every type except reserved (ADR 0025; before, dynamic entries had none,
// see configgen.Source.Version).
func (t AddressType) PublishesDNS() bool {
return t == TypeStatic || t == TypeManualDHCP || t == TypeDynamic
}
// ExcludedFromPools reports whether this type is cut out of DHCP pools.
func (t AddressType) ExcludedFromPools() bool { return t != TypeDynamic }
@@ -57,7 +67,10 @@ type Network struct {
LeaseTime *int // seconds
DNSServers []netip.Addr
NTPServers []netip.Addr
Pools []Pool
// Pools are the DHCP ranges. Since ADR 0025 they are derived from the
// dynamic entries (Ranges) and not stored; generator sources stored
// before carry the pools that were configured then.
Pools []Pool
// DHCPClusterID is the DHCP cluster serving the network; empty means
// the default (ADR 0023).
DHCPClusterID string
@@ -101,9 +114,48 @@ type Address struct {
LastSeen *time.Time
LeaseState string
LeaseExpiresAt *time.Time
Version int
CreatedAt time.Time
UpdatedAt time.Time
// LeaseHostname is the host name the DHCP client reported with its last
// lease. It is informational, never used for DNS (ADR 0025) and not part
// of the stored generator input.
LeaseHostname string `json:"-"`
Version int
CreatedAt time.Time
UpdatedAt time.Time
}
// Ranges merges IPv4 addresses into inclusive ranges of consecutive
// addresses. The input need not be sorted; duplicates and IPv6 addresses are
// ignored.
func Ranges(ips []netip.Addr) []Pool {
sorted := make([]netip.Addr, 0, len(ips))
for _, a := range ips {
if a.Is4() {
sorted = append(sorted, a)
}
}
slices.SortFunc(sorted, func(a, b netip.Addr) int { return a.Compare(b) })
sorted = slices.Compact(sorted)
var out []Pool
for _, a := range sorted {
if n := len(out); n > 0 && out[n-1].End.Next() == a {
out[n-1].End = a
continue
}
out = append(out, Pool{Start: a, End: a})
}
return out
}
// DynamicRanges returns the DHCP ranges formed by the dynamic entries
// (ADR 0025): every run of consecutive dynamic IPv4 addresses is one range.
func DynamicRanges(addrs []Address) []Pool {
var ips []netip.Addr
for i := range addrs {
if addrs[i].Type == TypeDynamic {
ips = append(ips, addrs[i].IP)
}
}
return Ranges(ips)
}
// v4 converts an IPv4 address to uint32.
+101 -41
View File
@@ -29,7 +29,6 @@ func baseNetwork() *Network {
Gateway: ptr(ip("192.0.2.1")),
Domain: "Office.Example.TEST.",
DHCPEnabled: true,
Pools: []Pool{pool("192.0.2.100", "192.0.2.199")},
DNSServers: []netip.Addr{ip("192.0.2.53")},
Tags: []string{" B ", "a", "b"},
}
@@ -102,28 +101,14 @@ func TestNormalizeNetworkErrors(t *testing.T) {
}{
"empty name": {func(n *Network) { n.Name = " " }, "name_required"},
"host bits": {func(n *Network) { n.CIDR = pfx("192.0.2.5/24") }, "cidr_not_network_address"},
"too large": {func(n *Network) { n.CIDR = pfx("10.0.0.0/7"); n.Pools = nil; n.Gateway = nil }, "prefix_length"},
"too large": {func(n *Network) { n.CIDR = pfx("10.0.0.0/7"); n.Gateway = nil }, "prefix_length"},
"gateway outside": {func(n *Network) { n.Gateway = ptr(ip("198.51.100.1")) }, "ip_outside_network"},
"gateway network": {func(n *Network) { n.Gateway = ptr(ip("192.0.2.0")) }, "ip_is_network_address"},
"gateway broadcast": {func(n *Network) { n.Gateway = ptr(ip("192.0.2.255")) }, "ip_is_broadcast_address"},
"gateway in pool": {func(n *Network) { n.Gateway = ptr(ip("192.0.2.150")) }, "gateway_in_pool"},
"pool reversed": {func(n *Network) { n.Pools = []Pool{pool("192.0.2.20", "192.0.2.10")} }, "pool_order"},
"pool outside": {func(n *Network) { n.Pools = []Pool{pool("192.0.2.200", "192.0.3.10")} }, "pool_outside_network"},
"pool broadcast": {func(n *Network) { n.Pools = []Pool{pool("192.0.2.200", "192.0.2.255")} }, "pool_outside_network"},
"pools overlap": {func(n *Network) {
n.Pools = []Pool{pool("192.0.2.100", "192.0.2.150"), pool("192.0.2.150", "192.0.2.160")}
}, "pool_overlap"},
"pools without dhcp": {func(n *Network) { n.DHCPEnabled = false }, "pools_need_dhcp"},
"vlan": {func(n *Network) { n.VLAN = ptr(5000) }, "vlan_range"},
"lease time": {func(n *Network) { n.LeaseTime = ptr(10) }, "lease_time_range"},
"bad domain": {func(n *Network) { n.Domain = "bad_domain.test" }, "invalid_domain"},
"v6 dns server": {func(n *Network) { n.DNSServers = []netip.Addr{ip("2001:db8::53")} }, "ip_family"},
"v6 pools": {func(n *Network) {
n.CIDR = pfx("2001:db8::/64")
n.Gateway = nil
n.DNSServers = nil
n.Pools = []Pool{pool("2001:db8::10", "2001:db8::20")}
}, "pool_ipv6_unsupported"},
"vlan": {func(n *Network) { n.VLAN = ptr(5000) }, "vlan_range"},
"lease time": {func(n *Network) { n.LeaseTime = ptr(10) }, "lease_time_range"},
"bad domain": {func(n *Network) { n.Domain = "bad_domain.test" }, "invalid_domain"},
"v6 dns server": {func(n *Network) { n.DNSServers = []netip.Addr{ip("2001:db8::53")} }, "ip_family"},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
@@ -136,14 +121,69 @@ func TestNormalizeNetworkErrors(t *testing.T) {
}
}
func TestNormalizeNetworkSortsPools(t *testing.T) {
n := baseNetwork()
n.Pools = []Pool{pool("192.0.2.150", "192.0.2.160"), pool("192.0.2.100", "192.0.2.110")}
if errs := NormalizeNetwork(n); len(errs) != 0 {
t.Fatal(errs)
func TestRanges(t *testing.T) {
got := Ranges([]netip.Addr{ip("192.0.2.12"), ip("192.0.2.10"), ip("192.0.2.11"), ip("192.0.2.11"),
ip("192.0.2.20"), ip("2001:db8::1"), ip("192.0.2.255"), ip("192.0.3.0")})
want := []Pool{pool("192.0.2.10", "192.0.2.12"), pool("192.0.2.20", "192.0.2.20"), pool("192.0.2.255", "192.0.3.0")}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
if n.Pools[0].Start != ip("192.0.2.100") {
t.Errorf("pools not sorted: %v", n.Pools)
if Ranges(nil) != nil {
t.Error("no addresses must yield no ranges")
}
addrs := []Address{
{IP: ip("192.0.2.101"), Type: TypeDynamic},
{IP: ip("192.0.2.100"), Type: TypeDynamic},
{IP: ip("192.0.2.102"), Type: TypeStatic},
{IP: ip("192.0.2.103"), Type: TypeDynamic},
}
if got := DynamicRanges(addrs); !reflect.DeepEqual(got, []Pool{pool("192.0.2.100", "192.0.2.101"), pool("192.0.2.103", "192.0.2.103")}) {
t.Errorf("dynamic ranges: %v", got)
}
}
func TestIPListHelpers(t *testing.T) {
n := &Network{CIDR: pfx("192.0.2.0/24"), Gateway: ptr(ip("192.0.2.1"))}
for a, want := range map[string]IPState{"192.0.2.0": IPNetwork, "192.0.2.255": IPBroadcast, "192.0.2.1": IPGateway,
"192.0.2.2": IPFree} {
if got := FixedState(n, ip(a)); got != want {
t.Errorf("%s: %s, want %s", a, got, want)
}
}
if got := FixedState(&Network{CIDR: pfx("192.0.2.0/31")}, ip("192.0.2.0")); got != IPFree {
t.Errorf("/31: %s", got)
}
if !Listable(pfx("10.0.0.0/16")) || Listable(pfx("10.0.0.0/15")) || Listable(pfx("2001:db8::/120")) {
t.Error("Listable wrong")
}
if AddrCount(pfx("10.0.0.0/16")) != 65536 || AddrCount(pfx("2001:db8::/64")) != 0 {
t.Error("AddrCount wrong")
}
if NthAddr(pfx("10.0.0.0/16"), 257) != ip("10.0.1.1") {
t.Error("NthAddr wrong")
}
if Assignable(pfx("192.0.2.0/24"), ip("192.0.2.0")) || !Assignable(pfx("192.0.2.0/24"), ip("192.0.2.1")) {
t.Error("Assignable wrong")
}
for _, tt := range []struct {
start, end string
limit uint64
size uint64
ok bool
}{
{"10.0.0.0", "10.0.255.255", 65536, 65536, true},
{"10.0.0.0", "10.1.0.0", 65536, 65537, false},
{"10.0.0.5", "10.0.0.5", 1, 1, true},
{"2001:db8::", "2001:db8::ffff", 65536, 65536, true},
{"2001:db8::", "2001:db8::1:0", 65536, 0, false},
{"2001:db8:0:0:ffff:ffff:ffff:ff00", "2001:db8:0:1::ff", 65536, 512, true},
{"2001:db8::", "2001:db9::", 65536, 0, false},
} {
size, ok := RangeSize(ip(tt.start), ip(tt.end), tt.limit)
if ok != tt.ok || (ok && size != tt.size) || (tt.start[0] == '1' && size != tt.size) {
t.Errorf("%s-%s: %d %v, want %d %v", tt.start, tt.end, size, ok, tt.size, tt.ok)
}
}
}
@@ -180,7 +220,8 @@ func TestNormalizeAddress(t *testing.T) {
}{
"manual without mac": {Address{IP: ip("192.0.2.10"), Type: TypeManualDHCP}, true, "mac_required"},
"manual without dhcp": {Address{IP: ip("192.0.2.10"), Type: TypeManualDHCP, MAC: "00:11:22:33:44:55"}, false, "dhcp_disabled"},
"dynamic outside pool": {Address{IP: ip("192.0.2.10"), Type: TypeDynamic}, true, "dynamic_outside_pool"},
"dynamic without dhcp": {Address{IP: ip("192.0.2.10"), Type: TypeDynamic}, false, "dhcp_disabled"},
"dynamic gateway": {Address{IP: ip("192.0.2.1"), Type: TypeDynamic}, true, "dynamic_is_gateway"},
"reserved with mac": {Address{IP: ip("192.0.2.10"), Type: TypeReserved, MAC: "00:11:22:33:44:55"}, true, "mac_not_allowed"},
"outside network": {Address{IP: ip("198.51.100.1"), Type: TypeStatic}, true, "ip_outside_network"},
"network address": {Address{IP: ip("192.0.2.0"), Type: TypeStatic}, true, "ip_is_network_address"},
@@ -201,13 +242,26 @@ func TestNormalizeAddress(t *testing.T) {
})
}
// Static and dynamic inside a pool are both fine.
// Every type works on any usable address (ADR 0025: no pools).
for _, typ := range []AddressType{TypeStatic, TypeDynamic, TypeReserved} {
a := &Address{IP: ip("192.0.2.150"), Type: typ}
if errs := NormalizeAddress(a, n); len(errs) != 0 {
t.Errorf("%s in pool: %v", typ, errs)
t.Errorf("%s: %v", typ, errs)
}
}
v6 := &Network{CIDR: pfx("2001:db8::/64"), DHCPEnabled: true}
if errs := NormalizeAddress(&Address{IP: ip("2001:db8::10"), Type: TypeDynamic}, v6); !hasCode(errs, "dynamic_ipv6_unsupported") {
t.Errorf("dynamic IPv6: %v", errs)
}
for _, typ := range []AddressType{TypeStatic, TypeManualDHCP, TypeDynamic} {
if !typ.PublishesDNS() {
t.Errorf("%s must publish DNS", typ)
}
}
if TypeReserved.PublishesDNS() {
t.Error("reserved must not publish DNS")
}
}
func TestErrorIs(t *testing.T) {
@@ -257,19 +311,19 @@ func TestUtilization(t *testing.T) {
n := &Network{
CIDR: pfx("192.0.2.0/24"),
Gateway: ptr(ip("192.0.2.1")),
Pools: []Pool{pool("192.0.2.100", "192.0.2.109")},
}
addrs := []Address{
{IP: ip("192.0.2.10"), Type: TypeStatic},
{IP: ip("192.0.2.100"), Type: TypeReserved},
{IP: ip("192.0.2.101"), Type: TypeDynamic, LeaseState: "active"},
{IP: ip("192.0.2.102"), Type: TypeDynamic, LeaseState: "expired"},
{IP: ip("192.0.2.1"), Type: TypeDynamic}, // gateway moved onto a dynamic entry
}
u := ComputeUtilization(n, addrs)
if u.Total != 254 || u.Used != 5 || u.PoolSize != 10 || u.PoolEffective != 9 || u.PoolLeased != 1 {
if u.Total != 254 || u.Used != 5 || u.PoolSize != 3 || u.PoolEffective != 2 || u.PoolLeased != 1 {
t.Errorf("unexpected %+v", u)
}
if u.ByType[TypeDynamic] != 2 || u.ByType[TypeManualDHCP] != 0 {
if u.ByType[TypeDynamic] != 3 || u.ByType[TypeManualDHCP] != 0 {
t.Errorf("by type %+v", u.ByType)
}
}
@@ -278,14 +332,13 @@ func TestAddressMap(t *testing.T) {
n := &Network{
CIDR: pfx("192.0.2.0/29"),
Gateway: ptr(ip("192.0.2.1")),
Pools: []Pool{pool("192.0.2.4", "192.0.2.6")},
}
addrs := []Address{
{IP: ip("192.0.2.2"), Type: TypeStatic, Hostname: "srv"},
{IP: ip("192.0.2.5"), Type: TypeDynamic, LeaseState: "active"},
}
cells := AddressMap(n, addrs)
want := []MapState{MapNetwork, MapGateway, MapStatic, MapFree, MapPool, MapDynamic, MapPool, MapBroadcast}
want := []MapState{MapNetwork, MapGateway, MapStatic, MapFree, MapFree, MapDynamic, MapFree, MapBroadcast}
if len(cells) != len(want) {
t.Fatalf("len %d", len(cells))
}
@@ -317,18 +370,18 @@ func codes(es []Effect) []string {
}
func TestPreviewChange(t *testing.T) {
n := &Network{CIDR: pfx("192.0.2.0/24"), Domain: "example.test", Pools: []Pool{pool("192.0.2.100", "192.0.2.199")}}
n := &Network{CIDR: pfx("192.0.2.0/24"), Domain: "example.test", DHCPEnabled: true}
dyn := &Address{IP: ip("192.0.2.150"), Type: TypeDynamic, MAC: "00:11:22:33:44:55", Hostname: "laptop", FQDN: "laptop.example.test"}
res := &Address{IP: ip("192.0.2.150"), Type: TypeManualDHCP, MAC: "00:11:22:33:44:55", Hostname: "laptop", FQDN: "laptop.example.test"}
got := codes(PreviewChange(n, dyn, res))
want := []string{EffectDNSAdd, EffectDHCPAddRes, EffectDHCPPoolExclude}
want := []string{EffectDHCPAddRes, EffectDHCPPoolExclude} // the name stays in DNS (ADR 0025)
if !reflect.DeepEqual(got, want) {
t.Errorf("dynamic → manual_dhcp: %v, want %v", got, want)
}
got = codes(PreviewChange(n, res, nil))
want = []string{EffectDNSRemove, EffectDHCPRemoveRes, EffectDHCPPoolInclude}
want = []string{EffectDNSRemove, EffectDHCPRemoveRes}
if !reflect.DeepEqual(got, want) {
t.Errorf("delete reservation: %v, want %v", got, want)
}
@@ -351,8 +404,15 @@ func TestPreviewChange(t *testing.T) {
if got := codes(PreviewChange(noDomain, nil, s)); !reflect.DeepEqual(got, []string{EffectDNSNoDomain}) {
t.Errorf("no domain: %v", got)
}
newDyn := &Address{IP: ip("192.0.2.120"), Type: TypeDynamic}
if got := codes(PreviewChange(n, nil, newDyn)); !reflect.DeepEqual(got, []string{EffectDHCPLeaseTracking}) {
t.Errorf("new dynamic: %v", got)
newDyn := &Address{IP: ip("192.0.2.120"), Type: TypeDynamic, Hostname: "dyn-a1b2c3d4", FQDN: "dyn-a1b2c3d4.example.test"}
want = []string{EffectDNSAdd, EffectDHCPPoolInclude, EffectDHCPLeaseTracking}
if got := codes(PreviewChange(n, nil, newDyn)); !reflect.DeepEqual(got, want) {
t.Errorf("new dynamic: %v, want %v", got, want)
}
moved := *newDyn
moved.IP = ip("192.0.2.121")
want = []string{EffectDNSRename, EffectDHCPPoolExclude, EffectDHCPPoolInclude}
if got := codes(PreviewChange(n, newDyn, &moved)); !reflect.DeepEqual(got, want) {
t.Errorf("moved dynamic: %v, want %v", got, want)
}
}
+89
View File
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package ipam
import (
"encoding/binary"
"net/netip"
)
// IPState is the state of one address in the IP list of a network
// (GET /networks/{id}/ips, ADR 0025).
type IPState string
// IP list states.
const (
IPFree IPState = "free"
IPNetwork IPState = "network"
IPBroadcast IPState = "broadcast"
IPGateway IPState = "gateway"
IPUsed IPState = "used"
)
// MinListBitsV4 is the shortest IPv4 prefix whose addresses can all be
// listed (a /16 has 65,536 addresses).
const MinListBitsV4 = 16
// Listable reports whether every address of p can be listed: IPv4 networks
// up to /16.
func Listable(p netip.Prefix) bool { return p.Addr().Is4() && p.Bits() >= MinListBitsV4 }
// AddrCount returns the number of addresses of an IPv4 prefix including the
// network and broadcast address (0 for IPv6).
func AddrCount(p netip.Prefix) uint64 {
if !p.Addr().Is4() {
return 0
}
return uint64(1) << (32 - p.Bits())
}
// NthAddr returns the address at offset i of an IPv4 prefix (i must be
// smaller than AddrCount).
func NthAddr(p netip.Prefix, i uint64) netip.Addr {
return fromV4(v4(p.Masked().Addr()) + uint32(i)) //nolint:gosec // i < 2^32 by contract
}
// FixedState returns the state of an address without entry: network,
// broadcast (IPv4 below /31), gateway or free.
func FixedState(n *Network, a netip.Addr) IPState {
if a.Is4() && n.CIDR.Bits() < 31 {
switch a {
case n.CIDR.Masked().Addr():
return IPNetwork
case Broadcast(n.CIDR):
return IPBroadcast
}
}
if n.Gateway != nil && a == *n.Gateway {
return IPGateway
}
return IPFree
}
// Assignable reports whether a is a usable host address of network p (not
// the network, broadcast or IPv6 subnet-router anycast address).
func Assignable(p netip.Prefix, a netip.Addr) bool { return checkAssignable(p, a) == nil }
// RangeSize returns the number of addresses from start to end (inclusive;
// same family, start <= end). ok is false if the size exceeds limit.
func RangeSize(start, end netip.Addr, limit uint64) (size uint64, ok bool) {
if start.Is4() {
size = uint64(v4(end)-v4(start)) + 1
return size, size <= limit
}
s, e := start.As16(), end.As16()
hs, he := binary.BigEndian.Uint64(s[:8]), binary.BigEndian.Uint64(e[:8])
ls, le := binary.BigEndian.Uint64(s[8:]), binary.BigEndian.Uint64(e[8:])
switch {
case he == hs:
size = le - ls
case he == hs+1 && le < ls:
size = le - ls // wraps around the 64-bit boundary
default:
return 0, false
}
if size >= limit {
return 0, false
}
return size + 1, true
}
+10 -11
View File
@@ -65,18 +65,17 @@ func PreviewChange(n *Network, before, after *Address) []Effect {
add(EffectDHCPChangeRes, "mac", after.MAC, "ip", after.IP.String())
}
inPoolBefore := before != nil && inAnyPool(n.Pools, before.IP)
inPoolAfter := after != nil && inAnyPool(n.Pools, after.IP)
exclBefore := inPoolBefore && before.Type.ExcludedFromPools()
exclAfter := inPoolAfter && after.Type.ExcludedFromPools()
// The DHCP range consists of the dynamic entries (ADR 0025).
rangeBefore := before != nil && before.Type == TypeDynamic
rangeAfter := after != nil && after.Type == TypeDynamic
switch {
case !exclBefore && exclAfter:
add(EffectDHCPPoolExclude, "ip", after.IP.String())
case exclBefore && !exclAfter:
add(EffectDHCPPoolInclude, "ip", before.IP.String())
case exclBefore && exclAfter && before.IP != after.IP:
add(EffectDHCPPoolInclude, "ip", before.IP.String())
add(EffectDHCPPoolExclude, "ip", after.IP.String())
case rangeBefore && !rangeAfter:
add(EffectDHCPPoolExclude, "ip", before.IP.String())
case !rangeBefore && rangeAfter:
add(EffectDHCPPoolInclude, "ip", after.IP.String())
case rangeBefore && rangeAfter && before.IP != after.IP:
add(EffectDHCPPoolExclude, "ip", before.IP.String())
add(EffectDHCPPoolInclude, "ip", after.IP.String())
}
if after != nil && after.Type == TypeDynamic && (before == nil || before.Type != TypeDynamic) {
add(EffectDHCPLeaseTracking, "ip", after.IP.String())
+10 -61
View File
@@ -4,7 +4,6 @@ package ipam
import (
"net/netip"
"slices"
"strings"
"unicode/utf8"
)
@@ -22,8 +21,8 @@ const (
maxOwnerLen = 200
)
// NormalizeNetwork validates n in place (lower-cased domains, sorted tags,
// sorted pools) and returns all problems found.
// NormalizeNetwork validates n in place (lower-cased domains, sorted tags)
// and returns all problems found.
func NormalizeNetwork(n *Network) Errors {
var errs Errors
add := func(e *Error, field string) { errs = append(errs, e.WithField(field)) }
@@ -107,52 +106,8 @@ func NormalizeNetwork(n *Network) Errors {
add(e, "gateway")
}
}
if len(n.Pools) > 0 {
if !isV4 {
add(ErrPoolIPv6, "pools")
return errs
}
if !n.DHCPEnabled {
add(ErrPoolsNeedDHCP, "pools")
}
errs = append(errs, normalizePools(n)...)
}
return errs
}
func normalizePools(n *Network) Errors {
var errs Errors
first, last := usableBounds(n.CIDR)
for _, p := range n.Pools {
switch {
case !p.Start.IsValid() || !p.End.IsValid() || !p.Start.Is4() || !p.End.Is4():
errs = append(errs, ErrIPFamily.WithField("pools"))
case p.Start.Compare(p.End) > 0:
errs = append(errs, ErrPoolOrder.With("start", p.Start.String()).WithField("pools"))
case p.Start.Compare(first) < 0 || p.End.Compare(last) > 0:
errs = append(errs, ErrPoolOutside.With("start", p.Start.String()).With("end", p.End.String()).WithField("pools"))
}
}
if len(errs) > 0 {
return errs
}
slices.SortFunc(n.Pools, func(a, b Pool) int { return a.Start.Compare(b.Start) })
for i := 1; i < len(n.Pools); i++ {
if n.Pools[i].Start.Compare(n.Pools[i-1].End) <= 0 {
errs = append(errs, ErrPoolOverlap.
With("first", n.Pools[i-1].Start.String()+"-"+n.Pools[i-1].End.String()).
With("second", n.Pools[i].Start.String()+"-"+n.Pools[i].End.String()).WithField("pools"))
}
}
if n.Gateway != nil {
for _, p := range n.Pools {
if p.Contains(*n.Gateway) {
errs = append(errs, ErrGatewayInPool.WithField("gateway"))
break
}
}
}
// The DHCP range is not part of the network (ADR 0025): it consists of
// the dynamic entries, which NormalizeAddress checks against the network.
return errs
}
@@ -237,10 +192,13 @@ func NormalizeAddress(a *Address, n *Network) Errors {
add(ErrDHCPDisabled, "type")
}
case TypeDynamic:
if !n.DHCPEnabled {
switch {
case !n.CIDR.Addr().Is4():
add(ErrDynamicIPv6, "type")
case !n.DHCPEnabled:
add(ErrDHCPDisabled, "type")
} else if !inAnyPool(n.Pools, a.IP) {
add(ErrDynamicOutsidePool, "ip")
case n.Gateway != nil && a.IP == *n.Gateway:
add(ErrDynamicIsGateway, "ip")
}
case TypeReserved:
if a.MAC != "" {
@@ -249,12 +207,3 @@ func NormalizeAddress(a *Address, n *Network) Errors {
}
return errs
}
func inAnyPool(pools []Pool, a netip.Addr) bool {
for _, p := range pools {
if p.Contains(a) {
return true
}
}
return false
}
+12 -13
View File
@@ -58,9 +58,12 @@ type Utilization struct {
Used uint64 `json:"used"`
// ByType counts entries per address type.
ByType map[AddressType]uint64 `json:"by_type"`
// PoolSize is the number of addresses in the configured pools.
// PoolSize is the number of dynamic IPv4 entries (the DHCP range,
// ADR 0025).
PoolSize uint64 `json:"pool_size"`
// PoolEffective is PoolSize minus addresses cut out of the pools.
// PoolEffective is the part of the range Kea hands out: PoolSize
// without a dynamic entry at the gateway address (possible only after
// changing the gateway).
PoolEffective uint64 `json:"pool_effective"`
// PoolLeased counts dynamic entries with an active lease.
PoolLeased uint64 `json:"pool_leased"`
@@ -76,8 +79,13 @@ func ComputeUtilization(n *Network, addrs []Address) Utilization {
for _, a := range addrs {
u.ByType[a.Type]++
u.Used++
if n.Gateway != nil && a.IP == *n.Gateway {
gatewayCounted = true
atGateway := n.Gateway != nil && a.IP == *n.Gateway
gatewayCounted = gatewayCounted || atGateway
if a.Type == TypeDynamic && a.IP.Is4() {
u.PoolSize++
if !atGateway {
u.PoolEffective++
}
}
if a.Type == TypeDynamic && a.LeaseState == "active" {
u.PoolLeased++
@@ -86,12 +94,6 @@ func ComputeUtilization(n *Network, addrs []Address) Utilization {
if n.Gateway != nil && !gatewayCounted {
u.Used++
}
for _, p := range n.Pools {
u.PoolSize += p.Size()
}
for _, p := range EffectivePools(n, addrs) {
u.PoolEffective += p.Size()
}
return u
}
@@ -101,7 +103,6 @@ type MapState string
// Address map states.
const (
MapFree MapState = "free"
MapPool MapState = "pool"
MapGateway MapState = "gateway"
MapNetwork MapState = "network"
MapBroadcast MapState = "broadcast"
@@ -149,8 +150,6 @@ func AddressMap(n *Network, addrs []Address) []MapCell {
c.Leased = a.LeaseState == "active"
case n.Gateway != nil && ip == *n.Gateway:
c.State = MapGateway
case inAnyPool(n.Pools, ip):
c.State = MapPool
}
cells = append(cells, c)
if ip == end {