From a1fb5f5a8f98dc0fd4062f506430739611971f71 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 26 Jul 2026 23:16:52 +0200 Subject: [PATCH] v0.5.40-beta - netwerken instellen zonder uitzoekwerk - Automatische detectie van het hostnetwerk: interface, subnet, gateway en een voorgestelde vrije IP-range worden bovenaan het formulier getoond en vullen met een klik alles in. - Detectie gebeurt via een kortstondige container in de netwerknamespace van de host, die de routetabel uitleest. Dat repareert meteen een fout: de vorige host_interfaces() las /sys/class/net binnen de container en toonde dus de bridge-interface van de container in plaats van de netwerkkaart van de host. - Live beoordeling terwijl je typt (nieuwe endpoint /api/networks/review): laat zien hoeveel adressen je overhoudt en welke, en legt in gewone taal uit wat er niet klopt. Onderscheid tussen blokkerende fouten en waarschuwingen. - Knop die een vrij blok voorstelt op basis van het subnet, met de gateway ontweken. - Uitklapbare uitleg in het formulier over hoe subnet, gateway, IP-range en host-interface samenhangen, met voorbeeld. - docs/netwerken.md uitgebreid met een schema van de adresverdeling, een tabel met veelgebruikte ranges, en de nadruk op het punt dat het instellen van de range in Server Up niets aan de router doet: daar moet het DHCP-bereik zelf verkleind worden. - Bijgesteld tijdens het testen: een gateway binnen het subnet is de normale situatie en gold ten onrechte als fout wanneer er geen eigen range was opgegeven. Nu alleen een waarschuwing bij een expliciete range. - 248 tests (21 nieuwe). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb --- CHANGELOG.md | 39 +++++++ VERSION | 2 +- docs/netwerken.md | 62 ++++++++-- server-up/app.py | 23 +++- server-up/core/networks.py | 206 +++++++++++++++++++++++++++++++-- server-up/templates/index.html | 154 ++++++++++++++++++++---- server-up/translations/en.json | 14 ++- server-up/translations/nl.json | 14 ++- tests/test_network_detect.py | 163 ++++++++++++++++++++++++++ 9 files changed, 631 insertions(+), 46 deletions(-) create mode 100644 tests/test_network_detect.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d97709..ec20255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,42 @@ +# v0.5.40-beta β€” Netwerken instellen zonder uitzoekwerk + +## πŸ” Automatische detectie + +Bij **Instellingen β†’ Netwerken β†’ Netwerk** toont Server Up nu bovenaan het +netwerk dat je server zelf gebruikt: interface, subnet, gateway en een +voorgestelde vrije IP-range. EΓ©n klik vult het hele formulier. + +De detectie start heel kort een container die de netwerknamespace van de host +deelt en leest daar de routetabel. Dat is nodig omdat Server Up in een eigen +namespace zit: de vorige versie las `/sys/class/net` binnen de container en +toonde daardoor de bridge-interface van de container zelf in plaats van de +netwerkkaart van de host β€” een lijstje dat er goed uitzag maar niet klopte. + +Lukt detecteren niet (geen docker-socket, ouder image), dan staat er een nette +melding en vul je het als vanouds handmatig in. + +## πŸ’‘ Live meedenken bij het invullen + +Terwijl je typt controleert Server Up de combinatie en toont wat het oplevert: +*"14 adressen beschikbaar: 192.168.1.241 tot en met 192.168.1.254"*. Klopt er +iets niet, dan staat er in gewone taal bij waarom β€” een range buiten het subnet, +een gateway die je beter buiten het bereik kunt houden, of een range zo klein +dat er maar een paar stacks in passen. + +Een knop stelt een vrij blok voor op basis van het subnet, met de gateway +ontweken. + +## πŸ“– Uitleg waar je hem nodig hebt + +In het formulier zit een uitklapbaar blok dat uitlegt hoe subnet, gateway, +IP-range en host-interface zich tot elkaar verhouden, met een concreet voorbeeld. +`docs/netwerken.md` heeft dezelfde uitleg met een schema van de adresverdeling, +een tabel met veelgebruikte ranges, en één punt dat er echt uit moet springen: +**het instellen van de IP-range in Server Up doet niets aan je router** β€” je moet +daar zelf het DHCP-bereik verkleinen, anders blijven dubbele adressen mogelijk. + +--- + # v0.5.30-beta β€” Rollen, app-store-filter en twee planningsfouten ## πŸ‘₯ Rollen per gebruiker diff --git a/VERSION b/VERSION index bcc01d8..4ca6d5d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.30-beta +0.5.40-beta diff --git a/docs/netwerken.md b/docs/netwerken.md index a314c9e..92ebb79 100644 --- a/docs/netwerken.md +++ b/docs/netwerken.md @@ -47,9 +47,17 @@ je switch en router er goed mee omgaan. Werkt macvlan niet, probeer dan ipvlan. --- -## Stap 1 β€” Bepaal je netwerkgegevens +## Stap 1 β€” Netwerk aanmaken in Server Up -Je hebt vier dingen nodig: +Ga naar **Instellingen β†’ Netwerken β†’ Netwerk**. Server Up kijkt zelf welk netwerk +je server gebruikt en toont dat bovenaan als voorstel: interface, subnet, gateway +en een vrije IP-range. EΓ©n klik vult het hele formulier. + +> Die detectie werkt door heel kort een container te starten die de +> netwerknamespace van de host deelt en daar de routetabel uitleest. Server Up +> zelf zit in een eigen namespace en ziet anders alleen zijn eigen bridge. + +Lukt detecteren niet, dan vul je het met de hand in. De vier velden: | Wat | Voorbeeld | Hoe kom je eraan | |-----|-----------|------------------| @@ -58,15 +66,51 @@ Je hebt vier dingen nodig: | Gateway | `192.168.1.1` | `ip route \| grep default` | | IP-range | `192.168.1.240/28` | Een blok dat je **buiten je DHCP-bereik** houdt | -De IP-range is optioneel maar sterk aanbevolen. Zonder range mag Docker het hele -subnet gebruiken en kan het een adres uitdelen dat je DHCP-server ook aan een -telefoon geeft. `192.168.1.240/28` reserveert `.240` t/m `.255` β€” zestien -adressen. Zet dat bereik in je router buiten de DHCP-pool. +### Hoe die vier zich tot elkaar verhouden -## Stap 2 β€” Netwerk aanmaken in Server Up +Dit is waar het meestal misgaat, dus expliciet: -Ga naar **Instellingen β†’ Netwerken β†’ Netwerk** en vul de gegevens in. Server Up -maakt het Docker-netwerk aan en onthoudt de definitie. +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ subnet: 192.168.1.0/24 ───────────────┐ + β”‚ β”‚ + .1 .20 ──── DHCP ──── .239 .240 ── Docker ── .255 + β”‚ β”‚ β”‚ β”‚ β”‚ + gateway je laptop, telefoon, einde begin einde + (je router) printer, enz. DHCP IP-range IP-range +``` + +- **Subnet** β€” precies hetzelfde als je router gebruikt. Een ander subnet + betekent dat je containers niemand kunnen bereiken. +- **Gateway** β€” je router, bijna altijd het eerste adres (`.1`). Laat je dit + leeg, dan kunnen de containers wel het LAN op maar niet het internet. +- **IP-range** β€” het deel dat Docker mag uitdelen. Dit moet **buiten** het + bereik liggen dat je router via DHCP uitgeeft, anders krijgt een container + hetzelfde adres als een apparaat in huis. Het einde van het subnet is meestal + vrij, vandaar `.240/28`. +- **Host-interface** β€” de netwerkkaart waar het LAN op zit. Niet die van Docker + (`docker0`, `br-…`) en niet wifi. + +**Belangrijk:** het instellen van de range in Server Up doet niets aan je router. +Je moet dÑÑr het DHCP-bereik verkleinen, bijvoorbeeld naar `192.168.1.20` – +`192.168.1.239`. Doe je dat niet, dan blijven er dubbele adressen mogelijk. + +Terwijl je typt controleert Server Up de combinatie en toont hoeveel adressen je +overhoudt (`192.168.1.240/28` β†’ veertien bruikbare adressen, `.241` t/m `.254`). +Klopt er iets niet, dan staat er in gewone taal bij waarom. + +### Veelgebruikte ranges + +| Subnet | Gateway | Voorstel | Ruimte | +|---|---|---|---| +| `192.168.1.0/24` | `192.168.1.1` | `192.168.1.240/28` | 14 stacks | +| `192.168.0.0/24` | `192.168.0.1` | `192.168.0.240/28` | 14 stacks | +| `10.0.0.0/24` | `10.0.0.1` | `10.0.0.240/28` | 14 stacks | +| `192.168.1.0/24` | `192.168.1.1` | `192.168.1.224/27` | 30 stacks | + +Meer nodig? Neem een groter blok: `/27` geeft 30 adressen, `/26` geeft 62. Zorg +dan wel dat je DHCP-bereik navenant kleiner wordt. + +## Stap 2 β€” Wat Server Up ermee doet Hetzelfde met de hand zou zijn: diff --git a/server-up/app.py b/server-up/app.py index 5ce525e..33c2534 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -1390,10 +1390,31 @@ def api_networks(): "used_ips": sorted(networks.used_ips(naam)) if naam in live else []} entry["next_ip"] = networks.next_free_ip(n, _reserved_ips()) out.append(entry) - return jsonify(networks=out, interfaces=networks.host_interfaces(), + gedetecteerd = networks.detect_host_networks() + # Al geconfigureerde subnetten niet nog een keer voorstellen. + bestaand = {n.get("subnet") for n in defined if isinstance(n, dict)} + voorstellen = [n for n in gedetecteerd["networks"] if n["subnet"] not in bestaand] + return jsonify(networks=out, + detected=voorstellen, + detect_error=gedetecteerd.get("error", ""), + interfaces=[n["interface"] for n in gedetecteerd["networks"]], drivers=list(networks.DRIVERS)) +@app.route("/api/networks/review", methods=["POST"]) +def api_networks_review(): + """Beoordeel een definitie zonder iets aan te maken (live in het formulier).""" + d = request.json or {} + return jsonify(networks.review({ + "name": (d.get("name") or "").strip(), + "driver": d.get("driver") or "macvlan", + "parent": (d.get("parent") or "").strip(), + "subnet": (d.get("subnet") or "").strip(), + "gateway": (d.get("gateway") or "").strip(), + "ip_range": (d.get("ip_range") or "").strip(), + })) + + @app.route("/api/networks", methods=["POST"]) def api_networks_add(): d = request.json or {} diff --git a/server-up/core/networks.py b/server-up/core/networks.py index 9228bbb..fb3c058 100644 --- a/server-up/core/networks.py +++ b/server-up/core/networks.py @@ -12,6 +12,9 @@ from __future__ import annotations import ipaddress import json import re +import socket +import struct +import time from core import docker @@ -66,22 +69,137 @@ def used_ips(name: str) -> set[str]: return ips -def host_interfaces() -> list[str]: - """Netwerkinterfaces van de host, als suggestie voor `parent`. +# ── Detectie van het hostnetwerk ───────────────────────────────────────────── +# +# Server Up draait in zijn eigen netwerknamespace, dus /sys/class/net en +# /proc/net binnen deze container beschrijven de bridge-interface van de +# container β€” niet het LAN waar de host op zit. Om het Γ©chte netwerk te zien +# starten we kort een container mΓ©t `--network host`; die deelt de namespace van +# de host en ziet dus diens routetabel. - De container draait met het netwerk van de host niet gedeeld, maar /sys is - wΓ©l zichtbaar wanneer de docker-socket gemount is; lukt het lezen niet, dan - krijgt de gebruiker gewoon een leeg lijstje en vult hij het zelf in. +_DETECT_CACHE: dict = {} +_DETECT_TS: float = 0.0 +_DETECT_TTL = 300 + + +def _hex_naar_ip(h: str) -> str: + """/proc/net/route schrijft adressen als little-endian hex.""" + return socket.inet_ntoa(struct.pack(" list[dict]: + """Zet /proc/net/route om in [{interface, subnet, gateway}]. + + Alleen interfaces met een default route zijn interessant: dat is de kant + waar het LAN zit, en dus de interface waar een macvlan aan moet hangen. + """ + gateways: dict[str, str] = {} + subnetten: dict[str, list[str]] = {} + for regel in tekst.splitlines()[1:]: + velden = regel.split() + if len(velden) < 8: + continue + iface, dest, gw, _flags, _r, _u, _m, mask = velden[:8] + try: + if dest == "00000000": # default route + gateways[iface] = _hex_naar_ip(gw) + else: + net = ipaddress.ip_network( + f"{_hex_naar_ip(dest)}/{_hex_naar_ip(mask)}", strict=False) + subnetten.setdefault(iface, []).append(str(net)) + except Exception: + continue + + uit = [] + for iface, gateway in gateways.items(): + # Het subnet waar de gateway in valt, is het LAN van deze interface. + gekozen = "" + for kandidaat in subnetten.get(iface, []): + try: + if ipaddress.ip_address(gateway) in ipaddress.ip_network(kandidaat): + gekozen = kandidaat + break + except ValueError: + continue + if not gekozen and subnetten.get(iface): + gekozen = subnetten[iface][0] + if gekozen: + uit.append({"interface": iface, "subnet": gekozen, "gateway": gateway}) + return sorted(uit, key=lambda d: d["interface"]) + + +def suggest_range(subnet: str, gateway: str = "") -> str: + """Stel een blok adressen voor aan het eind van het subnet. + + Het einde van een subnet is meestal vrij: DHCP-servers delen doorgaans vanaf + onderen uit, en de gateway zit vrijwel altijd op .1. Zestien adressen is + genoeg voor een flinke stapel stacks en klein genoeg om in je router als + uitzondering vrij te houden. """ try: - from pathlib import Path - # /sys/class/net bevat naast interfaces ook losse bestanden zoals - # 'bonding_masters'; echte interfaces zijn symlinks naar een device. - namen = sorted(p.name for p in Path("/sys/class/net").iterdir() if p.is_dir()) - return [n for n in namen - if n != "lo" and not n.startswith(("docker", "br-", "veth"))] + net = ipaddress.ip_network(subnet, strict=False) + except ValueError: + return "" + # Een /28 of kleiner valt niet zinnig op te delen: dan is het hele subnet + # het bereik en heeft een aparte range geen toegevoegde waarde. + if net.version != 4 or net.prefixlen >= 28: + return "" + blokken = list(net.subnets(new_prefix=28)) + for blok in reversed(blokken): + if gateway: + try: + if ipaddress.ip_address(gateway) in blok: + continue # nooit een blok met de gateway erin + except ValueError: + pass + return str(blok) + return "" + + +def _helper_image() -> str: + """Image voor de hulpcontainer: dat van Server Up zelf, dus altijd aanwezig.""" + try: + from core import selfupdate + return selfupdate.inspect_self().get("image", "") except Exception: - return [] + return "" + + +def detect_host_networks(force: bool = False) -> dict: + """Detecteer de netwerken van de host. + + Retourneert {"networks": [...], "error": "..."} β€” bij een fout een lege + lijst plus uitleg, zodat de interface kan terugvallen op handmatig invullen. + """ + global _DETECT_CACHE, _DETECT_TS + if not force and _DETECT_CACHE and (time.time() - _DETECT_TS) < _DETECT_TTL: + return dict(_DETECT_CACHE) + + image = _helper_image() + if not image: + return {"networks": [], "error": + "Kan het eigen image niet bepalen; vul de gegevens handmatig in."} + + r = docker._run([docker._bin(), "run", "--rm", "--network", "host", + "--entrypoint", "cat", image, "/proc/net/route"], timeout=30) + if r.returncode != 0: + return {"networks": [], "error": + (r.stderr or r.stdout or "").strip()[:200] or "Detectie mislukt."} + + netwerken = parse_proc_route(r.stdout) + for n in netwerken: + n["suggested_range"] = suggest_range(n["subnet"], n["gateway"]) + n["driver"] = "macvlan" + n["name"] = "lan" if len(netwerken) == 1 else f"lan-{n['interface']}" + resultaat = {"networks": netwerken, "error": ""} + + _DETECT_CACHE, _DETECT_TS = dict(resultaat), time.time() + return resultaat + + +def host_interfaces() -> list[str]: + """Interfacenamen van de host, voor de keuzelijst bij `parent`.""" + return [n["interface"] for n in detect_host_networks().get("networks", [])] # ── IP-toewijzing ──────────────────────────────────────────────────────────── @@ -150,6 +268,70 @@ def validate(net: dict) -> str: return "" +def review(net: dict) -> dict: + """Beoordeel een netwerkdefinitie en leg in gewone taal uit wat er mis is. + + Gescheiden van validate(): dat geeft één blokkerende fout, dit geeft alle + opmerkingen β€” ook waarschuwingen die je mΓ‘g negeren β€” plus wat het netwerk + concreet oplevert. Bedoeld om live mee te typen in de interface. + """ + fouten: list[str] = [] + waarschuwingen: list[str] = [] + info: dict = {} + + blokkerend = validate(net) + if blokkerend: + fouten.append(blokkerend) + return {"ok": False, "errors": fouten, "warnings": waarschuwingen, "info": info} + + subnet = ipaddress.ip_network(net["subnet"], strict=False) + gateway = (net.get("gateway") or "").strip() + rng = (net.get("ip_range") or "").strip() + + if not gateway: + waarschuwingen.append( + "Zonder gateway kunnen de containers alleen het eigen subnet bereiken, " + "geen internet. Meestal is dat het adres van je router.") + + bereik = ipaddress.ip_network(rng, strict=False) if rng else subnet + bruikbaar = [h for h in bereik.hosts()] + if gateway: + try: + gw = ipaddress.ip_address(gateway) + # Alleen relevant bij een expliciete range: dat de gateway ergens in + # het subnet zit is normaal. Docker houdt het gateway-adres apart, + # maar een range eromheen kiezen is duidelijker en scheelt verwarring. + if rng and gw in bereik: + waarschuwingen.append( + f"De gateway ({gateway}) valt binnen je IP-range. Docker houdt " + "dat adres vrij, maar het is overzichtelijker om een range te " + "kiezen die de gateway niet bevat.") + bruikbaar = [h for h in bruikbaar if h != gw] + except ValueError: + pass + + if not rng: + waarschuwingen.append( + "Zonder IP-range mag Docker het hele subnet gebruiken, inclusief " + "adressen die je DHCP-server aan telefoons en laptops uitdeelt. Dat " + "geeft vroeg of laat twee apparaten op hetzelfde adres.") + + if len(bruikbaar) == 0: + fouten.append("Er blijft geen enkel bruikbaar adres over in dit bereik.") + elif len(bruikbaar) < 4: + waarschuwingen.append( + f"Er passen maar {len(bruikbaar)} adressen in dit bereik; je kunt dus " + f"{len(bruikbaar)} stacks een eigen IP geven.") + + if bruikbaar: + info = {"first": str(bruikbaar[0]), "last": str(bruikbaar[-1]), + "count": len(bruikbaar), "subnet": str(subnet), + "range": str(bereik)} + + return {"ok": not fouten, "errors": fouten, + "warnings": waarschuwingen, "info": info} + + def create(net: dict) -> tuple[bool, str]: """Maak het netwerk aan bij de Docker-daemon (idempotent).""" probleem = validate(net) diff --git a/server-up/templates/index.html b/server-up/templates/index.html index a0e0284..aba2f0d 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -890,33 +890,106 @@ tailwind.config = { -