Add ipadm: Go-Tool zur Verwaltung statischer DHCP/DNS-Hosts

This commit is contained in:
2026-08-18 11:17:16 +02:00
parent dc1d490e26
commit 49bad1fec1
8 changed files with 818 additions and 0 deletions
+6
View File
@@ -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 müssen manuell auf dem Server vorgenommen werden (siehe unten) und sollten
danach hier eingecheckt werden, damit Doku und Realität übereinstimmen. 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 ## 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) | | 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) | | 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) | | 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 Installiertes Paket: `dnsmasq` (übernimmt sowohl DHCP- als auch
DNS-Server-Rolle). `nftables` war bereits installiert, wurde aber aktiviert DNS-Server-Rolle). `nftables` war bereits installiert, wurde aber aktiviert
+73
View File
@@ -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:
`name<TAB>ip<TAB>mac<TAB>comment`, `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 <hostname> add/edit host (interaktiv)
ipadm -l list hosts
ipadm -a [-f] <hostname> <ip address> [mac address] add host
ipadm -c <hostname> <comment> set comment
ipadm -i <hostname> <ip address> change ip address
ipadm -m <hostname> <mac address> change mac address
ipadm -r <old hostname> <new hostname> rename host
ipadm -d <hostname> 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.
+85
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
module ipadm
go 1.24.4
+143
View File
@@ -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
}
+92
View File
@@ -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 <hostname>` 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
}
+355
View File
@@ -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 <hostname> add/edit host (interactive)
ipadm -l list hosts
ipadm -a [-f] <hostname> <ip address> [mac address] add host
ipadm -c <hostname> <comment> set comment
ipadm -i <hostname> <ip address> change ip address
ipadm -m <hostname> <mac address> change mac address
ipadm -r <old hostname> <new hostname> rename host
ipadm -d <hostname> 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 <hostname>")
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] <hostname> <ip address> [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 <hostname> <comment>")
}
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 <hostname> <ip address>")
}
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 <hostname> <mac address>")
}
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 <old hostname> <new hostname>")
}
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 <hostname>")
}
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))
}
+61
View File
@@ -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
}