// ipadm - dhcp/dns management for the dnsmasq-based router setup on this host. // // Maintains a flat-file database of static hosts (hostname, IP, optional // MAC, comment) and, on `ipadm -u`, regenerates the dnsmasq include file // with matching host-record/dhcp-host entries and reloads dnsmasq. package main import ( "fmt" "os" "strings" "text/tabwriter" ) const version = "1.0.0" const usage = `ipadm - dhcp/dns management (dnsmasq backend) v` + version + ` usage: ipadm add/edit host (interactive) ipadm -l list hosts ipadm -a [-f] [mac address] add host ipadm -c set comment ipadm -i change ip address ipadm -m change mac address ipadm -r rename host ipadm -d delete host ipadm -u update (regenerate dnsmasq config + reload) ipadm -h this help ` // netConfig describes the LAN the tool validates static IPs against. // Overridable via env vars, mainly so this can be tested without touching // the real /etc files. type netConfig struct { lanCIDR string poolStart string poolEnd string } func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func dbPath() string { return envOr("IPADM_DB", "/etc/ipadm/hosts") } func dnsmasqHostsPath() string { return envOr("IPADM_DNSMASQ_HOSTS", "/etc/dnsmasq.d/hosts.conf") } func loadNetConfig() netConfig { return netConfig{ lanCIDR: envOr("IPADM_LAN_CIDR", "10.0.0.0/24"), poolStart: envOr("IPADM_POOL_START", "10.0.0.100"), poolEnd: envOr("IPADM_POOL_END", "10.0.0.200"), } } func fail(format string, a ...any) { fmt.Fprintf(os.Stderr, "ipadm: "+format+"\n", a...) os.Exit(1) } func main() { args := os.Args[1:] if len(args) == 0 { fmt.Fprint(os.Stderr, usage) os.Exit(1) } switch args[0] { case "-h", "--help": fmt.Print(usage) case "-l": requireArgs(args[1:], 0, "ipadm -l") cmdList() case "-u": requireArgs(args[1:], 0, "ipadm -u") cmdUpdate() case "-a": cmdAdd(args[1:]) case "-c": cmdComment(args[1:]) case "-i": cmdChangeIP(args[1:]) case "-m": cmdChangeMAC(args[1:]) case "-r": cmdRename(args[1:]) case "-d": cmdDelete(args[1:]) default: if strings.HasPrefix(args[0], "-") { fail("unbekannte Option %q\n\n%s", args[0], usage) } requireArgs(args[1:], 0, "ipadm ") cmdInteractive(args[0]) } } func requireArgs(rest []string, want int, form string) { if len(rest) != want { fail("falsche Anzahl Argumente für %q\n\n%s", form, usage) } } // withStore opens the locked host DB, loads it, lets fn mutate it, and saves // on success. fn returns the new host list plus a human-readable summary of // what changed (printed on success). func withStore(fn func(hosts []Host) ([]Host, string, error)) { s, err := openStore(dbPath()) if err != nil { fail("%s", err) } defer s.Close() hosts, err := s.Load() if err != nil { fail("%s", err) } newHosts, summary, err := fn(hosts) if err != nil { fail("%s", err) } if err := s.Save(newHosts); err != nil { fail("kann %s nicht schreiben: %s", dbPath(), err) } fmt.Println(summary) fmt.Println("Hinweis: `ipadm -u` ausführen, um dnsmasq zu aktualisieren.") } func cmdInteractive(name string) { cfg := loadNetConfig() withStore(func(hosts []Host) ([]Host, string, error) { newHosts, err := interactiveEdit(hosts, name, cfg) if err != nil { return nil, "", err } return newHosts, fmt.Sprintf("Host %q gespeichert.", name), nil }) } func cmdList() { s, err := openStore(dbPath()) if err != nil { fail("%s", err) } defer s.Close() hosts, err := s.Load() if err != nil { fail("%s", err) } if len(hosts) == 0 { fmt.Println("keine Hosts eingetragen") return } tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0) fmt.Fprintln(tw, "HOSTNAME\tIP\tMAC\tCOMMENT") for _, h := range sortedCopy(hosts) { mac := h.MAC if mac == "" { mac = "-" } fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", h.Name, h.IP, mac, h.Comment) } tw.Flush() } func sortedCopy(hosts []Host) []Host { out := make([]Host, len(hosts)) copy(out, hosts) // Save() sorts on write, but list can run before the first save. for i := 1; i < len(out); i++ { for j := i; j > 0 && strings.ToLower(out[j-1].Name) > strings.ToLower(out[j].Name); j-- { out[j-1], out[j] = out[j], out[j-1] } } return out } func cmdAdd(args []string) { force := false if len(args) > 0 && args[0] == "-f" { force = true args = args[1:] } if len(args) < 2 || len(args) > 3 { fail("usage: ipadm -a [-f] [mac address]") } name, ip := args[0], args[1] mac := "" if len(args) == 3 { mac = args[2] } cfg := loadNetConfig() withStore(func(hosts []Host) ([]Host, string, error) { if err := validateHostname(name); err != nil { return nil, "", err } if err := validateIP(ip, cfg.lanCIDR, cfg.poolStart, cfg.poolEnd); err != nil { return nil, "", err } normMac, err := normalizeMAC(mac) if err != nil { return nil, "", err } idx := findHost(hosts, name) if idx >= 0 && !force { return nil, "", fmt.Errorf("Host %q existiert bereits (benutze -f zum Überschreiben oder `ipadm %s` zum Bearbeiten)", name, name) } if other := findByIP(hosts, ip, idx); other >= 0 { return nil, "", fmt.Errorf("IP %s ist bereits Host %q zugewiesen", ip, hosts[other].Name) } if other := findByMAC(hosts, normMac, idx); other >= 0 { return nil, "", fmt.Errorf("MAC %s ist bereits Host %q zugewiesen", normMac, hosts[other].Name) } h := Host{Name: name, IP: ip, MAC: normMac} if idx >= 0 { hosts[idx] = h return hosts, fmt.Sprintf("Host %q überschrieben (-f).", name), nil } hosts = append(hosts, h) return hosts, fmt.Sprintf("Host %q hinzugefügt.", name), nil }) } func cmdComment(args []string) { if len(args) < 2 { fail("usage: ipadm -c ") } name := args[0] comment := strings.Join(args[1:], " ") withStore(func(hosts []Host) ([]Host, string, error) { idx := findHost(hosts, name) if idx < 0 { return nil, "", fmt.Errorf("Host %q nicht gefunden", name) } hosts[idx].Comment = comment return hosts, fmt.Sprintf("Kommentar von %q gesetzt.", name), nil }) } func cmdChangeIP(args []string) { if len(args) != 2 { fail("usage: ipadm -i ") } name, ip := args[0], args[1] cfg := loadNetConfig() withStore(func(hosts []Host) ([]Host, string, error) { idx := findHost(hosts, name) if idx < 0 { return nil, "", fmt.Errorf("Host %q nicht gefunden", name) } if err := validateIP(ip, cfg.lanCIDR, cfg.poolStart, cfg.poolEnd); err != nil { return nil, "", err } if other := findByIP(hosts, ip, idx); other >= 0 { return nil, "", fmt.Errorf("IP %s ist bereits Host %q zugewiesen", ip, hosts[other].Name) } hosts[idx].IP = ip return hosts, fmt.Sprintf("IP von %q auf %s geändert.", name, ip), nil }) } func cmdChangeMAC(args []string) { if len(args) != 2 { fail("usage: ipadm -m ") } name, mac := args[0], args[1] if mac == "-" { mac = "" } withStore(func(hosts []Host) ([]Host, string, error) { idx := findHost(hosts, name) if idx < 0 { return nil, "", fmt.Errorf("Host %q nicht gefunden", name) } normMac, err := normalizeMAC(mac) if err != nil { return nil, "", err } if other := findByMAC(hosts, normMac, idx); other >= 0 { return nil, "", fmt.Errorf("MAC %s ist bereits Host %q zugewiesen", normMac, hosts[other].Name) } hosts[idx].MAC = normMac label := normMac if label == "" { label = "(entfernt)" } return hosts, fmt.Sprintf("MAC von %q auf %s geändert.", name, label), nil }) } func cmdRename(args []string) { if len(args) != 2 { fail("usage: ipadm -r ") } oldName, newName := args[0], args[1] withStore(func(hosts []Host) ([]Host, string, error) { if err := validateHostname(newName); err != nil { return nil, "", err } idx := findHost(hosts, oldName) if idx < 0 { return nil, "", fmt.Errorf("Host %q nicht gefunden", oldName) } if existing := findHost(hosts, newName); existing >= 0 && existing != idx { return nil, "", fmt.Errorf("Host %q existiert bereits", newName) } hosts[idx].Name = newName return hosts, fmt.Sprintf("Host %q umbenannt in %q.", oldName, newName), nil }) } func cmdDelete(args []string) { if len(args) != 1 { fail("usage: ipadm -d ") } name := args[0] withStore(func(hosts []Host) ([]Host, string, error) { idx := findHost(hosts, name) if idx < 0 { return nil, "", fmt.Errorf("Host %q nicht gefunden", name) } hosts = append(hosts[:idx], hosts[idx+1:]...) return hosts, fmt.Sprintf("Host %q gelöscht.", name), nil }) } func cmdUpdate() { s, err := openStore(dbPath()) if err != nil { fail("%s", err) } hosts, err := s.Load() s.Close() if err != nil { fail("%s", err) } content := renderDnsmasqConfig(hosts) if err := applyDnsmasqConfig(dnsmasqHostsPath(), content); err != nil { fail("%s", err) } fmt.Printf("%s aktualisiert, dnsmasq neu geladen (%d Hosts).\n", dnsmasqHostsPath(), len(hosts)) }