Files
mikeandClaude Sonnet 5 c135f205fa 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>
2026-08-21 08:57:58 +02:00

112 lines
3.2 KiB
Go

package main
import (
"fmt"
"net"
"regexp"
)
var hostnameRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
func validateHostname(name string) error {
if !hostnameRe.MatchString(name) {
return fmt.Errorf("invalid hostname %q (letters, digits, hyphen only; no leading/trailing hyphen; no domain)", name)
}
return nil
}
func ip4ToUint32(ip net.IP) uint32 {
b := ip.To4()
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
}
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)
}
_, network, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("internal error: invalid network %q: %w", cidr, err)
}
if !network.Contains(parsed) {
return fmt.Errorf("IP %s is not inside network %s", ip, cidr)
}
if isNetworkOrBroadcast(parsed, network) {
return fmt.Errorf("IP %s is the network or broadcast address of %s and cannot be assigned", ip, cidr)
}
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) {
if mac == "" {
return "", nil
}
hw, err := net.ParseMAC(mac)
if err != nil {
return "", fmt.Errorf("invalid MAC address %q: %w", mac, err)
}
return hw.String(), nil
}