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>
112 lines
3.2 KiB
Go
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
|
|
}
|