v0.5.40-beta - netwerken instellen zonder uitzoekwerk
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m11s
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m11s
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
parent
342b2ee9e5
commit
a1fb5f5a8f
9 changed files with 631 additions and 46 deletions
39
CHANGELOG.md
39
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
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.5.30-beta
|
||||
0.5.40-beta
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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("<L", int(h, 16)))
|
||||
|
||||
|
||||
def parse_proc_route(tekst: str) -> 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)
|
||||
|
|
|
|||
|
|
@ -890,33 +890,106 @@ tailwind.config = {
|
|||
</div>
|
||||
|
||||
<!-- Netwerk toevoegen -->
|
||||
<div x-show="modal==='add-network'" class="modal max-w-md">
|
||||
<div x-show="modal==='add-network'" class="modal max-w-xl flex flex-col max-h-[88vh]">
|
||||
<div class="modal-head"><span class="mdi mdi-lan-connect text-ac dark:text-ac-dark"></span> <span x-text="t('add_network')">Netwerk toevoegen</span></div>
|
||||
<div class="modal-body space-y-3">
|
||||
<div><label class="form-label" x-text="t('lang_name')">Naam</label><input class="form-input" x-model="newNet.name" placeholder="lan"></div>
|
||||
<div class="modal-body overflow-y-auto space-y-4">
|
||||
|
||||
<!-- Gedetecteerd: één klik vult alles in -->
|
||||
<template x-if="netDetected.length">
|
||||
<div>
|
||||
<div class="form-label" x-text="t('detected_networks')">Gevonden op deze server</div>
|
||||
<div class="space-y-2">
|
||||
<template x-for="d in netDetected" :key="d.interface">
|
||||
<button type="button" @click="useDetected(d)"
|
||||
class="w-full text-left px-3.5 py-3 rounded-xl border transition"
|
||||
:class="newNet.parent === d.interface ? 'border-ac bg-ac/5' : 'border-bd dark:border-bd-dark hover:border-bd2 dark:hover:border-bd2-dark'">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="mdi mdi-ethernet text-ac dark:text-ac-dark"></span>
|
||||
<span class="font-semibold text-sm text-wh dark:text-wh-dark font-mono" x-text="d.interface"></span>
|
||||
<span class="badge badge-dim ml-auto" x-text="t('use_this')">Gebruiken</span>
|
||||
</div>
|
||||
<div class="text-[11px] font-mono text-t2 dark:text-t2-dark grid gap-0.5">
|
||||
<div><span x-text="t('subnet')"></span>: <span x-text="d.subnet"></span></div>
|
||||
<div><span x-text="t('gateway')"></span>: <span x-text="d.gateway"></span></div>
|
||||
<div><span x-text="t('ip_range')"></span>: <span x-text="d.suggested_range"></span></div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!netDetected.length && netDetectError" class="text-[11px] text-wr flex items-start gap-1.5">
|
||||
<span class="mdi mdi-alert-outline shrink-0"></span>
|
||||
<span><span x-text="t('detect_failed')"></span> <span class="font-mono" x-text="netDetectError"></span></span>
|
||||
</p>
|
||||
|
||||
<!-- Uitleg: hoe subnet, gateway en range zich tot elkaar verhouden -->
|
||||
<details class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden">
|
||||
<summary class="px-3.5 py-2.5 cursor-pointer font-bold text-xs text-wh dark:text-wh-dark bg-s2/60 dark:bg-s2-dark/60 flex items-center gap-2">
|
||||
<span class="mdi mdi-help-circle-outline"></span> <span x-text="t('how_it_fits')">Hoe hangt dit samen?</span>
|
||||
</summary>
|
||||
<div class="p-3.5 text-[11px] text-t2 dark:text-t2-dark space-y-2 leading-relaxed">
|
||||
<p><strong x-text="t('subnet')"></strong> — <span x-text="t('tip_subnet')"></span></p>
|
||||
<p><strong x-text="t('gateway')"></strong> — <span x-text="t('tip_gateway')"></span></p>
|
||||
<p><strong x-text="t('ip_range')"></strong> — <span x-text="t('tip_range')"></span></p>
|
||||
<p><strong x-text="t('host_interface')"></strong> — <span x-text="t('tip_parent')"></span></p>
|
||||
<div class="pt-2 border-t border-bd dark:border-bd-dark">
|
||||
<p class="font-semibold text-tx dark:text-tx-dark mb-1" x-text="t('tip_example_title')">Voorbeeld</p>
|
||||
<pre class="font-mono text-[10px] leading-snug whitespace-pre-wrap" x-text="t('tip_example')"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div><label class="form-label" x-text="t('lang_name')">Naam</label>
|
||||
<input class="form-input" x-model="newNet.name" @input="reviewNet()" placeholder="lan"></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="form-label" x-text="t('driver')">Driver</label>
|
||||
<select class="form-input" x-model="newNet.driver">
|
||||
<select class="form-input" x-model="newNet.driver" @change="reviewNet()">
|
||||
<option value="macvlan">macvlan</option>
|
||||
<option value="ipvlan">ipvlan</option>
|
||||
</select></div>
|
||||
<div><label class="form-label" x-text="t('host_interface')">Host-interface</label>
|
||||
<input class="form-input" x-model="newNet.parent" placeholder="eth0" list="ifaces">
|
||||
<input class="form-input font-mono" x-model="newNet.parent" @input="reviewNet()" placeholder="eth0" list="ifaces">
|
||||
<datalist id="ifaces"><template x-for="i in netIfaces" :key="i"><option :value="i"></option></template></datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="form-label" x-text="t('subnet')">Subnet</label><input class="form-input font-mono" x-model="newNet.subnet" placeholder="192.168.1.0/24"></div>
|
||||
<div><label class="form-label" x-text="t('gateway')">Gateway</label><input class="form-input font-mono" x-model="newNet.gateway" placeholder="192.168.1.1"></div>
|
||||
<div><label class="form-label" x-text="t('subnet')">Subnet</label>
|
||||
<input class="form-input font-mono" x-model="newNet.subnet" @input="reviewNet()" placeholder="192.168.1.0/24"></div>
|
||||
<div><label class="form-label" x-text="t('gateway')">Gateway</label>
|
||||
<input class="form-input font-mono" x-model="newNet.gateway" @input="reviewNet()" placeholder="192.168.1.1"></div>
|
||||
</div>
|
||||
<div><label class="form-label" x-text="t('ip_range')">IP-range</label>
|
||||
<input class="form-input font-mono" x-model="newNet.ip_range" placeholder="192.168.1.240/28">
|
||||
<div class="flex gap-2">
|
||||
<input class="form-input font-mono" x-model="newNet.ip_range" @input="reviewNet()" placeholder="192.168.1.240/28">
|
||||
<button type="button" class="btn-sm shrink-0" @click="suggestRange()" :title="t('suggest_range')">
|
||||
<span class="mdi mdi-auto-fix"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('ip_range_hint')"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live beoordeling -->
|
||||
<div x-show="netReview.info && netReview.info.count" class="rounded-xl border border-ok/30 bg-ok/5 px-3.5 py-2.5">
|
||||
<div class="text-[11px] text-tx dark:text-tx-dark"
|
||||
x-text="t('range_result', {count: (netReview.info||{}).count, first: (netReview.info||{}).first, last: (netReview.info||{}).last})"></div>
|
||||
</div>
|
||||
<template x-for="e in (netReview.errors || [])" :key="e">
|
||||
<p class="text-[11px] text-er flex items-start gap-1.5">
|
||||
<span class="mdi mdi-close-circle-outline shrink-0"></span><span x-text="e"></span></p>
|
||||
</template>
|
||||
<template x-for="w in (netReview.warnings || [])" :key="w">
|
||||
<p class="text-[11px] text-wr flex items-start gap-1.5">
|
||||
<span class="mdi mdi-alert-outline shrink-0"></span><span x-text="w"></span></p>
|
||||
</template>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
|
||||
<button class="btn btn-primary" @click="addNetwork()"><span class="mdi mdi-plus"></span> <span x-text="t('add')">Aanmaken</span></button>
|
||||
<button class="btn btn-primary" @click="addNetwork()" :disabled="netReview.ok === false">
|
||||
<span class="mdi mdi-plus"></span> <span x-text="t('add')">Aanmaken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1230,7 +1303,8 @@ function app() {
|
|||
stackUpd: {}, checkingUpd: false,
|
||||
|
||||
// Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen
|
||||
netList: [], netIfaces: [],
|
||||
netList: [], netIfaces: [], netDetected: [], netDetectError: '',
|
||||
netReview: {}, netReviewTimer: null,
|
||||
newNet: { name: '', driver: 'macvlan', parent: '', subnet: '', gateway: '', ip_range: '' },
|
||||
|
||||
docker: { running: 0, containers: 0, images: 0 },
|
||||
|
|
@ -1655,6 +1729,43 @@ function app() {
|
|||
const d = await this.rpc('/api/networks');
|
||||
this.netList = d.networks || [];
|
||||
this.netIfaces = d.interfaces || [];
|
||||
this.netDetected = d.detected || [];
|
||||
this.netDetectError = d.detect_error || '';
|
||||
},
|
||||
// Eén klik op een gevonden netwerk vult het hele formulier.
|
||||
useDetected(d) {
|
||||
this.newNet = {
|
||||
name: d.name || 'lan', driver: d.driver || 'macvlan',
|
||||
parent: d.interface, subnet: d.subnet, gateway: d.gateway,
|
||||
ip_range: d.suggested_range || '',
|
||||
};
|
||||
this.reviewNet();
|
||||
},
|
||||
// Laat de server een vrij blok voorstellen op basis van het subnet.
|
||||
async suggestRange() {
|
||||
const d = this.netDetected.find(x => x.subnet === this.newNet.subnet);
|
||||
if (d && d.suggested_range) { this.newNet.ip_range = d.suggested_range; this.reviewNet(); return; }
|
||||
const r = await this.rpc('/api/networks/review', { ...this.newNet, ip_range: '' });
|
||||
if (r.info && r.info.subnet) {
|
||||
// Val terug op het laatste /28 van het subnet.
|
||||
const [basis, prefix] = r.info.subnet.split('/');
|
||||
if (prefix && Number(prefix) <= 28) {
|
||||
const d4 = basis.split('.'); d4[3] = '240';
|
||||
this.newNet.ip_range = d4.join('.') + '/28';
|
||||
}
|
||||
}
|
||||
this.reviewNet();
|
||||
},
|
||||
// Live meelezen terwijl je typt; even wachten zodat niet elke toetsaanslag
|
||||
// een verzoek stuurt.
|
||||
reviewNet() {
|
||||
if (this.netReviewTimer) clearTimeout(this.netReviewTimer);
|
||||
this.netReviewTimer = setTimeout(async () => {
|
||||
if (!this.newNet.subnet || !this.newNet.parent || !this.newNet.name) {
|
||||
this.netReview = {}; return;
|
||||
}
|
||||
this.netReview = await this.rpc('/api/networks/review', this.newNet);
|
||||
}, 350);
|
||||
},
|
||||
// Vul automatisch het eerstvolgende vrije adres in als je een netwerk kiest.
|
||||
pickIp() {
|
||||
|
|
@ -1666,6 +1777,7 @@ function app() {
|
|||
this.toast(d.msg || this.t(d.ok ? 'add' : 'error'), d.ok ? 'ok' : 'er');
|
||||
if (d.ok) {
|
||||
this.newNet = { name:'', driver:'macvlan', parent:'', subnet:'', gateway:'', ip_range:'' };
|
||||
this.netReview = {};
|
||||
this.modal = null;
|
||||
this.loadNetworks();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,5 +220,17 @@
|
|||
"proxy_role_hint": "Anyone signing in through the reverse proxy without their own account gets this role.",
|
||||
"no_permission": "Your role does not allow this action",
|
||||
"search_apps": "Search apps…",
|
||||
"all": "All"
|
||||
"all": "All",
|
||||
"detected_networks": "Found on this server",
|
||||
"use_this": "Use this",
|
||||
"detect_failed": "Automatic detection failed; fill in the details manually.",
|
||||
"how_it_fits": "How do subnet, gateway and range relate?",
|
||||
"tip_subnet": "the network your server is on, including the prefix length (/24 = 254 addresses). Use exactly the same subnet as your router — otherwise the containers cannot reach anything.",
|
||||
"tip_gateway": "your router's address, where outbound traffic goes. Almost always the first address of the subnet, so 192.168.1.1 for 192.168.1.0/24. Leave it empty and the containers get no internet access.",
|
||||
"tip_range": "the slice of the subnet Docker may hand out. Pick a block your router does NOT serve via DHCP, or a container will end up with the same address as your phone. The end of the subnet is usually free.",
|
||||
"tip_parent": "the network card of your server that carries the LAN. Does not work over Wi-Fi — macvlan requires a wired connection.",
|
||||
"tip_example_title": "Example with a router at 192.168.1.1",
|
||||
"tip_example": "Subnet 192.168.1.0/24 ← same as your router\nGateway 192.168.1.1 ← your router\nIP range 192.168.1.240/28 ← .240 through .255\n\nSet your router's DHCP pool to 192.168.1.20 – 192.168.1.239 so that\nthose last sixteen addresses are reserved for your containers.",
|
||||
"suggest_range": "Suggest a free block",
|
||||
"range_result": "{count} addresses available: {first} through {last}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,5 +220,17 @@
|
|||
"proxy_role_hint": "Iedereen die via de reverse proxy binnenkomt en geen eigen account heeft, krijgt deze rol.",
|
||||
"no_permission": "Je rol geeft geen toegang tot deze actie",
|
||||
"search_apps": "Zoek een app…",
|
||||
"all": "Alle"
|
||||
"all": "Alle",
|
||||
"detected_networks": "Gevonden op deze server",
|
||||
"use_this": "Gebruiken",
|
||||
"detect_failed": "Automatisch detecteren lukte niet; vul de gegevens handmatig in.",
|
||||
"how_it_fits": "Hoe hangen subnet, gateway en range samen?",
|
||||
"tip_subnet": "het netwerk waar je server zelf in zit, inclusief het aantal bits (/24 = 254 adressen). Neem exact hetzelfde subnet als je router gebruikt — anders kunnen de containers niemand bereiken.",
|
||||
"tip_gateway": "het adres van je router, waar het verkeer naar buiten langs gaat. Bijna altijd het eerste adres van het subnet, dus 192.168.1.1 bij 192.168.1.0/24. Laat je dit leeg, dan komen de containers niet op internet.",
|
||||
"tip_range": "het stukje van het subnet dat Docker mag uitdelen. Kies een blok dat je router NIET via DHCP uitgeeft, anders krijgt een container hetzelfde adres als je telefoon. Het einde van het subnet is meestal vrij.",
|
||||
"tip_parent": "de netwerkkaart van je server waar het LAN op zit. Werkt niet op wifi — macvlan vereist bekabeld.",
|
||||
"tip_example_title": "Voorbeeld bij een router op 192.168.1.1",
|
||||
"tip_example": "Subnet 192.168.1.0/24 ← hetzelfde als je router\nGateway 192.168.1.1 ← je router\nIP-range 192.168.1.240/28 ← .240 t/m .255\n\nStel je DHCP-bereik in je router in op 192.168.1.20 – 192.168.1.239,\nzodat die laatste zestien adressen alleen voor je containers zijn.",
|
||||
"suggest_range": "Stel een vrij blok voor",
|
||||
"range_result": "{count} adressen beschikbaar: {first} tot en met {last}"
|
||||
}
|
||||
|
|
|
|||
163
tests/test_network_detect.py
Normal file
163
tests/test_network_detect.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""Netwerkdetectie: /proc/net/route parsen, ranges voorstellen en beoordelen."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
||||
|
||||
from core import networks
|
||||
|
||||
# Zoals /proc/net/route eruitziet op een host met één LAN-interface.
|
||||
# Adressen staan als little-endian hex: 0100A8C0 = 192.168.0.1
|
||||
ROUTE_ENKEL = """Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
|
||||
eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0
|
||||
eth0\t0001A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0
|
||||
docker0\t000011AC\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_vindt_lan_interface():
|
||||
n = networks.parse_proc_route(ROUTE_ENKEL)
|
||||
assert len(n) == 1
|
||||
assert n[0] == {"interface": "eth0", "subnet": "192.168.1.0/24",
|
||||
"gateway": "192.168.1.1"}
|
||||
|
||||
|
||||
def test_parse_negeert_interfaces_zonder_default_route():
|
||||
"""docker0 heeft wel een subnet maar geen default route — geen LAN dus."""
|
||||
assert all(x["interface"] != "docker0"
|
||||
for x in networks.parse_proc_route(ROUTE_ENKEL))
|
||||
|
||||
|
||||
def test_parse_meerdere_interfaces():
|
||||
tekst = ROUTE_ENKEL + "eth1\t00000000\t0102A8C0\t0003\t0\t0\t200\t00000000\t0\t0\t0\n" \
|
||||
"eth1\t0002A8C0\t00000000\t0001\t0\t0\t200\t00FFFFFF\t0\t0\t0\n"
|
||||
n = networks.parse_proc_route(tekst)
|
||||
assert [x["interface"] for x in n] == ["eth0", "eth1"]
|
||||
assert n[1]["subnet"] == "192.168.2.0/24"
|
||||
|
||||
|
||||
def test_parse_lege_of_onzinnige_invoer():
|
||||
assert networks.parse_proc_route("") == []
|
||||
assert networks.parse_proc_route("kop\nonzin regel\n") == []
|
||||
|
||||
|
||||
# ── Voorgestelde range ───────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("subnet,gateway,verwacht", [
|
||||
("192.168.1.0/24", "192.168.1.1", "192.168.1.240/28"),
|
||||
("10.0.0.0/24", "10.0.0.1", "10.0.0.240/28"),
|
||||
("192.168.0.0/16", "192.168.0.1", "192.168.255.240/28"),
|
||||
])
|
||||
def test_suggest_range_pakt_het_einde_van_het_subnet(subnet, gateway, verwacht):
|
||||
assert networks.suggest_range(subnet, gateway) == verwacht
|
||||
|
||||
|
||||
def test_suggest_range_vermijdt_de_gateway():
|
||||
"""Een gateway aan het eind van het subnet mag niet in de range vallen."""
|
||||
r = networks.suggest_range("192.168.1.0/24", "192.168.1.250")
|
||||
import ipaddress
|
||||
assert ipaddress.ip_address("192.168.1.250") not in ipaddress.ip_network(r)
|
||||
|
||||
|
||||
def test_suggest_range_bij_klein_subnet():
|
||||
assert networks.suggest_range("192.168.1.0/28", "192.168.1.1") == ""
|
||||
|
||||
|
||||
def test_suggest_range_bij_onzin():
|
||||
assert networks.suggest_range("geen-subnet") == ""
|
||||
|
||||
|
||||
# ── Beoordeling ──────────────────────────────────────────────────────────────
|
||||
|
||||
BASIS = {"name": "lan", "driver": "macvlan", "parent": "eth0",
|
||||
"subnet": "192.168.1.0/24", "gateway": "192.168.1.1",
|
||||
"ip_range": "192.168.1.240/28"}
|
||||
|
||||
|
||||
def test_review_goede_definitie():
|
||||
r = networks.review(BASIS)
|
||||
assert r["ok"] is True
|
||||
assert r["errors"] == []
|
||||
assert r["info"]["count"] == 14 # .241 t/m .254
|
||||
assert r["info"]["first"] == "192.168.1.241"
|
||||
assert r["info"]["last"] == "192.168.1.254"
|
||||
|
||||
|
||||
def test_review_waarschuwt_bij_gateway_binnen_een_expliciete_range():
|
||||
r = networks.review({**BASIS, "ip_range": "192.168.1.0/25"})
|
||||
assert r["ok"] is True # Docker houdt het adres vrij, dus geen fout
|
||||
assert any("gateway" in w for w in r["warnings"])
|
||||
|
||||
|
||||
def test_review_ziet_gateway_in_het_subnet_niet_als_probleem():
|
||||
"""Zonder eigen range is 'gateway zit in het subnet' de normale situatie."""
|
||||
r = networks.review({**BASIS, "ip_range": ""})
|
||||
assert not any("gateway" in w.lower() for w in r["warnings"])
|
||||
|
||||
|
||||
def test_review_waarschuwt_zonder_range():
|
||||
r = networks.review({**BASIS, "ip_range": ""})
|
||||
assert r["ok"] is True
|
||||
assert any("DHCP" in w for w in r["warnings"])
|
||||
|
||||
|
||||
def test_review_waarschuwt_zonder_gateway():
|
||||
r = networks.review({**BASIS, "gateway": ""})
|
||||
assert r["ok"] is True
|
||||
assert any("internet" in w for w in r["warnings"])
|
||||
|
||||
|
||||
def test_review_waarschuwt_bij_erg_kleine_range():
|
||||
r = networks.review({**BASIS, "ip_range": "192.168.1.252/30"})
|
||||
assert r["ok"] is True
|
||||
assert any("adressen" in w for w in r["warnings"])
|
||||
|
||||
|
||||
def test_review_geeft_blokkerende_validatiefout_door():
|
||||
r = networks.review({**BASIS, "subnet": "onzin"})
|
||||
assert r["ok"] is False
|
||||
assert any("subnet" in e.lower() for e in r["errors"])
|
||||
|
||||
|
||||
def test_review_range_buiten_subnet():
|
||||
r = networks.review({**BASIS, "ip_range": "10.9.9.0/28"})
|
||||
assert r["ok"] is False
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_review_endpoint(client):
|
||||
from conftest import login
|
||||
csrf = login(client)
|
||||
r = client.post("/api/networks/review", json=BASIS,
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
d = r.get_json()
|
||||
assert d["ok"] is True
|
||||
assert d["info"]["count"] == 14
|
||||
|
||||
|
||||
def test_networks_endpoint_geeft_detectie_terug(client, monkeypatch):
|
||||
from conftest import login
|
||||
from core import networks as nw
|
||||
login(client)
|
||||
monkeypatch.setattr(nw, "detect_host_networks", lambda force=False: {
|
||||
"networks": [{"interface": "eth0", "subnet": "192.168.1.0/24",
|
||||
"gateway": "192.168.1.1", "suggested_range": "192.168.1.240/28",
|
||||
"driver": "macvlan", "name": "lan"}],
|
||||
"error": ""})
|
||||
d = client.get("/api/networks").get_json()
|
||||
assert d["detected"][0]["interface"] == "eth0"
|
||||
assert d["interfaces"] == ["eth0"]
|
||||
|
||||
|
||||
def test_detectiefout_wordt_doorgegeven(client, monkeypatch):
|
||||
from conftest import login
|
||||
from core import networks as nw
|
||||
login(client)
|
||||
monkeypatch.setattr(nw, "detect_host_networks", lambda force=False: {
|
||||
"networks": [], "error": "docker niet bereikbaar"})
|
||||
d = client.get("/api/networks").get_json()
|
||||
assert d["detect_error"] == "docker niet bereikbaar"
|
||||
assert d["detected"] == []
|
||||
Loading…
Reference in a new issue