← zurück zur Toolbox

net_vlan_planner_v1.py · Quelltext

76787 Bytes · SHA-256: 1ff27ee18cb9924ef9d4e730e6b6d8d5a443e6d5d2ad94789a8f44ea97d0a09b

⤓ Download
#!/usr/home/jozapf/public_html/toolenv/bin/python
"""Strukturiert ein Netz nach dem Schema 10.<VLAN>.<Standort>.<Host>: Allokations-Matrix, Policy-Matrix mit fertiger Firewall-Regelliste und Subnetz-Prüfung nach Gerätezahl (VLSM) - Mixed-Use & externe VPN-Zugänge optional.

Net+VLAN Planner - Toolbox-Tool (jozapf.de).

Referenz-Schema (Single Source of Truth = Abschnitt SCHEMA weiter unten):
  hierarchischer IP-/VLAN-Adressplan (Rolle im 2., Standort im 3. Oktett) mit
  Mixed-Use-Erweiterung für einen kombinierten Privat-/Firmenstandort.
  Alle Kataloge sind anonymisierte Beispieldaten (reale Belegung extern gepflegt).

Angewandte Standards / Best-Practice-Vorlagen:
  - RFC 1918   private Adressräume (10/8 betrieblich, 192.168/16 privat)
  - RFC 4632   CIDR + VLSM (Subnetz-Sizing nach Bedarf/Gerätezahl)
  - IEEE 802.1Q  VLAN-Tagging / Segmentierung
  - Hierarchisch-strukturierter Adressplan (Rolle im 2., Standort im 3. Oktett)
  - Rollenbasierte Trust-Zonen / Zero-Trust-Segmentierung (deny-by-default)

Die Policy leitet sich vollständig aus dem VLAN-Katalog ab, aus zwei Begriffen,
die nicht dasselbe sind: dem Trust-Gefälle (Verwaltungsrichtung, wirkt nur nach
unten) und den Dienst-Ausnahmen (SERVICE_RULES, portbeschränkt nach oben - ohne
sie kaeme kein Client an den Server). Matrix und Regelliste sind zwei Sichten auf
dieselbe Quelle und können daher nicht auseinanderlaufen.

Architektur (bewusst modular, eine Datei für die Toolbox-Auslieferung):
  1. SCHEMA   - Domänen-Daten (Kataloge). Erweitern = hier Einträge ergänzen.
  2. DOMAIN   - reine Logik: Planner + PolicyEngine + SubnetSizer.
  3. REQUEST  - Query-String -> PlanConfig (HTTP von Domäne entkoppelt).
  4. RENDER   - Domänen-Objekte -> HTML (Präsentation entkoppelt).
  5. ENTRY    - CGI (stdout), Dev-Server (--serve), Self-Test (--self-test).

Deploy: als CGI wie die anderen Toolbox-Tools (gibt Content-Type-Header aus).
Lokal:  python3 net_vlan_planner_v1.py --serve 8080   -> http://localhost:8080
Test:   python3 net_vlan_planner_v1.py --self-test
"""
from __future__ import annotations

import html
import math
import os
import sys
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from urllib.parse import parse_qs, urlencode

VERSION = "1.1.0"

# 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": "Net+VLAN Planner", "desc": "Netz nach dem Schema 10.<VLAN>.<Standort>.<Host> strukturieren — Allokations-Matrix plus Policy-Matrix mit fertiger Firewall-Regelliste; Zonen von Infrastruktur bis Gäste/IoT/DMZ, Mixed-Use & VPN-Zugänge optional.", "icon": "🧮", "type": "web", "example": "?profile=standard&sites=2", "order": 35}
TILE_DESCRIPTION = ("Netz nach dem Schema 10.<VLAN>.<Standort>.<Host> strukturieren — "
                    "Allokations-Matrix plus Policy-Matrix mit fertiger Firewall-Regelliste; "
                    "Zonen von Infrastruktur bis Gäste/IoT/DMZ, Mixed-Use & VPN-Zugänge optional.")

# =============================================================================
# 1. SCHEMA  -  Domänen-Daten / Single Source of Truth
#    Alles was das Schema definiert steht hier. Ein neues VLAN, ein neuer
#    Standort oder eine andere Host-Konvention = genau ein Eintrag mehr.
# =============================================================================


class TrustLevel(Enum):
    """Trust-Zone eines VLAN. Der `.tone` steuert die Farbgebung im UI,
    der `.rank` das Trust-Gefälle der Policy (höher = vertrauenswürdiger,
    -1 = noch nicht festgelegt)."""
    HIGH = ("hoch", "pass", 3)
    MEDIUM = ("mittel", "info", 2)
    LOW_MEDIUM = ("niedrig-mittel", "warn", 1)
    LOW = ("niedrig", "warn", 0)
    TBD = ("tbd", "muted", -1)

    def __init__(self, label: str, tone: str, rank: int):
        self.label = label
        self.tone = tone
        self.rank = rank

    @property
    def defined(self) -> bool:
        return self.rank >= 0


@dataclass(frozen=True)
class VlanDef:
    """Ein VLAN aus dem 2. Oktett-Katalog."""
    vlan_id: int
    name: str
    purpose: str
    trust: TrustLevel


@dataclass(frozen=True)
class SiteDef:
    """Ein Standort (3. Oktett) - generischer Platzhalter, frei benennbar."""
    octet: int
    name: str


@dataclass(frozen=True)
class HostRange:
    """Ein Bereich der Host-Konvention (4. Oktett)."""
    label: str
    start: int
    end: int
    purpose: str

    def contains(self, host: int) -> bool:
        return self.start <= host <= self.end


@dataclass(frozen=True)
class PrivateNet:
    """Ein privates, nicht-betriebliches Netz des Mixed-Use-Standorts."""
    name: str
    vlan_id: int
    cidr: str
    policy: str


@dataclass(frozen=True)
class ServiceRule:
    """Eine Dienst-Ausnahme *entgegen* dem Trust-Gefälle.

    Das Trust-Gefälle allein ergibt keine benutzbare Policy: Clients müssen an
    den Server, sonst arbeitet niemand - obwohl der Server höher steht. Solche
    Zugriffe sind hier explizit und portbeschränkt hinterlegt, statt das
    Gefälle aufzuweichen.
    """
    dst: int                            # Ziel-VLAN
    sources: Optional[tuple[int, ...]]  # None = alle VLANs ohne Gefälle-Recht
    ports: str
    purpose: str

    def matches(self, src_id: int, dst_id: int) -> bool:
        return dst_id == self.dst and (self.sources is None or src_id in self.sources)


# --- Konstanten des Schemas ---------------------------------------------------
BASE_OCTET_DEFAULT = 10            # RFC1918 10.0.0.0/8, Default der Basis (waehlbar)

# Das /24 ist bewusst NICHT konfigurierbar - und das ist keine Bequemlichkeit:
# es ist keine Einstellung, sondern eine Folge des Schemas. Das 3. Oktett IST der
# Standort, also liegt die Prefix-Grenze zwangslaeufig bei Bit 24, genau dort wo
# "Standort" endet und "Host" beginnt.
#   /23 -> ein Netz umspannt zwei Standort-Oktette, Standort 0 und 1 verschmelzen
#   /25 -> zwei halbe Standorte teilen ein Oktett, es benennt keinen mehr eindeutig
# In beiden Faellen ist eine IP nicht mehr lesbar - also genau das weg, wofuer es
# das Schema gibt. Der einzige Grund fuer kleinere Subnetze waere Adressknappheit;
# 10/8 haelt 65536 /24 bereit, bei 9 VLANs x 50 Standorten braucht man 450.
# Braucht ein VLAN mehr als 254 Hosts, siehe NetworkPlan.overflows: aufteilen oder
# diesem einen VLAN einen groesseren Block ausserhalb der Systematik geben - das
# erhaelt das Schema fuer alle uebrigen, ein globaler Prefix-Schalter gaebe es
# fuer alle auf, um einem zu helfen.
SCHEMA_PREFIX = 24                 # strukturell, nicht konfigurierbar (s.o.)
SCHEMA_HOSTS = (1 << (32 - SCHEMA_PREFIX)) - 2   # 254 nutzbare Hosts je Schema-Netz
NATIVE_TRUNK_VLAN = 99             # Native/Trunk-Markierung, kein Endgeräte-Netz
FLOATING_VLAN = 50                 # VLAN der Externen - Floating-Peers liegen dort
FLOATING_RANGE = range(200, 250)   # 200-249 Floating-Peers (VLAN 50)
HUB_OCTET = 250                    # Cloud-VPS / WireGuard-Hub
HUB_TRANSFER_OCTET2 = 99           # Hub-Transfer-Netz <Basis>.99.0.0/24 (eigener WireGuard-Hub)
# Kein festes Oktett: der Mixed-Use-Standort IST einer der geplanten Standorte
# (Default = der letzte). Ein hart verdrahtetes Oktett erfand sonst Netze, die in
# der Allokations-Matrix gar nicht vorkommen.

# --- VLAN-Katalog (2. Oktett), hierarchisch: kleinste ID = höchste Trust-Stufe.
#     Ein Netz sieht in alle darunterliegenden (größere IDs) hinein, nie
#     umgekehrt - deshalb stehen die privilegierten Clients unter Server, aber
#     über den Standard-Clients (Einblick nach unten, kein Zugriff nach oben).
#
#     Benannt wird nach Funktion und Trust-Zone, nie nach Abteilung: Abteilungen
#     werden umorganisiert, das Netz nicht. Deshalb "Clients - privilegiert"
#     statt "Leitung" - der Name nennt die Sicherheitseigenschaft, die auch die
#     Policy erzeugt. (Und "Management" waere hier doppeldeutig: VLAN 10 ist das
#     Mgmt-VLAN im Netzsinn.)
#
#     Die unterste Stufe (Trust "niedrig": 50/60/70/80) ist KEINE Rangfolge,
#     sondern ein Feld gegenseitig isolierter Zonen - siehe PolicyEngine.
VLAN_CATALOG: list[VlanDef] = [
    VlanDef(10, "Infrastruktur-Management",
            "Switches, Access Points, Firewalls, Gateways - Out-of-Band-Verwaltung des Netzes selbst",
            TrustLevel.HIGH),
    VlanDef(20, "Server & Anwendungen",
            "Server, Datenbanken, interne Anwendungs-Backends", TrustLevel.HIGH),
    VlanDef(30, "Clients - privilegiert",
            "Arbeitsgeräte mit erweitertem Zugriff - Einblick in die darunterliegenden Zonen, "
            "kein Zugriff auf Infrastruktur/Server", TrustLevel.HIGH),
    VlanDef(40, "Clients - Standard",
            "Reguläre Arbeitsgeräte ohne erweiterte Rechte", TrustLevel.MEDIUM),
    VlanDef(50, "Externe / Dritte",
            "Geräte von Dienstleistern, Partnern, Lieferanten; deny-by-default + Whitelist, "
            "Floating 200-249", TrustLevel.LOW),
    VlanDef(60, "Gäste",
            "Besucher-WLAN: ausschließlich Internet, keinerlei Ost-West-Verkehr", TrustLevel.LOW),
    VlanDef(70, "IoT / Gebäudetechnik",
            "Kameras, Drucker, Sensorik, Klima, TK-Hardware - erreicht nur den eigenen Controller",
            TrustLevel.LOW),
    VlanDef(80, "DMZ",
            "Von außen erreichbare Dienste - darf nie von sich aus nach innen", TrustLevel.LOW),
    VlanDef(90, "Sprache (VoIP)",
            "IP-Telefone, PBX-Anbindung, QoS-priorisiert", TrustLevel.LOW_MEDIUM),
]
VLAN_BY_ID: dict[int, VlanDef] = {v.vlan_id: v for v in VLAN_CATALOG}

# --- Dienst-Ausnahmen: erlaubte Zugriffe entgegen dem Trust-Gefälle ----------
# Bewusst kurz halten: jede Zeile hier ist ein Loch in der Segmentierung und
# gehört im Ziel-VLAN auf konkrete Hosts eingeschränkt (Ports sind Beispiele).
#
# Quellen werden IMMER explizit aufgezählt. Ein bequemes sources=None ("alle")
# hiesse: auch Gäste und DMZ - und damit waere die Zone genau das nicht mehr,
# wofür sie da ist. Wer eine Quelle vergisst, merkt es an einer roten Zelle;
# wer None schreibt, merkt es nie.
SERVICE_RULES: list[ServiceRule] = [
    ServiceRule(20, (30, 40, 50, 90), "tcp/443, tcp/445, tcp/1433",
                "Clients, Telefone und Externe erreichen Anwendungs-, Datei- und DB-Dienste"),
    ServiceRule(20, (70,), "tcp/443",
                "IoT-/Gebäudetechnik meldet sich ausschließlich bei ihrem eigenen Controller"),
]

# --- Standorte (3. Oktett): generisch, Anzahl frei wählbar -------------------
# Es gibt bewusst KEINEN festen Standort-Katalog: reale Standortnamen gehören
# nicht in die (öffentliche) Vorlage. Konvention: Oktett 0 = Zentrale/HQ,
# weitere Standorte fortlaufend; 200-249 Floating, 250 Hub.
def site_label(octet: int) -> str:
    return "Zentrale (HQ)" if octet == 0 else f"Standort {octet}"

# --- Host-Konvention (4. Oktett) ---------------------------------------------
HOST_CONVENTION: list[HostRange] = [
    HostRange(".1", 1, 1, "Gateway (Router / Firewall)"),
    HostRange(".2-.9", 2, 9, "Infrastruktur: Switch-/AP-Mgmt, PoE-Injektoren"),
    HostRange(".10-.99", 10, 99, "Statische Server / Workstations / Drucker / VoIP"),
    HostRange(".100-.199", 100, 199, "DHCP-Pool reguläre Endgeräte"),
    HostRange(".200-.254", 200, 254, "Reserviert / Sonderfälle / Test-Geräte"),
]

# --- Mixed-Use private Domäne, bewusst außerhalb des Corporate-Schemas ------
PRIVATE_ZONE: list[PrivateNet] = [
    PrivateNet("Privat-WLAN 1", 150, "192.168.50.0/24", "nur Internet, Content-Filter + Zeitplan"),
    PrivateNet("Privat-WLAN 2", 151, "192.168.51.0/24", "nur Internet (Haushalt / Gäste)"),
    PrivateNet("Privat-LAN", 152, "192.168.52.0/24", "nur Internet, ungedrosselt, kabelgebunden"),
]
PRIVATE_BY_OCTET3: dict[int, PrivateNet] = {50: PRIVATE_ZONE[0], 51: PRIVATE_ZONE[1], 52: PRIVATE_ZONE[2]}

# --- Vorlagen: fertige VLAN-Sets, die nur das VLAN-Feld befüllen -------------
# Bewusst KEIN eigenes Formularfeld: Vorlage und VLAN-Liste wuerden sonst um
# denselben Wert konkurrieren und eines von beiden zeigte immer etwas Falsches.
# Einzige Wahrheit ist die VLAN-Liste; die Vorlage ist nur eine Abkuerzung.
PROFILES: dict[str, dict] = {
    "voll":     {"label": "Vollausbau", "vlans": [10, 20, 30, 40, 50, 60, 70, 80, 90]},
    "standard": {"label": "Standard", "vlans": [10, 20, 40, 60, 90]},
    "minimal":  {"label": "Minimal", "vlans": [10, 40]},
    "mixeduse": {"label": "Mixed-Use", "vlans": [10, 30, 90]},
    "custom":   {"label": "Custom", "vlans": [10, 20, 40]},
}
PRESET_ORDER = ["voll", "standard", "minimal", "mixeduse"]   # "custom" = nur Fallback

# Wachstums-Reserve als Auswahl mit Bedeutung statt als nacktes "Reserve %".
HEADROOM_CHOICES: list[tuple[int, str]] = [
    (10, "stabil (10 % Reserve)"),
    (30, "normal (30 % Reserve)"),
    (100, "stark wachsend (100 % Reserve)"),
]
HEADROOM_DEFAULT = 30

# Sicherheits-Obergrenzen gegen versehentlich riesige Ausgaben.
MAX_SITES = 50
MAX_FLOATING = 50


# =============================================================================
# 2. DOMAIN  -  reine Logik (kennt weder HTTP noch HTML)
# =============================================================================


@dataclass(frozen=True)
class Subnet:
    """Ein generiertes /24 (VLAN x Standort)."""
    vlan: VlanDef
    site: SiteDef
    base: int

    @property
    def cidr(self) -> str:
        return f"{self.base}.{self.vlan.vlan_id}.{self.site.octet}.0/{SCHEMA_PREFIX}"

    @property
    def gateway(self) -> str:
        return f"{self.base}.{self.vlan.vlan_id}.{self.site.octet}.1"

    @property
    def dhcp_pool(self) -> str:
        p = f"{self.base}.{self.vlan.vlan_id}.{self.site.octet}"
        return f"{p}.100 - {p}.199"


@dataclass(frozen=True)
class FloatingSubnet:
    """Floating-Peer eines Externen ohne Standortzuordnung (VLAN 50, 200-249)."""
    index: int          # 0, 1, 2 ...
    base: int

    @property
    def octet3(self) -> int:
        return FLOATING_RANGE.start + self.index

    @property
    def cidr(self) -> str:
        return f"{self.base}.{FLOATING_VLAN}.{self.octet3}.0/{SCHEMA_PREFIX}"


@dataclass
class NetworkPlan:
    """Ergebnis der Planung."""
    base: int
    vlans: list[VlanDef]
    sites: list[SiteDef]
    subnets: list[Subnet] = field(default_factory=list)
    floating: list[FloatingSubnet] = field(default_factory=list)
    mixed_use_site: Optional[SiteDef] = None
    hub_enabled: bool = False
    # Bedarf je VLAN, sofern angegeben. Das Schema vergibt immer ein /24 - eine
    # Geraetezahl kann den Plan daher nicht umbauen, nur belegen, ob er traegt.
    loads: dict[int, "SizeResult"] = field(default_factory=dict)

    @property
    def subnet_count(self) -> int:
        return len(self.subnets) + len(self.floating)

    def subnet_at(self, vlan_id: int, site_octet: int) -> Optional[Subnet]:
        for s in self.subnets:
            if s.vlan.vlan_id == vlan_id and s.site.octet == site_octet:
                return s
        return None

    def load_of(self, vlan_id: int) -> Optional["SizeResult"]:
        return self.loads.get(vlan_id)

    @property
    def overflows(self) -> list[tuple[VlanDef, "SizeResult"]]:
        """VLANs, deren Bedarf ein /24 sprengt - die einzige Aussage, aus der
        hier eine echte Planungsentscheidung folgt."""
        return [(v, self.loads[v.vlan_id]) for v in self.vlans
                if v.vlan_id in self.loads and not self.loads[v.vlan_id].fits_schema]


class Planner:
    """Baut aus einer PlanConfig einen NetworkPlan (vorwärts)."""

    @staticmethod
    def build(cfg: "PlanConfig") -> NetworkPlan:
        vlans = [VLAN_BY_ID[v] for v in cfg.vlan_ids if v in VLAN_BY_ID]
        sites = [resolve_site(o) for o in cfg.site_octets]
        plan = NetworkPlan(base=cfg.base, vlans=vlans, sites=sites)

        for vlan in vlans:
            for site in sites:
                plan.subnets.append(Subnet(vlan=vlan, site=site, base=cfg.base))

        for i in range(cfg.floating_count):
            plan.floating.append(FloatingSubnet(index=i, base=cfg.base))

        for vlan in vlans:
            n = cfg.devices.get(vlan.vlan_id)
            if n:
                plan.loads[vlan.vlan_id] = SubnetSizer.size(n, cfg.size_headroom)

        if cfg.mixed_use:
            plan.mixed_use_site = next((x for x in sites if x.octet == cfg.mixed_site), None)
        plan.hub_enabled = cfg.hub
        return plan


class PolicyVerdict(Enum):
    """Bewertung einer *gerichteten* VLAN-Beziehung (Zeile = von, Spalte = nach)."""
    ALLOW = ("erlaubt", "pass", "&check;")
    SERVICES = ("nur Dienste", "warn", "~")
    DENY = ("blockiert", "fail", "&times;")
    SELF = ("intra-VLAN", "muted", "&middot;")
    UNDEFINED = ("offen", "muted", "?")

    def __init__(self, label: str, tone: str, glyph: str):
        self.label = label
        self.tone = tone
        self.glyph = glyph


@dataclass(frozen=True)
class PolicyCell:
    """Eine Zelle der Policy-Matrix: darf `src` eine Verbindung zu `dst` aufbauen?"""
    src: VlanDef
    dst: VlanDef
    verdict: PolicyVerdict
    reason: str
    ports: str = ""


@dataclass(frozen=True)
class FirewallRule:
    """Eine Zeile der Regelliste - die Matrix in Abarbeitungsreihenfolge."""
    order: int
    action: str        # ALLOW | DENY
    src: str
    dst: str
    ports: str
    comment: str
    tone: str


@dataclass
class PolicyMatrix:
    """VLAN x VLAN, gerichtet. Zeile = Initiator, Spalte = Ziel."""
    vlans: list[VlanDef]
    cells: dict[tuple[int, int], PolicyCell] = field(default_factory=dict)

    def cell(self, src_id: int, dst_id: int) -> Optional[PolicyCell]:
        return self.cells.get((src_id, dst_id))

    def count(self, verdict: PolicyVerdict) -> int:
        return sum(1 for c in self.cells.values() if c.verdict is verdict)

    @property
    def has_undefined(self) -> bool:
        return self.count(PolicyVerdict.UNDEFINED) > 0

    def rules(self) -> list[FirewallRule]:
        """Dieselbe Aussage als Regelliste: erst die Allows, unten deny-any.

        Die Matrix ist zum Verstehen da, diese Liste zum Eintippen - beide aus
        derselben Quelle, damit sie nicht auseinanderlaufen können.
        """
        out: list[FirewallRule] = []
        n = 1
        out.append(FirewallRule(
            n, "ALLOW", "any", "any", "established, related",
            "Rückrichtung bereits erlaubter Verbindungen (stateful) - muss zuerst greifen",
            "info"))

        # Gefälle-Allows, je Initiator zu einer Zeile gebuendelt.
        for src in self.vlans:
            dsts = [c.dst for c in self._row(src.vlan_id) if c.verdict is PolicyVerdict.ALLOW]
            if dsts:
                n += 1
                out.append(FirewallRule(
                    n, "ALLOW", f"VLAN {src.vlan_id}",
                    ", ".join(f"VLAN {d.vlan_id}" for d in dsts), "any",
                    f"Trust-Gefälle: {src.name} verwaltet die tiefer eingestuften Netze",
                    "pass"))

        # Dienst-Ausnahmen, je Regel zu einer Zeile gebuendelt.
        for sr in SERVICE_RULES:
            srcs = [c.src for c in self.cells.values()
                    if c.verdict is PolicyVerdict.SERVICES and c.dst.vlan_id == sr.dst
                    and c.ports == sr.ports]
            if srcs:
                n += 1
                order = sorted({s.vlan_id for s in srcs})
                out.append(FirewallRule(
                    n, "ALLOW", ", ".join(f"VLAN {i}" for i in order),
                    f"VLAN {sr.dst}", sr.ports,
                    f"{sr.purpose} - im Ziel-VLAN auf die konkreten Hosts einschränken",
                    "warn"))

        n += 1
        out.append(FirewallRule(
            n, "DENY", "any", "any", "any",
            "deny-by-default: alles ohne Regel darüber ist verboten (mit Logging)",
            "fail"))
        return out

    def _row(self, src_id: int) -> list[PolicyCell]:
        return [c for (s, _), c in self.cells.items() if s == src_id]


class PolicyEngine:
    """Leitet die Firewall-Policy aus dem VLAN-Katalog ab.

    Zwei Begriffe, die nicht dasselbe sind:

      Verwaltungsrichtung - folgt dem Trust-Gefälle. Ein Netz erreicht die
        tiefer eingestuften Netze, nie umgekehrt.
      Dienst-Zugriffe     - explizite, portbeschraenkte Ausnahmen nach oben
        (SERVICE_RULES). Ohne sie wäre die Policy zwar konsistent, aber
        unbenutzbar: kein Client kaeme an den Server.
    """

    @staticmethod
    def build(vlans: list[VlanDef]) -> PolicyMatrix:
        m = PolicyMatrix(vlans=list(vlans))
        for src in vlans:
            for dst in vlans:
                m.cells[(src.vlan_id, dst.vlan_id)] = PolicyEngine.verdict(src, dst)
        return m

    @staticmethod
    def verdict(src: VlanDef, dst: VlanDef) -> PolicyCell:
        if src.vlan_id == dst.vlan_id:
            return PolicyCell(src, dst, PolicyVerdict.SELF,
                              "Verkehr innerhalb des VLAN - wird geswitcht, nicht gefiltert")

        if not src.trust.defined or not dst.trust.defined:
            return PolicyCell(src, dst, PolicyVerdict.UNDEFINED,
                              "Trust-Stufe eines der beiden Netze ist noch nicht festgelegt "
                              "(Reserve/Vorlage) - Policy vor Inbetriebnahme entscheiden")

        # Untrusted Zonen (Trust "niedrig": Externe, Gäste, IoT, DMZ) nehmen an
        # der Verwaltungsrichtung NICHT teil. Sie stehen nicht in einer Rangfolge
        # zueinander, sondern nebeneinander und gegenseitig isoliert - ihre
        # gleiche Trust-Stufe ist kein Gefälle. Ohne diese Unterscheidung dürften
        # Gäste in die DMZ und Externe ins Gästenetz, nur weil deren ID größer
        # ist. Der Riegel steht bewusst *vor* dem Gefälle und *nicht* vor den
        # Dienst-Ausnahmen: die bleiben der einzige Weg aus einer solchen Zone.
        src_darf_verwalten = src.trust.rank > TrustLevel.LOW.rank

        # Verwaltungsrichtung: kleinere ID = früher in der Hierarchie, und die
        # Trust-Stufe darf dabei nicht steigen. Beides muss gelten, sonst dürfte
        # ein Netz die höher eingestuften verwalten, nur weil deren ID größer ist.
        if src_darf_verwalten and src.vlan_id < dst.vlan_id and src.trust.rank >= dst.trust.rank:
            return PolicyCell(src, dst, PolicyVerdict.ALLOW,
                              f"Trust-Gefälle: {src.name} (Trust {src.trust.label}) steht über "
                              f"{dst.name} (Trust {dst.trust.label})")

        for sr in SERVICE_RULES:
            if sr.matches(src.vlan_id, dst.vlan_id):
                note = sr.purpose
                if not src_darf_verwalten:
                    note += (f" - {src.name} ist eine untrusted Zone: Whitelist strikt pro Host "
                             "und Port pflegen")
                return PolicyCell(src, dst, PolicyVerdict.SERVICES, note, sr.ports)

        if not src_darf_verwalten:
            return PolicyCell(
                src, dst, PolicyVerdict.DENY,
                f"{src.name} ist eine untrusted Zone und baut nie von sich aus Verbindungen auf "
                f"- für {dst.name} gibt es keine Dienst-Ausnahme")

        return PolicyCell(src, dst, PolicyVerdict.DENY,
                          f"deny-by-default: {src.name} hat keinen Anspruch auf {dst.name} "
                          "(kein Trust-Gefälle, keine Dienst-Ausnahme)")


def resolve_site(octet: int) -> SiteDef:
    """Generischer Standort-Platzhalter (reale Namen pflegt das Projekt extern)."""
    return SiteDef(octet=octet, name=site_label(octet))


@dataclass(frozen=True)
class SizeResult:
    """Ergebnis der Subnetz-Dimensionierung (VLSM/CIDR nach RFC 4632)."""
    devices: int
    headroom: float
    required: int          # devices inkl. Reserve
    prefix: int            # CIDR-Prefix, z.B. 24
    block_size: int        # Gesamtadressen 2^(32-prefix)
    usable_hosts: int      # nutzbare Hosts (block - 2, ausser /31, /32)
    mask: str              # dotted netmask, z.B. 255.255.255.0
    utilization: int       # Auslastung in % (required / usable_hosts)

    @property
    def fits_schema(self) -> bool:
        """Passt der Bedarf in das feste /24-Segment des Corporate-Schemas?"""
        return self.prefix >= SCHEMA_PREFIX

    @property
    def utilization_24(self) -> int:
        """Auslastung bezogen auf das Schema-Netz - die Zahl, die den Nutzer
        interessiert: wie voll wird das Netz, das er ohnehin bekommt?"""
        return math.ceil(self.required / SCHEMA_HOSTS * 100)

    @property
    def verdict_tone(self) -> str:
        if not self.fits_schema:
            return "warn"     # größer als /24 -> Schema-Segment sprengt
        if self.prefix <= 25:
            return "pass"     # /24 oder /25 = solide gefüllt
        return "info"         # deutlich kleiner als /24 = viel Reserve


class SubnetSizer:
    """Bestimmt das kleinste Subnetz, das eine Gerätezahl (plus Reserve) fasst.
    Klassisches VLSM/CIDR-Sizing, entkoppelt vom festen /24-Schema, damit der
    Nutzer eigene Adressräume nach realem Bedarf dimensionieren kann."""

    @staticmethod
    def size(devices: int, headroom: float = 0.3) -> SizeResult:
        d = max(0, int(devices))
        hr = max(0.0, float(headroom))
        required = math.ceil(d * (1 + hr)) if d > 0 else 1

        prefix = 0
        for p in range(30, -1, -1):            # /30 (2 Hosts) .. /0
            block = 1 << (32 - p)
            usable = block - 2
            if usable >= required:
                prefix = p
                break

        block_size = 1 << (32 - prefix)
        usable_hosts = block_size - 2
        mask = SubnetSizer._prefix_to_mask(prefix)
        util = math.ceil(required / usable_hosts * 100) if usable_hosts else 0
        return SizeResult(devices=d, headroom=hr, required=required, prefix=prefix,
                          block_size=block_size, usable_hosts=usable_hosts,
                          mask=mask, utilization=util)

    @staticmethod
    def _prefix_to_mask(prefix: int) -> str:
        bits = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF if prefix else 0
        return ".".join(str((bits >> (24 - 8 * i)) & 0xFF) for i in range(4))


# =============================================================================
# 3. REQUEST  -  Query-String -> PlanConfig
# =============================================================================


@dataclass
class PlanConfig:
    base: int = BASE_OCTET_DEFAULT
    profile: str = "standard"
    vlan_ids: list[int] = field(default_factory=list)
    site_octets: list[int] = field(default_factory=lambda: [0, 1])
    floating_count: int = 0
    mixed_use: bool = False
    mixed_site: int = 0       # Oktett des Mixed-Use-Standorts, IMMER aus site_octets
    hub: bool = False
    devices: dict[int, int] = field(default_factory=dict)   # VLAN-ID -> Gerätezahl
    size_headroom: float = HEADROOM_DEFAULT / 100.0
    submitted: bool = False
    warnings: list[str] = field(default_factory=list)


# Sanitization: kein Eingabewert wird laenger als das verarbeitet (DoS/Echo-Schutz);
# jede Ausgabe läuft zusätzlich durch esc()/html.escape.
MAX_INPUT_LEN = 128


def _get1(q: dict, key: str, default: str = "") -> str:
    v = q.get(key, [default])
    return (v[0] if v else default).strip()[:MAX_INPUT_LEN]


def _is_on(q: dict, key: str) -> bool:
    return _get1(q, key).lower() in ("1", "on", "true", "yes", "ja")


def parse_config(query: str) -> PlanConfig:
    """Reine Übersetzung Query -> Config. Validierung + Clamping inklusive,
    aber ohne Domaenenlogik (die lebt im Planner)."""
    q = parse_qs(query, keep_blank_values=True)
    cfg = PlanConfig()
    # "submitted" = hat der Nutzer den Plan selbst konfiguriert? Steuert nur, ob
    # der Beispiel-Hinweis erscheint - ein Plan wird immer gerechnet (der
    # Erstaufruf zeigt das Beispiel).
    cfg.submitted = any(k in q for k in
                        ("vlans", "profile", "sites", "floating", "mixeduse", "hub", "base")) \
        or any(k.startswith("dev_") for k in q)

    # Basis-Oktett: frei wählbar (Default 10 = RFC-1918-Standard des Schemas)
    base_raw = _get1(q, "base", str(BASE_OCTET_DEFAULT))
    cfg.base = int(base_raw) if base_raw.isdigit() and 1 <= int(base_raw) <= 223 else BASE_OCTET_DEFAULT
    if cfg.base != BASE_OCTET_DEFAULT:
        cfg.warnings.append(
            f"Basis-Oktett {cfg.base}: außerhalb von 10/8 ist RFC-1918-Konformität selbst "
            "sicherzustellen (172.16/12 und 192.168/16 legen weitere Oktette fest).")

    # Vorlage: befüllt die VLAN-Liste, wenn keine explizite kommt. Default
    # "standard" = der Beispielplan des Erstaufrufs.
    cfg.profile = _get1(q, "profile", "standard")
    if cfg.profile not in PROFILES:
        cfg.profile = "custom"

    # VLANs: entweder Zahl N (erste N kanonisch) oder explizite Liste "10,20,30".
    cfg.vlan_ids = _parse_vlans(_get1(q, "vlans"), cfg.profile, cfg.warnings)

    # Standorte: Anzahl N -> kanonische Oktette 0..N-1 (geklammert).
    cfg.site_octets = _parse_sites(_get1(q, "sites", "2"), cfg.warnings)

    # Floating-Externe (VLAN 50, 200+)
    fl_raw = _get1(q, "floating", "0")
    fl = int(fl_raw) if fl_raw.isdigit() else 0
    if fl > MAX_FLOATING:
        cfg.warnings.append(f"Floating auf {MAX_FLOATING} begrenzt (angefragt: {fl}).")
        fl = MAX_FLOATING
    cfg.floating_count = fl

    cfg.mixed_use = _is_on(q, "mixeduse")
    # Der Mixed-Use-Standort muss einer der geplanten sein - sonst zeigt die Karte
    # Netze, die in der Matrix nicht existieren. Default: der letzte Standort.
    ms_raw = _get1(q, "mixedsite")
    cfg.mixed_site = cfg.site_octets[-1]
    if ms_raw.isdigit():
        if int(ms_raw) in cfg.site_octets:
            cfg.mixed_site = int(ms_raw)
        elif cfg.mixed_use:
            cfg.warnings.append(
                f"Standort {ms_raw} ist nicht geplant - Mixed-Use liegt auf "
                f"{site_label(cfg.mixed_site)}. Erhöhe die Standort-Anzahl, um ihn zu wählen.")
    cfg.hub = _is_on(q, "hub")

    # Bedarf je VLAN: dev_<VLAN-ID>=<Anzahl>. Pro VLAN, weil eine globale Zahl
    # nicht sagen kann, *welches* Netz zu klein wird - und genau das ist die
    # einzige Aussage, aus der hier eine Entscheidung folgt.
    for key in q:
        if not key.startswith("dev_"):
            continue
        vid_raw = key[4:]
        if not vid_raw.isdigit() or int(vid_raw) not in VLAN_BY_ID:
            continue
        raw = _get1(q, key)
        if not raw.isdigit():
            continue
        n = int(raw)
        if n > 65534:
            cfg.warnings.append(f"Gerätezahl für VLAN {vid_raw} auf 65534 begrenzt.")
            n = 65534
        if n > 0:
            cfg.devices[int(vid_raw)] = n

    hr_raw = _get1(q, "headroom", str(HEADROOM_DEFAULT))
    hr_val = int(hr_raw) if hr_raw.isdigit() else HEADROOM_DEFAULT
    cfg.size_headroom = max(0, min(hr_val, 300)) / 100.0
    return cfg


def _parse_vlans(raw: str, profile: str, warnings: list[str]) -> list[int]:
    if not raw:
        return list(PROFILES.get(profile, PROFILES["custom"])["vlans"])
    # Einzelne Zahl ist mehrdeutig: "4" meint 4 VLANs, "10" meint VLAN 10.
    # Kollisionsfrei aufloesbar, weil alle Katalog-IDs >= 10 und jede Anzahl
    # <= Katalog-Größe (9) ist. Die ID gewinnt - wer "10" tippt, meint das VLAN.
    if raw.isdigit():
        n = int(raw)
        if n in VLAN_BY_ID:
            return [n]
        if n > len(VLAN_CATALOG):
            warnings.append(f"Nur {len(VLAN_CATALOG)} VLANs im Katalog (angefragt: {raw}) - "
                            "es werden alle gezeigt.")
        return [v.vlan_id for v in VLAN_CATALOG[:max(0, min(n, len(VLAN_CATALOG)))]]
    # Explizite Liste
    ids: list[int] = []
    for tok in raw.replace(";", ",").split(","):
        tok = tok.strip()
        if not tok:
            continue
        if tok.isdigit() and int(tok) in VLAN_BY_ID:
            if int(tok) not in ids:
                ids.append(int(tok))
        elif tok.isdigit():
            warnings.append(f"VLAN {tok} ist nicht im Katalog - ignoriert.")
    return ids or list(PROFILES[profile]["vlans"])


def _parse_sites(raw: str, warnings: list[str]) -> list[int]:
    if raw.isdigit():
        n = int(raw)
        if n < 1:
            n = 1
        if n > MAX_SITES:
            warnings.append(f"Standorte auf {MAX_SITES} begrenzt (angefragt: {n}).")
            n = MAX_SITES
        return list(range(0, n))
    # Explizite Oktett-Liste "0,1,2"
    octs: list[int] = []
    for tok in raw.replace(";", ",").split(","):
        tok = tok.strip()
        if tok.isdigit() and 0 <= int(tok) <= 255 and int(tok) not in octs:
            octs.append(int(tok))
    return octs or [0]


# =============================================================================
# 4. RENDER  -  Domänen-Objekte -> HTML (Toolbox-Design-System)
# =============================================================================

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}
.container{max-width:1100px;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:2rem}
h2{font-size:1.25rem;font-weight:600;margin-bottom:1rem}
h3{font-size:1rem;font-weight:600;margin:.25rem 0 .75rem;color:var(--text-secondary)}
.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)}
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{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}
input[type=text]::placeholder{color:var(--text-secondary)}
input:focus,select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(59,130,246,.2)}
.w-xs{width:84px}.w-sm{width:120px}.w-md{width:200px}.w-lg{width:260px}
.check-row{display:flex;gap:1.25rem;flex-wrap:wrap;align-items:center;width:100%;margin-top:.25rem}
.check-inline{display:flex;gap:.4rem;align-items:center;color:var(--text-secondary);font-size:.9rem}
input[type=checkbox]{width:1.1rem;height:1.1rem;accent-color:var(--accent)}
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}
.summary-card{display:flex;gap:1.5rem;align-items:center;flex-wrap:wrap}
.grade-badge{flex:0 0 auto;width:110px;height:110px;border-radius:16px;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#fff;background:var(--accent);box-shadow:0 8px 24px rgba(59,130,246,.35)}
.grade-badge .num{font-size:3rem;font-weight:700;line-height:1}
.grade-badge .lbl{font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;opacity:.85}
.summary-meta{flex:1;min-width:240px}
.score-line{font-size:1.05rem;color:var(--text-secondary)}.score-line strong{color:var(--text-primary)}
.count-line{display:flex;gap:.6rem;flex-wrap:wrap;margin-top:.6rem}
.count{font-size:.85rem;font-weight:600;padding:.2em .7em;border-radius:6px;background:rgba(148,163,184,.12);color:var(--text-secondary)}
.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;position:sticky;top:0}
tbody tr:last-child td{border-bottom:none}
td.rowhead{font-weight:600;background:rgba(148,163,184,.06)}
.pill{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;padding:.15em .55em;border-radius:999px;display:inline-block}
.pill-pass{background:rgba(16,185,129,.15);color:var(--success)}.pill-warn{background:rgba(245,158,11,.15);color:var(--warn)}.pill-fail{background:rgba(239,68,68,.15);color:var(--error)}.pill-info{background:rgba(59,130,246,.15);color:var(--accent)}.pill-muted{background:rgba(148,163,184,.15);color:var(--text-secondary)}
code{font-family:'JetBrains Mono','Fira Code',Consolas,monospace;font-size:.85em;background:var(--code-bg);color:#e879f9;padding:.15em .4em;border-radius:4px}
.dev-row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;width:100%;margin-top:.5rem}
.dev{display:flex;flex-direction:column;gap:.2rem}
.dev label{font-size:.7rem;color:var(--text-secondary);font-weight:700;font-family:'JetBrains Mono',monospace;text-align:center;cursor:help}
.dev input{padding:.4rem .5rem;font-size:.9rem;text-align:center}
.over-box{margin-top:1rem;padding:1rem;border:1px solid var(--error);border-radius:8px;background:rgba(239,68,68,.06)}
.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)}
.adv{width:100%;margin-top:.75rem;border-top:1px solid var(--border);padding-top:1rem}
.adv 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}
.adv summary:hover{background:rgba(59,130,246,.22)}
.adv summary::before{content:'\\25B8';display:inline-block;font-size:.9em;transition:transform .15s}
.adv[open] summary::before{transform:rotate(90deg)}
.adv summary::-webkit-details-marker{display:none}
.adv summary .sub{font-weight:400;color:var(--text-secondary);font-size:.85em}
.adv-body{display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end;margin-top:.9rem}
.example-card{border-left:4px solid var(--accent-secondary)}
.rules td.zone{white-space:normal;min-width:7.5rem;max-width:15rem}
.rules td.ports{white-space:normal;min-width:7rem;max-width:12rem}
.rules td.cmt{white-space:normal;min-width:14rem}
.pmatrix td.rowhead{font-size:.85rem}
.pmatrix .pc-corner{font-size:.75rem;color:var(--text-secondary);font-weight:600}
.pmatrix .pc-head{text-align:center;font-family:'JetBrains Mono',monospace}
td.pc{text-align:center;font-weight:700;font-size:1.05rem;cursor:help;border-left:1px solid var(--border)}
.pc-pass{background:rgba(16,185,129,.14);color:var(--success)}
.pc-warn{background:rgba(245,158,11,.14);color:var(--warn)}
.pc-fail{background:rgba(239,68,68,.10);color:var(--error)}
.pc-muted{background:rgba(148,163,184,.06);color:var(--text-secondary)}
.pc-lg{display:inline-block;width:1.6em;border-radius:4px;border:none;text-align:center}
.legend-row{display:flex;gap:1.1rem;flex-wrap:wrap;margin-top:.9rem;font-size:.85rem;color:var(--text-secondary)}
.lg{display:flex;gap:.4rem;align-items:center}
.note{color:var(--text-secondary);font-size:.88rem;margin-top:.6rem}
.legend-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1rem}
.hint-list{list-style:none;display:grid;gap:.5rem}
.hint-list li{padding-left:1.2rem;position:relative;color:var(--text-secondary);font-size:.9rem}
.hint-list li::before{content:'\\2192';position:absolute;left:0;color:var(--accent)}
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.5rem}.card{padding:1rem}.grade-badge{width:88px;height:88px}.grade-badge .num{font-size:2.25rem}}
"""


def esc(s) -> str:
    return html.escape(str(s), quote=True)


def _page(body: str) -> str:
    return f"""<!DOCTYPE html>
<!-- jozapf.de toolbox -->
<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 - Net+VLAN Planner</title>
<link rel="preconnect" href="https://assets.jozapf.de" crossorigin>
<link rel="stylesheet" href="https://assets.jozapf.de/css/fonts.css">
<style>{CSS}</style>
</head>
<body>
<div class="container">
<h1>toolbox.jozapf.de | Net+VLAN Planner v{VERSION}</h1>
<p class="subtitle">Strukturiert ein Netz nach dem Schema <code>&lt;Basis&gt;.&lt;VLAN&gt;.&lt;Standort&gt;.&lt;Host&gt;</code> (Standard-Basis 10) &middot; Allokations-Matrix, Policy-Matrix &amp; Firewall-Regelliste &middot; Mixed-Use &amp; VPN-Zugänge optional</p>
{body}
<footer>Schema: hierarchischer IP-/VLAN-Adressplan (Rolle im 2., Standort im 3. Oktett) &middot; Kataloge = anonymisierte Beispieldaten &middot; <a href="/">zur Toolbox</a></footer>
</div>
</body>
</html>"""


def _plan_state(cfg: PlanConfig) -> dict:
    """Der aktuelle Plan als Query-Paare.

    Die Vorlage-Links sind reine GET-Links und tragen daher nur, was hier drin
    steht. Ohne diesen Zustand wuerde ein Klick auf eine Vorlage alles andere
    (Standorte, Reserve, Optionen) stillschweigend auf die Defaults zuruecksetzen.
    Nebenbei ist die so erzeugte URL der vollstaendige Permalink des Plans.
    """
    st: dict = {
        "vlans": ",".join(str(v) for v in cfg.vlan_ids),
        "sites": len(cfg.site_octets),
        "base": cfg.base,
        "headroom": int(round(cfg.size_headroom * 100)),
    }
    for vid, n in cfg.devices.items():
        st[f"dev_{vid}"] = n
    if cfg.floating_count:
        st["floating"] = cfg.floating_count
    if cfg.mixed_use:
        st["mixeduse"] = 1
        st["mixedsite"] = cfg.mixed_site
    if cfg.hub:
        st["hub"] = 1
    return st


def _preset_links(cfg: PlanConfig) -> str:
    """Vorlagen befüllen nur das VLAN-Feld - sie sind kein eigener Zustand.
    Alles andere (Standorte, Reserve, Optionen) bleibt stehen."""
    out = []
    for key in PRESET_ORDER:
        p = PROFILES[key]
        st = _plan_state(cfg)
        st.pop("vlans")               # genau das setzt die Vorlage
        st["profile"] = key
        on = " preset-on" if cfg.vlan_ids == p["vlans"] else ""
        out.append(f'<a class="preset{on}" href="{esc("?" + urlencode(st))}">{esc(p["label"])}</a>')
    return "".join(out)


def _device_fields(cfg: PlanConfig) -> str:
    """Ein kleines Zahlenfeld je gewaehltem VLAN - der Bedarf gehoert an das
    VLAN, nicht an den Plan als Ganzes."""
    if not cfg.vlan_ids:
        return ""
    boxes = []
    for vid in cfg.vlan_ids:
        v = VLAN_BY_ID[vid]
        val = cfg.devices.get(vid, "")
        boxes.append(
            f'<span class="dev"><label for="dev_{vid}" title="{esc(v.name)}">{vid}</label>'
            f'<input type="number" id="dev_{vid}" name="dev_{vid}" class="w-xs" min="0" '
            f'max="65534" value="{esc(val)}" placeholder="&ndash;"></span>')
    return (f'<div class="dev-row"><span class="preset-lbl">Geräte je VLAN (optional):</span>'
            f'{"".join(boxes)}</div>')


def render_form(cfg: PlanConfig) -> str:
    vlans_val = ",".join(str(v) for v in cfg.vlan_ids)
    hr_now = int(round(cfg.size_headroom * 100))
    hr_opts = "".join(
        f'<option value="{pct}"{" selected" if pct == hr_now else ""}>{esc(lbl)}</option>'
        for pct, lbl in HEADROOM_CHOICES)
    # Nur geplante Standorte anbieten - alles andere waere ein Netz, das es nicht gibt.
    mixed_opts = "".join(
        f'<option value="{o}"{" selected" if o == cfg.mixed_site else ""}>{esc(site_label(o))}</option>'
        for o in cfg.site_octets)
    return f"""<div class="card">
<h2>Netz planen</h2>
<form method="get" action="">
  <div class="field"><label for="vlans">VLANs (Liste oder Anzahl)</label>
    <input type="text" id="vlans" name="vlans" class="w-lg" value="{esc(vlans_val)}" placeholder="z.B. 10,20,40,90 oder 4"></div>
  <div class="field"><label for="sites">Standorte (Anzahl)</label>
    <input type="number" id="sites" name="sites" class="w-sm" min="1" max="{MAX_SITES}" value="{len(cfg.site_octets)}"></div>
  <button type="submit">Plan erstellen</button>
  <div class="preset-row"><span class="preset-lbl">Vorlage laden:</span>{_preset_links(cfg)}</div>
  {_device_fields(cfg)}
  <details class="adv"{" open" if cfg.base != BASE_OCTET_DEFAULT or cfg.floating_count or cfg.mixed_use or cfg.hub else ""}>
    <summary>Erweiterte Optionen <span class="sub">Basis-Oktett, Reserve, VPN-Zugänge, Mixed-Use</span></summary>
    <div class="adv-body">
      <div class="field"><label for="base">Basis-Oktett (1. Stelle)</label>
        <input type="number" id="base" name="base" class="w-sm" min="1" max="223" value="{cfg.base}"></div>
      <div class="field"><label for="headroom">Wachstums-Reserve</label>
        <select id="headroom" name="headroom" class="w-lg">{hr_opts}</select></div>
      <div class="field"><label for="floating">Externe VPN-Zugänge (ohne Standort)</label>
        <input type="number" id="floating" name="floating" class="w-sm" min="0" max="{MAX_FLOATING}" value="{cfg.floating_count}"></div>
      <div class="field"><label for="mixedsite">Mixed-Use an Standort</label>
        <select id="mixedsite" name="mixedsite" class="w-lg">{mixed_opts}</select></div>
      <div class="check-row">
        <span class="check-inline"><input type="checkbox" id="mixeduse" name="mixeduse" value="1"{" checked" if cfg.mixed_use else ""}><label for="mixeduse">Mixed-Use: privates Netz am selben Standort</label></span>
        <span class="check-inline"><input type="checkbox" id="hub" name="hub" value="1"{" checked" if cfg.hub else ""}><label for="hub">Eigener WireGuard-Hub (Transfer-Netz)</label></span>
      </div>
      <p class="form-hint">Basis-Oktett <code>10</code> = RFC-1918-Standard. Die Reserve fließt in die Prüfung
      &laquo;reicht ein /24?&raquo; ein. Externe VPN-Zugänge sind Dienstleister, die zu keinem Standort gehören
      (VLAN {FLOATING_VLAN}, 3. Oktett {FLOATING_RANGE.start}-{FLOATING_RANGE.stop - 1}).</p>
    </div>
  </details>
  <p class="form-hint">Die VLAN-Liste ist die einzige Wahrheit - eine Vorlage befüllt sie nur.
  Standorte = Anzahl der Standort-Oktette ab 0 (0 = Zentrale). <strong>Nichts wird gespeichert</strong>,
  der Plan steckt vollständig in der URL.</p>
</form>
</div>"""


def render_warnings(cfg: PlanConfig) -> str:
    if not cfg.warnings:
        return ""
    items = "".join(f"<li>{esc(w)}</li>" for w in cfg.warnings)
    return f'<div class="card error-card"><h2>Hinweise zur Eingabe</h2><ul class="hint-list">{items}</ul></div>'


def render_summary(plan: NetworkPlan) -> str:
    # Die Kachel zeigt die Zahl, das Label sagt wovon - und die Pills schluesseln
    # den Rest auf. Frueher stand "/24-Netze" an einer Zahl, die Privatzone und
    # Hub-Netz nicht enthielt, obwohl auch das /24 sind: ein Label, das mehr
    # behauptete als es zaehlte. Jede Zahl steht jetzt genau einmal.
    pills = [
        f'<span class="count">{len(plan.vlans)} VLANs</span>',
        f'<span class="count">{len(plan.sites)} Standorte</span>',
    ]
    if plan.floating:
        pills.append(f'<span class="count">+ {len(plan.floating)} VPN-Zugänge</span>')
    if plan.mixed_use_site:
        pills.append(f'<span class="count">+ {len(PRIVATE_ZONE)} privat (192.168.x)</span>')
    if plan.hub_enabled:
        pills.append('<span class="count">+ 1 Hub-Transfer</span>')
    vlan_names = ", ".join(str(v.vlan_id) for v in plan.vlans) or "-"
    return f"""<div class="card summary-card">
<div class="grade-badge"><span class="num">{len(plan.subnets)}</span><span class="lbl">/24 im Schema</span></div>
<div class="summary-meta">
<div class="score-line">Basis <strong>{plan.base}.0.0.0/8</strong> &middot; VLANs <strong>{esc(vlan_names)}</strong></div>
<div class="count-line">{''.join(pills)}</div>
</div>
</div>"""


def _load_badge(sz: "SizeResult") -> str:
    """Auslastung des /24 als Badge - bezogen auf die 254 Hosts, die das Schema
    ohnehin vergibt. Erst wenn die gesprengt werden, folgt eine Entscheidung."""
    if not sz.fits_schema:
        tone, text = "fail", f"{sz.required} &gt; {SCHEMA_HOSTS} &middot; sprengt /{SCHEMA_PREFIX}"
    elif sz.utilization_24 > 80:
        tone, text = "warn", f"{sz.required}/{SCHEMA_HOSTS} &middot; {sz.utilization_24} % &middot; wird eng"
    else:
        tone, text = "pass", f"{sz.required}/{SCHEMA_HOSTS} &middot; {sz.utilization_24} %"
    return f'<span class="pill pill-{tone}">{text}</span>'


def render_matrix(plan: NetworkPlan) -> str:
    if not plan.subnets:
        return ""
    head = "<th>VLAN</th>" + "".join(f"<th>Std. {s.octet}</th>" for s in plan.sites)
    rows = []
    for vlan in plan.vlans:
        load = plan.load_of(vlan.vlan_id)
        badge = f"<br>{_load_badge(load)}" if load else ""
        cells = [f'<td class="rowhead">{vlan.vlan_id}<br>'
                 f'<span class="pill pill-{vlan.trust.tone}">Trust {esc(vlan.trust.label)}</span>'
                 f'{badge}</td>']
        for site in plan.sites:
            sub = plan.subnet_at(vlan.vlan_id, site.octet)
            cells.append(f'<td><code>{esc(sub.cidr)}</code><br>'
                         f'<span style="color:var(--text-secondary);font-size:.8rem">GW {esc(sub.gateway)}</span></td>')
        rows.append(f"<tr>{''.join(cells)}</tr>")

    if plan.loads:
        hr = int(round(next(iter(plan.loads.values())).headroom * 100))
        note = (f'<p class="note">Auslastung = Geräte + {hr} % Wachstums-Reserve gegen die 254 Hosts '
                f'eines <code>/24</code> (CIDR/VLSM, RFC 4632). Das Schema vergibt bewusst immer ein '
                f'<code>/24</code> — die Zahl baut den Plan nicht um, sie belegt, ob er trägt.</p>')
    else:
        note = ('<p class="note">Trage oben <strong>Geräte je VLAN</strong> ein, um zu sehen, '
                'wie voll die Netze werden und ob ein <code>/24</code> trägt.</p>')

    overflow = ""
    if plan.overflows:
        items = "".join(
            f'<li><strong>VLAN {v.vlan_id} {esc(v.name)}</strong>: Bedarf {sz.required} Hosts, ein '
            f'<code>/24</code> bietet 254. Nötig wäre <code>/{sz.prefix}</code> '
            f'({sz.usable_hosts} Hosts, Maske <code>{sz.mask}</code>).</li>'
            for v, sz in plan.overflows)
        overflow = f"""<div class="over-box">
<h3 style="color:var(--error)">Diese VLANs passen nicht ins Schema-<code>/24</code></h3>
<ul class="hint-list">{items}</ul>
<p class="note">Zwei Auswege: das VLAN <strong>auf mehrere Standorte aufteilen</strong> (jeder Standort
bekommt ein eigenes <code>/24</code>) — oder ihm <strong>einen größeren Block außerhalb der
Systematik</strong> geben und den Bruch dokumentieren.</p>
</div>"""

    return f"""<div class="card">
<h2>Allokations-Matrix</h2>
<h3>Pattern <code>{plan.base}.&lt;VLAN&gt;.&lt;Standort&gt;.0/24</code> &middot; Gateway je Netz auf <code>.1</code></h3>
<div class="table-wrap"><table><thead><tr>{head}</tr></thead><tbody>{''.join(rows)}</tbody></table></div>
{note}
{overflow}
</div>"""


def render_floating(plan: NetworkPlan) -> str:
    if not plan.floating:
        return ""
    rows = "".join(
        f"<tr><td>Floating-Externer {f.index + 1}</td><td><code>{esc(f.cidr)}</code></td>"
        f"<td>Oktett {f.octet3}</td><td>deny-by-default + Whitelist</td></tr>"
        for f in plan.floating)
    return f"""<div class="card">
<h2>Floating-Externe (VLAN {FLOATING_VLAN}, Oktett 200-249)</h2>
<h3>Externe Dienstleister ohne feste Standortzuordnung</h3>
<div class="table-wrap"><table><thead><tr><th>Peer</th><th>Subnetz</th><th>3. Oktett</th><th>Policy</th></tr></thead><tbody>{rows}</tbody></table></div>
</div>"""


def render_private_zone(plan: NetworkPlan) -> str:
    if not plan.mixed_use_site:
        return ""
    s = plan.mixed_use_site
    # Die betriebliche Zone ist exakt das, was der Plan an diesem Standort ohnehin
    # vergibt - deshalb aus plan.subnet_at() geholt und nicht selbst zusammengebaut.
    # Frueher stand hier eine feste Auswahl (10/30/90) plus handgebaute CIDR: die
    # Karte konnte dadurch Netze zeigen, die in der Matrix gar nicht vorkamen.
    biz_rows = "".join(
        f"<tr><td>{v.vlan_id} {esc(v.name)}</td><td><code>{esc(sub.cidr)}</code></td>"
        f"<td><span class='pill pill-{v.trust.tone}'>Trust {esc(v.trust.label)}</span></td>"
        f"<td>betrieblich</td></tr>"
        for v in plan.vlans
        for sub in [plan.subnet_at(v.vlan_id, s.octet)] if sub)
    priv_rows = "".join(
        f"<tr><td>{esc(p.name)}</td><td>{p.vlan_id}</td><td><code>{esc(p.cidr)}</code></td><td>{esc(p.policy)}</td></tr>"
        for p in PRIVATE_ZONE)
    return f"""<div class="card">
<h2>Mixed-Use-Standort: {esc(site_label(s.octet))}</h2>
<h3>Betriebliche Zone (Corporate-Schema) &middot; identisch mit Spalte &laquo;Std. {s.octet}&raquo; der Allokations-Matrix</h3>
<div class="table-wrap"><table><thead><tr><th>VLAN</th><th>Subnetz</th><th>Trust</th><th>Zone</th></tr></thead><tbody>{biz_rows}</tbody></table></div>
<h3 style="margin-top:1rem">Private Domäne (außerhalb Corporate-Schema)</h3>
<div class="table-wrap"><table><thead><tr><th>Netz</th><th>VLAN-ID</th><th>Subnetz</th><th>Policy</th></tr></thead><tbody>{priv_rows}</tbody></table></div>
<p class="note"><strong>Eiserne Regel:</strong> privat &harr; betrieblich = <strong>deny-all beidseitig</strong>. Die <code>192.168.x</code>-Netze werden nie ins Site-to-Site-VPN announced und teilen nur Gateway + Internet-Uplink.</p>
</div>"""


def render_hub(plan: NetworkPlan) -> str:
    if not plan.hub_enabled:
        return ""
    peers = [("WG-Server-Interface (Hub)", f"{plan.base}.99.0.1/24", "Hub-Adresse, statisches Routing-Ziel")]
    for i, site in enumerate(plan.sites):
        peers.append((f"WG-Peer {site.name.split(' (')[0]}", f"{plan.base}.99.0.{10 + i}/32", "Gateway als WG-Client"))
    rows = "".join(f"<tr><td>{esc(n)}</td><td><code>{esc(ip)}</code></td><td>{esc(fn)}</td></tr>"
                   for n, ip, fn in peers)
    return f"""<div class="card">
<h2>Hub-Transfer-Netz (optional: eigener WireGuard-Hub)</h2>
<h3>Sondernetz <code>{plan.base}.99.0.0/24</code> &middot; außerhalb der VLAN-Systematik</h3>
<div class="table-wrap"><table><thead><tr><th>Komponente</th><th>IP</th><th>Funktion</th></tr></thead><tbody>{rows}</tbody></table></div>
<p class="note">Nur bei eigenem WireGuard-Hub relevant. Läuft das Site-to-Site-VPN über den Controller, entfällt dieses Netz.</p>
</div>"""


def render_policy(matrix: PolicyMatrix) -> str:
    """Die Matrix beantwortet 'wer darf wohin' - gerichtet, auf einen Blick."""
    if len(matrix.vlans) < 2:
        return ""
    head = '<th class="pc-corner">von \\ nach &rarr;</th>' + "".join(
        f'<th class="pc-head" title="{esc(v.name)}">{v.vlan_id}</th>' for v in matrix.vlans)
    rows = []
    for src in matrix.vlans:
        cells = [f'<td class="rowhead">{src.vlan_id} {esc(src.name)}</td>']
        for dst in matrix.vlans:
            c = matrix.cell(src.vlan_id, dst.vlan_id)
            tip = f"VLAN {src.vlan_id} -> VLAN {dst.vlan_id}: {c.verdict.label}. {c.reason}"
            if c.ports:
                tip += f" (Ports: {c.ports})"
            cells.append(f'<td class="pc pc-{c.verdict.tone}" title="{esc(tip)}">{c.verdict.glyph}</td>')
        rows.append(f"<tr>{''.join(cells)}</tr>")

    legend = "".join(
        f'<span class="lg"><span class="pc pc-{v.tone} pc-lg">{v.glyph}</span> {esc(v.label)}</span>'
        for v in (PolicyVerdict.ALLOW, PolicyVerdict.SERVICES, PolicyVerdict.DENY,
                  PolicyVerdict.SELF, PolicyVerdict.UNDEFINED))
    undef = ('<p class="note"><strong>Offene Zellen:</strong> Reserve-/Vorlage-VLANs haben noch keine '
             'Trust-Stufe. Solange das so ist, gibt es für sie keine ableitbare Policy - '
             'vor Inbetriebnahme festlegen.</p>') if matrix.has_undefined else ""
    return f"""<div class="card">
<h2>Policy-Matrix</h2>
<h3>Zeile = <strong>von</strong> (wer baut auf) &middot; Spalte = <strong>nach</strong> (wohin) &middot;
Zelle antwortet: darf diese Verbindung aufgebaut werden?</h3>
<div class="table-wrap"><table class="pmatrix"><thead><tr>{head}</tr></thead><tbody>{''.join(rows)}</tbody></table></div>
<div class="legend-row">{legend}</div>
<p class="note">Zellen sind bewusst <strong>nicht</strong> spiegelbildlich: dass
<code>10 &rarr; 40</code> erlaubt und <code>40 &rarr; 10</code> verboten ist, ist der ganze Punkt.
Mauszeiger auf eine Zelle zeigt die Begründung. Die Rückrichtung einer erlaubten
Verbindung läuft über <em>established/related</em> und braucht keine eigene Zelle.</p>
{undef}
</div>"""


def _zones(s: str) -> str:
    """Zonen-Liste fuer eine Tabellenzelle: escapen, dann 'VLAN 20' fest
    zusammenbinden. Die Zelle darf zwischen den Eintraegen umbrechen (sonst
    scrollt die Regelliste horizontal), aber nie mitten in einer Bezeichnung."""
    return esc(s).replace("VLAN ", "VLAN&nbsp;")


def render_rules(matrix: PolicyMatrix) -> str:
    """Dieselbe Policy als Regelliste - zum Abtippen in die Firewall."""
    if len(matrix.vlans) < 2:
        return ""
    rows = "".join(
        f'<tr><td><code>{r.order}</code></td>'
        f'<td><span class="pill pill-{r.tone}">{esc(r.action)}</span></td>'
        f'<td class="zone">{_zones(r.src)}</td><td class="zone">{_zones(r.dst)}</td>'
        f'<td class="ports"><code>{esc(r.ports)}</code></td>'
        f'<td class="cmt">{esc(r.comment)}</td></tr>'
        for r in matrix.rules())
    return f"""<div class="card">
<h2>Regelliste</h2>
<h3>Dieselbe Aussage wie die Matrix, in Abarbeitungsreihenfolge &middot; erste Treffer-Regel gewinnt</h3>
<div class="table-wrap"><table class="rules"><thead><tr>
<th>#</th><th>Aktion</th><th>Von</th><th>Nach</th><th>Ports</th><th>Kommentar</th>
</tr></thead><tbody>{rows}</tbody></table></div>
<p class="note">Reihenfolge ist Teil der Aussage: <em>established/related</em> zuerst, <code>deny any any</code>
zuletzt. Die Portangaben der Dienst-Ausnahmen sind Beispiele - im Ziel-VLAN gehören sie auf die
konkreten Server-Hosts eingeschränkt, sonst ist die Ausnahme so breit wie ein offenes VLAN.</p>
</div>"""



def render_reference() -> str:
    vlan_rows = "".join(
        f"<tr><td><code>{v.vlan_id}</code></td><td>{esc(v.name)}</td>"
        f"<td><span class='pill pill-{v.trust.tone}'>{esc(v.trust.label)}</span></td>"
        f"<td style='white-space:normal'>{esc(v.purpose)}</td></tr>" for v in VLAN_CATALOG)
    site_rows = "".join(
        f"<tr><td><code>{esc(rng)}</code></td><td style='white-space:normal'>{esc(zweck)}</td></tr>"
        for rng, zweck in (
            ("0", "Zentrale / HQ (Konvention)"),
            ("1-199", "weitere Standorte, fortlaufend frei belegbar"),
            ("200-249", f"Floating-Externe (VLAN {FLOATING_VLAN}, ohne festen Standort)"),
            ("250", "Cloud-VPS / WireGuard-Hub"),
            ("251-254", "Reserve"),
        ))
    host_rows = "".join(
        f"<tr><td><code>{esc(h.label)}</code></td><td style='white-space:normal'>{esc(h.purpose)}</td></tr>"
        for h in HOST_CONVENTION)
    return f"""<div class="card">
<h2>Schema-Referenz</h2>
<div class="legend-grid">
<div><h3>VLAN-Katalog (2. Oktett)</h3><div class="table-wrap"><table><thead><tr><th>ID</th><th>Name</th><th>Trust</th><th>Zweck</th></tr></thead><tbody>{vlan_rows}</tbody></table></div></div>
</div>
<div class="legend-grid" style="margin-top:1rem">
<div><h3>Standort-Oktette (3. Oktett)</h3><div class="table-wrap"><table><thead><tr><th>Oktett</th><th>Belegung</th></tr></thead><tbody>{site_rows}</tbody></table></div></div>
<div><h3>Host-Konvention (4. Oktett)</h3><div class="table-wrap"><table><thead><tr><th>Bereich</th><th>Zweck</th></tr></thead><tbody>{host_rows}</tbody></table></div></div>
</div>
<h3 style="margin-top:1.25rem">Best-Practice-Hinweise</h3>
<ul class="hint-list">
<li><strong>Hierarchie:</strong> kleinere VLAN-ID = höhere Trust-Stufe - ein Netz sieht in alle darunterliegenden (größere IDs) hinein, nie umgekehrt.</li>
<li><strong>VLAN 99</strong> ist Native/Trunk-Markierung auf Uplinks - kein Endgeräte-Netz.</li>
<li><strong>Benennung nach Funktion, nie nach Abteilung:</strong> Abteilungen werden umorganisiert, das Netz nicht. Darum <em>Clients - privilegiert</em> statt <em>Leitung</em> - der Name nennt die Sicherheitseigenschaft, aus der auch die Policy folgt.</li>
<li><strong>Clients - privilegiert (VLAN 30)</strong> steht unter Server &amp; Anwendungen, aber über den Standard-Clients: Einblick nach unten, <strong>kein</strong> Zugriff auf Infrastruktur/Server.</li>
<li><strong>Untrusted Zonen (50 Externe, 60 Gäste, 70 IoT, 80 DMZ)</strong> stehen <em>nebeneinander</em>, nicht untereinander: sie bauen nie von sich aus etwas auf - auch nicht zueinander. Der einzige Weg heraus ist eine ausdrückliche Dienst-Ausnahme.</li>
<li><strong>Gäste</strong> erreichen ausschließlich das Internet, <strong>die DMZ</strong> nie von sich aus das Innere, <strong>IoT</strong> nur den eigenen Controller. Nicht standortgebundene Externe in den Floating-Bereich 200-249.</li>
<li><strong>Mixed-Use</strong> (optional): privat <code>192.168.x</code> &harr; betrieblich = deny-all; nur Internet-Uplink geteilt.</li>
<li>Jedes <code>/24</code> je VLAN+Standort bietet 254 Hosts - Gateway <code>.1</code>, DHCP <code>.100-.199</code>.</li>
</ul>
<h3 style="margin-top:1.25rem">Angewandte Standards / Vorlagen</h3>
<ul class="hint-list">
<li><strong>RFC 1918</strong> - private Adressräume: <code>10/8</code> betrieblich, <code>192.168/16</code> für die private Mixed-Use-Domäne.</li>
<li><strong>RFC 4632 (CIDR) + VLSM</strong> - bedarfsgerechte Subnetz-Dimensionierung (Sizing nach Gerätezahl).</li>
<li><strong>IEEE 802.1Q</strong> - VLAN-Tagging als L2-Segmentierung (VLAN 99 = Native/Trunk).</li>
<li><strong>Strukturierter/hierarchischer Adressplan</strong> - Bedeutung im Oktett codiert (Rolle im 2., Standort im 3.), wie in Enterprise-/Campus-Designs empfohlen.</li>
<li><strong>Rollenbasierte Trust-Zonen / Zero-Trust-Segmentierung</strong> - deny-by-default + Whitelist, harte privat/betrieblich-Trennung.</li>
</ul>
</div>"""


def render_example_hint() -> str:
    """Der Erstaufruf zeigt einen fertigen Plan statt eines leeren Formulars -
    wer die Kachel anklickt, kennt das Schema nicht und versteht es am Beispiel
    schneller als an einer Erklärung."""
    return ('<div class="card example-card"><h2>Beispielplan</h2>'
            '<p class="score-line">Du siehst die Vorlage <strong>Standard</strong> auf zwei Standorten. '
            'Ändere oben VLANs und Standorte - der Plan rechnet sich neu, und die URL '
            'enthaelt danach genau diesen Stand.</p></div>')


def render(cfg: PlanConfig) -> str:
    """Komponiert die Seite aus Config -> Domänen-Objekten -> HTML-Bloecken."""
    parts: list[str] = [render_form(cfg), render_warnings(cfg)]

    if not cfg.submitted:
        parts.append(render_example_hint())

    if cfg.vlan_ids and cfg.site_octets:
        plan = Planner.build(cfg)
        matrix = PolicyEngine.build(plan.vlans)
        parts += [render_summary(plan), render_matrix(plan),
                  render_policy(matrix), render_rules(matrix)]
        parts += [render_floating(plan), render_private_zone(plan), render_hub(plan)]

    parts.append(render_reference())
    return _page("\n".join(p for p in parts if p))


# =============================================================================
# 5. ENTRY  -  CGI (stdout), Dev-Server (--serve), Self-Test (--self-test)
# =============================================================================


def handle_cgi() -> None:
    query = os.environ.get("QUERY_STRING", "")
    body = render(parse_config(query))
    sys.stdout.write("Content-Type: text/html; charset=utf-8\r\n\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
            query = urlsplit(self.path).query
            body = render(parse_config(query)).encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def log_message(self, *args):  # ruhig halten
            pass

    print(f"Net+VLAN Planner v{VERSION} -> http://localhost:{port}  (Strg+C beendet)")
    HTTPServer(("127.0.0.1", port), Handler).serve_forever()


def self_test() -> int:
    """Kompakte Zusicherungen für Planner + Resolver (kein Framework nötig)."""
    failures: list[str] = []

    def check(cond: bool, msg: str):
        if not cond:
            failures.append(msg)

    # Planner: 4 VLANs x 3 Standorte = 12 Subnetze, CIDR/Gateway korrekt.
    cfg = parse_config(urlencode({"vlans": "10,20,30,90", "sites": "3"}))
    plan = Planner.build(cfg)
    check(len(plan.subnets) == 12, f"Erwartet 12 Subnetze, bekam {len(plan.subnets)}")
    sub = plan.subnet_at(30, 2)
    check(sub is not None and sub.cidr == "10.30.2.0/24", "Subnetz 10.30.2.0/24 falsch")
    check(sub is not None and sub.gateway == "10.30.2.1", "Gateway .1 falsch")

    # Profil-Default: Vollausbau = 9 VLANs.
    check(len(Planner.build(parse_config("profile=voll")).vlans) == 9, "Vollausbau != 9 VLANs")

    # Einzelne Zahl: Katalog-ID schlaegt Anzahl ("10" = VLAN 10, nicht 10 VLANs).
    check(parse_config("vlans=10").vlan_ids == [10], "vlans=10 muss VLAN 10 sein, nicht 10 Stueck")
    check(parse_config("vlans=4").vlan_ids == [10, 20, 30, 40], "vlans=4 != erste 4 aus dem Katalog")
    check(PROFILES["standard"]["vlans"] == [10, 20, 40, 60, 90], "Standard-Vorlage ohne Gaestenetz")

    # Flexible Basis: 172.<VLAN>.<Standort>.<Host> inkl. Warnung.
    cfg_b = parse_config("base=172&vlans=30,90&sites=1")
    sub_b = Planner.build(cfg_b).subnet_at(30, 0)
    check(sub_b is not None and sub_b.cidr == "172.30.0.0/24", "Flexible Basis falsch")
    check(bool(cfg_b.warnings), "Basis != 10 sollte einen Hinweis erzeugen")

    # Floating: VLAN der Externen (50), Oktett 200+.
    fl = Planner.build(parse_config("floating=2")).floating
    check(len(fl) == 2 and fl[0].cidr == "10.50.200.0/24", "Floating-Basis falsch")

    # Katalog: benannt nach Funktion/Trust-Zone, nie nach Abteilung.
    check(VLAN_BY_ID[30].name == "Clients - privilegiert", "VLAN 30 falsch benannt")
    check(VLAN_BY_ID[40].name == "Clients - Standard", "VLAN 40 falsch benannt")
    check(all(v.trust is not TrustLevel.TBD for v in VLAN_CATALOG), "Katalog hat noch TBD-Zonen")
    check({v.vlan_id for v in VLAN_CATALOG if v.trust is TrustLevel.LOW} == {50, 60, 70, 80},
          "Untrusted-Zonen falsch besetzt")

    # Policy: Verwaltungsrichtung folgt dem Trust-Gefälle - und nur nach unten.
    pm = PolicyEngine.build([VLAN_BY_ID[i] for i in (10, 20, 40, 50, 90)])
    check(pm.cell(10, 40).verdict is PolicyVerdict.ALLOW, "10->40 muss erlaubt sein")
    check(pm.cell(40, 10).verdict is PolicyVerdict.DENY, "40->10 muss blockiert sein")
    check(pm.cell(10, 10).verdict is PolicyVerdict.SELF, "Diagonale muss intra-VLAN sein")
    # Asymmetrie ist die Kernaussage der Matrix.
    check(pm.cell(20, 40).verdict is not pm.cell(40, 20).verdict, "Policy fälschlich symmetrisch")
    # Dienst-Ausnahme: Clients erreichen den Server portbeschränkt entgegen dem Gefälle.
    check(pm.cell(40, 20).verdict is PolicyVerdict.SERVICES, "40->20 muss Dienst-Ausnahme sein")
    check(bool(pm.cell(40, 20).ports), "Dienst-Ausnahme ohne Portangabe")
    # Niedrige Trust-Stufe darf trotz kleinerer ID nichts verwalten.
    check(pm.cell(50, 90).verdict is PolicyVerdict.DENY, "50->90 (Externe) darf nicht erlaubt sein")
    check("untrusted" in pm.cell(50, 90).reason, "Begruendung nennt den Untrusted-Riegel nicht")
    # Untrusted Zonen stehen NEBENeinander, nicht untereinander: gleiche
    # Trust-Stufe ist kein Gefälle. Genau hier war die Regel vorher kaputt.
    pm_low = PolicyEngine.build([VLAN_BY_ID[i] for i in (20, 40, 50, 60, 70, 80, 90)])
    for a, b in ((60, 70), (60, 80), (50, 60), (70, 80), (50, 70), (60, 90)):
        check(pm_low.cell(a, b).verdict is PolicyVerdict.DENY,
              f"{a}->{b}: untrusted Zone darf nicht von sich aus zugreifen")
    # ... und kommen auch nicht ueber eine zu breite Dienst-Ausnahme an den Server.
    check(pm_low.cell(60, 20).verdict is PolicyVerdict.DENY, "Gäste duerfen nicht an den Server")
    check(pm_low.cell(80, 20).verdict is PolicyVerdict.DENY, "DMZ darf nicht nach innen")
    # Die vorgesehenen Ausnahmen bleiben aber erhalten.
    check(pm_low.cell(50, 20).verdict is PolicyVerdict.SERVICES, "Externe brauchen Server-Dienste")
    check(pm_low.cell(70, 20).verdict is PolicyVerdict.SERVICES, "IoT braucht seinen Controller")
    check(pm_low.cell(40, 70).verdict is PolicyVerdict.ALLOW, "Clients muessen an Drucker/IoT")
    # Keine Dienst-Ausnahme darf sich auf "alle Quellen" stellen.
    check(all(sr.sources is not None for sr in SERVICE_RULES),
          "SERVICE_RULES mit sources=None wuerde Gäste und DMZ einschliessen")

    # TrustLevel.TBD bleibt fuer neu ergaenzte VLANs: keine erfundene Policy.
    neu = VlanDef(95, "Neues Netz", "noch nicht eingestuft", TrustLevel.TBD)
    pm_tbd = PolicyEngine.build([VLAN_BY_ID[10], neu])
    check(pm_tbd.cell(10, 95).verdict is PolicyVerdict.UNDEFINED, "TBD-VLAN muss offen bleiben")
    check(pm_tbd.has_undefined, "has_undefined nicht gesetzt")
    # Regelliste: stateful zuerst, deny-by-default zuletzt.
    rules = pm.rules()
    check(rules[0].ports.startswith("established"), "Erste Regel != established/related")
    check(rules[-1].action == "DENY" and rules[-1].src == "any" and rules[-1].dst == "any",
          "Letzte Regel != deny any any")

    # Sizer: Gerätezahl -> Prefix (VLSM/CIDR).
    sz = SubnetSizer.size(254, headroom=0.0)
    check(sz.prefix == 24 and sz.usable_hosts == 254, f"Sizing 254 Geräte -> /{sz.prefix} ({sz.usable_hosts})")
    check(SubnetSizer.size(2, headroom=0.0).prefix == 30, "Sizing 2 Geräte != /30")
    check(SubnetSizer.size(300, headroom=0.0).prefix == 23, "Sizing 300 Geräte (0% Reserve) != /23")
    check(SubnetSizer.size(200, headroom=0.3).prefix == 23, "Sizing 200 Geräte +30% Reserve != /23")
    check(not SubnetSizer.size(300).fits_schema, "300 Geräte passen fälschlich in Schema-/24")
    check(SubnetSizer.size(SCHEMA_HOSTS, headroom=0.0).utilization_24 == 100,
          "Volles Schema-Netz != 100% Auslastung")

    # Das /24 ist strukturell: es folgt aus dem Schema (3. Oktett = Standort),
    # nicht aus einer Einstellung. Waere es konfigurierbar, waere eine IP nicht
    # mehr lesbar - siehe Kommentar bei SCHEMA_PREFIX.
    check(SCHEMA_PREFIX == 24 and SCHEMA_HOSTS == 254, "Schema-Konstanten verschoben")
    check(Planner.build(parse_config("vlans=30&sites=3")).subnet_at(30, 2).cidr.endswith("/24"),
          "Schema-Netz ist kein /24 mehr")

    # Bedarf haengt am VLAN und landet im Plan - nicht neben ihm.
    pl = Planner.build(parse_config("vlans=10,40,90&sites=2&dev_40=180&dev_90=6&headroom=30"))
    check(set(pl.loads) == {40, 90}, f"Loads falsch zugeordnet: {sorted(pl.loads)}")
    check(pl.load_of(10) is None, "VLAN ohne Angabe darf keine Last haben")
    check(pl.load_of(40).required == 234, f"180 +30% != 234, sondern {pl.load_of(40).required}")
    check(pl.load_of(40).utilization_24 == 93, f"180 +30% != 93% eines /24")
    check(not pl.overflows, "234 Hosts passen in ein /24 - kein Ueberlauf erwartet")

    # Ueberlauf: die einzige Aussage, aus der eine Entscheidung folgt.
    pl_over = Planner.build(parse_config("vlans=40&sites=1&dev_40=300&headroom=0"))
    check(len(pl_over.overflows) == 1, "300 Geraete muessen als Ueberlauf auffallen")
    v_over, sz_over = pl_over.overflows[0]
    check(v_over.vlan_id == 40 and sz_over.prefix == 23, f"Ueberlauf-Empfehlung falsch: /{sz_over.prefix}")

    # Der Plan selbst bleibt vom Bedarf unberuehrt - das Schema vergibt immer /24.
    check(pl.subnet_at(40, 0).cidr == "10.40.0.0/24", "Gerätezahl darf die Allokation nicht umbauen")

    # dev_-Parameter zaehlen als Konfiguration.
    check(parse_config("dev_40=180").submitted, "dev_-Parameter muss als Plan-Eingabe zaehlen")
    # Unsinnige dev_-Keys werden ignoriert, nicht uebernommen.
    check(parse_config("dev_999=5&dev_abc=5&dev_40=abc").devices == {}, "Ungueltige dev_-Keys nicht gefiltert")

    # Vorlage-Links duerfen den uebrigen Plan-Zustand nicht verwerfen.
    cfg_st = parse_config("vlans=10,20,90&sites=4&dev_20=50&hub=1&mixeduse=1&floating=2&base=172")
    st = _plan_state(cfg_st)
    back = parse_config(urlencode(st))
    check(back.vlan_ids == cfg_st.vlan_ids, f"VLANs gehen verloren: {back.vlan_ids}")
    check(back.site_octets == cfg_st.site_octets, "Standorte gehen verloren")
    check(back.devices == cfg_st.devices == {20: 50}, f"Gerätezahlen gehen verloren: {back.devices}")
    check(back.base == cfg_st.base and back.hub and back.mixed_use, "Optionen gehen verloren")
    check(back.floating_count == cfg_st.floating_count, "VPN-Zugänge gehen verloren")
    check(back.submitted, "Uebernommener Zustand muss als konfiguriert gelten")

    # Mixed-Use liegt IMMER auf einem geplanten Standort - nie auf einem erfundenen.
    for q_mx in ("profile=minimal&sites=1&mixeduse=1", "sites=3&mixeduse=1",
                 "sites=2&mixeduse=1&mixedsite=99"):
        c_mx = parse_config(q_mx)
        p_mx = Planner.build(c_mx)
        check(c_mx.mixed_site in c_mx.site_octets, f"Mixed-Use-Oktett ausserhalb des Plans: {q_mx}")
        check(p_mx.mixed_use_site is not None and p_mx.mixed_use_site in p_mx.sites,
              f"Mixed-Use-Standort nicht Teil des Plans: {q_mx}")
        # Jedes Netz der Mixed-Use-Karte muss auch in der Matrix stehen.
        for v in p_mx.vlans:
            check(p_mx.subnet_at(v.vlan_id, p_mx.mixed_use_site.octet) is not None,
                  f"Mixed-Use zeigt ein Netz ausserhalb der Matrix: {q_mx}")
    # Default = letzter Standort, ungueltige Wahl wird gemeldet statt still verbogen.
    check(parse_config("sites=3&mixeduse=1").mixed_site == 2, "Default != letzter Standort")
    check(parse_config("sites=2&mixeduse=1&mixedsite=1").mixed_site == 1, "Gueltige Wahl ignoriert")
    check(bool(parse_config("sites=2&mixeduse=1&mixedsite=99").warnings),
          "Ungueltiger Mixed-Use-Standort muss einen Hinweis erzeugen")

    # Erstaufruf: Beispielplan statt leerem Formular.
    cfg_first = parse_config("")
    check(not cfg_first.submitted, "Leerer Aufruf darf nicht als 'submitted' gelten")
    check(len(Planner.build(cfg_first).subnets) > 0, "Erstaufruf zeigt keinen Beispielplan")

    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:]))