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