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
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
+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
}