From c135f205fa992f331d248d0f1dcf5e5acb67f94c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 08:57:58 +0200 Subject: [PATCH] Expand LAN to 10.0.0.0/22 with static/reserved/dynamic/spare zones Splits the LAN into four /24 zones: 10.0.0.0/24 static (no DHCP, DNS only), 10.0.1.0/24 fixed DHCP reservations by MAC, 10.0.2.0/24 dynamic DHCP pool, 10.0.3.0/24 spare/unused. ipadm now derives the required subnet from whether a host has a MAC, auto-assigns free IPs, and auto-migrates a host's IP when its MAC is added/removed. Migrated the existing archerc80 reservation from 10.0.0.2 to 10.0.1.2. Also fixes a latent bug found while doing this: dnsmasq's SIGHUP (`systemctl reload`) only re-reads /etc/hosts, not the conf-dir files ipadm writes to, so config changes were silently not applied on reload. ipadm now restarts dnsmasq instead. Co-Authored-By: Claude Sonnet 5 --- README.md | 122 ++++++++++++++++++++++++++++--------- config/dnsmasq.d/lan.conf | 11 +++- config/network-interfaces | 7 ++- config/nftables.conf | 2 +- tools/ipadm/README.md | 91 ++++++++++++++++++++------- tools/ipadm/dnsmasq.go | 15 +++-- tools/ipadm/host.go | 25 ++++++++ tools/ipadm/interactive.go | 32 ++++++---- tools/ipadm/main.go | 91 +++++++++++++++++++-------- tools/ipadm/validate.go | 80 +++++++++++++++++++----- 10 files changed, 364 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index dece310..3c4d5a6 100644 --- a/README.md +++ b/README.md @@ -31,26 +31,60 @@ mehr von Hand in `dnsmasq.d` gepflegt, sondern über das Tool └─────────────────────┘ │ enp7s0 (LAN) - 10.0.0.1/24 + 10.0.0.1/22 │ - ┌─────────┴─────────┐ - │ 10.0.0.0/24 │ - │ DHCP: .100–.200 │ - │ Clients │ - └───────────────────┘ + ┌───────────────┼───────────────┬───────────────┐ + │ │ │ │ + 10.0.0.0/24 10.0.1.0/24 10.0.2.0/24 10.0.3.0/24 + statisch DHCP-Reserv. dynamischer Reserve + (kein DHCP, (per MAC, DHCP-Pool (unbenutzt) + nur DNS) via ipadm) .10–.250 ``` - **WAN**: `enp3s0`, statische öffentliche IP, unverändert wie vorher. -- **LAN**: `enp7s0`, neu eingerichtet, `10.0.0.1/24`, Router = Gateway = DNS - für alle Clients im Netz. +- **LAN**: `enp7s0`, `10.0.0.1/22` (seit 2026-08-21, davor `/24`), Router = + Gateway = DNS für alle Clients im Netz. Details zur Aufteilung in vier + `/24`-Zonen siehe [IP-Adressschema](#ip-adressschema) unten. + +## IP-Adressschema + +Das LAN ist ein `10.0.0.0/22` (Netzmaske `255.255.252.0`, Adressen +`10.0.0.0`–`10.0.3.255`), aufgeteilt in vier gleich große `/24`-Zonen mit +klar getrennten Zwecken: + +| Subnetz | Zweck | Vergabe | +|---|---|---| +| `10.0.0.0/24` | **Statische Adressen** — Geräte, die ihre IP selbst fest eingestellt haben (kein DHCP), bekommen hier nur einen DNS-Eintrag | `ipadm -a ` (ohne MAC) | +| `10.0.1.0/24` | **Feste DHCP-Reservierungen** — Geräte, die per DHCP immer dieselbe IP bekommen sollen | `ipadm -a ` | +| `10.0.2.0/24` | **Dynamischer DHCP-Pool** — alle anderen Clients (Laptops, Handys, Gäste) | automatisch von dnsmasq, Bereich `.10`–`.250` | +| `10.0.3.0/24` | **Reserve** — aktuell nicht konfiguriert/genutzt | – | + +`10.0.0.1` (Router/Gateway) ist über das gesamte `/22` hinweg reserviert und +kann keinem Host zugewiesen werden. + +Das Tool [`ipadm`](tools/ipadm/) kennt diese Regel und wendet sie automatisch +an: Hosts **ohne** MAC-Adresse landen in `10.0.0.0/24`, Hosts **mit** +MAC-Adresse in `10.0.1.0/24`. Die IP wird beim Anlegen automatisch aus dem +passenden Subnetz vergeben, wenn keine explizit angegeben wird; wird +nachträglich eine MAC-Adresse hinzugefügt/entfernt (`ipadm -m`), verschiebt +`ipadm` den Host automatisch in eine freie IP des jetzt passenden Subnetzes. +Details siehe [tools/ipadm/README.md](tools/ipadm/README.md). + +Technisch ist das weiterhin **ein** LAN (eine Broadcast-Domain, ein +Interface, eine Netzmaske `/22`) — die vier `/24`-Zonen sind reine +Adress-Konventionen, keine getrennten physischen/VLAN-Netze. dnsmasq braucht +dafür nur einen `dhcp-range` für den dynamischen Pool +(`10.0.2.10,10.0.2.250,255.255.252.0`); `dhcp-host`-Reservierungen außerhalb +dieses Bereichs (z.B. in `10.0.1.0/24`) funktioniert trotzdem, solange sie im +selben `/22` liegen. ## Was wurde eingerichtet | Bereich | Was | Datei auf dem Server | Kopie in diesem Repo | |---|---|---|---| -| LAN-Interface | `enp7s0` statisch auf `10.0.0.1/24` | `/etc/network/interfaces` | [`config/network-interfaces`](config/network-interfaces) | +| LAN-Interface | `enp7s0` statisch auf `10.0.0.1/22` | `/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) | +| NAT | Masquerading `10.0.0.0/22` → 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) | @@ -70,9 +104,10 @@ DNS-Server-Rolle). `nftables` war bereits installiert, wurde aber aktiviert so restriktiv, damit auf der öffentlichen WAN-Seite kein offener DNS-Resolver/DHCP-Server erreichbar ist (Missbrauchsrisiko, z.B. DNS-Amplification). -- `dhcp-range=10.0.0.100,10.0.0.200,255.255.255.0,12h`: DHCP-Pool, 12h Lease. - `.2`–`.99` bleiben frei für statische Reservierungen/Server, `.201`–`.254` - als Puffer. +- `dhcp-range=10.0.2.10,10.0.2.250,255.255.252.0,12h`: dynamischer DHCP-Pool + in `10.0.2.0/24`, 12h Lease. Die Netzmaske `255.255.252.0` (`/22`) sagt + dnsmasq, dass das gesamte LAN ein einziges `/22`-Netz ist — siehe + [IP-Adressschema](#ip-adressschema). - `dhcp-option=option:router,10.0.0.1` / `dhcp-option=option:dns-server,10.0.0.1`: Clients bekommen den Router selbst als Gateway und als DNS-Server. - `domain=fhi.mpg.de` / `server=141.14.128.1` / `no-resolv`: DNS-Anfragen aus @@ -88,7 +123,7 @@ DNS-Server-Rolle). `nftables` war bereits installiert, wurde aber aktiviert gehärtet/eingeschränkt**, um die bestehende Erreichbarkeit des Servers nicht zu verändern. - `table inet nat` / `chain postrouting`: NAT-Masquerading für Traffic aus - `10.0.0.0/24`, der über `enp3s0` rausgeht. Das ist die einzige Regel, die + `10.0.0.0/22`, der über `enp3s0` rausgeht. Das ist die einzige Regel, die tatsächlich nötig ist, damit LAN-Clients ins Internet kommen. ## Konfiguration anpassen @@ -101,24 +136,33 @@ bleibt. ### DHCP-Pool / Lease-Zeit ändern -In `/etc/dnsmasq.d/lan.conf` die Zeile anpassen, z.B.: +Der dynamische Pool liegt in `10.0.2.0/24`. In `/etc/dnsmasq.d/lan.conf` die +Zeile anpassen, z.B. größerer Pool oder andere Lease-Zeit: ``` -dhcp-range=10.0.0.50,10.0.0.250,255.255.255.0,24h +dhcp-range=10.0.2.10,10.0.2.250,255.255.252.0,24h ``` +Die Netzmaske (`255.255.252.0` = `/22`) nicht verändern, solange das +[IP-Adressschema](#ip-adressschema) mit den vier `/24`-Zonen gilt — sie +definiert, dass dnsmasq das ganze `/22` bedient, nicht nur `10.0.2.0/24`. + Danach: `systemctl restart dnsmasq` -### Statische IP-Reservierung für ein Gerät (per MAC) +### Statischen Host anlegen (DNS und/oder feste DHCP-Reservierung per MAC) -In `/etc/dnsmasq.d/lan.conf` (oder eine eigene Datei `/etc/dnsmasq.d/hosts.conf`) -ergänzen: +Immer über `ipadm`, **nicht** von Hand in `dnsmasq.d` eintragen — das Tool +kennt das [IP-Adressschema](#ip-adressschema) und vergibt/prüft die IP im +passenden Subnetz automatisch: -``` -dhcp-host=aa:bb:cc:dd:ee:ff,10.0.0.10,mein-server,infinite +```sh +ipadm -a mein-server # nur DNS, IP aus 10.0.0.0/24 auto-vergeben +ipadm -a mein-server aa:bb:cc:dd:ee:ff # + feste DHCP-Reservierung, IP aus 10.0.1.0/24 auto-vergeben +ipadm -u # anwenden (dnsmasq neu starten) ``` -Danach: `systemctl restart dnsmasq` +Details, weitere Befehle (IP/MAC nachträglich ändern, Kommentar, Umbenennen, +löschen) siehe [tools/ipadm/README.md](tools/ipadm/README.md). ### DNS-Upstream ändern (z.B. auf öffentliche Resolver) @@ -162,21 +206,34 @@ offen gelassen), unbedingt zuerst SSH-Zugriff (und alle anderen aktiv genutzten Dienste) explizit erlauben, bevor eine restriktive `policy drop` auf `chain input` gesetzt wird – sonst sperrt man sich ggf. selbst aus. -### LAN-Netz/IP-Bereich komplett ändern (z.B. auf ein anderes /24) +### LAN-Netz/IP-Bereich komplett ändern (z.B. auf einen anderen Adressraum) 1. `/etc/network/interfaces`: `address` der `enp7s0`-Zeile anpassen. 2. `/etc/dnsmasq.d/lan.conf`: `dhcp-range` und beide `dhcp-option`-Zeilen (Router/DNS-IP) anpassen. -3. `/etc/nftables.conf`: `ip saddr 10.0.0.0/24` in der NAT-Regel anpassen. -4. Anwenden: `ifreload -a` (oder `ifdown enp7s0 && ifup enp7s0`), - `systemctl restart dnsmasq`, `nft -f /etc/nftables.conf`. +3. `/etc/nftables.conf`: `ip saddr 10.0.0.0/22` in der NAT-Regel anpassen. +4. Falls sich die Aufteilung der vier `/24`-Zonen ändert (nicht nur die + Basis-Adresse): `ipadm`s Env-Vars `IPADM_STATIC_CIDR`, + `IPADM_RESERVED_CIDR`, `IPADM_GATEWAY_IP` entsprechend über + `/etc/systemd/system/ipadm.env` o.ä. anpassen — siehe + [tools/ipadm/README.md](tools/ipadm/README.md) (Default ist + `10.0.0.0/24` / `10.0.1.0/24` / `10.0.0.1`, fest im Binary einkompiliert + als Fallback). +5. Anwenden: Adresse live setzen (`ip addr change / dev + enp7s0` + alte Adresse mit `ip addr del` entfernen, falls sich die + Präfixlänge ändert — sonst bleiben beide Adressen parallel aktiv), + `systemctl restart dnsmasq` (ein reines `reload` reicht bei + `dhcp-range`-Änderungen **nicht**, siehe Hinweis unten), `nft -f + /etc/nftables.conf`. Alternativ komplett: `ifdown enp7s0 && ifup enp7s0`. + Bestehende `ipadm`-Hosts danach mit `ipadm -i ` einzeln + umziehen und mit `ipadm -u` anwenden. ## Befehle zum Anwenden von Änderungen ```sh # dnsmasq-Konfig auf Syntaxfehler prüfen, dann neu laden dnsmasq --test -systemctl restart dnsmasq +systemctl restart dnsmasq # NICHT "reload" (SIGHUP) — siehe Hinweis unten # nftables-Konfig auf Syntaxfehler prüfen, dann neu laden nft -c -f /etc/nftables.conf @@ -190,6 +247,15 @@ ifdown enp7s0 && ifup enp7s0 sysctl --system ``` +⚠️ **`systemctl reload dnsmasq` (SIGHUP) genügt bei Config-Änderungen nicht.** +dnsmasq liest per SIGHUP nur `/etc/hosts` und einige spezielle +Hosts-Dateien-Optionen neu — **nicht** die `conf-dir`-Dateien +(`/etc/dnsmasq.d/*.conf`, also `lan.conf` und das von `ipadm` generierte +`hosts.conf`). Änderungen dort (`dhcp-range`, `host-record`, `dhcp-host`, …) +brauchen einen echten `systemctl restart dnsmasq`, sonst läuft der alte Stand +unbemerkt weiter. `ipadm -u` macht das seit Version 1.2.0 automatisch +richtig (restart statt reload) — bei manuellen Änderungen selbst dran denken. + ## Troubleshooting / nützliche Befehle ```sh @@ -235,7 +301,7 @@ DHCPNAK(enp7s0) 10.0.0.151 wrong server-ID ← wir lehnen zu Recht ab Client hat also ein Angebot von einer fremden Quelle angenommen. Gerät mit aktiver Web-Verwaltung im LAN suchen (z.B. `curl -I http:///`) und dessen DHCP-Server deaktivieren / in Access-Point-Modus umschalten — -narcissus soll der einzige DHCP-Server im `10.0.0.0/24`-Netz sein. +narcissus soll der einzige DHCP-Server im `10.0.0.0/22`-Netz sein. ## Sicherheitshinweise diff --git a/config/dnsmasq.d/lan.conf b/config/dnsmasq.d/lan.conf index b3ec14a..cba10b3 100644 --- a/config/dnsmasq.d/lan.conf +++ b/config/dnsmasq.d/lan.conf @@ -4,8 +4,15 @@ bind-interfaces except-interface=enp3s0 except-interface=lo -# DHCP für 10.0.0.0/24 -dhcp-range=10.0.0.100,10.0.0.200,255.255.255.0,12h +# DHCP für 10.0.0.0/22, aufgeteilt in vier /24er: +# 10.0.0.0/24 statische Adressen (kein DHCP, nur DNS via ipadm) +# 10.0.1.0/24 feste DHCP-Reservierungen anhand MAC-Adresse (via ipadm, siehe hosts.conf) +# 10.0.2.0/24 dynamischer Pool für alle anderen Clients (dieser dhcp-range) +# 10.0.3.0/24 Reserve, aktuell unbenutzt +# Ein dhcp-range genügt: die Netzmaske definiert das gesamte /22 als ein +# DHCP-Netz, dhcp-host-Reservierungen (hosts.conf) außerhalb dieses Bereichs +# funktionieren trotzdem, solange sie im selben /22 liegen. +dhcp-range=10.0.2.10,10.0.2.250,255.255.252.0,12h dhcp-option=option:router,10.0.0.1 dhcp-option=option:dns-server,10.0.0.1 dhcp-authoritative diff --git a/config/network-interfaces b/config/network-interfaces index 5961e6c..8373656 100644 --- a/config/network-interfaces +++ b/config/network-interfaces @@ -16,7 +16,10 @@ iface enp3s0 inet static dns-nameservers 141.14.128.1 dns-search fhi.mpg.de -# LAN interface (Router/Gateway/DHCP/DNS für 10.0.0.0/24) +# LAN interface (Router/Gateway/DHCP/DNS für 10.0.0.0/22) +# Aufteilung: 10.0.0.0/24 statisch, 10.0.1.0/24 DHCP-Reservierungen (MAC), +# 10.0.2.0/24 dynamischer DHCP-Pool, 10.0.3.0/24 Reserve/unbenutzt. +# Details: siehe README.md im Router-Repo. allow-hotplug enp7s0 iface enp7s0 inet static - address 10.0.0.1/24 + address 10.0.0.1/22 diff --git a/config/nftables.conf b/config/nftables.conf index 2662a46..cfb4cf0 100644 --- a/config/nftables.conf +++ b/config/nftables.conf @@ -17,7 +17,7 @@ table inet filter { table inet nat { chain postrouting { type nat hook postrouting priority srcnat; - ip saddr 10.0.0.0/24 oifname "enp3s0" masquerade + ip saddr 10.0.0.0/22 oifname "enp3s0" masquerade } } diff --git a/tools/ipadm/README.md b/tools/ipadm/README.md index 54ac4fe..2435f62 100644 --- a/tools/ipadm/README.md +++ b/tools/ipadm/README.md @@ -8,6 +8,22 @@ statt isc-dhcp-server/bind9 (siehe [../../README.md](../../README.md)). Installiert auf dem Server unter `/usr/local/bin/ipadm`. +## IP-Adressschema + +Das LAN ist ein `10.0.0.0/22`, aufgeteilt in vier `/24`-Zonen (Details siehe +[../../README.md#ip-adressschema](../../README.md#ip-adressschema)): + +- `10.0.0.0/24` — statische Adressen (kein DHCP, nur DNS), für Hosts **ohne** + MAC-Adresse in der ipadm-Datenbank. +- `10.0.1.0/24` — feste DHCP-Reservierungen (per MAC), für Hosts **mit** + MAC-Adresse in der ipadm-Datenbank. +- `10.0.2.0/24` — dynamischer DHCP-Pool (nicht von ipadm verwaltet). +- `10.0.3.0/24` — Reserve, unbenutzt. + +`ipadm` wendet diese Regel bei jeder IP-Zuweisung automatisch an: welches +Subnetz erlaubt ist, entscheidet allein, ob der Host eine MAC-Adresse hat +oder nicht — nicht der Nutzer. Details siehe unten. + ## Funktionsweise - Pflegt eine flache Textdatei `/etc/ipadm/hosts` (eine Zeile pro Host: @@ -16,27 +32,38 @@ Installiert auf dem Server unter `/usr/local/bin/ipadm`. (`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: +- `ipadm -u` generiert daraus zwei Dateien und wendet die zugehörigen + Dienste neu an: - `/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`. + validiert mit `dnsmasq --test`, danach `systemctl restart dnsmasq`. + **Bewusst `restart`, nicht `reload`**: dnsmasqs SIGHUP-Handler liest nur + `/etc/hosts` neu, nicht die `conf-dir`-Dateien, in denen `hosts.conf` + liegt — ein reines `reload` würde die Änderung nicht anwenden. - `/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`. + `systemctl reload nftables` (dessen `reload` liest die komplette Config + neu ein, das ist hier unproblematisch). 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 + neu angewendet. 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). +- IP-Adressen werden bei `-a`/`-i`/im interaktiven Modus geprüft: müssen im + zur MAC-Adresse passenden Subnetz liegen (`10.0.0.0/24` ohne MAC, + `10.0.1.0/24` mit MAC) und dürfen weder die Netz-/Broadcast-Adresse noch + die Gateway-Adresse `10.0.0.1` sein. +- Wird bei `-a` keine IP angegeben, vergibt `ipadm` automatisch die nächste + freie IP im passenden Subnetz. +- Wird bei `-m` eine MAC-Adresse hinzugefügt oder entfernt und der Host + liegt dadurch im falschen Subnetz, verschiebt `ipadm` ihn automatisch auf + eine freie IP im jetzt passenden Subnetz (mit Hinweis in der Ausgabe). + `-i` dagegen validiert nur — eine manuell angegebene IP muss bereits zum + (unveränderten) MAC-Status des Hosts passen. - 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. @@ -46,7 +73,7 @@ Installiert auf dem Server unter `/usr/local/bin/ipadm`. ``` ipadm add/edit host (interactive) ipadm -l list hosts -ipadm -a [-f] [mac address] add host +ipadm -a [-f] [ip address] [mac address] add host (IP auto-assigned if omitted) ipadm -c set comment ipadm -i change ip address ipadm -m change mac address @@ -62,16 +89,23 @@ ipadm -h this help (Output/messages of the tool itself are English; this README stays German like the rest of the repo.) -Beispiel: +Beispiele: ```sh -ipadm -a webserver 10.0.0.10 # Host anlegen (falls noch nicht vorhanden) +ipadm -a webserver # Host ohne MAC anlegen, IP aus 10.0.0.0/24 auto-vergeben +ipadm -a heizung aa:bb:cc:dd:ee:ff # Host mit MAC anlegen, IP aus 10.0.1.0/24 auto-vergeben +ipadm -a fixip 10.0.0.42 # Host mit expliziter IP (muss zum Subnetz passen) 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 +Bei `-a` sind IP-Adresse und MAC-Adresse optional und in beliebiger +Reihenfolge angebbar — das Tool erkennt selbst, welches Token eine IP und +welches eine MAC ist. Fehlt die IP, wird automatisch die nächste freie IP im +zur MAC passenden Subnetz vergeben. + +`lan-port` und Protokoll bei `-pa` sind optional und ebenfalls 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. @@ -91,26 +125,37 @@ install -m 0755 -o root -g root ipadm /usr/local/bin/ipadm ## Testen ohne die echte Server-Konfiguration anzufassen -Alle Pfade sind über Umgebungsvariablen überschreibbar: +Alle Pfade und das IP-Adressschema 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_STATIC_CIDR=10.0.0.0/24 +export IPADM_RESERVED_CIDR=10.0.1.0/24 +export IPADM_GATEWAY_IP=10.0.0.1 export IPADM_WAN_IFACE=enp3s0 -./ipadm -a testhost 10.0.0.50 aa:bb:cc:dd:ee:ff +./ipadm -a testhost aa:bb:cc:dd:ee:ff ./ipadm -l ``` 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 +und bei Erfolg `systemctl restart 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. +mit der echten Umgebung zusammenspielt), aber es wendet die echten +laufenden Dienste an (inkl. eines kurzen dnsmasq-Neustarts). `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. + +## Änderungshistorie (Kurzfassung) + +- **v1.2.0** (2026-08-21): LAN auf `10.0.0.0/22` erweitert, Subnetz-Zonen + eingeführt (`IPADM_STATIC_CIDR`/`IPADM_RESERVED_CIDR` statt + `IPADM_LAN_CIDR`/`IPADM_POOL_START`/`IPADM_POOL_END`), automatische + IP-Vergabe bei `-a`, automatisches Umziehen bei MAC-Änderung (`-m`), + dnsmasq-Anwendung von `reload` auf `restart` korrigiert (siehe oben). +- **v1.1.0**: Port-Forwarding-Verwaltung (`-pa`/`-pl`/`-pd`) ergänzt, + komplett auf Englisch übersetzt, Hilfetext-Ausrichtung korrigiert. diff --git a/tools/ipadm/dnsmasq.go b/tools/ipadm/dnsmasq.go index 9712087..11cbb1a 100644 --- a/tools/ipadm/dnsmasq.go +++ b/tools/ipadm/dnsmasq.go @@ -35,7 +35,7 @@ func renderDnsmasqConfig(hosts []Host) string { } // applyDnsmasqConfig writes the generated config to path, validates the full -// dnsmasq configuration, and on success reloads the dnsmasq service. On +// dnsmasq configuration, and on success restarts the dnsmasq service. On // validation failure the previous file content is restored and the service // is left untouched. func applyDnsmasqConfig(path string, content string) error { @@ -62,8 +62,8 @@ func applyDnsmasqConfig(path string, content string) error { return fmt.Errorf("dnsmasq config invalid, change rolled back: %w", err) } - if err := reloadDnsmasq(); err != nil { - return fmt.Errorf("%s written, but reload failed: %w", path, err) + if err := restartDnsmasq(); err != nil { + return fmt.Errorf("%s written, but restart failed: %w", path, err) } return nil } @@ -76,8 +76,13 @@ func runDnsmasqTest() error { return nil } -func reloadDnsmasq() error { - out, err := exec.Command("systemctl", "reload", "dnsmasq").CombinedOutput() +// restartDnsmasq does a full restart rather than `systemctl reload` +// (SIGHUP): dnsmasq's SIGHUP handler only re-reads /etc/hosts and a few +// specific hosts-file options, not the conf-dir files this generated +// hosts.conf lives in, so a reload would silently keep serving the old +// host-record/dhcp-host entries. +func restartDnsmasq() error { + out, err := exec.Command("systemctl", "restart", "dnsmasq").CombinedOutput() if err != nil { return fmt.Errorf("%s", strings.TrimSpace(string(out))) } diff --git a/tools/ipadm/host.go b/tools/ipadm/host.go index 3be9bed..7f13adb 100644 --- a/tools/ipadm/host.go +++ b/tools/ipadm/host.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "net" "sort" "strings" ) @@ -82,3 +83,27 @@ func findByMAC(hosts []Host, mac string, exceptIdx int) int { } return -1 } + +// classifyHostArgs sorts the optional trailing arguments of `ipadm -a` (ip +// address and/or mac address, in any order, either of which may be omitted) +// into their fields. Returns an error on unrecognized or duplicate tokens. +func classifyHostArgs(args []string) (ip, mac string, err error) { + for _, a := range args { + if _, macErr := net.ParseMAC(a); macErr == nil { + if mac != "" { + return "", "", fmt.Errorf("MAC address given more than once: %q", a) + } + mac = a + continue + } + if parsed := net.ParseIP(a); parsed != nil && parsed.To4() != nil { + if ip != "" { + return "", "", fmt.Errorf("IP address given more than once: %q", a) + } + ip = a + continue + } + return "", "", fmt.Errorf("unrecognized argument %q (expected IP address or MAC address)", a) + } + return ip, mac, nil +} diff --git a/tools/ipadm/interactive.go b/tools/ipadm/interactive.go index 8432c98..6a62dbc 100644 --- a/tools/ipadm/interactive.go +++ b/tools/ipadm/interactive.go @@ -40,19 +40,11 @@ func interactiveEdit(hosts []Host, name string, cfg netConfig) ([]Host, error) { fmt.Printf("Editing existing host %q (press enter to keep a value)\n", name) } else { cur = Host{Name: name} - fmt.Printf("New host %q (press enter to leave MAC/comment empty)\n", name) - } - - for { - ip := prompt("IP address", cur.IP) - if err := validateIP(ip, cfg.lanCIDR, cfg.poolStart, cfg.poolEnd); err != nil { - fmt.Fprintln(os.Stderr, "error:", err) - continue - } - cur.IP = ip - break + fmt.Printf("New host %q (press enter to accept defaults)\n", name) } + // MAC decides which subnet the host belongs in (see netConfig), so it's + // asked first and used to suggest a matching free IP below. for { mac := prompt("MAC address (optional, '-' to clear)", macOrDash(cur.MAC)) if mac == "-" { @@ -67,6 +59,24 @@ func interactiveEdit(hosts []Host, name string, cfg netConfig) ([]Host, error) { break } + cidr := cfg.cidrFor(cur.MAC) + suggested := cur.IP + if suggested == "" || validateIP(suggested, cidr, cfg.gatewayIP) != nil { + if freeIP, err := nextFreeIP(hosts, cidr, idx, cfg.gatewayIP); err == nil { + suggested = freeIP + } + } + + for { + ip := prompt(fmt.Sprintf("IP address (%s)", cidr), suggested) + if err := validateIP(ip, cidr, cfg.gatewayIP); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + cur.IP = ip + break + } + cur.Comment = prompt("Comment", cur.Comment) if other := findByIP(hosts, cur.IP, idx); other >= 0 { diff --git a/tools/ipadm/main.go b/tools/ipadm/main.go index 4149376..2749453 100644 --- a/tools/ipadm/main.go +++ b/tools/ipadm/main.go @@ -14,7 +14,7 @@ import ( "text/tabwriter" ) -const version = "1.1.0" +const version = "1.2.0" type usageRow struct { left string @@ -25,7 +25,7 @@ type usageRow struct { var usageRows = []usageRow{ {"ipadm ", "add/edit host (interactive)", false}, {"ipadm -l", "list hosts", false}, - {"ipadm -a [-f] [mac address]", "add host", false}, + {"ipadm -a [-f] [ip address] [mac address]", "add host (IP auto-assigned if omitted)", false}, {"ipadm -c ", "set comment", false}, {"ipadm -i ", "change ip address", false}, {"ipadm -m ", "change mac address", false}, @@ -34,7 +34,7 @@ var usageRows = []usageRow{ {"ipadm -pa [-f] [lan-port] [tcp|udp|both]", "add port-forward to a known host", true}, {"ipadm -pl", "list port-forwards", false}, {"ipadm -pd [tcp|udp|both]", "delete port-forward", false}, - {"ipadm -u", "update (regenerate dnsmasq+nftables config, reload)", false}, + {"ipadm -u", "update (regenerate dnsmasq+nftables config, apply)", false}, {"ipadm -h", "this help", false}, } @@ -73,11 +73,25 @@ var usage = buildUsage() // 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. +// +// The LAN is a /22 split into four /24s: staticCIDR for hosts without a MAC +// (manually configured on the device, DNS only), reservedCIDR for hosts with +// a MAC (DHCP static reservation), plus a dynamic DHCP pool and a reserved, +// currently unused /24 that ipadm doesn't need to know about. type netConfig struct { - lanCIDR string - poolStart string - poolEnd string - wanIface string + staticCIDR string + reservedCIDR string + gatewayIP string + wanIface string +} + +// cidrFor returns the subnet a host with the given MAC (possibly empty) +// belongs in. +func (c netConfig) cidrFor(mac string) string { + if mac == "" { + return c.staticCIDR + } + return c.reservedCIDR } func envOr(key, def string) string { @@ -105,10 +119,10 @@ func nftPortFwdPath() string { 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"), + staticCIDR: envOr("IPADM_STATIC_CIDR", "10.0.0.0/24"), + reservedCIDR: envOr("IPADM_RESERVED_CIDR", "10.0.1.0/24"), + gatewayIP: envOr("IPADM_GATEWAY_IP", "10.0.0.1"), + wanIface: envOr("IPADM_WAN_IFACE", "enp3s0"), } } @@ -276,13 +290,13 @@ func cmdAdd(args []string) { force = true args = args[1:] } - if len(args) < 2 || len(args) > 3 { - fail("usage: ipadm -a [-f] [mac address]") + if len(args) < 1 || len(args) > 3 { + fail("usage: ipadm -a [-f] [ip address] [mac address]") } - name, ip := args[0], args[1] - mac := "" - if len(args) == 3 { - mac = args[2] + name := args[0] + ip, mac, err := classifyHostArgs(args[1:]) + if err != nil { + fail("%s", err) } cfg := loadNetConfig() @@ -290,9 +304,6 @@ func cmdAdd(args []string) { 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 @@ -302,6 +313,18 @@ func cmdAdd(args []string) { if idx >= 0 && !force { return nil, "", fmt.Errorf("host %q already exists (use -f to overwrite, or `ipadm %s` to edit it)", name, name) } + + cidr := cfg.cidrFor(normMac) + if ip == "" { + freeIP, err := nextFreeIP(hosts, cidr, idx, cfg.gatewayIP) + if err != nil { + return nil, "", err + } + ip = freeIP + } else if err := validateIP(ip, cidr, cfg.gatewayIP); err != nil { + return nil, "", err + } + if other := findByIP(hosts, ip, idx); other >= 0 { return nil, "", fmt.Errorf("IP %s is already assigned to host %q", ip, hosts[other].Name) } @@ -312,10 +335,10 @@ func cmdAdd(args []string) { h := Host{Name: name, IP: ip, MAC: normMac} if idx >= 0 { hosts[idx] = h - return hosts, fmt.Sprintf("Host %q overwritten (-f).", name), nil + return hosts, fmt.Sprintf("Host %q overwritten (-f), IP %s.", name, ip), nil } hosts = append(hosts, h) - return hosts, fmt.Sprintf("Host %q added.", name), nil + return hosts, fmt.Sprintf("Host %q added with IP %s.", name, ip), nil }) } @@ -346,7 +369,8 @@ func cmdChangeIP(args []string) { if idx < 0 { return nil, "", fmt.Errorf("host %q not found", name) } - if err := validateIP(ip, cfg.lanCIDR, cfg.poolStart, cfg.poolEnd); err != nil { + cidr := cfg.cidrFor(hosts[idx].MAC) + if err := validateIP(ip, cidr, cfg.gatewayIP); err != nil { return nil, "", err } if other := findByIP(hosts, ip, idx); other >= 0 { @@ -365,6 +389,7 @@ func cmdChangeMAC(args []string) { if mac == "-" { mac = "" } + cfg := loadNetConfig() withStore(func(hosts []Host) ([]Host, string, error) { idx := findHost(hosts, name) if idx < 0 { @@ -377,12 +402,28 @@ func cmdChangeMAC(args []string) { if other := findByMAC(hosts, normMac, idx); other >= 0 { return nil, "", fmt.Errorf("MAC %s is already assigned to host %q", normMac, hosts[other].Name) } + hosts[idx].MAC = normMac label := normMac if label == "" { label = "(removed)" } - return hosts, fmt.Sprintf("MAC of %q changed to %s.", name, label), nil + summary := fmt.Sprintf("MAC of %q changed to %s.", name, label) + + // The subnet a host belongs in depends on whether it has a MAC + // (see netConfig). If that changed, move it to a free IP in the + // now-correct subnet instead of leaving it in the wrong one. + targetCIDR := cfg.cidrFor(normMac) + if validateIP(hosts[idx].IP, targetCIDR, cfg.gatewayIP) != nil { + newIP, err := nextFreeIP(hosts, targetCIDR, idx, cfg.gatewayIP) + if err != nil { + return nil, "", fmt.Errorf("MAC changed, but host must move to %s and no free IP is left there: %w", targetCIDR, err) + } + oldIP := hosts[idx].IP + hosts[idx].IP = newIP + summary += fmt.Sprintf(" Host moved from %s to %s (subnet %s).", oldIP, newIP, targetCIDR) + } + return hosts, summary, nil }) } @@ -566,7 +607,7 @@ func cmdUpdate() { if err := applyDnsmasqConfig(dnsmasqHostsPath(), dnsContent); err != nil { fail("%s", err) } - fmt.Printf("%s updated, dnsmasq reloaded (%d hosts).\n", dnsmasqHostsPath(), len(hosts)) + fmt.Printf("%s updated, dnsmasq restarted (%d hosts).\n", dnsmasqHostsPath(), len(hosts)) fs, err := openPortFwdStore(portFwdDBPath()) if err != nil { diff --git a/tools/ipadm/validate.go b/tools/ipadm/validate.go index 444ee8d..ecc8f01 100644 --- a/tools/ipadm/validate.go +++ b/tools/ipadm/validate.go @@ -20,33 +20,83 @@ func ip4ToUint32(ip net.IP) uint32 { return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) } -// validateIP checks that ip is a valid IPv4 address inside lanCIDR and outside -// the dynamic DHCP pool [poolStart, poolEnd] (both inclusive), so static -// reservations can never collide with dynamically leased addresses. -func validateIP(ip, lanCIDR, poolStart, poolEnd string) error { +func uint32ToIP4(v uint32) net.IP { + return net.IPv4(byte(v>>24), byte(v>>16), byte(v>>8), byte(v)) +} + +// validateIP checks that ip is a valid IPv4 address inside cidr, is neither +// the network nor the broadcast address of cidr, and is not one of the +// explicitly excluded addresses (e.g. the router's own gateway IP). +func validateIP(ip, cidr string, excluded ...string) error { parsed := net.ParseIP(ip) if parsed == nil || parsed.To4() == nil { return fmt.Errorf("invalid IPv4 address: %q", ip) } - _, cidr, err := net.ParseCIDR(lanCIDR) + _, network, err := net.ParseCIDR(cidr) if err != nil { - return fmt.Errorf("internal error: invalid LAN CIDR %q: %w", lanCIDR, err) + return fmt.Errorf("internal error: invalid network %q: %w", cidr, err) } - if !cidr.Contains(parsed) { - return fmt.Errorf("IP %s is not inside LAN network %s", ip, lanCIDR) + if !network.Contains(parsed) { + return fmt.Errorf("IP %s is not inside network %s", ip, cidr) } - start := net.ParseIP(poolStart) - end := net.ParseIP(poolEnd) - if start == nil || end == nil { - return fmt.Errorf("internal error: invalid DHCP pool %q-%q", poolStart, poolEnd) + if isNetworkOrBroadcast(parsed, network) { + return fmt.Errorf("IP %s is the network or broadcast address of %s and cannot be assigned", ip, cidr) } - v, s, e := ip4ToUint32(parsed), ip4ToUint32(start), ip4ToUint32(end) - if v >= s && v <= e { - return fmt.Errorf("IP %s is inside the dynamic DHCP pool (%s-%s) and is reserved, not allowed for static assignments", ip, poolStart, poolEnd) + for _, ex := range excluded { + if parsed.Equal(net.ParseIP(ex)) { + return fmt.Errorf("IP %s is reserved (router/gateway address) and cannot be assigned", ip) + } } return nil } +func isNetworkOrBroadcast(ip net.IP, network *net.IPNet) bool { + ip4 := ip.To4() + mask := network.Mask + netAddr := ip4.Mask(mask) + broadcast := make(net.IP, len(netAddr)) + for i := range netAddr { + broadcast[i] = netAddr[i] | ^mask[i] + } + return ip4.Equal(netAddr) || ip4.Equal(broadcast) +} + +// nextFreeIP returns the first address in cidr (ascending order) that is not +// the network or broadcast address, not in excluded, and not already used by +// a host other than exceptIdx (-1 to not exempt any host). +func nextFreeIP(hosts []Host, cidr string, exceptIdx int, excluded ...string) (string, error) { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + return "", fmt.Errorf("internal error: invalid network %q: %w", cidr, err) + } + ones, bits := network.Mask.Size() + size := uint32(1) << uint32(bits-ones) + if size < 2 { + return "", fmt.Errorf("internal error: network %q too small", cidr) + } + start := ip4ToUint32(network.IP.To4()) + for i := uint32(1); i < size-1; i++ { + candidate := uint32ToIP4(start + i).String() + if containsIP(excluded, candidate) { + continue + } + if findByIP(hosts, candidate, exceptIdx) >= 0 { + continue + } + return candidate, nil + } + return "", fmt.Errorf("no free IP address left in %s", cidr) +} + +func containsIP(ips []string, ip string) bool { + for _, v := range ips { + if v == ip { + return true + } + } + return false +} + // normalizeMAC validates a MAC address and returns it in canonical lower-case // colon-separated form. Empty input is accepted and returned as-is. func normalizeMAC(mac string) (string, error) {