Expand LAN to 10.0.0.0/22 with static/reserved/dynamic/spare zones

Splits the LAN into four /24 zones: 10.0.0.0/24 static (no DHCP, DNS
only), 10.0.1.0/24 fixed DHCP reservations by MAC, 10.0.2.0/24 dynamic
DHCP pool, 10.0.3.0/24 spare/unused. ipadm now derives the required
subnet from whether a host has a MAC, auto-assigns free IPs, and
auto-migrates a host's IP when its MAC is added/removed. Migrated the
existing archerc80 reservation from 10.0.0.2 to 10.0.1.2.

Also fixes a latent bug found while doing this: dnsmasq's SIGHUP
(`systemctl reload`) only re-reads /etc/hosts, not the conf-dir files
ipadm writes to, so config changes were silently not applied on
reload. ipadm now restarts dnsmasq instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 08:57:58 +02:00
co-authored by Claude Sonnet 5
parent 78beedeabc
commit c135f205fa
10 changed files with 364 additions and 112 deletions
+65 -15
View File
@@ -20,33 +20,83 @@ func ip4ToUint32(ip net.IP) uint32 {
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
}
// validateIP checks that ip is a valid IPv4 address inside lanCIDR and outside
// the dynamic DHCP pool [poolStart, poolEnd] (both inclusive), so static
// reservations can never collide with dynamically leased addresses.
func validateIP(ip, lanCIDR, poolStart, poolEnd string) error {
func uint32ToIP4(v uint32) net.IP {
return net.IPv4(byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
}
// validateIP checks that ip is a valid IPv4 address inside cidr, is neither
// the network nor the broadcast address of cidr, and is not one of the
// explicitly excluded addresses (e.g. the router's own gateway IP).
func validateIP(ip, cidr string, excluded ...string) error {
parsed := net.ParseIP(ip)
if parsed == nil || parsed.To4() == nil {
return fmt.Errorf("invalid IPv4 address: %q", ip)
}
_, cidr, err := net.ParseCIDR(lanCIDR)
_, network, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("internal error: invalid LAN CIDR %q: %w", lanCIDR, err)
return fmt.Errorf("internal error: invalid network %q: %w", cidr, err)
}
if !cidr.Contains(parsed) {
return fmt.Errorf("IP %s is not inside LAN network %s", ip, lanCIDR)
if !network.Contains(parsed) {
return fmt.Errorf("IP %s is not inside network %s", ip, cidr)
}
start := net.ParseIP(poolStart)
end := net.ParseIP(poolEnd)
if start == nil || end == nil {
return fmt.Errorf("internal error: invalid DHCP pool %q-%q", poolStart, poolEnd)
if isNetworkOrBroadcast(parsed, network) {
return fmt.Errorf("IP %s is the network or broadcast address of %s and cannot be assigned", ip, cidr)
}
v, s, e := ip4ToUint32(parsed), ip4ToUint32(start), ip4ToUint32(end)
if v >= s && v <= e {
return fmt.Errorf("IP %s is inside the dynamic DHCP pool (%s-%s) and is reserved, not allowed for static assignments", ip, poolStart, poolEnd)
for _, ex := range excluded {
if parsed.Equal(net.ParseIP(ex)) {
return fmt.Errorf("IP %s is reserved (router/gateway address) and cannot be assigned", ip)
}
}
return nil
}
func isNetworkOrBroadcast(ip net.IP, network *net.IPNet) bool {
ip4 := ip.To4()
mask := network.Mask
netAddr := ip4.Mask(mask)
broadcast := make(net.IP, len(netAddr))
for i := range netAddr {
broadcast[i] = netAddr[i] | ^mask[i]
}
return ip4.Equal(netAddr) || ip4.Equal(broadcast)
}
// nextFreeIP returns the first address in cidr (ascending order) that is not
// the network or broadcast address, not in excluded, and not already used by
// a host other than exceptIdx (-1 to not exempt any host).
func nextFreeIP(hosts []Host, cidr string, exceptIdx int, excluded ...string) (string, error) {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
return "", fmt.Errorf("internal error: invalid network %q: %w", cidr, err)
}
ones, bits := network.Mask.Size()
size := uint32(1) << uint32(bits-ones)
if size < 2 {
return "", fmt.Errorf("internal error: network %q too small", cidr)
}
start := ip4ToUint32(network.IP.To4())
for i := uint32(1); i < size-1; i++ {
candidate := uint32ToIP4(start + i).String()
if containsIP(excluded, candidate) {
continue
}
if findByIP(hosts, candidate, exceptIdx) >= 0 {
continue
}
return candidate, nil
}
return "", fmt.Errorf("no free IP address left in %s", cidr)
}
func containsIP(ips []string, ip string) bool {
for _, v := range ips {
if v == ip {
return true
}
}
return false
}
// normalizeMAC validates a MAC address and returns it in canonical lower-case
// colon-separated form. Empty input is accepted and returned as-is.
func normalizeMAC(mac string) (string, error) {