// 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 of port-forwards (WAN port/proto -> host from that // database). On `ipadm -u`, regenerates the dnsmasq include file // (host-record/dhcp-host entries) and the nftables include file (DNAT // rules), then reloads both services. package main import ( "fmt" "os" "strings" "text/tabwriter" ) const version = "1.1.0" const usage = `ipadm - dhcp/dns management (dnsmasq backend) v` + version + ` usage: ipadm add/edit host (interactive) ipadm -l list hosts ipadm -a [-f] [mac address] add host ipadm -c set comment ipadm -i change ip address ipadm -m change mac address ipadm -r rename host ipadm -d delete host ipadm -pa [-f] [lan-port] [tcp|udp|both] add port-forward to a known host ipadm -pl list port-forwards ipadm -pd [tcp|udp|both] delete port-forward ipadm -u update (regenerate dnsmasq+nftables config, reload) ipadm -h this help ` // netConfig describes the LAN/WAN the tool validates 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 wanIface 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 portFwdDBPath() string { return envOr("IPADM_PORTFWD_DB", "/etc/ipadm/portforwards") } func dnsmasqHostsPath() string { return envOr("IPADM_DNSMASQ_HOSTS", "/etc/dnsmasq.d/hosts.conf") } func nftPortFwdPath() string { return envOr("IPADM_NFT_PORTFWD", "/etc/nftables.d/portforward.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"), wanIface: envOr("IPADM_WAN_IFACE", "enp3s0"), } } 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:]) case "-pa": cmdPortFwdAdd(args[1:]) case "-pl": requireArgs(args[1:], 0, "ipadm -pl") cmdPortFwdList() case "-pd": cmdPortFwdDelete(args[1:]) default: if strings.HasPrefix(args[0], "-") { fail("unbekannte Option %q\n\n%s", args[0], usage) } requireArgs(args[1:], 0, "ipadm ") 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 := openHostStore(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) } sortHosts(newHosts) 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/nftables zu aktualisieren.") } // withPortFwdStore mirrors withStore for the port-forward DB. It also loads // the (read-only) host DB so add/delete can validate against known hosts. func withPortFwdStore(fn func(hosts []Host, fwds []PortForward) ([]PortForward, string, error)) { hs, err := openHostStore(dbPath()) if err != nil { fail("%s", err) } hosts, err := hs.Load() hs.Close() if err != nil { fail("%s", err) } s, err := openPortFwdStore(portFwdDBPath()) if err != nil { fail("%s", err) } defer s.Close() fwds, err := s.Load() if err != nil { fail("%s", err) } newFwds, summary, err := fn(hosts, fwds) if err != nil { fail("%s", err) } sortPortFwds(newFwds) if err := s.Save(newFwds); err != nil { fail("kann %s nicht schreiben: %s", portFwdDBPath(), err) } fmt.Println(summary) fmt.Println("Hinweis: `ipadm -u` ausführen, um nftables 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 := openHostStore(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 } sortHosts(hosts) tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0) fmt.Fprintln(tw, "HOSTNAME\tIP\tMAC\tCOMMENT") for _, h := range 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 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] [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 ") } 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 ") } 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 ") } 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 ") } 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 ") } 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 cmdPortFwdAdd(args []string) { force := false if len(args) > 0 && args[0] == "-f" { force = true args = args[1:] } if len(args) < 2 || len(args) > 4 { fail("usage: ipadm -pa [-f] [lan-port] [tcp|udp|both]") } name := args[0] wanPort, err := parsePort(args[1]) if err != nil { fail("%s", err) } withPortFwdStore(func(hosts []Host, fwds []PortForward) ([]PortForward, string, error) { if findHost(hosts, name) < 0 { return nil, "", fmt.Errorf("Host %q ist nicht in der ipadm-Datenbank (erst mit `ipadm %s` oder `ipadm -a` anlegen)", name, name) } lanPort, proto, err := classifyPortFwdArgs(args[2:], wanPort) if err != nil { return nil, "", err } if err := validatePort("wan-port", wanPort); err != nil { return nil, "", err } if err := validatePort("lan-port", lanPort); err != nil { return nil, "", err } conflicts := findConflictingPortFwds(fwds, wanPort, proto, -1) if len(conflicts) > 0 && !force { return nil, "", fmt.Errorf("wan-port %d/%s kollidiert mit bestehendem Port-Forward (benutze -f zum Überschreiben)", wanPort, proto) } for i := len(conflicts) - 1; i >= 0; i-- { fwds = append(fwds[:conflicts[i]], fwds[conflicts[i]+1:]...) } fwds = append(fwds, PortForward{WanPort: wanPort, Proto: proto, Host: name, LanPort: lanPort}) return fwds, fmt.Sprintf("Port-Forward %d/%s -> %s:%d angelegt.", wanPort, proto, name, lanPort), nil }) } func cmdPortFwdList() { hs, err := openHostStore(dbPath()) if err != nil { fail("%s", err) } hosts, err := hs.Load() hs.Close() if err != nil { fail("%s", err) } ipByHost := map[string]string{} for _, h := range hosts { ipByHost[strings.ToLower(h.Name)] = h.IP } s, err := openPortFwdStore(portFwdDBPath()) if err != nil { fail("%s", err) } defer s.Close() fwds, err := s.Load() if err != nil { fail("%s", err) } if len(fwds) == 0 { fmt.Println("keine Port-Forwards eingetragen") return } sortPortFwds(fwds) tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0) fmt.Fprintln(tw, "WAN-PORT\tPROTO\tHOST\tLAN-IP\tLAN-PORT") for _, f := range fwds { ip, ok := ipByHost[strings.ToLower(f.Host)] if !ok { ip = "??? (Host fehlt)" } fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%d\n", f.WanPort, f.Proto, f.Host, ip, f.LanPort) } tw.Flush() } func cmdPortFwdDelete(args []string) { if len(args) < 1 || len(args) > 2 { fail("usage: ipadm -pd [tcp|udp|both]") } wanPort, err := parsePort(args[0]) if err != nil { fail("%s", err) } proto := "" if len(args) == 2 { proto = args[1] if !validProtos[proto] { fail("ungültiges Protokoll %q (erwarte tcp/udp/both)", proto) } } withPortFwdStore(func(hosts []Host, fwds []PortForward) ([]PortForward, string, error) { matches := findPortFwd(fwds, wanPort, proto) if len(matches) == 0 { return nil, "", fmt.Errorf("kein Port-Forward für wan-port %d gefunden", wanPort) } if len(matches) > 1 { var protos []string for _, i := range matches { protos = append(protos, fwds[i].Proto) } return nil, "", fmt.Errorf("wan-port %d ist mehrdeutig (Protokolle: %s) — bitte Protokoll angeben", wanPort, strings.Join(protos, ", ")) } idx := matches[0] deleted := fwds[idx] fwds = append(fwds[:idx], fwds[idx+1:]...) return fwds, fmt.Sprintf("Port-Forward %d/%s (-> %s:%d) gelöscht.", deleted.WanPort, deleted.Proto, deleted.Host, deleted.LanPort), nil }) } func parsePort(s string) (int, error) { var port int if _, err := fmt.Sscanf(s, "%d", &port); err != nil { return 0, fmt.Errorf("ungültiger Port: %q", s) } return port, nil } func cmdUpdate() { cfg := loadNetConfig() hs, err := openHostStore(dbPath()) if err != nil { fail("%s", err) } hosts, err := hs.Load() hs.Close() if err != nil { fail("%s", err) } dnsContent := renderDnsmasqConfig(hosts) if err := applyDnsmasqConfig(dnsmasqHostsPath(), dnsContent); err != nil { fail("%s", err) } fmt.Printf("%s aktualisiert, dnsmasq neu geladen (%d Hosts).\n", dnsmasqHostsPath(), len(hosts)) fs, err := openPortFwdStore(portFwdDBPath()) if err != nil { fail("%s", err) } fwds, err := fs.Load() fs.Close() if err != nil { fail("%s", err) } nftContent, warnings := renderPortForwards(hosts, fwds, cfg.wanIface) for _, w := range warnings { fmt.Fprintln(os.Stderr, "warnung:", w) } if err := applyPortForwards(nftPortFwdPath(), nftContent); err != nil { fail("%s", err) } fmt.Printf("%s aktualisiert, nftables neu geladen (%d Port-Forwards).\n", nftPortFwdPath(), len(fwds)-len(warnings)) }