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 = { -