Add ipadm: Go-Tool zur Verwaltung statischer DHCP/DNS-Hosts
This commit is contained in:
@@ -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