Initial commit: BF Core-Switch-Prep + VLAN-Architektur + Delegations-Übergabe

- ALL-SG8316M auf FW 2.2.1.2956 geflasht; VLAN-DB 10/20/30/40/100 via Telnet-CLI (non-disruptiv)
- CLI-Tooling: run-cli.exp + .cli-Files + CLI-REFERENCE (FW 2.2.1, IOS-artig)
- Kanonisches Inventar: bf.csv/md + build.py (Doku-Motor), Core-Port-Map
- Finaler VLAN-/Adressplan (10.40.<vlanid>.0/24), Firmware-Runbooks, Recovery-Assets
- Secrets/Firmware/State/DS_Store via .gitignore ausgeschlossen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Christoph Emsenhuber
2026-06-09 18:08:45 +02:00
commit 498fa06472
25 changed files with 1474 additions and 0 deletions

144
inventory/build.py Normal file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Kanonischer Doku-Generator für das BF-Inventar.
Wahrheit = CSV. Markdown wird daraus generiert.
- `build.py --seed` : (einmalig) bf.csv aus IPs.xlsx erzeugen (mit Transform/Deltas)
- `build.py` : bf.md aus bf.csv + bf-core-ports.md aus bf-core-ports.csv rendern
Künftige Änderungen: bf.csv editieren -> `python3 build.py` -> bf.md aktuell.
"""
import sys, os, re, csv, zipfile
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
XLSX = os.path.join(HERE, 'IPs.xlsx')
BF_CSV = os.path.join(HERE, 'bf.csv')
BF_MD = os.path.join(HERE, 'bf.md')
PORTS_CSV = os.path.join(HERE, 'bf-core-ports.csv')
PORTS_MD = os.path.join(HERE, 'bf-core-ports.md')
IPRE = re.compile(r'^\d{1,3}(\.\d{1,3}){3}$')
MACRE = re.compile(r'^[0-9A-Fa-f]{1,2}(:[0-9A-Fa-f]{1,2}){5}$')
COLS = ['section', 'ip', 'hostname', 'mac', 'pve_ct', 'server_ip', 'server_mac', 'note']
def _strip(t): return re.sub(r'\{[^}]*\}', '', t)
def read_xlsx_sheet(path, sheet):
z = zipfile.ZipFile(path)
shared = []
if 'xl/sharedStrings.xml' in z.namelist():
for si in ET.fromstring(z.read('xl/sharedStrings.xml')):
shared.append(''.join(t.text or '' for t in si.iter() if _strip(t.tag) == 't'))
wb = ET.fromstring(z.read('xl/workbook.xml'))
rels = {r.get('Id'): r.get('Target') for r in ET.fromstring(z.read('xl/_rels/workbook.xml.rels'))}
tgt = None
for s in wb.iter():
if _strip(s.tag) == 'sheet' and s.get('name') == sheet:
rid = [v for k, v in s.attrib.items() if k.endswith('}id') or k == 'id'][0]
t = rels[rid]; tgt = t if t.startswith('xl/') else 'xl/' + t
ws = ET.fromstring(z.read(tgt))
def colnum(ref):
m = re.match(r'([A-Z]+)(\d+)', ref)
if not m: return 0, 0
c = 0
for ch in m.group(1): c = c * 26 + (ord(ch) - 64)
return c, int(m.group(2))
cells = {}; maxc = 0; maxr = 0
for c in ws.iter():
if _strip(c.tag) != 'c': continue
ref = c.get('r') or ''; t = c.get('t'); col, row = colnum(ref)
if not col: continue
vs = [x for x in c if _strip(x.tag) == 'v']; iss = [x for x in c if _strip(x.tag) == 'is']
val = ''
if t == 's' and vs:
try: val = shared[int(vs[0].text)]
except Exception: val = vs[0].text or ''
elif iss: val = ''.join(x.text or '' for x in iss[0].iter() if _strip(x.tag) == 't')
elif vs: val = vs[0].text or ''
if val != '':
cells.setdefault(row, {})[col] = val; maxc = max(maxc, col); maxr = max(maxr, row)
grid = [[cells.get(r, {}).get(c, '') for c in range(1, maxc + 1)] for r in range(1, maxr + 1)]
return grid
def seed_bf_csv():
grid = read_xlsx_sheet(XLSX, 'bf')
out = []
section = ''
for r in grid[1:]: # skip header row
nz = [x for x in r if x.strip()]
if not nz:
continue
# cols: A(0) empty | B(1) ip/section | C(2) host | D(3) mac | E(4) ct | F(5) sip | G(6) smac | H(7) note | I(8) note2
def g(i): return r[i].strip() if i < len(r) else ''
ipcell = g(1)
# section header row = single non-empty cell, not an IP
if len(nz) == 1 and not IPRE.match(nz[0].strip()):
section = nz[0].strip(); continue
ip = ipcell if IPRE.match(ipcell) else ''
host = g(2); mac = g(3) if MACRE.match(g(3)) else ''
ct = g(4); sip = g(5); smac = g(6)
notes = [g(7), g(8)]
# defensive: misplaced data -> note
if g(3) and not MACRE.match(g(3)): notes.append('mac?=' + g(3))
if ct and not ct.isdigit(): notes.append(ct); ct = ''
if sip and not IPRE.match(sip): notes.append(sip); sip = ''
if smac and not MACRE.match(smac): notes.append(smac); smac = ''
note = ' / '.join(n for n in notes if n.strip())
# --- deltas (User 2026-06-05) ---
if host == 'VPNjoernfuenfstueck':
continue # gekickt
if host == 'VPNchristoph':
note = 'Fallback bis CF One überall läuft; IP umziehbar/unwichtig'
out.append([section, ip, host, mac, ct, sip, smac, note])
with open(BF_CSV, 'w', newline='') as f:
w = csv.writer(f); w.writerow(COLS); w.writerows(out)
print(f'seed: wrote {BF_CSV} ({len(out)} rows)')
def render_bf_md():
with open(BF_CSV) as f:
rows = list(csv.DictReader(f))
lines = ['# Inventar — Standort BF (kanonisch)', '',
'> Generiert aus `bf.csv` via `build.py`. **Quelle der Wahrheit = bf.csv.** Seed: `IPs.xlsx`.',
'> ⚠️ Manche `pve_ct`/Einträge können Doku-Leichen sein (z.B. re-dockerisierte Container).', '']
cur = None
cols = ['ip', 'hostname', 'mac', 'pve_ct', 'server_ip', 'server_mac', 'note']
for row in rows:
if row['section'] != cur:
cur = row['section']
lines += ['', f'## {cur or "(ohne Sektion)"}', '',
'| ' + ' | '.join(cols) + ' |', '|' + '|'.join(['---'] * len(cols)) + '|']
if not any(row[c].strip() for c in cols):
continue
lines.append('| ' + ' | '.join(row[c].replace('|', '\\|') for c in cols) + ' |')
with open(BF_MD, 'w') as f:
f.write('\n'.join(lines) + '\n')
print(f'wrote {BF_MD} ({len(rows)} rows)')
def render_ports_md():
if not os.path.exists(PORTS_CSV):
return
with open(PORTS_CSV) as f:
rows = list(csv.DictReader(f))
cols = list(rows[0].keys()) if rows else []
lines = ['# Core-Switch ALL-SG8316M (sw1, 10.40.41.239) — Port-Map', '',
'> Ist-Belegung. Ports 4/8/15/16 **behalten** (4+8 interim bis neue Switches produktiv).',
'> Übrige Ports (1–3, 5–7, 9–14): noch nicht erfasst — gemeinsam ergänzen.', '',
'| ' + ' | '.join(cols) + ' |', '|' + '|'.join(['---'] * len(cols)) + '|']
for r in rows:
lines.append('| ' + ' | '.join((r[c] or '').replace('|', '\\|') for c in cols) + ' |')
with open(PORTS_MD, 'w') as f:
f.write('\n'.join(lines) + '\n')
print(f'wrote {PORTS_MD} ({len(rows)} ports)')
if __name__ == '__main__':
if '--seed' in sys.argv or not os.path.exists(BF_CSV):
seed_bf_csv()
render_bf_md()
render_ports_md()