Files
router/tools/ipadm/main.go
T
mike 8591223aca ipadm: Port-Forwarding hinzufügen (-pa/-pl/-pd) + nftables-Include verdrahten
- ipadm verwaltet jetzt auch WAN->LAN Port-Forwards, referenziert per
  Hostname aus der bestehenden Host-DB (folgt IP-Änderungen automatisch)
- ipadm -u generiert zusätzlich /etc/nftables.d/portforward.conf, validiert
  via 'nft -c -f' und reloadet nftables (Rollback bei ungültiger Config,
  wie beim dnsmasq-Teil)
- /etc/nftables.conf bindet dafür neu /etc/nftables.d/*.conf ein
- Host-Store-Locking-Logik in generischen LineStore[T] extrahiert, von
  Host- und PortForward-Store gemeinsam genutzt
2026-08-18 11:43:29 +02:00

554 lines
15 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 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 <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 -pa [-f] <hostname> <wan-port> [lan-port] [tcp|udp|both]
add port-forward to a known host
ipadm -pl list port-forwards
ipadm -pd <wan-port> [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 <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 := 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] <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 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] <hostname> <wan-port> [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 <wan-port> [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))
}