diff --git a/CHANGELOG.md b/CHANGELOG.md index 8192307..f9af3d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +# v0.5.42-beta — Netwerk toevoegen: twee blokkades weg + +Een netwerk aanmaken lukte niet, met wisselend gedrag: soms een foutmelding dat +de IP-range buiten het subnet lag terwijl dat aantoonbaar niet zo was, soms geen +melding maar toch geen resultaat. Het bleken twee losse oorzaken. + +## De foutmelding die niet klopte + +De beoordeling gaat af bij elke toetsaanslag. Tijdens het intypen van `/28` ga +je langs `/2`, en `10.0.20.240/2` normaliseert naar `0.0.0.0/2` — inderdaad +buiten je subnet. Kwam dat antwoord ná het antwoord voor de volledige waarde +binnen, dan bleef die foutmelding staan en bleef de knop uitgeschakeld. + +Er zit nu een volgnummer op: alleen het antwoord bij de laatste vraag telt. En +de knop wordt niet meer uitgeschakeld op de clientstatus — bij het opslaan +beslist de server, zodat een verouderde beoordeling je nooit kan blokkeren. + +## De range die Docker weigerde + +`10.0.20.200/28` heeft hostbits gezet. Python leest dat soepel als +`10.0.20.192/28`, maar Docker is streng en antwoordt *"has host bits set"*. Wij +valideerden soepel en stuurden vervolgens de ruwe tekst door, dus de interface +keurde iets goed dat bij het aanmaken alsnog strandde. + +Subnet en IP-range worden nu omgezet naar hun canonieke vorm voordat ze naar +Docker gaan én voordat ze worden opgeslagen, met een waarschuwing die laat zien +wat er van je invoer gemaakt is. `.200/28` is trouwens een logische invoer — je +bedoelt "vanaf .200" — maar een /28 begint nu eenmaal op een veelvoud van 16. + +--- + # v0.5.41-beta — Netwerk toevoegen liep vast tijdens het typen Een netwerk toevoegen gaf een JSON-parsefout in de browser. diff --git a/VERSION b/VERSION index bf882bd..5b27ae9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.41-beta +0.5.42-beta diff --git a/server-up/app.py b/server-up/app.py index 9a24850..5b2af01 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -1419,12 +1419,15 @@ def api_networks_review(): @app.route("/api/networks", methods=["POST"]) def api_networks_add(): d = request.json or {} - net = {"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()} + # Normaliseren vóór opslaan: wat we bewaren moet gelijk zijn aan wat Docker + # heeft gekregen, anders wijkt de config af van het echte netwerk. + net = networks.normalize({ + "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()}) probleem = networks.validate(net) if probleem: return jsonify(ok=False, msg=probleem), 400 diff --git a/server-up/core/networks.py b/server-up/core/networks.py index 23e4bd6..295dbea 100644 --- a/server-up/core/networks.py +++ b/server-up/core/networks.py @@ -290,6 +290,18 @@ def review(net: dict) -> dict: waarschuwingen: list[str] = [] info: dict = {} + ingevoerd_range = (net.get("ip_range") or "").strip() + ingevoerd_subnet = (net.get("subnet") or "").strip() + net = normalize(net) + for label, ingevoerd, genormaliseerd in ( + ("subnet", ingevoerd_subnet, net.get("subnet")), + ("IP-range", ingevoerd_range, net.get("ip_range"))): + if ingevoerd and genormaliseerd and ingevoerd != genormaliseerd: + waarschuwingen.append( + f"Het {label} {ingevoerd} is niet uitgelijnd op een netwerkgrens " + f"en wordt gelezen als {genormaliseerd}. Docker weigert de " + "oorspronkelijke notatie, dus Server Up slaat de omgezette vorm op.") + blokkerend = validate(net) if blokkerend: fouten.append(blokkerend) @@ -378,8 +390,30 @@ def _bruikbaar_bereik(net, gateway: str = ""): return eerste, laatste, max(aantal, 0) +def normalize(net: dict) -> dict: + """Zet subnet en ip_range om naar hun canonieke vorm. + + `10.0.20.200/28` is voor Python met strict=False gewoon `10.0.20.192/28`, + maar Docker weigert het met "has host bits set". Zonder deze omzetting komt + een adres dat de gebruiker logisch invult (het eerste adres dat hij wil + uitdelen) niet door `docker network create`. + """ + schoon = dict(net) + for sleutel in ("subnet", "ip_range"): + waarde = (schoon.get(sleutel) or "").strip() + if not waarde: + continue + try: + schoon[sleutel] = str(ipaddress.ip_network(waarde, strict=False)) + except ValueError: + schoon[sleutel] = waarde # laat validate() de fout melden + schoon["gateway"] = (schoon.get("gateway") or "").strip() + return schoon + + def create(net: dict) -> tuple[bool, str]: """Maak het netwerk aan bij de Docker-daemon (idempotent).""" + net = normalize(net) probleem = validate(net) if probleem: return False, probleem diff --git a/server-up/templates/index.html b/server-up/templates/index.html index ae4b7c7..128f630 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -991,7 +991,7 @@ tailwind.config = { @@ -1308,7 +1308,7 @@ function app() { // Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen netList: [], netIfaces: [], netDetected: [], netDetectError: '', - netReview: {}, netReviewTimer: null, + netReview: {}, netReviewTimer: null, netReviewSeq: 0, newNet: { name: '', driver: 'macvlan', parent: '', subnet: '', gateway: '', ip_range: '' }, docker: { running: 0, containers: 0, images: 0 }, @@ -1768,7 +1768,13 @@ function app() { if (!this.newNet.subnet || !this.newNet.parent || !this.newNet.name) { this.netReview = {}; return; } - this.netReview = await this.rpc('/api/networks/review', this.newNet); + // Volgnummer tegen antwoorden die te laat binnenkomen. Tijdens het + // typen van '/28' ga je langs '/2', en dat is een geldig maar veel te + // groot netwerk buiten je subnet. Kwam dat antwoord ná het goede + // binnen, dan bleef die foutmelding staan en bleef de knop uit. + const volgnummer = ++this.netReviewSeq; + const antwoord = await this.rpc('/api/networks/review', this.newNet); + if (volgnummer === this.netReviewSeq) this.netReview = antwoord; }, 350); }, // Vul automatisch het eerstvolgende vrije adres in als je een netwerk kiest. @@ -1777,6 +1783,8 @@ function app() { this.install.ip = n ? (n.next_ip || '') : ''; }, async addNetwork() { + // Eerst de laatste stand laten beoordelen, zodat een fout uit een + // afgebroken review niet blijft hangen. const d = await this.rpc('/api/networks', this.newNet); this.toast(d.msg || this.t(d.ok ? 'add' : 'error'), d.ok ? 'ok' : 'er'); if (d.ok) { diff --git a/tests/test_network_detect.py b/tests/test_network_detect.py index 4cc399e..d44e256 100644 --- a/tests/test_network_detect.py +++ b/tests/test_network_detect.py @@ -186,6 +186,64 @@ def test_bruikbaar_bereik_negeert_gateway_buiten_het_bereik(): assert networks._bruikbaar_bereik(net, "192.168.1.1")[2] == 14 +# ── Normalisatie ───────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("ingevoerd,verwacht", [ + ("10.0.20.200/28", "10.0.20.192/28"), # hostbits gezet + ("10.0.20.240/28", "10.0.20.240/28"), # al uitgelijnd + ("192.168.1.5/24", "192.168.1.0/24"), +]) +def test_normalize_lijnt_de_range_uit(ingevoerd, verwacht): + """Docker weigert een CIDR met hostbits ('has host bits set'), terwijl wij + hem soepel inlezen. Zonder omzetting faalt `docker network create` op iets + dat de interface wél accepteerde.""" + n = networks.normalize({**BASIS, "ip_range": ingevoerd}) + assert n["ip_range"] == verwacht + + +def test_normalize_laat_onzin_staan_voor_validate(): + n = networks.normalize({**BASIS, "ip_range": "geen-cidr"}) + assert n["ip_range"] == "geen-cidr" + + +def test_normalize_raakt_de_rest_niet_aan(): + n = networks.normalize(BASIS) + assert n["name"] == "lan" and n["driver"] == "macvlan" and n["parent"] == "eth0" + + +def test_review_waarschuwt_bij_niet_uitgelijnde_range(): + r = networks.review({**BASIS, "ip_range": "10.0.20.200/28", + "subnet": "10.0.20.0/24", "gateway": "10.0.20.1"}) + assert r["ok"] is True + assert any("10.0.20.192/28" in w for w in r["warnings"]) + + +def test_niet_uitgelijnde_range_uit_het_formulier_wordt_opgeslagen_als_canoniek(client, monkeypatch): + """Wat we bewaren moet gelijk zijn aan wat Docker heeft gekregen.""" + from conftest import login + from core import networks as nw + csrf = login(client) + monkeypatch.setattr(nw, "exists", lambda naam: False) + monkeypatch.setattr(nw, "create", lambda net: (True, "ok")) + client.post("/api/networks", + json={**BASIS, "subnet": "10.0.20.0/24", "gateway": "10.0.20.1", + "ip_range": "10.0.20.200/28"}, + headers={"X-CSRF-Token": csrf}) + opgeslagen = client.get("/api/networks").get_json()["networks"] + assert opgeslagen[0]["ip_range"] == "10.0.20.192/28" + + +def test_scherm_uit_de_melding_valt_niet_ten_onrechte_buiten_het_subnet(): + """De combinatie uit de foutmelding van de gebruiker: 10.0.20.240/28 in + 10.0.20.0/24 is geldig en mag geen fout geven.""" + r = networks.review({"name": "lan", "driver": "macvlan", "parent": "ens18", + "subnet": "10.0.20.0/24", "gateway": "10.0.20.1", + "ip_range": "10.0.20.240/28"}) + assert r["ok"] is True + assert r["errors"] == [] + assert r["info"]["count"] == 14 + + def test_review_geeft_blokkerende_validatiefout_door(): r = networks.review({**BASIS, "subnet": "onzin"}) assert r["ok"] is False