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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
+64
-23
@@ -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,13 +73,27 @@ 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 {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
@@ -105,9 +119,9 @@ 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
@@ -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)
|
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)
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user