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
+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`.
## IP-Adressschema
Das LAN ist ein `10.0.0.0/22`, aufgeteilt in vier `/24`-Zonen (Details siehe
[../../README.md#ip-adressschema](../../README.md#ip-adressschema)):
- `10.0.0.0/24` — statische Adressen (kein DHCP, nur DNS), für Hosts **ohne**
MAC-Adresse in der ipadm-Datenbank.
- `10.0.1.0/24` — feste DHCP-Reservierungen (per MAC), für Hosts **mit**
MAC-Adresse in der ipadm-Datenbank.
- `10.0.2.0/24` — dynamischer DHCP-Pool (nicht von ipadm verwaltet).
- `10.0.3.0/24` — Reserve, unbenutzt.
`ipadm` wendet diese Regel bei jeder IP-Zuweisung automatisch an: welches
Subnetz erlaubt ist, entscheidet allein, ob der Host eine MAC-Adresse hat
oder nicht — nicht der Nutzer. Details siehe unten.
## Funktionsweise
- Pflegt eine flache Textdatei `/etc/ipadm/hosts` (eine Zeile pro Host:
@@ -16,27 +32,38 @@ Installiert auf dem Server unter `/usr/local/bin/ipadm`.
(`wanport<TAB>proto<TAB>host<TAB>lanport`) für Port-Forwards. Ein
Port-Forward referenziert einen Hostnamen aus der Host-Datenbank statt
einer festen IP, damit er automatisch der aktuellen IP des Hosts folgt.
- `ipadm -u` generiert daraus zwei Dateien und reloadet die zugehörigen
Dienste:
- `ipadm -u` generiert daraus zwei Dateien und wendet die zugehörigen
Dienste neu an:
- `/etc/dnsmasq.d/hosts.conf` (pro Host ein `host-record=` für DNS, plus
`dhcp-host=` für Hosts mit MAC-Adresse für die DHCP-Reservierung),
validiert mit `dnsmasq --test`, danach `systemctl reload dnsmasq`.
validiert mit `dnsmasq --test`, danach `systemctl restart dnsmasq`.
**Bewusst `restart`, nicht `reload`**: dnsmasqs SIGHUP-Handler liest nur
`/etc/hosts` neu, nicht die `conf-dir`-Dateien, in denen `hosts.conf`
liegt — ein reines `reload` würde die Änderung nicht anwenden.
- `/etc/nftables.d/portforward.conf` (eine eigene `table inet portforward`
mit einer `dnat`-Regel pro Port-Forward, referenzierte Host-IP wird zum
Generierungszeitpunkt aus der Host-DB aufgelöst), validiert mit
`nft -c -f /etc/nftables.conf` (dafür bindet `/etc/nftables.conf` bereits
`include "/etc/nftables.d/*.conf"` ein), danach
`systemctl reload nftables`.
`systemctl reload nftables` (dessen `reload` liest die komplette Config
neu ein, das ist hier unproblematisch).
Schlägt die jeweilige Validierung fehl, wird die vorherige generierte
Datei automatisch wiederhergestellt und der betroffene Dienst **nicht**
neu geladen. Referenziert ein Port-Forward einen mittlerweile gelöschten
neu angewendet. Referenziert ein Port-Forward einen mittlerweile gelöschten
Host, wird er beim Generieren übersprungen und als Warnung ausgegeben,
statt den ganzen Lauf abzubrechen.
- IP-Adressen werden bei `-a`/`-i`/im interaktiven Modus geprüft: müssen in
`10.0.0.0/24` liegen und **außerhalb** des dynamischen DHCP-Pools
`10.0.0.100–10.0.0.200` (verhindert Kollisionen mit dynamisch vergebenen
Adressen).
- IP-Adressen werden bei `-a`/`-i`/im interaktiven Modus geprüft: müssen im
zur MAC-Adresse passenden Subnetz liegen (`10.0.0.0/24` ohne MAC,
`10.0.1.0/24` mit MAC) und dürfen weder die Netz-/Broadcast-Adresse noch
die Gateway-Adresse `10.0.0.1` sein.
- Wird bei `-a` keine IP angegeben, vergibt `ipadm` automatisch die nächste
freie IP im passenden Subnetz.
- Wird bei `-m` eine MAC-Adresse hinzugefügt oder entfernt und der Host
liegt dadurch im falschen Subnetz, verschiebt `ipadm` ihn automatisch auf
eine freie IP im jetzt passenden Subnetz (mit Hinweis in der Ausgabe).
`-i` dagegen validiert nur — eine manuell angegebene IP muss bereits zum
(unveränderten) MAC-Status des Hosts passen.
- Port-Forwards werden über `(wan-port, protokoll)` eindeutig identifiziert;
`both` (= tcp+udp in einer Regel via `meta l4proto { tcp, udp }`) kollidiert
dabei mit einem einzelnen `tcp`- oder `udp`-Eintrag auf demselben Port.
@@ -46,7 +73,7 @@ Installiert auf dem Server unter `/usr/local/bin/ipadm`.
```
ipadm <hostname> add/edit host (interactive)
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 -i <hostname> <ip address> change ip 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.)
Beispiel:
Beispiele:
```sh
ipadm -a webserver 10.0.0.10 # Host anlegen (falls noch nicht vorhanden)
ipadm -a webserver # Host ohne MAC anlegen, IP aus 10.0.0.0/24 auto-vergeben
ipadm -a heizung aa:bb:cc:dd:ee:ff # Host mit MAC anlegen, IP aus 10.0.1.0/24 auto-vergeben
ipadm -a fixip 10.0.0.42 # Host mit expliziter IP (muss zum Subnetz passen)
ipadm -pa webserver 443 tcp # WAN-Port 443/tcp -> webserver:443
ipadm -pa webserver 8080 80 tcp # WAN-Port 8080/tcp -> webserver:80
ipadm -u # anwenden (dnsmasq + nftables)
```
`lan-port` und Protokoll bei `-pa` sind optional und in beliebiger
Bei `-a` sind IP-Adresse und MAC-Adresse optional und in beliebiger
Reihenfolge angebbar — das Tool erkennt selbst, welches Token eine IP und
welches eine MAC ist. Fehlt die IP, wird automatisch die nächste freie IP im
zur MAC passenden Subnetz vergeben.
`lan-port` und Protokoll bei `-pa` sind optional und ebenfalls in beliebiger
Reihenfolge angebbar (Default `lan-port` = `wan-port`, Default Protokoll
`tcp`) — das Tool erkennt selbst, ob ein Token eine Portnummer oder
`tcp`/`udp`/`both` ist.
@@ -91,26 +125,37 @@ install -m 0755 -o root -g root ipadm /usr/local/bin/ipadm
## Testen ohne die echte Server-Konfiguration anzufassen
Alle Pfade sind über Umgebungsvariablen überschreibbar:
Alle Pfade und das IP-Adressschema sind über Umgebungsvariablen
überschreibbar:
```sh
export IPADM_DB=/tmp/test-hosts
export IPADM_PORTFWD_DB=/tmp/test-portforwards
export IPADM_DNSMASQ_HOSTS=/tmp/test-hosts.conf
export IPADM_NFT_PORTFWD=/tmp/test-portforward.conf
export IPADM_LAN_CIDR=10.0.0.0/24
export IPADM_POOL_START=10.0.0.100
export IPADM_POOL_END=10.0.0.200
export IPADM_STATIC_CIDR=10.0.0.0/24
export IPADM_RESERVED_CIDR=10.0.1.0/24
export IPADM_GATEWAY_IP=10.0.0.1
export IPADM_WAN_IFACE=enp3s0
./ipadm -a testhost 10.0.0.50 aa:bb:cc:dd:ee:ff
./ipadm -a testhost aa:bb:cc:dd:ee:ff
./ipadm -l
```
Achtung: `ipadm -u` ruft trotzdem `dnsmasq --test` bzw.
`nft -c -f /etc/nftables.conf` auf (prüft die echte System-Konfiguration)
und bei Erfolg `systemctl reload dnsmasq`/`systemctl reload nftables`, auch
und bei Erfolg `systemctl restart dnsmasq`/`systemctl reload nftables`, auch
im Testmodus — das ist beabsichtigt (validiert, dass die generierte Datei
mit der echten Umgebung zusammenspielt), aber es reloadet die echten
laufenden Dienste. `IPADM_NFT_PORTFWD` nur auf einen Pfad außerhalb von
`/etc/nftables.d/` zeigen lassen, sonst landet die Testdatei im echten
Include und wird real aktiv.
mit der echten Umgebung zusammenspielt), aber es wendet die echten
laufenden Dienste an (inkl. eines kurzen dnsmasq-Neustarts). `IPADM_NFT_PORTFWD`
nur auf einen Pfad außerhalb von `/etc/nftables.d/` zeigen lassen, sonst
landet die Testdatei im echten Include und wird real aktiv.
## Änderungshistorie (Kurzfassung)
- **v1.2.0** (2026-08-21): LAN auf `10.0.0.0/22` erweitert, Subnetz-Zonen
eingeführt (`IPADM_STATIC_CIDR`/`IPADM_RESERVED_CIDR` statt
`IPADM_LAN_CIDR`/`IPADM_POOL_START`/`IPADM_POOL_END`), automatische
IP-Vergabe bei `-a`, automatisches Umziehen bei MAC-Änderung (`-m`),
dnsmasq-Anwendung von `reload` auf `restart` korrigiert (siehe oben).
- **v1.1.0**: Port-Forwarding-Verwaltung (`-pa`/`-pl`/`-pd`) ergänzt,
komplett auf Englisch übersetzt, Hilfetext-Ausrichtung korrigiert.
+10 -5
View File
@@ -35,7 +35,7 @@ func renderDnsmasqConfig(hosts []Host) string {
}
// applyDnsmasqConfig writes the generated config to path, validates the full
// dnsmasq configuration, and on success reloads the dnsmasq service. On
// dnsmasq configuration, and on success restarts the dnsmasq service. On
// validation failure the previous file content is restored and the service
// is left untouched.
func applyDnsmasqConfig(path string, content string) error {
@@ -62,8 +62,8 @@ func applyDnsmasqConfig(path string, content string) error {
return fmt.Errorf("dnsmasq config invalid, change rolled back: %w", err)
}
if err := reloadDnsmasq(); err != nil {
return fmt.Errorf("%s written, but reload failed: %w", path, err)
if err := restartDnsmasq(); err != nil {
return fmt.Errorf("%s written, but restart failed: %w", path, err)
}
return nil
}
@@ -76,8 +76,13 @@ func runDnsmasqTest() error {
return nil
}
func reloadDnsmasq() error {
out, err := exec.Command("systemctl", "reload", "dnsmasq").CombinedOutput()
// restartDnsmasq does a full restart rather than `systemctl reload`
// (SIGHUP): dnsmasq's SIGHUP handler only re-reads /etc/hosts and a few
// specific hosts-file options, not the conf-dir files this generated
// hosts.conf lives in, so a reload would silently keep serving the old
// host-record/dhcp-host entries.
func restartDnsmasq() error {
out, err := exec.Command("systemctl", "restart", "dnsmasq").CombinedOutput()
if err != nil {
return fmt.Errorf("%s", strings.TrimSpace(string(out)))
}
+25
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"net"
"sort"
"strings"
)
@@ -82,3 +83,27 @@ func findByMAC(hosts []Host, mac string, exceptIdx int) int {
}
return -1
}
// classifyHostArgs sorts the optional trailing arguments of `ipadm -a` (ip
// address and/or mac address, in any order, either of which may be omitted)
// into their fields. Returns an error on unrecognized or duplicate tokens.
func classifyHostArgs(args []string) (ip, mac string, err error) {
for _, a := range args {
if _, macErr := net.ParseMAC(a); macErr == nil {
if mac != "" {
return "", "", fmt.Errorf("MAC address given more than once: %q", a)
}
mac = a
continue
}
if parsed := net.ParseIP(a); parsed != nil && parsed.To4() != nil {
if ip != "" {
return "", "", fmt.Errorf("IP address given more than once: %q", a)
}
ip = a
continue
}
return "", "", fmt.Errorf("unrecognized argument %q (expected IP address or MAC address)", a)
}
return ip, mac, nil
}
+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)
} else {
cur = Host{Name: name}
fmt.Printf("New host %q (press enter to leave MAC/comment empty)\n", name)
}
for {
ip := prompt("IP address", cur.IP)
if err := validateIP(ip, cfg.lanCIDR, cfg.poolStart, cfg.poolEnd); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
continue
}
cur.IP = ip
break
fmt.Printf("New host %q (press enter to accept defaults)\n", name)
}
// MAC decides which subnet the host belongs in (see netConfig), so it's
// asked first and used to suggest a matching free IP below.
for {
mac := prompt("MAC address (optional, '-' to clear)", macOrDash(cur.MAC))
if mac == "-" {
@@ -67,6 +59,24 @@ func interactiveEdit(hosts []Host, name string, cfg netConfig) ([]Host, error) {
break
}
cidr := cfg.cidrFor(cur.MAC)
suggested := cur.IP
if suggested == "" || validateIP(suggested, cidr, cfg.gatewayIP) != nil {
if freeIP, err := nextFreeIP(hosts, cidr, idx, cfg.gatewayIP); err == nil {
suggested = freeIP
}
}
for {
ip := prompt(fmt.Sprintf("IP address (%s)", cidr), suggested)
if err := validateIP(ip, cidr, cfg.gatewayIP); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
continue
}
cur.IP = ip
break
}
cur.Comment = prompt("Comment", cur.Comment)
if other := findByIP(hosts, cur.IP, idx); other >= 0 {
+66 -25
View File
@@ -14,7 +14,7 @@ import (
"text/tabwriter"
)
const version = "1.1.0"
const version = "1.2.0"
type usageRow struct {
left string
@@ -25,7 +25,7 @@ type usageRow struct {
var usageRows = []usageRow{
{"ipadm <hostname>", "add/edit host (interactive)", 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 -i <hostname> <ip address>", "change ip 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 -pl", "list port-forwards", 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},
}
@@ -73,11 +73,25 @@ var usage = buildUsage()
// netConfig describes the LAN/WAN the tool validates against. Overridable
// via env vars, mainly so this can be tested without touching the real
// /etc files.
//
// The LAN is a /22 split into four /24s: staticCIDR for hosts without a MAC
// (manually configured on the device, DNS only), reservedCIDR for hosts with
// a MAC (DHCP static reservation), plus a dynamic DHCP pool and a reserved,
// currently unused /24 that ipadm doesn't need to know about.
type netConfig struct {
lanCIDR string
poolStart string
poolEnd string
wanIface string
staticCIDR string
reservedCIDR string
gatewayIP string
wanIface string
}
// cidrFor returns the subnet a host with the given MAC (possibly empty)
// belongs in.
func (c netConfig) cidrFor(mac string) string {
if mac == "" {
return c.staticCIDR
}
return c.reservedCIDR
}
func envOr(key, def string) string {
@@ -105,10 +119,10 @@ func nftPortFwdPath() string {
func loadNetConfig() netConfig {
return netConfig{
lanCIDR: envOr("IPADM_LAN_CIDR", "10.0.0.0/24"),
poolStart: envOr("IPADM_POOL_START", "10.0.0.100"),
poolEnd: envOr("IPADM_POOL_END", "10.0.0.200"),
wanIface: envOr("IPADM_WAN_IFACE", "enp3s0"),
staticCIDR: envOr("IPADM_STATIC_CIDR", "10.0.0.0/24"),
reservedCIDR: envOr("IPADM_RESERVED_CIDR", "10.0.1.0/24"),
gatewayIP: envOr("IPADM_GATEWAY_IP", "10.0.0.1"),
wanIface: envOr("IPADM_WAN_IFACE", "enp3s0"),
}
}
@@ -276,13 +290,13 @@ func cmdAdd(args []string) {
force = true
args = args[1:]
}
if len(args) < 2 || len(args) > 3 {
fail("usage: ipadm -a [-f] <hostname> <ip address> [mac address]")
if len(args) < 1 || len(args) > 3 {
fail("usage: ipadm -a [-f] <hostname> [ip address] [mac address]")
}
name, ip := args[0], args[1]
mac := ""
if len(args) == 3 {
mac = args[2]
name := args[0]
ip, mac, err := classifyHostArgs(args[1:])
if err != nil {
fail("%s", err)
}
cfg := loadNetConfig()
@@ -290,9 +304,6 @@ func cmdAdd(args []string) {
if err := validateHostname(name); err != nil {
return nil, "", err
}
if err := validateIP(ip, cfg.lanCIDR, cfg.poolStart, cfg.poolEnd); err != nil {
return nil, "", err
}
normMac, err := normalizeMAC(mac)
if err != nil {
return nil, "", err
@@ -302,6 +313,18 @@ func cmdAdd(args []string) {
if idx >= 0 && !force {
return nil, "", fmt.Errorf("host %q already exists (use -f to overwrite, or `ipadm %s` to edit it)", name, name)
}
cidr := cfg.cidrFor(normMac)
if ip == "" {
freeIP, err := nextFreeIP(hosts, cidr, idx, cfg.gatewayIP)
if err != nil {
return nil, "", err
}
ip = freeIP
} else if err := validateIP(ip, cidr, cfg.gatewayIP); err != nil {
return nil, "", err
}
if other := findByIP(hosts, ip, idx); other >= 0 {
return nil, "", fmt.Errorf("IP %s is already assigned to host %q", ip, hosts[other].Name)
}
@@ -312,10 +335,10 @@ func cmdAdd(args []string) {
h := Host{Name: name, IP: ip, MAC: normMac}
if idx >= 0 {
hosts[idx] = h
return hosts, fmt.Sprintf("Host %q overwritten (-f).", name), nil
return hosts, fmt.Sprintf("Host %q overwritten (-f), IP %s.", name, ip), nil
}
hosts = append(hosts, h)
return hosts, fmt.Sprintf("Host %q added.", name), nil
return hosts, fmt.Sprintf("Host %q added with IP %s.", name, ip), nil
})
}
@@ -346,7 +369,8 @@ func cmdChangeIP(args []string) {
if idx < 0 {
return nil, "", fmt.Errorf("host %q not found", name)
}
if err := validateIP(ip, cfg.lanCIDR, cfg.poolStart, cfg.poolEnd); err != nil {
cidr := cfg.cidrFor(hosts[idx].MAC)
if err := validateIP(ip, cidr, cfg.gatewayIP); err != nil {
return nil, "", err
}
if other := findByIP(hosts, ip, idx); other >= 0 {
@@ -365,6 +389,7 @@ func cmdChangeMAC(args []string) {
if mac == "-" {
mac = ""
}
cfg := loadNetConfig()
withStore(func(hosts []Host) ([]Host, string, error) {
idx := findHost(hosts, name)
if idx < 0 {
@@ -377,12 +402,28 @@ func cmdChangeMAC(args []string) {
if other := findByMAC(hosts, normMac, idx); other >= 0 {
return nil, "", fmt.Errorf("MAC %s is already assigned to host %q", normMac, hosts[other].Name)
}
hosts[idx].MAC = normMac
label := normMac
if label == "" {
label = "(removed)"
}
return hosts, fmt.Sprintf("MAC of %q changed to %s.", name, label), nil
summary := fmt.Sprintf("MAC of %q changed to %s.", name, label)
// The subnet a host belongs in depends on whether it has a MAC
// (see netConfig). If that changed, move it to a free IP in the
// now-correct subnet instead of leaving it in the wrong one.
targetCIDR := cfg.cidrFor(normMac)
if validateIP(hosts[idx].IP, targetCIDR, cfg.gatewayIP) != nil {
newIP, err := nextFreeIP(hosts, targetCIDR, idx, cfg.gatewayIP)
if err != nil {
return nil, "", fmt.Errorf("MAC changed, but host must move to %s and no free IP is left there: %w", targetCIDR, err)
}
oldIP := hosts[idx].IP
hosts[idx].IP = newIP
summary += fmt.Sprintf(" Host moved from %s to %s (subnet %s).", oldIP, newIP, targetCIDR)
}
return hosts, summary, nil
})
}
@@ -566,7 +607,7 @@ func cmdUpdate() {
if err := applyDnsmasqConfig(dnsmasqHostsPath(), dnsContent); err != nil {
fail("%s", err)
}
fmt.Printf("%s updated, dnsmasq reloaded (%d hosts).\n", dnsmasqHostsPath(), len(hosts))
fmt.Printf("%s updated, dnsmasq restarted (%d hosts).\n", dnsmasqHostsPath(), len(hosts))
fs, err := openPortFwdStore(portFwdDBPath())
if err != nil {
+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])
}
// validateIP checks that ip is a valid IPv4 address inside lanCIDR and outside
// the dynamic DHCP pool [poolStart, poolEnd] (both inclusive), so static
// reservations can never collide with dynamically leased addresses.
func validateIP(ip, lanCIDR, poolStart, poolEnd string) error {
func uint32ToIP4(v uint32) net.IP {
return net.IPv4(byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
}
// validateIP checks that ip is a valid IPv4 address inside cidr, is neither
// the network nor the broadcast address of cidr, and is not one of the
// explicitly excluded addresses (e.g. the router's own gateway IP).
func validateIP(ip, cidr string, excluded ...string) error {
parsed := net.ParseIP(ip)
if parsed == nil || parsed.To4() == nil {
return fmt.Errorf("invalid IPv4 address: %q", ip)
}
_, cidr, err := net.ParseCIDR(lanCIDR)
_, network, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("internal error: invalid LAN CIDR %q: %w", lanCIDR, err)
return fmt.Errorf("internal error: invalid network %q: %w", cidr, err)
}
if !cidr.Contains(parsed) {
return fmt.Errorf("IP %s is not inside LAN network %s", ip, lanCIDR)
if !network.Contains(parsed) {
return fmt.Errorf("IP %s is not inside network %s", ip, cidr)
}
start := net.ParseIP(poolStart)
end := net.ParseIP(poolEnd)
if start == nil || end == nil {
return fmt.Errorf("internal error: invalid DHCP pool %q-%q", poolStart, poolEnd)
if isNetworkOrBroadcast(parsed, network) {
return fmt.Errorf("IP %s is the network or broadcast address of %s and cannot be assigned", ip, cidr)
}
v, s, e := ip4ToUint32(parsed), ip4ToUint32(start), ip4ToUint32(end)
if v >= s && v <= e {
return fmt.Errorf("IP %s is inside the dynamic DHCP pool (%s-%s) and is reserved, not allowed for static assignments", ip, poolStart, poolEnd)
for _, ex := range excluded {
if parsed.Equal(net.ParseIP(ex)) {
return fmt.Errorf("IP %s is reserved (router/gateway address) and cannot be assigned", ip)
}
}
return nil
}
func isNetworkOrBroadcast(ip net.IP, network *net.IPNet) bool {
ip4 := ip.To4()
mask := network.Mask
netAddr := ip4.Mask(mask)
broadcast := make(net.IP, len(netAddr))
for i := range netAddr {
broadcast[i] = netAddr[i] | ^mask[i]
}
return ip4.Equal(netAddr) || ip4.Equal(broadcast)
}
// nextFreeIP returns the first address in cidr (ascending order) that is not
// the network or broadcast address, not in excluded, and not already used by
// a host other than exceptIdx (-1 to not exempt any host).
func nextFreeIP(hosts []Host, cidr string, exceptIdx int, excluded ...string) (string, error) {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
return "", fmt.Errorf("internal error: invalid network %q: %w", cidr, err)
}
ones, bits := network.Mask.Size()
size := uint32(1) << uint32(bits-ones)
if size < 2 {
return "", fmt.Errorf("internal error: network %q too small", cidr)
}
start := ip4ToUint32(network.IP.To4())
for i := uint32(1); i < size-1; i++ {
candidate := uint32ToIP4(start + i).String()
if containsIP(excluded, candidate) {
continue
}
if findByIP(hosts, candidate, exceptIdx) >= 0 {
continue
}
return candidate, nil
}
return "", fmt.Errorf("no free IP address left in %s", cidr)
}
func containsIP(ips []string, ip string) bool {
for _, v := range ips {
if v == ip {
return true
}
}
return false
}
// normalizeMAC validates a MAC address and returns it in canonical lower-case
// colon-separated form. Empty input is accepted and returned as-is.
func normalizeMAC(mac string) (string, error) {