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:
co-authored by
Claude Opus 5.5
parent
7b0f096f87
commit
6473ab7b1d
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user