From 8591223aca5f7291325725b6a379a032c9c0e22d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 11:43:29 +0200 Subject: [PATCH] =?UTF-8?q?ipadm:=20Port-Forwarding=20hinzuf=C3=BCgen=20(-?= =?UTF-8?q?pa/-pl/-pd)=20+=20nftables-Include=20verdrahten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 20 ++-- config/nftables.conf | 3 + tools/ipadm/README.md | 83 +++++++++---- tools/ipadm/host.go | 111 +++++------------- tools/ipadm/main.go | 260 ++++++++++++++++++++++++++++++++++++----- tools/ipadm/nftpf.go | 100 ++++++++++++++++ tools/ipadm/portfwd.go | 131 +++++++++++++++++++++ tools/ipadm/store.go | 92 +++++++++++++++ 8 files changed, 653 insertions(+), 147 deletions(-) create mode 100644 tools/ipadm/nftpf.go create mode 100644 tools/ipadm/portfwd.go create mode 100644 tools/ipadm/store.go diff --git a/README.md b/README.md index 38ea0a4..8baf34e 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,11 @@ mehr von Hand in `dnsmasq.d` gepflegt, sondern über das Tool | LAN-Interface | `enp7s0` statisch auf `10.0.0.1/24` | `/etc/network/interfaces` | [`config/network-interfaces`](config/network-interfaces) | | IP-Forwarding | `net.ipv4.ip_forward=1`, persistent über Reboot | `/etc/sysctl.d/99-router-forwarding.conf` | [`config/sysctl.d/99-router-forwarding.conf`](config/sysctl.d/99-router-forwarding.conf) | | NAT | Masquerading `10.0.0.0/24` → raus über `enp3s0` | `/etc/nftables.conf` | [`config/nftables.conf`](config/nftables.conf) | +| Port-Forwarding-Include | `/etc/nftables.conf` bindet `/etc/nftables.d/*.conf` ein (dort generiert `ipadm` die Port-Forward-Regeln) | `/etc/nftables.conf` (Zeile am Ende) | [`config/nftables.conf`](config/nftables.conf) | | DHCP + DNS | dnsmasq: DHCP-Pool, Gateway/DNS-Option, DNS-Forwarding | `/etc/dnsmasq.d/lan.conf` | [`config/dnsmasq.d/lan.conf`](config/dnsmasq.d/lan.conf) | | DNS-Includes aktiviert | eine Zeile in `/etc/dnsmasq.conf` einkommentiert: `conf-dir=/etc/dnsmasq.d/,*.conf` | `/etc/dnsmasq.conf` | – (Rest der Datei ist Debian-Standard, nicht gespiegelt) | | Statische Hosts (DHCP-Reservierung + DNS) | von `ipadm` generiert, **nicht** von Hand editieren | `/etc/dnsmasq.d/hosts.conf` (generiert) + `/etc/ipadm/hosts` (Datenbank) | – (live Daten, siehe [`tools/ipadm/`](tools/ipadm/)) | +| Port-Forwards (WAN → LAN-Host) | von `ipadm` generiert, **nicht** von Hand editieren | `/etc/nftables.d/portforward.conf` (generiert) + `/etc/ipadm/portforwards` (Datenbank) | – (live Daten, siehe [`tools/ipadm/`](tools/ipadm/)) | | dnsmasq-Resilienz | `Restart=on-failure` (Default war `no`), damit dnsmasq bei einem Boot-Race mit `enp7s0` nicht dauerhaft tot bleibt | `/etc/systemd/system/dnsmasq.service.d/override.conf` | [`config/systemd/dnsmasq.service.d/override.conf`](config/systemd/dnsmasq.service.d/override.conf) | Installiertes Paket: `dnsmasq` (übernimmt sowohl DHCP- als auch @@ -141,20 +143,18 @@ address=/server1.lan/10.0.0.10 Oder Einträge über `/etc/hosts`-Syntax in einer eigenen Datei einbinden (`addn-hosts=/etc/dnsmasq.d/lan-hosts`). -### Weitere Firewall-/Portfreigaben (z.B. Port-Forwarding vom WAN ins LAN) +### Port-Forwarding (WAN → LAN-Host) -In `/etc/nftables.conf`, `table inet nat`, eine `chain prerouting` ergänzen, -z.B. um Port 8080 vom WAN auf einen LAN-Host weiterzuleiten: +Wird über `ipadm` verwaltet, siehe [tools/ipadm/README.md](tools/ipadm/README.md). +Kurzform: Zielhost muss zuerst als Host in `ipadm` angelegt sein, dann: -``` -chain prerouting { - type nat hook prerouting priority dstnat; - iifname "enp3s0" tcp dport 8080 dnat to 10.0.0.10:8080 -} +```sh +ipadm -pa [lan-port] [tcp|udp|both] +ipadm -u # generiert /etc/nftables.d/portforward.conf neu + reloadet nftables ``` -Danach: `nft -c -f /etc/nftables.conf` (Syntaxcheck) und dann -`systemctl reload nftables` bzw. `nft -f /etc/nftables.conf`. +Nicht mehr von Hand in `/etc/nftables.conf` eintragen — die Datei bindet +dafür `/etc/nftables.d/*.conf` ein, das ist der von `ipadm` verwaltete Teil. ⚠️ Falls künftig auch der WAN-Eingang gefiltert werden soll (aktuell bewusst offen gelassen), unbedingt zuerst SSH-Zugriff (und alle anderen aktiv diff --git a/config/nftables.conf b/config/nftables.conf index ddae7a5..2662a46 100644 --- a/config/nftables.conf +++ b/config/nftables.conf @@ -20,3 +20,6 @@ table inet nat { ip saddr 10.0.0.0/24 oifname "enp3s0" masquerade } } + +# Port-Forwards, verwaltet über `ipadm -pa`/`ipadm -pd` + `ipadm -u` (siehe tools/ipadm im Router-Repo) +include "/etc/nftables.d/*.conf" diff --git a/tools/ipadm/README.md b/tools/ipadm/README.md index 4785248..e6d7ba1 100644 --- a/tools/ipadm/README.md +++ b/tools/ipadm/README.md @@ -1,10 +1,10 @@ # 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)). +DNS-Eintrag) und WAN→LAN-Port-Forwards für den dnsmasq/nftables-Router auf +narcissus. Ersetzt/lehnt sich an das Original-Tool `ipadm` (dhcp/bind +management, mwx'2021) an, arbeitet aber gegen unseren dnsmasq/nftables-Stack +statt isc-dhcp-server/bind9 (siehe [../../README.md](../../README.md)). Installiert auf dem Server unter `/usr/local/bin/ipadm`. @@ -12,18 +12,34 @@ Installiert auf dem Server unter `/usr/local/bin/ipadm`. - Pflegt eine flache Textdatei `/etc/ipadm/hosts` (eine Zeile pro Host: `nameipmaccomment`, `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. + Datenbank für statische Hosts, und `/etc/ipadm/portforwards` + (`wanportprotohostlanport`) für Port-Forwards. Ein + Port-Forward referenziert einen Hostnamen aus der Host-Datenbank statt + einer festen IP, damit er automatisch der aktuellen IP des Hosts folgt. +- `ipadm -u` generiert daraus zwei Dateien und reloadet die zugehörigen + Dienste: + - `/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 mit `dnsmasq --test`, danach `systemctl reload dnsmasq`. + - `/etc/nftables.d/portforward.conf` (eine eigene `table inet portforward` + mit einer `dnat`-Regel pro Port-Forward, referenzierte Host-IP wird zum + Generierungszeitpunkt aus der Host-DB aufgelöst), validiert mit + `nft -c -f /etc/nftables.conf` (dafür bindet `/etc/nftables.conf` bereits + `include "/etc/nftables.d/*.conf"` ein), danach + `systemctl reload nftables`. + + Schlägt die jeweilige Validierung fehl, wird die vorherige generierte + Datei automatisch wiederhergestellt und der betroffene Dienst **nicht** + neu geladen. Referenziert ein Port-Forward einen mittlerweile gelöschten + Host, wird er beim Generieren übersprungen und als Warnung ausgegeben, + statt den ganzen Lauf abzubrechen. - 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). +- Port-Forwards werden über `(wan-port, protokoll)` eindeutig identifiziert; + `both` (= tcp+udp in einer Regel via `meta l4proto { tcp, udp }`) kollidiert + dabei mit einem einzelnen `tcp`- oder `udp`-Eintrag auf demselben Port. ## Usage @@ -36,13 +52,32 @@ ipadm -i change ip address ipadm -m change mac address ipadm -r rename host ipadm -d delete host -ipadm -u update (dnsmasq-Config regenerieren + reload) +ipadm -pa [-f] [lan-port] [tcp|udp|both] + add port-forward zu bekanntem Host +ipadm -pl list port-forwards +ipadm -pd [tcp|udp|both] delete port-forward +ipadm -u update (dnsmasq+nftables-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. +Beispiel: + +```sh +ipadm -a webserver 10.0.0.10 # Host anlegen (falls noch nicht vorhanden) +ipadm -pa webserver 443 tcp # WAN-Port 443/tcp -> webserver:443 +ipadm -pa webserver 8080 80 tcp # WAN-Port 8080/tcp -> webserver:80 +ipadm -u # anwenden (dnsmasq + nftables) +``` + +`lan-port` und Protokoll bei `-pa` sind optional und in beliebiger +Reihenfolge angebbar (Default `lan-port` = `wan-port`, Default Protokoll +`tcp`) — das Tool erkennt selbst, ob ein Token eine Portnummer oder +`tcp`/`udp`/`both` ist. + +Nach jeder Änderung an der Host- oder Port-Forward-DB (`-a`, `-c`, `-i`, +`-m`, `-r`, `-d`, `-pa`, `-pd`, interaktiv) muss `ipadm -u` ausgeführt +werden, damit dnsmasq/nftables die Änderung tatsächlich übernehmen — das +Tool weist nach jeder Änderung selbst darauf hin. ## Bauen / Installieren @@ -58,16 +93,22 @@ Alle Pfade sind über Umgebungsvariablen überschreibbar: ```sh export IPADM_DB=/tmp/test-hosts +export IPADM_PORTFWD_DB=/tmp/test-portforwards export IPADM_DNSMASQ_HOSTS=/tmp/test-hosts.conf +export IPADM_NFT_PORTFWD=/tmp/test-portforward.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 +export IPADM_WAN_IFACE=enp3s0 ./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. +Achtung: `ipadm -u` ruft trotzdem `dnsmasq --test` bzw. +`nft -c -f /etc/nftables.conf` auf (prüft die echte System-Konfiguration) +und bei Erfolg `systemctl reload dnsmasq`/`systemctl reload nftables`, auch +im Testmodus — das ist beabsichtigt (validiert, dass die generierte Datei +mit der echten Umgebung zusammenspielt), aber es reloadet die echten +laufenden Dienste. `IPADM_NFT_PORTFWD` nur auf einen Pfad außerhalb von +`/etc/nftables.d/` zeigen lassen, sonst landet die Testdatei im echten +Include und wird real aktiv. diff --git a/tools/ipadm/host.go b/tools/ipadm/host.go index 4f49707..2b2e7c4 100644 --- a/tools/ipadm/host.go +++ b/tools/ipadm/host.go @@ -1,12 +1,9 @@ package main import ( - "bufio" "fmt" - "os" "sort" "strings" - "syscall" ) // Host is one static host entry: hostname, IP, optional MAC, free-text comment. @@ -17,99 +14,43 @@ type Host struct { Comment string } -// Store gives locked read-modify-write access to the flat-file host database. +const hostHeader = "# ipadm host database - managed with `ipadm`, do not edit while ipadm is running\n" + + "# name\tip\tmac\tcomment\n" + // 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 +// MAC is stored as "-" when empty. +func parseHostLine(line string) (Host, error) { + parts := strings.SplitN(line, "\t", 4) + if len(parts) < 3 { + return Host{}, fmt.Errorf("ungültige Zeile (erwarte mind. 3 Tab-getrennte Felder): %q", line) + } + mac := parts[2] + if mac == "-" { + mac = "" + } + comment := "" + if len(parts) == 4 { + comment = parts[3] + } + return Host{Name: parts[0], IP: parts[1], MAC: mac, Comment: comment}, nil } -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) +func formatHostLine(h Host) string { + mac := h.MAC + if mac == "" { + mac = "-" } - 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 + return fmt.Sprintf("%s\t%s\t%s\t%s", h.Name, h.IP, mac, h.Comment) } -func (s *Store) Close() error { - syscall.Flock(int(s.file.Fd()), syscall.LOCK_UN) - return s.file.Close() +func openHostStore(path string) (*LineStore[Host], error) { + return openLineStore(path, hostHeader, parseHostLine, formatHostLine) } -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 { +func sortHosts(hosts []Host) { 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 { diff --git a/tools/ipadm/main.go b/tools/ipadm/main.go index 1e725fe..317ee1d 100644 --- a/tools/ipadm/main.go +++ b/tools/ipadm/main.go @@ -1,8 +1,10 @@ // 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. +// 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 ( @@ -12,7 +14,7 @@ import ( "text/tabwriter" ) -const version = "1.0.0" +const version = "1.1.0" const usage = `ipadm - dhcp/dns management (dnsmasq backend) v` + version + ` @@ -24,17 +26,22 @@ usage: ipadm add/edit host (inter ipadm -m change mac address ipadm -r rename host ipadm -d delete host - ipadm -u update (regenerate dnsmasq config + reload) + 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 the tool validates static IPs against. -// Overridable via env vars, mainly so this can be tested without touching -// the real /etc files. +// 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 { @@ -48,15 +55,24 @@ 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"), } } @@ -93,6 +109,13 @@ func main() { 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) @@ -112,7 +135,7 @@ func requireArgs(rest []string, want int, form string) { // 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()) + s, err := openHostStore(dbPath()) if err != nil { fail("%s", err) } @@ -128,11 +151,49 @@ func withStore(fn func(hosts []Host) ([]Host, string, error)) { 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 zu aktualisieren.") + 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) { @@ -147,7 +208,7 @@ func cmdInteractive(name string) { } func cmdList() { - s, err := openStore(dbPath()) + s, err := openHostStore(dbPath()) if err != nil { fail("%s", err) } @@ -160,9 +221,10 @@ func cmdList() { 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 sortedCopy(hosts) { + for _, h := range hosts { mac := h.MAC if mac == "" { mac = "-" @@ -172,18 +234,6 @@ func cmdList() { 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" { @@ -336,20 +386,168 @@ func cmdDelete(args []string) { }) } -func cmdUpdate() { - s, err := openStore(dbPath()) - if err != nil { - fail("%s", err) +func cmdPortFwdAdd(args []string) { + force := false + if len(args) > 0 && args[0] == "-f" { + force = true + args = args[1:] } - hosts, err := s.Load() - s.Close() + 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) } - content := renderDnsmasqConfig(hosts) - if err := applyDnsmasqConfig(dnsmasqHostsPath(), content); err != nil { + 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)) } diff --git a/tools/ipadm/nftpf.go b/tools/ipadm/nftpf.go new file mode 100644 index 0000000..24a1c0e --- /dev/null +++ b/tools/ipadm/nftpf.go @@ -0,0 +1,100 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// renderPortForwards builds the content of the generated nftables include +// file (a standalone "inet portforward" table) from the current forwards. +// Forwards whose target host no longer exists in the host DB are skipped +// and reported back as warnings instead of failing the whole render. +func renderPortForwards(hosts []Host, fwds []PortForward, wanIface string) (content string, warnings []string) { + ipByHost := map[string]string{} + for _, h := range hosts { + ipByHost[strings.ToLower(h.Name)] = h.IP + } + + sorted := make([]PortForward, len(fwds)) + copy(sorted, fwds) + sortPortFwds(sorted) + + var rules strings.Builder + for _, f := range sorted { + ip, ok := ipByHost[strings.ToLower(f.Host)] + if !ok { + warnings = append(warnings, fmt.Sprintf("Port-Forward %d/%s -> %q übersprungen: Host nicht (mehr) in der ipadm-Datenbank", f.WanPort, f.Proto, f.Host)) + continue + } + switch f.Proto { + case "tcp", "udp": + fmt.Fprintf(&rules, "\t\tiifname %q %s dport %d dnat ip to %s:%d\n", wanIface, f.Proto, f.WanPort, ip, f.LanPort) + case "both": + fmt.Fprintf(&rules, "\t\tiifname %q meta l4proto { tcp, udp } th dport %d dnat ip to %s:%d\n", wanIface, f.WanPort, ip, f.LanPort) + } + } + + var b strings.Builder + b.WriteString("# Generated by ipadm -u — DO NOT EDIT MANUALLY.\n") + b.WriteString("# Edit forwards with `ipadm -pa`/`ipadm -pd` and re-run `ipadm -u` to regenerate this file.\n\n") + b.WriteString("table inet portforward {\n") + b.WriteString("\tchain prerouting {\n") + b.WriteString("\t\ttype nat hook prerouting priority dstnat;\n") + b.WriteString(rules.String()) + b.WriteString("\t}\n") + b.WriteString("}\n") + return b.String(), warnings +} + +// applyPortForwards writes the generated table to path, validates the full +// nftables ruleset, and on success reloads nftables. On validation failure +// the previous file content is restored and the service is left untouched. +func applyPortForwards(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.MkdirAll(dirOf(path), 0755); err != nil { + return fmt.Errorf("kann Verzeichnis für %s nicht anlegen: %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 := runNftTest(); err != nil { + if hadFile { + os.WriteFile(path, backup, 0644) + } else { + os.Remove(path) + } + return fmt.Errorf("nftables-Konfiguration ungültig, Änderung zurückgerollt: %w", err) + } + + if err := reloadNftables(); err != nil { + return fmt.Errorf("%s geschrieben, aber Reload fehlgeschlagen: %w", path, err) + } + return nil +} + +func runNftTest() error { + out, err := exec.Command("nft", "-c", "-f", "/etc/nftables.conf").CombinedOutput() + if err != nil { + return fmt.Errorf("%s", strings.TrimSpace(string(out))) + } + return nil +} + +func reloadNftables() error { + out, err := exec.Command("systemctl", "reload", "nftables").CombinedOutput() + if err != nil { + return fmt.Errorf("%s", strings.TrimSpace(string(out))) + } + return nil +} diff --git a/tools/ipadm/portfwd.go b/tools/ipadm/portfwd.go new file mode 100644 index 0000000..4939eac --- /dev/null +++ b/tools/ipadm/portfwd.go @@ -0,0 +1,131 @@ +package main + +import ( + "fmt" + "sort" + "strconv" + "strings" +) + +// PortForward maps a WAN port/protocol to a port on a host that must exist +// in the ipadm host database (referenced by name, so the forward keeps +// working if the host's IP changes later). +type PortForward struct { + WanPort int + Proto string // "tcp", "udp", or "both" + Host string + LanPort int +} + +var validProtos = map[string]bool{"tcp": true, "udp": true, "both": true} + +const portFwdHeader = "# ipadm port-forward database - managed with `ipadm`, do not edit while ipadm is running\n" + + "# wanport\tproto\thost\tlanport\n" + +// One line per forward, tab-separated: wanport\tproto\thost\tlanport +func parsePortFwdLine(line string) (PortForward, error) { + parts := strings.Split(line, "\t") + if len(parts) != 4 { + return PortForward{}, fmt.Errorf("ungültige Zeile (erwarte 4 Tab-getrennte Felder): %q", line) + } + wanPort, err := strconv.Atoi(parts[0]) + if err != nil { + return PortForward{}, fmt.Errorf("ungültiger wan-port: %q", parts[0]) + } + lanPort, err := strconv.Atoi(parts[3]) + if err != nil { + return PortForward{}, fmt.Errorf("ungültiger lan-port: %q", parts[3]) + } + proto := parts[1] + if !validProtos[proto] { + return PortForward{}, fmt.Errorf("ungültiges Protokoll: %q", proto) + } + return PortForward{WanPort: wanPort, Proto: proto, Host: parts[2], LanPort: lanPort}, nil +} + +func formatPortFwdLine(p PortForward) string { + return fmt.Sprintf("%d\t%s\t%s\t%d", p.WanPort, p.Proto, p.Host, p.LanPort) +} + +func openPortFwdStore(path string) (*LineStore[PortForward], error) { + return openLineStore(path, portFwdHeader, parsePortFwdLine, formatPortFwdLine) +} + +func sortPortFwds(fwds []PortForward) { + sort.Slice(fwds, func(i, j int) bool { + if fwds[i].WanPort != fwds[j].WanPort { + return fwds[i].WanPort < fwds[j].WanPort + } + return fwds[i].Proto < fwds[j].Proto + }) +} + +func validatePort(label string, port int) error { + if port < 1 || port > 65535 { + return fmt.Errorf("ungültiger %s: %d (erlaubt: 1-65535)", label, port) + } + return nil +} + +// protosOverlap reports whether two port-forward entries on the same WAN +// port would both try to handle the same traffic (e.g. "tcp" and "both"). +func protosOverlap(a, b string) bool { + if a == b { + return true + } + return a == "both" || b == "both" +} + +// findConflictingPortFwds returns the indexes of entries that occupy the +// same (wanport, proto) as the given one, excluding exceptIdx. +func findConflictingPortFwds(fwds []PortForward, wanPort int, proto string, exceptIdx int) []int { + var out []int + for i, f := range fwds { + if i == exceptIdx { + continue + } + if f.WanPort == wanPort && protosOverlap(f.Proto, proto) { + out = append(out, i) + } + } + return out +} + +func findPortFwd(fwds []PortForward, wanPort int, proto string) []int { + var out []int + for i, f := range fwds { + if f.WanPort == wanPort && (proto == "" || f.Proto == proto) { + out = append(out, i) + } + } + return out +} + +// classifyPortFwdArgs sorts the optional trailing arguments of `ipadm -pa` +// (lan-port and/or protocol, in any order) into their fields. Returns an +// error on unrecognized or duplicate tokens. +func classifyPortFwdArgs(args []string, wanPort int) (lanPort int, proto string, err error) { + lanPort = wanPort + proto = "tcp" + lanPortSet, protoSet := false, false + for _, a := range args { + if validProtos[a] { + if protoSet { + return 0, "", fmt.Errorf("Protokoll mehrfach angegeben: %q", a) + } + proto = a + protoSet = true + continue + } + if n, convErr := strconv.Atoi(a); convErr == nil { + if lanPortSet { + return 0, "", fmt.Errorf("lan-port mehrfach angegeben: %q", a) + } + lanPort = n + lanPortSet = true + continue + } + return 0, "", fmt.Errorf("unbekanntes Argument %q (erwarte lan-port oder tcp/udp/both)", a) + } + return lanPort, proto, nil +} diff --git a/tools/ipadm/store.go b/tools/ipadm/store.go new file mode 100644 index 0000000..61dbfc9 --- /dev/null +++ b/tools/ipadm/store.go @@ -0,0 +1,92 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + "syscall" +) + +// LineStore gives locked read-modify-write access to a flat text file with +// one record per line. Lines starting with '#' and blank lines are ignored. +// parse/format convert between a line and a record of type T. +type LineStore[T any] struct { + path string + file *os.File + header string + parse func(line string) (T, error) + format func(v T) string +} + +func openLineStore[T any](path, header string, parse func(string) (T, error), format func(T) string) (*LineStore[T], 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 &LineStore[T]{path: path, file: f, header: header, parse: parse, format: format}, nil +} + +func (s *LineStore[T]) Close() error { + syscall.Flock(int(s.file.Fd()), syscall.LOCK_UN) + return s.file.Close() +} + +func (s *LineStore[T]) Load() ([]T, error) { + if _, err := s.file.Seek(0, 0); err != nil { + return nil, err + } + var items []T + sc := bufio.NewScanner(s.file) + lineNo := 0 + for sc.Scan() { + lineNo++ + line := strings.TrimRight(sc.Text(), "\r\n") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + v, err := s.parse(line) + if err != nil { + return nil, fmt.Errorf("%s:%d: %w", s.path, lineNo, err) + } + items = append(items, v) + } + if err := sc.Err(); err != nil { + return nil, err + } + return items, nil +} + +func (s *LineStore[T]) Save(items []T) error { + var b strings.Builder + b.WriteString(s.header) + for _, v := range items { + b.WriteString(s.format(v)) + b.WriteString("\n") + } + 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] +}