Initial import from bf-network-r6.opencode — opencode migration

This commit is contained in:
2026-08-26 12:44:21 +02:00
commit 5ee0716dda
297 changed files with 72324 additions and 0 deletions

5
mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# Secrets & runtime — niemals committen
**/.env
**/config/identities.yaml
**/config/audit.ndjson
# routers.yaml ist OK im Git (keine Secrets, nur Hosts); identities.yaml NICHT (Token-Hashes).

42
mcp/INVENTORY.md Normal file
View File

@@ -0,0 +1,42 @@
# MCP-Inventar — BF (Single Source of Truth gegen "1000 MCP-Leichen")
> **Regel:** Jeder MCP-Server, der für BF läuft, steht **hier** — sonst existiert er nicht.
> Kein lokaler Ad-hoc-`npx`-Wildwuchs auf Laptops. Zentral = Docker auf einem Host, FQDN-adressiert.
> Wer einen MCP abschaltet, **streicht die Zeile** (oder setzt Status `retired` mit Datum).
>
> **Richtungsentscheid (User, 2026-07-11):** MCP-Dienste gehören grundsätzlich auf **zentrale
> Serverdienste, nicht auf den Client**. Die aktuell **Client-lokal** laufenden (MikroMCP stdio,
> shelly-mcp `:3030`) sind bewusste **pragmatische Interims** ("Hauptsache es funktioniert") und
> werden **zu einem späteren Zeitpunkt zentral zusammengezogen** (ein Host, Container, FQDN) — Teil
> der Control-Plane-Konsolidierung (vgl. TP11 / Komodo-Migration).
## Aktive / geplante MCPs
| MCP | Zweck / Geräte | Host | Transport / Endpoint | Image | Config | Status |
|---|---|---|---|---|---|---|
| **MikroMCP** (AliKarami) | MikroTik hAP ax² ap1/ap2 (RouterOS 7) — WLAN/CAPsMAN, später Router-Cutover | **jetzt: lokal (dieser Mac)** / Ziel: Mac mini `.167` Docker | jetzt **stdio** (`npx mikromcp serve`) / Ziel HTTP `:3000` | `ghcr.io/alikarami/mikromcp:latest` (HTTP) bzw. npm `mikromcp` (stdio) | `mcp/mikromcp/` + Claude-Code user-config | 🟢 **live (stdio), doctor 12/12, in Claude Code ✓Connected** |
| **shelly-mcp** (eigenbau, FastMCP) | ~26 Shellys Gen3/4 — VLAN1→VLAN30-Migration (TP17) + Verwaltung | **jetzt: lokal (dieser Mac) `:3030`** / Ziel: zentral (Container) | **StreamableHTTP** `http://127.0.0.1:3030/mcp` (+ Status `/`, Health `/health`) | eigenbau `mcp/shelly/server.py` | `mcp/shelly/` ✓ (`.env` PSK) | 🟢 **live (Interim, Client-lokal). Tools: discover/get/set_sta[guarded]/verify/reservation_plan** |
| **pve-mcp** (samik081) | Proxmox VE API (Host `.100`) | DS923 (`.10`), macvlan-IP **`.173`** | SSE `http://10.40.41.173:3000/sse` | `ghcr.io/samik081/mcp-pve:latest` | — (Stack via Portainer) | 🟢 **live, in Claude Code eingetragen** |
| **omada-mcp** (jmtvms v0.15) | TP-Link Omada Controller → ES208G (+ künftige Omada-Geräte) | **pve-LXC (`.126`, endpointId=2)**, ipvlan-IP **`.176`** | **StreamableHTTP** `http://10.40.41.176:3000/mcp` (Health `/health`) | `jmtvms/tplink-omada-mcp:latest` | `mcp/omada/` ✓, settings.json ✓ | 🟢 **live (Up, /health 200, MCP-Handshake ✓). Auth = Omada Open API Client-Credentials. Tools nach Claude-Code-Reload.** |
| **omada-controller** | Omada Software Controller (keine MCP) | DS923 (`.10`), macvlan-IP **`.175`** | HTTPS `https://10.40.41.175:8043` | `mbentley/omada-controller:6.2` | `omada/docker-compose.yml` | 🟢 **live, configured, Wizard ✅** |
| **komodo-mcp** (samik081) | Docker-Management **5 Hosts** via Komodo Core (ersetzt Portainer) | DS923 (`.10`), macvlan-IP **`.176`** | SSE `http://10.40.41.176:3000/sse` | `ghcr.io/samik081/mcp-komodo:latest` | `mcp/komodo/` ✓ (`.env.example` → `.env` nach Phase 1) | 🟡 **geplant — Migration: `mcp/komodo/MIGRATION.md`** |
| ~~portainer-mcp~~ (offiziell) | Docker-Überblick via Portainer CE 2.40.0 | `.126:9443` | — | `portainer/portainer-mcp` | `mcp/portainer/` (Token .env ✓) | ⏳ **retired nach Komodo-Migration — Token aufheben bis Phase 4** |
| ~~ALLNET-MCP~~ | ALLNET ALL-SG8316M Core-Switch | — | — | — | — | ⛔ **bewusst NICHT** (Telnet/1-VTY/15-min-Lock auf Core = zu fragil). User-Entscheid: erst beschriebener Weg, sonst Omada-Core-Tausch. |
Legende: 🟢 live · 🟡 staged (Config da, nicht deployed) · 🔴 blockiert · ⛔ bewusst nicht gemacht
## Anti-Sprawl-Regeln
1. **Zentral statt lokal:** MCPs laufen als Container auf einem festen Host, nicht als `npx` pro Laptop.
2. **Eine Zeile pro MCP** in dieser Tabelle. Kein Eintrag → abschalten.
3. **Config im Git** (`mcp/<name>/`), Secrets nur in `.env` (gitignored).
4. **FQDN statt IP**, sobald eingerichtet (DNS-Rewrite) → Endpoint-Spalte updaten.
5. **Review-Datum:** mind. halbjährlich diese Tabelle gegen tatsächlich laufende Container prüfen
(`docker ps` auf den Hosts).
## ALLNET — warum kein MCP
ALL-SG8316M (Core) nach FW-2.2.1-Flash: **Web-UI tot, nur Telnet-CLI, kein SSH/REST/SNMP-Write,
nur 1 VTY-Line, ~15 min Busy-Lock bei Stale-Sessions.** Ein MCP darüber wäre auf dem **Core-Switch**
(an dem alles hängt) fragil und lockout-gefährlich. → Management bleibt bei der bestehenden
Telnet-CLI-Toolchain (`switch/cli/`). **Strategische Option:** Core-Switch (Methusalem, Bj. 2015)
gegen einen **Omada-Switch** tauschen → fällt unter denselben Omada-Controller/omada-mcp.
Das ist Hardware-Ausgabe + Core-Cutover → **User-Entscheidung** (offen).

49
mcp/README.md Normal file
View File

@@ -0,0 +1,49 @@
# MCP-Control-Plane — BF
Zentrale, versionierte MCP-Server für BF. Übersicht aller MCPs: **[INVENTORY.md](INVENTORY.md)**.
Ziel: ein nachvollziehbarer Satz MCPs (gegen "1000 MCP-Leichen"), zentral als Container, FQDN-adressiert.
## MikroMCP (MikroTik ap1/ap2)
REST-primär + guarded SSH; RouterOS-7-nativ; Safety: dry-run, `plan_changes`/`apply_plan`/`rollback_change`,
Confirmation-Tokens, RBAC, Audit-Log. Image: `ghcr.io/alikarami/mikromcp:latest`.
### Zwei Betriebsarten
- **A — lokal stdio (schnell, kein Netz-Exposure):** läuft dort wo Claude läuft, RBAC = built-in superadmin,
keine Tokens/Secret nötig. Gut für sofortige WLAN-Arbeit. Trotzdem in INVENTORY eintragen.
- **B — Docker HTTP auf Mac mini (zentral, Ziel):** `docker-compose.yml`. Erfordert Confirmation-Secret +
Bearer-Token-Identität. Endpoint später hinter NPM (FQDN+Auth).
### RouterOS-User (beide APs) — NICHT admin verwenden
```
/user group add name=mcp policy=read,write,api,rest-api,test,ssh,sniff,!ftp,!policy,!password
/user add name=mcp-api group=mcp password=<stark>
```
(`ftp` nur falls `upload_file` gebraucht wird; `sniff` nur für `torch`. SSH-Dienst muss laufen für
ping/traceroute/torch/run_command — REST blockt diese Befehle generell.)
### B deployen (Mac mini 10.40.41.167)
```
openssl rand -hex 32 # -> MIKROMCP_CONFIRMATION_SECRET
# identities.yaml aus identities.example.yaml bauen (Token: openssl rand -hex 32, dann bcrypt-Hash):
node -e "const b=require('bcryptjs');b.hash(process.argv[1],12).then(console.log)" <raw-token>
scp -r mcp/mikromcp 10.40.41.167:/opt/docker/mikromcp
ssh 10.40.41.167 'cd /opt/docker/mikromcp && cp .env.example .env' # .env + config/identities.yaml füllen
ssh 10.40.41.167 'cd /opt/docker/mikromcp && docker compose up -d && docker compose logs --tail=50'
```
Smoke-Test: `curl -s http://10.40.41.167:3000/health` bzw. MCP-Initialize mit Bearer-Token.
### In Claude Code einhängen
- **A (stdio):** `claude mcp add mikromcp -- npx -y mikromcp` + ENV (`MIKROMCP_CONFIG_PATH`, `ROUTER_AP1_USER/PASS`, …).
- **B (http):** `claude mcp add --transport http mikromcp http://10.40.41.167:3000` (Header `Authorization: Bearer <raw-token>`).
Nach FQDN: URL auf `https://<fqdn>` umstellen.
## omada-mcp (TP-Link ES208G)
Wartet auf: Omada-Controller (≥6.1.x, `../../omada/docker-compose.yml`, Ziel pve-LXC 10.40.41.126) **up** +
ES208G **adoptiert**. Env: `OMADA_BASE_URL`, `OMADA_CLIENT_ID/SECRET`, `OMADA_OMADAC_ID`, `OMADA_SITE_ID`,
`OMADA_STRICT_SSL=false` (self-signed). Tools: Switch-Ports/VLAN/Firewall CRUD. Details: INVENTORY.md.
## Sicherheit (wichtig)
Ein MikroMCP/omada-mcp-HTTP-Endpoint kann Infrastruktur administrieren. Daher: nur auf LAN-IP binden,
dedizierte Nicht-admin-User, Confirmation-Secret gesetzt, Audit-Log an, später NPM+Auth+TLS+FQDN davor.

View File

@@ -0,0 +1,22 @@
# Cloudflare scoped API-Token — Vorlage. Echte Werte → .env (gitignored via mcp/**/.env)
# Token-Scopes (least-privilege): Cloudflare Tunnel:Edit, DNS:Edit (Zone emsenhuber.net),
# Access: Apps and Policies:Edit, Access: Organizations, Identity Providers, and Groups:Edit
CF_API_TOKEN=
# Feste IDs (kein Secret, hier als Referenz):
CF_ACCOUNT_ID=393c051f3accf51881d6c98fc5b66c36
CF_ZONE_ID=1b5bc02fdf61b6b989afde543f3f4134
CF_BF_TUNNEL_ID=70d6a4cf-aa1d-4a91-b918-91fab4fa7270
# --- OAuth-App-Secrets für Access-IdPs (von den jeweiligen Provider-Konsolen) ---
# Callback-URL überall: https://emsenhuber.cloudflareaccess.com/cdn-cgi/access/callback
GITHUB_CLIENT_ID=Ov23li2h96hAkGRPJXYV
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
FACEBOOK_APP_ID=
FACEBOOK_APP_SECRET=
# --- Geli ---
GELI_EMAIL=
GELI_PUBLIC_IP= # optional, /32 — nur falls stabile öffentliche IPv4 (bei CGNAT leer lassen)

38
mcp/cloudflare/README.md Normal file
View File

@@ -0,0 +1,38 @@
# Cloudflare — scoped API-Token (Writes) + OAuth-IdP-Secrets
Cloudflare wird wie jeder andere Dienst im Repo über einen **scoped API-Token** geschrieben
(Konvention wie `mcp/portainer`, `mcp/omada`). Die `cloudflare`-OAuth-MCP in Claude Code ist
**read-only** (Consent bietet keinen Write-Scope) → nur zum Lesen.
Plan/Kontext: `~/.claude/plans/schei-e-wenn-ich-das-breezy-biscuit.md` · SSoT: `docs/cf-tunnel-mapping.md`.
## 1. API-Token anlegen
dash.cloudflare.com → **My Profile → API Tokens → Create Token → Create Custom Token**
- **Permissions:**
- Account · Cloudflare Tunnel · **Edit**
- Zone · DNS · **Edit**
- Account · Access: Apps and Policies · **Edit**
- Account · Access: Organizations, Identity Providers, and Groups · **Edit**
- **Account Resources:** Include → Christoph@emsenhuber.com's Account
- **Zone Resources:** Include → Specific zone → emsenhuber.net
- Create → Token kopieren → in `.env` als `CF_API_TOKEN`.
## 2. OAuth-Apps für Access-Login (Callback überall gleich)
**Callback-URL:** `https://emsenhuber.cloudflareaccess.com/cdn-cgi/access/callback`
- **GitHub** (reparieren — Secret fehlt): github.com → Settings → Developer settings → OAuth Apps
→ bestehende App (client_id `Ov23li2h96hAkGRPJXYV`) → **Generate a new client secret** →
Authorization callback URL = obige Callback-URL → Secret in `.env` (`GITHUB_CLIENT_SECRET`).
- **Google:** console.cloud.google.com → APIs & Services → Credentials → Create OAuth client ID →
Web application → Authorized redirect URI = Callback-URL → client_id/secret in `.env`.
- **Facebook:** developers.facebook.com → Create App → Facebook Login → Valid OAuth Redirect URI =
Callback-URL → App-ID/Secret in `.env`.
## 3. Geli
`GELI_EMAIL` (für Social-Login-Policy). `GELI_PUBLIC_IP` nur falls stabile öffentliche IPv4 (CGNAT → leer).
## 4. Ausführung
Writes laufen per `curl` gegen `api.cloudflare.com` mit `Authorization: Bearer $CF_API_TOKEN`
(`set -a; . mcp/cloudflare/.env; set +a`). Claude wendet die Change-Sets aus dem Plan an und
verifiziert jeden Schritt extern (HTTP-Status, kein 1033). Reihenfolge: Token-Verify → Track A
(Tunnel) → Track B (Access).

16
mcp/komodo/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Komodo Secrets — Vorlage (gitignored: .env)
# Kopieren nach .env und befüllen
# Core (core-compose.yml)
KOMODO_HOST=http://10.40.41.126:9120
KOMODO_DB_PASSWORD=<openssl rand -hex 24>
KOMODO_JWT_SECRET=<openssl rand -hex 32>
KOMODO_PASSKEY=<openssl rand -hex 16>
# komodo-mcp (komodo-mcp-compose.yml) — nach Phase 1 befüllen
KOMODO_URL=http://10.40.41.126:9120
KOMODO_API_KEY=
KOMODO_API_SECRET=
MCP_TRANSPORT=http
PORT=3000
ACCESS_LEVEL=read-execute

255
mcp/komodo/MIGRATION.md Normal file
View File

@@ -0,0 +1,255 @@
# Portainer → Komodo Migration
**Entscheidung (2026-06-15):** Portainer CE 2.40.0 wird durch [Komodo](https://komo.do) ersetzt.
- Portainer-MCP braucht 2.41/2.42 (kein Match für 2.40) → Blocker für portainer-mcp
- Komodo hat nativen MCP-Server ([Samik081/mcp-komodo](https://github.com/Samik081/mcp-komodo), selber Autor wie pve-mcp)
- Komodo = modernes Portainer-Äquivalent mit Git-Deploy, Prozeduren, Monitoring
---
## Architektur-Übersicht
```
Claude Code (Mac)
└── komodo-mcp (.176, SSE) ── Komodo Core (.126:9120) ──┬── Periphery auf .126 (self)
└── MongoDB ├── Periphery auf .10 (DS923)
├── Periphery auf .7 (odroid)
├── Periphery auf .167 (macmini)
└── Periphery auf nas-fm
```
**Komodo besteht aus zwei Teilen:**
- **Core** — Web-UI + API + Orchestrierung (läuft auf .126, Port 9120)
- **Periphery** — leichter Agent auf jedem verwalteten Host (managed Docker direkt lokal)
- **mcp-komodo** — MCP-Adapter zwischen Claude Code und Komodo-API
---
## Phase 0 — Vorbereitung: Portainer-Inventar exportieren
Bevor Portainer abgeschaltet wird, alle Stacks + Endpunkte dokumentieren:
```bash
# API-Token aus mcp/portainer/.env
source mcp/portainer/.env
# Endpunkte (= die 5 Hosts)
curl -sk "$PORTAINER_URL/api/endpoints" \
-H "X-API-Key: $PORTAINER_API_KEY" | jq '.[] | {id, name, URL}'
# Alle Stacks (pro Endpoint)
curl -sk "$PORTAINER_URL/api/stacks" \
-H "X-API-Key: $PORTAINER_API_KEY" | jq '.[] | {Id, Name, EndpointId}'
# Stack-Compose je Stack (Beispiel Stack-ID 1):
curl -sk "$PORTAINER_URL/api/stacks/1/file" \
-H "X-API-Key: $PORTAINER_API_KEY" | jq -r '.StackFileContent'
```
Bekannte Portainer-Endpunkte:
| endpointId | Host | IP |
|---|---|---|
| (lokal) | pve-LXC | 10.40.41.126 |
| 14 | nas-bf (DS923+) | 10.40.41.10 |
| ? | odroid-m1 | 10.40.41.7 |
| ? | macmini | 10.40.41.167 |
| ? | nas-fm (DS415+) | (IP prüfen) |
---
## Phase 1 — Komodo Core deployen (auf pve-LXC, .126)
Komodo Core ersetzt Portainer auf demselben Host. Portainer läuft während der Migration parallel
(Port 9443 ≠ Port 9120). Erst nach Abschluss von Phase 4 wird Portainer gestoppt.
### 1a — Secrets anlegen
```bash
# Datei: mcp/komodo/.env (gitignored via mcp/.gitignore)
KOMODO_HOST=http://10.40.41.126:9120
KOMODO_DB_PASSWORD=<generate: openssl rand -hex 24>
KOMODO_JWT_SECRET=<generate: openssl rand -hex 32>
KOMODO_PASSKEY=<generate: openssl rand -hex 16>
# Nach Core-Start befüllen:
# KOMODO_API_KEY=...
# KOMODO_API_SECRET=...
```
### 1b — Docker Compose für Core
Datei: `mcp/komodo/core-compose.yml` (liegt neben dieser Datei)
Deployment via Portainer endpointId=lokal (.126 selbst) **oder** direkt per SSH:
```bash
ssh <pve-lxc>
mkdir -p /opt/komodo && cd /opt/komodo
# compose + .env hochladen / eintragen
docker compose -f core-compose.yml up -d
```
UI erreichbar: `http://10.40.41.126:9120`
### 1c — Initialer Admin-User
Beim ersten Öffnen des UI → Admin-Account anlegen → danach:
`Settings → API Keys → neues Key-Paar generieren` → in `.env` eintragen.
---
## Phase 2 — Periphery-Agent auf den 5 Hosts
Komodo Core managt seinen eigenen Docker-Host (pve-LXC) nativ über `localhost`.
Für alle weiteren Hosts muss ein Periphery-Agent installiert werden.
### Methode A — Docker Container (bevorzugt, wo Docker schon läuft)
```yaml
# periphery.compose.yml (auf dem jeweiligen Host ablegen, z.B. /opt/komodo/)
services:
periphery:
image: ghcr.io/moghtech/komodo-periphery:latest
restart: unless-stopped
network_mode: host # muss direkt erreichbar sein
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /etc/komodo:/etc/komodo # periphery root (compose-Dateien landen hier)
environment:
PERIPHERY_ALLOWED_IPS: 10.40.41.126 # nur Core darf reden
PERIPHERY_PORT: 8120
```
### Methode B — systemd-Binary (für Hosts ohne Docker oder wenn Methode A nicht passt)
```bash
# Einzeiler vom Komodo-Onboarding-Wizard (im UI generiert):
curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.sh \
| bash -s -- --core-addr http://10.40.41.126:9120 \
--name <hostname> \
--passkey <KOMODO_PASSKEY>
```
### Host-Matrix
| Host | IP | Periphery-Methode | Besonderheit |
|---|---|---|---|
| pve-LXC | 10.40.41.126 | **lokal** (Core selbst) | kein Agent nötig — Core = erster Server |
| DS923 | 10.40.41.10 | Docker Container | Portainer endpointId=14 → Bootstrap via Portainer API |
| odroid-m1 | 10.40.41.7 | Docker Container | IP prüfen; Docker vorhanden |
| macmini | 10.40.41.167 | Docker Container o. systemd | `ssh macmini` → compose up |
| nas-fm | (IP prüfen) | Docker Container | IP + SSH-Zugang vorher klären |
**DS923-Bootstrap (einmalig, vor Phase 3):**
```bash
source mcp/portainer/.env
# periphery.compose.yml via Portainer API auf DS923 deployen:
curl -sk -X POST "$PORTAINER_URL/api/stacks/create/standalone/string?endpointId=14" \
-H "X-API-Key: $PORTAINER_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"name\":\"komodo-periphery\",\"stackFileContent\":\"$(cat mcp/komodo/periphery.compose.yml | jq -Rs .)\"}"
```
---
## Phase 3 — Stacks in Komodo eintragen
Nach Phase 1+2: alle vorhandenen Stacks aus Phase 0 in Komodo nachpflegen.
**Komodo Stack-Konzept:**
- UI → `Stacks → + New Stack`
- Compose-Inhalt: direkt im UI editieren **oder** aus Git-Repo (Webhook-Auto-Deploy)
- Komodo schreibt die Compose-Datei zur Deploy-Zeit auf den Host ins Periphery-Root
Bestehende Stacks (nach Portainer-Export bekannt):
- omada-controller (DS923, `.175`)
- omada-mcp (DS923, `.174`) — noch nicht deployed, wird direkt über Komodo erstdeployed
- pve-mcp (DS923, `.173`) — laufend, nahtlos umziehen
**Empfehlung:** Stacks zunächst in Komodo anlegen (disabled), dann umschalten, Portainer-Stack
deaktivieren (nicht löschen) → erst nach Verifikation aus Portainer entfernen.
---
## Phase 4 — Portainer abschalten
Wenn alle Stacks auf Komodo laufen und verifiziert:
```bash
# Auf pve-LXC:
docker stop portainer && docker rm portainer
# docker volume portainer_data → optional behalten als Backup
```
`mcp/INVENTORY.md` aktualisieren: portainer-mcp-Zeile auf `retired` setzen.
---
## Phase 5 — komodo-mcp deployen + Claude Code eintragen
**Image:** `ghcr.io/samik081/mcp-komodo:latest` (selber Autor wie pve-mcp ✓)
**Macvlan-IP:** `10.40.41.176` (folgt .173/.174/.175)
### 5a — Komodo API-Key erzeugen
Komodo UI → `Settings → API Keys → New Key`
→ Key + Secret in `mcp/komodo/.env` als `KOMODO_API_KEY` + `KOMODO_API_SECRET`
### 5b — compose.yml
Datei: `mcp/komodo/komodo-mcp-compose.yml` (liegt neben dieser Datei)
Stack via Komodo selbst deployen (Stack auf DS923, macvlan .176):
- Komodo UI → Stacks → New Stack → Ziel: DS923 (Periphery da)
### 5c — Claude Code eintragen
```bash
# ~/.claude/settings.json ergänzen (neben pve-mcp/.173):
# "komodo-mcp": {
# "type": "sse",
# "url": "http://10.40.41.176:3000/sse"
# }
```
Oder via CLI:
```bash
claude mcp add --transport sse komodo-mcp http://10.40.41.176:3000/sse -s user
```
Verfügbar nach Claude Code Neustart. Tools prüfen:
```
/mcp → komodo-mcp → 40 Tools sichtbar
```
---
## Abhängigkeiten & Reihenfolge
```
Phase 0 (Export)
↓
Phase 1 (Core auf .126)
↓
Phase 2 (Periphery auf .10, .7, .167, nas-fm)
↓
Phase 3 (Stacks umziehen)
↓
Phase 4 (Portainer stop)
↓
Phase 5 (komodo-mcp .176 + Claude Code)
```
Phasen 0–2 sind unabhängig von laufenden Stacks und können ohne Downtime vorbereitet werden.
Phasen 3–5 erfordern kurze Downtime je Stack beim Umschalten (Sekunden).
---
## Referenzen
- [Komodo Docs](https://komo.do/docs/intro)
- [Core Setup](https://komo.do/docs/setup)
- [Connect Servers (Periphery)](https://komo.do/docs/setup/connect-servers)
- [Docker Compose Stacks](https://komo.do/docs/deploy/compose)
- [Samik081/mcp-komodo](https://github.com/Samik081/mcp-komodo) — MCP-Adapter
- [MP-Tool/komodo-mcp-server](https://github.com/MP-Tool/komodo-mcp-server) — Alternative (70 Tools, aber kein GHCR-Image)
- [moghtech/komodo (GitHub)](https://github.com/moghtech/komodo) — Upstream

View File

@@ -0,0 +1,56 @@
# Komodo Core — BF (ersetzt Portainer CE auf pve-LXC 10.40.41.126)
# Deploy auf: pve-LXC (.126), per SSH oder direkt
# Web-UI: http://10.40.41.126:9120
# Doku: https://komo.do/docs/setup
#
# Secrets aus .env (gitignored):
# KOMODO_HOST, KOMODO_DB_PASSWORD, KOMODO_JWT_SECRET, KOMODO_PASSKEY
services:
komodo-core:
image: ghcr.io/moghtech/komodo:latest
restart: unless-stopped
depends_on:
komodo-mongo:
condition: service_healthy
ports:
- "9120:9120"
environment:
KOMODO_HOST: ${KOMODO_HOST}
KOMODO_TITLE: "BF Komodo"
KOMODO_PASSKEY: ${KOMODO_PASSKEY} # Periphery-Onboarding-Secret
KOMODO_DATABASE_ADDRESS: komodo-mongo:27017
KOMODO_DATABASE_USERNAME: komodo
KOMODO_DATABASE_PASSWORD: ${KOMODO_DB_PASSWORD}
KOMODO_JWT_SECRET: ${KOMODO_JWT_SECRET}
KOMODO_JWT_TTL: 1d
KOMODO_LOG_LEVEL: info
volumes:
- komodo-repos:/etc/komodo/repos
networks:
- komodo-internal
komodo-mongo:
image: mongo:7
restart: unless-stopped
command: --quiet
environment:
MONGO_INITDB_ROOT_USERNAME: komodo
MONGO_INITDB_ROOT_PASSWORD: ${KOMODO_DB_PASSWORD}
volumes:
- komodo-mongo-data:/data/db
networks:
- komodo-internal
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.runCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 5
volumes:
komodo-repos:
komodo-mongo-data:
networks:
komodo-internal:
driver: bridge

View File

@@ -0,0 +1,34 @@
# komodo-mcp (Samik081) — BF
# Image: ghcr.io/samik081/mcp-komodo (selber Autor wie pve-mcp/.173)
# Host: DS923 (.10), macvlan-IP 10.40.41.176
# Transport: SSE (MCP_TRANSPORT=http → streamable HTTP auf Port 3000)
# Doku: https://github.com/Samik081/mcp-komodo
#
# Deploy via Komodo Core (Stack auf DS923-Periphery) — NACH Phase 1-4.
# Secrets aus .env:
# KOMODO_URL, KOMODO_API_KEY, KOMODO_API_SECRET
#
# Claude Code eintragen:
# claude mcp add --transport sse komodo-mcp http://10.40.41.176:3000/sse -s user
# (oder ~/.claude/settings.json → "komodo-mcp": {"type":"sse","url":"http://10.40.41.176:3000/sse"})
services:
komodo-mcp:
image: ghcr.io/samik081/mcp-komodo:latest
container_name: komodo-mcp
restart: unless-stopped
networks:
default_external:
ipv4_address: 10.40.41.176
env_file: .env
# Env-Vars (in .env, nie hier):
# KOMODO_URL=http://10.40.41.126:9120
# KOMODO_API_KEY=...
# KOMODO_API_SECRET=...
# MCP_TRANSPORT=http # SSE/streamable HTTP
# PORT=3000
# ACCESS_LEVEL=read-execute # oder: read-only | full
networks:
default_external:
external: true

View File

@@ -0,0 +1,21 @@
# Komodo Periphery-Agent — BF
# Deploy auf: jeden verwalteten Host (DS923, odroid, macmini, nas-fm)
# Doku: https://komo.do/docs/setup/connect-servers
#
# Anpassungen pro Host:
# PERIPHERY_ALLOWED_IPS: nur Komodo Core (.126) darf connecten
#
# Nach dem Start: im Komodo-UI unter Servers → New Server → Passkey eintragen
services:
periphery:
image: ghcr.io/moghtech/komodo-periphery:latest
restart: unless-stopped
network_mode: host # Core muss Periphery direkt erreichen können
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /etc/komodo:/etc/komodo # Periphery-Root: Compose-Dateien + Repos landen hier
environment:
PERIPHERY_ALLOWED_IPS: 10.40.41.126 # nur Core darf sich verbinden
PERIPHERY_PORT: 8120
PERIPHERY_ROOT_DIRECTORY: /etc/komodo

11
mcp/mikromcp/.env.example Normal file
View File

@@ -0,0 +1,11 @@
# MikroMCP BF — Secrets. Kopiere zu .env (gitignored), fülle Werte.
#
# Confirmation-Secret (Pflicht im HTTP-Modus): openssl rand -hex 32
MIKROMCP_CONFIRMATION_SECRET=
# Dedizierter RouterOS-User (NICHT admin) — anlegen lt. README.md §RouterOS-User.
# Policies nötig: read,write,api,rest-api,test,ssh,sniff (ftp nur für upload_file).
ROUTER_AP1_USER=mcp-api
ROUTER_AP1_PASS=
ROUTER_AP2_USER=mcp-api
ROUTER_AP2_PASS=

View File

@@ -0,0 +1,60 @@
# MikroMCP Router-Registry — BF Netzwerk (final IPs, TP21)
# Credentials NICHT hier, sondern via ENV (envPrefix -> ROUTER_<PREFIX>_USER/PASS in .env).
# Alle Geräte sprechen REST über PLAIN HTTP auf Port 80.
# -> tls.enabled: false (rest-client.ts: scheme = tls.enabled ? https : http)
# Änderungen hier werden erst nach MikroMCP-Neustart wirksam.
#
# TP21.1-Stand (2026-08-08): crs326-core IST der L3-Router (Routing/DHCP/DNS/NAT/WG-Hub live).
# core mgmt = 10.40.99.1 (vlan99-SVI, aus VLAN10-Client via core geroutet erreichbar;
# Same-Subnet-Fallback = 10.40.10.1). branch = 10.40.99.2 (L2, .10.2 temp).
# ap1/ap2 (.99.11/.99.12) = Zielwerte — ap1 ist derzeit Werksreset/getrennt und kommt
# erst mit der WLAN-Neuprovisionierung (eigene Session) zurück.
routers:
ap1:
host: "10.40.99.11"
port: 80
tls:
enabled: false
rejectUnauthorized: true
credentials:
source: "env"
envPrefix: "ROUTER_AP1"
tags: ["bf", "wifi", "access-point"]
rosVersion: "7.23"
ap2:
host: "10.40.99.12"
port: 80
tls:
enabled: false
rejectUnauthorized: true
credentials:
source: "env"
envPrefix: "ROUTER_AP2"
tags: ["bf", "wifi", "access-point"]
rosVersion: "7.23"
crs326-core:
host: "10.40.99.1"
port: 80
tls:
enabled: false
rejectUnauthorized: true
credentials:
source: "env"
envPrefix: "CRS326_CORE"
tags: ["bf", "switch", "core", "l3-router"]
rosVersion: "7.19.5"
crs326-branch:
host: "10.40.99.2"
port: 80
tls:
enabled: false
rejectUnauthorized: true
credentials:
source: "env"
envPrefix: "CRS326_BRANCH"
tags: ["bf", "switch", "branch"]
rosVersion: "7.19.5"

View File

@@ -0,0 +1,35 @@
# MikroMCP — BF, Docker auf Mac mini (10.40.41.167)
# Prebuilt-Image (kein Source-Build nötig). HTTP/streamable für Remote-Clients.
#
# SECURITY: Dieser Endpoint kann RouterOS administrieren. Er ist NUR durch Bearer-Token
# (identities.yaml, bcrypt) + Confirmation-Secret geschützt. Daher:
# - ports auf das Mgmt-/LAN-Interface binden, NICHT 0.0.0.0 ins Internet,
# - später hinter NPM (FQDN + Auth/TLS) hängen,
# - dedizierter RouterOS-User 'mcp-api' (NICHT admin) — siehe ../README.md.
#
# Deploy (auf dem mini):
# scp -r mcp/mikromcp 10.40.41.167:/opt/docker/mikromcp
# ssh 10.40.41.167 'cd /opt/docker/mikromcp && cp .env.example .env && $EDITOR .env'
# ssh 10.40.41.167 'cd /opt/docker/mikromcp && docker compose up -d && docker compose logs -f'
services:
mikromcp:
image: ghcr.io/alikarami/mikromcp:latest
container_name: mikromcp
restart: unless-stopped
environment:
MIKROMCP_TRANSPORT: http
MIKROMCP_PORT: "3000"
MIKROMCP_BIND_HOST: "0.0.0.0" # im Container; Exposure via ports-Mapping unten begrenzen
MIKROMCP_CONFIG_PATH: /app/config/routers.yaml
MIKROMCP_IDENTITIES_PATH: /app/config/identities.yaml
MIKROMCP_CONFIRMATION_SECRET: ${MIKROMCP_CONFIRMATION_SECRET}
MIKROMCP_AUDIT_LOG_PATH: /app/config/audit.ndjson
MIKROMCP_HTTP_RATE_LIMIT_RPM: "60"
MIKROMCP_LOG_LEVEL: info
env_file:
- .env # ROUTER_AP1_USER/PASS, ROUTER_AP2_USER/PASS, secret
volumes:
- ./config:/app/config # routers.yaml + identities.yaml + audit.ndjson
ports:
- "10.40.41.167:3000:3000" # nur auf LAN-IP des mini binden (nicht überall)

23
mcp/omada/.env.template Normal file
View File

@@ -0,0 +1,23 @@
# omada-mcp Secrets — .env (gitignored). Dieses File = Template, .env = echte Werte.
#
# jmtvms/tplink-omada-mcp v0.15: StreamableHTTP-Transport + Omada OPEN API (Client Credentials).
# Client-Creds erzeugen: Controller-UI → Settings → Platform Integration → Open API →
# App anlegen (Grant Type: Client Credentials) → liefert Client ID + Client Secret.
# omadac_id: https://10.40.41.175:8043/api/info → result.omadacId
# site_id: Controller-UI Site, oder API /{omadacId}/api/v2/sites → result.data[].id
# --- Transport: StreamableHTTP auf :3000/mcp ---
MCP_SERVER_USE_HTTP=true
MCP_HTTP_BIND_ADDR=0.0.0.0
MCP_HTTP_PORT=3000
MCP_HTTP_PATH=/mcp
MCP_HTTP_ENABLE_HEALTHCHECK=true
MCP_HTTP_HEALTHCHECK_PATH=/health
# --- Omada Open API ---
OMADA_BASE_URL=https://10.40.41.175:8043
OMADA_OMADAC_ID=adbef8fa3505fb16b09818eab71d824e
OMADA_CLIENT_ID=REPLACE_OPENAPI_CLIENT_ID
OMADA_CLIENT_SECRET=REPLACE_OPENAPI_CLIENT_SECRET
OMADA_SITE_ID=REPLACE_SITE_ID
OMADA_STRICT_SSL=false

View File

@@ -0,0 +1,34 @@
# omada-mcp — TP-Link Omada Controller MCP Server (jmtvms/tplink-omada-mcp v0.15)
# Host: pve-LXC (10.40.41.126, Portainer endpointId=2) — Docker-LXC auf Proxmox
# IP: 10.40.41.176 (ipvlan default_external) — .173=pve-mcp, .175=omada-controller (beide auf DS923)
#
# Transport: StreamableHTTP (NICHT SSE) → Endpoint http://10.40.41.176:3000/mcp, Health /health
# Auth: Omada OPEN API (Client Credentials) — KEIN username/password!
# Creds: Controller-UI → Settings → Platform Integration → Open API (Grant: Client Credentials).
#
# Hinweis: pve-LXC-Disk ist klein (1GB-Controller-Image scheiterte dort → Controller läuft auf DS923).
# omada-mcp (node-alpine) ist klein genug für den pve-LXC.
#
# Deploy/Update (via Portainer API, endpointId=2):
# POST /api/stacks/create/standalone/string?endpointId=2 (erstmalig)
# PUT /api/stacks/200?endpointId=2 (update, Stack-Id=200)
# ⚠️ Portainer-String-Deploy findet env_file NICHT → Env beim Deploy INLINE senden (Werte aus .env).
# Claude Code: ~/.claude/settings.json → "omada-mcp": {"url": "http://10.40.41.176:3000/mcp"}
services:
omada-mcp:
image: jmtvms/tplink-omada-mcp:latest
container_name: omada-mcp
restart: unless-stopped
networks:
default_external:
ipv4_address: 10.40.41.176
env_file: .env
# Env-Vars (siehe .env.template) — Transport + Omada Open API:
# MCP_SERVER_USE_HTTP=true · MCP_HTTP_BIND_ADDR=0.0.0.0 · MCP_HTTP_PORT=3000 · MCP_HTTP_PATH=/mcp
# MCP_HTTP_ENABLE_HEALTHCHECK=true · MCP_HTTP_HEALTHCHECK_PATH=/health
# OMADA_BASE_URL · OMADA_OMADAC_ID · OMADA_SITE_ID · OMADA_CLIENT_ID · OMADA_CLIENT_SECRET · OMADA_STRICT_SSL=false
networks:
default_external:
external: true

41
mcp/portainer/README.md Normal file
View File

@@ -0,0 +1,41 @@
# portainer-mcp (offiziell) — BF
Überblick über die Docker-Landschaft via Portainer. Übersicht aller MCPs: [../INVENTORY.md](../INVENTORY.md).
## Ist-Stand (2026-06-09)
- **Portainer-Server läuft** auf `https://10.40.41.126:9443`, **v2.40.0**, managed **5 Endpoints**:
`pve` (lokal), `nas-bf (ds923+)`, `odroid-m1`, `macmini`, `nas-fm (ds415+)`.
- **API-Token** vorhanden (admin) → in `.env` (gitignored).
- **Blocker:** `portainer-mcp` gibt es nur als **2.41.x / 2.42.x**, jeweils = gleiche Portainer-Minor.
**2.40 hat keinen passenden MCP** → Portainer auf `.126` auf **2.42.x** bumpen (sauberster Weg).
## Weg A — lokal stdio via uvx (least-invasive, kein Portainer-Touch nötig zum Probieren)
Voraussetzung: `uv` installieren (`brew install uv` oder `curl -LsSf https://astral.sh/uv/install.sh | sh`).
```
# NACH Portainer-Bump auf 2.42 (oder Versuch 2.41 gegen 2.40):
claude mcp add portainer -s user \
-e PORTAINER_URL=https://10.40.41.126:9443 \
-e PORTAINER_API_KEY=ptr_... \
-e PORTAINER_TLS_VERIFY=0 \
-- uvx --from "mcp-portainer~=2.42.0" mcp-portainer
```
Read-only erzwingen: über MCP-Config/Profil (siehe portainer-mcp `docs/configuration.md` → „read-only capabilities").
## Weg B — Docker HTTP auf Mac mini (.167), zentral hinter FQDN (wie ha-mcp/MikroMCP-Ziel)
```
docker run -d --name portainer-mcp -p 17717:17717 \
-e PORTAINER_URL=https://10.40.41.126:9443 \
-e PORTAINER_API_KEY=ptr_... \
-e PORTAINER_TLS_VERIFY=0 \
-e PORTAINER_MCP_AUTH_TOKEN=<gate-secret> \
-e PORTAINER_MCP_ALLOWED_HOSTS=<fqdn-or-167-ip>:17717 \
portainer/portainer-mcp:2.42
# Client:
claude mcp add portainer --transport http http://<mini>:17717/mcp \
--header "Authorization: Bearer <gate-secret>" \
--header "X-Portainer-API-Key: ptr_..."
```
## Versions-Policy (Doku)
Match MCP-Minor = Portainer-Minor. Tabelle: nur 2.41.x↔2.41.x, 2.42.x↔2.42.x. Kein 2.40.
TLS: self-signed → `PORTAINER_TLS_VERIFY=0`.

7
mcp/shelly/.env.example Normal file
View File

@@ -0,0 +1,7 @@
# shelly-mcp config — copy to .env (gitignored) and fill in.
SHELLY_IOT_SSID=dirtyvader-iot
SHELLY_IOT_PSK= # IOT SSID passphrase (from ap1 w2-iot). Required for set_sta.
SHELLY_DEVICE_PW= # uniform Shelly admin password (Shelly.SetAuth). Required for set_auth. Separate from the WLAN PSK.
SHELLY_SWEEP=10.40.41.180-250 # discovery range (VLAN1 today)
SHELLY_MCP_HOST=127.0.0.1
SHELLY_MCP_PORT=3030

40
mcp/shelly/README.md Normal file
View File

@@ -0,0 +1,40 @@
# shelly-mcp — Shelly-Verwaltung & VLAN1→VLAN30-Migration (TP17)
Tooling, um die ~26 Shellys von der alten cudy-WLAN/VLAN1 (`10.40.41.2xx`) auf die
IOT-SSID **`dirtyvader-iot`** (VLAN30, `dhcp-v30`/pool-v30) umzuklemmen und danach
dauerhaft zu verwalten. Steuerung/Status: `docs/teilprojekte.md` → **TP17**.
## Ist-Stand (Recon 2026-07-11)
- **16 live**, alle **Gen3/Gen4** → uniforme RPC-API (`WiFi.SetConfig`), `auth=off`.
Kein Gen1-Sonderfall.
- **10 nicht erreichbar:** `weihnachten_*` (s1/s2/s3, saisonal ausgesteckt) +
s12/s14/s16/s18/s22/s23 (aus / DHCP-drift). s24 hat keine MAC im Inventar.
- Recon-Snapshot: `python3 discover.py 10.40.41.180-250 --json`.
## Migrationsmechanismus (per Gerät)
Gen2+ RPC (HTTP POST `http://<ip>/rpc`):
```
WiFi.SetConfig {"config":{"sta":{"ssid":"dirtyvader-iot","pass":"<PSK>","enable":true}}}
```
→ `Shelly.Reboot` (oder greift nach kurzer Zeit selbst) → Gerät verschwindet aus VLAN1,
kommt per `dhcp-v30` auf `10.40.30.x` (mit MAC-Reservierung = stabile IP).
⚠️ **Falsches PSK → Gerät geht in AP-Mode (SSID `Shelly*`) → physischer Reset nötig.**
Immer erst `WiFi.GetStatus`/`Sys.GetStatus` gegenchecken, Pilot vor Batch.
## Dateien
- `discover.py` — read-only Sweep, identifiziert Shellys per `/shelly`, matcht MAC gegen
`inventory/bf.csv`, meldet DHCP-Drift und Inventar-Lücken. Basis des MCP-`discover`-Tools.
- `server.py` — MCP-Server: `discover` / `get` / `set_sta` (WLAN→VLAN30, guarded) /
`set_auth` (einheitliches Admin-PW `SHELLY_DEVICE_PW`, guarded, Digest-Auth-Fallback) /
`verify` / `reservation_plan`. Go-Runbook: `docs/tp17-shelly-go-runbook.md`.
## Rollout-Reihenfolge (User-Entscheid: Pilot→Rest, Reservierungen je MAC)
1. ✅ VLAN30-DHCP-Reservierungen je MAC (liegen; s6-MAC 2026-07-11 gefixt).
2. **Pilot** unkritischer Always-on (`set_sta`) → **VLAN30-Verify** → **`set_auth`** (Admin-PW) → HA-Credential.
3. VLAN30-Reachability + HA/ioBroker-Wiederanbindung verifizieren (Entities bleiben unverändert).
4. Rest in Batches; saisonale (`weihnachten_*`, Twinkly, Creality) beim nächsten Einstecken.
5. cudy-Alt-WLAN abbauen; DNS/Inventar/`docs/ip-plan.md` nachziehen.
Status/Detail: **6/26 schon auf VLAN30**, 16 morgen. Volle Runbook-Tabelle + Netz-Abgleich in
`docs/tp17-shelly-go-runbook.md`.

124
mcp/shelly/discover.py Normal file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Shelly recon: sweep an IPv4 range for Shelly devices and identify them.
Read-only. Hits each host's unauthenticated `/shelly` endpoint (works on Gen1
and Gen2+), collects gen/model/fw/mac, and cross-references the project
inventory (inventory/bf.csv) by MAC so DHCP-drifted devices are still matched
to their logical name.
Usage:
python3 discover.py # default sweep 10.40.41.200-240
python3 discover.py 10.40.41.180-250
python3 discover.py --json # machine-readable to stdout
"""
import csv, json, os, sys, urllib.request, socket
from concurrent.futures import ThreadPoolExecutor
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
INVENTORY = os.path.join(REPO, "inventory", "bf.csv")
def load_inventory():
"""MAC(lower, colon) -> {name, ip, section} for shelly rows."""
by_mac = {}
if not os.path.exists(INVENTORY):
return by_mac
with open(INVENTORY) as f:
for row in csv.DictReader(f):
host = (row.get("hostname") or "")
if "shelly" not in host.lower():
continue
mac = (row.get("mac") or "").strip().lower()
entry = {"name": (row.get("note") or host).strip(),
"shelly_id": host.replace("shelly:", "").strip(),
"inv_ip": (row.get("ip") or "").strip(),
"mac": mac}
if mac:
by_mac[mac] = entry
return by_mac
def probe(ip, timeout=3.0):
"""Return identity dict for a Shelly at ip, or None."""
try:
req = urllib.request.Request(f"http://{ip}/shelly")
with urllib.request.urlopen(req, timeout=timeout) as r:
d = json.load(r)
except Exception:
return None
gen = d.get("gen", 1)
mac = (d.get("mac") or "").lower()
# normalise MAC to colon form for inventory matching
if mac and ":" not in mac and len(mac) == 12:
mac = ":".join(mac[i:i+2] for i in range(0, 12, 2))
return {
"ip": ip,
"gen": gen,
"model": d.get("model") or d.get("type") or "?",
"fw": d.get("fw") or d.get("ver") or "?",
"mac": mac,
"auth": d.get("auth", d.get("auth_en", None)),
"name_dev": d.get("name"),
}
def expand(spec):
"""'10.40.41.200-240' or '10.40.41.208' -> list of IPs."""
if "-" in spec.split(".")[-1]:
base, rng = spec.rsplit(".", 1)
lo, hi = rng.split("-")
return [f"{base}.{i}" for i in range(int(lo), int(hi) + 1)]
return [spec]
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
as_json = "--json" in sys.argv
spec = args[0] if args else "10.40.41.200-240"
ips = expand(spec)
inv = load_inventory()
found = []
with ThreadPoolExecutor(max_workers=32) as ex:
for res in ex.map(probe, ips):
if res:
found.append(res)
# match to inventory
seen_macs = set()
for f in found:
m = inv.get(f["mac"])
f["inv_name"] = m["name"] if m else None
f["inv_id"] = m["shelly_id"] if m else None
f["inv_ip"] = m["inv_ip"] if m else None
f["ip_drifted"] = bool(m and m["inv_ip"] and m["inv_ip"] != f["ip"])
if m:
seen_macs.add(f["mac"])
missing = [{"mac": mac, **e} for mac, e in inv.items() if mac not in seen_macs]
if as_json:
print(json.dumps({"found": found, "missing_from_inventory": missing,
"swept": spec, "reachable": len(found),
"inventory_total": len(inv)}, indent=2))
return
found.sort(key=lambda x: x["inv_id"] or "zzz")
print(f"Sweep {spec}: {len(found)} Shellys reachable "
f"(inventory has {len(inv)} with MAC)\n")
print(f"{'shelly':7} {'ip':15} {'gen':4} {'model':18} {'fw':10} {'name':26} drift")
print("-" * 100)
for f in found:
drift = f"was {f['inv_ip']}" if f["ip_drifted"] else ""
print(f"{(f['inv_id'] or '?'):7} {f['ip']:15} g{f['gen']:<3} "
f"{str(f['model']):18} {str(f['fw']):10} "
f"{str(f['inv_name'] or f.get('name_dev') or '?'):26} {drift}")
if missing:
print(f"\n{len(missing)} in inventory but NOT reachable in sweep "
f"(offline / seasonal / outside range):")
for m in sorted(missing, key=lambda x: x["shelly_id"]):
print(f" {m['shelly_id']:5} {m['inv_ip']:15} {m['mac']:18} {m['name']}")
if __name__ == "__main__":
main()

321
mcp/shelly/migrate.py Normal file
View File

@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""TP17 migration runner — anchored VLAN1→VLAN30 cutover for Shellys.
Per-device sequence (Gen2+ RPC), ordered so recovery is ALWAYS possible:
1. probe old IP — must be reachable; record current sta for the log
2. ANCHOR: ap.enable=true — device now also broadcasts its own AP
(Shelly*-<mac>, open) as an emergency anchor.
AP+STA run concurrently, no reboot, sta untouched.
3. set sta -> dirtyvader-iot / VLAN30 static 10.40.30.<octet>
4. Shelly.Reboot
5. poll new IP (10.40.30.<octet>) until it answers, or time out
-> success: AP stays on until end-of-run `deanchor`
-> fail: device is still reachable via its own AP (step 2) — no reset
Only AFTER step 2 do we overwrite sta, so the working fallback that vanishes on
reboot (old sta) is replaced by a fallback that does NOT depend on any WLAN infra
(the device's own AP). That's the whole point of the anchor.
Reuses discover.py (probe/expand/load_inventory) and the same .env as server.py.
Commands:
python3 migrate.py show <ip> read WiFi.GetConfig (read-only)
python3 migrate.py migrate <ip...> [--go] anchor+sta+reboot+verify (dry-run w/o --go)
python3 migrate.py deanchor <ip...> [--go] turn the AP anchor back off (.30.x IPs)
python3 migrate.py auth <ip...> [--go] set uniform admin password (last step)
python3 migrate.py eco <ip...> [--go] turn device.eco_mode OFF (--on to re-enable)
python3 migrate.py table final tabular overview (old/new/model/fw)
<ip...> accepts single IPs, ranges (10.40.41.205-233) or 'all' (the default sweep).
Batches run sequentially and STOP on the first failed verify (safety).
"""
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import discover # noqa: E402
# ---- shared .env loader (same as server.py) ----
_envfile = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
if os.path.exists(_envfile):
for _line in open(_envfile):
_line = _line.strip()
if _line and not _line.startswith("#") and "=" in _line:
_k, _v = _line.split("=", 1)
os.environ.setdefault(_k.strip(), _v.strip().strip('"').strip("'"))
IOT_SSID = os.environ.get("SHELLY_IOT_SSID", "dirtyvader-iot")
IOT_PSK = os.environ.get("SHELLY_IOT_PSK", "")
DEVICE_PW = os.environ.get("SHELLY_DEVICE_PW", "")
V30_NET, V30_MASK, V30_GW = "10.40.30", "255.255.255.0", "10.40.30.1"
LOG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "migration-log.jsonl")
def _rpc(ip, method, params=None, timeout=8.0):
body = json.dumps({"id": 1, "method": method, "params": params or {}}).encode()
def _mk():
return urllib.request.Request(f"http://{ip}/rpc", data=body,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(_mk(), timeout=timeout) as r:
return json.load(r)
except urllib.error.HTTPError as e:
if e.code == 401 and DEVICE_PW:
mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
mgr.add_password(None, f"http://{ip}/rpc", "admin", DEVICE_PW)
opener = urllib.request.build_opener(urllib.request.HTTPDigestAuthHandler(mgr))
with opener.open(_mk(), timeout=timeout) as r:
return json.load(r)
raise
def _octet(ip):
return ip.rsplit(".", 1)[-1]
def _name_for(mac):
m = discover.load_inventory().get((mac or "").lower())
return m["name"] if m else None
def _log(rec):
with open(LOG, "a") as f:
f.write(json.dumps(rec) + "\n")
def _resolve(specs):
"""Expand CLI ip args into a concrete IP list. 'all' -> default VLAN1 sweep."""
if not specs or specs == ["all"]:
return discover.expand(os.environ.get("SHELLY_SWEEP", "10.40.41.180-250"))
ips = []
for s in specs:
ips.extend(discover.expand(s))
return ips
# ---------------- commands ----------------
def cmd_show(ip):
print(json.dumps(_rpc(ip, "WiFi.GetConfig"), indent=2))
def _anchor(ip):
"""Enable the device's own AP as an emergency anchor. Live, no reboot.
This firmware refuses an OPEN AP (`-103 'pass' should not be empty`), so the
anchor is WPA2 secured with the IOT PSK — a value the operator already has,
and it's removed again by `deanchor`. AP+STA run concurrently; sta untouched.
"""
res = _rpc(ip, "WiFi.SetConfig",
{"config": {"ap": {"enable": True, "is_open": False, "pass": IOT_PSK}}})
if res.get("error"):
return {"enabled": False, "error": res["error"]}
ap = _rpc(ip, "WiFi.GetConfig").get("result", {}).get("ap", {})
return {"enabled": bool(ap.get("enable")), "ap_ssid": ap.get("ssid"),
"secured": not ap.get("is_open")}
def _migrate_one(ip, go, poll=90):
ident = discover.probe(ip)
if not ident:
return {"ip": ip, "ok": False, "error": "not reachable on old IP"}
if not IOT_PSK:
return {"ip": ip, "ok": False, "error": "SHELLY_IOT_PSK not set"}
cur = _rpc(ip, "WiFi.GetConfig").get("result", {}).get("sta", {})
new_ip = f"{V30_NET}.{_octet(ip)}"
name = _name_for(ident["mac"]) or ident.get("name_dev") or "?"
sta = {"ssid": IOT_SSID, "pass": IOT_PSK, "enable": True, "ipv4mode": "static",
"ip": new_ip, "netmask": V30_MASK, "gw": V30_GW, "nameserver": V30_GW}
plan = {"ip": ip, "name": name, "mac": ident["mac"], "model": ident["model"],
"fw": ident["fw"], "old_ssid": cur.get("ssid"), "new_ip": new_ip,
"new_ssid": IOT_SSID}
if not go:
return {**plan, "ok": False, "dry_run": True,
"would": "anchor AP -> set sta(static .30) -> reboot -> verify"}
# 2. anchor
anc = _anchor(ip)
if not anc["enabled"]:
return {**plan, "ok": False, "error": "AP anchor did not enable — aborting before sta write"}
print(f" anchor up: AP '{anc['ap_ssid']}' (WPA2, PSK=IOT-PSK)")
# 3+4. set sta, reboot
_rpc(ip, "WiFi.SetConfig", {"config": {"sta": sta}})
_rpc(ip, "Shelly.Reboot")
print(f" sta set -> {new_ip}, rebooting; polling…")
# 5. poll new IP
t0 = time.time()
while time.time() - t0 < poll:
time.sleep(5)
got = discover.probe(new_ip)
if got and got["mac"].lower() == ident["mac"].lower():
st = _rpc(new_ip, "WiFi.GetStatus").get("result", {})
rec = {**plan, "ok": True, "verified_ssid": st.get("ssid"),
"rssi": st.get("rssi"), "secs": round(time.time() - t0)}
_log(rec)
return rec
return {**plan, "ok": False,
"error": f"did not appear on {new_ip} within {poll}s — "
f"reach it via its AP '{anc['ap_ssid']}' (no reset needed)"}
def cmd_migrate(specs, go):
ips = _resolve(specs)
print(f"{'GO' if go else 'DRY-RUN'} migrate {len(ips)} device(s) "
f"-> {IOT_SSID}/VLAN30 (PSK {'set' if IOT_PSK else 'MISSING'})\n")
ok = 0
for ip in ips:
if not discover.probe(ip):
continue # skip empty slots in a range
print(f"[{ip}] …")
r = _migrate_one(ip, go)
tag = "OK " if r.get("ok") else ("dry" if r.get("dry_run") else "FAIL")
print(f" {tag}: {r.get('name','?')} {ip} -> {r.get('new_ip','?')} "
f"{r.get('error') or r.get('would') or ('rssi '+str(r.get('rssi')))}")
if r.get("ok"):
ok += 1
elif go and not r.get("dry_run"):
print(" >> STOP: batch halted on failure (device recoverable via its AP).")
break
print(f"\n{ok} migrated OK.")
def cmd_deanchor(specs, go):
ips = _resolve(specs) if specs != ["all"] else discover.expand("10.40.30.180-250")
for ip in ips:
if not discover.probe(ip):
continue
if not go:
print(f"dry: would disable AP on {ip}")
continue
_rpc(ip, "WiFi.SetConfig", {"config": {"ap": {"enable": False}}})
cfg = _rpc(ip, "WiFi.GetConfig").get("result", {}).get("ap", {})
print(f"{ip}: AP enable -> {cfg.get('enable')}")
def cmd_auth(specs, go):
if not DEVICE_PW:
print("SHELLY_DEVICE_PW not set — aborting.")
return
for ip in _resolve(specs):
if not discover.probe(ip):
continue
info = _rpc(ip, "Shelly.GetDeviceInfo").get("result", {})
realm = info.get("id")
ha1 = hashlib.sha256(f"admin:{realm}:{DEVICE_PW}".encode()).hexdigest()
if not go:
print(f"dry: {ip} realm={realm} ha1={ha1[:12]}…")
continue
_rpc(ip, "Shelly.SetAuth", {"user": "admin", "realm": realm, "ha1": ha1})
print(f"{ip}: auth set (realm {realm})")
def cmd_eco(specs, go, want=False):
"""Set device.eco_mode across the fleet (TP17.3, 2026-08-23).
eco_mode throttles the CPU clock and TX power. It was applied in TP17 by an
RSSI rule and turned out to cause real misbehaviour: the s23 phase-cut dimmer
flickered, and s26's `momentary` input swallowed the first edge. Both were
fixed by switching it off, so the fleet decision is: OFF everywhere.
No reboot needed — the device answers `restart_required: false` to this write.
Devices that don't support it report eco_mode=None and are skipped (s24).
"""
# Weak links (s20 sits at -85 dBm) time out intermittently. A single timeout
# must not abort the sweep, so every call is retried and failures are
# collected and reported at the end instead of raising.
def _try(ip, method, params=None, tries=3):
for n in range(tries):
try:
return _rpc(ip, method, params, timeout=10.0)
except Exception as e:
last = e
raise last
failed = []
for ip in _resolve(specs):
ident = discover.probe(ip)
if not ident:
continue
name = _name_for(ident["mac"]) or ident.get("name_dev") or "?"
try:
cur = _try(ip, "Sys.GetConfig").get("result", {}).get("device", {}).get("eco_mode")
if cur is None:
print(f" {ip:15} {name:26} eco not supported — skipped")
continue
if cur == want:
print(f" {ip:15} {name:26} already {want}")
continue
if not go:
print(f" dry: {ip:11} {name:26} eco {cur} -> {want}")
continue
res = _try(ip, "Sys.SetConfig", {"config": {"device": {"eco_mode": want}}})
new = _try(ip, "Sys.GetConfig").get("result", {}).get("device", {}).get("eco_mode")
rr = (res.get("result") or {}).get("restart_required")
ok = "OK " if new == want else "MISMATCH"
print(f" {ip:15} {name:26} eco {cur} -> {new} {ok}"
+ (" (restart_required was already set before this write)" if rr else ""))
if new != want:
failed.append((ip, name, f"read back {new}"))
except Exception as e:
print(f" {ip:15} {name:26} FAILED: {type(e).__name__}")
failed.append((ip, name, type(e).__name__))
if failed:
print(f"\n{len(failed)} device(s) need a retry:")
for ip, name, why in failed:
print(f" {ip:15} {name:26} {why}")
def cmd_table():
inv = discover.load_inventory()
ips = discover.expand("10.40.30.180-250")
from concurrent.futures import ThreadPoolExecutor
rows = []
with ThreadPoolExecutor(max_workers=32) as ex:
for r in ex.map(discover.probe, ips):
if r:
m = inv.get(r["mac"])
rows.append({"id": (m["shelly_id"] if m else "?"),
"name": (m["name"] if m else r.get("name_dev") or "?"),
"old": f"10.40.41.{_octet(r['ip'])}", "new": r["ip"],
"gen": r["gen"], "model": r["model"], "fw": r["fw"]})
rows.sort(key=lambda x: x["id"])
print(f"\n{'id':5} {'name':26} {'old IP':15} {'new IP':15} {'gen':4} {'model':18} fw")
print("-" * 108)
for x in rows:
print(f"{x['id']:5} {x['name']:26} {x['old']:15} {x['new']:15} "
f"g{x['gen']:<3} {x['model']:18} {x['fw']}")
print(f"\n{len(rows)} Shellys on VLAN30.")
def main():
a = sys.argv[1:]
if not a:
print(__doc__)
return
cmd, rest = a[0], a[1:]
go = "--go" in rest
specs = [x for x in rest if not x.startswith("--")]
if cmd == "show":
cmd_show(specs[0])
elif cmd == "migrate":
cmd_migrate(specs, go)
elif cmd == "deanchor":
cmd_deanchor(specs or ["all"], go)
elif cmd == "auth":
cmd_auth(specs, go)
elif cmd == "eco":
cmd_eco(specs, go, want=("--on" in rest))
elif cmd == "table":
cmd_table()
else:
print(f"unknown command: {cmd}\n{__doc__}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,16 @@
{"ip": "10.40.41.233", "name": "bad_luefter", "mac": "98:a3:16:7a:a3:54", "model": "S4SW-002P16EU", "fw": "1.7.5", "old_ssid": "elektrosmog", "new_ip": "10.40.30.233", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -65, "secs": 8}
{"ip": "10.40.41.211", "name": "kueche_mikrowelle", "mac": "e4:b0:63:e4:0f:30", "model": "S3PL-00112EU", "fw": "1.7.5", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.211", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -93, "secs": 7}
{"ip": "10.40.41.212", "name": "kueche_backofen", "mac": "d8:85:ac:0c:5c:c8", "model": "S3PL-30116EU", "fw": "1.7.99-plugpmg3prod1", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.212", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -68, "secs": 7}
{"ip": "10.40.41.213", "name": "kueche_kuehlschrank", "mac": "d0:cf:13:d8:18:fc", "model": "S3PL-00112EU", "fw": "1.7.5", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.213", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -67, "secs": 7}
{"ip": "10.40.41.214", "name": "kueche_tiefkuehlschrank", "mac": "d0:cf:13:d9:94:e8", "model": "S3PL-00112EU", "fw": "1.7.5", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.214", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -85, "secs": 7}
{"ip": "10.40.41.215", "name": "kueche_geschirrspueler", "mac": "e4:b0:63:fa:fd:e0", "model": "S3PL-00112EU", "fw": "1.7.5", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.215", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -71, "secs": 7}
{"ip": "10.40.41.216", "name": "wohnzimmer_sideboard", "mac": "e8:f6:0a:61:88:08", "model": "S4PL-00416EU", "fw": "1.7.99-powerstripg4prod1", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.216", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -66, "secs": 13}
{"ip": "10.40.41.217", "name": "bad_sonos", "mac": "e4:b0:63:fc:57:e4", "model": "S3PL-00112EU", "fw": "1.7.5", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.217", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -64, "secs": 7}
{"ip": "10.40.41.218", "name": "kinderzimmer_sonos", "mac": "e4:b0:63:fa:6c:00", "model": "S3PL-00112EU", "fw": "2.0.0", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.218", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -57, "secs": 7}
{"ip": "10.40.41.222", "name": "technik_pve", "mac": "e4:b0:63:fa:d5:34", "model": "S3PL-00112EU", "fw": "1.7.5", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.222", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -26, "secs": 7}
{"ip": "10.40.41.226", "name": "kueche_steckdosen3", "mac": "10:20:ba:fb:70:fc", "model": "S4SW-002P16EU", "fw": "1.7.5", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.226", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -79, "secs": 8}
{"ip": "10.40.41.206", "name": "kueche_steckdosen2", "mac": "98:a3:16:7c:5f:ec", "model": "S4SW-002P16EU", "fw": "1.7.5", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.206", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -73, "secs": 8}
{"ip": "10.40.41.207", "name": "kueche_steckdosen4", "mac": "20:6e:f1:01:f9:f8", "model": "S4SW-002P16EU", "fw": "1.7.5", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.207", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -72, "secs": 8}
{"ip": "10.40.41.205", "name": "bad_lichtschalter", "mac": "98:a3:16:79:38:50", "model": "S4SW-002P16EU", "fw": "1.7.5", "old_ssid": "elektrosmog", "new_ip": "10.40.30.205", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -62, "secs": 13}
{"ip": "10.40.41.220", "name": "arbeitszimmer_technik", "mac": "48:f6:ee:dd:6a:f4", "model": "S4PL-00416EU", "fw": "1.7.99-powerstripg4prod1", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.220", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -63, "secs": 8}
{"ip": "10.40.41.224", "name": "bad_steckdosen", "mac": "10:20:ba:fb:6d:64", "model": "S4SW-002P16EU", "fw": "1.7.5", "old_ssid": "elektrosmog.c", "new_ip": "10.40.30.224", "new_ssid": "dirtyvader-iot", "ok": true, "verified_ssid": "dirtyvader-iot", "rssi": -81, "secs": 8}

295
mcp/shelly/server.py Normal file
View File

@@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""shelly-mcp — MCP server for managing Shellys and the VLAN1→VLAN30 migration (TP17).
Transport: StreamableHTTP (endpoint /mcp), matching the repo's other MCP servers.
Also serves a browser-viewable status page at `/` and JSON health at `/health`,
so it works as a local dev server (launch.json / preview_start).
Config via env (see .env.example):
SHELLY_IOT_SSID target IOT SSID (default: dirtyvader-iot)
SHELLY_IOT_PSK target IOT PSK (required for set_sta)
SHELLY_SWEEP default discovery range (default: 10.40.41.180-250)
SHELLY_MCP_HOST bind host (default: 127.0.0.1)
SHELLY_MCP_PORT bind port (default: 3030)
Run: python3 server.py (loads ./.env if present)
"""
import hashlib
import json
import os
import sys
import urllib.error
import urllib.request
# reuse the read-only recon logic
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import discover # probe(), load_inventory(), expand()
from mcp.server.fastmcp import FastMCP
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse
# ---- tiny .env loader (no external deps) ----
_envfile = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
if os.path.exists(_envfile):
for _line in open(_envfile):
_line = _line.strip()
if _line and not _line.startswith("#") and "=" in _line:
_k, _v = _line.split("=", 1)
os.environ.setdefault(_k.strip(), _v.strip().strip('"').strip("'"))
IOT_SSID = os.environ.get("SHELLY_IOT_SSID", "dirtyvader-iot")
IOT_PSK = os.environ.get("SHELLY_IOT_PSK", "")
DEVICE_PW = os.environ.get("SHELLY_DEVICE_PW", "") # uniform admin password (Shelly.SetAuth)
SWEEP = os.environ.get("SHELLY_SWEEP", "10.40.41.180-250")
HOST = os.environ.get("SHELLY_MCP_HOST", "127.0.0.1")
PORT = int(os.environ.get("SHELLY_MCP_PORT", "3030"))
mcp = FastMCP("shelly-mcp", host=HOST, port=PORT, stateless_http=True,
instructions="Manage Shellys and drive the VLAN1→VLAN30 (dirtyvader-iot) "
"migration. Read tools are safe; set_sta reconfigures live "
"Wi-Fi and requires confirm=true.")
# ---------------- helpers ----------------
def _rpc(ip: str, method: str, params: dict | None = None, timeout: float = 6.0) -> dict:
"""Gen2+ Shelly RPC over HTTP POST /rpc.
Falls back to HTTP digest auth (user 'admin' / SHELLY_DEVICE_PW) if the
device requires authentication — so devices stay manageable after set_auth.
"""
body = json.dumps({"id": 1, "method": method, "params": params or {}}).encode()
def _mk():
return urllib.request.Request(f"http://{ip}/rpc", data=body,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(_mk(), timeout=timeout) as r:
return json.load(r)
except urllib.error.HTTPError as e:
if e.code == 401 and DEVICE_PW:
mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
mgr.add_password(None, f"http://{ip}/rpc", "admin", DEVICE_PW)
opener = urllib.request.build_opener(urllib.request.HTTPDigestAuthHandler(mgr))
with opener.open(_mk(), timeout=timeout) as r:
return json.load(r)
raise
def _sweep(spec: str | None = None) -> list[dict]:
inv = discover.load_inventory()
ips = discover.expand(spec or SWEEP)
found = []
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=32) as ex:
for res in ex.map(discover.probe, ips):
if res:
m = inv.get(res["mac"])
res["inv_name"] = m["name"] if m else None
res["inv_id"] = m["shelly_id"] if m else None
res["inv_ip"] = m["inv_ip"] if m else None
res["ip_drifted"] = bool(m and m["inv_ip"] and m["inv_ip"] != res["ip"])
found.append(res)
found.sort(key=lambda x: (x["inv_id"] or "zzz"))
return found
# ---------------- MCP tools ----------------
@mcp.tool()
def discover_shellys(sweep: str | None = None) -> dict:
"""Sweep the LAN for Shellys (read-only) and match them to inventory by MAC.
sweep: IPv4 range like '10.40.41.180-250' or a single IP. Defaults to SHELLY_SWEEP.
Returns reachable devices (ip, gen, model, fw, mac, inventory name, ip_drift).
"""
found = _sweep(sweep)
return {"sweep": sweep or SWEEP, "reachable": len(found), "devices": found}
@mcp.tool()
def get_device(ip: str) -> dict:
"""Read a single Shelly's identity + current Wi-Fi STA status (read-only)."""
ident = discover.probe(ip)
if not ident:
return {"ip": ip, "reachable": False}
out = {"ip": ip, "reachable": True, "identity": ident}
try:
out["wifi_status"] = _rpc(ip, "WiFi.GetStatus")
out["wifi_config"] = _rpc(ip, "WiFi.GetConfig")
except Exception as e:
out["rpc_error"] = f"{type(e).__name__}: {e}"
return out
V30_NET = "10.40.30"
V30_MASK = "255.255.255.0"
V30_GW = "10.40.30.1"
def _sta_cfg(ssid: str, static: bool, target_ip: str) -> dict:
"""Build the WiFi STA config. Project decision (2026-07-11): all Shellys get a
STATIC IP on-device (keeping the last octet) plus a matching in-pool DHCP
reservation as backup — not DHCP-only."""
sta = {"ssid": ssid, "pass": IOT_PSK, "enable": True}
if static:
sta.update({"ipv4mode": "static", "ip": target_ip, "netmask": V30_MASK,
"gw": V30_GW, "nameserver": V30_GW})
else:
sta["ipv4mode"] = "dhcp"
return sta
@mcp.tool()
def set_sta(ip: str, confirm: bool = False, ssid: str | None = None,
static: bool = True) -> dict:
"""MIGRATION ACTION — point a Shelly's Wi-Fi STA at the IOT SSID, then reboot.
Reconfigures a LIVE device: it drops off VLAN1 and rejoins on VLAN30. Per the
project decision it sets a STATIC IP (10.40.30.<same-last-octet>, gw/dns .30.1);
a matching in-pool DHCP reservation exists as backup. Pass static=False to use DHCP.
A wrong PSK leaves it in AP-mode (physical reset needed). Therefore:
- confirm MUST be true to actually write,
- SHELLY_IOT_PSK must be set in the environment.
With confirm=false this is a dry-run (returns the exact config that would be sent).
"""
target_ssid = ssid or IOT_SSID
target_ip = f"{V30_NET}.{ip.rsplit('.', 1)[-1]}"
ident = discover.probe(ip)
plan = {"ip": ip, "target_ssid": target_ssid, "target_ip": target_ip,
"mode": "static" if static else "dhcp",
"device": ident, "psk_configured": bool(IOT_PSK)}
if not ident:
return {**plan, "ok": False, "error": "device not reachable"}
if not IOT_PSK:
return {**plan, "ok": False, "error": "SHELLY_IOT_PSK not set in environment"}
sta = _sta_cfg(target_ssid, static, target_ip)
if not confirm:
redacted = {**sta, "pass": "***"}
return {**plan, "ok": False, "dry_run": True,
"would_send": {"method": "WiFi.SetConfig",
"params": {"config": {"sta": redacted}}}}
try:
res = _rpc(ip, "WiFi.SetConfig", {"config": {"sta": sta}})
reboot = _rpc(ip, "Shelly.Reboot")
return {**plan, "ok": True, "set_result": res, "reboot": reboot,
"note": f"device rebooting; expect it at {target_ip} shortly"}
except Exception as e:
return {**plan, "ok": False, "error": f"{type(e).__name__}: {e}"}
@mcp.tool()
def set_auth(ip: str, confirm: bool = False) -> dict:
"""MIGRATION ACTION — set the uniform admin password (SHELLY_DEVICE_PW) on a Shelly (Gen2+).
Enables HTTP digest auth: user 'admin', realm = the device id (per-device),
ha1 = sha256("admin:<realm>:<password>"). Same cleartext password across the
fleet, different ha1 per device. After this, _rpc authenticates automatically.
Run this AFTER the device is verified on VLAN30 (last step per device).
- confirm MUST be true to write; SHELLY_DEVICE_PW must be set.
- confirm=false is a dry-run: returns the computed realm (no write).
"""
if not DEVICE_PW:
return {"ip": ip, "ok": False, "error": "SHELLY_DEVICE_PW not set in environment"}
try:
info = _rpc(ip, "Shelly.GetDeviceInfo").get("result") or {}
except Exception as e:
return {"ip": ip, "ok": False, "error": f"{type(e).__name__}: {e}"}
realm = info.get("id")
if not realm:
return {"ip": ip, "ok": False, "error": "could not read device id (realm)"}
ha1 = hashlib.sha256(f"admin:{realm}:{DEVICE_PW}".encode()).hexdigest()
if not confirm:
return {"ip": ip, "ok": False, "dry_run": True, "realm": realm,
"would_send": {"method": "Shelly.SetAuth",
"params": {"user": "admin", "realm": realm,
"ha1": ha1[:12] + "…"}}}
try:
res = _rpc(ip, "Shelly.SetAuth", {"user": "admin", "realm": realm, "ha1": ha1})
return {"ip": ip, "ok": True, "realm": realm, "set_result": res,
"note": "admin auth enabled; future RPC uses digest via SHELLY_DEVICE_PW"}
except Exception as e:
return {"ip": ip, "ok": False, "error": f"{type(e).__name__}: {e}"}
@mcp.tool()
def verify_on_iot(ip: str) -> dict:
"""Verify a device now sits on the IOT SSID / VLAN30 (post-migration check).
ip = the device's NEW (10.40.30.x) address. Confirms reachability + SSID.
"""
ident = discover.probe(ip)
if not ident:
return {"ip": ip, "on_iot": False, "reachable": False}
out = {"ip": ip, "reachable": True, "identity": ident}
try:
st = _rpc(ip, "WiFi.GetStatus")
ssid = (st.get("result") or {}).get("ssid")
out["ssid"] = ssid
out["on_iot"] = (ssid == IOT_SSID) and ip.startswith("10.40.30.")
except Exception as e:
out["rpc_error"] = f"{type(e).__name__}: {e}"
out["on_iot"] = False
return out
@mcp.tool()
def reservation_plan(sweep: str | None = None) -> dict:
"""Build the VLAN30 DHCP-reservation plan (MAC → suggested .30.x, keeping last octet).
Suggests preserving each Shelly's current last octet under 10.40.30.0/24.
Output feeds MikroMCP manage_dhcp_lease. Read-only.
"""
found = _sweep(sweep)
rows = []
for f in found:
last = f["ip"].split(".")[-1]
rows.append({"name": f.get("inv_name") or f.get("inv_id") or "?",
"mac": f["mac"], "current_ip": f["ip"],
"suggested_v30_ip": f"10.40.30.{last}", "gen": f["gen"]})
return {"count": len(rows), "server": "dhcp-v30", "reservations": rows}
# ---------------- browser-viewable routes ----------------
# NOTE: sync route fns — Starlette runs them in a threadpool, so the blocking
# network sweep never freezes the async event loop (which also serves /mcp).
@mcp.custom_route("/health", methods=["GET"])
def health(_req: Request) -> JSONResponse:
return JSONResponse({"status": "ok", "server": "shelly-mcp",
"iot_ssid": IOT_SSID, "psk_configured": bool(IOT_PSK),
"mcp_endpoint": "/mcp"})
@mcp.custom_route("/", methods=["GET"])
def index(_req: Request) -> HTMLResponse:
try:
found = _sweep()
except Exception as e:
found = []
err = f"{type(e).__name__}: {e}"
else:
err = None
rows = "".join(
f"<tr><td>{f.get('inv_id') or '?'}</td><td>{f['ip']}</td>"
f"<td>g{f['gen']}</td><td>{f['model']}</td><td>{f['fw']}</td>"
f"<td>{f['mac']}</td><td>{f.get('inv_name') or ''}</td>"
f"<td>{'⚠︎ '+(f.get('inv_ip') or '') if f.get('ip_drifted') else ''}</td></tr>"
for f in found)
html = f"""<!doctype html><meta charset=utf-8>
<title>shelly-mcp</title>
<style>body{{font:14px/1.5 system-ui;margin:2rem;color:#222}}
h1{{font-size:1.3rem}} table{{border-collapse:collapse;margin-top:1rem}}
td,th{{border:1px solid #ccc;padding:.25rem .6rem;text-align:left}}
th{{background:#f4f4f4}} .m{{color:#666}}</style>
<h1>shelly-mcp <span class=m>· TP17 VLAN1→VLAN30</span></h1>
<p class=m>MCP endpoint <code>/mcp</code> · target SSID <b>{IOT_SSID}</b> ·
PSK {'set' if IOT_PSK else '<b>NOT set</b>'} · sweep <code>{SWEEP}</code></p>
{'<p style=color:#b00>'+err+'</p>' if err else ''}
<p>{len(found)} Shellys reachable:</p>
<table><tr><th>id<th>ip<th>gen<th>model<th>fw<th>mac<th>name<th>drift</tr>{rows}</table>
"""
return HTMLResponse(html)
if __name__ == "__main__":
mcp.run(transport="streamable-http")

23
mcp/sonos/README.md Normal file
View File

@@ -0,0 +1,23 @@
# sonos-inv — Sonos-Inventar/Verifikation (zero-dep, KEIN MCP)
Bewusste Entscheidung statt Sonos-MCP-Container: für unseren Zweck (Bestand erheben +
VLAN-Umzug verifizieren) reicht ein kleines stdlib-Python-Skript über die Sonos-`:1400`-API.
Ein SoCo-MCP-Container bräuchte einen gepatchten Fork (Multicast-only, kein Seeding, kein
Image) und je VLAN-Partition einen Seed — Aufwand ohne Mehrwert, solange wir kein
Playback per Chat steuern. **Sonos hat keine API zum WLAN-Umstellen** — daher hier nicht abgebildet.
## Nutzung
```
./sonos-inv.py # nur lokales Segment (SSDP)
./sonos-inv.py --seeds 10.40.40.199 # + VLAN-40-Partition (BF-Standard)
./sonos-inv.py --seeds 10.40.40.199 --json # maschinenlesbar
./sonos-inv.py --seeds 10.40.40.199 --verify 40 # Exit!=0 wenn ein Gerät nicht in VLAN 40
```
Von einem **VLAN-1-Host** ausführen (z.B. Mac `10.40.41.82`); inter-VLAN-L3 ist offen (bis TP5),
daher sind die VLAN-40-Geräte per Seed-IP erreichbar.
## Gotcha — Discovery ist pro VLAN partitioniert
SSDP-Multicast quert keine VLANs → ein Seed **je Partition** nötig. Lokales SSDP deckt das
Segment des ausführenden Hosts ab (VLAN 1), `--seeds 10.40.40.199` zieht die VLAN-40-Partition dazu.
Bestand & Migrationsplan: `../../docs/sonos-inventar.md`.

152
mcp/sonos/sonos-inv.py Executable file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""sonos-inv — zero-dep Sonos-Inventar & Verifikation über die :1400-HTTP-API.
KEIN MCP/Daemon, KEINE Fremd-Libs (nur Python-stdlib). Ersetzt für unseren Zweck
(Inventar + Umzugs-Verifikation) einen Sonos-MCP-Container. Sonos hat KEINE API zum
WLAN-Umstellen — das ist hier bewusst nicht abgebildet.
WICHTIG — Discovery ist bei Sonos pro L2/VLAN **partitioniert** (SSDP-Multicast quert
keine VLANs). Darum: SSDP auf dem lokalen Segment + optional **ein Seed je weiterer
Partition**. Bei BF: lokal (VLAN 1) + Seed 10.40.40.199 (VLAN 40).
Usage:
./sonos-inv.py # nur lokales Segment (SSDP)
./sonos-inv.py --seeds 10.40.40.199 # + VLAN-40-Partition
./sonos-inv.py --seeds 10.40.40.199,10.40.41.193 # explizit beide Partitionen
./sonos-inv.py --json # maschinenlesbar
./sonos-inv.py --verify 40 # Exit!=0 wenn ein Gerät NICHT in VLAN 40
Felder je Gerät: ip, vlan(rateil aus 3. Oktett), model, zone, mac, swgen(1=S1/2=S2),
sw, wifi(WifiEnabled), eth(EthLink).
"""
import argparse, html, json, re, socket, sys, urllib.request
TIMEOUT = 5
SOAP_BODY = (
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>'
'<u:GetZoneGroupState xmlns:u="urn:schemas-upnp-org:service:ZoneGroupTopology:1">'
'</u:GetZoneGroupState></s:Body></s:Envelope>'
)
def _get(ip, path, retries=2):
for _ in range(retries):
try:
return urllib.request.urlopen(f"http://{ip}:1400{path}", timeout=TIMEOUT).read().decode(errors="replace")
except Exception:
continue
return ""
def ssdp_discover(timeout=3):
"""SSDP M-SEARCH nach ZonePlayern auf dem lokalen Segment → set(IPs)."""
msg = "\r\n".join([
"M-SEARCH * HTTP/1.1", "HOST: 239.255.255.250:1900", 'MAN: "ssdp:discover"',
"MX: 2", "ST: urn:schemas-upnp-org:device:ZonePlayer:1", "", "",
])
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
s.settimeout(timeout)
found = set()
try:
s.sendto(msg.encode(), ("239.255.255.250", 1900))
import time
t = time.time()
while time.time() - t < timeout + 0.5:
try:
_, addr = s.recvfrom(2048)
found.add(addr[0])
except socket.timeout:
break
finally:
s.close()
return found
def zone_group_members(ip):
"""GetZoneGroupState an einem Gerät → {ip: {attr: val}} aller Member dieser Partition."""
req = urllib.request.Request(
f"http://{ip}:1400/ZoneGroupTopology/Control", data=SOAP_BODY.encode(),
headers={"Content-Type": "text/xml; charset=utf-8",
"SOAPACTION": '"urn:schemas-upnp-org:service:ZoneGroupTopology:1#GetZoneGroupState"'})
try:
d = urllib.request.urlopen(req, timeout=TIMEOUT).read().decode(errors="replace")
except Exception:
return {}
m = re.search(r"<ZoneGroupState>(.*)</ZoneGroupState>", d, re.S)
body = html.unescape(m.group(1)) if m else d
members = {}
for tag in re.finditer(r"<(?:ZoneGroupMember|Satellite)\s+([^>]*?)/?>", body):
attrs = dict(re.findall(r'(\w+)="([^"]*)"', tag.group(1)))
loc = attrs.get("Location", "")
mip = re.search(r"http://([\d.]+):", loc)
if mip:
members[mip.group(1)] = attrs
return members
def device_detail(ip):
"""Modell + MAC aus device_description/status/zp (was ZGS nicht liefert)."""
dd = _get(ip, "/xml/device_description.xml")
zp = _get(ip, "/status/zp")
def g(pat, src):
mm = re.search(pat, src)
return mm.group(1) if mm else ""
return {
"model": g(r"<modelName>(.*?)</modelName>", dd),
"modelnum": g(r"<modelNumber>(.*?)</modelNumber>", dd),
"mac": g(r"<SerialNumber>(.*?)</SerialNumber>", zp).split(":")[0], # "AA-BB-..-XX:3" → MAC
}
def vlan_of(ip):
o = ip.split(".")
if len(o) == 4 and o[0] == "10" and o[1] == "40":
return {"10": "10", "20": "20", "30": "30", "40": "40", "50": "50",
"90": "90", "99": "99", "41": "1/legacy"}.get(o[2], o[2])
return "?"
def inventory(seeds):
candidates = set(ssdp_discover()) | set(seeds)
members = {}
for ip in sorted(candidates):
if ip in members:
continue
members.update(zone_group_members(ip))
rows = []
for ip in sorted(members, key=lambda x: tuple(int(p) for p in x.split("."))):
a = members[ip]
det = device_detail(ip)
rows.append({
"ip": ip, "vlan": vlan_of(ip),
"model": f"{det['model']} ({det['modelnum']})".strip(),
"zone": a.get("ZoneName", ""), "mac": det["mac"],
"swgen": a.get("SWGen", ""), "sw": a.get("SoftwareVersion", ""),
"wifi": a.get("WifiEnabled", ""), "eth": a.get("EthLink", ""),
})
return rows
def main():
ap = argparse.ArgumentParser(description="Sonos-Inventar/Verifikation (zero-dep, :1400)")
ap.add_argument("--seeds", default="", help="Komma-IPs, je 1 pro weiterer VLAN-Partition (z.B. 10.40.40.199)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--verify", metavar="VLAN", help="Exit!=0 wenn ein Gerät NICHT im genannten VLAN ist")
args = ap.parse_args()
seeds = [s.strip() for s in args.seeds.split(",") if s.strip()]
rows = inventory(seeds)
if args.json:
print(json.dumps(rows, indent=2, ensure_ascii=False))
else:
h = f"{'IP':15} {'VLAN':9} {'Modell':28} {'Zone':18} {'MAC':18} {'Gen':3} {'SW':14} {'Wifi':4} {'Eth':3}"
print(h); print("-" * len(h))
for r in rows:
print(f"{r['ip']:15} {r['vlan']:9} {r['model']:28.28} {r['zone']:18.18} {r['mac']:18} "
f"{r['swgen']:3} {r['sw']:14} {r['wifi']:4} {r['eth']:3}")
print(f"\n{len(rows)} Sonos. (Discovery pro VLAN partitioniert — Seeds: {seeds or 'nur lokal'})")
if args.verify:
bad = [r["ip"] for r in rows if r["vlan"] != args.verify]
if bad:
print(f"VERIFY FAIL: nicht in VLAN {args.verify}: {bad}", file=sys.stderr)
sys.exit(1)
print(f"VERIFY OK: alle {len(rows)} in VLAN {args.verify}")
return 0
if __name__ == "__main__":
sys.exit(main())