Add ipadm: Go-Tool zur Verwaltung statischer DHCP/DNS-Hosts
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module ipadm
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user