From 49bad1fec1d7f35f1c9b62d6e58fc2c9445b8213 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 11:17:16 +0200 Subject: [PATCH] Add ipadm: Go-Tool zur Verwaltung statischer DHCP/DNS-Hosts --- README.md | 6 + tools/ipadm/README.md | 73 ++++++++ tools/ipadm/dnsmasq.go | 85 +++++++++ tools/ipadm/go.mod | 3 + tools/ipadm/host.go | 143 +++++++++++++++ tools/ipadm/interactive.go | 92 ++++++++++ tools/ipadm/main.go | 355 +++++++++++++++++++++++++++++++++++++ tools/ipadm/validate.go | 61 +++++++ 8 files changed, 818 insertions(+) create mode 100644 tools/ipadm/README.md create mode 100644 tools/ipadm/dnsmasq.go create mode 100644 tools/ipadm/go.mod create mode 100644 tools/ipadm/host.go create mode 100644 tools/ipadm/interactive.go create mode 100644 tools/ipadm/main.go create mode 100644 tools/ipadm/validate.go diff --git a/README.md b/README.md index d35e4a3..9b58a9e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ Konfiguration auf dem Server (Stand: siehe Git-Log). Das Repo ist reine müssen manuell auf dem Server vorgenommen werden (siehe unten) und sollten danach hier eingecheckt werden, damit Doku und Realität übereinstimmen. +Statische Host-Einträge (feste IP/DHCP-Reservierung + DNS-Name) werden nicht +mehr von Hand in `dnsmasq.d` gepflegt, sondern über das Tool +[`ipadm`](tools/ipadm/) (`/usr/local/bin/ipadm` auf narcissus), siehe +[tools/ipadm/README.md](tools/ipadm/README.md). + ## Netzwerk-Topologie ``` @@ -48,6 +53,7 @@ danach hier eingecheckt werden, damit Doku und Realität übereinstimmen. | NAT | Masquerading `10.0.0.0/24` → raus über `enp3s0` | `/etc/nftables.conf` | [`config/nftables.conf`](config/nftables.conf) | | DHCP + DNS | dnsmasq: DHCP-Pool, Gateway/DNS-Option, DNS-Forwarding | `/etc/dnsmasq.d/lan.conf` | [`config/dnsmasq.d/lan.conf`](config/dnsmasq.d/lan.conf) | | DNS-Includes aktiviert | eine Zeile in `/etc/dnsmasq.conf` einkommentiert: `conf-dir=/etc/dnsmasq.d/,*.conf` | `/etc/dnsmasq.conf` | – (Rest der Datei ist Debian-Standard, nicht gespiegelt) | +| Statische Hosts (DHCP-Reservierung + DNS) | von `ipadm` generiert, **nicht** von Hand editieren | `/etc/dnsmasq.d/hosts.conf` (generiert) + `/etc/ipadm/hosts` (Datenbank) | – (live Daten, siehe [`tools/ipadm/`](tools/ipadm/)) | Installiertes Paket: `dnsmasq` (übernimmt sowohl DHCP- als auch DNS-Server-Rolle). `nftables` war bereits installiert, wurde aber aktiviert diff --git a/tools/ipadm/README.md b/tools/ipadm/README.md new file mode 100644 index 0000000..4785248 --- /dev/null +++ b/tools/ipadm/README.md @@ -0,0 +1,73 @@ +# ipadm + +Kleines Go-CLI-Tool zur Verwaltung statischer Hosts (DHCP-Reservierung + +DNS-Eintrag) für den dnsmasq-Router auf narcissus. Ersetzt/lehnt sich an das +Original-Tool `ipadm` (dhcp/bind management, mwx'2021) an, arbeitet aber +gegen unseren dnsmasq-Stack statt isc-dhcp-server/bind9 (siehe +[../../README.md](../../README.md)). + +Installiert auf dem Server unter `/usr/local/bin/ipadm`. + +## Funktionsweise + +- Pflegt eine flache Textdatei `/etc/ipadm/hosts` (eine Zeile pro Host: + `nameipmaccomment`, `mac` ist `-` wenn nicht gesetzt) als + Datenbank für statische Hosts. +- `ipadm -u` generiert daraus `/etc/dnsmasq.d/hosts.conf` (pro Host ein + `host-record=` für DNS, plus `dhcp-host=` für Hosts mit MAC-Adresse für die + DHCP-Reservierung), validiert die komplette dnsmasq-Konfiguration + (`dnsmasq --test`) und lädt dnsmasq bei Erfolg neu + (`systemctl reload dnsmasq`). Schlägt die Validierung fehl, wird die + vorherige `hosts.conf` automatisch wiederhergestellt und dnsmasq **nicht** + neu geladen. +- IP-Adressen werden bei `-a`/`-i`/im interaktiven Modus geprüft: müssen in + `10.0.0.0/24` liegen und **außerhalb** des dynamischen DHCP-Pools + `10.0.0.100–10.0.0.200` (verhindert Kollisionen mit dynamisch vergebenen + Adressen). + +## Usage + +``` +ipadm add/edit host (interaktiv) +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 (dnsmasq-Config regenerieren + reload) +ipadm -h this help +``` + +Nach jeder Änderung an der Host-DB (`-a`, `-c`, `-i`, `-m`, `-r`, `-d`, +interaktiv) muss `ipadm -u` ausgeführt werden, damit dnsmasq die Änderung +tatsächlich übernimmt — das Tool weist nach jeder Änderung selbst darauf hin. + +## Bauen / Installieren + +```sh +cd tools/ipadm +go build -o ipadm . +install -m 0755 -o root -g root ipadm /usr/local/bin/ipadm +``` + +## Testen ohne die echte Server-Konfiguration anzufassen + +Alle Pfade sind über Umgebungsvariablen überschreibbar: + +```sh +export IPADM_DB=/tmp/test-hosts +export IPADM_DNSMASQ_HOSTS=/tmp/test-hosts.conf +export IPADM_LAN_CIDR=10.0.0.0/24 +export IPADM_POOL_START=10.0.0.100 +export IPADM_POOL_END=10.0.0.200 +./ipadm -a testhost 10.0.0.50 aa:bb:cc:dd:ee:ff +./ipadm -l +``` + +Achtung: `ipadm -u` ruft trotzdem `dnsmasq --test` (prüft die echte +System-Konfiguration in `/etc/dnsmasq.conf` + `/etc/dnsmasq.d/`) und bei +Erfolg `systemctl reload dnsmasq` auf, auch im Testmodus — das ist +beabsichtigt (validiert, dass die generierte Datei mit der echten +Umgebung zusammenspielt), aber es reloadet den echten laufenden Dienst. diff --git a/tools/ipadm/dnsmasq.go b/tools/ipadm/dnsmasq.go new file mode 100644 index 0000000..5d0ec46 --- /dev/null +++ b/tools/ipadm/dnsmasq.go @@ -0,0 +1,85 @@ +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 reloads 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("kann bestehende %s nicht lesen: %w", path, err) + } + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return fmt.Errorf("kann %s nicht schreiben: %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-Konfiguration ungültig, Änderung zurückgerollt: %w", err) + } + + if err := reloadDnsmasq(); err != nil { + return fmt.Errorf("%s geschrieben, aber Reload fehlgeschlagen: %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 +} + +func reloadDnsmasq() error { + out, err := exec.Command("systemctl", "reload", "dnsmasq").CombinedOutput() + if err != nil { + return fmt.Errorf("%s", strings.TrimSpace(string(out))) + } + return nil +} diff --git a/tools/ipadm/go.mod b/tools/ipadm/go.mod new file mode 100644 index 0000000..ae49ec1 --- /dev/null +++ b/tools/ipadm/go.mod @@ -0,0 +1,3 @@ +module ipadm + +go 1.24.4 diff --git a/tools/ipadm/host.go b/tools/ipadm/host.go new file mode 100644 index 0000000..4f49707 --- /dev/null +++ b/tools/ipadm/host.go @@ -0,0 +1,143 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "sort" + "strings" + "syscall" +) + +// Host is one static host entry: hostname, IP, optional MAC, free-text comment. +type Host struct { + Name string + IP string + MAC string // "" if not set + Comment string +} + +// Store gives locked read-modify-write access to the flat-file host database. +// One line per host, tab-separated: name\tip\tmac\tcomment +// MAC is stored as "-" when empty. Lines starting with '#' and blank lines are ignored. +type Store struct { + path string + file *os.File +} + +func openStore(path string) (*Store, error) { + if err := os.MkdirAll(dirOf(path), 0755); err != nil { + return nil, fmt.Errorf("kann Verzeichnis für %s nicht anlegen: %w", path, err) + } + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644) + if err != nil { + return nil, fmt.Errorf("kann %s nicht öffnen: %w", path, err) + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + f.Close() + return nil, fmt.Errorf("kann Lock auf %s nicht setzen: %w", path, err) + } + return &Store{path: path, file: f}, nil +} + +func (s *Store) Close() error { + syscall.Flock(int(s.file.Fd()), syscall.LOCK_UN) + return s.file.Close() +} + +func (s *Store) Load() ([]Host, error) { + if _, err := s.file.Seek(0, 0); err != nil { + return nil, err + } + var hosts []Host + sc := bufio.NewScanner(s.file) + lineNo := 0 + for sc.Scan() { + lineNo++ + line := strings.TrimRight(sc.Text(), "\r\n") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "\t", 4) + if len(parts) < 3 { + return nil, fmt.Errorf("%s:%d: ungültige Zeile (erwarte mind. 3 Tab-getrennte Felder): %q", s.path, lineNo, line) + } + mac := parts[2] + if mac == "-" { + mac = "" + } + comment := "" + if len(parts) == 4 { + comment = parts[3] + } + hosts = append(hosts, Host{Name: parts[0], IP: parts[1], MAC: mac, Comment: comment}) + } + if err := sc.Err(); err != nil { + return nil, err + } + return hosts, nil +} + +func (s *Store) Save(hosts []Host) error { + sort.Slice(hosts, func(i, j int) bool { + return strings.ToLower(hosts[i].Name) < strings.ToLower(hosts[j].Name) + }) + var b strings.Builder + b.WriteString("# ipadm host database - managed with `ipadm`, do not edit while ipadm is running\n") + b.WriteString("# name\tip\tmac\tcomment\n") + for _, h := range hosts { + mac := h.MAC + if mac == "" { + mac = "-" + } + fmt.Fprintf(&b, "%s\t%s\t%s\t%s\n", h.Name, h.IP, mac, h.Comment) + } + if err := s.file.Truncate(0); err != nil { + return err + } + if _, err := s.file.Seek(0, 0); err != nil { + return err + } + if _, err := s.file.WriteString(b.String()); err != nil { + return err + } + return s.file.Sync() +} + +func dirOf(path string) string { + i := strings.LastIndexByte(path, '/') + if i <= 0 { + return "." + } + return path[:i] +} + +func findHost(hosts []Host, name string) int { + for i, h := range hosts { + if strings.EqualFold(h.Name, name) { + return i + } + } + return -1 +} + +func findByIP(hosts []Host, ip string, exceptIdx int) int { + for i, h := range hosts { + if i != exceptIdx && h.IP == ip { + return i + } + } + return -1 +} + +func findByMAC(hosts []Host, mac string, exceptIdx int) int { + if mac == "" { + return -1 + } + for i, h := range hosts { + if i != exceptIdx && h.MAC != "" && strings.EqualFold(h.MAC, mac) { + return i + } + } + return -1 +} diff --git a/tools/ipadm/interactive.go b/tools/ipadm/interactive.go new file mode 100644 index 0000000..826b166 --- /dev/null +++ b/tools/ipadm/interactive.go @@ -0,0 +1,92 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +var stdinReader = bufio.NewReader(os.Stdin) + +// prompt shows label with a "[current]" hint, reads one line, and returns +// current unchanged if the user just presses enter. +func prompt(label, current string) string { + if current != "" { + fmt.Printf("%s [%s]: ", label, current) + } else { + fmt.Printf("%s: ", label) + } + line, _ := stdinReader.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + return current + } + return line +} + +// interactiveEdit implements the bare `ipadm ` form: add the host +// if it doesn't exist yet, otherwise edit it in place. Returns the updated +// host list, or an error if validation fails. +func interactiveEdit(hosts []Host, name string, cfg netConfig) ([]Host, error) { + if err := validateHostname(name); err != nil { + return nil, err + } + idx := findHost(hosts, name) + + var cur Host + if idx >= 0 { + cur = hosts[idx] + fmt.Printf("Bearbeite bestehenden Host %q (Enter = Wert behalten)\n", name) + } else { + cur = Host{Name: name} + fmt.Printf("Neuer Host %q (Enter bei MAC/Comment = leer lassen)\n", name) + } + + for { + ip := prompt("IP-Adresse", cur.IP) + if err := validateIP(ip, cfg.lanCIDR, cfg.poolStart, cfg.poolEnd); err != nil { + fmt.Fprintln(os.Stderr, "Fehler:", err) + continue + } + cur.IP = ip + break + } + + for { + mac := prompt("MAC-Adresse (optional, '-' zum Löschen)", macOrDash(cur.MAC)) + if mac == "-" { + mac = "" + } + norm, err := normalizeMAC(mac) + if err != nil { + fmt.Fprintln(os.Stderr, "Fehler:", err) + continue + } + cur.MAC = norm + break + } + + cur.Comment = prompt("Kommentar", cur.Comment) + + if other := findByIP(hosts, cur.IP, idx); other >= 0 { + return nil, fmt.Errorf("IP %s ist bereits Host %q zugewiesen", cur.IP, hosts[other].Name) + } + if other := findByMAC(hosts, cur.MAC, idx); other >= 0 { + return nil, fmt.Errorf("MAC %s ist bereits Host %q zugewiesen", cur.MAC, hosts[other].Name) + } + + if idx >= 0 { + hosts[idx] = cur + } else { + hosts = append(hosts, cur) + } + return hosts, nil +} + +func macOrDash(mac string) string { + if mac == "" { + return "" + } + return mac +} diff --git a/tools/ipadm/main.go b/tools/ipadm/main.go new file mode 100644 index 0000000..1e725fe --- /dev/null +++ b/tools/ipadm/main.go @@ -0,0 +1,355 @@ +// 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)) +} diff --git a/tools/ipadm/validate.go b/tools/ipadm/validate.go new file mode 100644 index 0000000..3a63695 --- /dev/null +++ b/tools/ipadm/validate.go @@ -0,0 +1,61 @@ +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("ungültiger Hostname %q (nur Buchstaben, Ziffern, Bindestrich, kein führender/abschließender Bindestrich, keine 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]) +} + +// 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 { + parsed := net.ParseIP(ip) + if parsed == nil || parsed.To4() == nil { + return fmt.Errorf("ungültige IPv4-Adresse: %q", ip) + } + _, cidr, err := net.ParseCIDR(lanCIDR) + if err != nil { + return fmt.Errorf("internes Problem: ungültiges LAN-CIDR %q: %w", lanCIDR, err) + } + if !cidr.Contains(parsed) { + return fmt.Errorf("IP %s liegt nicht im LAN-Netz %s", ip, lanCIDR) + } + start := net.ParseIP(poolStart) + end := net.ParseIP(poolEnd) + if start == nil || end == nil { + return fmt.Errorf("internes Problem: ungültiger DHCP-Pool %q-%q", poolStart, poolEnd) + } + v, s, e := ip4ToUint32(parsed), ip4ToUint32(start), ip4ToUint32(end) + if v >= s && v <= e { + return fmt.Errorf("IP %s liegt im dynamischen DHCP-Pool (%s-%s) und ist für statische Reservierungen gesperrt", ip, poolStart, poolEnd) + } + return nil +} + +// 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("ungültige MAC-Adresse %q: %w", mac, err) + } + return hw.String(), nil +}