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
This commit is contained in:
2026-08-18 11:43:29 +02:00
parent cfb184eebe
commit 8591223aca
8 changed files with 653 additions and 147 deletions
+62 -21
View File
@@ -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:
`name<TAB>ip<TAB>mac<TAB>comment`, `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`
(`wanport<TAB>proto<TAB>host<TAB>lanport`) 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 <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 (dnsmasq-Config regenerieren + reload)
ipadm -pa [-f] <hostname> <wan-port> [lan-port] [tcp|udp|both]
add port-forward zu bekanntem Host
ipadm -pl list port-forwards
ipadm -pd <wan-port> [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.
+26 -85
View File
@@ -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 {
+229 -31
View File
@@ -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 <hostname> add/edit host (inter
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 -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 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] <hostname> <wan-port> [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 <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))
}
+100
View File
@@ -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
}
+131
View File
@@ -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
}
+92
View File
@@ -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]
}