Compare commits
4 Commits
claude/pri
...
123a0fefdf
| Author | SHA1 | Date | |
|---|---|---|---|
| 123a0fefdf | |||
| aebf3776ac | |||
| b6f2129b41 | |||
| 82cd8098ef |
39
docs/announcements.md
Normal file
39
docs/announcements.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Announcements — bf-infra
|
||||||
|
|
||||||
|
Breaking News / Querschnitts-Änderungen, die **jede** folgende Session kennen muss, bevor sie
|
||||||
|
losarbeitet — Dinge, die man sonst erst nach dem ersten Fehlschlag merkt. Kein Ersatz für die
|
||||||
|
TP-Prosa (`docs/teilprojekte.md`) oder das Gitea-Board; hier steht nur, was *quer* zu den TPs gilt.
|
||||||
|
|
||||||
|
**Format je Eintrag:** Überschrift `## YYYY-MM-DD — <Titel>`, dann die Zeilen
|
||||||
|
**Betrifft:** (wer/was) und **Aktion:** (was eine Session konkret anders machen muss),
|
||||||
|
optional ein, zwei Sätze Kontext. **Neueste oben.** Der Skill `/news` liest diese Datei und
|
||||||
|
zeigt die Einträge, die seit dem letzten `/news`-Lauf dazugekommen sind (Rest auf Wunsch).
|
||||||
|
|
||||||
|
<!-- TP35 Schicht 4. Eintrag hinzufügen = neuen ##-Block ganz oben unter dieser Zeile einfügen. -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-12 — claude.json-Secrets laufen über `{env:}` (TP35 Schicht 2 ✅)
|
||||||
|
**Betrifft:** alle, die `~/.claude.json` oder `~/.secrets/exports.zsh` anfassen.
|
||||||
|
**Aktion:** Keine Klartext-Secrets mehr in `~/.claude.json` — MCP-Credentials referenzieren jetzt
|
||||||
|
`{env:MCP_*}` (dieselben Namen wie `opencode.jsonc`, bidirektional kompatibel). SSoT ist
|
||||||
|
`~/.secrets/exports.zsh` (25 Vars). Nach jeder Änderung dort: `~/dev/.bin/gui-env-sync.sh`
|
||||||
|
**und App-Neustart** (laufender Prozess erbt die launchd-Umgebung nicht). ⚠ `~/.claude.json` nie aus
|
||||||
|
einer **laufenden** Session editieren — Claude Code schreibt dort State, der Edit würde überschrieben;
|
||||||
|
der Flip läuft bei beendeter App über `~/dev/.bin/schicht2-claude-json-migrate.py --apply-claude`.
|
||||||
|
|
||||||
|
## 2026-09-12 — TP36-Live: Gerätedaten stehen als CustomFields in Nautobot
|
||||||
|
**Betrifft:** alle, die Geräte-Ist-Werte (RouterOS-Version, Compliance %, DHCP-Server-Zahl,
|
||||||
|
DNS-Modus, Mgmt-IP) brauchen.
|
||||||
|
**Aktion:** Nicht mehr aus der Prosa ablesen und nicht raten — die fünf Felder (`grouping: TP36-Live`)
|
||||||
|
hängen an den 5 RouterOS-`dcim.Device` in Nautobot. Aktualisieren: `python3 nautobot/gc-snapshot.py
|
||||||
|
--commit` (liest nur aus Nautobot/GC, kontaktiert kein Gerät). Die Werte sind so aktuell wie das
|
||||||
|
letzte Golden-Config-Backup (`backup_last_success_date` am Gerät).
|
||||||
|
|
||||||
|
## 2026-09-07 — SSoT-Cutover: Index/Status = Gitea, NICHT mehr teilprojekte.md
|
||||||
|
**Betrifft:** jede Session, die einen TP-Status ändert, anlegt oder abschließt.
|
||||||
|
**Aktion:** Status/Reihenfolge/„was lief zuletzt" gehen ins **Gitea-Issue**
|
||||||
|
([`admin/bf-infra`](https://gitea.emsenhuber.net/admin/bf-infra/issues), Labels `status:*`/`thema:*`),
|
||||||
|
**nicht** in eine Tabelle in `docs/teilprojekte.md`. Die Datei trägt nur noch die `### TPnn`-Prosa.
|
||||||
|
`/projektstatus` liest Baum + Status aus der Gitea-API. Branch `tpNN-kurz` jederzeit möglich, Merge
|
||||||
|
nach `main`, sobald Code/Config steht.
|
||||||
@@ -2354,15 +2354,15 @@ kann in Ruhe nachziehen.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### TP35 — MCP-Schichtung global/projekt/user + 3-Schicht-CLAUDE.md + Announcement-Mechanismus + Backup-Guard ▸ ⏳ **ANGELEGT 2026-08-28, Schicht 1 (FQDN→IP) + Schicht 3 (globale Regeln) + Schicht 6 (Skill) ✅**
|
### TP35 — MCP-Schichtung global/projekt/user + 3-Schicht-CLAUDE.md + Announcement-Mechanismus + Backup-Guard ▸ ⏳ **ANGELEGT 2026-08-28, Schicht 1 (FQDN→IP) + Schicht 3 (globale Regeln) + Schicht 4 (Announcement/`/news`) + Schicht 5 (Backup-Guard) + Schicht 6 (Skill) ✅**
|
||||||
- **Auslöser:** Bestandsaufnahme Session 2026-08-28 (Handoff `docs/handoff-20260828-pre-opencode-migration.md`). Erkenntnis: MCP-Configs sind heute über `~/.config/opencode/opencode.jsonc` UND `~/.claude.json` verstreut, ohne klares Schema.
|
- **Auslöser:** Bestandsaufnahme Session 2026-08-28 (Handoff `docs/handoff-20260828-pre-opencode-migration.md`). Erkenntnis: MCP-Configs sind heute über `~/.config/opencode/opencode.jsonc` UND `~/.claude.json` verstreut, ohne klares Schema.
|
||||||
- **Befund (geprüft):** 11/12 Backups im Verzeichnis `~/dev/.pre-opencode-migration-backups/20260828-150335/` gzip-valid; `uwe-job-bewerbung.tar.gz` korrupt (Source unverändert da). Repos und globale Configs md5-/HEAD-identisch zum Vor-Session-Stand. Synology-Drive-Sync aktiv (Anwender, manuell).
|
- **Befund (geprüft):** 11/12 Backups im Verzeichnis `~/dev/.pre-opencode-migration-backups/20260828-150335/` gzip-valid; `uwe-job-bewerbung.tar.gz` korrupt (Source unverändert da). Repos und globale Configs md5-/HEAD-identisch zum Vor-Session-Stand. Synology-Drive-Sync aktiv (Anwender, manuell).
|
||||||
- **Scope:**
|
- **Scope:**
|
||||||
1. **MCP-Schichtung** (global/projekt/user) — wo lebt welche MCP-Definition, wie wird sie aufgelöst, wie bleibt sie synchron, wenn ein Projekt physisch auf einen anderen Host umzieht. **✅ 2026-08-29 (Teilschritt):** `gitea-mcp` + `n8n-mcp` in der globalen `opencode.jsonc` von CF-FQDN auf LAN-IP migriert (s. Ist-Stand-Block). `cloudflare`-MCP bleibt FQDN (User-Direktive).
|
1. **MCP-Schichtung** (global/projekt/user) — wo lebt welche MCP-Definition, wie wird sie aufgelöst, wie bleibt sie synchron, wenn ein Projekt physisch auf einen anderen Host umzieht. **✅ 2026-08-29 (Teilschritt):** `gitea-mcp` + `n8n-mcp` in der globalen `opencode.jsonc` von CF-FQDN auf LAN-IP migriert (s. Ist-Stand-Block). `cloudflare`-MCP bleibt FQDN (User-Direktive).
|
||||||
2. **Credentials-Schichtung** — alle Secrets in `~/.secrets/exports.zsh`, MCP-Configs ausschließlich `{env:...}`. Schließt TP30 Posten #1 ab.
|
2. **Credentials-Schichtung** — alle Secrets in `~/.secrets/exports.zsh`, MCP-Configs ausschließlich `{env:...}`. Schließt TP30 Posten #1 ab. **✅ 2026-09-12 angewandt:** `exports.zsh` um die 3 fehlenden `MCP_MIKROMCP_{ROUTER_AP3,CRS326_CORE,CRS326_BRANCH}_PASS` ergänzt (jetzt 25 Vars, die opencode schon referenzierte — vorher liefen die dort leer), `gui-env-sync` gelaufen (25/25 in launchd-Env). `~/.claude.json` via `~/dev/.bin/schicht2-claude-json-migrate.py --apply-claude` (bei beendeter App, Backup `.bak-20260912-*`) geflippt — **alle 7 Secrets** (`mikromcp` 5× PASS, `hostinger-api` Token, `n8n-mcp` Bearer) jetzt `{env:}`, verifiziert; App neu gestartet, MCP-Server laden sauber. 🔴 **Merkregel:** `~/.claude.json` nie aus laufender Session editieren (Claude Code schreibt dort State → überschreibt). Restcheck offen: **authentifizierter** mikromcp-Read **on-network** (am 09-12 Connect-Timeout VLAN99:80 = Erreichbarkeit vom Standort, kein Auth-Fehler). Rollback = `.bak` zurück.
|
||||||
3. **3-Schicht-CLAUDE.md** — `CLAUDE.md` (öffentlich) / `CLAUDE.local.md` (gitignored) / `~/.claude/CLAUDE.md` (global). **✅ 2026-08-29 (globale Schicht):** `~/.config/opencode/AGENTS.md` als SSoT angelegt (Secrets + VLAN-Schema + 5 Lessons + SoC + Übergabe-Mechanik + Mid-Action + Commit-Hinweis + Backup-Guard); `~/.claude/CLAUDE.md` hält denselben Block als Spiegel. **Offen:** die pro-Projekt-Schicht (`CLAUDE.md`/`CLAUDE.local.md` + Handover-Posts) — eigene Sessions.
|
3. **3-Schicht-CLAUDE.md** — `CLAUDE.md` (öffentlich) / `CLAUDE.local.md` (gitignored) / `~/.claude/CLAUDE.md` (global). **✅ 2026-08-29 (globale Schicht):** `~/.config/opencode/AGENTS.md` als SSoT angelegt (Secrets + VLAN-Schema + 5 Lessons + SoC + Übergabe-Mechanik + Mid-Action + Commit-Hinweis + Backup-Guard); `~/.claude/CLAUDE.md` hält denselben Block als Spiegel. **Offen:** die pro-Projekt-Schicht (`CLAUDE.md`/`CLAUDE.local.md` + Handover-Posts) — eigene Sessions.
|
||||||
4. **Announcement-Mechanismus (Hybrid)** — zentraler Ort (voraussichtlich `bf-infra/docs/announcements.md`) + Skill `/news` oder `/announce` zum Einlesen beim Session-Start. Initial: Breaking News über die MCP-Schichtung selbst.
|
4. **Announcement-Mechanismus (Hybrid)** — zentraler Ort + Skill zum Einlesen beim Session-Start. **✅ 2026-09-12:** `docs/announcements.md` angelegt (Format: `## YYYY-MM-DD — Titel` + `Betrifft:`/`Aktion:`, neueste oben) + globaler Skill **`/news`** (in beiden Skill-Pfaden `~/.claude/skills/news/` **und** `~/.config/opencode/skills/news/`, `cp` kein Symlink — Schicht-6-Lehre). `/news` zeigt Einträge, die seit dem letzten Lauf dazukamen (Seen-State pro Repo unter `~/.news-seen/`). Erste Einträge: SSoT-Cutover (TP36) + TP36-Live.
|
||||||
5. **Backup-Guard** — `~/dev/.bin/backup-guard.sh`, Pre-Commit-Hook pro Projekt, „3 Tage aktiv"-Definition: 5 Commits oder 1000 LOC diff.
|
5. **Backup-Guard** — `~/dev/.bin/backup-guard.sh`, Pre-Commit-Hook pro Projekt, „3 Tage aktiv"-Definition: 5 Commits oder 1000 LOC diff. **✅ 2026-09-12 abgenommen:** Guard (112 Z., `check`/`mark`/`status`) + `.git/hooks/pre-commit` in `bf-infra` live verdrahtet; der Hook hat beim TP36-Live-Commit real gegriffen („Projekt aktiv 12 Commits/198 LOC, Backup markiert"). ⚠️ **Bekannte Grenze:** `check` prüft nur *Existenz* der Guard-Datei, nicht deren Alter — ein alter `mark` (hier 2026-08-29) lässt den Commit durch. Das ist als Push-Erinnerung gewollt, keine Frische-Garantie.
|
||||||
6. **`/projektstatus`-Skill global** — als Erstes, vor den anderen Schichten. Alle Projekte verwenden dasselbe Skill. **✅ 2026-08-28:** `/projektstatus`-Skill nach `~/.config/opencode/skills/projektstatus/SKILL.md` kopiert (`cp`, nicht `ln -s`) — opencode scannt diesen Default-Pfad, kein `skills.paths`-Eintrag in der JSONC nötig. Lehre im CLAUDE.md-Lessons-Block.
|
6. **`/projektstatus`-Skill global** — als Erstes, vor den anderen Schichten. Alle Projekte verwenden dasselbe Skill. **✅ 2026-08-28:** `/projektstatus`-Skill nach `~/.config/opencode/skills/projektstatus/SKILL.md` kopiert (`cp`, nicht `ln -s`) — opencode scannt diesen Default-Pfad, kein `skills.paths`-Eintrag in der JSONC nötig. Lehre im CLAUDE.md-Lessons-Block.
|
||||||
- **Abgrenzung:** TP35 ist Konzept + Schichtung, nicht die Umsetzung. Pro Projekt eigene Session, eigener Handover-Post, eigene Mini-Anpassung.
|
- **Abgrenzung:** TP35 ist Konzept + Schichtung, nicht die Umsetzung. Pro Projekt eigene Session, eigener Handover-Post, eigene Mini-Anpassung.
|
||||||
- **Verzahnt mit:** TP30 (Credentials), TP34 (Repository-Pattern), TP22 (Nautobot-GC als Vorbild für SSoT + Compliance), TP11 (Monitoring).
|
- **Verzahnt mit:** TP30 (Credentials), TP34 (Repository-Pattern), TP22 (Nautobot-GC als Vorbild für SSoT + Compliance), TP11 (Monitoring).
|
||||||
|
|||||||
@@ -23,12 +23,6 @@ Das ist der letzte Baustein von TP23 und lebt in der **TP5-Firewall** (`forward`
|
|||||||
Reihenfolge-Regel („erst der Ersatz, dann die Sperre").
|
Reihenfolge-Regel („erst der Ersatz, dann die Sperre").
|
||||||
- `dns-bypass-exempt`-Gruppe existiert auf dem Resolver-Paar (AGH-Client-Seite).
|
- `dns-bypass-exempt`-Gruppe existiert auf dem Resolver-Paar (AGH-Client-Seite).
|
||||||
|
|
||||||
**Entwurf liegt bereit (freigegeben 2026-09-11, NOCH NICHT deployt — Christoph war remote):**
|
|
||||||
- Phase A (Substrat + Exempt/JIT-Accepts + log-only): `routeros/tp23-stufe6-phaseA-exempt-log.rsc`
|
|
||||||
- Phase B (reject + Auto-Revert): `routeros/tp23-stufe6-phaseB-reject.rsc`
|
|
||||||
- 🔴 **Vor dem Deploy live verifizieren** (im Dateikopf markiert): (1) der Egress-Accept-Anker in
|
|
||||||
der forward-Kette, vor dem die Regeln stehen müssen; (2) die Exempt-IPs des dev-MacBooks.
|
|
||||||
|
|
||||||
**Noch zu bauen (Teil dieser Session, VOR dem Drop):**
|
**Noch zu bauen (Teil dieser Session, VOR dem Drop):**
|
||||||
- **Firewall-Exempt** ist noch NICHT da: eine Address-List `dns-bypass-exempt` (permanent) mit dem
|
- **Firewall-Exempt** ist noch NICHT da: eine Address-List `dns-bypass-exempt` (permanent) mit dem
|
||||||
dev-MacBook (`10.40.10.183` **und** `10.40.10.82`, dual-homed) + Agent-Toolchain, plus eine
|
dev-MacBook (`10.40.10.183` **und** `10.40.10.82`, dual-homed) + Agent-Toolchain, plus eine
|
||||||
|
|||||||
197
nautobot/gc-snapshot.py
Normal file
197
nautobot/gc-snapshot.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
TP36-Live — Live-Gerätedaten als CustomFields auf den RouterOS-dcim.Device.
|
||||||
|
|
||||||
|
Legt (idempotent, get-or-create) fünf CustomFields auf `dcim.device` an und
|
||||||
|
befüllt sie für jedes RouterOS-Gerät. Die Werte kommen AUSSCHLIESSLICH aus
|
||||||
|
Nautobot selbst — es wird KEIN Gerät kontaktiert:
|
||||||
|
|
||||||
|
ros_version aus der GoldenConfig-`backup_config` ("by RouterOS X.Y.Z")
|
||||||
|
dhcp_server_count gezählte Einträge der Sektion "/ip dhcp-server" im Backup
|
||||||
|
dns_upstream_mode aus der "/ip dns"-Sektion des Backups: doh | plain | none
|
||||||
|
compliance_pct GC config-compliance: compliant / total * 100 (gerundet)
|
||||||
|
mgmt_ip device.primary_ip4 (ohne /prefix)
|
||||||
|
|
||||||
|
Warum aus Nautobot statt live vom Gerät: der MGMT-/REST-Weg (VLAN99:80) ist
|
||||||
|
nicht aus jedem Vantage erreichbar; die GC-Backups werden ohnehin 2×/Tag per
|
||||||
|
SSH gezogen und sind die Doku-SSoT. Damit ist der Snapshot cron-fest und
|
||||||
|
braucht keine Geräte-Credentials. Aktualität steht in `backup_last_success_date`.
|
||||||
|
|
||||||
|
Der Backup-Config-Text wird nur INTERN geparst (Zähler/Modus abgeleitet) und
|
||||||
|
NIE ausgegeben — er kann gescrubte Reste enthalten (PSK-Leak-Regel).
|
||||||
|
|
||||||
|
Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored),
|
||||||
|
Schlüssel NAUTOBOT_SUPERUSER_API_TOKEN.
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python3 nautobot/gc-snapshot.py # Dry-Run (default) — zeigt nur an
|
||||||
|
python3 nautobot/gc-snapshot.py --commit # legt CustomFields an + patcht Devices
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|
||||||
|
|
||||||
|
def load_token() -> str:
|
||||||
|
for line in YML.read_text().splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN = load_token()
|
||||||
|
HDRS = {"Authorization": f"Token {TOKEN}", "Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def req(method: str, path: str, body=None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
r = urllib.request.Request(f"{NB}{path}", data=data, headers=HDRS, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=20) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode() or "{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read().decode() or "{}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- CustomFields
|
||||||
|
# (key, label, type, description). content_types wird ergänzt.
|
||||||
|
CUSTOM_FIELDS = [
|
||||||
|
("ros_version", "RouterOS-Version", "text",
|
||||||
|
"RouterOS-Version aus der GC-backup_config (by RouterOS …)."),
|
||||||
|
("compliance_pct", "GC-Compliance %", "integer",
|
||||||
|
"Golden-Config config-compliance: compliant/total in Prozent."),
|
||||||
|
("dhcp_server_count", "DHCP-Server", "integer",
|
||||||
|
"Anzahl Einträge der Sektion /ip dhcp-server im GC-Backup."),
|
||||||
|
("mgmt_ip", "Mgmt-IP", "text",
|
||||||
|
"device.primary_ip4 ohne Prefix (VLAN99-Management)."),
|
||||||
|
("dns_upstream_mode", "DNS-Upstream-Modus", "text",
|
||||||
|
"Aus /ip dns des GC-Backups: doh | plain | none."),
|
||||||
|
]
|
||||||
|
CF_KEYS = [c[0] for c in CUSTOM_FIELDS]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_custom_fields():
|
||||||
|
"""get-or-create die fünf CFs; bei Bestand: content_type dcim.device sicherstellen."""
|
||||||
|
st, d = req("GET", "/api/extras/custom-fields/?limit=0")
|
||||||
|
have = {r["key"]: r for r in d.get("results", [])}
|
||||||
|
for key, label, typ, desc in CUSTOM_FIELDS:
|
||||||
|
ex = have.get(key)
|
||||||
|
if ex:
|
||||||
|
cts = ex.get("content_types", [])
|
||||||
|
ok = "dcim.device" in cts
|
||||||
|
print(f" = CF '{key}' existiert ({ex['id']}){'' if ok else ' ⚠ ohne dcim.device!'}")
|
||||||
|
if not ok and COMMIT:
|
||||||
|
req("PATCH", f"/api/extras/custom-fields/{ex['id']}/",
|
||||||
|
{"content_types": sorted(set(cts) | {"dcim.device"})})
|
||||||
|
print(f" + dcim.device ergänzt")
|
||||||
|
continue
|
||||||
|
payload = {"key": key, "label": label, "type": typ, "description": desc,
|
||||||
|
"content_types": ["dcim.device"], "grouping": "TP36-Live",
|
||||||
|
"required": False}
|
||||||
|
if not COMMIT:
|
||||||
|
print(f" + CF '{key}' ({typ}) [DRY-RUN]")
|
||||||
|
continue
|
||||||
|
st, r = req("POST", "/api/extras/custom-fields/", payload)
|
||||||
|
print(f" {'+' if st in (200,201) else '!'} CF '{key}'"
|
||||||
|
+ (f" angelegt ({r.get('id')})" if st in (200, 201)
|
||||||
|
else f" FEHLER {st}: {json.dumps(r)[:200]}"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- Backup-Parser
|
||||||
|
def parse_backup(cfg: str):
|
||||||
|
"""Werte aus dem flachen RouterOS-Export (eine '/…'-Zeile pro Kommando)."""
|
||||||
|
ver = ""
|
||||||
|
m = re.search(r"by RouterOS ([0-9][0-9A-Za-z.\-]*)", cfg)
|
||||||
|
if m:
|
||||||
|
ver = m.group(1)
|
||||||
|
# DHCP-Server = Zeilen '/ip dhcp-server add …' (NICHT lease/network/option)
|
||||||
|
dhcp = len(re.findall(r"(?m)^\s*/ip dhcp-server add\b", cfg))
|
||||||
|
# DNS-Modus aus der '/ip dns set …'-Zeile
|
||||||
|
mode = "none"
|
||||||
|
md = re.search(r"(?m)^\s*/ip dns set\b.*", cfg)
|
||||||
|
if md:
|
||||||
|
line = md.group(0)
|
||||||
|
doh = re.search(r"use-doh-server=([^\s]+)", line)
|
||||||
|
srv = re.search(r"\bservers=([^\s]+)", line)
|
||||||
|
if doh and doh.group(1) not in ("", '""'):
|
||||||
|
mode = "doh"
|
||||||
|
elif srv and srv.group(1) not in ("", '""'):
|
||||||
|
mode = "plain"
|
||||||
|
return ver, dhcp, mode
|
||||||
|
|
||||||
|
|
||||||
|
def compliance_pct(device_name: str):
|
||||||
|
q = urllib.parse.urlencode({"device": device_name, "limit": 0, "depth": 0})
|
||||||
|
st, d = req("GET", f"/api/plugins/golden-config/config-compliance/?{q}")
|
||||||
|
rows = d.get("results", [])
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
ok = sum(1 for r in rows if r.get("compliance"))
|
||||||
|
return round(ok / len(rows) * 100)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- main
|
||||||
|
def routeros_devices():
|
||||||
|
q = urllib.parse.urlencode({"limit": 0, "depth": 1, "platform": "RouterOS"})
|
||||||
|
st, d = req("GET", f"/api/dcim/devices/?{q}")
|
||||||
|
devs = []
|
||||||
|
for r in d.get("results", []):
|
||||||
|
ip = r.get("primary_ip4")
|
||||||
|
ip = ip.get("address") if isinstance(ip, dict) else ip
|
||||||
|
if not ip:
|
||||||
|
continue # tote Altgeräte (cudy/sw) ohne IP überspringen
|
||||||
|
devs.append((r["id"], r["name"], ip.split("/")[0]))
|
||||||
|
return sorted(devs, key=lambda x: x[1])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"== TP36-Live gc-snapshot ({'COMMIT' if COMMIT else 'DRY-RUN'}) @ {NB} ==")
|
||||||
|
|
||||||
|
print("\n[1] CustomFields sicherstellen")
|
||||||
|
ensure_custom_fields()
|
||||||
|
|
||||||
|
print("\n[2] Golden-Config-Backups laden")
|
||||||
|
st, gc = req("GET", "/api/plugins/golden-config/golden-config/?depth=1&limit=0")
|
||||||
|
backups = {}
|
||||||
|
for r in gc.get("results", []):
|
||||||
|
dev = r.get("device")
|
||||||
|
dev = dev.get("name") if isinstance(dev, dict) else dev
|
||||||
|
backups[dev] = (r.get("backup_config") or "", r.get("backup_last_success_date") or "?")
|
||||||
|
|
||||||
|
print("\n[3] Snapshot je Gerät")
|
||||||
|
print(f" {'Gerät':16} {'ros':9} {'cmpl%':>5} {'dhcp':>4} {'dns':6} {'mgmt-ip':15} backup")
|
||||||
|
for dev_id, name, mgmt in routeros_devices():
|
||||||
|
cfg, when = backups.get(name, ("", "—"))
|
||||||
|
ver, dhcp, dns = parse_backup(cfg) if cfg else ("?", 0, "?")
|
||||||
|
pct = compliance_pct(name)
|
||||||
|
vals = {
|
||||||
|
"ros_version": ver or None,
|
||||||
|
"compliance_pct": pct,
|
||||||
|
"dhcp_server_count": dhcp,
|
||||||
|
"mgmt_ip": mgmt,
|
||||||
|
"dns_upstream_mode": dns,
|
||||||
|
}
|
||||||
|
pct_s = "—" if pct is None else str(pct)
|
||||||
|
print(f" {name:16} {ver or '?':9} {pct_s:>5} {dhcp:>4} {dns:6} {mgmt:15} {when[:10]}")
|
||||||
|
if COMMIT:
|
||||||
|
st, r = req("PATCH", f"/api/dcim/devices/{dev_id}/", {"custom_fields": vals})
|
||||||
|
if st not in (200, 201):
|
||||||
|
print(f" ! PATCH FEHLER {st}: {json.dumps(r)[:200]}")
|
||||||
|
|
||||||
|
print(f"\n== fertig ({'COMMIT — geschrieben' if COMMIT else 'DRY-RUN — nichts geschrieben'}) ==")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# TP23 Stufe 6 — PHASE A: Substrat + Exempt/JIT-Accepts + log-only-Vorstufe.
|
|
||||||
#
|
|
||||||
# 🔴🔴 NUR VOR ORT. Dieser Deploy allein bricht noch nichts (kein drop/reject),
|
|
||||||
# aber Phase B (der reject) macht .36/.37 zum DNS-SPOF ohne Fern-Rollback.
|
|
||||||
# Runbook: docs/tp23-stufe6-bypass-sperre-runbook.md.
|
|
||||||
#
|
|
||||||
# STATUS: ENTWURF, freigegeben 2026-09-11, NOCH NICHT DEPLOYT (Christoph war remote).
|
|
||||||
#
|
|
||||||
# ┌─ LIVE-VERIFY VOR DEM DEPLOY (nicht raten — MikroMCP war beim Schreiben tot) ─┐
|
|
||||||
# │ (1) ANKER <<EGRESS-ACCEPT>>: die forward-Regel, die HEUTE LAN->WAN-DNS zu │
|
|
||||||
# │ 8.8.8.8:53 durchlaesst (der generische Internet-Egress-Accept). Meine │
|
|
||||||
# │ Regeln muessen DAVOR stehen, sonst greift der Egress-Accept zuerst und │
|
|
||||||
# │ die log-only-Regel zaehlt 0. Anker aus der Live-Kette holen: │
|
|
||||||
# │ /ip firewall filter print where chain=forward │
|
|
||||||
# │ und unten das place-before-Kommentar ersetzen. Faellt externer DNS heute │
|
|
||||||
# │ schon in deny-by-default (TVs mit 8.8.8.8 loesen JETZT nicht auf), ist │
|
|
||||||
# │ Stufe 6 gegenstandslos — dann Runbook neu bewerten. │
|
|
||||||
# │ (2) EXEMPT-IPs: dev-MacBook ist dual-homed. Beide Beine gegen die Realitaet │
|
|
||||||
# │ pruefen (en5/en0 koennen per DHCP gewechselt haben): │
|
|
||||||
# │ /ip dhcp-server lease print where comment~"macbook" │
|
|
||||||
# │ und mit der AGH-Seite (clients.persistent 'dns-bypass-exempt') abgleichen.│
|
|
||||||
# └───────────────────────────────────────────────────────────────────────────┘
|
|
||||||
#
|
|
||||||
# Weg (KEIN mikromcp-Direkt-Write — ip-firewall-filter steht unter Drift-Kontrolle):
|
|
||||||
# Backup davor: create_backup routerId=crs326-core (core-pre-tp23-s6-<datum>.backup)
|
|
||||||
# Deploy: python3 nautobot/gc-manual-plan.py --device crs326-core \
|
|
||||||
# --commands-file routeros/tp23-stufe6-phaseA-exempt-log.rsc \
|
|
||||||
# --change-id TP23-S6-PhaseA --deploy
|
|
||||||
# log-only als befristeter Versuch fuehren (NICHT als Versehen lesbar machen):
|
|
||||||
# python3 nautobot/gc-transaktion.py (Transaktion 'TP23-S6-would-drop-dns')
|
|
||||||
# Soll-Sync (Substrat + Accepts sind Dauerzustand -> ins intended):
|
|
||||||
# python3 nautobot/ingest-core-firewall.py --apply
|
|
||||||
# python3 nautobot/gc-configplan.py --refresh-compliance
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 0) Substrat: erlaubte Resolver (dst), permanente Exempts (src), leere JIT-Liste.
|
|
||||||
:do { :if ([:len [/ip firewall address-list find list="dns-resolvers" address="10.40.20.36"]] = 0) do={ /ip firewall address-list add list=dns-resolvers address=10.40.20.36 comment="TP23-S6: AGH agh-bf1" } } on-error={ :log info "tp23s6A: 00a warf" }
|
|
||||||
:do { :if ([:len [/ip firewall address-list find list="dns-resolvers" address="10.40.20.37"]] = 0) do={ /ip firewall address-list add list=dns-resolvers address=10.40.20.37 comment="TP23-S6: AGH agh-bf2" } } on-error={ :log info "tp23s6A: 00b warf" }
|
|
||||||
:do { :if ([:len [/ip firewall address-list find list="dns-bypass-exempt" address="10.40.10.183"]] = 0) do={ /ip firewall address-list add list=dns-bypass-exempt address=10.40.10.183 comment="TP23-S6: dev-MacBook en5 (VERIFY)" } } on-error={ :log info "tp23s6A: 00c warf" }
|
|
||||||
:do { :if ([:len [/ip firewall address-list find list="dns-bypass-exempt" address="10.40.10.82"]] = 0) do={ /ip firewall address-list add list=dns-bypass-exempt address=10.40.10.82 comment="TP23-S6: dev-MacBook en0 (VERIFY)" } } on-error={ :log info "tp23s6A: 00d warf" }
|
|
||||||
|
|
||||||
# 1) Exempt-Accepts. dev-MacBook + Agent-Toolchain behalten ihren DNS-Ausweg.
|
|
||||||
# MUSS vor der log-only-Regel stehen (action=accept terminiert -> Exempt-Traffic
|
|
||||||
# erreicht die Log-Regel nie und taucht im WOULD-DROP-Log korrekt NICHT auf).
|
|
||||||
:do { :if ([:len [/ip firewall filter find comment="TP23-S6: dns-exempt udp"]] = 0) do={ /ip firewall filter add chain=forward action=accept src-address-list=dns-bypass-exempt protocol=udp dst-port=53,853 comment="TP23-S6: dns-exempt udp" place-before=[/ip firewall filter find comment="<<EGRESS-ACCEPT — LIVE VERIFY>>"] } } on-error={ :log info "tp23s6A: 01a warf" }
|
|
||||||
:do { :if ([:len [/ip firewall filter find comment="TP23-S6: dns-exempt tcp"]] = 0) do={ /ip firewall filter add chain=forward action=accept src-address-list=dns-bypass-exempt protocol=tcp dst-port=53,853 comment="TP23-S6: dns-exempt tcp" place-before=[/ip firewall filter find comment="<<EGRESS-ACCEPT — LIVE VERIFY>>"] } } on-error={ :log info "tp23s6A: 01b warf" }
|
|
||||||
|
|
||||||
# 2) JIT-Accepts. Leere, selbstverfallende Liste (Muster jit-admin, tp5-etappe4):
|
|
||||||
# /ip firewall address-list add list=jit-dns-bypass address=<ip> timeout=1h
|
|
||||||
:do { :if ([:len [/ip firewall filter find comment="TP23-S6: dns-jit udp"]] = 0) do={ /ip firewall filter add chain=forward action=accept src-address-list=jit-dns-bypass protocol=udp dst-port=53,853 comment="TP23-S6: dns-jit udp" place-before=[/ip firewall filter find comment="<<EGRESS-ACCEPT — LIVE VERIFY>>"] } } on-error={ :log info "tp23s6A: 02a warf" }
|
|
||||||
:do { :if ([:len [/ip firewall filter find comment="TP23-S6: dns-jit tcp"]] = 0) do={ /ip firewall filter add chain=forward action=accept src-address-list=jit-dns-bypass protocol=tcp dst-port=53,853 comment="TP23-S6: dns-jit tcp" place-before=[/ip firewall filter find comment="<<EGRESS-ACCEPT — LIVE VERIFY>>"] } } on-error={ :log info "tp23s6A: 02b warf" }
|
|
||||||
|
|
||||||
# 3) log-only-Vorstufe. Loggt LAN->extern DNS, das NICHT zu einem erlaubten
|
|
||||||
# Resolver geht. dst-address-list=!dns-resolvers negiert die Liste. Mehrere
|
|
||||||
# Tage lesen (Kollektor 10.40.20.18:514), dann Phase B. NUR 53/853-Zeilen zaehlen.
|
|
||||||
:do { :if ([:len [/ip firewall filter find comment="TP23-S6: WOULD-DROP-DNS udp"]] = 0) do={ /ip firewall filter add chain=forward action=log log-prefix="TP5-WOULD-DROP-DNS" protocol=udp dst-port=53,853 dst-address-list=!dns-resolvers comment="TP23-S6: WOULD-DROP-DNS udp" place-before=[/ip firewall filter find comment="<<EGRESS-ACCEPT — LIVE VERIFY>>"] } } on-error={ :log info "tp23s6A: 03a warf" }
|
|
||||||
:do { :if ([:len [/ip firewall filter find comment="TP23-S6: WOULD-DROP-DNS tcp"]] = 0) do={ /ip firewall filter add chain=forward action=log log-prefix="TP5-WOULD-DROP-DNS" protocol=tcp dst-port=53,853 dst-address-list=!dns-resolvers comment="TP23-S6: WOULD-DROP-DNS tcp" place-before=[/ip firewall filter find comment="<<EGRESS-ACCEPT — LIVE VERIFY>>"] } } on-error={ :log info "tp23s6A: 03b warf" }
|
|
||||||
|
|
||||||
:log info ("tp23s6A: ENDE exempt-udp=" . [:tostr [:len [/ip firewall filter find comment="TP23-S6: dns-exempt udp"]]] . " log-udp=" . [:tostr [:len [/ip firewall filter find comment="TP23-S6: WOULD-DROP-DNS udp"]]] . " resolvers=" . [:tostr [:len [/ip firewall address-list find list="dns-resolvers"]]] . " exempt=" . [:tostr [:len [/ip firewall address-list find list="dns-bypass-exempt"]]])
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# TP23 Stufe 6 — PHASE B: der scharfe Schnitt (reject) + Auto-Revert.
|
|
||||||
#
|
|
||||||
# 🔴🔴 NUR VOR ORT, mit offener Konsole. Erst NACHDEM das WOULD-DROP-DNS-Log
|
|
||||||
# (Phase A) mehrere Tage ruhig/verstanden ist und jeder externe Resolver
|
|
||||||
# entschieden wurde (Reservierung auf AGH / Exempt / bewusst brechen lassen).
|
|
||||||
# AB HIER ist .36/.37 ein DNS-SPOF ohne Fern-Rollback (Runbook, Gate).
|
|
||||||
#
|
|
||||||
# STATUS: ENTWURF, freigegeben 2026-09-11, NOCH NICHT DEPLOYT.
|
|
||||||
#
|
|
||||||
# WARUM reject STATT drop: reject icmp-port-unreachable laesst Clients schneller
|
|
||||||
# auf den naechsten (erlaubten) Resolver fallen, statt in einen Timeout zu laufen.
|
|
||||||
#
|
|
||||||
# WARUM die Log-Regel STEHEN BLEIBT: action=log terminiert NICHT — das Paket wird
|
|
||||||
# geloggt und faellt dann in die reject-Regel darunter. Gleiche Doktrin wie
|
|
||||||
# tp5-etappe3-drop (Log VOR Drop), der Kollektor zeigt weiter, was reject frisst.
|
|
||||||
#
|
|
||||||
# Weg (wie Phase A, ueber Nautobot GC):
|
|
||||||
# Deploy: python3 nautobot/gc-manual-plan.py --device crs326-core \
|
|
||||||
# --commands-file routeros/tp23-stufe6-phaseB-reject.rsc \
|
|
||||||
# --change-id TP23-S6-PhaseB --deploy
|
|
||||||
# Danach (reject ist jetzt Dauerzustand -> Soll, log-only-Transaktion schliessen):
|
|
||||||
# python3 nautobot/ingest-core-firewall.py --apply
|
|
||||||
# python3 nautobot/gc-configplan.py --refresh-compliance # Ziel gruen
|
|
||||||
#
|
|
||||||
# Rollback sofort (Vor-Ort-Konsole): /ip firewall filter set [find comment~"TP23-S6: reject"] disabled=yes
|
|
||||||
# -> Clients koennen sofort wieder externe Resolver nutzen. Bzw. Auto-Revert unten.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 0) Auto-Revert ZUERST — deaktiviert die reject-Regeln nach 10m, falls nicht
|
|
||||||
# bestaetigt (analog tp5-etappe3/4). Nach der Abnahme abbestellen:
|
|
||||||
# /system scheduler remove [find name="tp23s6autorevert"]
|
|
||||||
:do { :if ([:len [/system scheduler find name="tp23s6autorevert"]] = 0) do={ /system scheduler add name="tp23s6autorevert" interval=10m on-event="/ip firewall filter set [find comment~\"TP23-S6: reject\"] disabled=yes; /system scheduler remove [find name=\"tp23s6autorevert\"]; :log warning \"tp23s6: AUTO-REVERT hat die DNS-reject-Regeln abgeschaltet\"" } } on-error={ :log info "tp23s6B: 00 scheduler warf" }
|
|
||||||
|
|
||||||
# 1) reject. Gleiche Match-Tupel wie die WOULD-DROP-DNS-Log-Regeln, direkt
|
|
||||||
# dahinter (place-before denselben Egress-Anker -> landet nach den Log-Regeln).
|
|
||||||
# <<EGRESS-ACCEPT>>: derselbe live-verifizierte Anker wie in Phase A.
|
|
||||||
:do { :if ([:len [/ip firewall filter find comment="TP23-S6: reject dns udp"]] = 0) do={ /ip firewall filter add chain=forward action=reject reject-with=icmp-port-unreachable protocol=udp dst-port=53,853 dst-address-list=!dns-resolvers comment="TP23-S6: reject dns udp" place-before=[/ip firewall filter find comment="<<EGRESS-ACCEPT — LIVE VERIFY>>"] } } on-error={ :log info "tp23s6B: 01a warf" }
|
|
||||||
:do { :if ([:len [/ip firewall filter find comment="TP23-S6: reject dns tcp"]] = 0) do={ /ip firewall filter add chain=forward action=reject reject-with=icmp-port-unreachable protocol=tcp dst-port=53,853 dst-address-list=!dns-resolvers comment="TP23-S6: reject dns tcp" place-before=[/ip firewall filter find comment="<<EGRESS-ACCEPT — LIVE VERIFY>>"] } } on-error={ :log info "tp23s6B: 01b warf" }
|
|
||||||
|
|
||||||
:log info ("tp23s6B: ENDE reject-udp=" . [:tostr [:len [/ip firewall filter find comment="TP23-S6: reject dns udp"]]] . " reject-tcp=" . [:tostr [:len [/ip firewall filter find comment="TP23-S6: reject dns tcp"]]] . " autorevert=" . [:tostr [:len [/system scheduler find name="tp23s6autorevert"]]])
|
|
||||||
Reference in New Issue
Block a user