Files
bf-infra/nautobot/mikrotik_routeros.j2
Christoph Emsenhuber 3666e1e7fe TP22: RouterOS-Fleet unter Nautobot Golden Config, Deploy scharf
Nimmt die 4 Live-Geräte (crs326-core/-branch, ap1, ap2) real unter Golden
Config: Backup -> Compliance -> intended -> kontrollierter Push. Damit greift
der Governance-Entscheid "Config exklusiv über Nautobot" operativ.

Secret-Leak geschlossen (Anlass, ungeplant gefunden)
  Es gab keine ConfigReplace-Regeln, deshalb hatte der Backup-Job den
  ap1-/export ungefiltert nach Gitea geschrieben: der live genutzte
  WG-Hub-private-key und 6 WLAN-PSKs lagen dort im Klartext.
  - nautobot/ingest-gc-scrubbing.py: 5 ConfigReplace-Regeln, Maske
    "<<REDACTED>>". RouterOS quotet nur bei Bedarf (4 von 6 PSKs standen
    unquotiert), daher Alternation mit der quotierten Variante zuerst --
    \S+ ist greedy und würde bei einem PSK mit Leerzeichen den Rest
    exponieren. Regex-Selbsttest läuft bei jedem Aufruf.
  - Gitea-Repo neu angelegt (History-Rewrite verworfen: Gitea hält
    unerreichbare Blobs bis zum server-seitigen gc). Verifiziert: 0
    unmaskierte Secrets über alle 4 Geräte. User-Entscheid: nicht rotieren.
  - push-gc-template.py: Pre-Push-Secret-Guard, --dry-run, -m, sha optional.

Template
  - AP-Zweig (role == "Wireless AP"), datengetrieben aus ConfigContext
    ap-common + local_config_context_data; ap1/ap2 differieren nur in 4
    Werten. RadioProfile verworfen (hängt an einem Controller, den es seit
    der CAPsMAN-Abschaffung nicht gibt).
  - crs326-core-Zweig von Slash- auf /export-Form (Space-Form, Attribute
    alphabetisch), stale disabled=yes aus der Cutover-Ära entfernt.
  - Switch-Zweig für crs326-branch.
  - Mgmt-Nummernplan korrigiert: ap2 und branch waren in den DNS-Statics
    vertauscht, ap1 zeigte auf ein nicht existierendes .99.3.
    Verbindlich: core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12.
  Ergebnis: ap1 intended == Geräte-Ist (0 Drift), core 68/69 Zeilen
  byte-identisch. 14 ComplianceRules, 51/56 compliant -- die 5 Restdrifts
  sind echte Altlasten (Rettungspfade, Aufräumen gehört zu TP19).

Deploy (nautobot_config.py, Image gc9)
  enable_plan/enable_deploy an. napalm-ros ist READ-ONLY: der Default-
  Dispatcher wählt NAPALM (Platform trägt napalm: ros), Backup geht,
  merge_config scheitert grundsätzlich mit leerer Fehlermeldung. Fix =
  custom_dispatcher auf NetmikoMikrotikRouteros, in den Golden-Config-
  Settings (nicht den Nornir-Settings). Pilot-Deploy: DNS-Statics auf dem
  Core korrigiert, per Purge-vor-Add, weil plan_type=missing nur add
  erzeugt und sonst Duplikate entstanden wären.
  Governance-Regeln in docs/teilprojekte.md festgeschrieben: Pläne nur
  feature-scoped (ein Voll-Push schriebe die Maske als PSK auf die APs),
  AP-Template nie zeilenweise auf ein laufendes Gerät (vlan-filtering=no
  reisst den Mgmt-Pfad ab).

CLAUDE.md: Power-Save-Fehldiagnose korrigiert
  Der P750W war nicht im Power-Save, sondern schlicht ausgeschaltet; das
  Ping-Symptom kam vom lokal mitlaufenden WARP-Client (cloudflared =
  QUIC-Proxy, kein ICMP). Die Merkregel "DHCP-geht-aber-Ping-nicht =
  ARP/Power-Save" ist damit widerlegt und entfernt; strukturell schliesst
  TP16.1 diese ICMP-Lücke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:16:21 +02:00

190 lines
11 KiB
Django/Jinja

# Nautobot Golden Config -- intended RouterOS config for {{ obj.name }}
#
# FORMAT-REGEL (TP22, 2026-08-13): Zeilen sind im RouterOS-**/export-Format** geschrieben
# (Space-Form `/ip address add ...`, Attribute ALPHABETISCH), nicht in der Slash-Form der
# Provisionierungs-Skripte. Golden Config vergleicht Backup (= echter /export) und Intended
# textuell -- jede Abweichung in Reihenfolge, Quoting oder Pfadform ist ein Falsch-Drift.
# Referenz-Exporte: routeros/state-backups/*-gc-backup-scrubbed-*.cfg
#
# Secrets tragen die Maske "<<REDACTED>>" der ConfigReplace-Backup-Regeln.
# 🔴 Zeilen mit <<REDACTED>> duerfen NIE per Deploy gepusht werden.
/system identity set name={{ obj.name }}
{% if obj.name == "crs326-core" %}
{#- VLAN-Tabelle: Site-Konstante. name = RouterOS-comment, lease/pool je VLAN. -#}
{%- set vlans = [
{"id": 10, "name": "MAIN", "pool": "10.40.10.100-10.40.10.200", "lease": "1d"},
{"id": 20, "name": "INFRA", "pool": "10.40.20.100-10.40.20.200", "lease": "1d"},
{"id": 30, "name": "IOT", "pool": "10.40.30.100-10.40.30.250", "lease": "1d"},
{"id": 40, "name": "MEDIA", "pool": "10.40.40.100-10.40.40.200", "lease": "1d"},
{"id": 50, "name": "UNTRUSTED", "pool": "10.40.50.100-10.40.50.200", "lease": "1d"},
{"id": 90, "name": "GUEST", "pool": "10.40.90.100-10.40.90.200", "lease": "12h"},
{"id": 99, "name": "MGMT", "pool": "10.40.99.100-10.40.99.200", "lease": "1d"}
] %}
# =====================================================================================
# crs326-core — PRODUKTIVER L3-Router (seit Cutover 2026-08-08, TP21.1).
# Die frueheren `disabled=yes`-Staging-Flags sind ENTFERNT: sie stammten aus der
# Vorbereitung des Cutovers und beschreiben nicht mehr den Sollzustand.
# =====================================================================================
# [1] INTERFACE-LISTS
/interface list add comment=TP15 name=WAN
/interface list add comment=TP15 name=LAN
/interface list member add interface=vlan100 list=WAN
{% for v in vlans %}
/interface list member add interface=vlan{{ v.id }} list=LAN
{% endfor %}
# [2] VLAN-SVIs auf der Bridge (vlan99 traegt bewusst keinen comment)
{% for v in vlans %}
/interface vlan add {% if v.id != 99 %}comment={{ v.name }} {% endif %}interface=bridge name=vlan{{ v.id }} vlan-id={{ v.id }}
{% endfor %}
/interface vlan add comment=WAN interface=bridge name=vlan100 vlan-id=100
# [3] GW-IPs je VLAN — datengetrieben aus Nautobot (IP am SVI-Interface gebunden).
# comment = Interface-Description; network wird aus der Host-IP abgeleitet.
{% for iface in obj.interfaces.all() %}
{% if iface.name.startswith("vlan") %}
{% for ip in iface.ip_addresses.all() %}
/ip address add address={{ ip.address }} comment="{{ iface.description }}" interface={{ iface.name }} network={{ ip.host.split(".")[:3] | join(".") }}.0
{% endfor %}
{% endif %}
{% endfor %}
# [4] WAN — DHCP-Client auf vlan100 (Liwest/Arris)
/ip dhcp-client add comment="WAN Arris DHCP (Liwest) via vlan100" interface=vlan100 use-peer-dns=no use-peer-ntp=no
# [5] DHCP-Server je VLAN
{% for v in vlans %}
/ip pool add name=pool-v{{ v.id }} ranges={{ v.pool }}
{% endfor %}
{% for v in vlans %}
/ip dhcp-server add address-pool=pool-v{{ v.id }} comment={{ v.name }} interface=vlan{{ v.id }} lease-time={{ v.lease }} name=dhcp-v{{ v.id }}
{% endfor %}
{% for v in vlans %}
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.{{ v.id }}.1 domain=bf.lan gateway=10.40.{{ v.id }}.1
{% endfor %}
/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101
# [6] DNS — Upstream aktuell PLAIN 1.1.1.1. DoH-Cloudflare-Gateway ist offener TODO
# (core hat kein CA-Bundle -> verify-doh-cert nicht moeglich), s. TP21.1.
/ip dns set allow-remote-requests=yes mdns-repeat-ifaces=vlan10,vlan20,vlan40,vlan30,vlan90 servers=1.1.1.1
# Kanonischer Mgmt-Nummernplan (User-Bestaetigung 2026-08-13, verbindlich):
# core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12
/ip dns static add address=10.40.99.1 comment="core = GW/Router" name=router.lan type=A
/ip dns static add address=10.40.99.1 comment="crs326-core (L3)" name=core.bf.lan type=A
/ip dns static add address=10.40.99.2 comment=crs326-branch name=branch.bf.lan type=A
/ip dns static add address=10.40.99.11 comment="ap1 = reiner AP" name=ap1.bf.lan type=A
/ip dns static add address=10.40.99.12 comment="ap2 = reiner AP" name=ap2.bf.lan type=A
/ip dns static add address=10.40.20.167 name=macmini.bf.lan type=A
/ip dns static add address=10.40.20.100 name=pve.bf.lan type=A
/ip dns static add address=10.40.20.173 name=pve-mcp.bf.lan type=A
/ip dns static add address=10.40.20.10 name=nas.bf.lan type=A
/ip dns static add address=10.40.20.126 name=docker.bf.lan type=A
/ip dns static add address=10.40.20.20 name=platform-pg.bf.lan type=A
# [7] NAT
/ip firewall nat add action=accept chain=srcnat comment="TP4: no-NAT inter-VLAN (LAN-internal, before masquerade)" dst-address=10.40.0.0/16
/ip firewall nat add action=masquerade chain=srcnat comment="defconf: masquerade" ipsec-policy=out,none out-interface-list=WAN
# [8] ROUTEN zu den WG-Spokes (Default-Route kommt dynamisch vom WAN-DHCP-Client)
/ip route add comment="TP10: FM spoke -> wg-hub" dst-address=10.101.0.0/24 gateway=wg-hub
/ip route add comment="TP10: DW spoke -> wg-hub" dst-address=10.95.10.0/24 gateway=wg-hub
# [9] WG-HUB (TP10 Multi-Site). Der echte Privkey liegt in routeros/ap1-wg-hub.privkey.
/interface wireguard add comment="TP10 Multi-Site VPN Hub (FM/DW spokes)" listen-port=51821 mtu=1412 name=wg-hub private-key="<<REDACTED>>"
/interface wireguard peers add allowed-address=10.101.0.0/24 comment=fm endpoint-address=abxhutfgsid9snmy.myfritz.net endpoint-port=52476 interface=wg-hub name=fm public-key="dzRY0lnnUygijChWe5wXZmXPeNcbdALpqBptA3fyiVE="
/interface wireguard peers add allowed-address=10.95.10.0/24 comment=dw interface=wg-hub name=dw public-key="FqYZLztQ/qME5VDie5QLGH++5299WJiE1dPpQnPbzUw="
{% elif obj.role.name == "Switch" %}
# =====================================================================================
# L2-Switch (crs326-branch) — Sollzustand des Mgmt-Layers.
# Bewusst NICHT emittiert: die TEMP-Bring-up-Objekte (vlan10 "TEMP Mgmt VLAN10" +
# 10.40.10.2). Die tauchen dadurch als Compliance-`extra` auf — genau richtig, denn sie
# sind Altlast aus dem Bring-up und gehoeren entfernt (VLAN1/VLAN10-Interim, TP19).
# =====================================================================================
/interface vlan add comment=MGMT interface=bridge name=vlan99 vlan-id=99
/ip address add address={{ obj.primary_ip4.address }} comment=TP21-mgmt-target interface=vlan99 network={{ obj.primary_ip4.host.split(".")[:3] | join(".") }}.0
/ip dns set servers=10.40.99.1
/ip route add comment=TP21-default dst-address=0.0.0.0/0 gateway=10.40.99.1
{% elif obj.role.name == "Wireless AP" %}
{% set ap = obj.get_config_context()["ap"] %}
{% set mgmt_host = obj.primary_ip4.host %}
{% set uplink = ap.uplink %}
# =====================================================================================
# TP22 — Standalone-AP (kein CAPsMAN). Gerendert aus ConfigContext "ap-common"
# + Device.local_config_context_data (Kanaele) + DCIM (Name, primary_ip4).
#
# SCOPE: nur die COMPLIANCE-VERWALTETEN Sektionen, bewusst NICHT die ganze Config.
# Golden Config vergleicht per ComplianceRule.match_config sektionsweise -> defconf-
# Boilerplate (ipv4/ipv6-Firewall, /disk, /tool mac-server) und die VAP-Zeilen
# (/interface wifi add ... mac-address=...) bleiben draussen: deren MACs sind
# geraetevergeben und in Nautobot nicht modelliert.
#
# 🔴 NIE als Ganzes auf ein laufendes Geraet pushen: die Provisionierung setzt
# zwischenzeitlich vlan-filtering=no, was den vlan99-Mgmt-Pfad abreisst -> der Rest
# der Zeilen kaeme ueber dieselbe SSH-Session nie an. Voll-Reprovisionierung
# ausschliesslich per Datei-Upload + /import (siehe routeros/.secrets/ap*-prov-base.rsc).
#
# Attributreihenfolge = alphabetisch, exakt wie RouterOS /export sie schreibt.
# Referenz: routeros/state-backups/ap1-gc-backup-scrubbed-20260813.cfg
# =====================================================================================
# [1] CHANNELS — Frequenz aus der Kanalnummer gerechnet (eine Quelle der Wahrheit)
{% for r in ap.radios %}
{% set ch = ap.channels[r.key] %}
{% set freq = (5000 + 5 * ch) if r.key == "5g" else (2407 + 5 * ch) %}
/interface wifi channel add band={{ r.band }} frequency={{ freq }} name=ch-{{ r.key }} width={{ r.width }}
{% endfor %}
# [2] DATAPATH — je Zone ein VLAN-Tag. OHNE datapath egressiert die SSID ungetaggt
# in VLAN1 (das war das TP3.1-Leck) -> jede Zone MUSS hier auftauchen.
{% for z in ap.zones %}
/interface wifi datapath add bridge={{ ap.bridge }} name=dp-{{ z.name }} vlan-id={{ z.vlan }}
{% endfor %}
# [3] SECURITY — Passphrasen tragen die Backup-Maske aus TP22 Schritt 0.
# ft-over-ds schreibt RouterOS nur, wenn ft=yes.
{% for z in ap.zones %}
/interface wifi security add authentication-types={{ z.auth }} ft={{ z.ft }}{% if z.ft == "yes" %} ft-over-ds={{ z.ft_over_ds }}{% endif %} name=sec-{{ z.name }} passphrase="<<REDACTED>>"
{% endfor %}
# [4] CONFIGURATION — main sitzt auf den Master-Radios und bringt den Channel mit
# (je Band eine configuration); alle anderen Zonen sind Slave-VAPs ohne channel=.
{% for z in ap.zones %}
{% if z.master %}
{% for r in ap.radios %}
{% if r.key in z.bands %}
/interface wifi configuration add channel=ch-{{ r.key }} country={{ ap.country }} datapath=dp-{{ z.name }} mode=ap name=cfg-{{ z.name }}-{{ r.key }} security=sec-{{ z.name }} ssid={{ z.ssid }}
{% endif %}
{% endfor %}
{% else %}
/interface wifi configuration add country={{ ap.country }} datapath=dp-{{ z.name }} mode=ap name=cfg-{{ z.name }} security=sec-{{ z.name }} ssid={{ z.ssid }}
{% endif %}
{% endfor %}
# [5] BRIDGE-VLAN — Nur Master-Radios sind Bridge-Ports (Slave-VAPs forwarden darueber),
# deshalb muessen wifi1/wifi2 tagged-Member je Client-VLAN sein. Die frueheren
# "Ausnahmen" fallen hier aus den Daten heraus: IOT hat bands=["2g"] -> nur wifi2.
/interface bridge vlan add bridge={{ ap.bridge }} untagged={{ ap.bridge }},{{ uplink }} vlan-ids={{ ap.native_vlan }}
{% for z in ap.zones %}
{% set members = [uplink] + (ap.radios | selectattr("key", "in", z.bands) | map(attribute="iface") | list) %}
/interface bridge vlan add bridge={{ ap.bridge }} tagged={{ members | join(",") }} vlan-ids={{ z.vlan }}
{% endfor %}
# MGMT liegt auf der Bridge, NICHT auf den Radios - sonst waere Management over-the-air.
/interface bridge vlan add bridge={{ ap.bridge }} tagged={{ uplink }},{{ ap.bridge }} vlan-ids={{ ap.mgmt.vlan }}
# [6] MGMT-LAYER
# interface-list + dns hier, damit die platform-weiten ComplianceFeatures
# (gelten pro Platform, nicht pro Rolle) auf APs nicht ins Leere laufen.
/interface list add comment=defconf name=WAN
/interface list add comment=defconf name=LAN
/interface list member add comment=defconf interface={{ ap.bridge }} list=LAN
/interface list member add interface=vlan{{ ap.mgmt.vlan }} list=LAN
/ip dns set allow-remote-requests=yes servers={{ ap.mgmt.gateway }}
/interface vlan add interface={{ ap.bridge }} name=vlan{{ ap.mgmt.vlan }} vlan-id={{ ap.mgmt.vlan }}
/ip address add address={{ obj.primary_ip4.address }} interface=vlan{{ ap.mgmt.vlan }} network={{ mgmt_host.split(".")[:3] | join(".") }}.0
/ip route add comment="mgmt default via core" dst-address=0.0.0.0/0 gateway={{ ap.mgmt.gateway }}
{% endif %}