v0.5.42-beta - netwerk toevoegen: twee blokkades weg
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m3s

Gemeld met twee screenshots: een foutmelding dat de IP-range buiten het subnet
lag terwijl 10.0.20.240/28 gewoon binnen 10.0.20.0/24 valt, en een geval zonder
melding waarbij aanmaken toch niet lukte. Twee losse oorzaken.

1. Verouderd beoordelingsantwoord
   De live beoordeling gaat af bij elke toetsaanslag. Tijdens het typen van
   /28 kom je langs /2, en 10.0.20.240/2 normaliseert naar 0.0.0.0/2 - buiten
   het subnet. Kwam dat antwoord na het goede binnen, dan bleef die fout staan
   en bleef de knop uit.
   - Volgnummer op de verzoeken; alleen het antwoord bij de laatste vraag telt.
   - Knop niet meer uitgeschakeld op clientstatus; de server beslist bij het
     opslaan, zodat een verouderde beoordeling nooit blokkeert.

2. CIDR met hostbits
   10.0.20.200/28 leest Python met strict=False als 10.0.20.192/28, maar Docker
   weigert het met "has host bits set". We valideerden soepel en stuurden de
   ruwe tekst door, dus de interface keurde iets goed dat bij het aanmaken
   alsnog strandde.
   - normalize() zet subnet en ip_range om naar canonieke vorm voor ze naar
     Docker gaan en voor ze worden opgeslagen.
   - Waarschuwing die toont wat er van de invoer gemaakt is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
Ramon 2026-07-27 00:06:12 +02:00
parent 923c8dabee
commit cbb232d63f
6 changed files with 144 additions and 10 deletions

View file

@ -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.

View file

@ -1 +1 @@
0.5.41-beta
0.5.42-beta

View file

@ -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

View file

@ -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

View file

@ -991,7 +991,7 @@ tailwind.config = {
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="addNetwork()" :disabled="netReview.ok === false">
<button class="btn btn-primary" @click="addNetwork()">
<span class="mdi mdi-plus"></span> <span x-text="t('add')">Aanmaken</span>
</button>
</div>
@ -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) {

View file

@ -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