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 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 08:57:58 +02:00
co-authored by Claude Sonnet 5
parent 78beedeabc
commit c135f205fa
10 changed files with 364 additions and 112 deletions
+94 -28
View File
@@ -31,26 +31,60 @@ mehr von Hand in `dnsmasq.d` gepflegt, sondern über das Tool
└─────────────────────┘ └─────────────────────┘
│ │
enp7s0 (LAN) enp7s0 (LAN)
10.0.0.1/24 10.0.0.1/22
│ │
┌─────────┴─────────┐ ┌───────────────┼───────────────┬───────────────┐
│ 10.0.0.0/24 │ │ │ │ │
│ DHCP: .100–.200 │ 10.0.0.0/24 10.0.1.0/24 10.0.2.0/24 10.0.3.0/24
│ Clients │ 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. - **WAN**: `enp3s0`, statische öffentliche IP, unverändert wie vorher.
- **LAN**: `enp7s0`, neu eingerichtet, `10.0.0.1/24`, Router = Gateway = DNS - **LAN**: `enp7s0`, `10.0.0.1/22` (seit 2026-08-21, davor `/24`), Router =
für alle Clients im Netz. 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 <name>` (ohne MAC) |
| `10.0.1.0/24` | **Feste DHCP-Reservierungen** — Geräte, die per DHCP immer dieselbe IP bekommen sollen | `ipadm -a <name> <mac>` |
| `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 ## Was wurde eingerichtet
| Bereich | Was | Datei auf dem Server | Kopie in diesem Repo | | 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) | | 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) | | 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) | | 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) | | 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 so restriktiv, damit auf der öffentlichen WAN-Seite kein offener
DNS-Resolver/DHCP-Server erreichbar ist (Missbrauchsrisiko, z.B. DNS-Resolver/DHCP-Server erreichbar ist (Missbrauchsrisiko, z.B.
DNS-Amplification). DNS-Amplification).
- `dhcp-range=10.0.0.100,10.0.0.200,255.255.255.0,12h`: DHCP-Pool, 12h Lease. - `dhcp-range=10.0.2.10,10.0.2.250,255.255.252.0,12h`: dynamischer DHCP-Pool
`.2`–`.99` bleiben frei für statische Reservierungen/Server, `.201`–`.254` in `10.0.2.0/24`, 12h Lease. Die Netzmaske `255.255.252.0` (`/22`) sagt
als Puffer. 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`: - `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. 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 - `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 gehärtet/eingeschränkt**, um die bestehende Erreichbarkeit des Servers
nicht zu verändern. nicht zu verändern.
- `table inet nat` / `chain postrouting`: NAT-Masquerading für Traffic aus - `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. tatsächlich nötig ist, damit LAN-Clients ins Internet kommen.
## Konfiguration anpassen ## Konfiguration anpassen
@@ -101,24 +136,33 @@ bleibt.
### DHCP-Pool / Lease-Zeit ändern ### 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` 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`) Immer über `ipadm`, **nicht** von Hand in `dnsmasq.d` eintragen — das Tool
ergänzen: kennt das [IP-Adressschema](#ip-adressschema) und vergibt/prüft die IP im
passenden Subnetz automatisch:
``` ```sh
dhcp-host=aa:bb:cc:dd:ee:ff,10.0.0.10,mein-server,infinite 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) ### 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` genutzten Dienste) explizit erlauben, bevor eine restriktive `policy drop`
auf `chain input` gesetzt wird – sonst sperrt man sich ggf. selbst aus. 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. 1. `/etc/network/interfaces`: `address` der `enp7s0`-Zeile anpassen.
2. `/etc/dnsmasq.d/lan.conf`: `dhcp-range` und beide `dhcp-option`-Zeilen 2. `/etc/dnsmasq.d/lan.conf`: `dhcp-range` und beide `dhcp-option`-Zeilen
(Router/DNS-IP) anpassen. (Router/DNS-IP) anpassen.
3. `/etc/nftables.conf`: `ip saddr 10.0.0.0/24` in der NAT-Regel anpassen. 3. `/etc/nftables.conf`: `ip saddr 10.0.0.0/22` in der NAT-Regel anpassen.
4. Anwenden: `ifreload -a` (oder `ifdown enp7s0 && ifup enp7s0`), 4. Falls sich die Aufteilung der vier `/24`-Zonen ändert (nicht nur die
`systemctl restart dnsmasq`, `nft -f /etc/nftables.conf`. 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 <neue-ip>/<prefix> 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 <name> <neue-ip>` einzeln
umziehen und mit `ipadm -u` anwenden.
## Befehle zum Anwenden von Änderungen ## Befehle zum Anwenden von Änderungen
```sh ```sh
# dnsmasq-Konfig auf Syntaxfehler prüfen, dann neu laden # dnsmasq-Konfig auf Syntaxfehler prüfen, dann neu laden
dnsmasq --test dnsmasq --test
systemctl restart dnsmasq systemctl restart dnsmasq # NICHT "reload" (SIGHUP) — siehe Hinweis unten
# nftables-Konfig auf Syntaxfehler prüfen, dann neu laden # nftables-Konfig auf Syntaxfehler prüfen, dann neu laden
nft -c -f /etc/nftables.conf nft -c -f /etc/nftables.conf
@@ -190,6 +247,15 @@ ifdown enp7s0 && ifup enp7s0
sysctl --system 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 ## Troubleshooting / nützliche Befehle
```sh ```sh
@@ -235,7 +301,7 @@ DHCPNAK(enp7s0) 10.0.0.151 <mac> wrong server-ID ← wir lehnen zu Recht ab
Client hat also ein Angebot von einer fremden Quelle angenommen. Gerät mit Client hat also ein Angebot von einer fremden Quelle angenommen. Gerät mit
aktiver Web-Verwaltung im LAN suchen (z.B. `curl -I http://<verdächtige-ip>/`) aktiver Web-Verwaltung im LAN suchen (z.B. `curl -I http://<verdächtige-ip>/`)
und dessen DHCP-Server deaktivieren / in Access-Point-Modus umschalten — 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 ## Sicherheitshinweise
+9 -2
View File
@@ -4,8 +4,15 @@ bind-interfaces
except-interface=enp3s0 except-interface=enp3s0
except-interface=lo except-interface=lo
# DHCP für 10.0.0.0/24 # DHCP für 10.0.0.0/22, aufgeteilt in vier /24er:
dhcp-range=10.0.0.100,10.0.0.200,255.255.255.0,12h # 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:router,10.0.0.1
dhcp-option=option:dns-server,10.0.0.1 dhcp-option=option:dns-server,10.0.0.1
dhcp-authoritative dhcp-authoritative
+5 -2
View File
@@ -16,7 +16,10 @@ iface enp3s0 inet static
dns-nameservers 141.14.128.1 dns-nameservers 141.14.128.1
dns-search fhi.mpg.de 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 allow-hotplug enp7s0
iface enp7s0 inet static iface enp7s0 inet static
address 10.0.0.1/24 address 10.0.0.1/22
+1 -1
View File
@@ -17,7 +17,7 @@ table inet filter {
table inet nat { table inet nat {
chain postrouting { chain postrouting {
type nat hook postrouting priority srcnat; 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
} }
} }
+68 -23
View File
@@ -8,6 +8,22 @@ statt isc-dhcp-server/bind9 (siehe [../../README.md](../../README.md)).
Installiert auf dem Server unter `/usr/local/bin/ipadm`. 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 ## Funktionsweise
- Pflegt eine flache Textdatei `/etc/ipadm/hosts` (eine Zeile pro Host: - Pflegt eine flache Textdatei `/etc/ipadm/hosts` (eine Zeile pro Host:
@@ -16,27 +32,38 @@ Installiert auf dem Server unter `/usr/local/bin/ipadm`.
(`wanport<TAB>proto<TAB>host<TAB>lanport`) für Port-Forwards. Ein (`wanport<TAB>proto<TAB>host<TAB>lanport`) für Port-Forwards. Ein
Port-Forward referenziert einen Hostnamen aus der Host-Datenbank statt Port-Forward referenziert einen Hostnamen aus der Host-Datenbank statt
einer festen IP, damit er automatisch der aktuellen IP des Hosts folgt. einer festen IP, damit er automatisch der aktuellen IP des Hosts folgt.
- `ipadm -u` generiert daraus zwei Dateien und reloadet die zugehörigen - `ipadm -u` generiert daraus zwei Dateien und wendet die zugehörigen
Dienste: Dienste neu an:
- `/etc/dnsmasq.d/hosts.conf` (pro Host ein `host-record=` für DNS, plus - `/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), `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` - `/etc/nftables.d/portforward.conf` (eine eigene `table inet portforward`
mit einer `dnat`-Regel pro Port-Forward, referenzierte Host-IP wird zum mit einer `dnat`-Regel pro Port-Forward, referenzierte Host-IP wird zum
Generierungszeitpunkt aus der Host-DB aufgelöst), validiert mit Generierungszeitpunkt aus der Host-DB aufgelöst), validiert mit
`nft -c -f /etc/nftables.conf` (dafür bindet `/etc/nftables.conf` bereits `nft -c -f /etc/nftables.conf` (dafür bindet `/etc/nftables.conf` bereits
`include "/etc/nftables.d/*.conf"` ein), danach `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 Schlägt die jeweilige Validierung fehl, wird die vorherige generierte
Datei automatisch wiederhergestellt und der betroffene Dienst **nicht** 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, Host, wird er beim Generieren übersprungen und als Warnung ausgegeben,
statt den ganzen Lauf abzubrechen. statt den ganzen Lauf abzubrechen.
- IP-Adressen werden bei `-a`/`-i`/im interaktiven Modus geprüft: müssen in - IP-Adressen werden bei `-a`/`-i`/im interaktiven Modus geprüft: müssen im
`10.0.0.0/24` liegen und **außerhalb** des dynamischen DHCP-Pools zur MAC-Adresse passenden Subnetz liegen (`10.0.0.0/24` ohne MAC,
`10.0.0.100–10.0.0.200` (verhindert Kollisionen mit dynamisch vergebenen `10.0.1.0/24` mit MAC) und dürfen weder die Netz-/Broadcast-Adresse noch
Adressen). 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; - Port-Forwards werden über `(wan-port, protokoll)` eindeutig identifiziert;
`both` (= tcp+udp in einer Regel via `meta l4proto { tcp, udp }`) kollidiert `both` (= tcp+udp in einer Regel via `meta l4proto { tcp, udp }`) kollidiert
dabei mit einem einzelnen `tcp`- oder `udp`-Eintrag auf demselben Port. 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 <hostname> add/edit host (interactive) ipadm <hostname> add/edit host (interactive)
ipadm -l list hosts ipadm -l list hosts
ipadm -a [-f] <hostname> <ip address> [mac address] add host ipadm -a [-f] <hostname> [ip address] [mac address] add host (IP auto-assigned if omitted)
ipadm -c <hostname> <comment> set comment ipadm -c <hostname> <comment> set comment
ipadm -i <hostname> <ip address> change ip address ipadm -i <hostname> <ip address> change ip address
ipadm -m <hostname> <mac address> change mac address ipadm -m <hostname> <mac address> 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.) (Output/messages of the tool itself are English; this README stays German like the rest of the repo.)
Beispiel: Beispiele:
```sh ```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 443 tcp # WAN-Port 443/tcp -> webserver:443
ipadm -pa webserver 8080 80 tcp # WAN-Port 8080/tcp -> webserver:80 ipadm -pa webserver 8080 80 tcp # WAN-Port 8080/tcp -> webserver:80
ipadm -u # anwenden (dnsmasq + nftables) 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 Reihenfolge angebbar (Default `lan-port` = `wan-port`, Default Protokoll
`tcp`) — das Tool erkennt selbst, ob ein Token eine Portnummer oder `tcp`) — das Tool erkennt selbst, ob ein Token eine Portnummer oder
`tcp`/`udp`/`both` ist. `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 ## Testen ohne die echte Server-Konfiguration anzufassen
Alle Pfade sind über Umgebungsvariablen überschreibbar: Alle Pfade und das IP-Adressschema sind über Umgebungsvariablen
überschreibbar:
```sh ```sh
export IPADM_DB=/tmp/test-hosts export IPADM_DB=/tmp/test-hosts
export IPADM_PORTFWD_DB=/tmp/test-portforwards export IPADM_PORTFWD_DB=/tmp/test-portforwards
export IPADM_DNSMASQ_HOSTS=/tmp/test-hosts.conf export IPADM_DNSMASQ_HOSTS=/tmp/test-hosts.conf
export IPADM_NFT_PORTFWD=/tmp/test-portforward.conf export IPADM_NFT_PORTFWD=/tmp/test-portforward.conf
export IPADM_LAN_CIDR=10.0.0.0/24 export IPADM_STATIC_CIDR=10.0.0.0/24
export IPADM_POOL_START=10.0.0.100 export IPADM_RESERVED_CIDR=10.0.1.0/24
export IPADM_POOL_END=10.0.0.200 export IPADM_GATEWAY_IP=10.0.0.1
export IPADM_WAN_IFACE=enp3s0 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 ./ipadm -l
``` ```
Achtung: `ipadm -u` ruft trotzdem `dnsmasq --test` bzw. Achtung: `ipadm -u` ruft trotzdem `dnsmasq --test` bzw.
`nft -c -f /etc/nftables.conf` auf (prüft die echte System-Konfiguration) `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 im Testmodus — das ist beabsichtigt (validiert, dass die generierte Datei
mit der echten Umgebung zusammenspielt), aber es reloadet die echten mit der echten Umgebung zusammenspielt), aber es wendet die echten
laufenden Dienste. `IPADM_NFT_PORTFWD` nur auf einen Pfad außerhalb von laufenden Dienste an (inkl. eines kurzen dnsmasq-Neustarts). `IPADM_NFT_PORTFWD`
`/etc/nftables.d/` zeigen lassen, sonst landet die Testdatei im echten nur auf einen Pfad außerhalb von `/etc/nftables.d/` zeigen lassen, sonst
Include und wird real aktiv. 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.
+10 -5
View File
@@ -35,7 +35,7 @@ func renderDnsmasqConfig(hosts []Host) string {
} }
// applyDnsmasqConfig writes the generated config to path, validates the full // 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 // validation failure the previous file content is restored and the service
// is left untouched. // is left untouched.
func applyDnsmasqConfig(path string, content string) error { 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) return fmt.Errorf("dnsmasq config invalid, change rolled back: %w", err)
} }
if err := reloadDnsmasq(); err != nil { if err := restartDnsmasq(); err != nil {
return fmt.Errorf("%s written, but reload failed: %w", path, err) return fmt.Errorf("%s written, but restart failed: %w", path, err)
} }
return nil return nil
} }
@@ -76,8 +76,13 @@ func runDnsmasqTest() error {
return nil return nil
} }
func reloadDnsmasq() error { // restartDnsmasq does a full restart rather than `systemctl reload`
out, err := exec.Command("systemctl", "reload", "dnsmasq").CombinedOutput() // (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 { if err != nil {
return fmt.Errorf("%s", strings.TrimSpace(string(out))) return fmt.Errorf("%s", strings.TrimSpace(string(out)))
} }
+25
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"net"
"sort" "sort"
"strings" "strings"
) )
@@ -82,3 +83,27 @@ func findByMAC(hosts []Host, mac string, exceptIdx int) int {
} }
return -1 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
}
+21 -11
View File
@@ -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) fmt.Printf("Editing existing host %q (press enter to keep a value)\n", name)
} else { } else {
cur = Host{Name: name} cur = Host{Name: name}
fmt.Printf("New host %q (press enter to leave MAC/comment empty)\n", name) fmt.Printf("New host %q (press enter to accept defaults)\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
} }
// 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 { for {
mac := prompt("MAC address (optional, '-' to clear)", macOrDash(cur.MAC)) mac := prompt("MAC address (optional, '-' to clear)", macOrDash(cur.MAC))
if mac == "-" { if mac == "-" {
@@ -67,6 +59,24 @@ func interactiveEdit(hosts []Host, name string, cfg netConfig) ([]Host, error) {
break 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) cur.Comment = prompt("Comment", cur.Comment)
if other := findByIP(hosts, cur.IP, idx); other >= 0 { if other := findByIP(hosts, cur.IP, idx); other >= 0 {
+66 -25
View File
@@ -14,7 +14,7 @@ import (
"text/tabwriter" "text/tabwriter"
) )
const version = "1.1.0" const version = "1.2.0"
type usageRow struct { type usageRow struct {
left string left string
@@ -25,7 +25,7 @@ type usageRow struct {
var usageRows = []usageRow{ var usageRows = []usageRow{
{"ipadm <hostname>", "add/edit host (interactive)", false}, {"ipadm <hostname>", "add/edit host (interactive)", false},
{"ipadm -l", "list hosts", false}, {"ipadm -l", "list hosts", false},
{"ipadm -a [-f] <hostname> <ip address> [mac address]", "add host", false}, {"ipadm -a [-f] <hostname> [ip address] [mac address]", "add host (IP auto-assigned if omitted)", false},
{"ipadm -c <hostname> <comment>", "set comment", false}, {"ipadm -c <hostname> <comment>", "set comment", false},
{"ipadm -i <hostname> <ip address>", "change ip address", false}, {"ipadm -i <hostname> <ip address>", "change ip address", false},
{"ipadm -m <hostname> <mac address>", "change mac address", false}, {"ipadm -m <hostname> <mac address>", "change mac address", false},
@@ -34,7 +34,7 @@ var usageRows = []usageRow{
{"ipadm -pa [-f] <hostname> <wan-port> [lan-port] [tcp|udp|both]", "add port-forward to a known host", true}, {"ipadm -pa [-f] <hostname> <wan-port> [lan-port] [tcp|udp|both]", "add port-forward to a known host", true},
{"ipadm -pl", "list port-forwards", false}, {"ipadm -pl", "list port-forwards", false},
{"ipadm -pd <wan-port> [tcp|udp|both]", "delete port-forward", false}, {"ipadm -pd <wan-port> [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}, {"ipadm -h", "this help", false},
} }
@@ -73,11 +73,25 @@ var usage = buildUsage()
// netConfig describes the LAN/WAN the tool validates against. Overridable // netConfig describes the LAN/WAN the tool validates against. Overridable
// via env vars, mainly so this can be tested without touching the real // via env vars, mainly so this can be tested without touching the real
// /etc files. // /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 { type netConfig struct {
lanCIDR string staticCIDR string
poolStart string reservedCIDR string
poolEnd string gatewayIP string
wanIface 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 { func envOr(key, def string) string {
@@ -105,10 +119,10 @@ func nftPortFwdPath() string {
func loadNetConfig() netConfig { func loadNetConfig() netConfig {
return netConfig{ return netConfig{
lanCIDR: envOr("IPADM_LAN_CIDR", "10.0.0.0/24"), staticCIDR: envOr("IPADM_STATIC_CIDR", "10.0.0.0/24"),
poolStart: envOr("IPADM_POOL_START", "10.0.0.100"), reservedCIDR: envOr("IPADM_RESERVED_CIDR", "10.0.1.0/24"),
poolEnd: envOr("IPADM_POOL_END", "10.0.0.200"), gatewayIP: envOr("IPADM_GATEWAY_IP", "10.0.0.1"),
wanIface: envOr("IPADM_WAN_IFACE", "enp3s0"), wanIface: envOr("IPADM_WAN_IFACE", "enp3s0"),
} }
} }
@@ -276,13 +290,13 @@ func cmdAdd(args []string) {
force = true force = true
args = args[1:] args = args[1:]
} }
if len(args) < 2 || len(args) > 3 { if len(args) < 1 || len(args) > 3 {
fail("usage: ipadm -a [-f] <hostname> <ip address> [mac address]") fail("usage: ipadm -a [-f] <hostname> [ip address] [mac address]")
} }
name, ip := args[0], args[1] name := args[0]
mac := "" ip, mac, err := classifyHostArgs(args[1:])
if len(args) == 3 { if err != nil {
mac = args[2] fail("%s", err)
} }
cfg := loadNetConfig() cfg := loadNetConfig()
@@ -290,9 +304,6 @@ func cmdAdd(args []string) {
if err := validateHostname(name); err != nil { if err := validateHostname(name); err != nil {
return nil, "", err return nil, "", err
} }
if err := validateIP(ip, cfg.lanCIDR, cfg.poolStart, cfg.poolEnd); err != nil {
return nil, "", err
}
normMac, err := normalizeMAC(mac) normMac, err := normalizeMAC(mac)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
@@ -302,6 +313,18 @@ func cmdAdd(args []string) {
if idx >= 0 && !force { if idx >= 0 && !force {
return nil, "", fmt.Errorf("host %q already exists (use -f to overwrite, or `ipadm %s` to edit it)", name, name) 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 { if other := findByIP(hosts, ip, idx); other >= 0 {
return nil, "", fmt.Errorf("IP %s is already assigned to host %q", ip, hosts[other].Name) 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} h := Host{Name: name, IP: ip, MAC: normMac}
if idx >= 0 { if idx >= 0 {
hosts[idx] = h 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) 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 { if idx < 0 {
return nil, "", fmt.Errorf("host %q not found", name) 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 return nil, "", err
} }
if other := findByIP(hosts, ip, idx); other >= 0 { if other := findByIP(hosts, ip, idx); other >= 0 {
@@ -365,6 +389,7 @@ func cmdChangeMAC(args []string) {
if mac == "-" { if mac == "-" {
mac = "" mac = ""
} }
cfg := loadNetConfig()
withStore(func(hosts []Host) ([]Host, string, error) { withStore(func(hosts []Host) ([]Host, string, error) {
idx := findHost(hosts, name) idx := findHost(hosts, name)
if idx < 0 { if idx < 0 {
@@ -377,12 +402,28 @@ func cmdChangeMAC(args []string) {
if other := findByMAC(hosts, normMac, idx); other >= 0 { if other := findByMAC(hosts, normMac, idx); other >= 0 {
return nil, "", fmt.Errorf("MAC %s is already assigned to host %q", normMac, hosts[other].Name) return nil, "", fmt.Errorf("MAC %s is already assigned to host %q", normMac, hosts[other].Name)
} }
hosts[idx].MAC = normMac hosts[idx].MAC = normMac
label := normMac label := normMac
if label == "" { if label == "" {
label = "(removed)" 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 { if err := applyDnsmasqConfig(dnsmasqHostsPath(), dnsContent); err != nil {
fail("%s", err) 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()) fs, err := openPortFwdStore(portFwdDBPath())
if err != nil { if err != nil {
+65 -15
View File
@@ -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]) 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 func uint32ToIP4(v uint32) net.IP {
// the dynamic DHCP pool [poolStart, poolEnd] (both inclusive), so static return net.IPv4(byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
// reservations can never collide with dynamically leased addresses. }
func validateIP(ip, lanCIDR, poolStart, poolEnd string) error {
// 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) parsed := net.ParseIP(ip)
if parsed == nil || parsed.To4() == nil { if parsed == nil || parsed.To4() == nil {
return fmt.Errorf("invalid IPv4 address: %q", ip) return fmt.Errorf("invalid IPv4 address: %q", ip)
} }
_, cidr, err := net.ParseCIDR(lanCIDR) _, network, err := net.ParseCIDR(cidr)
if err != nil { 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) { if !network.Contains(parsed) {
return fmt.Errorf("IP %s is not inside LAN network %s", ip, lanCIDR) return fmt.Errorf("IP %s is not inside network %s", ip, cidr)
} }
start := net.ParseIP(poolStart) if isNetworkOrBroadcast(parsed, network) {
end := net.ParseIP(poolEnd) return fmt.Errorf("IP %s is the network or broadcast address of %s and cannot be assigned", ip, cidr)
if start == nil || end == nil {
return fmt.Errorf("internal error: invalid DHCP pool %q-%q", poolStart, poolEnd)
} }
v, s, e := ip4ToUint32(parsed), ip4ToUint32(start), ip4ToUint32(end) for _, ex := range excluded {
if v >= s && v <= e { if parsed.Equal(net.ParseIP(ex)) {
return fmt.Errorf("IP %s is inside the dynamic DHCP pool (%s-%s) and is reserved, not allowed for static assignments", ip, poolStart, poolEnd) return fmt.Errorf("IP %s is reserved (router/gateway address) and cannot be assigned", ip)
}
} }
return nil 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 // normalizeMAC validates a MAC address and returns it in canonical lower-case
// colon-separated form. Empty input is accepted and returned as-is. // colon-separated form. Empty input is accepted and returned as-is.
func normalizeMAC(mac string) (string, error) { func normalizeMAC(mac string) (string, error) {