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>
91 lines
2.7 KiB
Go
91 lines
2.7 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// renderDnsmasqConfig builds the content of the generated dnsmasq include
|
|
// file from the current host list. Every host gets a host-record (forward +
|
|
// reverse DNS). Hosts with a MAC additionally get a dhcp-host static lease.
|
|
func renderDnsmasqConfig(hosts []Host) string {
|
|
sorted := make([]Host, len(hosts))
|
|
copy(sorted, hosts)
|
|
sort.Slice(sorted, func(i, j int) bool {
|
|
return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name)
|
|
})
|
|
|
|
var b strings.Builder
|
|
b.WriteString("# Generated by ipadm -u — DO NOT EDIT MANUALLY.\n")
|
|
b.WriteString("# Edit hosts with `ipadm` and re-run `ipadm -u` to regenerate this file.\n\n")
|
|
for _, h := range sorted {
|
|
if h.Comment != "" {
|
|
fmt.Fprintf(&b, "# %s: %s\n", h.Name, h.Comment)
|
|
}
|
|
fmt.Fprintf(&b, "host-record=%s,%s\n", h.Name, h.IP)
|
|
if h.MAC != "" {
|
|
fmt.Fprintf(&b, "dhcp-host=%s,%s,%s,infinite\n", h.MAC, h.IP, h.Name)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// applyDnsmasqConfig writes the generated config to path, validates the full
|
|
// dnsmasq configuration, and on success restarts the dnsmasq service. On
|
|
// validation failure the previous file content is restored and the service
|
|
// is left untouched.
|
|
func applyDnsmasqConfig(path string, content string) error {
|
|
var backup []byte
|
|
hadFile := false
|
|
if b, err := os.ReadFile(path); err == nil {
|
|
backup = b
|
|
hadFile = true
|
|
} else if !os.IsNotExist(err) {
|
|
return fmt.Errorf("cannot read existing %s: %w", path, err)
|
|
}
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
return fmt.Errorf("cannot write %s: %w", path, err)
|
|
}
|
|
|
|
if err := runDnsmasqTest(); err != nil {
|
|
// roll back so a bad generated file never lingers
|
|
if hadFile {
|
|
os.WriteFile(path, backup, 0644)
|
|
} else {
|
|
os.Remove(path)
|
|
}
|
|
return fmt.Errorf("dnsmasq config invalid, change rolled back: %w", err)
|
|
}
|
|
|
|
if err := restartDnsmasq(); err != nil {
|
|
return fmt.Errorf("%s written, but restart failed: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runDnsmasqTest() error {
|
|
out, err := exec.Command("dnsmasq", "--test").CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("%s", strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// restartDnsmasq does a full restart rather than `systemctl reload`
|
|
// (SIGHUP): dnsmasq's SIGHUP handler only re-reads /etc/hosts and a few
|
|
// specific hosts-file options, not the conf-dir files this generated
|
|
// hosts.conf lives in, so a reload would silently keep serving the old
|
|
// host-record/dhcp-host entries.
|
|
func restartDnsmasq() error {
|
|
out, err := exec.Command("systemctl", "restart", "dnsmasq").CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("%s", strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|