← zurück zur Toolbox
subnet_coach_v1.py · Quelltext
132586 Bytes · SHA-256: 2bee4319f59b3deeba67bec8bd530f7043dc0afe55bbd08081bfc951a5519a2a
#!/usr/home/jozapf/public_html/toolenv/bin/python
"""IPv4-Subnetting und IPv6-Grundlagen mit vollständigem Rechenweg - von der binären Herleitung bis zum VLSM-Adresskonzept mit Export, plus Kalkulationstrainer mit teilbaren Links.
Subnetz-Kalkulator - Toolbox-Tool (jozapf.de).
Das Produkt ist der ERKLÄRTE WEG zum Ergebnis, nicht die Zahl am Ende:
jede Antwort kommt mit binärer Zerlegung (Netz-/Hostbits), Merksätzen und
der Quelle der angewandten Regel. Der Trainings-Modus erzeugt Aufgaben
deterministisch aus einem Seed im Query-String - gleiche URL = gleiche
Aufgabe, teilbar und reproduzierbar.
Angewandte Standards (jede Regel im Rechenweg nennt ihre Quelle):
- RFC 950 Subnetting (Netz-/Hostanteil, Broadcast)
- RFC 4632 CIDR / Präfix-Notation (löst die Klassen von RFC 791 ab)
- RFC 1918 private Adressbereiche
- RFC 3021 /31-Punkt-zu-Punkt-Netze (Sonderfall ohne Broadcast)
- RFC 3927 Link-Local 169.254/16 (APIPA - "DHCP nicht erreicht")
- RFC 6598 Carrier-Grade NAT 100.64/10
Architektur (bewusst modular, eine Datei für die Toolbox-Auslieferung):
1. SCHEMA - Aufgabentypen-Katalog, Stufen, wiederkehrende Textbausteine.
2. DOMAIN - reine Logik: Rechenweg-Engine + Aufgabengenerator (seed-fest).
3. REQUEST - Query-String -> CoachConfig (HTTP von Domäne entkoppelt).
4. RENDER - Domänen-Objekte -> HTML (funktioniert ohne JavaScript).
5. ENTRY - CGI (stdout), Dev-Server (--serve), Self-Test (--self-test).
Abgrenzung: organisationsweite VLAN-/Policy-Strukturen macht der
Net+VLAN Planner (Geschwister-Tool) - hier geht es um den Weg vom
Client-Bedarf zum Subnetz und um das Verstehen der Rechnung.
Deploy: als CGI wie die anderen Toolbox-Tools (gibt Content-Type-Header aus).
Lokal: python3 subnet_coach_v1.py --serve 8080 -> http://localhost:8080
Test: python3 subnet_coach_v1.py --self-test
"""
from __future__ import annotations
import html
import ipaddress
import os
import random
import re
import sys
from dataclasses import dataclass, field
from urllib.parse import parse_qs, urlencode
VERSION = "1.5.1"
# Kachel-Marker für die Toolbox-Landingpage: index.py scannt die ersten ~80 Zeilen
# aller *.py statisch nach genau dieser einen Kommentarzeile (JSON auf einer Zeile,
# das Tool wird dafuer nie ausgefuehrt). "desc" == TILE_DESCRIPTION - synchron halten.
# TOOLBOX-TILE: {"title": "Subnetz-Kalkulator", "desc": "IPv4-Subnetting und IPv6-Grundlagen mit vollständigem Rechenweg — von der binären Herleitung bis zum VLSM-Adresskonzept mit Export, plus Kalkulationstrainer mit teilbaren Links.", "icon": "🧭", "type": "web", "example": "?ip=192.168.10.77&prefix=26", "order": 36}
TILE_DESCRIPTION = ("IPv4-Subnetting und IPv6-Grundlagen mit vollständigem Rechenweg — "
"von der binären Herleitung bis zum VLSM-Adresskonzept mit Export, "
"plus Kalkulationstrainer mit teilbaren Links.")
# =============================================================================
# 0. CONFIG - alles Änderbare an einem Ort (Ziel: Übertragbarkeit)
# =============================================================================
TOOL_TITLE = "Subnetz-Kalkulator"
TOOLBOX_URL = "/" # Link "zur Toolbox" im Footer
PLANNER_URL = "/net_vlan_planner_v1.py" # Geschwister-Tool (Abgrenzung s. Docstring)
FONTS_CSS = "https://assets.jozapf.de/css/fonts.css"
DEFAULT_EXAMPLE_IP = "192.168.10.77" # Erstaufruf: Beispiel statt leerem Formular
DEFAULT_EXAMPLE_PREFIX = 26
SEED_MIN, SEED_MAX = 100000, 999999 # sechsstellige, gut diktierbare Seeds
MAX_HOSTS_INPUT = 1_000_000 # Obergrenze für "Präfix für n Clients"
MAX_DEMANDS = 20 # Planen: max. Bereiche (URL-Länge, Übersicht)
MAX_NAME_LEN = 40 # Planen: max. Länge eines Bereichsnamens
WATERMARK = "<!-- jozapf.de toolbox · rev jzt-7c3f9a2e -->"
# =============================================================================
# 1. SCHEMA - Aufgabentypen, Stufen, wiederkehrende Textbausteine
# =============================================================================
# Quellenangaben, auf die Rechenweg-Schritte verweisen (Kürzel -> Langform).
SOURCES = {
"rfc950": "RFC 950 (Subnetting)",
"rfc4632": "RFC 4632 (CIDR)",
"rfc1918": "RFC 1918 (private Adressbereiche)",
"rfc3021": "RFC 3021 (/31-Punkt-zu-Punkt)",
"rfc3927": "RFC 3927 (Link-Local/APIPA)",
"rfc6598": "RFC 6598 (Carrier-Grade NAT)",
"rfc791": "RFC 791 (historische Klassen)",
"rfc4291": "RFC 4291 (IPv6-Adressarchitektur)",
"rfc5952": "RFC 5952 (IPv6-Textdarstellung)",
"rfc4193": "RFC 4193 (Unique Local Addresses)",
"rfc3849": "RFC 3849 (IPv6-Dokumentationsbereich)",
"rfc4862": "RFC 4862 (SLAAC)",
}
TASK_TYPES = {
# key: (Anzeigename, Kurzbeschreibung für die Typ-Auswahl)
"netz": ("Netz & Broadcast", "Netzadresse, Broadcast, Hostbereich bestimmen"),
"hosts": ("Hostanzahl", "Hosts zählen bzw. passendes Präfix wählen"),
"maske": ("Maske umrechnen", "Präfix ↔ Dezimalmaske (+ Wildcard)"),
"same": ("Gleiches Subnetz?", "Erreichen sich zwei Adressen ohne Router?"),
"subnetze": ("Subnetze bilden", "Ein Netz in n gleich große Teile teilen"),
"vlsm": ("VLSM", "Bedarfe unterschiedlicher Größe zuordnen"),
}
TASK_ORDER = ["netz", "hosts", "maske", "same", "subnetze", "vlsm"]
# IPv6-Aufgabentypen: Schlüssel DISJUNKT zu den v4-Typen - der Typ-String ist
# Teil des rng-Seeds und diskriminiert zugleich die Familie (kein eigener
# URL-Parameter nötig; TASK_ORDER bleibt unantastbar, sonst kippen alte
# mix-URLs). Katalog nach dreifacher Recherche: Curriculum-Kanon,
# Übungspraxis/Fehlerbilder, Generator-Architektur.
TASK_TYPES_V6 = {
"notation": ("Notation", "Kürzen ↔ Expandieren nach RFC 5952"),
"adresstyp": ("Adresstyp", "GUA, ULA, Link-Local & Co. zuordnen"),
"netzpraefix": ("Netz-Präfix", "Netzanteil einer Adresse bestimmen"),
"sub64": ("n-tes /64", "Subnetz-IDs hexadezimal bilden"),
"teilen": ("Netz teilen", "in n Subnetze - auch abseits der 4-Bit-Grenze"),
}
TASK_ORDER_V6 = ["notation", "adresstyp", "netzpraefix", "sub64", "teilen"]
ALL_TASK_TYPES = {**TASK_TYPES, **TASK_TYPES_V6}
LEVELS = {1: "Grundlagen", 2: "Standard", 3: "anspruchsvoll"}
# Abteilungsnamen für VLSM-Aufgaben (bewusst generisch, keine echten Daten).
VLSM_NAMES = ["Verwaltung", "Vertrieb", "Produktion", "IT", "Lager", "Empfang",
"Buchhaltung", "Aussendienst", "Schulungsraum", "Server-Segment"]
# =============================================================================
# 2. DOMAIN - Rechenweg-Engine + Aufgabengenerator (reine Logik, kein HTTP)
# =============================================================================
@dataclass
class BinRow:
"""Eine Zeile Binärdarstellung: 4 Oktette, optisch getrennt am Präfix."""
label: str
octets: list[str] # ['11000000', '10101000', ...]
dotted: str # '192.168.10.77'
split: int = -1 # Präfixlänge für die Netz-/Hostbit-Trennung (-1 = keine)
@dataclass
class Step:
"""Ein Schritt im Rechenweg. `text` ist Klartext; `Backticks` werden im
RENDER zu <code>. `rows` ist eine optionale Binärtabelle, `source` das
Kürzel der angewandten Regel (SCHEMA.SOURCES)."""
title: str
text: str
rows: list[BinRow] = field(default_factory=list)
source: str = ""
def _bin_octets(value: int) -> list[str]:
return [format((value >> shift) & 0xFF, "08b") for shift in (24, 16, 8, 0)]
def _row(label: str, addr: int, dotted: str, split: int = -1) -> BinRow:
return BinRow(label, _bin_octets(addr), dotted, split)
@dataclass
class BitStep:
"""Ein Schritt der Stellenwert-Methode (Dezimal -> Binär) für ein Oktett:
Passt der Stellenwert noch in den Rest? Ja -> 1 und abziehen, Nein -> 0."""
place: int # 128, 64, 32, 16, 8, 4, 2, 1
rest_before: int
fits: bool
rest_after: int
def octet_steps(value: int) -> list[BitStep]:
"""Die 8 Fragen der Stellenwert-Methode für eine Zahl 0-255 (Baustein
'Umrechnen'): dieselbe Rechnung, die der Rechenweg binär voraussetzt."""
if not 0 <= value <= 255:
raise ValueError("Oktett muss zwischen 0 und 255 liegen")
rest = value
out: list[BitStep] = []
for place in (128, 64, 32, 16, 8, 4, 2, 1):
fits = rest >= place
out.append(BitStep(place, rest, fits, rest - place if fits else rest))
if fits:
rest -= place
return out
def hosts_for_prefix(prefix: int) -> int:
"""Nutzbare Host-Adressen je Präfix - mit den Sonderfällen /31 und /32."""
if prefix >= 32:
return 1
if prefix == 31:
return 2 # RFC 3021: Punkt-zu-Punkt, beide Adressen nutzbar
return 2 ** (32 - prefix) - 2
def classify(ip: ipaddress.IPv4Address) -> list[tuple[str, str, str]]:
"""Einordnung einer Adresse als (Kurzlabel, Erklärung, Quellen-Kürzel).
Wird kompakt als Badge-Zeile gerendert (Erklärung im title-Tooltip),
nicht mehr als eigene Rechenweg-Schritte - spart vertikalen Raum."""
out: list[tuple[str, str, str]] = []
if ip in ipaddress.ip_network("10.0.0.0/8") or \
ip in ipaddress.ip_network("172.16.0.0/12") or \
ip in ipaddress.ip_network("192.168.0.0/16"):
out.append(("privater Adressbereich", "im Internet nicht routbar, "
"typisch für Firmen- und Heimnetze", "rfc1918"))
elif ip in ipaddress.ip_network("127.0.0.0/8"):
out.append(("Loopback", "immer der eigene Rechner", "rfc791"))
elif ip in ipaddress.ip_network("169.254.0.0/16"):
out.append(("Link-Local/APIPA", "ein Client mit so einer Adresse hat "
"seinen DHCP-Server nicht erreicht (häufiges Störungsbild)", "rfc3927"))
elif ip in ipaddress.ip_network("100.64.0.0/10"):
out.append(("Carrier-Grade NAT", "Provider-interner Übergangsbereich", "rfc6598"))
elif ip.is_multicast:
out.append(("Multicast", "historisch Klasse D - keine Host-Adresse", "rfc791"))
else:
out.append(("öffentlich routbar", "weltweit eindeutige Adresse", "rfc4632"))
first = int(ip) >> 24
klass = ("A" if first < 128 else "B" if first < 192 else
"C" if first < 224 else "D" if first < 240 else "E")
out.append((f"Klasse {klass} (historisch)", "seit CIDR (1993) technisch "
"bedeutungslos, als Begriff aber weiterhin verbreitet", "rfc4632"))
return out
def _octet_step(ip: ipaddress.IPv4Address, net: ipaddress.IPv4Network) -> Step | None:
"""Der Dezimal-Schnellweg: Präfix-Grenze im Oktett verorten, Blockgröße
ablesen, Netzadresse ohne Binärrechnung bestimmen. Genau dieser Schritt
entscheidet bei Präfixen abseits der Oktettgrenzen (/17, /21, /26 ...)."""
prefix = net.prefixlen
if not 1 <= prefix <= 30:
return None
if prefix % 8 == 0:
o = prefix // 8
return Step(
"Oktettgrenze nutzen",
f"Die Präfix-Grenze von `/{prefix}` fällt genau auf die Grenze "
f"zwischen {o}. und {o + 1}. Oktett: die ersten {o} Oktette der IP "
f"bleiben unverändert Netzanteil, alle folgenden werden für die "
f"Netzadresse 0 - ganz ohne Binärrechnung: `{net.network_address}`.",
source="rfc4632",
)
o = prefix // 8 + 1
mask_octet = int(str(net.netmask).split(".")[o - 1])
block = 256 - mask_octet
ip_octet = int(str(ip).split(".")[o - 1])
lower = ip_octet // block * block
reihe = ", ".join(str(v) for v in range(0, min(4 * block, 256), block))
return Step(
f"Oktettgrenze: /{prefix} liegt im {o}. Oktett",
f"Schnellweg ohne Binärrechnung: Die Präfix-Grenze liegt mitten im "
f"{o}. Oktett (Masken-Oktett dort `{mask_octet}`). Blockgröße = "
f"256 − {mask_octet} = `{block}` - die Netzadressen springen im "
f"{o}. Oktett also in {block}er-Schritten ({reihe}, …). Das "
f"{o}. Oktett der IP ist `{ip_octet}`; das nächstkleinere Vielfache "
f"von {block} ist `{lower}` → Netzadresse `{net.network_address}`.",
source="rfc950",
)
@dataclass
class Analyse:
"""Ergebnis von Baustein A: alle Kennwerte plus der Rechenweg dorthin."""
ip: ipaddress.IPv4Address
prefix: int
network: ipaddress.IPv4Network
steps: list[Step]
@property
def netmask(self) -> str:
return str(self.network.netmask)
@property
def wildcard(self) -> str:
return str(self.network.hostmask)
@property
def net_addr(self) -> str:
return str(self.network.network_address)
@property
def broadcast(self) -> str:
return str(self.network.broadcast_address)
@property
def usable(self) -> int:
return hosts_for_prefix(self.prefix)
@property
def first_host(self) -> str:
if self.prefix >= 31:
return str(self.network.network_address)
return str(self.network.network_address + 1)
@property
def last_host(self) -> str:
if self.prefix >= 31:
return str(self.network.broadcast_address)
return str(self.network.broadcast_address - 1)
def analyse_subnetz(ip: ipaddress.IPv4Address, prefix: int) -> Analyse:
"""Der Kern von Baustein A: Kennwerte MIT vollständigem Rechenweg."""
net = ipaddress.ip_network(f"{ip}/{prefix}", strict=False)
mask_int = int(net.netmask)
steps: list[Step] = []
steps.append(Step(
"Netzadresse: IP UND Maske",
f"`/{prefix}` heißt: die ersten {prefix} Bits sind Netzanteil, die "
f"restlichen {32 - prefix} Hostanteil - als Dezimalmaske `{net.netmask}`. "
"Die Netzadresse entsteht, indem jedes IP-Bit mit dem Masken-Bit "
"UND-verknüpft wird - praktisch: Netzbits behalten, Hostbits auf 0 "
f"setzen. Ergebnis: `{net.network_address}`.",
rows=[
_row("IP-Adresse", int(ip), str(ip), split=prefix),
_row("Maske", mask_int, str(net.netmask), split=prefix),
_row("Netzadresse", int(net.network_address), str(net.network_address), split=prefix),
],
source="rfc950",
))
quick = _octet_step(ip, net)
if quick:
steps.append(quick)
if prefix == 31:
steps.append(Step(
"Sonderfall /31: Punkt-zu-Punkt",
"Ein /31 hat nur 2 Adressen und KEINEN klassischen Broadcast - "
"beide Adressen sind als Host-Adressen nutzbar. Gedacht für "
"Router-zu-Router-Verbindungen. Die übliche Formel (2^n − 2) gilt "
"hier ausdrücklich nicht.",
source="rfc3021",
))
elif prefix == 32:
steps.append(Step(
"Sonderfall /32: Einzeladresse",
"Ein /32 bezeichnet genau EINE Adresse (Host-Route) - es gibt "
"weder Netz- noch Broadcast-Adresse im üblichen Sinn.",
source="rfc4632",
))
else:
usable = hosts_for_prefix(prefix)
steps.append(Step(
"Broadcast und Hostbereich",
"Broadcast = das Gegenstück zur Netzadresse, alle Hostbits auf 1: "
f"`{net.broadcast_address}`. Netz- und Broadcast-Adresse sind für "
f"Clients tabu - daher nutzbare Hosts = 2^{32 - prefix} − 2 = "
f"`{usable}`, vom ersten Host `{net.network_address + 1}` "
f"(Netzadresse + 1) bis zum letzten `{net.broadcast_address - 1}` "
"(Broadcast − 1).",
rows=[_row("Broadcast", int(net.broadcast_address),
str(net.broadcast_address), split=prefix)],
source="rfc950",
))
return Analyse(ip=ip, prefix=prefix, network=net, steps=steps)
def same_subnet(ip1: ipaddress.IPv4Address, ip2: ipaddress.IPv4Address,
prefix: int) -> tuple[bool, list[Step]]:
"""Baustein A, Zusatzfrage: liegen zwei Adressen im selben Subnetz?"""
n1 = ipaddress.ip_network(f"{ip1}/{prefix}", strict=False)
n2 = ipaddress.ip_network(f"{ip2}/{prefix}", strict=False)
equal = n1 == n2
verdict = ("BEIDE im selben Subnetz - direkte Kommunikation ohne Router "
"möglich." if equal else
"VERSCHIEDENE Subnetze - Pakete müssen über ein Gateway/einen "
"Router. Fehlt der Weg dorthin, sieht das im Alltag aus wie "
"„Ziel nicht erreichbar“, obwohl beide Geräte laufen.")
steps = [Step(
"Beide Netzadressen bestimmen und vergleichen",
f"Für beide Adressen gilt dieselbe Maske `/{prefix}`. IP UND Maske "
f"liefert `{n1.network_address}` bzw. `{n2.network_address}`. {verdict}",
rows=[
_row(f"{ip1} → Netz", int(n1.network_address), str(n1.network_address), split=prefix),
_row(f"{ip2} → Netz", int(n2.network_address), str(n2.network_address), split=prefix),
],
source="rfc950",
)]
if 1 <= prefix <= 30 and prefix % 8:
o = prefix // 8 + 1
mask_octet = int(str(n1.netmask).split(".")[o - 1])
block = 256 - mask_octet
front1, front2 = str(ip1).split(".")[:o - 1], str(ip2).split(".")[:o - 1]
v1 = int(str(ip1).split(".")[o - 1]) // block * block
v2 = int(str(ip2).split(".")[o - 1]) // block * block
if front1 != front2:
befund = (f"Schon vor der Präfix-Grenze unterscheiden sich die Oktette "
f"({'.'.join(front1)} gegen {'.'.join(front2)}) - damit sind es "
"zwingend verschiedene Subnetze.")
elif v1 == v2:
befund = (f"`{ip1}` fällt dort in den Block ab {v1}, `{ip2}` ebenfalls - "
"gleicher Block, gleiches Subnetz.")
else:
befund = (f"`{ip1}` fällt dort in den Block ab {v1}, `{ip2}` in den Block "
f"ab {v2} - verschiedene Blöcke, verschiedene Subnetze.")
steps.append(Step(
f"Schnellweg: Blockgröße im {o}. Oktett",
f"Die Präfix-Grenze `/{prefix}` liegt mitten im {o}. Oktett, "
f"Masken-Oktett dort `{mask_octet}` → Blockgröße 256 − {mask_octet} "
f"= `{block}`. Alle Oktette davor müssen identisch sein, und das "
f"{o}. Oktett muss im selben {block}er-Block liegen. {befund}",
source="rfc950",
))
return equal, steps
def prefix_for_hosts(n: int) -> tuple[int, list[Step]]:
"""Baustein A, Zusatzfrage: kleinstes Präfix für n nutzbare Hosts."""
host_bits = 2
while hosts_for_prefix(32 - host_bits) < n:
host_bits += 1
if host_bits > 24:
break
prefix = 32 - host_bits
steps = [Step(
"Hostbits bestimmen",
f"Gesucht ist die kleinste Zahl Hostbits h mit 2^h − 2 ≥ {n}. "
f"2^{host_bits} − 2 = {2 ** host_bits - 2} ≥ {n} passt "
f"(2^{host_bits - 1} − 2 = {2 ** (host_bits - 1) - 2} wäre zu klein). "
f"Also {host_bits} Hostbits → Präfix `/{32 - host_bits}` mit "
f"`{2 ** host_bits - 2}` nutzbaren Adressen - Reserve: "
f"{2 ** host_bits - 2 - n}.",
source="rfc950",
)]
if n <= 2:
steps.append(Step(
"Praxis-Hinweis",
"Für reine Punkt-zu-Punkt-Strecken (2 Geräte) ist statt /30 auch "
"/31 zulässig und üblich - das spart die Hälfte der Adressen.",
source="rfc3021",
))
return prefix, steps
def split_subnets(net: ipaddress.IPv4Network, count: int) -> tuple[list[ipaddress.IPv4Network], int, list[Step]]:
"""Baustein B: ein Netz in `count` gleich große Subnetze teilen (count = 2^k)."""
extra_bits = (count - 1).bit_length()
new_prefix = net.prefixlen + extra_bits
if new_prefix > 30:
raise ValueError(f"/{net.prefixlen} lässt sich nicht in {count} nutzbare Subnetze teilen")
subnets = list(net.subnets(new_prefix=new_prefix))
step_octet = (new_prefix + 7) // 8 # Oktett des letzten Netzbits = dort springt die Netzadresse
steps = [
Step(
"Zusätzliche Netzbits",
f"{count} Subnetze brauchen {extra_bits} zusätzliche Netzbits "
f"(2^{extra_bits} = {2 ** extra_bits}). Neues Präfix: "
f"`/{net.prefixlen}` + {extra_bits} = `/{new_prefix}` "
f"(Maske `{subnets[0].netmask}`).",
source="rfc950",
),
Step(
"Schrittweite und Subnetz-Liste",
f"Jedes Subnetz umfasst 2^{32 - new_prefix} = {2 ** (32 - new_prefix)} "
f"Adressen, davon `{hosts_for_prefix(new_prefix)}` nutzbar. Die "
f"Netzadressen springen daher im {step_octet}. Oktett in "
f"{max(1, 2 ** (32 - new_prefix) >> (8 * (4 - step_octet)))}er-Schritten: "
+ ", ".join(f"`{s.network_address}`" for s in subnets[:4])
+ (" …" if count > 4 else "") + ".",
source="rfc4632",
),
]
return subnets, new_prefix, steps
@dataclass
class VlsmAlloc:
name: str
hosts_needed: int
network: ipaddress.IPv4Network
@property
def usable(self) -> int:
return hosts_for_prefix(self.network.prefixlen)
def vlsm_plan(net: ipaddress.IPv4Network,
demands: list[tuple[str, int]]) -> tuple[list[VlsmAlloc], list[Step]]:
"""Baustein B: VLSM - Bedarfe absteigend sortiert lückenlos zuteilen."""
ordered = sorted(demands, key=lambda d: d[1], reverse=True)
steps = [Step(
"Sortieren: größter Bedarf zuerst",
"VLSM vergibt die Blöcke von groß nach klein - so bleiben keine "
"unbrauchbaren Lücken zwischen den Subnetzen. Reihenfolge: "
+ ", ".join(f"{name} ({n} Hosts)" for name, n in ordered) + ".",
source="rfc4632",
)]
allocs: list[VlsmAlloc] = []
cursor = int(net.network_address)
end = int(net.broadcast_address) + 1
for name, n in ordered:
prefix, _ = prefix_for_hosts(n)
size = 2 ** (32 - prefix)
# auf die eigene Blockgröße ausrichten (bei absteigender Vergabe
# ist der Cursor bereits ausgerichtet - das Alignment ist die Regel,
# auf die es in Stufe-3-Aufgaben ankommt)
if cursor % size:
cursor += size - (cursor % size)
if cursor + size > end:
raise ValueError(f"Bedarf '{name}' passt nicht mehr in {net}")
alloc = ipaddress.ip_network((cursor, prefix))
allocs.append(VlsmAlloc(name, n, alloc))
steps.append(Step(
f"{name}: {n} Hosts → /{prefix}",
f"Kleinstes Präfix mit 2^h − 2 ≥ {n}: `/{prefix}` "
f"({hosts_for_prefix(prefix)} nutzbar). Nächster freier, "
f"passend ausgerichteter Block: `{alloc}` "
f"(Hostbereich `{alloc.network_address + 1}` – "
f"`{alloc.broadcast_address - 1}`).",
source="rfc4632",
))
cursor += size
rest = end - cursor
steps.append(Step(
"Ergebnis prüfen",
f"Vergeben: {cursor - int(net.network_address)} von "
f"{end - int(net.network_address)} Adressen in `{net}` - "
f"Reserve für Wachstum: {rest} Adressen.",
source="rfc4632",
))
return allocs, steps
# --- IPv6-Grundlagen (Baustein D) --------------------------------------------
# Bewusst Grundlagen, kein v4-Parity: Notation, Adresstyp, Präfix-Logik,
# Subnetz-Denke. Prüf-Reihenfolge der Typen ist relevant (2001:db8::/32
# liegt INNERHALB von 2000::/3 und muss davor erkannt werden).
V6_TYPES: list[tuple[str, str, str, str]] = [
("::1/128", "Loopback", "immer der eigene Rechner - das Gegenstück zu 127.0.0.1", "rfc4291"),
("::/128", "Unspezifiziert", "„noch keine Adresse“ - das Gegenstück zu 0.0.0.0", "rfc4291"),
("2001:db8::/32", "Dokumentationsbereich", "reserviert für Beispiele und Anleitungen - "
"taucht in echten Netzen nie auf", "rfc3849"),
("fe80::/10", "Link-Local", "entsteht automatisch auf jeder Schnittstelle - anders als "
"APIPA bei v4 kein Störungsbild, sondern Normalzustand", "rfc4291"),
("fc00::/7", "ULA (Unique Local)", "privater Bereich, das Gegenstück zu RFC 1918 - "
"in der Praxis beginnen ULAs mit fd", "rfc4193"),
("ff00::/8", "Multicast", "ersetzt bei IPv6 auch den Broadcast", "rfc4291"),
("2000::/3", "Global Unicast (GUA)", "öffentlich routbar - das Gegenstück zur "
"öffentlichen v4-Adresse", "rfc4291"),
]
def classify_v6(ip: ipaddress.IPv6Address) -> tuple[str, str, str]:
"""Adresstyp einer IPv6-Adresse: (Kurzlabel, Erklärung, Quellen-Kürzel)."""
for net, label, detail, source in V6_TYPES:
if ip in ipaddress.ip_network(net):
return label, detail, source
return ("Sonder-/Reserviert-Bereich",
"außerhalb der gängigen Zuweisungen (Unicast 2000::/3, ULA, Link-Local, Multicast)",
"rfc4291")
def notation_steps_v6(ip: ipaddress.IPv6Address) -> list[Step]:
"""Die drei Notations-Schritte (Vollform, beide Kürzungsregeln) - von
analyse_v6 UND dem Aufgabentyp 'notation' genutzt (eine Quelle)."""
blocks = ip.exploded.split(":")
steps = [Step(
"Vollform: 8 Blöcke à 16 Bit",
f"Eine IPv6-Adresse hat 128 Bit, geschrieben als 8 Blöcke mit je 4 "
f"Hexadezimalstellen (1 Hexstelle = 4 Bit): `{ip.exploded}`.",
source="rfc4291",
)]
trimmed = [b.lstrip("0") or "0" for b in blocks]
steps.append(Step(
"Kürzungsregel 1: führende Nullen je Block streichen",
"In jedem Block dürfen führende Nullen weg (ein Block bleibt aber "
f"mindestens eine Stelle): `{':'.join(trimmed)}`.",
source="rfc5952",
))
runs: list[tuple[int, int]] = [] # (start, laenge) der Nullblock-Folgen
i = 0
while i < 8:
if int(blocks[i], 16) == 0:
j = i
while j < 8 and int(blocks[j], 16) == 0:
j += 1
runs.append((i, j - i))
i = j
else:
i += 1
best = max(runs, key=lambda r: r[1], default=(0, 0))
if best[1] >= 2:
steps.append(Step(
"Kürzungsregel 2: längste Nullfolge wird ::",
f"Die längste Folge von Nullblöcken (Block {best[0] + 1} bis "
f"{best[0] + best[1]}, {best[1]} Blöcke) wird genau EINMAL durch "
"`::` ersetzt - bei Gleichstand die erste Folge, und nie an zwei "
f"Stellen (sonst wäre die Adresse mehrdeutig). Kürzform: `{ip.compressed}`.",
source="rfc5952",
))
else:
steps.append(Step(
"Kürzungsregel 2: hier kein ::",
"`::` ersetzt nur Folgen von MINDESTENS zwei Nullblöcken - ein "
"einzelner Nullblock wird als `0` geschrieben. Kürzform: "
f"`{ip.compressed}`.",
source="rfc5952",
))
return steps
def classify_step_v6(ip: ipaddress.IPv6Address) -> Step:
"""classify_v6 als Rechenweg-Schritt (für den Aufgabentyp 'adresstyp')."""
label, detail, source = classify_v6(ip)
return Step(f"{ip.compressed} → {label}",
f"{detail}. Erkennungsweg: das Präfix der Adresse mit den "
"bekannten Bereichen abgleichen - Sonderbereiche (wie der "
"Dokumentationsbereich) VOR dem großen Unicast-Bereich prüfen.",
source=source)
def nth_subnet_v6(net: ipaddress.IPv6Network, index: int,
new_prefix: int = 64) -> tuple[ipaddress.IPv6Network, list[Step]]:
"""Das n-te Subnetz (Standard: /64) aus einem Block - arithmetisch statt
Aufzählung (ein /48 hat 65 536 Subnetze). Rechenweg: Subnetz-Bits
verorten, Index nach Hex wandeln, einsetzen."""
sub_bits = new_prefix - net.prefixlen
if sub_bits <= 0 or index >= 2 ** sub_bits:
raise ValueError("Index passt nicht in die Subnetz-Bits")
sub = ipaddress.IPv6Network((int(net.network_address)
+ (index << (128 - new_prefix)), new_prefix))
hex_id = format(index, "x")
steps = [Step(
f"Subnetz-Bits verorten: /{net.prefixlen} → /{new_prefix}",
f"Zwischen Präfix und Interface-Anteil liegen {sub_bits} Subnetz-Bits "
f"(= {sub_bits // 4} Hexstellen{' + Rest' if sub_bits % 4 else ''}). "
f"Dort wird die Subnetz-ID eingetragen - gezählt wird hexadezimal.",
source="rfc4291",
), Step(
f"Index {index} → Hexadezimal",
f"Dezimal {index} = Hex `{hex_id}` (bei Bedarf mit führenden Nullen "
f"auf die Stellenzahl der Subnetz-ID auffüllen). Eingesetzt ergibt "
f"das `{sub.compressed}`.",
source="rfc4291",
)]
return sub, steps
def split_v6(net: ipaddress.IPv6Network,
count: int) -> tuple[list[ipaddress.IPv6Network], int, int, list[Step]]:
"""Ein v6-Netz in `count` gleich große Subnetze teilen: (erste vier
Subnetze, neues Präfix, Schrittweite im betroffenen Block, Rechenweg).
Deckt auch den Fall ABSEITS der Nibble-Grenze ab (z. B. /56 → 4 x /58,
Schrittweite 0x40) - dort scheitern Lernende laut Praxis am häufigsten:
die Präfixlänge gelingt, die konkreten Subnetzadressen nicht."""
extra_bits = (count - 1).bit_length()
new_prefix = net.prefixlen + extra_bits
if new_prefix > 64:
raise ValueError("Teilung würde unter /64 gehen")
size = 1 << (128 - new_prefix)
subnets = [ipaddress.IPv6Network((int(net.network_address) + i * size, new_prefix))
for i in range(min(count, 4))]
block_i = (new_prefix - 1) // 16 # Block, in dem sich die Adressen ändern
step_in_block = (size >> (16 * (7 - block_i))) & 0xFFFF
steps = [Step(
"Neues Präfix bestimmen",
f"{count} Subnetze brauchen {extra_bits} zusätzliche Bits "
f"(2^{extra_bits} = {2 ** extra_bits}): `/{net.prefixlen}` + {extra_bits} "
f"= `/{new_prefix}`.",
source="rfc4291",
)]
if new_prefix % 4:
steps.append(Step(
"Achtung: Grenze mitten in der Hexstelle",
f"/{new_prefix} ist kein Vielfaches von 4 - die Grenze läuft durch "
f"eine Hexstelle von Block {block_i + 1}. Deshalb binär denken: "
f"die Schrittweite ist 2^{(16 * (block_i + 1)) - new_prefix} an der "
f"Blockposition, hexadezimal `0x{step_in_block:x}`. (Die Betriebspraxis "
"bevorzugt Teilungen an 4-Bit-Grenzen, weil genau hier Fehler "
"entstehen - rechnen können muss man es trotzdem.)",
source="rfc4291",
))
else:
steps.append(Step(
"Schrittweite an der Hex-Grenze",
f"Die Grenze fällt auf eine ganze Hexstelle - die Subnetz-IDs "
f"zählen in Block {block_i + 1} einfach hexadezimal hoch "
f"(Schrittweite `0x{step_in_block:x}`).",
source="rfc4291",
))
steps.append(Step(
"Subnetze aufzählen",
"Die ersten Subnetze: " + ", ".join(f"`{s.compressed}`" for s in subnets)
+ (" …" if count > 4 else "") + f" - insgesamt {count} Stück.",
source="rfc4291",
))
return subnets, new_prefix, step_in_block, steps
def analyse_v6(ip: ipaddress.IPv6Address, prefix: int) -> tuple[ipaddress.IPv6Network, list[Step]]:
"""Der IPv6-Rechenweg: Notation (RFC 5952), Präfix-Trennung, Subnetz-Denke."""
net = ipaddress.ip_network(f"{ip}/{prefix}", strict=False)
steps = notation_steps_v6(ip)
if prefix % 16 == 0:
grenze = (f"Die Grenze fällt auf eine Blockgrenze: die ersten "
f"{prefix // 16} Blöcke sind Netzanteil.")
else:
grenze = (f"Die Grenze läuft mitten durch Block {prefix // 16 + 1} - "
"Präfixe, die kein Vielfaches von 16 sind, teilen einen Block.")
steps.append(Step(
f"Präfix /{prefix}: Netz- und Interface-Anteil trennen",
f"Wie bei v4 gilt: die ersten {prefix} Bit sind Netzanteil. {grenze} "
f"Netz-Präfix: `{net.compressed}`.",
source="rfc4291",
))
if prefix < 64:
steps.append(Step(
"Subnetze zählen statt Hosts",
f"Aus einem /{prefix} lassen sich 2^{64 - prefix} = "
f"`{2 ** (64 - prefix):,}`".replace(",", " ") + " Standard-Subnetze "
"(/64) bilden. Typische Struktur: /48 für den Standort, 16 Bit "
"Subnetz-ID, /64 je Netzsegment.",
source="rfc4291",
))
elif prefix == 64:
steps.append(Step(
"Das /64-Endnetz: Hostzählerei entfällt",
"64 Bit Interface-ID = 2^64 Adressen je Subnetz - niemand zählt "
"mehr, ob es reicht. Es gibt keinen Broadcast (Multicast übernimmt) "
"und keine −2-Formel. Geplant wird in Subnetzen, nicht in Hosts; "
"die automatische Adressvergabe (SLAAC) setzt genau diese "
"64 Interface-Bits voraus.",
source="rfc4862",
))
else:
steps.append(Step(
f"Achtung: /{prefix} ist länger als /64",
"Endnetze sind bei IPv6 per Konvention /64 - längere Präfixe sind "
"Punkt-zu-Punkt-Sonderfälle und brechen die automatische "
"Adressvergabe (SLAAC).",
source="rfc4862",
))
return net, steps
@dataclass
class KonzeptZeile:
"""Eine Zeile des Adresskonzepts (Baustein 'Planen')."""
name: str
bedarf: int # eingegebene Clientzahl
mit_reserve: int # Bedarf inkl. Wachstumsreserve (das plant VLSM)
network: ipaddress.IPv4Network
gateway: str
static_range: str # "x – y" oder "–"
dhcp_range: str # "x – y" oder "–"
@property
def usable(self) -> int:
return hosts_for_prefix(self.network.prefixlen)
@property
def auslastung(self) -> int:
return round(self.mit_reserve / self.usable * 100)
def adresskonzept(net: ipaddress.IPv4Network, demands: list[tuple[str, int]],
reserve_pct: int, gw_last: bool,
static_per: int) -> tuple[list[KonzeptZeile], list[Step], int]:
"""Baustein 'Planen': VLSM-Zuteilung + Bereichsaufteilung je Subnetz
(Gateway-Konvention, statischer Block, DHCP-Pool). Liefert (Zeilen,
Rechenweg, Rest-Adressen im Basisnetz)."""
eff = [(name, n, (n * (100 + reserve_pct) + 99) // 100) for name, n in demands]
steps: list[Step] = []
if reserve_pct:
steps.append(Step(
f"Wachstumsreserve {reserve_pct} % aufschlagen",
"Geplant wird nicht der heutige Bedarf, sondern Bedarf + Reserve: "
+ "; ".join(f"{name} {n} → `{m}`" for name, n, m in eff) + ".",
source="rfc4632",
))
allocs, vlsm_steps = vlsm_plan(net, [(name, m) for name, n, m in eff])
steps.extend(vlsm_steps)
by_name = {name: (n, m) for name, n, m in eff}
rows: list[KonzeptZeile] = []
for al in allocs:
n, m = by_name[al.name]
first = al.network.network_address + 1
last = al.network.broadcast_address - 1
usable = hosts_for_prefix(al.network.prefixlen)
gw = last if gw_last else first
pool_start = first if gw_last else first + 1
pool_end = last - 1 if gw_last else last
static_n = max(0, min(static_per, usable - 1))
if static_n:
static_range = (f"{pool_start} – {pool_start + static_n - 1}"
if static_n > 1 else str(pool_start))
else:
static_range = "–"
dhcp_start = pool_start + static_n
dhcp_range = (f"{dhcp_start} – {pool_end}" if dhcp_start < pool_end
else (str(dhcp_start) if dhcp_start == pool_end else "–"))
rows.append(KonzeptZeile(al.name, n, m, al.network, str(gw),
static_range, dhcp_range))
rest = net.num_addresses - sum(r.network.num_addresses for r in rows)
steps.append(Step(
"Bereiche im Subnetz aufteilen",
f"Konvention: Gateway = {'letzte' if gw_last else 'erste'} nutzbare "
f"Adresse, danach {static_per} statische Adressen (Server, Drucker, "
"Management), der Rest ist DHCP-Pool. Bei kleinen Subnetzen wird der "
"statische Block automatisch gekürzt.",
source="rfc950",
))
return rows, steps, rest
def min_base_prefix(net: ipaddress.IPv4Network,
demands: list[tuple[str, int]]) -> int | None:
"""Wenn die Bedarfe nicht in `net` passen: das größte (= zahlenmäßig
kleinste) Präfix finden, in das sie ab derselben Basis passen würden."""
for p in range(net.prefixlen - 1, 7, -1):
candidate = net.supernet(new_prefix=p)
try:
vlsm_plan(candidate, demands)
return p
except ValueError:
continue
return None
# --- Aufgabengenerator (Baustein B) ------------------------------------------
# Deterministisch: random.Random(seed). Gleicher Seed + Typ + Stufe = gleiche
# Aufgabe. Die LÖSUNG wird nie separat erzeugt, sondern immer über dieselben
# DOMAIN-Funktionen berechnet wie im Erklär-Modus - eine Quelle, kein Drift.
@dataclass
class Task:
typ: str
level: int
seed: int
question: str # Fragetext (Klartext, `Backticks` -> code)
subquestions: list[str] # Teilfragen a), b), ...
solution_steps: list[Step] # vollständiger Rechenweg
answers: list[tuple[str, str]] # (Bezeichnung, Kurzantwort) für die Schnellkontrolle
def _rand_host_ip(rng: random.Random, net: ipaddress.IPv4Network) -> ipaddress.IPv4Address:
"""Zufällige Host-Adresse (nie Netz-/Broadcast-Adresse) aus einem Netz."""
return ipaddress.IPv4Address(
rng.randrange(int(net.network_address) + 1, int(net.broadcast_address)))
def _base_net(rng: random.Random, level: int) -> tuple[ipaddress.IPv4Address, int]:
"""IP + Präfix für Analyse-Aufgaben, nach Stufe kalibriert."""
if level == 1:
# Rechnen nur im 4. Oktett, privater Klassiker
prefix = rng.choice([25, 26, 27, 28, 29, 30])
base = ipaddress.IPv4Address(f"192.168.{rng.randrange(0, 255)}.0")
net = ipaddress.ip_network(f"{base}/24")
elif level == 2:
# Oktettgrenze im 3. Oktett kommt ins Spiel
prefix = rng.choice([20, 21, 22, 23, 25, 26, 27, 28])
first = rng.choice([10, 172, 192])
second = rng.randrange(16, 32) if first == 172 else (
168 if first == 192 else rng.randrange(0, 255))
net = ipaddress.ip_network(f"{first}.{second}.0.0/16")
else:
prefix = rng.choice([17, 18, 19, 20, 21, 22, 26, 27, 28, 29])
net = ipaddress.ip_network(f"10.{rng.randrange(0, 255)}.0.0/16")
ip = ipaddress.IPv4Address(
rng.randrange(int(net.network_address) + 1, int(net.broadcast_address)))
return ip, prefix
def _gen_netz(rng: random.Random, level: int) -> tuple[str, list[str], list[Step], list[tuple[str, str]]]:
ip, prefix = _base_net(rng, level)
a = analyse_subnetz(ip, prefix)
q = (f"Ein Client hat die Adresse `{ip}/{prefix}` "
f"(Maske `{a.netmask}`).")
subs = ["Netzadresse?", "Broadcast-Adresse?",
"Erster und letzter nutzbarer Host?", "Anzahl nutzbarer Host-Adressen?"]
answers = [("Netzadresse", a.net_addr), ("Broadcast", a.broadcast),
("Hostbereich", f"{a.first_host} – {a.last_host}"),
("nutzbare Hosts", str(a.usable))]
return q, subs, a.steps, answers
def _gen_hosts(rng: random.Random, level: int) -> tuple[str, list[str], list[Step], list[tuple[str, str]]]:
if level == 1 or rng.random() < 0.4:
prefix = rng.choice([22, 23, 24, 25, 26, 27, 28, 29, 30])
q = f"Gegeben ist ein Subnetz mit dem Präfix `/{prefix}`."
subs = ["Wie viele nutzbare Host-Adressen bietet es?"]
usable = hosts_for_prefix(prefix)
steps = [Step(
"Hostbits zählen",
f"`/{prefix}` lässt 32 − {prefix} = {32 - prefix} Hostbits. "
f"Nutzbar: 2^{32 - prefix} − 2 = {2 ** (32 - prefix)} − 2 = "
f"`{usable}` (Netz- und Broadcast-Adresse abziehen).",
source="rfc950",
)]
answers = [("nutzbare Hosts", str(usable))]
return q, subs, steps, answers
n = rng.choice([10, 14, 25, 30, 50, 60, 100, 120, 200, 250, 500, 1000][
: 8 if level == 2 else 12])
prefix, steps = prefix_for_hosts(n)
q = (f"Eine Abteilung braucht Adressen für `{n}` Geräte "
"(Gateway schon eingerechnet).")
subs = ["Welches ist das kleinste passende Präfix?",
"Wie viele Adressen bleiben als Reserve?"]
answers = [("Präfix", f"/{prefix}"),
("Reserve", str(hosts_for_prefix(prefix) - n))]
return q, subs, steps, answers
def _gen_maske(rng: random.Random, level: int) -> tuple[str, list[str], list[Step], list[tuple[str, str]]]:
prefix = rng.choice([8, 16, 24, 25, 26, 27] if level == 1 else
list(range(17, 31)))
net = ipaddress.ip_network(f"0.0.0.0/{prefix}")
to_dotted = rng.random() < 0.5
binrow = _row("Maske", int(net.netmask), str(net.netmask), split=prefix)
if to_dotted:
q = f"Gegeben ist die Präfix-Schreibweise `/{prefix}`."
subs = ["Wie lautet die Subnetzmaske in Dezimalschreibweise?"]
else:
q = f"Gegeben ist die Subnetzmaske `{net.netmask}`."
subs = ["Wie lautet die Präfix-Schreibweise (CIDR)?"]
steps = [Step(
"Bits übersetzen",
f"`/{prefix}` = {prefix} Einsen von links, Rest Nullen. Oktettweise "
f"ausgerechnet ergibt das `{net.netmask}`. Merkhilfe: die möglichen "
"Oktett-Werte sind 0, 128, 192, 224, 240, 248, 252, 254, 255 - "
"jede weitere 1 addiert die nächstkleinere Zweierpotenz.",
rows=[binrow],
source="rfc4632",
)]
answers = [("Maske", str(net.netmask)), ("Präfix", f"/{prefix}")]
if level >= 2:
subs.append("Zusatz: Wie lautet die Wildcard-Maske (Invertierung)?")
steps.append(Step(
"Wildcard = Maske invertiert",
f"Jedes Bit kippen: `{net.hostmask}`. Gebraucht wird das u. a. in "
"ACLs (Router-Zugriffslisten).",
source="rfc4632",
))
answers.append(("Wildcard", str(net.hostmask)))
return q, subs, steps, answers
def _gen_same(rng: random.Random, level: int) -> tuple[str, list[str], list[Step], list[tuple[str, str]]]:
if level == 1:
prefix = rng.choice([25, 26, 27])
supernet = ipaddress.ip_network(f"192.168.{rng.randrange(0, 255)}.0/24")
else:
prefix = rng.choice([21, 22, 23, 25, 26, 27, 28])
supernet = ipaddress.ip_network(f"10.{rng.randrange(0, 255)}.0.0/16")
subnets = list(supernet.subnets(new_prefix=prefix))
n1 = rng.choice(subnets[:-1])
if rng.random() < 0.5:
n2 = n1 # gleiches Subnetz
else:
n2 = subnets[subnets.index(n1) + 1] # das Nachbar-Subnetz - knapp daneben
ip1, ip2 = _rand_host_ip(rng, n1), _rand_host_ip(rng, n2)
equal, steps = same_subnet(ip1, ip2, prefix)
mask = ipaddress.ip_network(f"0.0.0.0/{prefix}").netmask
q = (f"Client A (`{ip1}`) erreicht das Gateway (`{ip2}`) nicht. "
f"Beide sind mit der Maske `{mask}` (= `/{prefix}`) konfiguriert "
"und hängen am selben Switch.")
subs = ["Liegen beide Adressen im selben Subnetz?",
"Was bedeutet das Ergebnis für die Fehlersuche?"]
answers = [("gleiches Subnetz?", "ja" if equal else "nein")]
return q, subs, steps, answers
def _gen_subnetze(rng: random.Random, level: int) -> tuple[str, list[str], list[Step], list[tuple[str, str]]]:
if level == 1:
net = ipaddress.ip_network(f"192.168.{rng.randrange(0, 255)}.0/24")
count = rng.choice([2, 4, 8])
elif level == 2:
net = rng.choice([
ipaddress.ip_network(f"172.{rng.randrange(16, 32)}.0.0/16"),
ipaddress.ip_network(f"192.168.{rng.randrange(0, 255)}.0/24"),
])
count = rng.choice([4, 8, 16])
else:
net = ipaddress.ip_network(f"10.{rng.randrange(0, 255)}.{rng.choice([0, 16, 32, 64, 128])}.0/20")
count = rng.choice([8, 16, 32])
subnets, new_prefix, steps = split_subnets(net, count)
q = f"Das Netz `{net}` soll in `{count}` gleich große Subnetze geteilt werden."
subs = ["Wie lautet die neue Präfixlänge (und Maske)?",
"Nenne die Netzadressen der ersten vier Subnetze.",
"Wie viele nutzbare Hosts hat jedes Subnetz?"]
answers = [("neues Präfix", f"/{new_prefix} ({subnets[0].netmask})"),
("erste Subnetze", ", ".join(str(s.network_address) for s in subnets[:4])),
("Hosts je Subnetz", str(hosts_for_prefix(new_prefix)))]
return q, subs, steps, answers
def _gen_vlsm(rng: random.Random, level: int) -> tuple[str, list[str], list[Step], list[tuple[str, str]]]:
if level <= 2:
net = ipaddress.ip_network(f"192.168.{rng.randrange(0, 255)}.0/24")
pool = [100, 60, 50, 25, 20, 10, 5, 2]
k = 3
else:
net = ipaddress.ip_network(f"172.{rng.randrange(16, 32)}.{rng.choice([0, 2, 4, 8])}.0/23")
pool = [200, 120, 100, 60, 50, 25, 20, 10, 2]
k = 4
names = rng.sample(VLSM_NAMES, k)
# Bedarfe wählen, bis die Blockgrößen ins Netz passen (deterministisch,
# weil ausschließlich rng-gesteuert weitergewürfelt wird).
for _ in range(50):
demands = list(zip(names, sorted(rng.sample(pool, k), reverse=True)))
need = sum(2 ** (32 - prefix_for_hosts(n)[0]) for _, n in demands)
if need <= net.num_addresses:
break
allocs, steps = vlsm_plan(net, demands)
q = (f"Das Netz `{net}` ist per VLSM aufzuteilen. Bedarf: "
+ "; ".join(f"{name} `{n}` Hosts" for name, n in demands) + ".")
subs = ["Welche Bedarfsreihenfolge gilt (und warum)?",
"Welches Subnetz (Netzadresse/Präfix) bekommt jeder Bereich?",
"Wie viele Adressen bleiben als Reserve?"]
rest = net.num_addresses - sum(a.network.num_addresses for a in allocs)
answers = [(a.name, f"{a.network} ({a.usable} nutzbar)") for a in allocs]
answers.append(("Reserve", f"{rest} Adressen"))
return q, subs, steps, answers
# --- IPv6-Generatoren --------------------------------------------------------
# Adress-Material: Basis 2001:db8::/32 (Dokumentationsbereich), Nullblock-
# Choreografie statt Gleichverteilung - rein zufällige Blöcke wären fast nie
# kürzbar und die Notations-Aufgabe trivial.
def _rand_v6(rng: random.Random, level: int) -> ipaddress.IPv6Address:
"""Kürzbare Doku-Adresse: Nullfolgen-Muster nach Stufe."""
blocks = [0x2001, 0x0db8] + [rng.randrange(0x1000, 0xFFFF) for _ in range(6)]
lead0 = rng.sample(range(2, 8), 2) # 1-2 Blöcke mit führenden Nullen
for i in lead0:
blocks[i] = rng.randrange(0x001, 0xFFF)
if level == 1:
start = rng.randrange(2, 6)
run = rng.choice([2, 3])
for i in range(start, start + run):
blocks[i] = 0
elif level == 2:
start = rng.randrange(2, 5)
for i in range(start, start + 2):
blocks[i] = 0
single = rng.choice([i for i in range(2, 8) if not start <= i <= start + 2
and i != start - 1])
blocks[single] = 0
else:
first_run = rng.choice([2, 3]) # bei 2: Gleichstand -> erste gewinnt
for i in range(2, 4):
blocks[i] = 0
for i in range(5, 5 + first_run):
if i < 8:
blocks[i] = 0
return ipaddress.IPv6Address(":".join(f"{b:04x}" for b in blocks))
def _gen_v6_notation(rng: random.Random, level: int):
ip = _rand_v6(rng, level)
to_short = rng.random() < 0.5
steps = notation_steps_v6(ip)
if to_short:
q = f"Gegeben ist die Vollform `{ip.exploded}`."
subs = ["Wie lautet die Kürzform nach RFC 5952?",
"Warum darf `::` nur an genau einer Stelle stehen?"]
else:
q = f"Gegeben ist die Kürzform `{ip.compressed}`."
subs = ["Wie lautet die Vollform (alle 8 Blöcke, 4 Stellen je Block)?",
"Für wie viele Nullblöcke steht das `::` hier?"]
visible = ip.compressed.replace("::", ":!:").split(":")
hidden = 8 - len([b for b in visible if b and b != "!"])
answers = [("Kürzform", ip.compressed), ("Vollform", ip.exploded),
("`::` steht für", f"{hidden} Nullblöcke")]
return q, subs, steps, answers
def _gen_v6_adresstyp(rng: random.Random, level: int):
def doku():
return f"2001:db8:{rng.randrange(0, 0xFFFF):x}::{rng.randrange(1, 0xFFFF):x}"
pools = {
1: [lambda: "::1", lambda: f"fe80::{rng.randrange(1, 0xFFFF):x}", doku,
lambda: "ff02::1"],
2: [lambda: f"fd{rng.randrange(0, 0xFF):02x}::{rng.randrange(1, 0xFFFF):x}",
lambda: f"2a00:{rng.randrange(1, 0xFFFF):x}::{rng.randrange(1, 0xFFFF):x}",
lambda: f"fe80::{rng.randrange(1, 0xFFFF):x}", doku, lambda: "::"],
3: [doku, # liegt in 2000::/3 - Doku gewinnt
lambda: f"fc{rng.randrange(0, 0xFF):02x}::{rng.randrange(1, 0xFFFF):x}",
lambda: f"fec0::{rng.randrange(1, 0xFFFF):x}", # veraltet Site-Local -> Sonderbereich
lambda: f"ff05::{rng.randrange(1, 0xFF):x}",
lambda: f"2003:{rng.randrange(1, 0xFFFF):x}::{rng.randrange(1, 0xFFFF):x}"],
}
picks = rng.sample(pools[level], 3)
ips = [ipaddress.IPv6Address(p()) for p in picks]
q = "Ordne jeder Adresse ihren Typ zu: " + "; ".join(
f"{chr(97 + i)}) `{ip.compressed}`" for i, ip in enumerate(ips))
subs = ["Welcher Adresstyp ist das jeweils - und woran erkennt man ihn?"]
steps = [classify_step_v6(ip) for ip in ips]
answers = [(f"{chr(97 + i)}) {ip.compressed}",
f"{classify_v6(ip)[0]}") for i, ip in enumerate(ips)]
return q, subs, steps, answers
def _gen_v6_netzpraefix(rng: random.Random, level: int):
ip = _rand_v6(rng, level if level < 3 else 2)
prefix = rng.choice({1: [32, 48, 64], 2: [52, 56, 60], 3: [52, 56, 60]}[level])
net, steps = analyse_v6(ip, prefix)
q = f"Ein Interface hat die Adresse `{ip.compressed}/{prefix}`."
subs = ["Wie lautet das Netz-Präfix?",
"Fällt die Präfix-Grenze auf eine Blockgrenze?"]
answers = [("Netz-Präfix", net.compressed),
("Blockgrenze?", "ja" if prefix % 16 == 0 else
f"nein - mitten in Block {prefix // 16 + 1}")]
if level == 3:
other = ipaddress.IPv6Address(int(net.network_address)
+ (rng.choice([0, 1]) << (128 - prefix))
+ rng.randrange(1, 1 << 64))
net2, steps2 = analyse_v6(other, prefix)
steps = steps + steps2
subs.append(f"Liegt `{other.compressed}` im selben Netz?")
answers.append(("gleiches Netz?", "ja" if net2 == net else "nein"))
return q, subs, steps, answers
def _gen_v6_sub64(rng: random.Random, level: int):
if level <= 2:
base = ipaddress.IPv6Network(f"2001:db8:{rng.randrange(1, 0xFFFF):x}::/48")
n = rng.randrange(0, 16) if level == 1 else rng.randrange(16, 4096)
else:
base = ipaddress.IPv6Network(
f"2001:db8:{rng.randrange(1, 0xFFFF):x}:{rng.randrange(0, 0xFF):02x}00::/56")
n = rng.randrange(1, 256)
sub, steps = nth_subnet_v6(base, n)
total = 2 ** (64 - base.prefixlen)
last, _ = nth_subnet_v6(base, total - 1)
q = (f"Ein Standort hat `{base.compressed}`. Jedes Netzsegment bekommt "
"ein /64. Gezählt wird ab 0.")
subs = [f"Wie lautet Subnetz Nr. `{n}`?", "Wie lautet das letzte mögliche /64?",
"Wie viele /64 gibt es insgesamt?"]
steps.append(Step(
"Letztes Subnetz und Gesamtzahl",
f"{base.prefixlen} → 64 lässt {64 - base.prefixlen} Subnetz-Bits: "
f"2^{64 - base.prefixlen} = `{total:,}`".replace(",", " ")
+ f" Subnetze; das letzte hat die höchste Subnetz-ID: `{last.compressed}`.",
source="rfc4291",
))
answers = [(f"Subnetz {n}", sub.compressed), ("letztes /64", last.compressed),
("Anzahl", f"{total:,}".replace(",", " "))]
return q, subs, steps, answers
def _gen_v6_teilen(rng: random.Random, level: int):
if level == 1:
base = ipaddress.IPv6Network(f"2001:db8:{rng.randrange(1, 0xFFFF):x}::/48")
count = 16
elif level == 2:
base = ipaddress.IPv6Network(
f"2001:db8:{rng.randrange(1, 0xFFFF):x}:{rng.choice(['00', '40', '80'])}00::/56")
count = 16
else:
# der Praxis-Klassiker: Teilung ABSEITS der 4-Bit-Grenze (/56 -> /57-/59)
base = ipaddress.IPv6Network(
f"2001:db8:{rng.randrange(1, 0xFFFF):x}:{rng.choice(['a0', 'b0', 'e0'])}00::/56")
count = rng.choice([2, 4, 8])
subnets, new_prefix, step_in_block, steps = split_v6(base, count)
q = f"Das Netz `{base.compressed}` soll in `{count}` gleich große Subnetze geteilt werden."
subs = ["Wie lautet die neue Präfixlänge?",
"Nenne die ersten vier Subnetze (Netzadresse/Präfix)."
if count >= 4 else "Nenne alle Subnetze (Netzadresse/Präfix).",
"Welche Schrittweite gilt im betroffenen Block?"]
answers = [("neues Präfix", f"/{new_prefix}"),
("Subnetze", ", ".join(s.compressed for s in subnets)),
("Schrittweite", f"0x{step_in_block:x} in Block {(new_prefix - 1) // 16 + 1}")]
return q, subs, steps, answers
_GENERATORS = {
"netz": _gen_netz,
"hosts": _gen_hosts,
"maske": _gen_maske,
"same": _gen_same,
"subnetze": _gen_subnetze,
"vlsm": _gen_vlsm,
"notation": _gen_v6_notation,
"adresstyp": _gen_v6_adresstyp,
"netzpraefix": _gen_v6_netzpraefix,
"sub64": _gen_v6_sub64,
"teilen": _gen_v6_teilen,
}
def generate_task(typ: str, level: int, seed: int) -> Task:
"""Deterministische Aufgabe: (typ, level, seed) legt alles fest."""
rng = random.Random(f"{typ}:{level}:{seed}")
if typ == "mix":
typ = rng.choice(TASK_ORDER)
elif typ == "mix6":
typ = rng.choice(TASK_ORDER_V6)
question, subs, steps, answers = _GENERATORS[typ](rng, level)
return Task(typ=typ, level=level, seed=seed, question=question,
subquestions=subs, solution_steps=steps, answers=answers)
# =============================================================================
# 3. REQUEST - Query-String -> CoachConfig (HTTP von Domäne entkoppelt)
# =============================================================================
@dataclass
class CoachConfig:
mode: str = "erklaeren" # erklaeren | binaer | ueben
submitted: bool = False
# Berechnen-/Umrechnen-Modus
ip: ipaddress.IPv4Address | None = None
ip2: ipaddress.IPv4Address | None = None
prefix: int | None = None
hosts: int | None = None # "Präfix für n Clients"
single_value: int | None = None # Umrechnen: einzelne Zahl 0-255 statt IP
# IPv6-Modus
ip6: ipaddress.IPv6Address | None = None
prefix6: int | None = None
# Planen-Modus (Mini-Adresskonzept)
base_net: ipaddress.IPv4Network | None = None
demands: list[tuple[str, int]] = field(default_factory=list)
reserve_pct: int = 0
gw_last: bool = False # Gateway-Konvention: letzte statt erste Adresse
static_per: int = 10 # statische Adressen je Bereich (nach dem Gateway)
export: str = "" # "", "md", "csv"
# Übungs-Modus
typ: str = "mix"
level: int = 2
seed: int | None = None
# Diagnose
warnings: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def _get1(q: dict, key: str, default: str = "") -> str:
return q.get(key, [default])[0].strip()
def parse_config(query: str) -> CoachConfig:
q = parse_qs(query, keep_blank_values=True)
cfg = CoachConfig()
raw_mode = _get1(q, "mode")
cfg.mode = raw_mode if raw_mode in ("ueben", "binaer", "planen", "ipv6") else "erklaeren"
if cfg.mode == "ipv6":
raw = _get1(q, "ip")
cfg.submitted = bool(raw)
if not cfg.submitted:
return cfg
addr_part, _, prefix_part = raw.partition("/")
addr_part = addr_part.strip()
try:
cfg.ip6 = ipaddress.IPv6Address(addr_part)
except ValueError:
cfg.errors.append(f"„{html.escape(addr_part)}“ ist keine gültige IPv6-Adresse.")
return cfg
if prefix_part:
try:
p = int(prefix_part)
if not 0 <= p <= 128:
raise ValueError
cfg.prefix6 = p
except ValueError:
cfg.errors.append(f"Präfix „{html.escape(prefix_part)}“ muss zwischen 0 und 128 liegen.")
return cfg
else:
cfg.prefix6 = 64
cfg.warnings.append("Kein Präfix angegeben - /64 als Standard-Endnetz angenommen.")
return cfg
if cfg.mode == "planen":
raw_net, raw_bedarf = _get1(q, "netz"), _get1(q, "bedarf")
cfg.submitted = bool(raw_net or raw_bedarf)
raw_export = _get1(q, "export")
cfg.export = raw_export if raw_export in ("md", "csv") else ""
cfg.gw_last = _get1(q, "gateway") == "letzte"
try:
cfg.reserve_pct = max(0, min(200, int(_get1(q, "reserve", "0") or "0")))
except ValueError:
cfg.reserve_pct = 0
try:
cfg.static_per = max(0, min(1000, int(_get1(q, "statisch", "10") or "10")))
except ValueError:
cfg.static_per = 10
if not cfg.submitted:
return cfg
try:
if "/" not in raw_net:
raise ValueError
net = ipaddress.ip_network(raw_net, strict=False)
except ValueError:
cfg.errors.append(f"„{html.escape(raw_net)}“ ist kein gültiges Basisnetz - "
"bitte mit Präfix angeben, z. B. 192.168.0.0/24.")
return cfg
if net.prefixlen > 30:
cfg.errors.append(f"Basisnetz /{net.prefixlen} ist zu klein, um Bereiche aufzunehmen.")
return cfg
if str(net.network_address) not in raw_net:
cfg.warnings.append(f"Eingabe auf die Netzadresse {net.network_address}/"
f"{net.prefixlen} normalisiert (Hostbits waren gesetzt).")
cfg.base_net = net
lines = [ln.strip() for ln in raw_bedarf.splitlines() if ln.strip()]
if not lines:
cfg.errors.append("Mindestens eine Bedarfszeile angeben - Format je Zeile: "
"Name, Clientzahl (z. B. „Verwaltung, 50“).")
return cfg
if len(lines) > MAX_DEMANDS:
cfg.errors.append(f"Höchstens {MAX_DEMANDS} Bereiche - {len(lines)} angegeben.")
return cfg
for i, line in enumerate(lines, start=1):
m = re.match(r"^(.*?)[\s,;:]*(\d+)\s*$", line)
if not m or not 1 <= int(m.group(2)) <= MAX_HOSTS_INPUT:
cfg.errors.append(f"Zeile {i} („{html.escape(line[:60])}“) nicht lesbar - "
"erwartet: Name, Clientzahl.")
continue
name = m.group(1).strip(" ,;:\t") or f"Bereich {i}"
cfg.demands.append((name[:MAX_NAME_LEN], int(m.group(2))))
return cfg
if cfg.mode == "binaer":
raw = _get1(q, "ip")
cfg.submitted = bool(raw)
if not cfg.submitted:
return cfg
if "." not in raw:
try:
n = int(raw)
if not 0 <= n <= 255:
raise ValueError
cfg.single_value = n
except ValueError:
cfg.errors.append(f"„{html.escape(raw)}“ ist weder eine IPv4-Adresse "
"noch eine Zahl zwischen 0 und 255.")
return cfg
try:
cfg.ip = ipaddress.IPv4Address(raw)
except ValueError:
cfg.errors.append(f"„{html.escape(raw)}“ ist keine gültige IPv4-Adresse.")
return cfg
raw_prefix, raw_mask = _get1(q, "prefix"), _get1(q, "mask")
if raw_prefix:
try:
p = int(raw_prefix.lstrip("/"))
if not 0 <= p <= 32:
raise ValueError
cfg.prefix = p
except ValueError:
cfg.errors.append(f"Präfix „{html.escape(raw_prefix)}“ muss zwischen 0 und 32 liegen.")
elif raw_mask:
try:
cfg.prefix = ipaddress.ip_network(f"0.0.0.0/{raw_mask}").prefixlen
except ValueError:
cfg.errors.append(f"„{html.escape(raw_mask)}“ ist keine gültige Subnetzmaske.")
return cfg
if cfg.mode == "ueben":
typ = _get1(q, "typ", "mix")
cfg.typ = typ if typ in _GENERATORS or typ in ("mix", "mix6") else "mix"
try:
cfg.level = int(_get1(q, "stufe", "2"))
except ValueError:
cfg.level = 2
if cfg.level not in LEVELS:
cfg.level = 2
raw_seed = _get1(q, "seed")
if raw_seed.isdigit():
cfg.seed = int(raw_seed)
return cfg
raw_ip, raw_ip2 = _get1(q, "ip"), _get1(q, "ip2")
raw_prefix, raw_mask = _get1(q, "prefix"), _get1(q, "mask")
raw_hosts = _get1(q, "hosts")
cfg.submitted = bool(raw_ip or raw_hosts)
if not cfg.submitted:
return cfg
if raw_hosts and not raw_ip:
try:
n = int(raw_hosts)
if not 1 <= n <= MAX_HOSTS_INPUT:
raise ValueError
cfg.hosts = n
except ValueError:
cfg.errors.append(f"Geräteanzahl „{html.escape(raw_hosts)}“ ist keine "
f"Zahl zwischen 1 und {MAX_HOSTS_INPUT}.")
return cfg
try:
cfg.ip = ipaddress.IPv4Address(raw_ip)
except ValueError:
cfg.errors.append(f"„{html.escape(raw_ip)}“ ist keine gültige IPv4-Adresse.")
return cfg
if raw_prefix:
try:
p = int(raw_prefix.lstrip("/"))
if not 0 <= p <= 32:
raise ValueError
cfg.prefix = p
except ValueError:
cfg.errors.append(f"Präfix „{html.escape(raw_prefix)}“ muss zwischen 0 und 32 liegen.")
return cfg
if raw_mask:
cfg.warnings.append("Präfix und Maske angegeben - das Präfix gewinnt.")
elif raw_mask:
try:
cfg.prefix = ipaddress.ip_network(f"0.0.0.0/{raw_mask}").prefixlen
except ValueError:
cfg.errors.append(f"„{html.escape(raw_mask)}“ ist keine gültige Subnetzmaske "
"(die Einsen müssen zusammenhängen).")
return cfg
else:
cfg.prefix = 24
cfg.warnings.append("Keine Maske angegeben - /24 als üblicher Standard angenommen.")
if raw_ip2:
try:
cfg.ip2 = ipaddress.IPv4Address(raw_ip2)
except ValueError:
cfg.errors.append(f"Vergleichsadresse „{html.escape(raw_ip2)}“ ist keine gültige IPv4-Adresse.")
return cfg
def needs_seed_redirect(cfg: CoachConfig) -> str | None:
"""Übungsmodus ohne Seed -> Redirect-Ziel mit frischem Seed, damit die
Adresszeile IMMER eine reproduzierbare, teilbare Aufgabe zeigt."""
if cfg.mode == "ueben" and cfg.seed is None:
seed = random.SystemRandom().randrange(SEED_MIN, SEED_MAX + 1)
return "?" + urlencode({"mode": "ueben", "typ": cfg.typ,
"stufe": cfg.level, "seed": seed})
return None
# =============================================================================
# 4. RENDER - Domänen-Objekte -> HTML (Toolbox-Design-System, kein JS nötig)
# =============================================================================
CSS = """
:root{--bg-primary:#0f172a;--bg-secondary:#1e293b;--text-primary:#f1f5f9;--text-secondary:#94a3b8;--accent:#3b82f6;--accent-secondary:#8b5cf6;--border:#334155;--code-bg:#0f172a;--success:#10b981;--warn:#f59e0b;--error:#ef4444;}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.6;min-height:100vh;padding:2rem}
a{color:var(--accent);text-decoration:none;font-weight:500}
.container{max-width:1000px;margin:0 auto}
h1{font-size:2rem;font-weight:700;margin-bottom:.5rem;background:linear-gradient(135deg,var(--accent),var(--accent-secondary));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.subtitle{color:var(--text-secondary);margin-bottom:1.5rem}
h2{font-size:1.25rem;font-weight:600;margin-bottom:1rem}
.card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;padding:1.5rem;margin-bottom:1.5rem}
.error-card{border-left:4px solid var(--error)}.error-card h2{color:var(--error)}
.warn-note{color:var(--warn);font-size:.9rem;margin-bottom:.75rem}
form{display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end}
.field{display:flex;flex-direction:column;gap:.35rem}
.field label{font-size:.8rem;color:var(--text-secondary);font-weight:600}
input[type=text],input[type=number],select,textarea{padding:.7rem .9rem;border-radius:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);font-size:1rem;font-family:inherit}
textarea{font-family:'JetBrains Mono','Fira Code',Consolas,monospace;font-size:.9rem;resize:vertical}
input[type=text]::placeholder,textarea::placeholder{color:var(--text-secondary)}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(59,130,246,.2)}
.w-sm{width:110px}.w-md{width:200px}
button{padding:.7rem 1.5rem;border-radius:8px;border:none;cursor:pointer;background:var(--accent);color:#fff;font-size:1rem;font-weight:600;font-family:inherit}
button:hover{background:#2563eb}
.form-hint{width:100%;font-size:.85rem;color:var(--text-secondary);margin-top:.5rem}
.formgrid{display:flex;gap:2rem;flex-wrap:wrap;align-items:flex-start}
.formcol{flex:1 1 380px}.formcol-narrow{flex:0 1 300px}
.cardhead{display:flex;align-items:baseline;gap:.8rem;flex-wrap:wrap;margin-bottom:1rem}
.cardhead h2{margin-bottom:0}
.badges{display:inline-flex;gap:.4rem;flex-wrap:wrap}
.badges .src{margin-top:0;cursor:help}
.tabs{display:flex;gap:.5rem;margin-bottom:1.5rem;flex-wrap:wrap}
.tab{font-size:.95rem;font-weight:600;padding:.5em 1.2em;border-radius:999px;text-decoration:none;color:var(--text-secondary);background:rgba(148,163,184,.12);border:1px solid transparent}
.tab:hover{color:var(--text-primary);border-color:var(--border)}
.tab-on{background:rgba(59,130,246,.15);color:var(--accent);border-color:var(--accent)}
.preset-row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;width:100%;margin-top:.25rem}
.preset-lbl{font-size:.8rem;color:var(--text-secondary);font-weight:600}
.preset{font-size:.8rem;font-weight:600;padding:.25em .8em;border-radius:999px;text-decoration:none;color:var(--text-secondary);background:rgba(148,163,184,.12);border:1px solid transparent}
.preset:hover{color:var(--text-primary);border-color:var(--border)}
.preset-on{background:rgba(59,130,246,.15);color:var(--accent);border-color:var(--accent)}
.table-wrap{overflow-x:auto;border-radius:8px;border:1px solid var(--border)}
table{border-collapse:collapse;width:100%;font-size:.9rem}
th,td{padding:.55rem .7rem;text-align:left;border-bottom:1px solid var(--border);white-space:nowrap}
thead th{background:var(--bg-primary);color:var(--text-secondary);font-weight:600}
tbody tr:last-child td{border-bottom:none}
td.rowhead{font-weight:600;background:rgba(148,163,184,.06)}
code{font-family:'JetBrains Mono','Fira Code',Consolas,monospace;font-size:.85em;background:var(--code-bg);color:#e879f9;padding:.15em .4em;border-radius:4px}
.steps{list-style:none;counter-reset:step;display:grid;gap:.75rem}
.steps li{padding:.8rem .9rem .8rem 3rem;position:relative;background:rgba(148,163,184,.05);border:1px solid var(--border);border-radius:10px}
.steps li::before{counter-increment:step;content:counter(step);position:absolute;left:.85rem;top:.85rem;width:1.6rem;height:1.6rem;border-radius:50%;background:var(--accent);color:#fff;font-weight:700;font-size:.85rem;display:flex;align-items:center;justify-content:center}
.steps h3{font-size:.95rem;font-weight:600;margin-bottom:.35rem}
.steps p{font-size:.9rem;color:var(--text-secondary)}
.steps p strong{color:var(--text-primary)}
.src{display:inline-block;margin-top:.4rem;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;padding:.15em .6em;border-radius:999px;background:rgba(139,92,246,.15);color:var(--accent-secondary)}
.bintab{font-family:'JetBrains Mono','Fira Code',Consolas,monospace;font-size:.82rem;margin-top:.7rem;border-collapse:collapse}
.bintab td{border:none;padding:.15rem .5rem .15rem 0;white-space:nowrap}
.bintab .lbl{color:var(--text-secondary);font-size:.78rem;padding-right:.9rem}
.bintab .dot{color:var(--text-secondary);padding-left:.9rem}
.nb{color:#22d3ee}
.hb{color:var(--text-secondary)}
.bsep{color:var(--accent-secondary);font-weight:700}
.legend-row{display:flex;gap:1.1rem;flex-wrap:wrap;margin-top:.6rem;font-size:.8rem;color:var(--text-secondary)}
.wide-toggle{position:absolute;left:-9999px}
.exportrow{display:flex;gap:.4rem;justify-content:flex-end;margin:-.6rem 0 .9rem}
.wide-btn{margin-left:auto;cursor:pointer;background:rgba(139,92,246,.15);color:var(--accent-secondary);border:1px solid var(--accent-secondary)}
.wide-btn:hover{background:rgba(139,92,246,.3);color:#c4b5fd;border-color:var(--accent-secondary)}
.wide-btn .lbl-off{display:none}
.wide-toggle:checked + .card .wide-btn{background:var(--accent-secondary);color:#fff}
.wide-toggle:checked + .card .wide-btn .lbl-on{display:none}
.wide-toggle:checked + .card .wide-btn .lbl-off{display:inline}
.wide-toggle:checked + .card{position:relative;left:50%;transform:translateX(-50%);width:max-content;min-width:100%;max-width:calc(100vw - 3rem)}
.wide-toggle:focus-visible + .card .wide-btn{border-color:var(--accent);color:var(--accent)}
@media(max-width:768px){.wide-btn{display:none}}
.octtab th,.octtab td{text-align:center;font-family:'JetBrains Mono','Fira Code',Consolas,monospace;font-size:.82rem;padding:.3rem .5rem;white-space:nowrap}
.octtab th.rowhead,.octtab td.rowhead{text-align:left;font-family:'Montserrat',sans-serif;font-size:.78rem}
.b1{font-weight:700;color:var(--text-primary)}
.b0{color:var(--text-secondary)}
.octctl{margin-top:.5rem;font-size:.85rem;color:var(--text-secondary)}
.octctl code{color:#e879f9}
details.oct{margin-top:.6rem}
details.oct summary{display:flex;gap:.7rem;flex-wrap:wrap;align-items:center;cursor:pointer;font-family:'JetBrains Mono','Fira Code',Consolas,monospace;font-size:.85rem;padding:.5rem .9rem;border:1px solid var(--border);border-radius:8px;color:var(--text-secondary);list-style:none;user-select:none}
details.oct summary:hover{border-color:var(--accent);color:var(--text-primary)}
details.oct summary::before{content:'\\25B8';display:inline-block;font-size:.9em;transition:transform .15s;color:var(--accent)}
details.oct[open] summary::before{transform:rotate(90deg)}
details.oct summary::-webkit-details-marker{display:none}
details.oct .octbody{margin-top:.6rem;padding-left:.2rem}
.taskmeta{display:flex;gap:.6rem;flex-wrap:wrap;margin-bottom:1rem;font-size:.85rem;color:var(--text-secondary)}
.taskmeta .count{font-weight:600;padding:.15em .7em;border-radius:6px;background:rgba(148,163,184,.12)}
.subq{list-style:none;display:grid;gap:.4rem;margin-top:.8rem}
.subq li{padding-left:1.6rem;position:relative;color:var(--text-primary)}
.subq li::before{content:counter(sq,lower-alpha) ')';counter-increment:sq;position:absolute;left:0;color:var(--accent);font-weight:700}
.subq{counter-reset:sq}
details.sol{margin-top:1.25rem;border-top:1px solid var(--border);padding-top:1rem}
details.sol summary{display:inline-flex;align-items:center;gap:.55rem;cursor:pointer;font-size:.9rem;font-weight:600;color:var(--accent);background:rgba(59,130,246,.1);border:1px solid var(--accent);border-radius:8px;padding:.55rem 1rem;list-style:none;user-select:none}
details.sol summary:hover{background:rgba(59,130,246,.22)}
details.sol summary::before{content:'\\25B8';display:inline-block;font-size:.9em;transition:transform .15s}
details.sol[open] summary::before{transform:rotate(90deg)}
details.sol summary::-webkit-details-marker{display:none}
.sol-body{margin-top:1rem}
.answer-table{margin-bottom:1rem}
.permalink{font-size:.82rem;color:var(--text-secondary);margin-top:1rem;word-break:break-all;white-space:normal}
.permalink code{white-space:normal}
.note{color:var(--text-secondary);font-size:.88rem;margin-top:.6rem}
footer{color:var(--text-secondary);font-size:.8rem;text-align:center;margin-top:2rem}
footer a{color:var(--accent);text-decoration:none}
@media(max-width:768px){body{padding:1rem}h1{font-size:1.4rem}.card{padding:1rem}}
"""
def esc(s) -> str:
return html.escape(str(s), quote=True)
def fmt(text: str) -> str:
"""Klartext mit `Backtick`-Auszeichnung -> escaptes HTML mit <code>."""
parts = text.split("`")
out = []
for i, part in enumerate(parts):
out.append(f"<code>{esc(part)}</code>" if i % 2 else esc(part))
return "".join(out)
def render_binrow_cells(row: BinRow) -> str:
"""Ein Oktett-Quartett mit farblicher Netz-/Hostbit-Trennung."""
cells = []
for i, octet in enumerate(row.octets):
start = i * 8
marked = []
for j, bit in enumerate(octet):
pos = start + j
cls = "nb" if row.split >= 0 and pos < row.split else "hb"
if row.split >= 0 and pos == row.split and j > 0:
marked.append('<span class="bsep">│</span>')
marked.append(f'<span class="{cls}">{bit}</span>')
if row.split >= 0 and row.split == start and i > 0:
cells.append('<td><span class="bsep">│</span>' + "".join(marked) + "</td>")
else:
cells.append("<td>" + "".join(marked) + "</td>")
return (f'<td class="lbl">{esc(row.label)}</td>' + ".".join(cells).replace("</td>.<td", "</td><td class=\"dotsep\">.</td><td")
+ f'<td class="dot">= {esc(row.dotted)}</td>')
def render_steps(steps: list[Step]) -> str:
items = []
legend_done = False # die Legende nur einmal zeigen, nicht je Binärtabelle
for s in steps:
rows_html = ""
if s.rows:
body = "".join(f"<tr>{render_binrow_cells(r)}</tr>" for r in s.rows)
rows_html = f'<table class="bintab">{body}</table>'
if not legend_done:
rows_html += ('<div class="legend-row">'
'<span><span class="nb">cyan</span> = Netzanteil</span>'
'<span><span class="hb">grau</span> = Hostanteil</span>'
'<span><span class="bsep">│</span> = Präfix-Grenze</span></div>')
legend_done = True
src = f'<span class="src">{esc(SOURCES[s.source])}</span>' if s.source else ""
items.append(f"<li><h3>{esc(s.title)}</h3><p>{fmt(s.text)}</p>{rows_html}{src}</li>")
return '<ol class="steps">' + "".join(items) + "</ol>"
def render_badges(ip: ipaddress.IPv4Address) -> str:
"""Die Einordnung der Adresse als kompakte Badge-Zeile (statt eigener
Rechenweg-Schritte); die Erklärung steckt im title-Tooltip."""
badges = []
for label, detail, source in classify(ip):
rfc = SOURCES[source].split(" ")[0] + " " + SOURCES[source].split(" ")[1]
badges.append(f'<span class="src" title="{esc(detail)} — {esc(SOURCES[source])}">'
f'{esc(label)} · {esc(rfc)}</span>')
return '<span class="badges">' + "".join(badges) + "</span>"
def render_tabs(cfg: CoachConfig) -> str:
# ip/prefix wandern zwischen Berechnen und Umrechnen mit - die Eingabe
# bleibt beim Tab-Wechsel erhalten (Nutzerwunsch V1.1)
ctx: dict = {}
if cfg.mode in ("erklaeren", "binaer") and cfg.ip is not None:
ctx["ip"] = str(cfg.ip)
if cfg.prefix is not None:
ctx["prefix"] = cfg.prefix
er = "tab tab-on" if cfg.mode == "erklaeren" else "tab"
bi = "tab tab-on" if cfg.mode == "binaer" else "tab"
pl = "tab tab-on" if cfg.mode == "planen" else "tab"
v6 = "tab tab-on" if cfg.mode == "ipv6" else "tab"
ue = "tab tab-on" if cfg.mode == "ueben" else "tab"
href_er = "?" + urlencode(ctx) if ctx else "?"
href_bi = "?" + urlencode({"mode": "binaer", **ctx})
return (f'<nav class="tabs"><a class="{er}" href="{esc(href_er)}">Berechnen</a>'
f'<a class="{bi}" href="{esc(href_bi)}">Umrechnen</a>'
f'<a class="{pl}" href="?mode=planen">Planen</a>'
f'<a class="{v6}" href="?mode=ipv6">IPv6</a>'
f'<a class="{ue}" href="?mode=ueben">Trainieren</a></nav>')
def render_form(cfg: CoachConfig) -> str:
ip_val = esc(cfg.ip) if cfg.ip else ""
ip2_val = esc(cfg.ip2) if cfg.ip2 else ""
prefix_val = esc(cfg.prefix) if cfg.prefix is not None else ""
return f"""<div class="card"><div class="formgrid">
<div class="formcol"><h2>Adresse untersuchen</h2>
<form method="get" action="">
<div class="field"><label for="ip">IP-Adresse</label>
<input class="w-md" type="text" id="ip" name="ip" value="{ip_val}" placeholder="192.168.10.77" required></div>
<div class="field"><label for="prefix">Präfix (0–32)</label>
<input class="w-sm" type="text" id="prefix" name="prefix" value="{prefix_val}" placeholder="26"></div>
<div class="field"><label for="mask">… oder Maske</label>
<input class="w-md" type="text" id="mask" name="mask" placeholder="255.255.255.192"></div>
<div class="field"><label for="ip2">Vergleichs-IP (optional)</label>
<input class="w-md" type="text" id="ip2" name="ip2" value="{ip2_val}" placeholder="gleiches Subnetz?"></div>
<button type="submit">Rechenweg zeigen</button>
<p class="form-hint">Präfix oder Maske reicht. Mit Vergleichs-IP wird zusätzlich geprüft, ob beide Adressen im selben Subnetz liegen.</p>
</form></div>
<div class="formcol formcol-narrow"><h2>Präfix für n Geräte</h2>
<form method="get" action="">
<div class="field"><label for="hosts">Anzahl Geräte (inkl. Gateway)</label>
<input class="w-sm" type="number" id="hosts" name="hosts" min="1" max="{MAX_HOSTS_INPUT}" placeholder="50" required></div>
<button type="submit">Präfix herleiten</button>
<p class="form-hint">„Welches Subnetz brauche ich für n Clients?“ - mit Rechenweg und Reserve.</p>
</form></div>
</div></div>"""
def render_kennwerte(a: Analyse, note: str = "") -> str:
rows = [
("Eingabe", f"<code>{esc(a.ip)}/{a.prefix}</code>"),
("Netzadresse", f"<code>{esc(a.net_addr)}</code>"),
("Subnetzmaske", f"<code>{esc(a.netmask)}</code> (Wildcard <code>{esc(a.wildcard)}</code>)"),
]
if a.prefix == 32:
rows.append(("Charakter", "Einzeladresse (Host-Route) - kein Netz im üblichen Sinn"))
elif a.prefix == 31:
rows.append(("Adressen", "<code>2</code> - beide nutzbar, kein Broadcast (RFC 3021)"))
rows.append(("Hostbereich", f"<code>{esc(a.first_host)}</code> – <code>{esc(a.last_host)}</code>"))
else:
rows.append(("Broadcast", f"<code>{esc(a.broadcast)}</code>"))
rows.append(("Hostbereich", f"<code>{esc(a.first_host)}</code> – <code>{esc(a.last_host)}</code>"))
rows.append(("Nutzbare Hosts", f"<code>{a.usable}</code>"))
body = "".join(f'<tr><td class="rowhead">{k}</td><td>{v}</td></tr>' for k, v in rows)
note_html = f'<span class="note">{esc(note)}</span>' if note else ""
return (f'<div class="card"><div class="cardhead"><h2>Kennwerte</h2>{note_html}</div>'
f'<div class="table-wrap"><table><tbody>{body}</tbody></table></div></div>')
def render_erklaeren(cfg: CoachConfig) -> str:
parts = [render_form(cfg)]
if cfg.warnings:
parts.append('<div class="card">' + "".join(
f'<p class="warn-note">⚠ {esc(w)}</p>' for w in cfg.warnings) + "</div>")
if cfg.hosts is not None:
prefix, steps = prefix_for_hosts(cfg.hosts)
parts.append(f'<div class="card"><h2>Ergebnis: /{prefix} für {cfg.hosts} Geräte</h2>'
f'<p class="note">Maske <code>{esc(ipaddress.ip_network(f"0.0.0.0/{prefix}").netmask)}</code> '
f'· <code>{hosts_for_prefix(prefix)}</code> nutzbare Adressen</p></div>')
parts.append('<div class="card"><h2>Rechenweg</h2>' + render_steps(steps) + "</div>")
return "".join(parts)
example = not cfg.submitted
if example:
cfg = CoachConfig(ip=ipaddress.IPv4Address(DEFAULT_EXAMPLE_IP),
prefix=DEFAULT_EXAMPLE_PREFIX, submitted=True)
a = analyse_subnetz(cfg.ip, cfg.prefix)
parts.append(render_kennwerte(
a, note=(f"Beispiel {DEFAULT_EXAMPLE_IP}/{DEFAULT_EXAMPLE_PREFIX} - "
"eigene Werte oben eintragen." if example else "")))
steps = list(a.steps)
if cfg.ip2 is not None:
_, cmp_steps = same_subnet(cfg.ip, cfg.ip2, cfg.prefix)
steps.extend(cmp_steps)
parts.append('<div class="card"><div class="cardhead"><h2>Rechenweg</h2>'
+ render_badges(cfg.ip) + '</div>' + render_steps(steps) + "</div>")
return "".join(parts)
def render_ueben(cfg: CoachConfig) -> str:
task = generate_task(cfg.typ, cfg.level, cfg.seed)
is_v6 = cfg.typ in TASK_ORDER_V6 or cfg.typ == "mix6"
family_mix = "mix6" if is_v6 else "mix"
order = TASK_ORDER_V6 if is_v6 else TASK_ORDER
fam_links = []
for fam_label, fam_typ, on in (("IPv4", "mix", not is_v6), ("IPv6", "mix6", is_v6)):
cls = "preset preset-on" if on else "preset"
href = "?" + urlencode({"mode": "ueben", "typ": fam_typ,
"stufe": cfg.level, "seed": cfg.seed})
fam_links.append(f'<a class="{cls}" href="{esc(href)}">{fam_label}</a>')
typ_links = []
for key in [family_mix] + order:
label = "Zufällig" if key in ("mix", "mix6") else ALL_TASK_TYPES[key][0]
cls = "preset preset-on" if cfg.typ == key else "preset"
href = "?" + urlencode({"mode": "ueben", "typ": key, "stufe": cfg.level, "seed": cfg.seed})
typ_links.append(f'<a class="{cls}" href="{esc(href)}">{esc(label)}</a>')
lvl_links = []
for lvl, name in LEVELS.items():
cls = "preset preset-on" if cfg.level == lvl else "preset"
href = "?" + urlencode({"mode": "ueben", "typ": cfg.typ, "stufe": lvl, "seed": cfg.seed})
lvl_links.append(f'<a class="{cls}" href="{esc(href)}">{lvl} · {esc(name)}</a>')
new_href = "?" + urlencode({"mode": "ueben", "typ": cfg.typ, "stufe": cfg.level})
controls = (f'<div class="card"><h2>Aufgabentrainer</h2>'
f'<div class="preset-row"><span class="preset-lbl">Version:</span>{"".join(fam_links)}</div>'
f'<div class="preset-row"><span class="preset-lbl">Typ:</span>{"".join(typ_links)}</div>'
f'<div class="preset-row"><span class="preset-lbl">Stufe:</span>{"".join(lvl_links)}</div>'
f'<div class="preset-row" style="margin-top:.9rem">'
f'<a class="tab tab-on" href="{esc(new_href)}">Neue Aufgabe ↻</a></div>'
'<p class="form-hint">Jede Aufgabe steckt vollständig in ihrer URL (Seed) - '
'Link teilen genügt, und alle sehen exakt dieselbe Aufgabe. '
'Die Lösung bleibt eingeklappt, bis sie aufgeklappt wird.</p></div>')
subs = "".join(f"<li>{fmt(s)}</li>" for s in task.subquestions)
answers = "".join(f'<tr><td class="rowhead">{esc(k)}</td><td>{esc(v)}</td></tr>'
for k, v in task.answers)
permalink = "?" + urlencode({"mode": "ueben", "typ": cfg.typ,
"stufe": cfg.level, "seed": cfg.seed})
task_card = (
'<div class="card"><h2>Aufgabe</h2>'
'<div class="taskmeta">'
f'<span class="count">Typ: {"IPv6" if is_v6 else "IPv4"} · {esc(ALL_TASK_TYPES[task.typ][0])}</span>'
f'<span class="count">Stufe {cfg.level} · {esc(LEVELS[cfg.level])}</span>'
f'<span class="count">Seed {cfg.seed}</span></div>'
f'<p>{fmt(task.question)}</p><ol class="subq">{subs}</ol>'
'<details class="sol"><summary>Lösung mit Rechenweg anzeigen</summary>'
'<div class="sol-body">'
'<div class="table-wrap answer-table"><table><thead><tr><th>Gefragt</th><th>Antwort</th></tr></thead>'
f'<tbody>{answers}</tbody></table></div>'
+ render_steps(task.solution_steps) +
'</div></details>'
f'<p class="permalink">Aufgabe teilen: <code>{esc(permalink)}</code></p>'
'</div>')
return controls + task_card
def render_binaer_form(cfg: CoachConfig) -> str:
val = str(cfg.ip) if cfg.ip else (str(cfg.single_value) if cfg.single_value is not None else "")
prefix_val = esc(cfg.prefix) if cfg.prefix is not None else ""
return f"""<div class="card"><h2>Dezimal → Binär, Schritt für Schritt</h2>
<form method="get" action="">
<input type="hidden" name="mode" value="binaer">
<div class="field"><label for="bip">IP-Adresse oder Zahl (0–255)</label>
<input class="w-md" type="text" id="bip" name="ip" value="{esc(val)}" placeholder="192.168.10.77" required></div>
<div class="field"><label for="bprefix">Präfix (optional)</label>
<input class="w-sm" type="text" id="bprefix" name="prefix" value="{prefix_val}" placeholder="26"></div>
<button type="submit">Umrechnen</button>
<p class="form-hint">Zeigt die Stellenwert-Methode: von 128 abwärts fragen „passt der Stellenwert noch in den Rest?“. Mit Präfix färbt das Ergebnis Netz- und Hostanteil und verlinkt zum Berechnen-Tab.</p>
</form></div>"""
def _octet_calc(value: int, octet_index: int | None, prefix: int | None) -> tuple[str, str, str]:
"""Die Stellenwert-Tabelle für EIN Oktett: (Tabellen-HTML, Bit-String,
Kontroll-Text). Feste 8 Spalten erzwingen die führenden Nullen; die
Rest-Zeile protokolliert jeden Schritt (die zwei häufigsten
Anfängerfehler laut Lehrmaterial-Recherche)."""
steps = octet_steps(value)
bits = "".join("1" if s.fits else "0" for s in steps)
head = '<tr><th class="rowhead">Stellenwert</th>' + "".join(
f"<th>{s.place}</th>" for s in steps) + "</tr>"
frage = '<tr><td class="rowhead">Frage: Rest ≥ Stellenwert?</td>' + "".join(
f"<td>{s.rest_before}≥{s.place}? {'Ja' if s.fits else 'Nein'}</td>" for s in steps) + "</tr>"
bit_cells = []
for col, s in enumerate(steps):
cls = "b1" if s.fits else "b0"
if prefix is not None and octet_index is not None:
pos = octet_index * 8 + col
cls += " nb" if pos < prefix else " hb"
bit_cells.append(f'<td class="{cls}">{1 if s.fits else 0}</td>')
bit = '<tr><td class="rowhead">Bit</td>' + "".join(bit_cells) + "</tr>"
rest = '<tr><td class="rowhead">Rest danach</td>' + "".join(
f"<td>{s.rest_after}</td>" for s in steps) + "</tr>"
table = (f'<div class="table-wrap"><table class="octtab"><thead>{head}</thead>'
f'<tbody>{frage}{bit}{rest}</tbody></table></div>')
gesetzt = [str(s.place) for s in steps if s.fits]
kontrolle = (" + ".join(gesetzt) + f" = {value}") if gesetzt else f"keine 1 gesetzt → {value}"
return table, bits, kontrolle
def _bits_span(bits: str, octet_index: int, prefix: int | None) -> str:
"""Bit-String für die Summary-Zeile - mit Präfix in Netz-/Hostfarben."""
if prefix is None:
return f"<code>{bits}</code>"
out = []
for col, b in enumerate(bits):
pos = octet_index * 8 + col
out.append(f'<span class="{"nb" if pos < prefix else "hb"}">{b}</span>')
return "<code>" + "".join(out) + "</code>"
def render_binaer(cfg: CoachConfig) -> str:
parts = [render_binaer_form(cfg)]
example = not cfg.submitted
if example:
cfg = CoachConfig(mode="binaer", ip=ipaddress.IPv4Address(DEFAULT_EXAMPLE_IP),
prefix=DEFAULT_EXAMPLE_PREFIX, submitted=True)
merksatz = ('<p class="note">Von 128 abwärts dieselbe Frage stellen: <strong>passt der '
'Stellenwert noch in den Rest?</strong> Ja → 1 aufschreiben und abziehen, '
'Nein → 0 und weiter. Immer alle 8 Bits notieren - auch führende Nullen. '
'Kontrolle: die Stellenwerte mit einer 1 addieren; Merkanker: acht Einsen = 255.</p>')
if cfg.single_value is not None:
table, bits, kontrolle = _octet_calc(cfg.single_value, None, None)
note = "Beispiel - eigene Werte oben eintragen." if example else ""
parts.append(
f'<div class="card"><div class="cardhead"><h2>{cfg.single_value} → <code>{bits}</code></h2>'
+ (f'<span class="note">{esc(note)}</span>' if note else "") + '</div>'
+ merksatz + table
+ f'<p class="octctl">Kontrolle (Rückrichtung): <code>{esc(kontrolle)}</code></p></div>')
return "".join(parts)
octets = [int(o) for o in str(cfg.ip).split(".")]
note_html = ('<span class="note">Beispiel '
f'{DEFAULT_EXAMPLE_IP}/{DEFAULT_EXAMPLE_PREFIX} - eigene Werte oben eintragen.</span>'
if example else "")
inner = [merksatz]
# Oktett 1 als ausformuliertes Lehrbeispiel ...
table1, bits1, ctl1 = _octet_calc(octets[0], 0, cfg.prefix)
inner.append(f'<h3 style="margin-top:.8rem">1. Oktett · {octets[0]} - dieselbe Frage 8×, von 128 bis 1:</h3>')
inner.append(table1)
inner.append(f'<p class="octctl">Ergebnis {_bits_span(bits1, 0, cfg.prefix)} · '
f'Kontrolle: <code>{esc(ctl1)}</code></p>')
# ... die Oktette 2-4 kompakt zum Aufklappen (Selbstkontrolle)
for i, value in enumerate(octets[1:], start=1):
table, bits, ctl = _octet_calc(value, i, cfg.prefix)
inner.append(
f'<details class="oct"><summary><span>{i + 1}. Oktett · {value}</span>'
f'<span>→ {_bits_span(bits, i, cfg.prefix)}</span>'
f'<span>Kontrolle: {esc(ctl)}</span></summary>'
f'<div class="octbody">{table}</div></details>')
# Ergebnis-Zeile in der Optik des Berechnen-Tabs (mit Präfix inkl. Grenze)
split = cfg.prefix if cfg.prefix is not None else -1
row = _row("Ergebnis", int(cfg.ip), str(cfg.ip), split=split)
inner.append(f'<table class="bintab" style="margin-top:1rem"><tr>{render_binrow_cells(row)}</tr></table>')
if cfg.prefix is not None:
inner.append('<div class="legend-row">'
'<span><span class="nb">cyan</span> = Netzanteil</span>'
'<span><span class="hb">grau</span> = Hostanteil</span>'
'<span><span class="bsep">│</span> = Präfix-Grenze</span></div>')
deep = "?" + urlencode({"ip": str(cfg.ip), "prefix": cfg.prefix})
inner.append(f'<p class="octctl">Genau so liest der Subnetz-Rechner die Adresse: '
f'<a class="preset" href="{esc(deep)}">→ mit dieser Eingabe berechnen</a></p>')
parts.append('<div class="card"><div class="cardhead">'
f'<h2>Dezimal → Binär: {esc(cfg.ip)}</h2>{note_html}</div>'
+ "".join(inner) + '</div>')
return "".join(parts)
DEMO_BEDARF = "Verwaltung, 100\nVertrieb, 50\nProduktion, 20\nUplink, 2"
DEMO_V6 = "2001:db8:0:42:0:0:0:7/64" # Dokumentationsbereich (RFC 3849), zeigt beide Kürzungsregeln
def render_ipv6_form(cfg: CoachConfig) -> str:
val = ""
if cfg.ip6 is not None:
val = str(cfg.ip6) + (f"/{cfg.prefix6}" if cfg.prefix6 is not None else "")
return f"""<div class="card"><h2>IPv6-Adresse verstehen</h2>
<form method="get" action="">
<input type="hidden" name="mode" value="ipv6">
<div class="field"><label for="v6ip">IPv6-Adresse (mit oder ohne /Präfix)</label>
<input class="w-lg" style="width:340px;max-width:100%" type="text" id="v6ip" name="ip" value="{esc(val)}" placeholder="2001:db8:0:42::7/64" required></div>
<button type="submit">Rechenweg zeigen</button>
<p class="form-hint">Grundlagen-Baustein: Notation (Vollform ↔ Kürzung nach RFC 5952), Adresstyp, Präfix-Logik und warum die v4-Hostzählerei entfällt - bewusst kein vollständiges IPv6-Werkzeug. IPv4 und IPv6 sind getrennte Protokolle - eine Umwandlung gibt es nicht.</p>
</form></div>"""
def _v6_colored_exploded(ip: ipaddress.IPv6Address, prefix: int) -> str:
"""Vollform mit Netz-/Interface-Färbung auf Hexstellen-Ebene (1 Stelle = 4 Bit)."""
out = []
pos = 0
for block_i, block in enumerate(ip.exploded.split(":")):
if block_i:
out.append('<span class="bsep">:</span>' if pos == prefix else ":")
for ziffer in block:
end = pos + 4
cls = "nb" if end <= prefix else ("hb" if pos >= prefix else "bsep")
out.append(f'<span class="{cls}">{ziffer}</span>')
pos = end
return '<code style="font-size:.95em">' + "".join(out) + "</code>"
def render_ipv6(cfg: CoachConfig) -> str:
parts = [render_ipv6_form(cfg)]
if cfg.warnings:
parts.append('<div class="card">' + "".join(
f'<p class="warn-note">⚠ {esc(w)}</p>' for w in cfg.warnings) + "</div>")
example = not cfg.submitted
if example:
cfg = parse_config("mode=ipv6&ip=" + DEMO_V6.replace("/", "%2F"))
net, steps = analyse_v6(cfg.ip6, cfg.prefix6)
label, detail, source = classify_v6(cfg.ip6)
rfc_short = " ".join(SOURCES[source].split(" ")[:2])
badge = (f'<span class="badges"><span class="src" title="{esc(detail)} — '
f'{esc(SOURCES[source])}">{esc(label)} · {esc(rfc_short)}</span></span>')
note = "Beispiel - eigene Adresse oben eintragen." if example else ""
if cfg.prefix6 < 64:
budget = f"2^{64 - cfg.prefix6} /64-Subnetze"
elif cfg.prefix6 == 64:
budget = "Standard-Endnetz (64 Bit Interface-ID)"
else:
budget = f"Sonderfall länger als /64 ({128 - cfg.prefix6} Interface-Bits)"
rows = [
("Eingabe", f"<code>{esc(cfg.ip6)}/{cfg.prefix6}</code>"),
("Vollform", f"<code>{esc(cfg.ip6.exploded)}</code>"),
("Kürzform (RFC 5952)", f"<code>{esc(cfg.ip6.compressed)}</code>"),
("Adresstyp", esc(label)),
("Netz-Präfix", f"<code>{esc(net.compressed)}</code>"),
("Aufteilung", esc(budget)),
("Netz-/Interface-Anteil", _v6_colored_exploded(cfg.ip6, cfg.prefix6)),
]
body = "".join(f'<tr><td class="rowhead">{k}</td><td>{v}</td></tr>' for k, v in rows)
parts.append('<div class="card"><div class="cardhead"><h2>Kennwerte</h2>'
+ (f'<span class="note">{esc(note)}</span>' if note else "")
+ '</div><div class="table-wrap"><table><tbody>' + body
+ '</tbody></table></div>'
'<div class="legend-row"><span><span class="nb">cyan</span> = Netzanteil</span>'
'<span><span class="hb">grau</span> = Interface-Anteil</span></div></div>')
parts.append('<div class="card"><div class="cardhead"><h2>Rechenweg</h2>'
+ badge + '</div>' + render_steps(steps) + "</div>")
typ_rows = "".join(
f'<tr><td class="rowhead">{esc(l)}</td><td><code>{esc(n)}</code></td>'
f'<td style="white-space:normal">{esc(d)}</td></tr>'
for n, l, d, _ in V6_TYPES if n not in ("::1/128", "::/128"))
parts.append(
'<div class="card"><h2>Adresstypen im Überblick</h2>'
'<div class="table-wrap"><table><thead><tr><th>Typ</th><th>Präfix</th>'
'<th>Bedeutung / v4-Gegenstück</th></tr></thead>'
f'<tbody>{typ_rows}</tbody></table></div>'
'<p class="note">Dazu: <code>::1</code> = Loopback (127.0.0.1) und '
'<code>::</code> = unspezifiziert (0.0.0.0). Grundlagen-Baustein - '
'für IPv4-Subnetting: die Tabs Berechnen und Planen.</p></div>')
return "".join(parts)
def _planen_query(cfg: CoachConfig) -> dict:
"""Der Plan als Query-Paare - Permalink, Export-Links und Formular
speisen sich aus derselben Quelle."""
q = {"mode": "planen",
"netz": f"{cfg.base_net.network_address}/{cfg.base_net.prefixlen}",
"bedarf": "\n".join(f"{name}, {n}" for name, n in cfg.demands)}
if cfg.reserve_pct:
q["reserve"] = cfg.reserve_pct
if cfg.gw_last:
q["gateway"] = "letzte"
if cfg.static_per != 10:
q["statisch"] = cfg.static_per
return q
def render_planen_form(cfg: CoachConfig) -> str:
netz_val = (f"{cfg.base_net.network_address}/{cfg.base_net.prefixlen}"
if cfg.base_net else "")
bedarf_val = "\n".join(f"{name}, {n}" for name, n in cfg.demands)
gw_first = "" if cfg.gw_last else " selected"
gw_last = " selected" if cfg.gw_last else ""
return f"""<div class="card"><h2>Adresskonzept planen (VLSM)</h2>
<form method="get" action="">
<input type="hidden" name="mode" value="planen">
<div class="field"><label for="pnetz">Basisnetz</label>
<input class="w-md" type="text" id="pnetz" name="netz" value="{esc(netz_val)}" placeholder="192.168.0.0/24" required></div>
<div class="field"><label for="pbedarf">Bereiche - eine Zeile je Bereich: Name, Clientzahl</label>
<textarea id="pbedarf" name="bedarf" rows="4" cols="34" placeholder="Verwaltung, 100 Vertrieb, 50 Uplink, 2" required>{esc(bedarf_val)}</textarea></div>
<div class="field"><label for="preserve">Reserve %</label>
<input class="w-sm" type="number" id="preserve" name="reserve" value="{cfg.reserve_pct}" min="0" max="200"></div>
<div class="field"><label for="pgw">Gateway</label>
<select id="pgw" name="gateway"><option value="erste"{gw_first}>erste Adresse</option><option value="letzte"{gw_last}>letzte Adresse</option></select></div>
<div class="field"><label for="pstat">Statisch je Bereich</label>
<input class="w-sm" type="number" id="pstat" name="statisch" value="{cfg.static_per}" min="0" max="1000"></div>
<button type="submit">Konzept erstellen</button>
<p class="form-hint">VLSM vergibt vom größten zum kleinsten Bedarf. Reserve = Wachstumsaufschlag auf jede Clientzahl (üblich 20–30 %). Je Subnetz gilt: Gateway, dann der statische Block (Server, Drucker), der Rest wird DHCP-Pool.</p>
</form></div>"""
def _konzept_data(cfg: CoachConfig):
return adresskonzept(cfg.base_net, cfg.demands, cfg.reserve_pct,
cfg.gw_last, cfg.static_per)
def export_konzept(cfg: CoachConfig) -> tuple[str, str, str]:
"""Adresskonzept als Markdown oder CSV: (Body, Content-Type, Dateiname).
Das Artefakt gehört dem Nutzer - inklusive Permalink zur Quelle."""
rows, _, rest = _konzept_data(cfg)
permalink = "https://toolbox.jozapf.de/subnet_coach_v1.py?" + urlencode(_planen_query(cfg))
stem = f"adresskonzept_{str(cfg.base_net.network_address).replace('.', '-')}_{cfg.base_net.prefixlen}"
if cfg.export == "csv":
lines = ["Bereich;Bedarf;mit Reserve;Netz;Gateway;Statisch;DHCP-Pool;Broadcast;Nutzbar;Auslastung %"]
for r in rows:
lines.append(f"{r.name};{r.bedarf};{r.mit_reserve};{r.network};{r.gateway};"
f"{r.static_range};{r.dhcp_range};{r.network.broadcast_address};"
f"{r.usable};{r.auslastung}")
lines.append(f"Reserve Basisnetz;;;;;;;;{rest};")
lines.append(f"Quelle;{permalink};;;;;;;;")
return "\r\n".join(lines) + "\r\n", "text/csv; charset=utf-8", stem + ".csv"
lines = [f"# Adresskonzept {cfg.base_net}", "",
f"Gateway-Konvention: {'letzte' if cfg.gw_last else 'erste'} nutzbare Adresse · "
f"Reserve {cfg.reserve_pct} % · statischer Block {cfg.static_per} Adressen je Bereich", "",
"| Bereich | Bedarf | mit Reserve | Netz | Gateway | Statisch | DHCP-Pool | Broadcast | Nutzbar | Auslastung |",
"|---|---|---|---|---|---|---|---|---|---|"]
for r in rows:
lines.append(f"| {r.name} | {r.bedarf} | {r.mit_reserve} | `{r.network}` | `{r.gateway}` | "
f"`{r.static_range}` | `{r.dhcp_range}` | `{r.network.broadcast_address}` | "
f"{r.usable} | {r.auslastung} % |")
lines += ["", f"Freie Reserve im Basisnetz: {rest} Adressen", "",
f"Erzeugt mit dem Subnetz-Kalkulator: <{permalink}>"]
return "\n".join(lines) + "\n", "text/markdown; charset=utf-8", stem + ".md"
def render_planen(cfg: CoachConfig) -> str:
parts = [render_planen_form(cfg)]
if cfg.warnings:
parts.append('<div class="card">' + "".join(
f'<p class="warn-note">⚠ {esc(w)}</p>' for w in cfg.warnings) + "</div>")
example = not cfg.submitted
if example:
cfg = parse_config("mode=planen&" + urlencode({"netz": "192.168.0.0/24",
"bedarf": DEMO_BEDARF}))
try:
rows, steps, rest = _konzept_data(cfg)
except ValueError:
tipp = min_base_prefix(cfg.base_net, [(n, (h * (100 + cfg.reserve_pct) + 99) // 100)
for n, h in cfg.demands])
tipp_html = (f" Ab einem Basisnetz <code>/{tipp}</code> würde es passen "
f"(z. B. <code>{cfg.base_net.supernet(new_prefix=tipp)}</code>)."
if tipp else "")
parts.append('<div class="card error-card"><h2>Bedarfe passen nicht</h2>'
f'<p>Die Bereiche brauchen zusammen mehr Adressraum, als '
f'<code>{esc(cfg.base_net)}</code> hergibt.{tipp_html}</p></div>')
return "".join(parts)
base_query = _planen_query(cfg)
md_href = "?" + urlencode({**base_query, "export": "md"})
csv_href = "?" + urlencode({**base_query, "export": "csv"})
note = "Beispiel - eigene Werte oben eintragen." if example else ""
head = ('<div class="cardhead"><h2>Adresskonzept '
f'{esc(cfg.base_net)}</h2>'
+ (f'<span class="note">{esc(note)}</span>' if note else "")
+ '<label class="preset wide-btn" for="tblwide" '
'title="Tabelle ohne Scrollleiste in voller Breite anzeigen (und zurück)">'
'<span class="lbl-on">⤢ Volle Breite</span>'
'<span class="lbl-off">⤡ Schmale Ansicht</span></label></div>'
f'<div class="exportrow"><a class="preset" href="{esc(md_href)}">Export Markdown</a>'
f'<a class="preset" href="{esc(csv_href)}">Export CSV</a></div>')
body_rows = "".join(
f'<tr><td class="rowhead">{esc(r.name)}</td>'
f'<td>{r.bedarf}{f" → {r.mit_reserve}" if r.mit_reserve != r.bedarf else ""}</td>'
f'<td><code>{r.network}</code></td><td><code>{esc(r.gateway)}</code></td>'
f'<td><code>{esc(r.static_range)}</code></td><td><code>{esc(r.dhcp_range)}</code></td>'
f'<td><code>{r.network.broadcast_address}</code></td>'
f'<td>{r.usable}</td><td>{r.auslastung} %</td></tr>'
for r in rows)
table = ('<div class="table-wrap"><table><thead><tr><th>Bereich</th><th>Bedarf</th>'
'<th>Netz</th><th>Gateway</th><th>Statisch</th><th>DHCP-Pool</th>'
'<th>Broadcast</th><th>Nutzbar</th><th>Auslastung</th></tr></thead>'
f'<tbody>{body_rows}</tbody></table></div>'
f'<p class="note">Freie Reserve im Basisnetz: {rest} Adressen. '
'Für organisationsweite VLAN-Strukturen: der '
f'<a href="{esc(PLANNER_URL)}">Net+VLAN Planner</a>.</p>')
# Checkbox als direktes Geschwister VOR der Karte - der CSS-Toggle
# (.wide-toggle:checked + .card) hängt an genau dieser Reihenfolge
parts.append(f'<input type="checkbox" id="tblwide" class="wide-toggle">'
f'<div class="card">{head}{table}</div>')
parts.append('<div class="card"><h2>Rechenweg</h2>' + render_steps(steps) + "</div>")
return "".join(parts)
def _page(body: str, mode_title: str) -> str:
return f"""<!DOCTYPE html>
{WATERMARK}
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{esc(TILE_DESCRIPTION)}">
<title>Jo Zapf Toolbox - {esc(TOOL_TITLE)} · {esc(mode_title)}</title>
<link rel="preconnect" href="https://assets.jozapf.de" crossorigin>
<link rel="stylesheet" href="{esc(FONTS_CSS)}">
<style>{CSS}</style>
</head>
<body>
<div class="container">
<h1>toolbox.jozapf.de | {esc(TOOL_TITLE)} v{VERSION}</h1>
<p class="subtitle">Subnetting mit vollständigem Rechenweg - binäre Herleitung statt bloßer Ergebnisse</p>
{body}
<footer>Regeln belegt nach RFC 950 · 1918 · 3021 · 4632 · 4291 · 5952 · keine Datenspeicherung, Aufgaben stecken komplett in der URL · für organisationsweite VLAN-Strukturen: <a href="{esc(PLANNER_URL)}">Net+VLAN Planner</a> · <a href="{esc(TOOLBOX_URL)}">zur Toolbox</a></footer>
</div>
</body>
</html>"""
def render(cfg: CoachConfig) -> str:
if cfg.errors:
err = '<div class="card error-card"><h2>Eingabe prüfen</h2>' + "".join(
f"<p>{e}</p>" for e in cfg.errors) + "</div>"
form = {"erklaeren": render_form, "binaer": render_binaer_form,
"planen": render_planen_form, "ipv6": render_ipv6_form}.get(cfg.mode)
return _page(render_tabs(cfg) + err + (form(cfg) if form else ""), "Eingabe prüfen")
if cfg.mode == "ueben":
return _page(render_tabs(cfg) + render_ueben(cfg), "Trainieren")
if cfg.mode == "binaer":
return _page(render_tabs(cfg) + render_binaer(cfg), "Umrechnen")
if cfg.mode == "planen":
return _page(render_tabs(cfg) + render_planen(cfg), "Planen")
if cfg.mode == "ipv6":
return _page(render_tabs(cfg) + render_ipv6(cfg), "IPv6")
return _page(render_tabs(cfg) + render_erklaeren(cfg), "Berechnen")
# =============================================================================
# 5. ENTRY - CGI (stdout), Dev-Server (--serve), Self-Test (--self-test)
# =============================================================================
def respond(query: str) -> tuple[int, list[tuple[str, str]], str]:
"""Gemeinsamer Kern für CGI und Dev-Server: Status, Header, Body."""
cfg = parse_config(query)
target = needs_seed_redirect(cfg)
if target:
return 302, [("Location", target)], ""
if (cfg.mode == "planen" and cfg.export and cfg.submitted
and not cfg.errors and cfg.base_net and cfg.demands):
try:
body, ctype, fname = export_konzept(cfg)
return 200, [("Content-Type", ctype),
("Content-Disposition", f'attachment; filename="{fname}"')], body
except ValueError:
pass # passt nicht ins Basisnetz -> HTML-Seite erklärt es
return 200, [("Content-Type", "text/html; charset=utf-8")], render(cfg)
def handle_cgi() -> None:
status, headers, body = respond(os.environ.get("QUERY_STRING", ""))
if status == 302:
sys.stdout.write(f"Status: 302 Found\r\nLocation: {headers[0][1]}\r\n\r\n")
return
sys.stdout.write("".join(f"{k}: {v}\r\n" for k, v in headers) + "\r\n")
sys.stdout.write(body)
def run_dev_server(port: int) -> None:
"""Lokaler Testserver - kein Deploy-Weg, nur zum Ausprobieren im Browser."""
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlsplit
class Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
status, headers, body = respond(urlsplit(self.path).query)
raw = body.encode("utf-8")
self.send_response(status)
for k, v in headers:
self.send_header(k, v)
if status == 200:
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def log_message(self, *args): # ruhig halten
pass
print(f"{TOOL_TITLE} v{VERSION} -> http://localhost:{port} (Strg+C beendet)")
HTTPServer(("127.0.0.1", port), Handler).serve_forever()
def self_test() -> int:
"""Kompakte Zusicherungen - die Fixtures sind bekannte Standardaufgaben."""
failures: list[str] = []
def check(cond: bool, msg: str):
if not cond:
failures.append(msg)
# --- Baustein A: Analyse (der Klassiker 192.168.10.77/26) ---------------
a = analyse_subnetz(ipaddress.IPv4Address("192.168.10.77"), 26)
check(a.net_addr == "192.168.10.64", f"Netzadresse falsch: {a.net_addr}")
check(a.broadcast == "192.168.10.127", f"Broadcast falsch: {a.broadcast}")
check(a.first_host == "192.168.10.65" and a.last_host == "192.168.10.126",
"Hostbereich falsch")
check(a.usable == 62, f"62 Hosts erwartet, {a.usable} bekommen")
check(a.netmask == "255.255.255.192" and a.wildcard == "0.0.0.63",
"Maske/Wildcard falsch")
check(any(src == "rfc1918" for *_, src in classify(a.ip)),
"RFC-1918-Einordnung fehlt")
# Einordnung ist KEIN Rechenweg-Schritt mehr (kompakte Badge-Zeile)
check(not any(s.title == "Einordnung der Adresse" for s in a.steps),
"Einordnung darf kein eigener Schritt mehr sein")
check(len(a.steps) == 3, f"Analyse /26 soll 3 kompakte Schritte haben, hat {len(a.steps)}")
# Blockgrößen-Schritt: /26 -> 4. Oktett, 64er-Schritte
check(any(s.title.startswith("Oktettgrenze") and "`64`" in s.text and "4. Oktett" in s.title
for s in a.steps), "Blockgrößen-Schritt /26 fehlt oder falsch")
# Oktettgrenze im 3. Oktett (Stufe-2-Klassiker)
b = analyse_subnetz(ipaddress.IPv4Address("172.16.130.44"), 20)
check(b.net_addr == "172.16.128.0" and b.broadcast == "172.16.143.255",
f"/20-Rechnung falsch: {b.net_addr}/{b.broadcast}")
check(b.usable == 4094, "/20 muss 4094 nutzbare Hosts haben")
# Blockgrößen-Schritt an der Oktettgrenzen-Baustelle: /20 -> 3. Oktett,
# Masken-Oktett 240, Blockgröße 16, 130 -> Block ab 128
check(any("3. Oktett" in s.title and "`16`" in s.text and "`128`" in s.text
for s in b.steps), "Blockgrößen-Schritt /20 fehlt oder falsch")
# Glatte Präfixe: eigener Oktettgrenzen-Hinweis statt Blockgrößen-Rechnung
c24 = analyse_subnetz(ipaddress.IPv4Address("192.168.10.77"), 24)
check(any(s.title == "Oktettgrenze nutzen" and "3. und 4. Oktett" in s.text
for s in c24.steps), "/24 muss den Oktettgrenzen-Hinweis tragen")
# Sonderfälle /31, /32, /30
check(hosts_for_prefix(31) == 2, "/31 hat 2 nutzbare Adressen (RFC 3021)")
check(hosts_for_prefix(32) == 1, "/32 ist genau eine Adresse")
check(hosts_for_prefix(30) == 2, "/30 hat 2 nutzbare Hosts")
a31 = analyse_subnetz(ipaddress.IPv4Address("10.0.0.4"), 31)
check(any(s.source == "rfc3021" for s in a31.steps), "/31 ohne RFC-3021-Schritt")
check(a31.first_host == "10.0.0.4" and a31.last_host == "10.0.0.5",
"/31: beide Adressen sind der Hostbereich")
# Einordnung: APIPA als Praxis-Störungsbild, CGN, öffentlich
check(any("DHCP" in detail for _, detail, _ in classify(ipaddress.IPv4Address("169.254.7.9"))),
"APIPA-Hinweis fehlt")
check(any(src == "rfc6598" for *_, src in classify(ipaddress.IPv4Address("100.64.0.1"))),
"CGN-Einordnung fehlt")
check(any("öffentlich" in label for label, *_ in classify(ipaddress.IPv4Address("8.8.8.8"))),
"8.8.8.8 muss als öffentlich gelten")
# Maske <-> Präfix, Wildcard
check(ipaddress.ip_network("0.0.0.0/255.255.240.0").prefixlen == 20,
"255.255.240.0 muss /20 sein")
# prefix_for_hosts: Klassiker + Grenzwerte
check(prefix_for_hosts(50)[0] == 26, "50 Hosts -> /26")
check(prefix_for_hosts(2)[0] == 30, "2 Hosts -> /30 (klassisch)")
check(prefix_for_hosts(126)[0] == 25, "126 Hosts -> /25")
check(prefix_for_hosts(127)[0] == 24, "127 Hosts -> /24 (126 reicht nicht)")
check(any(s.source == "rfc3021" for s in prefix_for_hosts(2)[1]),
"PtP-Hinweis bei n=2 fehlt")
# split_subnets: 4er- und 8er-Klassiker
subs4, np4, _ = split_subnets(ipaddress.ip_network("192.168.0.0/24"), 4)
check(np4 == 26 and [str(s.network_address) for s in subs4] ==
["192.168.0.0", "192.168.0.64", "192.168.0.128", "192.168.0.192"],
"192.168.0.0/24 in 4 -> /26-Viertel")
subs8, np8, steps8 = split_subnets(ipaddress.ip_network("172.16.0.0/16"), 8)
check(np8 == 19 and hosts_for_prefix(np8) == 8190, "172.16.0.0/16 in 8 -> /19 mit 8190")
# Der Erklärtext muss das richtige Oktett und die richtige Schrittweite nennen
check("3. Oktett in 32er-Schritten" in steps8[1].text,
f"Schrittweiten-Text falsch: {steps8[1].text}")
_, _, steps4 = split_subnets(ipaddress.ip_network("192.168.0.0/24"), 4)
check("4. Oktett in 64er-Schritten" in steps4[1].text,
f"Schrittweiten-Text falsch: {steps4[1].text}")
# same_subnet: knapp daneben ist auch vorbei
eq, _ = same_subnet(ipaddress.IPv4Address("10.0.5.130"),
ipaddress.IPv4Address("10.0.5.190"), 26)
check(eq, "10.0.5.130 und .190 liegen beide in 10.0.5.128/26")
eq2, st2 = same_subnet(ipaddress.IPv4Address("10.0.5.190"),
ipaddress.IPv4Address("10.0.5.193"), 26)
check(not eq2, ".190 und .193 liegen in verschiedenen /26")
check(any("Blockgröße" in s.title and "verschiedene Blöcke" in s.text
for s in st2), "same_subnet: Blockgrößen-Schnellweg fehlt")
# Vordere Oktette verschieden: Blockvergleich allein darf nicht täuschen
eq3, st3 = same_subnet(ipaddress.IPv4Address("10.1.130.5"),
ipaddress.IPv4Address("10.2.130.5"), 26)
check(not eq3 and any("vor der Präfix-Grenze" in s.text for s in st3),
"same_subnet: Vorbedingung 'vordere Oktette gleich' fehlt")
# VLSM: der Lehrbuchfall - und Sortierung unabhängig von der Eingabe
net = ipaddress.ip_network("192.168.0.0/24")
allocs, _ = vlsm_plan(net, [("D", 2), ("A", 100), ("C", 20), ("B", 50)])
got = [(al.name, str(al.network)) for al in allocs]
check(got == [("A", "192.168.0.0/25"), ("B", "192.168.0.128/26"),
("C", "192.168.0.192/27"), ("D", "192.168.0.224/30")],
f"VLSM-Zuteilung falsch: {got}")
# GOLDENE REGRESSIONSWERTE (vor dem v6-Ausbau gemessen): alte Aufgaben-
# URLs müssen für immer dieselben Aufgaben liefern - TASK_ORDER und die
# v4-Typ-Strings sind deshalb unantastbar.
check(generate_task("mix", 2, 7).typ == "maske", "GOLDEN: mix/2/7 muss 'maske' bleiben")
check(generate_task("netz", 2, 424242).answers[0] == ("Netzadresse", "172.27.36.96"),
"GOLDEN: netz/2/424242 hat sich verändert")
check(generate_task("vlsm", 3, 555555).answers[0] == ("Lager", "172.31.8.0/25 (126 nutzbar)"),
"GOLDEN: vlsm/3/555555 hat sich verändert")
# Generator: deterministisch je (typ, stufe, seed), alle Typen x Stufen laufen
t1 = generate_task("netz", 2, 424242)
t2 = generate_task("netz", 2, 424242)
check(t1.question == t2.question and t1.answers == t2.answers,
"Gleicher Seed muss dieselbe Aufgabe liefern")
t3 = generate_task("netz", 2, 424243)
check(t1.question != t3.question, "Anderer Seed sollte andere Aufgabe liefern")
for typ in TASK_ORDER + ["mix"]:
for level in LEVELS:
for seed in (1, 2, 3, 4, 5):
task = generate_task(typ, level, seed)
check(bool(task.question and task.solution_steps and task.answers),
f"Generator {typ}/S{level}/seed{seed} unvollständig")
# mix wählt deterministisch einen konkreten Typ
check(generate_task("mix", 2, 7).typ == generate_task("mix", 2, 7).typ
and generate_task("mix", 2, 7).typ in TASK_ORDER, "mix-Auflösung kaputt")
# --- Umrechnen (V1.1): Stellenwert-Methode ------------------------------
def bits_of(v):
return "".join("1" if s.fits else "0" for s in octet_steps(v))
check(bits_of(192) == "11000000", "192 -> 11000000")
st192 = octet_steps(192)
check(st192[0].rest_after == 64 and st192[1].rest_after == 0, "Reste von 192 falsch")
check(bits_of(77) == "01001101", "77 -> 01001101")
check(bits_of(0) == "00000000" and bits_of(255) == "11111111", "Randwerte 0/255 falsch")
check(sum(s.place for s in octet_steps(173) if s.fits) == 173, "Rückaddition 173 falsch")
try:
octet_steps(256)
check(False, "256 muss ValueError geben")
except ValueError:
pass
cb = parse_config("mode=binaer&ip=192.168.10.77&prefix=26")
check(cb.mode == "binaer" and cb.ip is not None and cb.prefix == 26, "binaer-Parse IP+Präfix")
check(parse_config("mode=binaer&ip=192").single_value == 192, "binaer-Parse Einzelzahl")
check(parse_config("mode=binaer&ip=192.168.10.77&mask=255.255.255.192").prefix == 26,
"binaer: Maske muss als /26 ankommen")
check(parse_config("mode=binaer&ip=300").errors, "300 muss Fehler geben")
check(parse_config("mode=binaer&ip=1.2.3.999").errors, "ungültige IP muss Fehler geben")
sb, _, bb = respond("mode=binaer&ip=192.168.10.77&prefix=26")
check(sb == 200 and "192≥128? Ja" in bb, "Umrechnen-Seite unvollständig")
check("128 + 64 = 192" in bb, "Kontrollzeile fehlt")
check(bb.count('<details class="oct"') == 3, "Oktett 2-4 müssen Akkordeons sein")
check('class="nb"' in bb, "Präfix-Färbung fehlt bei Angabe von /26")
_, _, bb_np = respond("mode=binaer&ip=192.168.10.77")
check("<code>11000000</code>" in bb_np, "Bit-String 11000000 fehlt (ohne Präfix)")
check('class="nb"' not in bb_np, "ohne Präfix darf nichts als Netzanteil gefärbt sein")
sb2, _, bb2 = respond("mode=binaer")
check(sb2 == 200 and DEFAULT_EXAMPLE_IP in bb2, "Umrechnen-Erstaufruf ohne Beispiel")
check("mode=binaer" in respond("ip=192.168.10.77&prefix=26")[2],
"Berechnen-Tab muss Kontext an den Umrechnen-Tab durchreichen")
_, _, bsingle = respond("mode=binaer&ip=173")
check("10101101" in bsingle, "Einzelzahl 173 -> 10101101 fehlt")
# --- Planen (V1.2): Adresskonzept ---------------------------------------
net24 = ipaddress.ip_network("192.168.0.0/24")
demo = [("Verwaltung", 100), ("Vertrieb", 50), ("Produktion", 20), ("Uplink", 2)]
rows, psteps, prest = adresskonzept(net24, demo, 0, False, 10)
check([str(r.network) for r in rows] ==
["192.168.0.0/25", "192.168.0.128/26", "192.168.0.192/27", "192.168.0.224/30"],
"Konzept: VLSM-Zuteilung falsch")
verw = rows[0]
check(verw.gateway == "192.168.0.1", "Gateway muss die erste Adresse sein")
check(verw.static_range == "192.168.0.2 – 192.168.0.11", f"Statischer Block falsch: {verw.static_range}")
check(verw.dhcp_range == "192.168.0.12 – 192.168.0.126", f"DHCP-Pool falsch: {verw.dhcp_range}")
check(rows[3].usable == 2 and rows[3].static_range == "192.168.0.226"
and rows[3].dhcp_range == "–", "Kleines /30: statisch klemmen, kein DHCP")
check(verw.auslastung == 79, f"Auslastung 100/126 muss 79 %% sein, ist {verw.auslastung}")
check(prest == 256 - 128 - 64 - 32 - 4, "Rest im Basisnetz falsch")
rows_gl, _, _ = adresskonzept(net24, demo, 0, True, 10)
check(rows_gl[0].gateway == "192.168.0.126", "gw_last: Gateway muss letzte Adresse sein")
rows_res, steps_res, _ = adresskonzept(net24, [("A", 100)], 25, False, 10)
check(rows_res[0].mit_reserve == 125 and rows_res[0].network.prefixlen == 25,
"Reserve 25 %% auf 100 -> 125 in /25")
check(any("Wachstumsreserve" in s.title for s in steps_res), "Reserve-Schritt fehlt")
check(min_base_prefix(net24, [("A", 200), ("B", 200)]) == 23,
"2x200 Hosts brauchen mindestens /23")
# REQUEST planen: Zeilenformate, Normalisierung, Fehler
pc = parse_config("mode=planen&netz=192.168.0.5%2F24&bedarf=Verwaltung%2C%2050%0ATechnik%2030%0ALager%3B10")
check(pc.base_net is not None and str(pc.base_net) == "192.168.0.0/24" and pc.warnings,
"planen: Normalisierung auf Netzadresse + Warnung")
check(pc.demands == [("Verwaltung", 50), ("Technik", 30), ("Lager", 10)],
f"planen: Zeilen-Parser falsch: {pc.demands}")
check(parse_config("mode=planen&netz=192.168.0.0&bedarf=A%2C1").errors,
"planen: Netz ohne Präfix muss Fehler geben")
check(parse_config("mode=planen&netz=10.0.0.0%2F31&bedarf=A%2C1").errors,
"planen: /31 als Basisnetz muss Fehler geben")
check(parse_config("mode=planen&netz=10.0.0.0%2F24&bedarf=kaputt").errors,
"planen: unlesbare Bedarfszeile muss Fehler geben")
# RENDER/EXPORT planen
sp, _, bp = respond("mode=planen&netz=192.168.0.0%2F24&bedarf=" +
DEMO_BEDARF.replace(", ", "%2C%20").replace("\n", "%0A"))
check(sp == 200 and "Adresskonzept" in bp and "Export Markdown" in bp
and "192.168.0.224/30" in bp, "Planen-Seite unvollständig")
# Breit-Umschalter: Checkbox muss DIREKT vor der Konzept-Karte stehen
# (der CSS-Selektor .wide-toggle:checked + .card hängt daran)
check('<input type="checkbox" id="tblwide" class="wide-toggle"><div class="card">' in bp,
"Breit-Toggle: Checkbox nicht direkt vor der Karte")
check('for="tblwide"' in bp and "Volle Breite" in bp, "Breit-Toggle: Label fehlt")
sm, hm, bm = respond("mode=planen&netz=192.168.0.0%2F24&bedarf=A%2C50&export=md")
check(sm == 200 and hm[0][1].startswith("text/markdown")
and "| A | 50 |" in bm and "attachment" in hm[1][1], "Markdown-Export defekt")
sc_, hc, bc = respond("mode=planen&netz=192.168.0.0%2F24&bedarf=A%2C50&export=csv")
check(sc_ == 200 and hc[0][1].startswith("text/csv") and "A;50;50;" in bc,
"CSV-Export defekt")
check(bm == respond("mode=planen&netz=192.168.0.0%2F24&bedarf=A%2C50&export=md")[2],
"Export muss deterministisch sein")
sp0, _, bp0 = respond("mode=planen")
check(sp0 == 200 and "Beispiel" in bp0 and "Uplink" in bp0, "Planen-Erstaufruf ohne Beispiel")
spb, _, bpb = respond("mode=planen&netz=192.168.0.0%2F26&bedarf=A%2C200")
check(spb == 200 and "passen nicht" in bpb and "/24" in bpb,
"Zu-klein-Fall muss Mindest-Präfix-Tipp nennen")
# --- IPv6 (V1.3): Notation, Typen, Präfix-Logik -------------------------
v6 = ipaddress.IPv6Address("2001:db8:0:42::7")
check(v6.exploded == "2001:0db8:0000:0042:0000:0000:0000:0007"
and v6.compressed == "2001:db8:0:42::7", "v6-Fixture Voll-/Kürzform")
net6, steps6 = analyse_v6(v6, 64)
check(str(net6) == "2001:db8:0:42::/64", "v6-Netzpräfix falsch")
check(any("längste Nullfolge" in s.title for s in steps6), "::-Regel-Schritt fehlt")
check(any(s.source == "rfc4862" for s in steps6), "/64 muss den SLAAC-Schritt tragen")
# Einzelner Nullblock wird laut RFC 5952 NICHT zu ::
single0 = ipaddress.IPv6Address("2001:db8:0:1:1:1:1:1")
check(single0.compressed == "2001:db8:0:1:1:1:1:1", "einzelner Nullblock darf kein :: werden")
check(any("kein ::" in s.title for s in analyse_v6(single0, 64)[1]),
"Hinweis auf Nicht-Kürzung fehlt")
_, steps48 = analyse_v6(ipaddress.IPv6Address("2001:db8::"), 48)
check(any("65 536" in s.text for s in steps48), "/48 muss 65 536 /64-Subnetze nennen")
_, steps52 = analyse_v6(ipaddress.IPv6Address("2001:db8::"), 52)
check(any("mitten durch Block 4" in s.text for s in steps52),
"/52: Grenzblock-Hinweis fehlt")
# Typen: Doku-Bereich MUSS vor GUA erkannt werden (liegt in 2000::/3)
check(classify_v6(ipaddress.IPv6Address("2001:db8::1"))[0] == "Dokumentationsbereich",
"2001:db8:: muss Doku-Bereich sein, nicht GUA")
check(classify_v6(ipaddress.IPv6Address("fd12::1"))[0].startswith("ULA"), "fd -> ULA")
check(classify_v6(ipaddress.IPv6Address("fe80::1"))[0] == "Link-Local", "fe80 -> Link-Local")
check(classify_v6(ipaddress.IPv6Address("2a00::1"))[0].startswith("Global"), "2a00 -> GUA")
check(classify_v6(ipaddress.IPv6Address("ff02::1"))[0] == "Multicast", "ff02 -> Multicast")
check(classify_v6(ipaddress.IPv6Address("::1"))[0] == "Loopback", ":: 1 -> Loopback")
# REQUEST ipv6
c6 = parse_config("mode=ipv6&ip=2001%3Adb8%3A%3A7%2F48")
check(c6.ip6 is not None and c6.prefix6 == 48, "ipv6-Parse mit /48")
c6d = parse_config("mode=ipv6&ip=2001%3Adb8%3A%3A7")
check(c6d.prefix6 == 64 and c6d.warnings, "ipv6 ohne Präfix: /64 + Hinweis")
check(parse_config("mode=ipv6&ip=kaputt").errors, "ungültige v6-Adresse muss Fehler geben")
check(parse_config("mode=ipv6&ip=%3A%3A1%2F200").errors, "/200 muss Fehler geben")
# RENDER ipv6
s6, _, b6 = respond("mode=ipv6&ip=" + DEMO_V6.replace("/", "%2F"))
check(s6 == 200 and "2001:0db8:0000:0042:0000:0000:0000:0007" in b6
and "Dokumentationsbereich" in b6, "IPv6-Seite unvollständig")
check("Adresstypen im Überblick" in b6, "Typen-Tabelle fehlt")
s60, _, b60 = respond("mode=ipv6")
check(s60 == 200 and "Beispiel" in b60, "IPv6-Erstaufruf ohne Beispiel")
check(">IPv6</a>" in respond("")[2], "IPv6-Tab fehlt in der Navigation")
# IPv4 im IPv6-Tab: ZURÜCKGEBAUT (Nutzerentscheid 2026-07-29).
# Fachliche Basis: v4/v6 haben getrennte Adressräume (RFC 4291); die
# zuvor gezeigten Formate ::ffff:/64:ff9b:: sind Einbettungen für
# Dual-Stack bzw. Übersetzung (RFC 4291 §2.5.5.2, RFC 6052), keine
# Adress-Umwandlung - der Tab soll das Missverständnis nicht nähren.
check(parse_config("mode=ipv6&ip=192.168.10.77").errors,
"v4 im ipv6-Modus muss abgelehnt werden")
s4, _, b4 = respond("mode=ipv6&ip=192.168.10.77")
check(s4 == 200 and "Eingabe prüfen" in b4 and "keine gültige IPv6-Adresse" in b4,
"v4-Eingabe muss die Fehlerkarte zeigen")
check("analyse_v4_in_v6" not in globals(), "v4-in-v6-Funktion muss entfernt sein")
# --- Trainer v6 (V1.5): Familien-Umschalter + 5 Aufgabentypen -----------
sub300, s300 = nth_subnet_v6(ipaddress.IPv6Network("2001:db8:2f::/48"), 300)
check(str(sub300) == "2001:db8:2f:12c::/64", f"Subnetz 300 falsch: {sub300}")
check(any("12c" in s.text for s in s300), "Dez->Hex-Schritt fehlt")
last48, _ = nth_subnet_v6(ipaddress.IPv6Network("2001:db8:2f::/48"), 65535)
check(str(last48) == "2001:db8:2f:ffff::/64", "letztes /64 falsch")
# der Praxis-Klassiker: /56 -> 4 x /58, Schrittweite 0x40
subs58, np58, step58, steps58 = split_v6(
ipaddress.IPv6Network("2001:db8:aaaa:bb00::/56"), 4)
check(np58 == 58 and step58 == 0x40, f"/56 in 4 -> /58 mit 0x40, bekam /{np58}, 0x{step58:x}")
check([s.compressed for s in subs58] ==
["2001:db8:aaaa:bb00::/58", "2001:db8:aaaa:bb40::/58",
"2001:db8:aaaa:bb80::/58", "2001:db8:aaaa:bbc0::/58"],
"die vier /58-Subnetzadressen sind falsch")
check(any("mitten in der Hexstelle" in s.title for s in steps58),
"Nicht-Nibble-Warnschritt fehlt")
subs52, np52, step52, steps52 = split_v6(ipaddress.IPv6Network("2001:db8:2f::/48"), 16)
check(np52 == 52 and step52 == 0x1000
and not any("mitten" in s.title for s in steps52),
"Nibble-Teilung /48->16 falsch oder fälschlich gewarnt")
# Generatoren: deterministisch, mix6 bleibt in der v6-Familie
for typ in TASK_ORDER_V6 + ["mix6"]:
for level in LEVELS:
t_a = generate_task(typ, level, 4711)
t_b = generate_task(typ, level, 4711)
check(t_a.question == t_b.question and t_a.answers == t_b.answers,
f"v6-Generator {typ}/S{level} nicht deterministisch")
check(t_a.typ in TASK_ORDER_V6, f"{typ} muss in der v6-Familie bleiben")
# Notation-Aufgabe: Adresse ist immer kürzbar (sonst trivial)
for seed in range(1, 30):
tno = generate_task("notation", 1, seed)
check("::" in dict(tno.answers)["Kürzform"],
f"notation/S1/seed{seed}: Adresse nicht kürzbar")
# adresstyp Stufe 3 enthält den Doku-vor-GUA-Fallstrick im Pool (Smoke)
t_at = generate_task("adresstyp", 3, 12)
check(len(t_at.answers) == 3 and all(a[1] for a in t_at.answers),
"adresstyp: drei Zuordnungen erwartet")
# REQUEST/RENDER: mix6-Whitelist, Familien-Leiste, Alt-URLs unberührt
check(parse_config("mode=ueben&typ=mix6&seed=1").typ == "mix6", "mix6 muss erlaubt sein")
check(parse_config("mode=ueben&typ=quatsch&seed=1").typ == "mix",
"unbekannter Typ fällt weiter auf mix (v4) zurück")
sr6, hr6, _ = respond("mode=ueben&typ=mix6")
check(sr6 == 302 and "typ=mix6" in hr6[0][1], "mix6 ohne Seed muss redirecten")
_, _, bt6 = respond("mode=ueben&typ=teilen&stufe=3&seed=777001")
check("IPv6 · Netz teilen" in bt6 and ">IPv4</a>" in bt6 and ">IPv6</a>" in bt6,
"Trainer-Familienleiste oder v6-Typlabel fehlt")
_, _, bt4 = respond("mode=ueben&typ=netz&stufe=2&seed=424242")
check("IPv4 · Netz & Broadcast" in bt4 and "172.27.36.96" in bt4,
"v4-Trainer verändert (GOLDEN-URL)")
# REQUEST: Validierung und Redirect-Regel
check(parse_config("ip=999.1.2.3").errors, "Ungültige IP muss Fehler geben")
check(parse_config("ip=10.0.0.1&mask=255.0.255.0").errors,
"Nicht zusammenhängende Maske muss Fehler geben")
check(parse_config("ip=10.0.0.1&prefix=26&mask=255.255.255.0").warnings,
"prefix+mask zusammen muss warnen")
check(parse_config("ip=10.0.0.1").warnings, "fehlende Maske muss /24-Hinweis geben")
check(parse_config("ip=10.0.0.1&mask=255.255.255.192").prefix == 26,
"Maske muss als /26 ankommen")
check(not parse_config("").submitted, "Leerer Aufruf darf nicht 'submitted' sein")
check(needs_seed_redirect(parse_config("mode=ueben")) is not None,
"Üben ohne Seed muss auf Seed-URL umleiten")
check(needs_seed_redirect(parse_config("mode=ueben&seed=123456")) is None,
"Üben mit Seed darf nicht umleiten")
# RENDER/ENTRY: Wasserzeichen, Determinismus der Seite, Redirect-Antwort
status, headers, body = respond("ip=192.168.10.77&prefix=26")
check(status == 200 and "jzt-7c3f9a2e" in body.splitlines()[1],
"Wasserzeichen muss direkt nach <!DOCTYPE html> stehen")
check("192.168.10.64" in body, "Analyse-Ergebnis fehlt im HTML")
check("privater Adressbereich · RFC 1918" in body,
"Einordnungs-Badge fehlt im HTML")
check(body.count("= Netzanteil") == 1,
"Binär-Legende soll genau einmal erscheinen")
s2, h2, b2 = respond("mode=ueben&typ=vlsm&stufe=3&seed=555555")
s3, h3, b3 = respond("mode=ueben&typ=vlsm&stufe=3&seed=555555")
check(s2 == 200 and b2 == b3, "Gleiche Übungs-URL muss identisches HTML liefern")
sr, hr, _ = respond("mode=ueben")
check(sr == 302 and hr[0][0] == "Location" and "seed=" in hr[0][1],
"Üben ohne Seed: 302 auf Seed-URL erwartet")
check("TOOLBOX-TILE" not in render(parse_config("")), "Marker gehört nicht in die Seite")
if failures:
print("SELF-TEST FEHLGESCHLAGEN:")
for f in failures:
print(" -", f)
return 1
print("SELF-TEST OK - alle Zusicherungen erfüllt.")
return 0
def main(argv: list[str]) -> int:
if "--self-test" in argv:
return self_test()
if "--serve" in argv:
i = argv.index("--serve")
port = int(argv[i + 1]) if len(argv) > i + 1 and argv[i + 1].isdigit() else 8080
run_dev_server(port)
return 0
# CGI-Modus (Default): auch ohne Webserver druckt es die Seite auf stdout.
handle_cgi()
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))