Add ipadm: Go-Tool zur Verwaltung statischer DHCP/DNS-Hosts
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user