Files
router/tools/ipadm/main.go
T

356 lines
9.3 KiB
Go

// 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))
}