Initial import from bf-network-r6.opencode — opencode migration
This commit is contained in:
5
mcp/.gitignore
vendored
Normal file
5
mcp/.gitignore
vendored
Normal 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
42
mcp/INVENTORY.md
Normal 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
49
mcp/README.md
Normal 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.
|
||||
22
mcp/cloudflare/.env.example
Normal file
22
mcp/cloudflare/.env.example
Normal 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
38
mcp/cloudflare/README.md
Normal 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
16
mcp/komodo/.env.example
Normal 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
255
mcp/komodo/MIGRATION.md
Normal 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
|
||||
56
mcp/komodo/core-compose.yml
Normal file
56
mcp/komodo/core-compose.yml
Normal 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
|
||||
34
mcp/komodo/komodo-mcp-compose.yml
Normal file
34
mcp/komodo/komodo-mcp-compose.yml
Normal 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
|
||||
21
mcp/komodo/periphery.compose.yml
Normal file
21
mcp/komodo/periphery.compose.yml
Normal 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
11
mcp/mikromcp/.env.example
Normal 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=
|
||||
60
mcp/mikromcp/config/routers.yaml
Normal file
60
mcp/mikromcp/config/routers.yaml
Normal 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"
|
||||
35
mcp/mikromcp/docker-compose.yml
Normal file
35
mcp/mikromcp/docker-compose.yml
Normal 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
23
mcp/omada/.env.template
Normal 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
|
||||
34
mcp/omada/docker-compose.yml
Normal file
34
mcp/omada/docker-compose.yml
Normal 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
41
mcp/portainer/README.md
Normal 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
7
mcp/shelly/.env.example
Normal 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
40
mcp/shelly/README.md
Normal 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
124
mcp/shelly/discover.py
Normal 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
321
mcp/shelly/migrate.py
Normal 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()
|
||||
16
mcp/shelly/migration-log.jsonl
Normal file
16
mcp/shelly/migration-log.jsonl
Normal 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
295
mcp/shelly/server.py
Normal 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
23
mcp/sonos/README.md
Normal 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
152
mcp/sonos/sonos-inv.py
Executable 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())
|
||||
Reference in New Issue
Block a user