commit f2fdf9589703cf3adf4173643c6f841783335503 Author: root Date: Fri Jun 26 20:41:35 2026 +0200 v0.5.0: Add RBAC auth, LLM router, audit logging, output sanitization - New: auth.py – Role-based access (operator/approver/viewer) - New: llm_router.py – Configurable primary/fallback provider chain - New: audit_log.py + audit_query.py – DSGVO-compliant audit trail - New: sanitize_output() – masks secrets in bot responses - Updated: bot.py – Integrated auth guards, router, audit logging - Updated: ollama_client.py – Router-compatible interface - Updated: CLOWNFISCHSERVER.md – Documentation updated - Updated: README.md – Documentation updated diff --git a/CLOWNFISCHSERVER.md b/CLOWNFISCHSERVER.md new file mode 100644 index 0000000..81925ee --- /dev/null +++ b/CLOWNFISCHSERVER.md @@ -0,0 +1,467 @@ +# 🐠 Clownfischserver – Projekt-Kontext + +> Diese Datei ist der zentrale Wissensspeicher für das Clownfischserver-Projekt. +> Lade sie in jede KI/Chat-Session um sofort produktiv zu sein. +> Letzte Aktualisierung: v0.5.0 (30.05.2026) + +> **⚠️ Bewusst entfernt (nicht wieder einbauen!):** Ein „Vault/MCP-Context"-System +> (`_get_vault_context`, `MCP_VAULT_URL`, `MCP_API_KEY`) wurde in v0.5.0 komplett +> entfernt. Es war toter Code, nutzte `verify=False` und schickte Chat-Nachrichten an +> externe Endpoints – ein klarer DSGVO-Verstoß und Widerspruch zum „local-first"-Prinzip. +> Token-Optimierung über Obsidian gehört NICHT in den Bot. + +--- + +## Was ist der Clownfischserver? + +Ein Open-Source, Telegram-gesteuerter Linux-Server-Manager. Der Admin schreibt dem Bot über Telegram – der Bot versteht, generiert Befehle, erstellt Scripts, verwaltet den Server. Komplett lokal mit Ollama, optional Cloud-Fallback über OpenRouter. + +**Repo:** https://github.com/mehlzoerwer-claude/clownfischserver +**Lizenz:** GPL-3.0 +**Entwickler:** Mehlzoerwer-Claude (Thorsten Matthes) +**KI-Assistent:** Claude (Anthropic) – Pair-Programming Partner + +--- + +## Architektur + +``` + ┌─────────────────────┐ + │ Telegram Bot │ + │ (bot.py) │ + └──────┬──────┬────────┘ + │ │ + ┌─────────────────┤ ├─────────────────┐ + ▼ ▼ ▼ ▼ + ┌────────────────┐ ┌────────────┐ ┌──────────────────┐ + │ ollama_client │ │ shell.py │ │ aider_wrapper │ + │ (Dual-Model) │ │ (run_shell)│ │ (Code-Agent) │ + └───────┬────┬────┘ └────────────┘ └──────────────────┘ + │ │ + ┌────────┘ └────────┐ + ▼ ▼ +┌──────────┐ ┌──────────────┐ ┌──────────────┐ +│ Chat-Model│ │ Coder-Model │ │ OpenRouter │ +│ (gemma3) │ │(qwen2.5-coder)│ │ (Fallback) │ +└──────────┘ └──────────────┘ └──────────────┘ +``` + +**Prinzip:** Freitext = Chat. `/shell` = KI generiert Befehl → Admin bestätigt mit `/ja`. `/code` = Aider erstellt Dateien. `/run` = direkte Shell. + +--- + +## Dateistruktur (19 Dateien, ~3250 Zeilen) + +``` +clownfischserver/ +├── bot/ +│ ├── bot.py # Hauptbot: alle Commands, Auth, Self-Update, Update-Dialog +│ ├── ollama_client.py # Dual-Model, OpenRouter Fallback, NUM_CTX, Shell-Prompt +│ ├── openrouter_client.py # OpenRouter Free Models Client (openrouter/free) +│ ├── shell.py # run_shell (shlex.quote), execute_command +│ ├── aider_wrapper.py # Aider mit --edit-format diff, 600s Timeout +│ ├── snapshot.py # tar.gz Snapshots, Workspace, Paketliste, sudo rsync +│ ├── boot_notify.py # Telegram-Nachricht 120s nach Boot +│ ├── knock_notify.py # Port-Knocking SSH-Benachrichtigungen +│ └── requirements.txt # python-telegram-bot, requests, python-dotenv +├── config/ +│ ├── .env.example # Vorlage mit allen Variablen +│ └── knockd.conf # Port-Knocking Sequenz (7000→8000→9000) +├── systemd/ +│ ├── clownfisch.service # Bot-Service (User=clownfish, wird gepatcht) +│ └── clownfisch-boot.service # Boot-Notification (oneshot, 120s delay) +├── install.sh # 8-Schritt Installer mit Modell/CTX-Auswahl +├── README.md # Vollständige Dokumentation +├── LICENSE # GPL-3.0 +├── .gitignore +├── CONTRIBUTING.md +└── GITHUB_PROFILE.md +``` + +--- + +## Wichtige Konventionen + +### Naming +- **Service:** `clownfisch.service` (Deutsch, mit "ch") +- **Linux-User:** Beliebig (z.B. `clownfisch`, `clownfish`, `admin`) – wird aus `MGMT_USER` in `.env` gelesen +- **Branding:** Immer "Clownfisch" (Deutsch) +- **ZIP-Benennung:** `v0.5.0-clownfischserver.zip` + +### Code-Stil +- Python 3, keine Type Hints in Funktionssignaturen (außer Optional) +- Logging über `logging` Modul +- Async für alle Telegram-Handler +- `safe_reply()` für alle Telegram-Nachrichten (Markdown-Fallback) +- Fehler werden dem User angezeigt, nie verschluckt + +### Sicherheits-Regeln +- Configs (`sshd_config`, `knockd.conf`) NIE überschreiben – nur gezielte Zeilen ändern +- `.env` Permissions: 0600 +- SSH Host-Keys, `/etc/shadow` NIE in Snapshots +- `.env` seit v0.4.12 aus Snapshots excludiert +- Self-Update: systemd-Services werden mit `MGMT_USER` aus `.env` gepatcht (Regex: `User=\S+`) + +--- + +## .env Variablen + +``` +TELEGRAM_BOT_TOKEN=... # Bot-Token von @BotFather +TELEGRAM_CHAT_ID=... # Autorisierte Chat-ID (Single-User) +OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_MODEL=qwen2.5-coder:7b # Coder-Modell für /shell und /code +OLLAMA_MODEL_FAST=gemma3:4b # Chat-Modell für Freitext +OLLAMA_NUM_CTX=16384 # Context-Window (4096-32768) +OPENROUTER_API_KEY=sk-or-v1-... # Optional: kostenloser Cloud-Fallback +SNAPSHOT_DIR=/opt/clownfisch-snapshots +SNAPSHOT_METHOD=tar +INSTALL_DIR=/opt/clownfischserver +MGMT_USER=clownfisch # Linux-User (beliebig wählbar) +AIDER_WORKDIR=/opt/clownfisch-workspace +``` + +--- + +## Bot-Befehle + +| Befehl | Funktion | +|--------|----------| +| Freitext | Chat mit KI (gemma3 lokal, OpenRouter Fallback) | +| `/shell ` | KI generiert Shell-Befehl, non-interaktiv | +| `/ja` | Letzten `/shell` Befehl ausführen (mit Snapshot davor) | +| `/code ` | Aider erstellt Dateien (Smart Prompt: Dateiname wird ergänzt) | +| `/run ` | Direkte Shell, kein KI, kein Bestätigen | +| `/status` | CPU, RAM, Disk, Top-Prozesse | +| `/ssh open [ip]` | SSH via ufw öffnen | +| `/ssh close [ip]` | SSH via ufw schließen | +| `/snapshot [label]` | Manuell Snapshot erstellen | +| `/snapshot keep/delete ` | Snapshot markieren/löschen | +| `/snapshots` | Alle Snapshots anzeigen | +| `/rollback ` | Zu Snapshot zurückkehren (sudo rsync --delete) | +| `/start` / `/help` | Bot-Info und Befehlsübersicht | +| ZIP senden | Self-Update mit optionalem OpenRouter-Setup-Dialog | + +--- + +## Dual-Model Strategie + +| Rolle | Modell | RAM | Aufgabe | +|-------|--------|-----|---------| +| Coder | qwen2.5-coder:7b | ~4.7GB | `/shell`, `/code`, Befehlsgenerierung | +| Chat | gemma3:4b | ~3.3GB | Freitext, Erklärungen, Übersetzungen | +| Fallback | openrouter/free | 0 (Cloud) | Wenn Ollama nicht verfügbar | + +**Shell-Prompt:** Few-Shot mit non-interaktiven Regeln (kein nano/vim/crontab -e, sudo wenn nötig). +**Chat-Prompt:** Deutsch als Standard, antwortet in Sprache des Nutzers. +**Explain-Prompt:** Erklärt Befehlsergebnisse auf Deutsch. + +--- + +## Snapshot-System + +**Gesichert:** `/etc`, `/opt/clownfischserver`, `/home`, `/opt/clownfisch-workspace` +**Excludiert:** SSH Host-Keys, shadow, `.env`, `__pycache__`, `node_modules` +**Paketliste:** `dpkg --get-selections` pro Snapshot als `.packages.txt` +**Rollback:** `sudo rsync -av --delete` pro Verzeichnis +**Auto-Cleanup:** Max 20 Snapshots, `keep`-markierte bleiben immer + +--- + +## OpenRouter Integration + +- **Client:** `openrouter_client.py` mit `openrouter/free` Auto-Router +- **Fallback-Trigger:** Ollama Timeout ODER "nicht erreichbar" +- **Betrifft:** Chat + `/shell` (nicht `/code` – Aider braucht Ollama direkt) +- **Setup:** Beim Self-Update fragt Bot "OpenRouter aktivieren?" → Key eingeben → `.env` +- **Kosten:** Free Tier: 50 Requests/Tag, 20/Minute, keine Kreditkarte + +--- + +## Bekannte Bugs / Einschränkungen + +- `/code` hat keinen OpenRouter-Fallback (Aider spricht nur Ollama) +- Kleine Modelle (< 8B) halluzinieren bei Erklärungen +- Aider `diff`-Format überfordert kleine Modelle manchmal → Datei wird nicht erstellt +- Shell-Prompt vergisst manchmal `sudo` bei Root-Operationen + +--- + +## Server-Umgebung + +- **Hoster:** netcup VPS +- **OS:** Ubuntu 24.04 LTS +- **RAM:** 16GB +- **Disk:** 300GB +- **IP:** 92.60.38.175 +- **Ollama:** qwen2.5-coder:7b + gemma3:4b (oder gemma4:e2b) +- **Docker:** installiert +- **SSH:** Port-Knocking via knockd (7000→8000→9000) + +--- + +## Roadmap + +### v0.5.0 – Sicherheit + Features +**Security (DSGVO-Pflicht):** +- Sensitive Dateien über Telegram blockieren (`.env`, shadow, Keys) +- Output-Sanitizer: API Keys/Tokens in JEDER Bot-Antwort maskieren +- Log-Sanitizer: Keine Secrets im Audit-Log +- Audit-Log: Jeder Befehl mit Timestamp (DSGVO Art. 5/32) +- SHA256-Hash bei Self-Update + Admin-Bestätigung +- `.env` Permissions-Check beim Bot-Start +- `bot.log`, `.aider.chat.history.md`, `bash_history` aus Snapshots excluden +- Rate-Limiting: Max 30 Befehle/Minute +- Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut +- 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot) +- Persönlicher Code-Offset (SECURITY_OFFSET in .env) + +**Features:** +- Snapshot-Scheduling + Export via Telegram +- Verschlüsselte Snapshots (SNAPSHOT_PASSWORD) +- `/logs`, `/docker` +- Port-Knocking Sequenz wählbar +- Open Interpreter als Aider-Alternative evaluieren + +**Quick-Win Security:** +- Panik-Wort: Admin-definiertes Wort → Bot löscht .env, sperrt alles +- Honeypot-Befehle: Fake-Commands die Eindringlinge verraten +- Selbstzerstörende Nachrichten: Bot löscht eigene Outputs nach X Minuten +- Rollback-Bombe: 3x falscher 2FA → auto-Rollback + SSH sperren + Alert +- Dead Man's Switch: Kein Lebenszeichen in 24h → Bot sperrt sich selbst + +### v0.6.0 – Multi-Server + Gedächtnis +- Ein Bot steuert mehrere Server +- Audit-Log / Server-Gedächtnis (server_log.txt) +- Chat kann vergangene Aktionen abfragen + +### v0.7.0 – DSGVO-bewusster Backup-Manager +- Verschlüsselte Snapshots (SNAPSHOT_PASSWORD in .env) + +### v0.8.0 – Erweiterte Cloud-API +- Premium-Modelle auf OpenRouter +- Anthropic/OpenAI direkt +- Kosten-Tracking + +### v0.9.0 – Multi-Interface + Advanced Security +**Alternative Interfaces (E2E-verschlüsselt):** +- Matrix/Element Connector (matrix-nio, self-hosted, E2E) +- Signal Connector (signal-cli, E2E) +- Web-UI (HTTPS, self-hosted, Let's Encrypt) +- SSH-CLI (interaktive Shell-Session, kein Messenger nötig) +- E-Mail mit GPG-Verschlüsselung + +**Advanced Security Features (evaluieren bei v0.8.x Abschluss):** +- Fake-Modus: Falscher 2FA-Code → Bot tut so als ob, führt nichts aus, stiller Alarm +- Distress-Code: Umgekehrter Offset = "Ich werde gezwungen" → Fake-Modus +- Geo-Fencing: Bot antwortet nur aus erlaubten Ländern/IP-Bereichen +- Buddy-System: Kritische Befehle brauchen Bestätigung von zweitem Admin +- Canary Token: Unsichtbare Marker-Dateien, Alert bei Zugriff +- Verschlüsselter Rückkanal: Bot-Antworten verschlüsselt über Telegram +- Hardware-Token: USB-Stick als physischer Schlüssel + +**Architektur-Ziel:** +``` +[Telegram] ─┐ +[Matrix] ─┤ +[Web-UI] ─┼─→ [bot_core.py] → [Ollama/OpenRouter] +[SSH-CLI] ─┤ +[E-Mail] ─┘ +``` +Ein Bot, viele Eingänge. Admin wählt was er will. + +### v1.0.0 – Disaster Recovery +### v2.0.0 – Cross-Distribution Server Migration + +--- + +## Getestete Modelle + +| Modell | Größe | Rolle | Ergebnis | +|--------|-------|-------|----------| +| qwen2.5-coder:7b | 4.7GB | Coder | ✅ Beste Wahl für Code | +| qwen2.5-coder:3b | 1.9GB | Coder | ✅ Funktioniert, schwächer | +| gemma3:4b | 3.3GB | Chat | ✅ Gutes Deutsch, schnell | +| gemma4:e2b | 7.2GB | Beide | ⚠️ Zu groß für Dual-Setup | +| rnj-1:8b | 5.1GB | Coder | ❓ Test ausstehend | +| qwen3.5:4b | – | Coder | ❌ Thinking, zu langsam auf CPU | +| openrouter/free | Cloud | Fallback | ✅ Nemotron-H, stark | + +--- + +## Wichtige Design-Entscheidungen + +1. **Local-first:** Cloud ist Opt-in Werkzeug, nicht Abhängigkeit +2. **Kein Classifier:** Freitext = Chat. Befehle explizit per `/shell`, `/run`, `/code` +3. **Bestätigungs-Flow:** `/shell` generiert → Admin prüft → `/ja` führt aus +4. **Beliebiger Username:** MGMT_USER wird überall per Regex eingesetzt +5. **Smart `/code` Prompt:** Bot ergänzt Dateiname wenn User keinen nennt +6. **Snapshot vor destruktiven Aktionen:** Automatisch bei apt install, rm, chmod etc. +7. **DSGVO by design:** Lokal, keine Daten an Cloud, Snapshots ohne Secrets + +--- + +## Security-Analyse (v0.4.13) + +### Was wir haben ✅ +- Chat-ID Auth (Single-User) +- `.env` mit 0600 Permissions +- SSH Host-Keys + shadow excluded aus Snapshots +- `.env` excluded aus Snapshots (seit v0.4.12) +- Port-Knocking statt offenes SSH +- Lokal-first, Cloud opt-in +- Safe-Reply Markdown-Fallback (kein Crash bei Sonderzeichen) + +### Bekannte Schwachstellen ❌ + +**Telegram als Angriffsfläche:** +- Bot-Token im Klartext in `.env` – wer den Server hackt hat den Bot +- Telegram Chat-History enthält alle Befehle + Outputs – nicht löschbar vom Bot +- Telegram API ist NICHT E2E-verschlüsselt – Telegram Inc. kann mitlesen +- Jemand der das Handy entsperrt hat vollen Zugriff +- `.env` Inhalte können per `/run cat .env` über Telegram geleakt werden + +**Bot-Sicherheit:** +- Kein Rate-Limiting – Brute-Force auf Befehle möglich +- Kein Session-Timeout – Bot antwortet 24/7 ohne erneute Auth +- `bot.log` enthält möglicherweise sensible Daten (unverschlüsselt) +- `.aider.chat.history.md` loggt alle Code-Prompts im Klartext +- Ollama API auf localhost:11434 hat keine Auth – jeder lokale Prozess kann anfragen + +**Snapshots:** +- Snapshots enthalten `/home` – SSH Keys, bash_history, private Dateien +- Snapshot-Verzeichnis nicht verschlüsselt +- Kein Integrity-Check ob Snapshots manipuliert wurden + +**Netzwerk:** +- knockd Sequenz steht im Klartext in `knockd.conf` +- ufw Regeln können über `/run` beliebig geändert werden + +**Server:** +- NOPASSWD sudo – kompromittierter Bot = Root +- `install.sh` lädt Pakete aus dem Internet ohne Signatur-Check +- Docker Socket zugänglich → Container-Escape möglich + +### Security-Maßnahmen geplant + +**v0.5.0 (realistisch umsetzbar):** +1. `.env`/Secrets über Telegram blockieren (Blacklist für sensitive Pfade) +2. Output-Sanitizer: API Keys/Tokens in JEDER Bot-Antwort maskieren +3. Log-Sanitizer: Keine Secrets im Audit-Log +4. `bot.log` + `.aider.chat.history.md` + `bash_history` aus Snapshots excluden +5. Rate-Limiting: Max 30 Befehle/Minute +6. Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut nötig +7. 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot) +8. Persönlicher Code-Offset (SECURITY_OFFSET in .env) +9. SHA256-Hash bei Self-Update → Admin bestätigt +10. `.env` Permissions-Check beim Bot-Start (warnen wenn nicht 0600) +11. Audit-Log mit Timestamp für jeden Befehl (DSGVO Art. 5/32) + +**v0.6.0+ (langfristig):** +12. Bot-Token Rotation +13. Verschlüsselte Snapshots (SNAPSHOT_PASSWORD) +14. Integrity-Hashes für Snapshots (Manipulation erkennen) +15. Ollama API Auth +16. Audit-Log mit Tamper-Protection (Signierung) + +### DSGVO-Regel für Telegram +`.env` wird NUR angelegt/geändert über SSH. Niemals über Telegram lesbar. +Der Bot BLOCKIERT Befehle die sensitive Dateien über Telegram ausgeben würden. + +--- + +## ZIP bauen – exakte Vorgehensweise + +### Voraussetzungen +- Alle 20 Dateien müssen im Ordner `clownfischserver/` liegen +- Python-Dateien müssen syntax-geprüft sein +- install.sh muss bash-syntax-geprüft sein + +### Schritt 1: Syntax-Check (IMMER vor dem ZIP) +```bash +cd clownfischserver +python3 -m py_compile bot/bot.py +python3 -m py_compile bot/ollama_client.py +python3 -m py_compile bot/openrouter_client.py +python3 -m py_compile bot/shell.py +python3 -m py_compile bot/snapshot.py +python3 -m py_compile bot/aider_wrapper.py +python3 -m py_compile bot/boot_notify.py +python3 -m py_compile bot/knock_notify.py +bash -n install.sh +``` + +### Schritt 2: ZIP erstellen +```bash +cd .. # Eine Ebene über clownfischserver/ +zip -r v0.5.0-clownfischserver.zip clownfischserver/ -x "*__pycache__*" "*.pyc" +``` + +**WICHTIG:** +- ZIP-Name: `v-clownfischserver-opus.zip` +- Root-Ordner im ZIP muss `clownfischserver/` heißen (nicht `build/` oder anderer Name) +- Keine `__pycache__` oder `.pyc` Dateien +- Keine `.git/` Ordner +- Keine `.env` mit echten Credentials + +### Schritt 3: Verifizieren +```bash +unzip -l v0.5.0-clownfischserver.zip | head -30 +# Muss zeigen: clownfischserver/bot/bot.py etc. (20 Dateien) +``` + +### Schritt 4: Einspielen +**Per Telegram:** ZIP an den ClownfischServer Bot senden → Self-Update Dialog +**Per Server:** `bash install.sh` im entpackten Ordner + +### Schritt 5: GitHub Push +```bash +cd clownfischserver +rm -rf .git +git init +git remote add origin https://github.com/mehlzoerwer-claude/clownfischserver.git +git add -A +git commit -m "v0.4.13 – Beschreibung der Änderungen" +git branch -M main +git push -u origin main --force +``` + +### Dateiliste (20 Dateien, vollständig) +``` +clownfischserver/ +├── .gitignore +├── CLOWNFISCHSERVER.md ← diese Datei +├── CONTRIBUTING.md +├── GITHUB_PROFILE.md +├── LICENSE +├── README.md +├── install.sh +├── bot/ +│ ├── aider_wrapper.py +│ ├── boot_notify.py +│ ├── bot.py +│ ├── knock_notify.py +│ ├── ollama_client.py +│ ├── openrouter_client.py +│ ├── requirements.txt +│ ├── shell.py +│ └── snapshot.py +├── config/ +│ ├── .env.example +│ └── knockd.conf +└── systemd/ + ├── clownfisch-boot.service + └── clownfisch.service +``` + +### Versions-Bump Checkliste +Bei neuer Version diese Stellen aktualisieren: +1. `install.sh` – Banner + Log-Meldung (3x `v0.4.13`) +2. `bot/bot.py` – `cmd_start` Begrüßung + `main()` Log-Meldung +3. `README.md` – Badge `Version-0.4.13` + Roadmap "Fertig (v0.1 – v0.4.13)" +4. `CLOWNFISCHSERVER.md` – Header + Dateiliste +5. ZIP-Dateiname + +--- + +> *"Angefangen als einfacher Telegram-Bot – gewachsen zu einer Vision."* 🐠 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..96b5b18 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing to Clownfischserver 🐠 + +Danke dass du mitmachen willst! Hier sind die Regeln: + +## Grundprinzipien + +- **Open Source bleibt Open Source** – GPL-3.0, keine Ausnahmen +- **Lokal first** – keine Cloud-Dependencies, keine externen APIs +- **Einfach bleiben** – ein Nutzer, ein Bot, ein Server +- **Sicherheit** – Safety-Check vor jeder Aktion + +## Pull Requests + +1. Fork das Repo +2. Branch erstellen: `git checkout -b feature/dein-feature` +3. Änderungen committen +4. PR öffnen mit Beschreibung was und warum + +## Code Style + +- Python: PEP8, async/await +- Bash: shellcheck-kompatibel +- Kommentare auf Deutsch oder Englisch +- Header in jeder Datei (siehe bestehende Dateien) + +## Code-Hygiene (wichtig!) + +- **Kein toter Code** – keine Funktionen/Module die definiert aber nie aufgerufen werden +- **Keine versteckten Cloud-Calls** – jeder externe Request muss klar ersichtlich und opt-in sein +- **Niemals `verify=False`** bei HTTPS-Requests +- **Keine User-Eingaben an Dritte** ohne explizites Opt-in (DSGVO by design) +- **Klare Conditions** – verschachtelte Ternaries (`A if B else C` innerhalb von `and`-Ketten) vermeiden +- Selbst-Review vor jedem Release: macht jede Zeile noch das was sie soll? + +## Issues + +- Bug? → Issue mit Log-Output +- Feature-Idee? → Issue mit Use-Case Beschreibung +- Frage? → Issue oder Discussions + +## Was wir suchen + +- Distro-Support (getestete Fedora/Arch Fixes) +- Neue Bot-Commands +- Bessere Safety-Prompts +- Dokumentation +- Tests + +## Was wir NICHT wollen + +- Cloud-Dependencies +- Closed-Source Komponenten +- Komplexität die Einsteiger abschreckt diff --git a/DSGVO.md b/DSGVO.md new file mode 100644 index 0000000..3ff7d54 --- /dev/null +++ b/DSGVO.md @@ -0,0 +1,149 @@ +# DSGVO-Konformität – Clownfischserver v0.5.0 + +> Dieser Stand bezieht sich auf v0.5.0. Frühere Versionen erfüllen nicht den vollen +> Funktionsumfang. v0.6.0 erweitert um Transport-Verschlüsselung und Backup-Konzept. + +Clownfischserver ist als **Gateway für lokal gehostete LLMs in Unternehmens­umgebungen** +positioniert. Datenschutz ist kein Add-on, sondern strukturelle Voraussetzung. + +Verantwortlich für die DSGVO-konforme Konfiguration im konkreten Einsatz bleibt +**der Betreiber**. Dieses Dokument beschreibt, welche technischen Maßnahmen die +Software dafür bereitstellt. + +--- + +## Art. 5 DSGVO – Grundsätze der Verarbeitung + +### Art. 5 (1) (a) – Rechtmäßigkeit, Verarbeitung nach Treu und Glauben, Transparenz + +Jeder privilegierte Vorgang wird im Audit-Trail (`audit.jsonl`) protokolliert: + +- Wer hat was wann ausgeführt? (`/shell`, `/code`, `/run`, `/ja`, `/ssh`, `/snapshot`, `/rollback`) +- Welches LLM-Modell hat eine Antwort generiert? (Audit-Event `llm.route`) +- Welche Zugriffe wurden abgelehnt? (`result: denied`) + +Operatoren können den Trail jederzeit via `/logs` einsehen +(`bot/audit_query.py`, `cmd_logs` in `bot/bot.py`). + +### Art. 5 (1) (b) – Zweckbindung + +`/shell` und `/code` benötigen eine explizite Bestätigung durch einen Approver +(`/ja`). Dieser Approval-Flow ist nicht umgehbar: jeder Befehl wird *vor* der +Ausführung als Vorschlag im Chat dargestellt, der ausführende Approver +hinterlässt seinerseits einen Audit-Eintrag (`shell.approval`). + +### Art. 5 (1) (c) – Datenminimierung + +- Audit-Einträge enthalten **nur die numerische Telegram User-ID** – keine + Klarnamen, keine Telegram-Usernames, keine E-Mail-Adressen. +- Befehlstexte werden auf `AUDIT_MAX_DETAIL_LEN` Zeichen (Default 2000) gekürzt. + Konfigurierbar via `.env`. +- Es findet **keine Telemetrie nach außen** statt. Sämtliche LLM-Calls können + konfigurationsabhängig vollständig on-prem gehalten werden + (`LLM_PRIMARY_PROVIDER=ollama`, `LLM_FALLBACK_PROVIDER=none`). + +### Art. 5 (1) (d) – Richtigkeit + +Audit-Einträge enthalten Eingaben (`description`, `command`) **und** Ergebnis +(`returncode`, ggf. `stderr`). Falschmeldungen lassen sich gegen den realen +Server­zustand verifizieren. + +### Art. 5 (1) (e) – Speicherbegrenzung + +`audit_log.prune_old_logs()` läuft beim Bot-Start und entfernt Einträge, +die älter als `AUDIT_RETENTION_DAYS` (Default 90 Tage) sind. + +- `AUDIT_RETENTION_DAYS=0` deaktiviert das Pruning bewusst (z. B. für + forensische Anforderungen mit längerer Aufbewahrungspflicht). +- Das Pruning nutzt einen atomic-rename, sodass auch ein Crash während des + Bereinigungslaufs keine Audit-Lücken erzeugt. + +### Art. 5 (1) (f) – Integrität und Vertraulichkeit + +- Audit-Datei wird nach jedem Append auf `0600` gesetzt (nur Eigentümer + lesbar/schreibbar). Auf Windows-Filesystemen ohne POSIX-Permissions wird + der Schreibvorgang nicht blockiert, sondern lediglich geloggt – Produktion + läuft per `install.sh` auf Linux. +- Sämtliche LLM-Kommunikation kann ausschließlich lokal stattfinden + (Ollama via `127.0.0.1:11434`). OpenRouter-Fallback ist **opt-in**. + +--- + +## Art. 32 DSGVO – Sicherheit der Verarbeitung + +### Art. 32 (1) (a) – Pseudonymisierung + +Audit-Einträge speichern numerische User-IDs, keine Namen oder Handles. +Die Zuordnung User-ID → Person verbleibt beim Betreiber, außerhalb der Software. + +### Art. 32 (1) (a) – Verschlüsselung + +- **In v0.5.0 nicht enthalten**: Die Audit-Datei liegt unverschlüsselt im + Filesystem. Schutz erfolgt ausschließlich über Dateisystem-Permissions + (`0600`, `MGMT_USER`). +- **Plan für v0.6.0**: HTTPS-only Webhook-Endpoint (Telegram-Bot ist + derzeit Long-Polling), at-rest-Encryption der Audit-Datei. + +### Art. 32 (1) (b) – Vertraulichkeit, Integrität, Verfügbarkeit + +- **Rollenbasierter Zugriff** (`bot/auth.py`): + - `operator` – darf Befehle vorschlagen, ausführen, Code generieren, + SSH-Firewall steuern, Snapshots verwalten, Rollback fahren. + - `approver` – darf vorgeschlagene Befehle bestätigen (`/ja`). + - `viewer` – darf Status und Snapshots einsehen, im Chat lesen. + - Inheritance: operator ⊃ approver ⊃ viewer. +- Jeder Endpunkt im Bot prüft die Rolle explizit; ein nicht autorisierter + Zugriff wird abgelehnt **und** als `result: denied` im Audit protokolliert. +- Verfügbarkeit: Bot läuft als `systemd`-Service (`clownfisch`); Boot-Notify- + Service informiert beim Neustart. + +### Art. 32 (1) (c) – Wiederherstellbarkeit + +`bot/snapshot.py` erstellt automatisch Snapshots vor potenziell zerstörerischen +Operationen (`/code`, ZIP-Upload, schreibende `/shell`-Befehle). `/rollback` +stellt einen Snapshot wieder her – ebenfalls per Operator-Rolle abgesichert. + +### Art. 32 (1) (d) – Verfahren zur Überprüfung + +Tests unter `tests/`: + +- `test_auth.py` – Rollenmodell + Backwards-Compat +- `test_audit_log.py` – Schreibverhalten, Pruning, Datenminimierung +- `test_audit_query.py` – Filterlogik, Format +- `test_llm_router.py` – Routing-Verhalten, Audit-Hooks + +`tests/_smoke.py`, `tests/_smoke_router.py`, `tests/_smoke_query.py` sind +standalone-Skripte für Einsatz ohne pytest. + +--- + +## Was Clownfischserver **nicht** automatisch erledigt + +Diese Punkte verbleiben beim Betreiber: + +- **Auftragsverarbeitungs-Vertrag** mit OpenRouter (falls Fallback aktiviert). + OpenRouter-Server stehen außerhalb der EU – aktiviere den Fallback nicht + für Daten mit Personenbezug. +- **Verzeichnis von Verarbeitungstätigkeiten** (Art. 30 DSGVO). +- **Datenschutz-Folgenabschätzung** bei großvolumiger Personendaten­verarbeitung + durch das gehostete LLM. +- **Backup-Strategie** für die Audit-Datei (in v0.6.0 geplant). +- **Schulung der Operatoren** im Umgang mit dem `/shell`-Approval-Flow. + +--- + +## Kurz: Welche `.env`-Variablen sind DSGVO-relevant? + +| Variable | Effekt | +|---|---| +| `CLOWNFISCH_OPERATORS` / `_APPROVERS` / `_VIEWERS` | Rollenzuweisung, Art. 32 (1) (b) | +| `AUDIT_LOG_PATH` | Pfad zur Audit-Datei (sollte auf verschlüsseltem Volume liegen) | +| `AUDIT_RETENTION_DAYS` | Speicherbegrenzung, Art. 5 (1) (e) | +| `AUDIT_MAX_DETAIL_LEN` | Datenminimierung, Art. 5 (1) (c) | +| `LLM_PRIMARY_PROVIDER=ollama` | On-prem-LLM, keine Cloud-Übertragung | +| `LLM_FALLBACK_PROVIDER=none` | Cloud-Fallback explizit deaktivieren | +| `OPENROUTER_API_KEY` | nur setzen wenn AV-Vertrag mit OpenRouter besteht | + +--- + +**Stand:** Juni 2026 · v0.5.0 · Mehlzoerwer-Claude diff --git a/GITHUB_PROFILE.md b/GITHUB_PROFILE.md new file mode 100644 index 0000000..3c56c73 --- /dev/null +++ b/GITHUB_PROFILE.md @@ -0,0 +1,27 @@ +# GitHub Profil Text für Mehlzoerwer-Claude + +## Bio (kurz, für das Profil-Feld): +Solo developer building open source server tools. Local AI, no cloud, no BS. + +## Profil README (optionaler langer Text): + +# Hey, ich bin Mehlzoerwer 👋 + +Solo-Entwickler mit einer Leidenschaft für selbst-gehostete, Open-Source Infrastruktur. + +**Was ich baue:** +- 🐠 [Clownfischserver](https://github.com/mehlzoerwer-claude/clownfischserver) – Telegram-gesteuerter, KI-gestützter Server-Manager +- 🌍 Weltklasse – ein KI OS Layer für natürliche Sprachsteuerung (in Entwicklung) + +**Meine Philosophie:** +- Local AI > Cloud AI +- Open Source immer +- Einfach > Komplex +- Funktioniert > Perfekt + +**Stack:** Python · Bash · Debian · Ollama · Telegram · Docker + +**KI-Partner:** Entwickle mit Claude (Anthropic) als Pair-Programming Partner + +--- +*"Keep it open. Always."* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9446b93 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright (C) 2025-2026 Clownfischserver Contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/README.md b/README.md new file mode 100644 index 0000000..2178459 --- /dev/null +++ b/README.md @@ -0,0 +1,508 @@ +# 🐠 Clownfischserver + +> *Wie der Clownfisch in seiner Anemone lebt – geschützt, autonom, und beißt überraschend hart.* + +Ein selbst-hostbarer, KI-gestützter Server-Manager der ausschließlich über Telegram kommuniziert. Kein Dashboard, kein Web-UI, keine Cloud. Nur du und dein Server – über eine Chat-Nachricht. + +![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg) +![Platform: Linux](https://img.shields.io/badge/Platform-Linux-red.svg) +![Version](https://img.shields.io/badge/Version-0.5.0-green.svg) +![Status](https://img.shields.io/badge/Status-Active%20Development-yellow.svg) + +--- + +## 🐟 Warum "Clownfisch"? + +Der Clownfisch lebt in seiner Anemone – einem der wenigen Orte im Ozean die für andere Fische tödlich sind. Er verlässt sie kaum, schützt sie aktiv, und fühlt sich darin vollkommen sicher. + +Genauso lebt der Clownfischserver **in seinem Server** (seiner Anemone): +- Er schirmt alles ab was nicht sein muss +- SSH bleibt standardmäßig zu – nur wer das richtige Klopfmuster kennt kommt rein +- Die KI entscheidet was sicher ist und was nicht +- Und wie der echte Clownfisch: **beißt er überraschend hart wenn nötig** 🐠 + +--- + +## Was ist das? + +Clownfischserver verwandelt einen Linux-Server in einen vollautonomen, Telegram-gesteuerten Assistenten. Du schreibst ihm in natürlicher Sprache – er versteht, generiert Befehle, erstellt Scripts, und verwaltet deinen Server. + +Das Ziel: **Server-Administration so einfach wie eine Chat-Nachricht.** Heute ein Server, morgen eine ganze Farm. + +**Kein SCP. Kein Dashboard. Kein Cloud-Zwang. Nur Telegram.** + +--- + +## Features + +- 🤖 **Telegram Bot** als primäres Interface – von überall, auch vom Handy +- 🧠 **Dual-Model KI** – Code-Modell (qwen2.5-coder) für Befehle + Chat-Modell (gemma3) für Konversation +- 🔧 **`/shell` + `/ja`** – KI generiert Shell-Befehle, du bestätigst vor der Ausführung +- 🛠️ **`/code` + Aider** – autonome Code- und Script-Generierung direkt auf dem Server +- 💬 **Natürlicher Chat** – Fragen stellen, Erklärungen bekommen, übersetzen lassen +- 📸 **Automatische Snapshots** vor destruktiven Aktionen (tar.gz mit Keep/Unkeep + Paketliste) +- 🔐 **Port-Knocking SSH** als sicherer Backup-Zugang (knockd) mit Telegram-Benachrichtigung +- 🔔 **Benachrichtigungen** – SSH-Zugang, Server-Boot, Knock-Versuche via Telegram +- 📦 **Self-Update via Telegram** – ZIP an den Bot schicken, fertig (inkl. systemd-Services) +- ☁️ **OpenRouter Fallback** – kostenlose Cloud-Modelle als Backup wenn Ollama nicht verfügbar +- 👥 **Team-Rollen** *(v0.5.0)* – `operator` / `approver` / `viewer` mit Inheritance, env-gesteuert +- 📜 **Audit-Trail** *(v0.5.0)* – jeder privilegierte Vorgang in `audit.jsonl`, abfragbar via `/logs`, Retention konfigurierbar +- 🔀 **LLM-Router** *(v0.5.0)* – Primary/Fallback-Chain via `LLM_PRIMARY_PROVIDER` etc.; pro Provider eigenes Kontextfenster +- 👤 **Eigener User** – beliebiger Name, passwordless sudo, wird überall korrekt eingesetzt +- 🌍 **Mehrsprachig** – antwortet in der Sprache in der du schreibst +- 🐧 **Multi-Distro** – Debian, Ubuntu, Fedora, Arch (Installer) +- 🔒 **DSGVO by design** – lokal ist Standard, Cloud ist Opt-in (siehe [DSGVO.md](DSGVO.md)) + +--- + +## For Enterprise Teams (v0.5.0) + +Clownfischserver v0.5.0 ist als **Gateway für Teams mit lokal gehosteten +LLMs** positioniert. Wenn ihr Claude Opus, Qwen 32B oder Gemma 4 auf eigener +Hardware betreibt, gibt euch v0.5.0: + +### Rollenbasierte Zugriffskontrolle + +Drei Rollen mit Inheritance (`operator` ⊃ `approver` ⊃ `viewer`): + +```dotenv +# .env +CLOWNFISCH_OPERATORS=12345,67890 # dürfen /shell /code /run /ssh /ja /snapshot /rollback +CLOWNFISCH_APPROVERS=11111 # dürfen /ja (Bestätigungen) +CLOWNFISCH_VIEWERS=22222,33333 # dürfen /status /snapshots /help + Chat +``` + +Wer keine Rolle hat, wird abgelehnt – inklusive Audit-Eintrag mit +`result: denied`. Backwards-kompatibel: ohne Rollen-Vars wird die alte +`TELEGRAM_CHAT_ID` automatisch zu `operator`. + +### Audit-Trail nach DSGVO Art. 5 + 32 + +Jeder Vorgang wird in `audit.jsonl` als JSONL-Eintrag protokolliert: + +```jsonl +{"ts":"2026-06-04T14:21:03+00:00","user_id":"12345","action":"shell.execute","result":"ok","role":"operator","details":{"command":"systemctl restart nginx","returncode":0}} +{"ts":"2026-06-04T14:21:07+00:00","action":"llm.route","result":"hit","details":{"tier":"primary","provider":"ollama","model":"qwen2.5-coder:7b","method":"chat"}} +``` + +Operatoren fragen den Trail direkt aus Telegram ab: + +```text +/logs # letzte 20 Einträge +/logs user 12345 days 7 # User-Aktivität, letzte 7 Tage +/logs prefix shell. result error +/logs action llm.route limit 50 +``` + +Retention via `AUDIT_RETENTION_DAYS` (Default 90 Tage, beim Bot-Start automatisch). + +### Konfigurierbares LLM-Routing + +Primary/Fallback-Chain via Env – kein Auto-Selection, keine Halluzinationen: + +```dotenv +# Primary: lokal, sensitive Daten bleiben on-prem +LLM_PRIMARY_PROVIDER=ollama +LLM_PRIMARY_MODEL=qwen2.5-coder:7b +LLM_PRIMARY_NUM_CTX=65536 # z. B. Gemma 4 27B braucht 64K+ + +# Fallback nur für non-sensitive Tasks +LLM_FALLBACK_PROVIDER=openrouter +LLM_FALLBACK_MODEL=deepseek/deepseek-r1:free + +# Fallback komplett deaktivieren: +# LLM_FALLBACK_PROVIDER=none +``` + +Routable Errors (Timeout, Connection-Refused) lösen Fallback aus; harte +Fehler (JSON-Parse, Bad API Key) propagieren – ohne stille Modellwechsel. + +### Vollständige Compliance-Dokumentation + +Siehe **[DSGVO.md](DSGVO.md)** – aufgeschlüsselt nach Art. 5 + 32 mit +Referenzen auf den konkreten Code. + +--- + +## Schnellstart + +```bash +# Frisches Ubuntu/Debian – als root: +wget https://github.com/mehlzoerwer-claude/clownfischserver/archive/refs/heads/main.zip +unzip main.zip && cd clownfischserver-main +bash install.sh +``` + +Der Installer führt dich durch alles – Schritt für Schritt, mit Erklärungen. + +--- + +## Installation + +### Was du brauchst + +- Frischer Ubuntu 22/24 oder Debian 12/13 VPS (min. 8GB RAM empfohlen, 20GB Disk) +- Root SSH-Zugang für die Installation +- Ein Telegram-Account (5 Minuten für Bot-Setup) + +### RAM-Empfehlung + +| RAM | Coder-Modell | Chat-Modell | Gesamt ca. | +|-----|-------------|-------------|------------| +| 8-12 GB | qwen2.5-coder:3b | gemma3:1b | ~3 GB | +| 16+ GB | qwen2.5-coder:7b | gemma3:4b | ~8 GB | + +### Der Installer + +``` +[1/8] Systemcheck → RAM, Disk, Distro-Erkennung +[2/8] Pakete → alles Nötige mit Spinner +[3/8] User einrichten → eigener User oder vorhandenen nutzen +[4/8] SSH + Firewall → Port-Knocking, ufw, SSH bleibt offen +[5/8] Ollama → Dual-Model KI-Setup (Coder + Chat) +[6/8] Python + Aider → Bot-Umgebung + Code-Agent +[7/8] Telegram → geführtes Bot-Setup mit BotFather +[8/8] Service starten → systemd + Boot-Notification +``` + +### Update (wenn bereits installiert) + +```bash +bash install.sh +# → erkennt laufenden Service +# → [1] Update [2] Neuinstallation [3] Abbrechen +``` + +Oder noch einfacher: **ZIP direkt an den Bot schicken** – Self-Update! + +--- + +## Nutzung + +### Bot-Befehle + +| Befehl | Beschreibung | +|--------|-------------| +| `/shell ` | KI generiert Shell-Befehl aus natürlicher Sprache | +| `/ja` | Letzten vorgeschlagenen Befehl ausführen | +| `/code ` | Aider erstellt/bearbeitet Code und Scripts | +| `/run ` | Direkte Shell – kein Ollama, kein Bestätigen | +| `/status` | CPU, RAM, Disk, Top-Prozesse | +| `/snapshots` | Alle Snapshots anzeigen | +| `/snapshot [label]` | Manuell Snapshot erstellen | +| `/snapshot keep ` | Snapshot als wichtig markieren | +| `/snapshot delete ` | Snapshot löschen | +| `/rollback ` | Zu einem Snapshot zurückkehren | +| `/ssh open [ip]` | SSH via ufw öffnen (für alle oder eine IP) | +| `/ssh close [ip]` | SSH wieder sperren | +| `/start` / `/help` | Bot-Info und Befehlsübersicht | + +### Freitext-Chat + +Einfach schreiben – ohne Befehl. Der Bot antwortet als Chat-Partner: + +``` +"Was ist ein Reverse Proxy?" +"Übersetze das ins Englische: ..." +"Erkläre mir den Unterschied zwischen apt und snap" +"Welche Services laufen gerade auf meinem Server?" +``` + +### Workflow: `/shell` + `/ja` + +``` +Du: /shell zeig mir die größten Dateien auf dem Server +Bot: 🔧 Vorgeschlagener Befehl: + du -ah / | sort -rh | head -20 + → Mit /ja ausführen oder einfach ignorieren. +Du: /ja +Bot: ✅ Ergebnis: ... +``` + +### Workflow: `/code` + +``` +Du: /code erstelle ein bash script das alte Logs älter als 30 Tage löscht +Bot: 🛠️ Aider Ergebnis: + ✅ Aider Task abgeschlossen + Applied edit to cleanup-logs.sh +``` + +### Self-Update + +ZIP-Datei direkt an den Bot schicken → Bot erstellt Snapshot → installiert Update → startet neu. + +--- + +## Architektur + +``` + ┌─────────────────────┐ + │ Telegram Bot │ + │ (bot.py) │ + └──────┬──────┬────────┘ + │ │ + ┌─────────────────┤ ├─────────────────┐ + ▼ ▼ ▼ ▼ + ┌────────────────┐ ┌────────────┐ ┌──────────────────┐ + │ ollama_client │ │ shell.py │ │ aider_wrapper │ + │ (Dual-Model) │ │ (run_shell)│ │ (Code-Agent) │ + └───────┬────┬────┘ └────────────┘ └──────────────────┘ + │ │ + ┌────────┘ └────────┐ + ▼ ▼ +┌──────────┐ ┌──────────────┐ +│ Chat-Model│ │ Coder-Model │ +│ (gemma3) │ │(qwen2.5-coder)│ +└──────────┘ └──────────────┘ + +Weitere Module: + snapshot.py – Backup-System (tar.gz, keep/unkeep, auto-cleanup) + knock_notify.py – SSH Port-Knocking Alerts + boot_notify.py – Server-Neustart Benachrichtigung +``` + +--- + +## Sicherheit + +### Port-Knocking + +SSH ist standardmäßig durch ufw blockiert. Zugang via Klopf-Sequenz: + +```bash +# Oeffnen: 7000 -> 8000 -> 9000 +knock SERVER_IP 7000 8000 9000 + +# Schliessen: 9000 -> 8000 -> 7000 +knock SERVER_IP 9000 8000 7000 +``` + +Bei jedem Knock kommt eine Telegram-Benachrichtigung mit IP und Zeitstempel. + +### KI-gestützte Befehlsgenerierung + +``` +/shell installiere nginx + → KI generiert: apt install -y nginx + → Gefährlich? Nein → Vorschlag anzeigen + → /ja zum Ausführen, oder ignorieren + +/shell lösche alles in /etc + → KI generiert: rm -rf /etc/* + → Gefährlich? JA → Verweigert mit Erklärung +``` + +Kein Befehl wird ohne Bestätigung ausgeführt. Gefährliche Befehle werden immer verweigert. + +### Snapshot-System + +Vor destruktiven Aktionen automatischer Snapshot (tar.gz). Wichtige Snapshots können mit `/snapshot keep` markiert werden und werden nie automatisch gelöscht. + +### Boot-Notification + +Nach jedem Server-Neustart bekommst du eine Telegram-Nachricht mit Status aller Dienste (Clownfisch, Ollama, ufw, SSH, Cron) – nach 2 Minuten, wenn alles hochgefahren ist. + +--- + +## Roadmap + +### Fertig (v0.1 – v0.5.0) + +- [x] Basis-Installer mit geführtem Setup (8 Schritte) +- [x] Telegram Bot mit Auth Guard (Single-User) +- [x] Dual-Model KI: Coder (qwen2.5-coder) + Chat (gemma3/gemma4) +- [x] `/shell` + `/ja` – Befehlsgenerierung mit Bestätigung (non-interaktive Befehle) +- [x] `/code` – Aider Code-Agent für autonome Script-Erstellung (Smart Prompt) +- [x] `/run` – Direkte Shell ohne KI +- [x] `/status` – Systemübersicht (CPU, RAM, Disk, Prozesse) +- [x] `/ssh open/close` – ufw-basierter SSH-Zugang +- [x] Freitext-Chat mit History (letzte 10 Exchanges) +- [x] Snapshot-System (tar.gz, keep/unkeep, auto-cleanup max 20, Paketliste) +- [x] `/snapshot`, `/snapshots`, `/rollback` (inkl. Workspace) +- [x] Port-Knocking SSH (knockd) mit Telegram-Benachrichtigung +- [x] Boot-Notification nach Server-Neustart (Dienste-Status) +- [x] Self-Update via Telegram ZIP (inkl. systemd-Services + User-Patch) +- [x] Update-Modus im Installer (mit MGMT_USER aus .env) +- [x] Multi-Distro Installer (apt/dnf/pacman) +- [x] Ollama Warmup im Hintergrund (Bot sofort bereit) +- [x] **OpenRouter Free Fallback** – kostenlose Cloud-Modelle wenn Ollama nicht verfügbar +- [x] Interaktiver Update-Dialog (OpenRouter Key Eingabe via Telegram) +- [x] `OLLAMA_NUM_CTX` konfigurierbar (4K–32K, RAM-basierte Empfehlung im Installer) +- [x] Safe Telegram Output (Markdown-Fallback bei Parse-Fehlern) +- [x] Lange Antworten werden in Teile gesplittet (kein Abschneiden) +- [x] Mehrsprachig (antwortet in der Sprache des Nutzers) +- [x] MGMT_USER Regex-Patch (beliebiger Username funktioniert überall) +- [x] Security-Analyse dokumentiert (Schwachstellen + Maßnahmen in CLOWNFISCHSERVER.md) +- [x] DSGVO-Regel: `.env` nur per SSH, nie über Telegram lesbar +- [x] Projekt-Kontext-Datei (CLOWNFISCHSERVER.md) für KI-gestütztes Pair-Programming + +### v0.5.0 – Cleanup & Stabilität (aktuelles Release) + +**Bugfixes & Aufräumarbeiten (dieser Release):** +- [x] Totes Vault/MCP-Context-System komplett entfernt (war nie verdrahtet, `verify=False`-Lücke) +- [x] Privacy-Fix: Chat-Nachrichten gingen via Vault-Suche an externe Endpoints → entfernt +- [x] `/code` Filepath-Logik: fragiler Ternary-Ausdruck sauber umgeschrieben (kein Crash bei leerem Task) +- [x] Aider-Model-Default vereinheitlicht (zieht sich `OLLAMA_MODEL` aus der Umgebung) +- [x] Irreführende „Token-Optimization"-Logs (Anthropic-Kosten/Obsidian) aus dem Start-Log entfernt + +**Sicherheit & DSGVO (geplant):** +- [ ] Sensitive Dateien über Telegram blockieren (`.env`, `shadow`, Keys) +- [ ] Output-Sanitizer: API Keys/Tokens in jeder Bot-Antwort maskieren +- [ ] Log-Sanitizer: Keine Secrets im Audit-Log +- [ ] Audit-Log: Jeder Befehl mit Timestamp (DSGVO Art. 5/32) +- [ ] Self-Update Hardening: SHA256-Hash anzeigen, Admin bestätigt +- [ ] `.env` Permissions-Check beim Bot-Start +- [ ] `bot.log`, `.aider.chat.history.md`, `bash_history` aus Snapshots excluden +- [ ] Rate-Limiting: Max 30 Befehle/Minute +- [ ] Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut nötig +- [ ] 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot) +- [ ] Persönlicher Code-Offset (SECURITY_OFFSET in `.env`) + +**Features:** +- [ ] Snapshot-Scheduling (`/snapshot schedule daily 03:00`) +- [ ] Snapshot-Export als ZIP via Telegram (`/snapshot export `) +- [ ] `/logs` – Systemlogs direkt im Bot +- [ ] `/docker` – Docker Container Management +- [ ] Port-Knocking Sequenz im Installer wählbar +- [ ] Verschlüsselte Snapshots (SNAPSHOT_PASSWORD) +- [ ] Open Interpreter als Aider-Alternative evaluieren + +**Quick-Win Security:** +- [ ] Panik-Wort: Admin-definiertes Wort → Bot löscht .env, sperrt alles +- [ ] Honeypot-Befehle: Fake-Commands die Eindringlinge verraten +- [ ] Selbstzerstörende Nachrichten: Bot löscht eigene Outputs nach X Minuten +- [ ] Rollback-Bombe: 3x falscher 2FA → auto-Rollback + SSH sperren +- [ ] Dead Man's Switch: Kein Lebenszeichen in 24h → Bot sperrt sich + +### v0.6.0 – Multi-Server + +- [ ] Ein Bot steuert mehrere Server +- [ ] Zentrale Snapshot-Verwaltung +- [ ] Serverpark-Übersicht und -Steuerung +- [ ] Mehrere Nutzer mit unterschiedlichen Rechten + +### v0.7.0 – DSGVO-bewusster Backup-Manager + +- [ ] `/backup configs` → immer erlaubt +- [ ] `/backup app` → eigene Anwendungsdaten +- [ ] `/backup db` → Warnung + Bestätigung bei personenbezogenen Daten +- [ ] Lokale KI bewertet ob Daten DSGVO-relevant sein könnten + +### v0.8.0 – Erweiterte Cloud-API Integration + +- [ ] Wahl zwischen Free- und Premium-Modellen auf OpenRouter +- [ ] Direkte Anthropic/OpenAI API-Anbindung (neben OpenRouter) +- [ ] Admin steuert pro Befehlstyp: lokal oder Cloud +- [ ] Kosten-Tracking für Cloud-API-Nutzung + +### v0.9.0 – Multi-Interface + Advanced Security + +- [ ] Matrix/Element Connector (E2E-verschlüsselt, self-hosted) +- [ ] Web-UI mit HTTPS (self-hosted, Let's Encrypt) +- [ ] SSH-CLI als direktes Interface +- [ ] Fake-Modus + Distress-Code (falscher 2FA → stiller Alarm) +- [ ] Geo-Fencing (nur erlaubte Länder/IP-Bereiche) +- [ ] Buddy-System (zwei Admins für kritische Befehle) +- [ ] Verschlüsselter Rückkanal über Telegram + +--- + +## Langzeit-Vision 🚀 + +### v1.0.0 – Intelligent Disaster Recovery + +Server komplett aus Snapshot wiederherstellen – auch auf anderer Hardware oder in einer anderen Umgebung. Die KI analysiert den Snapshot und rekonstruiert die Umgebung. + +*"Stelle Server 3 auf Stand von gestern 02:00 Uhr wieder her."* + +Ziel: Open Source Alternative zu teuren Enterprise DR-Lösungen. + +### v2.0.0 – Cross-Distribution Server Migration + +Die eigentliche Revolution: **Ein Snapshot, jedes Zielsystem.** + +Der Clownfischserver versteht nicht nur Dateien, sondern die *Logik* eines Servers: +- Welche Pakete sind installiert und wofür? +- Welche Services laufen und wie sind sie konfiguriert? +- Welche Abhängigkeiten bestehen zwischen Komponenten? + +Diese Logik ist distro-unabhängig. Ein Debian-Server mit nginx + PostgreSQL + Docker hat die gleiche *Absicht* wie ein Arch-Server mit denselben Diensten – nur andere Paketnamen, Pfade und Init-Systeme. + +**Ziel-Plattformen:** +- Debian / Ubuntu (heute) +- Arch Linux +- Red Hat / Fedora / CentOS +- openSUSE +- FreeBSD +- Weitere UNIX-Systeme + +**Wie es funktioniert:** +1. KI analysiert Snapshot und erstellt ein distro-neutrales "Server-Profil" +2. Profil beschreibt: Dienste, Konfigurationen, Daten, Netzwerk, Nutzer +3. Auf dem Zielsystem: KI übersetzt das Profil in native Befehle +4. `apt install nginx` → `pacman -S nginx` → `pkg install nginx` +5. Konfigurationen werden angepasst (Pfade, Syntax, Permissions) + +*"Migriere meinen Debian-Webserver auf Arch Linux."* + +### Weltklasse Integration 🌍 + +Clownfischserver als Infrastruktur-Layer für die Weltklasse App – natürliche Sprachsteuerung des gesamten Server-Ökosystems. GDPR-konform, vollständig lokal, kein Cloud-Zwang. + +--- + +> *"Angefangen als einfacher Telegram-Bot – gewachsen zu einer Vision."* 🐠 + +--- + +## Prinzipien 🛡️ + +``` +1. Local AI first – Lokal ist Standard. Cloud ist Opt-in. +2. Open Source always – GPL-3.0, keine Ausnahmen +3. DSGVO by design – Datenschutz ist kein Feature, es ist Pflicht +4. Security first – SSH zu, KI prüft, Snapshots sichern +5. Einfachheit – ein Mensch, ein Bot, ein Server +6. Distro-agnostisch – Linux ist Linux. Die KI kennt den Unterschied. +``` + +### Zur Cloud-Frage + +Clownfischserver ist und bleibt local-first. Aber wir sind keine Puristen: wenn ein Admin einen OpenRouter-API-Key hat und ihn für komplexe Aufgaben nutzen will, soll er das können. Die Entscheidung liegt beim Admin, nicht beim Tool. + +Was wir **nicht** tun: +- Cloud als Voraussetzung +- Daten automatisch an externe APIs senden +- Features hinter Cloud-APIs verstecken + +Was wir **ermöglichen** (seit v0.4.12/v0.4.13): +- Optionaler OpenRouter API-Key in `.env` (kostenlos, keine Kreditkarte) +- Automatischer Fallback: Ollama Timeout → OpenRouter übernimmt +- Wenn Cloud ausfällt, funktioniert alles weiterhin lokal +- Setup direkt im Bot: ZIP schicken → Bot fragt nach Key → fertig + +> *Als Admin entscheidest du – nicht das Tool.* + +--- + +## Entwicklung + +**Entwickler:** [Mehlzoerwer-Claude](https://github.com/mehlzoerwer-claude) + +**KI-Assistent:** Dieses Projekt wurde mit [Claude](https://claude.ai) (Anthropic) als Pair-Programming Partner entwickelt. Claude ist der einzige nicht-Open-Source Teil dieser Zusammenarbeit. 😄 + +**Philosophie:** Open Source ohne Wenn und Aber. Wer's nutzt, muss es offen lassen (GPL-3.0). + +--- + +## Lizenz + +GPL-3.0 - Keep it open. Always. + +Copyright (C) 2025-2026 Mehlzoerwer-Claude + +--- + +*Danke an alle die Ollama, Aider, python-telegram-bot, knockd und ufw gebaut haben.* +*Und an den echten Clownfisch – für die Inspiration.* 🐠 diff --git a/bot/aider_wrapper.py b/bot/aider_wrapper.py new file mode 100644 index 0000000..7afeb9c --- /dev/null +++ b/bot/aider_wrapper.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +🐠 Clownfischserver +Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) +License: GPL-3.0 – Keep it open. Always. +Repo: https://github.com/mehlzoerwer-claude/clownfischserver +""" + +import asyncio +import logging +import os +import tempfile +from pathlib import Path + +logger = logging.getLogger(__name__) + +INSTALL_DIR = os.getenv("INSTALL_DIR", "/opt/clownfischserver") +AIDER_WORKDIR = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace") +OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") +OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b") +TIMEOUT = 600 # 10 minutes for aider tasks on CPU + + +class AiderWrapper: + def __init__(self): + self.workdir = Path(AIDER_WORKDIR) + self.workdir.mkdir(parents=True, exist_ok=True) + self.venv_python = Path(INSTALL_DIR) / "aider-venv" / "bin" / "python3" + self.venv_aider = Path(INSTALL_DIR) / "aider-venv" / "bin" / "aider" + logger.info(f"AiderWrapper init: workdir={self.workdir}") + + async def run(self, task: str, target_file: str = None) -> str: + """ + Run aider in script mode for a coding task. + Returns the output/result as string. + """ + logger.info(f"Aider task: {task[:100]}") + + # Determine working directory and files + workdir = self.workdir + + # Build aider command + cmd = self._build_aider_command(task, target_file) + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(workdir), + env={ + **os.environ, + "OLLAMA_API_BASE": OLLAMA_BASE_URL, + "HOME": f"/home/{os.getenv('MGMT_USER', 'clownfish')}", + } + ) + + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=TIMEOUT + ) + except asyncio.TimeoutError: + process.kill() + return f"⏱️ Aider Timeout nach {TIMEOUT}s. Task zu komplex oder Modell überlastet." + + stdout_str = stdout.decode("utf-8", errors="replace").strip() + stderr_str = stderr.decode("utf-8", errors="replace").strip() + + # Build response + result_parts = [] + + if stdout_str: + result_parts.append(stdout_str) + + if stderr_str and process.returncode != 0: + result_parts.append(f"[STDERR]\n{stderr_str}") + + if process.returncode == 0: + result_parts.insert(0, f"✅ Aider Task abgeschlossen\nArbeitsverzeichnis: {workdir}\n") + else: + result_parts.insert(0, f"⚠️ Aider beendet mit Code {process.returncode}\n") + + return "\n".join(result_parts) or "✓ Task abgeschlossen (keine Ausgabe)" + + except FileNotFoundError: + return ( + "❌ Aider nicht gefunden. Bitte prüfen:\n" + f" ls {self.venv_aider}\n" + " Ggf. neu installieren mit: pip install aider-chat" + ) + except Exception as e: + logger.error(f"Aider execution error: {e}", exc_info=True) + return f"❌ Aider Fehler: {e}" + + def _build_aider_command(self, task: str, target_file: str = None) -> list: + """Build the aider command with appropriate flags""" + cmd = [ + str(self.venv_aider), + "--model", f"ollama/{OLLAMA_MODEL}", + "--no-git", # Don't require git + "--yes", # Auto-confirm all changes + "--no-pretty", # Plain output for telegram + "--no-show-model-warnings", # Skip env var warnings + "--no-analytics", # No analytics prompts + "--edit-format", "diff", # Better format for small models + "--message", task, # The task as a message + ] + + if target_file: + cmd.append(target_file) + + return cmd + + async def list_workspace_files(self) -> str: + """List files in the aider workspace""" + try: + files = list(self.workdir.rglob("*")) + if not files: + return f"📁 Workspace {self.workdir} ist leer." + + file_list = "\n".join([ + f" {'📁' if f.is_dir() else '📄'} {f.relative_to(self.workdir)}" + for f in sorted(files)[:50] + ]) + return f"📁 Workspace: {self.workdir}\n\n{file_list}" + except Exception as e: + return f"❌ Fehler beim Lesen des Workspace: {e}" diff --git a/bot/audit_log.py b/bot/audit_log.py new file mode 100644 index 0000000..1878822 --- /dev/null +++ b/bot/audit_log.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +🐠 Clownfischserver – Audit Trail (DSGVO Art. 5 + 32) +Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) +License: GPL-3.0 + +Writes JSON-line entries for every privileged action. +Pseudonymized: stores only numeric Telegram user_ids, never usernames. + +Compliance: +- Art. 5(1)(a) lawfulness/transparency: every action is recorded. +- Art. 5(1)(c) data minimization: numeric IDs only, command/result trimmed. +- Art. 5(1)(e) storage limitation: prune_old_logs() drops entries older + than AUDIT_RETENTION_DAYS (default 90). +- Art. 32 integrity: append-only file, 0600 permissions. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# How much of a command/result we keep – longer payloads are truncated +# to honour data minimization. Configurable via env. +_MAX_DETAIL_LEN = int(os.getenv("AUDIT_MAX_DETAIL_LEN", "2000")) + +# Lock for append writes – multiple coroutines may log concurrently. +_write_lock = threading.Lock() + + +def _default_audit_path() -> str: + install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver") + return os.getenv("AUDIT_LOG_PATH", os.path.join(install_dir, "audit.jsonl")) + + +def _retention_days() -> int: + try: + return max(0, int(os.getenv("AUDIT_RETENTION_DAYS", "90"))) + except ValueError: + return 90 + + +def _trim(value: Any) -> Any: + if isinstance(value, str) and len(value) > _MAX_DETAIL_LEN: + return value[:_MAX_DETAIL_LEN] + f"... (truncated, {len(value)} chars total)" + return value + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def log_action( + user_id: str | int | None, + action: str, + *, + role: str | None = None, + result: str = "ok", + details: dict[str, Any] | None = None, + path: str | None = None, +) -> None: + """Append a single audit record. Never raises – audit failure must not + break the bot.""" + entry: dict[str, Any] = { + "ts": _now_iso(), + "user_id": str(user_id) if user_id is not None else None, + "action": action, + "result": result, + } + if role: + entry["role"] = role + if details: + entry["details"] = {k: _trim(v) for k, v in details.items()} + + target = path or _default_audit_path() + try: + Path(target).parent.mkdir(parents=True, exist_ok=True) + line = json.dumps(entry, ensure_ascii=False) + "\n" + with _write_lock: + with open(target, "a", encoding="utf-8") as fh: + fh.write(line) + try: + os.chmod(target, 0o600) + except OSError: + # chmod can fail on non-POSIX FS (e.g. tests on Windows) – + # the JSONL content is still written. + pass + except Exception as e: + logger.error(f"Audit log write failed: {e}") + + +def log_shell(user_id, command: str, *, role: str | None = None, + status: str = "executed", returncode: int | None = None, + stderr: str | None = None, path: str | None = None) -> None: + details: dict[str, Any] = {"command": command} + if returncode is not None: + details["returncode"] = returncode + if stderr: + details["stderr"] = stderr + log_action(user_id, "shell.execute", role=role, result=status, + details=details, path=path) + + +def log_shell_proposed(user_id, description: str, command: str, + role: str | None = None, dangerous: bool = False, + path: str | None = None) -> None: + log_action( + user_id, + "shell.proposed", + role=role, + result="dangerous" if dangerous else "ok", + details={"description": description, "command": command}, + path=path, + ) + + +def log_code(user_id, task: str, *, role: str | None = None, + status: str = "ok", path: str | None = None) -> None: + log_action(user_id, "code.run", role=role, result=status, + details={"task": task}, path=path) + + +def log_approval(user_id, command: str, *, role: str | None = None, + approved: bool = True, path: str | None = None) -> None: + log_action( + user_id, + "shell.approval", + role=role, + result="approved" if approved else "rejected", + details={"command": command}, + path=path, + ) + + +def log_denied(user_id, action: str, *, role: str | None = None, + reason: str = "insufficient_role", + path: str | None = None) -> None: + log_action(user_id, action, role=role, result="denied", + details={"reason": reason}, path=path) + + +def prune_old_logs(path: str | None = None, + retention_days: int | None = None) -> int: + """Rewrite the audit file, dropping entries older than retention_days. + + Returns the number of dropped lines. retention_days=0 disables pruning. + Uses an atomic temp-file + rename so a crash mid-prune cannot corrupt + the audit trail. + """ + target = path or _default_audit_path() + days = retention_days if retention_days is not None else _retention_days() + if days <= 0 or not os.path.exists(target): + return 0 + + cutoff = time.time() - days * 86400 + tmp_path = target + ".prune.tmp" + dropped = 0 + kept = 0 + + try: + with _write_lock: + with open(target, "r", encoding="utf-8") as src, \ + open(tmp_path, "w", encoding="utf-8") as dst: + for line in src: + raw = line.strip() + if not raw: + continue + try: + record = json.loads(raw) + ts = record.get("ts", "") + # ISO 8601 – parse with fromisoformat (UTC suffix safe) + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + if dt.timestamp() < cutoff: + dropped += 1 + continue + except (ValueError, KeyError): + # Malformed line: keep it rather than silently dropping + # forensic evidence. + pass + dst.write(line if line.endswith("\n") else line + "\n") + kept += 1 + os.replace(tmp_path, target) + try: + os.chmod(target, 0o600) + except OSError: + pass + logger.info(f"Audit prune: kept={kept} dropped={dropped} retention={days}d") + except Exception as e: + logger.error(f"Audit prune failed: {e}") + if os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass + return 0 + + return dropped diff --git a/bot/audit_query.py b/bot/audit_query.py new file mode 100644 index 0000000..0be6987 --- /dev/null +++ b/bot/audit_query.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +🐠 Clownfischserver – Audit Trail Query (Phase 2b) +Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) +License: GPL-3.0 + +Read-side companion to audit_log.py. Filters/sorts JSONL audit records for +the `/logs` admin command. + +The query layer is intentionally separate from the writer so that: +- Tests can exercise filtering without touching real audit data. +- The writer stays append-only and minimal. +- Future query backends (SQLite, OpenSearch) can swap in without touching + the writer. +""" + +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime, timezone, timedelta +from typing import Any, Iterable + +logger = logging.getLogger(__name__) + + +def _default_audit_path() -> str: + install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver") + return os.getenv("AUDIT_LOG_PATH", os.path.join(install_dir, "audit.jsonl")) + + +def _parse_ts(record: dict) -> datetime | None: + """Best-effort parse of the record's ts field. Returns None on + malformed timestamps (caller decides how to handle).""" + ts = record.get("ts") + if not isinstance(ts, str): + return None + try: + return datetime.fromisoformat(ts.replace("Z", "+00:00")) + except ValueError: + return None + + +def read_all(path: str | None = None) -> list[dict]: + """Load every audit record from the JSONL file. Malformed lines are + skipped with a debug log (the writer keeps them for forensic purposes, + but the query layer prefers a clean result set).""" + target = path or _default_audit_path() + if not os.path.exists(target): + return [] + + out: list[dict] = [] + try: + with open(target, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + logger.debug(f"audit_query: skipping malformed line") + continue + except OSError as e: + logger.error(f"audit_query: cannot read {target}: {e}") + return [] + return out + + +def filter_records( + records: Iterable[dict], + *, + user_id: str | int | None = None, + action: str | None = None, + action_prefix: str | None = None, + result: str | None = None, + since: datetime | None = None, + until: datetime | None = None, +) -> list[dict]: + """Apply filters and return matching records, newest first. + + Filters are AND-combined. None values are ignored. action/result match + exactly; action_prefix matches if the record's action starts with it. + """ + target_user = str(user_id) if user_id is not None else None + + matched: list[dict] = [] + for rec in records: + if target_user is not None and rec.get("user_id") != target_user: + continue + if action is not None and rec.get("action") != action: + continue + if action_prefix is not None: + rec_action = rec.get("action", "") + if not isinstance(rec_action, str) or not rec_action.startswith(action_prefix): + continue + if result is not None and rec.get("result") != result: + continue + if since is not None or until is not None: + dt = _parse_ts(rec) + if dt is None: + # Malformed ts – exclude when caller asked for a date window. + continue + if since is not None and dt < since: + continue + if until is not None and dt > until: + continue + matched.append(rec) + + # Newest first – stable sort by ts (records without ts go to the bottom). + matched.sort(key=lambda r: r.get("ts", ""), reverse=True) + return matched + + +def query( + *, + user_id: str | int | None = None, + action: str | None = None, + action_prefix: str | None = None, + result: str | None = None, + days: int | None = None, + limit: int = 20, + path: str | None = None, +) -> list[dict]: + """Convenience wrapper: read + filter in one call. + + `days` is a shortcut for `since = now - days`. limit clamps the + returned record count (use limit=0 for no limit).""" + since = None + if days is not None and days > 0: + since = datetime.now(timezone.utc) - timedelta(days=days) + records = read_all(path=path) + matched = filter_records( + records, + user_id=user_id, + action=action, + action_prefix=action_prefix, + result=result, + since=since, + ) + if limit and limit > 0: + matched = matched[:limit] + return matched + + +def format_record(rec: dict) -> str: + """Render one audit record as a single compact line for Telegram.""" + ts = rec.get("ts", "?") + # Trim ts down to HH:MM:SS or YYYY-MM-DD HH:MM:SS for readability. + if isinstance(ts, str) and len(ts) >= 19: + ts_short = ts[:19].replace("T", " ") + else: + ts_short = str(ts) + uid = rec.get("user_id") or "—" + action = rec.get("action", "?") + result = rec.get("result", "?") + role = rec.get("role") + role_part = f" [{role}]" if role else "" + + details = rec.get("details") or {} + detail_str = "" + if isinstance(details, dict): + # Pick the single most-informative key per common action. + for key in ("command", "task", "reason", "snapshot", "method", "tier"): + if key in details: + val = str(details[key]) + if len(val) > 80: + val = val[:77] + "..." + detail_str = f" {val}" + break + + return f"{ts_short} {uid}{role_part} {action}={result}{detail_str}" + + +def summarize(records: list[dict]) -> dict[str, Any]: + """Aggregate counts useful for the /logs header.""" + by_action: dict[str, int] = {} + by_user: dict[str, int] = {} + by_result: dict[str, int] = {} + for r in records: + act = r.get("action", "?") + by_action[act] = by_action.get(act, 0) + 1 + uid = r.get("user_id") or "—" + by_user[uid] = by_user.get(uid, 0) + 1 + res = r.get("result", "?") + by_result[res] = by_result.get(res, 0) + 1 + return {"total": len(records), "by_action": by_action, + "by_user": by_user, "by_result": by_result} diff --git a/bot/auth.py b/bot/auth.py new file mode 100644 index 0000000..05b909d --- /dev/null +++ b/bot/auth.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +🐠 Clownfischserver – Role-Based Access Control +Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) +License: GPL-3.0 + +Implements DSGVO Art. 32 access control: +- operator: full execution rights (/shell, /code, /run, /ja, /ssh, /snapshot) +- approver: can approve pending operations (/ja) +- viewer: read-only access (/status, /snapshots, /help) + +Backwards compatible: if no role env vars are set but TELEGRAM_CHAT_ID +is configured, that user is auto-granted the operator role. +""" + +from __future__ import annotations + +import logging +import os + +logger = logging.getLogger(__name__) + +ROLE_OPERATOR = "operator" +ROLE_APPROVER = "approver" +ROLE_VIEWER = "viewer" + +VALID_ROLES = (ROLE_OPERATOR, ROLE_APPROVER, ROLE_VIEWER) + +# Operators implicitly hold approver + viewer rights. +# Approvers implicitly hold viewer rights. +_ROLE_INHERITANCE: dict[str, tuple[str, ...]] = { + ROLE_OPERATOR: (ROLE_OPERATOR, ROLE_APPROVER, ROLE_VIEWER), + ROLE_APPROVER: (ROLE_APPROVER, ROLE_VIEWER), + ROLE_VIEWER: (ROLE_VIEWER,), +} + + +def _parse_id_list(raw: str | None) -> set[str]: + if not raw: + return set() + return {part.strip() for part in raw.split(",") if part.strip()} + + +def _load_role_map() -> dict[str, str]: + """Read role config from env vars. + + Returns {user_id_str: highest_role}. If a user appears in multiple + role lists, the strongest role wins (operator > approver > viewer). + """ + operators = _parse_id_list(os.getenv("CLOWNFISCH_OPERATORS")) + approvers = _parse_id_list(os.getenv("CLOWNFISCH_APPROVERS")) + viewers = _parse_id_list(os.getenv("CLOWNFISCH_VIEWERS")) + + role_map: dict[str, str] = {} + + for uid in viewers: + role_map[uid] = ROLE_VIEWER + for uid in approvers: + role_map[uid] = ROLE_APPROVER + for uid in operators: + role_map[uid] = ROLE_OPERATOR + + # Backwards compatibility: legacy TELEGRAM_CHAT_ID becomes operator + # when no explicit role lists are defined. + if not role_map: + legacy = os.getenv("TELEGRAM_CHAT_ID", "").strip() + if legacy: + role_map[legacy] = ROLE_OPERATOR + logger.info( + "Auth: no role lists configured – granting operator to legacy " + "TELEGRAM_CHAT_ID for backwards compatibility." + ) + + return role_map + + +def get_role(user_id: str | int | None) -> str | None: + """Return the role of a user, or None if unauthorized.""" + if user_id is None: + return None + return _load_role_map().get(str(user_id)) + + +def has_role(user_id: str | int | None, *required: str) -> bool: + """Check if the user satisfies ANY of the required roles. + + Inheritance applies: an operator passes a check for approver/viewer. + """ + role = get_role(user_id) + if role is None: + return False + granted = _ROLE_INHERITANCE.get(role, (role,)) + return any(r in granted for r in required) + + +def is_authorized(user_id: str | int | None) -> bool: + """True if the user has any role at all.""" + return get_role(user_id) is not None + + +def list_users_with_role(role: str) -> list[str]: + """Return all user_ids that hold (or inherit) the given role.""" + if role not in VALID_ROLES: + return [] + return [uid for uid, r in _load_role_map().items() + if role in _ROLE_INHERITANCE.get(r, (r,))] + + +def describe_roles() -> dict[str, list[str]]: + """Snapshot of current role assignments – useful for /status or audits.""" + role_map = _load_role_map() + result: dict[str, list[str]] = {r: [] for r in VALID_ROLES} + for uid, role in role_map.items(): + result[role].append(uid) + return result diff --git a/bot/boot_notify.py b/bot/boot_notify.py new file mode 100644 index 0000000..80d4b81 --- /dev/null +++ b/bot/boot_notify.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +🐠 Clownfischserver – Boot Notification +Sends a Telegram message after server boot with service status. +Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) +License: GPL-3.0 – Keep it open. Always. +Repo: https://github.com/mehlzoerwer-claude/clownfischserver +""" + +import os +import subprocess +import sys +from datetime import datetime +from dotenv import load_dotenv +import requests + +# Load .env +INSTALL_DIR = os.getenv("INSTALL_DIR", "/opt/clownfischserver") +load_dotenv(os.path.join(INSTALL_DIR, "config", ".env")) + +BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +CHAT_ID = os.getenv("TELEGRAM_CHAT_ID") + +SERVICES = ["clownfisch", "ollama", "ufw", "ssh", "cron"] + + +def get_service_status(name: str) -> str: + """Check if a systemd service is active.""" + try: + result = subprocess.run( + ["systemctl", "is-active", name], + capture_output=True, text=True, timeout=5 + ) + status = result.stdout.strip() + if status == "active": + return "✅" + elif status == "inactive": + return "⚪" + else: + return "❌" + except Exception: + return "❓" + + +def get_uptime() -> str: + """Get system uptime.""" + try: + result = subprocess.run( + ["uptime", "-p"], capture_output=True, text=True, timeout=5 + ) + return result.stdout.strip() + except Exception: + return "unbekannt" + + +def get_memory() -> str: + """Get memory usage.""" + try: + result = subprocess.run( + ["free", "-h", "--si"], capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.splitlines(): + if line.startswith("Mem:"): + parts = line.split() + return f"{parts[2]} / {parts[1]}" + except Exception: + pass + return "unbekannt" + + +def send_message(text: str): + """Send Telegram message.""" + if not BOT_TOKEN or not CHAT_ID: + print("ERROR: Token oder Chat-ID fehlt in .env") + sys.exit(1) + try: + requests.post( + f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage", + json={"chat_id": CHAT_ID, "text": text, "parse_mode": "Markdown"}, + timeout=10 + ) + except Exception as e: + print(f"Telegram Fehler: {e}") + sys.exit(1) + + +def main(): + zeit = datetime.now().strftime("%H:%M:%S") + datum = datetime.now().strftime("%d.%m.%Y") + + # Build service status list + status_lines = [] + for svc in SERVICES: + icon = get_service_status(svc) + status_lines.append(f" {icon} {svc}") + + uptime = get_uptime() + memory = get_memory() + + msg = ( + f"🐠 *Server gestartet!*\n\n" + f"Zeit: {datum} {zeit}\n" + f"Uptime: {uptime}\n" + f"RAM: {memory}\n\n" + f"*Dienste:*\n" + + "\n".join(status_lines) + ) + + send_message(msg) + print(f"Boot notification sent at {datum} {zeit}") + + +if __name__ == "__main__": + main() diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 0000000..eff5ea5 --- /dev/null +++ b/bot/bot.py @@ -0,0 +1,1081 @@ +#!/usr/bin/env python3 +""" +🐠 Clownfischserver +Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) +License: GPL-3.0 – Keep it open. Always. +Repo: https://github.com/mehlzoerwer-claude/clownfischserver +""" + +import asyncio +import logging +import os +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile +from dotenv import load_dotenv +from telegram import Update +from telegram.ext import ( + Application, + MessageHandler, + CommandHandler, + filters, + ContextTypes, +) + +# Global Ollama ready flag +ollama_ready = None + +# Load local modules +sys.path.insert(0, os.path.dirname(__file__)) +from ollama_client import OllamaClient +from openrouter_client import OpenRouterClient +from shell import run_shell, execute_command +from snapshot import SnapshotManager, should_snapshot +from aider_wrapper import AiderWrapper +import auth +import audit_log +import audit_query +from llm_router import LLMRouter, OllamaProvider + +# --- Load env --- +_install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver") +load_dotenv(os.path.join(_install_dir, "config", ".env")) + +# --- Logging --- +logging.basicConfig( + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + level=logging.INFO, + handlers=[ + logging.StreamHandler(), + logging.FileHandler(os.path.join(_install_dir, "bot.log")), + ], +) +logger = logging.getLogger(__name__) +BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +CHAT_ID = str(os.getenv("TELEGRAM_CHAT_ID") or "") + +# v0.5.0: auth is satisfied if EITHER legacy TELEGRAM_CHAT_ID OR any of +# the new role envs is set. The bot only requires BOT_TOKEN to boot. +_has_role_config = any( + os.getenv(k) for k in + ("CLOWNFISCH_OPERATORS", "CLOWNFISCH_APPROVERS", "CLOWNFISCH_VIEWERS") +) + +if not BOT_TOKEN: + logger.error("TELEGRAM_BOT_TOKEN fehlt in .env!") + sys.exit(1) +if not CHAT_ID and not _has_role_config: + logger.error( + "Weder TELEGRAM_CHAT_ID noch CLOWNFISCH_OPERATORS/APPROVERS/VIEWERS " + "in .env gesetzt – Bot hätte keine autorisierten Nutzer!" + ) + sys.exit(1) + +# --- Init modules --- +# LLMRouter owns the primary/fallback chain. OpenRouterClient is kept as a +# thin status proxy for backwards-compatible /start banner ("Fallback aktiv"). +openrouter = OpenRouterClient() +llm = LLMRouter() +# Legacy alias so any third-party patches/plugins that still refer to `ollama` +# keep working. The router exposes the same async surface. +ollama = llm +snapshots = SnapshotManager() +aider = AiderWrapper() + +# ============================================================================= +# AUTH GUARD + HELPERS +# ============================================================================= + +def _user_id(update: Update) -> str | None: + """Prefer the actual user id over chat id so group chats with multiple + members still produce distinct audit records.""" + if update.effective_user and update.effective_user.id is not None: + return str(update.effective_user.id) + if update.effective_chat: + return str(update.effective_chat.id) + return None + + +def is_authorized(update: Update) -> bool: + """Backwards-compatible auth check. Delegates to auth.is_authorized() + which falls back to legacy TELEGRAM_CHAT_ID when no roles are configured.""" + return auth.is_authorized(_user_id(update)) + + +async def deny(update: Update, action: str, required_role: str): + """Reply with a denial message and write an audit entry.""" + uid = _user_id(update) + role = auth.get_role(uid) + audit_log.log_denied(uid, action, role=role, + reason=f"requires_{required_role}") + if update.message: + try: + await update.message.reply_text( + f"⛔ Nicht erlaubt – `{action}` benötigt Rolle `{required_role}`.", + parse_mode="Markdown", + ) + except Exception: + pass + + +def require(update: Update, *roles: str) -> bool: + """Returns True if the user holds any of the listed roles. + Returns False if unauthorized OR role is too weak. Caller must + short-circuit on False (deny() should be awaited separately).""" + uid = _user_id(update) + if not auth.is_authorized(uid): + return False + return auth.has_role(uid, *roles) + + +def sanitize_output(text: str) -> str: + """Mask sensitive data in bot output: API keys, tokens, passwords, secrets.""" + import re + + # Patterns to mask (key prefix -> replacement) + patterns = [ + # Bearer tokens (in headers and in text) - MUST come before API key patterns + (r"Bearer\s+(?:sk-or-v1-[a-zA-Z0-9\-_.]{10,}|sk-[a-zA-Z0-9\-_.]{10,}|[a-zA-Z0-9\-_\.=]{20,})", r"Bearer ***"), + # Authorization headers + (r"(Authorization:\s*)[a-zA-Z0-9\-_\.=]{20,}", r"\1***"), + # OpenRouter keys (full: sk-or-v1-..., display: sk-or-...) + (r"(sk-or-v1-[a-zA-Z0-9\-_]{10,})", r"sk-or-v1-***"), + (r"(sk-or-\.{3}[a-zA-Z0-9]{4,})", r"sk-or-***"), + # Generic API keys (sk-..., pk-...) + (r"\b(sk-[a-zA-Z0-9\-_]{20,})\b", r"sk-***"), + (r"\b(pk-[a-zA-Z0-9\-_]{20,})\b", r"pk-***"), + # Generic long base64-like secrets (32+ chars) + (r"\b([A-Za-z0-9+/]{32,}={0,2})\b", r"***"), + # Passwords in URLs + (r"(://)([^:@/]+):([^:@/]+)@", r"\1\2:***@"), + # Environment variable assignments with secrets (env-style: VAR=value, VAR: value) + (r"(?m)^\s*(?:PASSWORD|SECRET|TOKEN|KEY|API_KEY)\s*[=:]?\s*\S+", "***", re.IGNORECASE), + # Password/passwort in text with assignment (password: value, password=value) + (r"(?i)(password|passwort|pwd)\s*[:=]\s*\S+", "***"), + # SSH private key markers + (r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", "***PRIVATE KEY***"), + ] + + result = text + for pattern_tuple in patterns: + if len(pattern_tuple) == 3: + pattern, repl, flags = pattern_tuple + result = re.sub(pattern, repl, result, flags=flags) + else: + pattern, repl = pattern_tuple + result = re.sub(pattern, repl, result) + + return result + + +async def safe_reply(target, text: str, parse_mode: str = "Markdown", edit: bool = False): + """Send or edit a message. Falls back to plain text if Markdown fails.""" + # Sanitize output to prevent leaking secrets + text = sanitize_output(text) + try: + if edit: + return await target.edit_text(text, parse_mode=parse_mode) + return await target.reply_text(text, parse_mode=parse_mode) + except Exception: + plain = text.replace("`", "").replace("*", "").replace("_", "") + try: + if edit: + return await target.edit_text(plain) + return await target.reply_text(plain) + except Exception as e: + logger.error(f"Telegram send failed: {e}") + +# ============================================================================= +# COMMANDS +# ============================================================================= + +async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not require(update, auth.ROLE_VIEWER): + await deny(update, "/start", auth.ROLE_VIEWER) + return + + desc = llm.describe() + primary_info = "" + if desc["primary"]: + primary_info = f"\n🧠 Primary: `{desc['primary']['provider']}` ({desc['primary']['model']})" + fallback_info = "" + if desc["fallback"]: + fallback_info = f"\n🔄 Fallback: `{desc['fallback']['provider']}` ({desc['fallback']['model']})" + + role = auth.get_role(_user_id(update)) or "—" + audit_log.log_action(_user_id(update), "bot.start", role=role) + + await update.message.reply_text( + f"🐠 *Clownfischserver v0.5.1 online.*{primary_info}{fallback_info}\n" + f"👤 Deine Rolle: `{role}`\n\n" + "Schreib mir einfach – ich antworte als Chat.\n\n" + "*Befehle:*\n" + "• `/shell ` – KI generiert Befehl, du bestätigst\n" + "• `/ja` – letzten Shell-Befehl ausführen\n" + "• `/code ` – Aider generiert Code\n" + "• `/run ` – direkte Shell, kein Ollama\n" + "• `/status` – CPU, RAM, Disk\n" + "• `/snapshots` – alle Snapshots anzeigen\n" + "• `/rollback ` – zu einem Snapshot zurück\n" + "• `/snapshot [label]` – manuell Snapshot erstellen\n" + "• `/ssh open [ip]` – SSH öffnen\n" + "• `/ssh close [ip]` – SSH schließen\n" + "• `/logs [filter]` – Audit-Trail abfragen (operator)\n" + "• `/help` – diese Hilfe", + parse_mode="Markdown" + ) + +async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not require(update, auth.ROLE_VIEWER): + await deny(update, "/status", auth.ROLE_VIEWER) + return + audit_log.log_action(_user_id(update), "status.read", + role=auth.get_role(_user_id(update))) + cmd = ( + "echo '=== UPTIME ===' && uptime && " + "echo '=== MEMORY ===' && free -h && " + "echo '=== DISK ===' && df -h && " + "echo '=== TOP PROCESSES ===' && ps aux --sort=-%cpu | head -10" + ) + stdout, stderr, rc = await run_shell(cmd) + result = stdout or stderr or "Keine Ausgabe" + if len(result) > 4000: + result = result[:4000] + "\n... (gekürzt)" + await safe_reply(update.message, f"📊 Systemstatus\n\n```\n{result}\n```") + +async def cmd_snapshots(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not require(update, auth.ROLE_VIEWER): + await deny(update, "/snapshots", auth.ROLE_VIEWER) + return + audit_log.log_action(_user_id(update), "snapshots.list", + role=auth.get_role(_user_id(update))) + snap_list = snapshots.list_snapshots() + if not snap_list: + await update.message.reply_text("📸 Keine Snapshots vorhanden.") + return + msg = "📸 *Snapshots:*\n\n" + for s in snap_list[:20]: + star = "⭐ " if s["kept"] else "" + msg += f"• {star}`{s['name']}` ({s['size_mb']}MB)\n" + msg += f"\n_Gesamt: {len(snap_list)} | Max auto: 20 (⭐ bleiben immer)_\n" + msg += "\n`/snapshot keep ` – wichtig markieren\n" + msg += "`/snapshot delete ` – löschen" + await update.message.reply_text(msg, parse_mode="Markdown") + +async def cmd_rollback(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not require(update, auth.ROLE_OPERATOR): + await deny(update, "/rollback", auth.ROLE_OPERATOR) + return + if not context.args: + await update.message.reply_text( + "Usage: `/rollback `\n\nBeispiel: `/rollback 2025-01-15_17-24-00`", + parse_mode="Markdown" + ) + return + snap_name = context.args[0] + audit_log.log_action( + _user_id(update), "snapshot.rollback", + role=auth.get_role(_user_id(update)), + details={"snapshot": snap_name}, + ) + await update.message.reply_text(f"⏪ Rollback zu `{snap_name}` wird eingeleitet...", parse_mode="Markdown") + result = snapshots.rollback(snap_name) + await update.message.reply_text(result) + +async def cmd_snapshot(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Snapshot management: create, keep, delete""" + args = context.args + subcmd = args[0].lower() if args else "create" + + # Read-only subcommands are viewer-accessible; everything else mutates + # and requires operator. + if subcmd in ("list", "ls"): + required = auth.ROLE_VIEWER + else: + required = auth.ROLE_OPERATOR + + if not require(update, required): + await deny(update, f"/snapshot {subcmd}", required) + return + + audit_log.log_action( + _user_id(update), f"snapshot.{subcmd}", + role=auth.get_role(_user_id(update)), + details={"args": args[1:] if len(args) > 1 else []}, + ) + + if subcmd == "keep" and len(args) > 1: + result = snapshots.keep_snapshot(args[1]) + await update.message.reply_text(result) + elif subcmd == "unkeep" and len(args) > 1: + result = snapshots.unkeep_snapshot(args[1]) + await update.message.reply_text(result) + elif subcmd == "delete" and len(args) > 1: + result = snapshots.delete_snapshot(args[1]) + await update.message.reply_text(result) + elif subcmd in ("list", "ls"): + await cmd_snapshots(update, context) + else: + label = args[0] if args and subcmd not in ("create",) else "manual" + if subcmd == "create": + label = args[1] if len(args) > 1 else "manual" + await update.message.reply_text(f"📸 Erstelle Snapshot ({label})...") + snap_name = snapshots.create_snapshot(label=label) + if snap_name: + await update.message.reply_text( + f"✅ Snapshot erstellt: `{snap_name}`\n\nAls wichtig markieren: `/snapshot keep {snap_name}`", + parse_mode="Markdown" + ) + else: + await update.message.reply_text("❌ Snapshot fehlgeschlagen – check Logs.") + +async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not require(update, auth.ROLE_VIEWER): + await deny(update, "/help", auth.ROLE_VIEWER) + return + await cmd_start(update, context) + +async def cmd_logs(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Operator-only audit query. + + Usage: + /logs → last 20 entries + /logs user 12345 → filter by user + /logs action shell.execute → exact action + /logs prefix shell. → action prefix + /logs days 7 → last 7 days only + /logs result denied → filter by result + /logs limit 50 → up to 50 entries + Filters can be combined: /logs user 12345 days 1 limit 10 + """ + if not require(update, auth.ROLE_OPERATOR): + await deny(update, "/logs", auth.ROLE_OPERATOR) + return + + args = list(context.args) if context.args else [] + filters: dict[str, object] = {"limit": 20} + i = 0 + while i < len(args) - 1: + key = args[i].lower() + val = args[i + 1] + if key == "user": + filters["user_id"] = val + elif key == "action": + filters["action"] = val + elif key == "prefix": + filters["action_prefix"] = val + elif key == "result": + filters["result"] = val + elif key == "days": + try: + filters["days"] = int(val) + except ValueError: + await update.message.reply_text(f"❌ `days` muss eine Zahl sein, war `{val}`", + parse_mode="Markdown") + return + elif key == "limit": + try: + filters["limit"] = max(1, min(200, int(val))) + except ValueError: + await update.message.reply_text(f"❌ `limit` muss eine Zahl sein, war `{val}`", + parse_mode="Markdown") + return + else: + await update.message.reply_text( + f"❓ Unbekannter Filter `{key}`. Erlaubt: user, action, prefix, result, days, limit", + parse_mode="Markdown") + return + i += 2 + + audit_log.log_action( + _user_id(update), "logs.query", + role=auth.get_role(_user_id(update)), + details={"filters": filters}, + ) + + try: + records = audit_query.query(**filters) + except Exception as e: + logger.error(f"audit query failed: {e}", exc_info=True) + await update.message.reply_text(f"❌ Audit-Query fehlgeschlagen: `{e}`", + parse_mode="Markdown") + return + + if not records: + await update.message.reply_text("📭 Keine passenden Audit-Einträge.") + return + + summary = audit_query.summarize(records) + header = f"📜 *{summary['total']} Einträge*" + if summary["by_result"]: + header += " · " + ", ".join( + f"{k}={v}" for k, v in sorted(summary["by_result"].items()) + ) + + lines = [audit_query.format_record(r) for r in records] + body = "\n".join(lines) + # Telegram limit ~4096; chunk if necessary. + msg = f"{header}\n```\n{body}\n```" + if len(msg) > 4000: + # Drop the code-fence wrapping for large results and split. + await safe_reply(update.message, header) + chunk_size = 3800 + for i in range(0, len(body), chunk_size): + chunk = body[i:i + chunk_size] + await safe_reply(update.message, f"```\n{chunk}\n```") + else: + await safe_reply(update.message, msg) + +async def cmd_run(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Direct shell execution – bypasses Ollama completely""" + if not require(update, auth.ROLE_OPERATOR): + await deny(update, "/run", auth.ROLE_OPERATOR) + return + if not context.args: + await update.message.reply_text( + "Usage: /run \n\nBeispiel: /run free -h", + parse_mode="Markdown" + ) + return + cmd = " ".join(context.args) + uid = _user_id(update) + role = auth.get_role(uid) + await update.message.reply_text(f"⚡ Direkt: `{cmd}`", parse_mode="Markdown") + stdout, stderr, returncode = await run_shell(cmd) + audit_log.log_shell(uid, cmd, role=role, + status="ok" if returncode == 0 else "error", + returncode=returncode, stderr=stderr or None) + result = stdout or stderr or "✓ Fertig (keine Ausgabe)" + if len(result) > 4000: + result = result[:4000] + "\n... (gekürzt)" + await safe_reply(update.message, f"```\n{result}\n```") + +async def cmd_ssh(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Open or close SSH via ufw""" + if not require(update, auth.ROLE_OPERATOR): + await deny(update, "/ssh", auth.ROLE_OPERATOR) + return + action = context.args[0].lower() if context.args else "" + client_ip = context.args[1] if len(context.args) > 1 else None + audit_log.log_action( + _user_id(update), "ssh.toggle", + role=auth.get_role(_user_id(update)), + details={"action": action, "client_ip": client_ip}, + ) + + if action == "open": + if client_ip: + await run_shell(f"sudo ufw allow from {client_ip} to any port 22 comment 'clownfisch-ssh'") + else: + await run_shell("sudo ufw allow 22/tcp comment 'clownfisch-ssh'") + ip_info = f"für {client_ip}" if client_ip else "für alle" + await update.message.reply_text( + f"🔓 SSH geöffnet {ip_info}!\nVergiss nicht: /ssh close danach!", + parse_mode="Markdown" + ) + elif action == "close": + if client_ip: + await run_shell(f"sudo ufw delete allow from {client_ip} to any port 22") + else: + await run_shell("sudo ufw delete allow 22/tcp") + await update.message.reply_text("🔒 SSH wieder geschlossen.") + else: + await update.message.reply_text( + "Usage:\n" + "/ssh open – SSH für alle öffnen\n" + "/ssh open 1.2.3.4 – SSH nur für deine IP\n" + "/ssh close – SSH schließen\n" + "/ssh close 1.2.3.4 – SSH für IP schließen", + parse_mode="Markdown" + ) + +# ============================================================================= +# /shell – KI generiert Befehl, Nutzer bestätigt mit /ja +# ============================================================================= + +async def cmd_shell(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not require(update, auth.ROLE_OPERATOR): + await deny(update, "/shell", auth.ROLE_OPERATOR) + return + if not context.args: + await update.message.reply_text( + "Usage: `/shell `\n\n" + "Beispiel: `/shell zeig mir den freien RAM`", + parse_mode="Markdown" + ) + return + + primary_is_ollama = isinstance(llm.primary, OllamaProvider) + ollama_warming = ollama_ready is None or not ollama_ready.is_set() + if primary_is_ollama and ollama_warming and not llm.has_fallback(): + await update.message.reply_text("⏳ Ollama lädt noch – bitte kurz warten.") + return + + description = " ".join(context.args) + thinking_msg = await update.message.reply_text("🤔 Generiere Befehl...") + + try: + result = await ollama.generate_shell_command(description) + command = result.get("command", "").strip() + error = result.get("_error", "") + + if error: + await safe_reply(thinking_msg, f"⚠️ {error}", edit=True) + return + + if not command: + await safe_reply(thinking_msg, "❌ Konnte keinen Befehl generieren. Versuch es anders zu formulieren.", edit=True) + return + + uid = _user_id(update) + role = auth.get_role(uid) + + if result.get("dangerous", False): + audit_log.log_shell_proposed(uid, description, command, + role=role, dangerous=True) + await safe_reply(thinking_msg, + f"⚠️ *Gefährlicher Befehl verweigert!*\n\n" + f"Befehl: `{command}`\n" + f"Grund: {result.get('reason', 'Zu riskant')}", + edit=True + ) + return + + audit_log.log_shell_proposed(uid, description, command, role=role) + context.user_data["pending_cmd"] = command + await safe_reply(thinking_msg, + f"🔧 Vorgeschlagener Befehl:\n\n" + f"`{command}`\n\n" + f"→ Mit `/ja` ausführen oder einfach ignorieren.", + edit=True + ) + except Exception as e: + logger.error(f"cmd_shell failed: {e}", exc_info=True) + await safe_reply(thinking_msg, f"❌ Fehler: `{e}`", edit=True) + +# ============================================================================= +# /ja – Bestätigung für pending /shell Befehl +# ============================================================================= + +async def cmd_ja(update: Update, context: ContextTypes.DEFAULT_TYPE): + # /ja is the approval gate – approver inherits to operator. + if not require(update, auth.ROLE_APPROVER): + await deny(update, "/ja", auth.ROLE_APPROVER) + return + + pending = context.user_data.get("pending_cmd") + if not pending: + await update.message.reply_text("❓ Kein ausstehender Befehl. Nutze erst `/shell `.", parse_mode="Markdown") + return + + command = pending + context.user_data.pop("pending_cmd", None) + uid = _user_id(update) + role = auth.get_role(uid) + audit_log.log_approval(uid, command, role=role, approved=True) + + snap_name = None + if should_snapshot(command): + await update.message.reply_text("📸 Snapshot wird erstellt...") + snap_name = snapshots.create_snapshot() + if snap_name: + await update.message.reply_text(f"📸 Snapshot: `{snap_name}`", parse_mode="Markdown") + + status_msg = await update.message.reply_text(f"⚙️ Ausführung: `{command}`", parse_mode="Markdown") + + try: + result = await execute_command(command, ollama) + audit_log.log_shell(uid, command, role=role, status="ok") + if len(result) > 4000: + chunks = [result[i:i+4000] for i in range(0, len(result), 4000)] + await safe_reply(status_msg, f"✅ Ergebnis (Teil 1/{len(chunks)}):\n\n```\n{chunks[0]}\n```", edit=True) + for i, chunk in enumerate(chunks[1:], 2): + await safe_reply(update.message, f"Teil {i}/{len(chunks)}:\n\n```\n{chunk}\n```") + else: + await safe_reply(status_msg, f"✅ Ergebnis:\n\n```\n{result}\n```", edit=True) + except Exception as e: + logger.error(f"cmd_ja execution failed: {e}", exc_info=True) + audit_log.log_shell(uid, command, role=role, status="error", + stderr=str(e)) + await safe_reply(status_msg, + f"❌ Fehler:\n`{e}`\n\nSnapshot: `{snap_name or 'keiner'}`", + edit=True + ) + +# ============================================================================= +# /code – Aider für Code-Generierung +# ============================================================================= + +async def cmd_code(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not require(update, auth.ROLE_OPERATOR): + await deny(update, "/code", auth.ROLE_OPERATOR) + return + if not context.args: + await update.message.reply_text( + "Usage: `/code `\n\n" + "Beispiel: `/code erstelle eine einfache Flask API`\n\n" + "💡 Tipp: Kläre Details erst im Chat, dann `/code` mit klarer Aufgabe.", + parse_mode="Markdown" + ) + return + + task = " ".join(context.args) + + # Smart prompt: ensure Aider creates a FILE, not just chat output + workspace = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace") + task_lower = task.lower() + + # Only auto-generate a filepath if the task doesn't already point at one. + words = task.split() + first_word_is_path = bool(words) and "/" in words[0] + needs_filepath = ( + workspace not in task + and "/opt/" not in task + and not first_word_is_path + ) + + if needs_filepath: + file_match = re.search(r'(\S+\.(sh|py|js|ts|html|css|yaml|yml|json|conf|cfg|txt))', task) + if file_match: + filename = file_match.group(1) + filepath = f"{workspace}/{filename}" + else: + words = re.sub(r'[^a-z0-9 ]', '', task_lower).split()[:3] + slug = "-".join(words) if words else "script" + ext = ".py" if "python" in task_lower else ".sh" + filepath = f"{workspace}/{slug}{ext}" + + task = f"erstelle die datei {filepath} mit folgendem inhalt: {task}" + logger.info(f"Code task enhanced: {task[:100]}") + + status_msg = await update.message.reply_text("🛠️ Aider arbeitet...") + + snap_name = snapshots.create_snapshot(label="pre-aider") + if snap_name: + await update.message.reply_text(f"📸 Snapshot: `{snap_name}`", parse_mode="Markdown") + + uid = _user_id(update) + role = auth.get_role(uid) + + try: + result = await aider.run(task) + audit_log.log_code(uid, task, role=role, status="ok") + if len(result) > 4000: + result = result[:4000] + "\n... (gekürzt)" + await safe_reply(status_msg, f"🛠️ Aider Ergebnis:\n\n```\n{result}\n```", edit=True) + except Exception as e: + logger.error(f"cmd_code failed: {e}", exc_info=True) + audit_log.log_code(uid, task, role=role, status="error") + await safe_reply(status_msg, + f"❌ Aider Fehler:\n`{e}`\n\nSnapshot: `{snap_name or 'keiner'}`", + edit=True + ) + +# ============================================================================= +# FILE HANDLER – Self-Update via Telegram +# ============================================================================= + +async def handle_file(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle file uploads – ZIP = self-update""" + if not require(update, auth.ROLE_OPERATOR): + await deny(update, "file.upload", auth.ROLE_OPERATOR) + return + + doc = update.message.document + if not doc: + return + + filename = doc.file_name or "" + + if not filename.endswith(".zip"): + await update.message.reply_text( + f"📎 Datei empfangen: {filename}\nNur .zip Dateien werden als Updates verarbeitet." + ) + return + + status_msg = await update.message.reply_text( + f"📦 Update-Paket empfangen: `{filename}`\n⏳ Verarbeite...", + parse_mode="Markdown" + ) + + try: + # Snapshot before update + await status_msg.edit_text("📸 Erstelle Snapshot vor Update...") + snap_name = snapshots.create_snapshot(label="pre-update") + if snap_name: + await update.message.reply_text(f"📸 Snapshot: `{snap_name}`", parse_mode="Markdown") + + # Download ZIP + await status_msg.edit_text("⬇️ Lade Datei herunter...") + tmp_dir = tempfile.mkdtemp() + zip_path = os.path.join(tmp_dir, filename) + tg_file = await context.bot.get_file(doc.file_id) + await tg_file.download_to_drive(zip_path) + + # Extract + # PowerShell's Compress-Archive writes path entries with backslashes, + # which Python's zipfile does NOT split on under POSIX – without this + # normalization the archive extracts as flat files named + # "clownfischserver\bot\bot.py", and os.walk never finds bot.py. + await status_msg.edit_text("📂 Entpacke ZIP...") + extract_dir = os.path.join(tmp_dir, "extracted") + os.makedirs(extract_dir, exist_ok=True) + with zipfile.ZipFile(zip_path, "r") as z: + for info in z.infolist(): + normalized = info.filename.replace("\\", "/") + if not normalized or normalized.endswith("/"): + if normalized: + os.makedirs(os.path.join(extract_dir, normalized.rstrip("/")), + exist_ok=True) + continue + target_path = os.path.join(extract_dir, *normalized.split("/")) + # Zip-Slip guard: refuse entries that escape extract_dir. + if not os.path.abspath(target_path).startswith( + os.path.abspath(extract_dir) + os.sep): + raise ValueError(f"Unsicherer Pfad im ZIP: {info.filename}") + os.makedirs(os.path.dirname(target_path), exist_ok=True) + with z.open(info) as src, open(target_path, "wb") as dst: + shutil.copyfileobj(src, dst) + + # Find bot/ directory + bot_dir = None + for root, dirs, files in os.walk(extract_dir): + if "bot.py" in files: + bot_dir = root + break + + if not bot_dir: + raise FileNotFoundError("Kein bot.py im ZIP gefunden!") + + # Check if openrouter_client.py is in the update + has_openrouter = os.path.exists(os.path.join(bot_dir, "openrouter_client.py")) + + if has_openrouter and not openrouter.is_available(): + # New feature available – ask user if they want to set it up + context.user_data["update_tmp_dir"] = tmp_dir + context.user_data["update_bot_dir"] = bot_dir + context.user_data["update_extract_dir"] = extract_dir + context.user_data["update_step"] = "ask_openrouter" + + await status_msg.edit_text( + "✅ Update-Paket gültig\n\n" + "🆕 *Neues Feature: OpenRouter Fallback*\n\n" + "Wenn Ollama mal zu langsam ist, kann der Bot\n" + "kostenlose Cloud-Modelle als Backup nutzen.\n" + "Kein Zwang, keine Kreditkarte nötig.\n\n" + "Möchtest du OpenRouter aktivieren?\n" + "Antworte: `ja` oder `nein`", + parse_mode="Markdown" + ) + else: + # No OpenRouter setup needed – install directly + await _perform_update(update, context, tmp_dir, bot_dir, extract_dir) + + except Exception as e: + logger.error(f"Update fehlgeschlagen: {e}", exc_info=True) + await status_msg.edit_text( + f"❌ Update fehlgeschlagen:\n`{e}`\n\nSnapshot zum Rollback verfügbar.", + parse_mode="Markdown" + ) + + +async def _perform_update(update, context, tmp_dir, bot_dir, extract_dir, openrouter_key=None): + """Execute the actual update installation.""" + try: + msg = await update.message.reply_text("📋 Kopiere Bot-Dateien...") + install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver") + install_bot_dir = os.path.join(install_dir, "bot") + mgmt_user = os.getenv("MGMT_USER", "clownfish") + + for f in os.listdir(bot_dir): + if f.endswith(".py"): + src = os.path.join(bot_dir, f) + dst = os.path.join(install_bot_dir, f) + shutil.copy2(src, dst) + os.chmod(dst, 0o644) + logger.info(f"Updated: {f}") + + subprocess.run( + ["sudo", "chown", "-R", f"{mgmt_user}:{mgmt_user}", install_bot_dir], + capture_output=True, + ) + + # Copy systemd services – patch User= with actual MGMT_USER + systemd_dir = None + for root, dirs, files in os.walk(extract_dir): + if any(f.endswith(".service") for f in files): + systemd_dir = root + break + + if systemd_dir: + for f in os.listdir(systemd_dir): + if f.endswith(".service"): + src = os.path.join(systemd_dir, f) + with open(src, "r") as sf: + service_content = sf.read() + # Replace ANY User=/Group= value with actual MGMT_USER + service_content = re.sub(r'User=\S+', f'User={mgmt_user}', service_content) + service_content = re.sub(r'Group=\S+', f'Group={mgmt_user}', service_content) + tmp_svc = os.path.join(install_dir, f".tmp_{f}") + with open(tmp_svc, "w") as sf: + sf.write(service_content) + subprocess.run(["sudo", "cp", tmp_svc, f"/etc/systemd/system/{f}"], capture_output=True) + os.remove(tmp_svc) + logger.info(f"Service updated: {f} (User={mgmt_user})") + subprocess.run(["sudo", "systemctl", "daemon-reload"], capture_output=True) + subprocess.run(["sudo", "systemctl", "enable", "clownfisch-boot"], capture_output=True) + + # Save OpenRouter key if provided + if openrouter_key: + env_file = os.path.join(install_dir, "config", ".env") + try: + with open(env_file, "r") as f: + env_content = f.read() + except FileNotFoundError: + env_content = "" + + if "OPENROUTER_API_KEY=" in env_content: + env_content = re.sub(r'OPENROUTER_API_KEY=.*', f'OPENROUTER_API_KEY={openrouter_key}', env_content) + else: + env_content += f"\nOPENROUTER_API_KEY={openrouter_key}\n" + + with open(env_file, "w") as f: + f.write(env_content) + os.chmod(env_file, 0o600) + logger.info("OpenRouter Key in .env gespeichert") + + # Cleanup + shutil.rmtree(tmp_dir, ignore_errors=True) + + # Clear update state + for key in ["update_tmp_dir", "update_bot_dir", "update_extract_dir", "update_step", "update_openrouter_key"]: + context.user_data.pop(key, None) + + or_status = "\n✅ OpenRouter Fallback aktiviert" if openrouter_key else "" + await msg.edit_text( + f"✅ *Update installiert!*{or_status}\n\nBot startet in 3 Sekunden neu...", + parse_mode="Markdown" + ) + + await asyncio.sleep(3) + subprocess.Popen(["sudo", "systemctl", "restart", "clownfisch"]) + + except Exception as e: + logger.error(f"Update execution failed: {e}", exc_info=True) + await update.message.reply_text( + f"❌ Update fehlgeschlagen:\n`{e}`\n\nSnapshot zum Rollback verfügbar.", + parse_mode="Markdown" + ) + for key in ["update_tmp_dir", "update_bot_dir", "update_extract_dir", "update_step"]: + context.user_data.pop(key, None) + +# ============================================================================= +# MAIN MESSAGE HANDLER – Chat + Update Dialog +# ============================================================================= + +async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Normal messages: either handle update dialog or go to Ollama chat.""" + uid = _user_id(update) + if not require(update, auth.ROLE_VIEWER): + logger.warning(f"Unauthorized access attempt from user_id: {uid}") + audit_log.log_denied(uid, "chat.message", reason="unauthorized") + return + + user_message = update.message.text.strip() + + # --- Active update dialog? Handle that first, don't send to chat --- + update_step = context.user_data.get("update_step") + if update_step: + await _handle_update_dialog(update, context, user_message, update_step) + return + + # --- Normal chat --- + logger.info(f"Chat message: {user_message[:100]}") + + primary_is_ollama = isinstance(llm.primary, OllamaProvider) + ollama_warming = ollama_ready is None or not ollama_ready.is_set() + if primary_is_ollama and ollama_warming and not llm.has_fallback(): + await update.message.reply_text( + "⏳ Ollama lädt noch...\n\n" + "Direkte Befehle funktionieren sofort:\n" + "• /run \n" + "• /ssh open/close\n" + "• /status\n\n" + "Bitte in ~1 Minute nochmal versuchen." + ) + return + + thinking_msg = await update.message.reply_text("💬 ...") + + try: + history = context.user_data.get("chat_history", []) + result = await ollama.chat(user_message, history=history) + + # Sanitize LLM response before sending + result = sanitize_output(result) + + history.append({"role": "user", "content": user_message}) + history.append({"role": "assistant", "content": result}) + if len(history) > 20: + history = history[-20:] + context.user_data["chat_history"] = history + + if len(result) > 4000: + chunks = [result[i:i+4000] for i in range(0, len(result), 4000)] + await thinking_msg.edit_text(f"💬 {chunks[0]}") + for i, chunk in enumerate(chunks[1:], 2): + await update.message.reply_text(f"💬 ({i}/{len(chunks)}) {chunk}") + else: + await thinking_msg.edit_text(f"💬 {result}") + + except Exception as e: + logger.error(f"Chat error: {e}", exc_info=True) + error_msg = sanitize_output(f"❌ Chat-Fehler: `{e}`") + await thinking_msg.edit_text(error_msg, parse_mode="Markdown") + + +async def _handle_update_dialog(update, context, user_input, step): + """Handle interactive update setup dialog (OpenRouter key etc.).""" + user_input_lower = user_input.lower() + + if step == "ask_openrouter": + if user_input_lower in ("ja", "yes", "j", "y"): + context.user_data["update_step"] = "ask_openrouter_key" + await update.message.reply_text( + "🔑 *OpenRouter API Key eingeben*\n\n" + "Kostenlos – keine Kreditkarte nötig!\n\n" + "1. Gehe zu openrouter.ai\n" + "2. Sign up (2 Minuten)\n" + "3. Settings → API Keys\n" + "4. Key kopieren und hier senden\n\n" + "Format: `sk-or-v1-...`", + parse_mode="Markdown" + ) + elif user_input_lower in ("nein", "no", "n"): + tmp_dir = context.user_data.get("update_tmp_dir") + bot_dir = context.user_data.get("update_bot_dir") + extract_dir = context.user_data.get("update_extract_dir") + await _perform_update(update, context, tmp_dir, bot_dir, extract_dir) + else: + await update.message.reply_text("❓ Bitte antworte mit `ja` oder `nein`", parse_mode="Markdown") + + elif step == "ask_openrouter_key": + key = user_input.strip() + if len(key) < 20: + await update.message.reply_text( + "❌ Key zu kurz. Bitte den kompletten Key von openrouter.ai senden.", + parse_mode="Markdown" + ) + return + + await update.message.reply_text("✅ Key gespeichert – Installation startet...") + tmp_dir = context.user_data.get("update_tmp_dir") + bot_dir = context.user_data.get("update_bot_dir") + extract_dir = context.user_data.get("update_extract_dir") + await _perform_update(update, context, tmp_dir, bot_dir, extract_dir, openrouter_key=key) + +# ============================================================================= +# MAIN +# ============================================================================= + +def main(): + logger.info("🐠 Clownfischserver v0.5.1 Bot startet...") + + import requests as req + import time + import threading + + # Pull Ollama models from the router – respects LLM_*_MODEL overrides + # and falls back to OLLAMA_MODEL/OLLAMA_MODEL_FAST for backwards compat. + base_url = llm.ollama_base_url + ollama_models = llm.ollama_models + + global ollama_ready + ollama_ready = threading.Event() + + def warmup_model(model_name, label): + for attempt in range(60): + try: + resp = req.post(f"{base_url}/api/chat", json={ + "model": model_name, + "messages": [{"role": "user", "content": "hi"}], + "stream": False, + "keep_alive": -1, + }, timeout=150) + if resp.status_code == 200: + logger.info(f"✓ {label} warm: {model_name}") + return True + except Exception: + pass + time.sleep(3) + logger.warning(f"{label} nach 3 Minuten nicht bereit: {model_name}") + return False + + def warmup_ollama(): + if not ollama_models: + logger.info("Kein Ollama-Provider konfiguriert – Warmup übersprungen.") + ollama_ready.set() + return + logger.info(f"Ollama Warmup gestartet (Hintergrund): {ollama_models}") + for idx, m in enumerate(ollama_models): + label = "Fast-Modell" if idx == 0 and len(ollama_models) > 1 else "Hauptmodell" + warmup_model(m, label) + ollama_ready.set() + + threading.Thread(target=warmup_ollama, daemon=True).start() + if ollama_models: + logger.info(f"🐠 Bot startet – Ollama warmup für {len(ollama_models)} Modell(e)") + else: + logger.info("🐠 Bot startet – kein Ollama-Provider, kein Warmup") + ollama_ready.set() + + route_desc = llm.describe() + logger.info(f"LLM Routing: {route_desc}") + + # Audit-Trail Setup: prune old logs on boot, log the role roster. + dropped = audit_log.prune_old_logs() + if dropped: + logger.info(f"Audit: {dropped} alte Einträge entfernt (Retention)") + roster = auth.describe_roles() + logger.info( + f"Rollen: operators={len(roster['operator'])} " + f"approvers={len(roster['approver'])} viewers={len(roster['viewer'])}" + ) + audit_log.log_action(None, "bot.boot", details={"roster": roster}) + + app = Application.builder().token(BOT_TOKEN).build() + + # Commands + app.add_handler(CommandHandler("start", cmd_start)) + app.add_handler(CommandHandler("status", cmd_status)) + app.add_handler(CommandHandler("snapshots", cmd_snapshots)) + app.add_handler(CommandHandler("rollback", cmd_rollback)) + app.add_handler(CommandHandler("help", cmd_help)) + app.add_handler(CommandHandler("run", cmd_run)) + app.add_handler(CommandHandler("snapshot", cmd_snapshot)) + app.add_handler(CommandHandler("ssh", cmd_ssh)) + app.add_handler(CommandHandler("shell", cmd_shell)) + app.add_handler(CommandHandler("ja", cmd_ja)) + app.add_handler(CommandHandler("code", cmd_code)) + app.add_handler(CommandHandler("logs", cmd_logs)) + + # Messages – single handler for chat AND update dialog (no double-processing) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) + app.add_handler(MessageHandler(filters.Document.ALL, handle_file)) + + legacy_info = f" legacy_chat={CHAT_ID}" if CHAT_ID else "" + logger.info(f"Bot läuft. Rollen geladen.{legacy_info}") + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(app.initialize()) + app.run_polling(allowed_updates=Update.ALL_TYPES) + finally: + loop.run_until_complete(app.shutdown()) + +if __name__ == "__main__": + main() diff --git a/bot/knock_notify.py b/bot/knock_notify.py new file mode 100644 index 0000000..4286750 --- /dev/null +++ b/bot/knock_notify.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +""" +🐠 Clownfischserver +Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) +License: GPL-3.0 – Keep it open. Always. +Repo: https://github.com/mehlzoerwer-claude/clownfischserver +""" + +import os +import sys +import requests +from pathlib import Path +from dotenv import load_dotenv + +# Load .env +ENV_FILE = "/opt/clownfischserver/config/.env" +load_dotenv(ENV_FILE) + +BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +CHAT_ID = os.getenv("TELEGRAM_CHAT_ID") + +def send_message(text: str): + if not BOT_TOKEN or not CHAT_ID: + print("ERROR: Token oder Chat-ID fehlt in .env") + sys.exit(1) + try: + requests.post( + f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage", + json={"chat_id": CHAT_ID, "text": text, "parse_mode": "Markdown"}, + timeout=10 + ) + except Exception as e: + print(f"Telegram Fehler: {e}") + +if __name__ == "__main__": + event = sys.argv[1] if len(sys.argv) > 1 else "unknown" + ip = sys.argv[2] if len(sys.argv) > 2 else "unbekannt" + + from datetime import datetime + zeit = datetime.now().strftime("%H:%M:%S") + datum = datetime.now().strftime("%d.%m.%Y") + + if event == "open": + send_message(f"🔓 *SSH geöffnet*\nIP: `{ip}`\nZeit: {datum} {zeit}") + elif event == "close": + send_message(f"🔒 *SSH geschlossen*\nIP: `{ip}`\nZeit: {datum} {zeit}") + elif event == "attempt": + send_message(f"⚠️ *Knock-Versuch*\nIP: `{ip}`\nZeit: {datum} {zeit}") diff --git a/bot/llm_router.py b/bot/llm_router.py new file mode 100644 index 0000000..c74c3f8 --- /dev/null +++ b/bot/llm_router.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +""" +🐠 Clownfischserver – LLM Provider Router (Phase 3) +Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) +License: GPL-3.0 + +Replaces the previously hard-wired Ollama+OpenRouter fallback logic with a +config-driven primary/fallback chain. + +Env vars: + LLM_PRIMARY_PROVIDER = "ollama" | "openrouter" (default: ollama) + LLM_PRIMARY_MODEL = model id for the primary provider + LLM_PRIMARY_MODEL_FAST = optional faster model for chat replies (ollama only) + LLM_PRIMARY_NUM_CTX = context window override (ollama) + LLM_FALLBACK_PROVIDER = "openrouter" | "ollama" | "" ("" disables fallback) + LLM_FALLBACK_MODEL = model id for fallback provider + TRACK_LLM_COSTS = "true"|"false" – currently informational only + +Backwards compatibility: +- If LLM_*_PROVIDER vars are absent we still default to Ollama primary and, + when OPENROUTER_API_KEY exists, OpenRouter as fallback. Existing + OLLAMA_MODEL / OLLAMA_MODEL_FAST / OLLAMA_NUM_CTX / OPENROUTER_API_KEY + env vars are honoured. + +Routing policy (intentionally explicit, not "self-improving"): +- Primary is tried first. Only routable errors (network, timeout, provider + unavailable) trigger fallback. Hard errors (invalid JSON, malformed prompt, + bad API key) propagate so callers see them. +- Every routing decision is written to the audit trail as `llm.route` so + admins can see which model produced which output (DSGVO Art. 5(1)(a) + transparency). +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Awaitable, Callable, Optional + +from ollama_client import OllamaClient +from openrouter_client import OpenRouterClient + +try: # audit is optional – router must work even if audit module is missing + import audit_log +except ImportError: # pragma: no cover + audit_log = None # type: ignore + +logger = logging.getLogger(__name__) + +PROVIDER_OLLAMA = "ollama" +PROVIDER_OPENROUTER = "openrouter" + +# Error substrings that should trigger a fallback rather than propagate. +# Kept narrow on purpose – we don't want to mask programmer errors. +_ROUTABLE_ERROR_FRAGMENTS = ( + "Timeout", + "nicht erreichbar", + "ConnectionError", + "Connection refused", + "API Key nicht konfiguriert", +) + + +def _is_routable_error(exc: BaseException) -> bool: + msg = str(exc) + return any(frag in msg for frag in _ROUTABLE_ERROR_FRAGMENTS) + + +class LLMProvider: + """Thin interface every provider satisfies.""" + + name: str = "base" + model: str = "" + + def is_available(self) -> bool: + raise NotImplementedError + + async def chat(self, message: str, history: list | None = None) -> str: + raise NotImplementedError + + async def generate_shell_command(self, description: str) -> dict: + raise NotImplementedError + + async def explain_output(self, command: str, output: str, + error: str = "") -> str: + raise NotImplementedError + + +class OllamaProvider(LLMProvider): + name = PROVIDER_OLLAMA + + def __init__(self, model: Optional[str] = None, + model_fast: Optional[str] = None, + num_ctx: Optional[int] = None, + base_url: Optional[str] = None): + # Pass openrouter_client=None so the underlying client does NOT + # perform its own fallback – the router owns that decision now. + self.client = OllamaClient( + openrouter_client=None, + model=model, + model_fast=model_fast, + num_ctx=num_ctx, + base_url=base_url, + ) + self.model = self.client.model + + def is_available(self) -> bool: + # Connection health is checked at call time; we always advertise + # ourselves as available so the router will attempt and trigger + # fallback on actual failure. + return True + + async def chat(self, message, history=None): + return await self.client.chat(message, history) + + async def generate_shell_command(self, description): + return await self.client.generate_shell_command(description) + + async def explain_output(self, command, output, error=""): + return await self.client.explain_output(command, output, error) + + +class OpenRouterProvider(LLMProvider): + name = PROVIDER_OPENROUTER + + def __init__(self, model: Optional[str] = None): + self.client = OpenRouterClient() + # OpenRouterClient reads OPENROUTER_API_KEY at import time. We + # only override the model id, leaving the API key handling alone. + self.model = model or "openrouter/free" + if model: + # Patch the FREE_MODELS dict so chat/coder both use the + # configured model. Limited blast radius – it only affects + # this OpenRouterClient instance, not the module constant. + self._patch_model(model) + + def _patch_model(self, model: str) -> None: + # The underlying OpenRouterClient hard-codes FREE_MODELS["coder"]/ + # ["chat"] at call sites. Wrap _chat_sync so every call resolves + # to the user-pinned model regardless of what callers pass in. + pinned = model + original_chat_sync = self.client._chat_sync + + def wrapper(messages, system=None, model=None): + return original_chat_sync(messages, system=system, model=pinned) + + self.client._chat_sync = wrapper # type: ignore[assignment] + + def is_available(self) -> bool: + return self.client.is_available() + + async def chat(self, message, history=None): + return await self.client.chat(message, history) + + async def generate_shell_command(self, description): + return await self.client.generate_shell_command(description) + + async def explain_output(self, command, output, error=""): + return await self.client.explain_output(command, output, error) + + +def _build_provider(kind: str) -> Optional[LLMProvider]: + """Build the provider for kind ∈ {"primary", "fallback"}. + + Returns None if the configuration disables this tier (e.g. fallback + requested but no key available). + """ + explicit = os.getenv(f"LLM_{kind.upper()}_PROVIDER", "").strip().lower() + + if kind == "primary": + provider_name = explicit or PROVIDER_OLLAMA + else: + # Fallback default: openrouter only if API key is configured. + if explicit: + provider_name = explicit + elif os.getenv("OPENROUTER_API_KEY"): + provider_name = PROVIDER_OPENROUTER + else: + return None + + if provider_name in ("", "none", "off", "disabled"): + return None + + model = os.getenv(f"LLM_{kind.upper()}_MODEL", "").strip() or None + + if provider_name == PROVIDER_OLLAMA: + model_fast = os.getenv(f"LLM_{kind.upper()}_MODEL_FAST", "").strip() or None + num_ctx_raw = os.getenv(f"LLM_{kind.upper()}_NUM_CTX", "").strip() + num_ctx = int(num_ctx_raw) if num_ctx_raw.isdigit() else None + return OllamaProvider(model=model, model_fast=model_fast, + num_ctx=num_ctx) + if provider_name == PROVIDER_OPENROUTER: + provider = OpenRouterProvider(model=model) + # Skip the tier if OpenRouter is configured but key is missing. + if not provider.is_available(): + logger.warning( + f"LLM router: {kind} provider 'openrouter' has no API key – disabled" + ) + return None + return provider + + logger.error(f"LLM router: unknown {kind} provider '{provider_name}'") + return None + + +_BUILD_FROM_ENV = object() # sentinel: distinguish "not passed" from "None" + + +class LLMRouter: + """Drop-in replacement for the old OllamaClient surface. + + Exposes the same async methods (`chat`, `generate_shell_command`, + `explain_output`) plus a small status surface (`primary`, `fallback`, + `describe()`). + + Pass `primary=None` / `fallback=None` to explicitly disable a tier; + omit the kwarg entirely to build the tier from environment. + """ + + def __init__(self, primary=_BUILD_FROM_ENV, fallback=_BUILD_FROM_ENV): + self.primary = _build_provider("primary") if primary is _BUILD_FROM_ENV else primary + self.fallback = _build_provider("fallback") if fallback is _BUILD_FROM_ENV else fallback + if self.primary is None and self.fallback is None: + logger.error("LLMRouter: neither primary nor fallback configured!") + + # ---- public describe ------------------------------------------------ + def describe(self) -> dict: + def _info(p: LLMProvider | None) -> dict | None: + if p is None: + return None + return {"provider": p.name, "model": p.model, + "available": p.is_available()} + return {"primary": _info(self.primary), "fallback": _info(self.fallback)} + + # ---- dispatcher ----------------------------------------------------- + async def _dispatch(self, method_name: str, *args, **kwargs) -> Any: + attempts: list[tuple[str, LLMProvider]] = [] + if self.primary is not None: + attempts.append(("primary", self.primary)) + if self.fallback is not None: + attempts.append(("fallback", self.fallback)) + + if not attempts: + raise RuntimeError( + "Kein LLM-Provider konfiguriert (LLM_PRIMARY_PROVIDER fehlt)" + ) + + last_error: Optional[BaseException] = None + for tier, provider in attempts: + if not provider.is_available(): + self._audit("skip", tier, provider, method_name, + reason="provider_unavailable") + continue + try: + result = await getattr(provider, method_name)(*args, **kwargs) + self._audit("hit", tier, provider, method_name) + return result + except Exception as e: + last_error = e + routable = _is_routable_error(e) + self._audit("error", tier, provider, method_name, + reason=str(e)[:200], + routable=routable) + if not routable: + # Hard error – don't mask it with a fallback. + raise + # Otherwise loop to next tier. + continue + + # All tiers exhausted. + raise RuntimeError( + f"Alle LLM-Provider fehlgeschlagen für {method_name}: {last_error}" + ) + + def _audit(self, status: str, tier: str, provider: LLMProvider, + method: str, **extra) -> None: + if audit_log is None: + return + try: + details = {"tier": tier, "provider": provider.name, + "model": provider.model, "method": method} + details.update(extra) + audit_log.log_action(None, "llm.route", result=status, + details=details) + except Exception as e: # pragma: no cover – audit must never fail + logger.debug(f"audit emit failed: {e}") + + # ---- public surface ------------------------------------------------- + async def chat(self, message: str, history: list | None = None) -> str: + return await self._dispatch("chat", message, history) + + async def generate_shell_command(self, description: str) -> dict: + return await self._dispatch("generate_shell_command", description) + + async def explain_output(self, command: str, output: str, + error: str = "") -> str: + return await self._dispatch("explain_output", command, output, error) + + # ---- convenience for the warmup path in bot.py ---------------------- + @property + def ollama_models(self) -> list[str]: + """Return distinct Ollama model ids configured across tiers – + used by the warmup thread in bot.py.""" + models: list[str] = [] + for p in (self.primary, self.fallback): + if isinstance(p, OllamaProvider): + if p.client.model and p.client.model not in models: + models.append(p.client.model) + if (p.client.model_fast + and p.client.model_fast not in models): + models.append(p.client.model_fast) + return models + + @property + def ollama_base_url(self) -> str: + for p in (self.primary, self.fallback): + if isinstance(p, OllamaProvider): + return p.client.base_url + return os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") + + def has_fallback(self) -> bool: + return self.fallback is not None and self.fallback.is_available() diff --git a/bot/ollama_client.py b/bot/ollama_client.py new file mode 100644 index 0000000..e0e2798 --- /dev/null +++ b/bot/ollama_client.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +🐠 Clownfischserver +Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) +License: GPL-3.0 – Keep it open. Always. +Repo: https://github.com/mehlzoerwer-claude/clownfischserver +""" + +import json +import logging +import os +import re +import asyncio +import requests +from typing import Optional + +logger = logging.getLogger(__name__) + +OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") +OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b") +OLLAMA_MODEL_FAST = os.getenv("OLLAMA_MODEL_FAST", "") +OLLAMA_NUM_CTX = int(os.getenv("OLLAMA_NUM_CTX", "4096")) + +SHELL_PROMPT_TEMPLATE = """You are a Linux bash command generator for an Ubuntu 24.04 server. +Convert the user request into a single non-interactive bash command. +Respond with ONLY a JSON object. + +CRITICAL RULES: +- Commands run WITHOUT a terminal. No interactive commands like nano, vim, crontab -e, less, top. +- Use non-interactive alternatives: echo/tee for files, /etc/cron.d/ for cron jobs, sed for edits. +- Use sudo when root permissions are needed. +- Chain commands with && if needed. + +Example request: "show free ram" +Example response: {{"dangerous":false,"reason":"","command":"free -h"}} + +Example request: "installiere nginx" +Example response: {{"dangerous":false,"reason":"","command":"sudo apt install -y nginx"}} + +Example request: "erstelle einen cronjob der alle 5 minuten ein script ausfuehrt" +Example response: {{"dangerous":false,"reason":"","command":"echo '*/5 * * * * root /path/to/script.sh' | sudo tee /etc/cron.d/myjob"}} + +Example request: "schreibe hello world in eine datei" +Example response: {{"dangerous":false,"reason":"","command":"echo 'hello world' | sudo tee /tmp/hello.txt"}} + +Example request: "starte nginx neu" +Example response: {{"dangerous":false,"reason":"","command":"sudo systemctl restart nginx"}} + +Example request: "lösche alles in /etc" +Example response: {{"dangerous":true,"reason":"Würde Systemkonfiguration zerstören","command":"rm -rf /etc/*"}} + +Now generate JSON for this request: "{description}" + +IMPORTANT: Respond with ONLY the JSON object. No other text. No markdown.""" + +CHAT_SYSTEM_PROMPT = """Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers. +Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt. +Wenn du ein Befehlsergebnis erklärst, sei präzise und verständlich.""" + + +def _extract_json(raw: str) -> dict: + """Extract JSON from Ollama response, handling markdown wrappers and stray text.""" + text = raw.strip() + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + text = text.strip() + + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + match = re.search(r"\{[^{}]*\}", text, re.DOTALL) + if match: + try: + return json.loads(match.group()) + except json.JSONDecodeError: + pass + + raise json.JSONDecodeError("No valid JSON found", text, 0) + + +class OllamaClient: + def __init__(self, openrouter_client=None, model: Optional[str] = None, + model_fast: Optional[str] = None, + num_ctx: Optional[int] = None, + base_url: Optional[str] = None): + self.base_url = base_url or OLLAMA_BASE_URL + self.model = model or OLLAMA_MODEL + self.model_fast = model_fast or OLLAMA_MODEL_FAST or self.model + self.num_ctx = num_ctx if num_ctx is not None else OLLAMA_NUM_CTX + self.openrouter = openrouter_client + + def _chat_sync(self, messages: list, system: Optional[str] = None, think: Optional[bool] = None, use_fast: bool = False) -> str: + """Synchronous Ollama API call""" + model = self.model_fast if use_fast else self.model + payload = { + "model": model, + "messages": messages, + "stream": False, + "keep_alive": -1, + "options": {"num_ctx": self.num_ctx}, + } + if system: + payload["system"] = system + if think is not None: + payload["think"] = think + + try: + response = requests.post( + f"{self.base_url}/api/chat", + json=payload, + timeout=180, + ) + response.raise_for_status() + data = response.json() + return data.get("message", {}).get("content", "") + except requests.exceptions.ConnectionError: + raise RuntimeError("Ollama nicht erreichbar – läuft der Service? (systemctl status ollama)") + except requests.exceptions.Timeout: + raise RuntimeError("Ollama Timeout – Modell lädt möglicherweise noch.") + except Exception as e: + raise RuntimeError(f"Ollama Fehler: {e}") + + async def generate_shell_command(self, description: str) -> dict: + """Generate a shell command from natural language description. + Tries Ollama first, falls back to OpenRouter on timeout.""" + loop = asyncio.get_running_loop() + raw = "" + + # 1. Try local Ollama + try: + prompt = SHELL_PROMPT_TEMPLATE.format(description=description) + raw = await loop.run_in_executor( + None, + lambda: self._chat_sync( + messages=[{"role": "user", "content": prompt}], + think=False, + use_fast=False, + ), + ) + result = _extract_json(raw) + + if "command" not in result or not result["command"].strip(): + logger.warning(f"Shell generate: no command in result: {result}") + return {"dangerous": False, "reason": "", "command": ""} + if "dangerous" not in result: + result["dangerous"] = False + + logger.info(f"Shell generate (Ollama): {result}") + return result + + except json.JSONDecodeError as e: + logger.warning(f"Shell generate JSON error: {e}, raw: {raw[:500]}") + return {"dangerous": False, "reason": "", "command": ""} + except RuntimeError as e: + if "Timeout" not in str(e) and "nicht erreichbar" not in str(e): + raise + logger.warning("Ollama nicht verfügbar für /shell – trying OpenRouter fallback") + + # 2. Fallback to OpenRouter + if self.openrouter and self.openrouter.is_available(): + try: + logger.info("Using OpenRouter fallback for /shell...") + result = await self.openrouter.generate_shell_command(description) + logger.info(f"Shell generate (OpenRouter): {result}") + return result + except Exception as e: + logger.error(f"OpenRouter fallback failed: {e}") + + return { + "dangerous": False, "reason": "", + "command": "", + "_error": "Ollama Timeout und kein OpenRouter Fallback verfügbar.", + } + + async def explain_output(self, command: str, output: str, error: str = "") -> str: + """Ask Ollama to explain/summarize command output""" + loop = asyncio.get_running_loop() + + content = f"Command: {command}\nOutput:\n{output}" + if error: + content += f"\nErrors:\n{error}" + content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer." + + try: + return await loop.run_in_executor( + None, + lambda: self._chat_sync( + messages=[{"role": "user", "content": content}], + system=CHAT_SYSTEM_PROMPT, + think=False, + use_fast=True, + ), + ) + except Exception as e: + logger.error(f"explain_output failed: {e}") + # Try OpenRouter fallback + if self.openrouter and self.openrouter.is_available(): + try: + return await self.openrouter.explain_output(command, output, error) + except Exception: + pass + return "" + + async def chat(self, message: str, history: list = None) -> str: + """General chat with context. Tries Ollama, falls back to OpenRouter.""" + loop = asyncio.get_running_loop() + messages = list(history) if history else [] + messages.append({"role": "user", "content": message}) + + # 1. Try local Ollama + try: + return await loop.run_in_executor( + None, + lambda: self._chat_sync( + messages=messages, + system=CHAT_SYSTEM_PROMPT, + use_fast=True, + ), + ) + except RuntimeError as e: + if "Timeout" not in str(e) and "nicht erreichbar" not in str(e): + return f"Fehler: {e}" + logger.warning("Ollama chat nicht verfügbar – trying OpenRouter") + + # 2. Fallback to OpenRouter + if self.openrouter and self.openrouter.is_available(): + try: + logger.info("Using OpenRouter for chat...") + return await self.openrouter.chat(message, history) + except Exception as e: + return f"Ollama und OpenRouter nicht verfügbar: {e}" + + return ( + "Ollama nicht verfügbar und kein OpenRouter Fallback konfiguriert.\n" + "Versuche es später oder konfiguriere OpenRouter in .env" + ) diff --git a/bot/openrouter_client.py b/bot/openrouter_client.py new file mode 100644 index 0000000..6ae1002 --- /dev/null +++ b/bot/openrouter_client.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +🐠 Clownfischserver – OpenRouter Free Models Fallback +Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) +License: GPL-3.0 – Keep it open. Always. +Repo: https://github.com/mehlzoerwer-claude/clownfischserver +""" + +import json +import logging +import os +import re +import asyncio +import requests +from typing import Optional + +logger = logging.getLogger(__name__) + +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "") +OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" + +# Free models on OpenRouter (no credit card needed) +# openrouter/free auto-selects the best available free model +FREE_MODELS = { + "coder": "openrouter/free", + "chat": "openrouter/free", +} + + +def _extract_json(raw: str) -> dict: + """Extract JSON from response.""" + text = raw.strip() + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + text = text.strip() + + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + match = re.search(r"\{[^{}]*\}", text, re.DOTALL) + if match: + try: + return json.loads(match.group()) + except json.JSONDecodeError: + pass + + raise json.JSONDecodeError("No valid JSON found", text, 0) + + +class OpenRouterClient: + """OpenRouter Free Models Client – fallback when Ollama times out.""" + + def __init__(self): + # Read at construction time, not import time, so env changes between + # boot and config reloads (or test monkeypatching) take effect. + self.api_key = os.getenv("OPENROUTER_API_KEY", "") or OPENROUTER_API_KEY + self.base_url = OPENROUTER_BASE_URL + if self.api_key: + logger.info("OpenRouter Fallback konfiguriert") + else: + logger.info("OpenRouter nicht konfiguriert – Fallback deaktiviert") + + def is_available(self) -> bool: + return bool(self.api_key) + + def _chat_sync(self, messages: list, system: Optional[str] = None, model: str = None) -> str: + """Synchronous OpenRouter API call.""" + if not self.api_key: + raise RuntimeError("OpenRouter API Key nicht konfiguriert") + + model = model or FREE_MODELS["chat"] + + api_messages = [] + if system: + api_messages.append({"role": "system", "content": system}) + api_messages.extend(messages) + + try: + response = requests.post( + f"{self.base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json={ + "model": model, + "messages": api_messages, + "temperature": 0.7, + }, + timeout=60, + ) + response.raise_for_status() + data = response.json() + return data.get("choices", [{}])[0].get("message", {}).get("content", "") + except requests.exceptions.ConnectionError: + raise RuntimeError("OpenRouter nicht erreichbar") + except requests.exceptions.Timeout: + raise RuntimeError("OpenRouter Timeout") + except Exception as e: + raise RuntimeError(f"OpenRouter Fehler: {e}") + + async def generate_shell_command(self, description: str) -> dict: + """Generate shell command via OpenRouter.""" + loop = asyncio.get_running_loop() + + system = "Server: Ubuntu 24.04, Clownfischserver setup." + + prompt = ( + "You are a Linux bash command generator for Ubuntu 24.04.\n" + "Convert the request into a single non-interactive bash command.\n" + "Respond ONLY with JSON: {\"dangerous\": bool, \"reason\": string, \"command\": string}\n" + "No interactive commands (nano, vim, crontab -e). Use sudo when needed.\n\n" + f"Request: {description}\n\nJSON only:" + ) + + try: + raw = await loop.run_in_executor( + None, + lambda: self._chat_sync( + messages=[{"role": "user", "content": prompt}], + system=system, + model=FREE_MODELS["coder"], + ), + ) + result = _extract_json(raw) + if "command" not in result: + result["command"] = "" + if "dangerous" not in result: + result["dangerous"] = False + logger.info(f"OpenRouter shell: {result}") + return result + except json.JSONDecodeError as e: + logger.warning(f"OpenRouter JSON error: {e}") + return {"dangerous": False, "reason": "", "command": ""} + except Exception as e: + logger.error(f"OpenRouter shell failed: {e}") + raise + + async def chat(self, message: str, history: list = None) -> str: + """General chat via OpenRouter.""" + loop = asyncio.get_running_loop() + messages = list(history) if history else [] + messages.append({"role": "user", "content": message}) + + system = ( + "Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.\n" + "Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt." + ) + + try: + return await loop.run_in_executor( + None, + lambda: self._chat_sync( + messages=messages, + system=system, + model=FREE_MODELS["chat"], + ), + ) + except Exception as e: + return f"OpenRouter Fehler: {e}" + + async def explain_output(self, command: str, output: str, error: str = "") -> str: + """Explain command output via OpenRouter.""" + loop = asyncio.get_running_loop() + + content = f"Command: {command}\nOutput:\n{output}" + if error: + content += f"\nErrors:\n{error}" + content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer." + + try: + return await loop.run_in_executor( + None, + lambda: self._chat_sync( + messages=[{"role": "user", "content": content}], + model=FREE_MODELS["chat"], + ), + ) + except Exception as e: + logger.error(f"OpenRouter explain failed: {e}") + return "" diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..bcdecba --- /dev/null +++ b/bot/requirements.txt @@ -0,0 +1,4 @@ +python-telegram-bot==21.0 +aider-chat +requests +python-dotenv diff --git a/bot/shell.py b/bot/shell.py new file mode 100644 index 0000000..d5c7554 --- /dev/null +++ b/bot/shell.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +🐠 Clownfischserver +Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) +License: GPL-3.0 – Keep it open. Always. +Repo: https://github.com/mehlzoerwer-claude/clownfischserver +""" + +import asyncio +import logging +import os +import shlex + +logger = logging.getLogger(__name__) + +MGMT_USER = os.getenv("MGMT_USER", "clownfish") +COMMAND_TIMEOUT = 120 # seconds + + +async def run_shell(command: str) -> tuple[str, str, int]: + """Run a shell command as mgmt user, return (stdout, stderr, returncode)""" + try: + current_user = os.getenv("USER", "") + if current_user != MGMT_USER: + full_cmd = f"sudo -u {MGMT_USER} bash -c {shlex.quote(command)}" + else: + full_cmd = command + + process = await asyncio.create_subprocess_shell( + full_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, "HOME": f"/home/{MGMT_USER}"} + ) + + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=COMMAND_TIMEOUT + ) + except asyncio.TimeoutError: + process.kill() + return "", f"Timeout nach {COMMAND_TIMEOUT}s", 1 + + return ( + stdout.decode("utf-8", errors="replace").strip(), + stderr.decode("utf-8", errors="replace").strip(), + process.returncode + ) + + except Exception as e: + logger.error(f"Shell execution error: {e}") + return "", str(e), 1 + + +async def execute_command(shell_cmd: str, ollama_client) -> str: + """ + Execute a pre-determined shell command and return formatted output. + Optionally asks Ollama to explain errors or complex output. + """ + logger.info(f"Executing: {shell_cmd}") + + stdout, stderr, returncode = await run_shell(shell_cmd) + + # Build response + response_parts = [f"$ {shell_cmd}"] + + if stdout: + response_parts.append(stdout) + + if stderr and returncode != 0: + response_parts.append(f"[STDERR]\n{stderr}") + + if returncode != 0: + response_parts.append(f"[Exit code: {returncode}]") + + raw_output = "\n".join(response_parts) + + # If there's an error or output is complex, ask Ollama to explain + if returncode != 0 or len(stdout) > 500: + explanation = await ollama_client.explain_output(shell_cmd, stdout, stderr) + if explanation: + raw_output += f"\n\n💡 {explanation}" + + return raw_output or "✓ Ausgeführt (keine Ausgabe)" diff --git a/bot/snapshot.py b/bot/snapshot.py new file mode 100644 index 0000000..fe56fda --- /dev/null +++ b/bot/snapshot.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +""" +🐠 Clownfischserver +Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) +License: GPL-3.0 – Keep it open. Always. +Repo: https://github.com/mehlzoerwer-claude/clownfischserver +""" + +import logging +import os +import subprocess +import shutil +import json +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +SNAPSHOT_DIR = os.getenv("SNAPSHOT_DIR", "/opt/clownfisch-snapshots") +SNAPSHOT_METHOD = os.getenv("SNAPSHOT_METHOD", "tar") +MAX_SNAPSHOTS = 20 # Auto-cleanup limit (kept snapshots are exempt) +KEPT_FILE = os.path.join(SNAPSHOT_DIR, ".kept.json") # Tracks important snapshots + +# Commands that trigger automatic snapshots +AUTO_SNAPSHOT_TRIGGERS = [ + "apt install", "apt-get install", "apt remove", "apt-get remove", + "apt upgrade", "apt-get upgrade", "dnf install", "dnf remove", + "pacman -S", "pacman -R", + "systemctl enable", "systemctl disable", + "rm -", "mv ", "cp -", + "chmod ", "chown ", + "nano ", "vim ", "vi ", "echo >", "tee ", + "dd ", "mkfs", "fdisk", +] + +# Commands that are always read-only – never need a snapshot +READONLY_PREFIXES = [ + "ls", "cat ", "grep ", "find ", "df ", "du ", "free", + "top", "ps ", "who", "uptime", "journalctl", "tail ", + "head ", "wc ", "stat ", "file ", "which ", "type ", + "systemctl status", "systemctl is-active", "systemctl show", + "ollama list", "docker ps", "docker images", +] + + +def should_snapshot(command: str) -> bool: + """Check if a shell command warrants an automatic snapshot.""" + cmd_lower = command.lower().strip() + + # Skip read-only commands + for ro in READONLY_PREFIXES: + if cmd_lower.startswith(ro): + return False + + # Check triggers + for trigger in AUTO_SNAPSHOT_TRIGGERS: + if trigger in cmd_lower: + return True + + return False + + +class SnapshotManager: + def __init__(self): + self.snapshot_dir = Path(SNAPSHOT_DIR) + self.method = SNAPSHOT_METHOD + self.snapshot_dir.mkdir(parents=True, exist_ok=True) + self._load_kept() + logger.info(f"SnapshotManager init: method={self.method}, dir={self.snapshot_dir}") + + def _timestamp(self) -> str: + return datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + + def _load_kept(self): + """Load list of kept (important) snapshots.""" + try: + if os.path.exists(KEPT_FILE): + with open(KEPT_FILE, "r") as f: + self.kept = set(json.loads(f.read())) + else: + self.kept = set() + except Exception: + self.kept = set() + + def _save_kept(self): + """Save kept snapshots list.""" + try: + with open(KEPT_FILE, "w") as f: + json.dump(list(self.kept), f) + except Exception as e: + logger.error(f"Could not save kept list: {e}") + + def create_snapshot(self, label: str = "") -> Optional[str]: + """Create a snapshot. Returns snapshot name or None on failure.""" + name = self._timestamp() + if label: + name += f"_{label}" + + try: + result = self._create_tar_snapshot(name) + if result: + logger.info(f"Snapshot created: {name}") + self._cleanup_old_snapshots() + return name + return None + except Exception as e: + logger.error(f"Snapshot creation failed: {e}") + return None + + def _create_tar_snapshot(self, name: str) -> bool: + """Create tar.gz snapshot, excluding sensitive files.""" + snap_path = self.snapshot_dir / f"{name}.tar.gz" + + # Save current package list for rollback comparison + pkg_list = self.snapshot_dir / f"{name}.packages.txt" + try: + with open(pkg_list, "w") as f: + subprocess.run( + ["dpkg", "--get-selections"], + stdout=f, + stderr=subprocess.DEVNULL, + timeout=10, + ) + except Exception as e: + logger.warning(f"Could not save package list: {e}") + + workspace = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace") + + dirs_to_backup = [ + "/etc", + "/opt/clownfischserver", + "/home", + workspace, + ] + existing = [d for d in dirs_to_backup if os.path.exists(d)] + + if not existing: + raise Exception("Keine Verzeichnisse zum Sichern gefunden") + + cmd = ["sudo", "tar", "czf", str(snap_path)] + cmd += ["--ignore-failed-read"] + # Exclude sensitive files – NEVER backup secrets + cmd += ["--exclude=/etc/ssh/ssh_host_rsa_key"] + cmd += ["--exclude=/etc/ssh/ssh_host_ecdsa_key"] + cmd += ["--exclude=/etc/ssh/ssh_host_ed25519_key"] + cmd += ["--exclude=/etc/shadow"] + cmd += ["--exclude=/etc/gshadow"] + cmd += ["--exclude=*/config/.env"] # Don't backup API keys/tokens + # Exclude large caches + cmd += ["--exclude=__pycache__"] + cmd += ["--exclude=*.pyc"] + cmd += ["--exclude=node_modules"] + cmd += existing + + result = subprocess.run(cmd, capture_output=True) + if result.returncode not in (0, 1): # 1 = warnings only + raise Exception(result.stderr.decode()) + return True + + def list_snapshots(self) -> list[dict]: + """List all snapshots with metadata, newest first.""" + try: + snaps = [] + for f in self.snapshot_dir.iterdir(): + if f.name.endswith(".tar.gz"): + name = f.name.replace(".tar.gz", "") + size_mb = f.stat().st_size // (1024 * 1024) + kept = name in self.kept + snaps.append({ + "name": name, + "size_mb": size_mb, + "kept": kept + }) + snaps.sort(key=lambda x: x["name"], reverse=True) + return snaps + except Exception as e: + logger.error(f"list_snapshots failed: {e}") + return [] + + def keep_snapshot(self, name: str) -> str: + """Mark a snapshot as important (won't be auto-deleted).""" + snaps = [s["name"] for s in self.list_snapshots()] + if name not in snaps: + return f"❌ Snapshot '{name}' nicht gefunden." + self.kept.add(name) + self._save_kept() + return f"⭐ Snapshot '{name}' als wichtig markiert – wird nicht automatisch gelöscht." + + def unkeep_snapshot(self, name: str) -> str: + """Remove important mark from snapshot.""" + self.kept.discard(name) + self._save_kept() + return f"✅ Markierung von '{name}' entfernt." + + def delete_snapshot(self, name: str) -> str: + """Delete a specific snapshot.""" + snap_path = self.snapshot_dir / f"{name}.tar.gz" + pkg_path = self.snapshot_dir / f"{name}.packages.txt" + if not snap_path.exists(): + return f"❌ Snapshot '{name}' nicht gefunden." + try: + snap_path.unlink() + if pkg_path.exists(): + pkg_path.unlink() + self.kept.discard(name) + self._save_kept() + return f"🗑️ Snapshot '{name}' gelöscht." + except Exception as e: + return f"❌ Fehler beim Löschen: {e}" + + def rollback(self, snapshot_name: str) -> str: + """Roll back to a specific snapshot.""" + snap_path = self.snapshot_dir / f"{snapshot_name}.tar.gz" + if not snap_path.exists(): + return f"❌ Snapshot '{snapshot_name}' nicht gefunden." + try: + tmp_dir = tempfile.mkdtemp() + + # Extract all content + result = subprocess.run( + ["sudo", "tar", "xzf", str(snap_path), "-C", tmp_dir], + capture_output=True + ) + if result.returncode not in (0, 1): + raise Exception(f"tar extract failed: {result.stderr.decode()}") + + restored = [] + + etc_backup = os.path.join(tmp_dir, "etc") + if os.path.exists(etc_backup): + subprocess.run(["sudo", "rsync", "-av", "--delete", + etc_backup + "/", "/etc/"], capture_output=True) + restored.append("/etc") + + cfs_backup = os.path.join(tmp_dir, "opt/clownfischserver") + if os.path.exists(cfs_backup): + subprocess.run(["sudo", "rsync", "-av", "--delete", + cfs_backup + "/", "/opt/clownfischserver/"], capture_output=True) + restored.append("/opt/clownfischserver") + + home_backup = os.path.join(tmp_dir, "home") + if os.path.exists(home_backup): + subprocess.run(["sudo", "rsync", "-av", "--delete", + home_backup + "/", "/home/"], capture_output=True) + restored.append("/home") + + workspace = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace") + ws_backup = os.path.join(tmp_dir, workspace.lstrip("/")) + if os.path.exists(ws_backup): + subprocess.run(["sudo", "rsync", "-av", "--delete", + ws_backup + "/", workspace + "/"], capture_output=True) + restored.append(workspace) + + shutil.rmtree(tmp_dir, ignore_errors=True) + + restored_str = ", ".join(restored) if restored else "nichts" + + # Check if package list exists for this snapshot + pkg_hint = "" + pkg_path = self.snapshot_dir / f"{snapshot_name}.packages.txt" + if pkg_path.exists(): + pkg_hint = "\n\n💡 Paketliste verfügbar – prüfe mit:\n/run diff <(dpkg --get-selections) " + str(pkg_path) + + return ( + f"✅ Rollback zu '{snapshot_name}' abgeschlossen.\n" + f"Wiederhergestellt: {restored_str}\n" + f"Dienste neu starten: systemctl restart clownfisch ollama" + f"{pkg_hint}" + ) + except Exception as e: + return f"❌ Rollback Fehler: {e}" + + def _cleanup_old_snapshots(self): + """Remove oldest snapshots if over MAX_SNAPSHOTS, skip kept ones.""" + snaps = self.list_snapshots() + deletable = [s for s in snaps if not s["kept"]] + if len(deletable) > MAX_SNAPSHOTS: + to_delete = deletable[MAX_SNAPSHOTS:] + for snap in to_delete: + snap_path = self.snapshot_dir / f"{snap['name']}.tar.gz" + pkg_path = self.snapshot_dir / f"{snap['name']}.packages.txt" + try: + snap_path.unlink() + if pkg_path.exists(): + pkg_path.unlink() + logger.info(f"Auto-cleanup: {snap['name']}") + except Exception as e: + logger.warning(f"Could not delete {snap['name']}: {e}") diff --git a/config/.env.example b/config/.env.example new file mode 100644 index 0000000..0818692 --- /dev/null +++ b/config/.env.example @@ -0,0 +1,73 @@ +# 🐠 Clownfischserver – Konfiguration +# Diese Datei wird automatisch durch install.sh erstellt. +# Niemals diese Datei committen oder teilen! + +# Telegram +TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrSTUvwxYZ +# Legacy single-user mode: dieser Nutzer bekommt automatisch die operator-Rolle, +# wenn keine CLOWNFISCH_* Rollen-Variablen gesetzt sind (v0.4-Kompatibilität). +TELEGRAM_CHAT_ID=987654321 + +# === Team-Rollen (v0.5.0) =========================================== +# Kommagetrennte Telegram User-IDs (nicht Chat-IDs!). +# Operator = darf /shell /code /run /ja /ssh /snapshot /rollback ausführen +# Approver = darf /ja (Bestätigungen) ausführen +# Viewer = darf /start /help /status /snapshots /chat lesen +# Inheritance: operator > approver > viewer (höhere Rolle erbt niedrigere Rechte). +# CLOWNFISCH_OPERATORS=12345,67890 +# CLOWNFISCH_APPROVERS=11111 +# CLOWNFISCH_VIEWERS=22222,33333 + +# === Audit-Trail (DSGVO Art. 5, 32) ================================= +# Pfad zur JSON-Lines-Audit-Datei. Default: $INSTALL_DIR/audit.jsonl +# AUDIT_LOG_PATH=/opt/clownfischserver/audit.jsonl +# Aufbewahrungsdauer in Tagen (Art. 5(1)(e) – Storage Limitation). +# Beim Bot-Start werden ältere Einträge automatisch entfernt. 0 = nie löschen. +AUDIT_RETENTION_DAYS=90 +# Maximale Länge eines Audit-Detailfeldes (Datenminimierung). +AUDIT_MAX_DETAIL_LEN=2000 + +# Ollama +OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_MODEL=qwen2.5-coder:7b +OLLAMA_MODEL_FAST=gemma3:4b +OLLAMA_NUM_CTX=16384 + +# OpenRouter (optional – kostenloser Fallback bei Ollama-Timeout) +# API Key von openrouter.ai (kostenlos, keine Kreditkarte) +# OPENROUTER_API_KEY=sk-or-v1-... + +# === LLM Routing (v0.5.0, Phase 3) ================================== +# Konfiguriert welches Modell als Primary und welches als Fallback genutzt wird. +# Wenn nicht gesetzt: defaultet auf Ollama primary, OpenRouter fallback (sofern +# OPENROUTER_API_KEY gesetzt ist). Damit bleibt das v0.4-Verhalten erhalten. +# +# Primary (für sensible Daten – läuft lokal, keine Cloud) +# LLM_PRIMARY_PROVIDER=ollama +# LLM_PRIMARY_MODEL=qwen2.5-coder:7b +# LLM_PRIMARY_MODEL_FAST=gemma3:4b +# Gemma 4 27B benötigt mindestens 64K Token Kontext – sonst halluziniert das Modell. +# Override pro Provider (überschreibt OLLAMA_NUM_CTX). +# LLM_PRIMARY_NUM_CTX=65536 +# +# Fallback (nur falls Primary nicht erreichbar / Timeout) +# LLM_FALLBACK_PROVIDER=openrouter +# LLM_FALLBACK_MODEL=deepseek/deepseek-r1:free +# Wert "none" oder leer = Fallback explizit deaktivieren +# LLM_FALLBACK_PROVIDER=none +# +# Optional: Kostentracking für Admins (informativ, keine Limit-Erzwingung). +# TRACK_LLM_COSTS=true + +# Snapshots +SNAPSHOT_DIR=/opt/clownfisch-snapshots +SNAPSHOT_METHOD=tar + +# HomeIP Auto-Update (optional – myfritz.net Dynamic DNS) +# Domain für automatische Firewall-Regel-Updates bei IP-Wechsel +# CLOWNFISCHSERVER_HOME_IP_DOMAIN=vorname.myfritz.net + +# Paths +INSTALL_DIR=/opt/clownfischserver +MGMT_USER=clownfish +AIDER_WORKDIR=/opt/clownfisch-workspace diff --git a/config/knockd.conf b/config/knockd.conf new file mode 100644 index 0000000..c121428 --- /dev/null +++ b/config/knockd.conf @@ -0,0 +1,18 @@ +[options] + UseSyslog + # Uncomment to specify interface: + # Interface = eth0 + +# Knock sequence: 7000 → 8000 → 9000 to OPEN SSH +[openSSH] + sequence = 7000,8000,9000 + seq_timeout = 10 + command = /sbin/iptables -I INPUT -s %IP% -p tcp --dport 22 -j ACCEPT + tcpflags = syn + +# Knock sequence: 9000 → 8000 → 7000 to CLOSE SSH again +[closeSSH] + sequence = 9000,8000,7000 + seq_timeout = 10 + command = /sbin/iptables -D INPUT -s %IP% -p tcp --dport 22 -j ACCEPT + tcpflags = syn diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..b002270 --- /dev/null +++ b/install.sh @@ -0,0 +1,940 @@ +#!/bin/bash +# ============================================================================= +# 🐠 CLOWNFISCHSERVER +# Version: see banner +# Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) +# License: GPL-3.0 – Keep it open. Always. +# Repo: https://github.com/mehlzoerwer-claude/clownfischserver +# ============================================================================= + +# GPL-3.0 License - Keep it open. Always. +# https://github.com/mehlzoerwer-claude/clownfischserver + +# --- Colors --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# --- Defaults --- +INSTALL_DIR="/opt/clownfischserver" +SNAPSHOT_DIR="/opt/clownfisch-snapshots" +WORKSPACE_DIR="/opt/clownfisch-workspace" +LOG_FILE="/var/log/clownfischserver-install.log" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +MGMT_USER="" +PKG_MANAGER="" +DISTRO="" +OLLAMA_MODEL="" +OLLAMA_MODEL_FAST="" +SNAPSHOT_METHOD="tar" +OLLAMA_NUM_CTX=4096 +MYFRITZ_DOMAIN="" +BOT_TOKEN="" +CHAT_ID="" +OPENROUTER_API_KEY="" + +# HELPERS + +log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG_FILE"; } +ok() { echo -e " ${GREEN}✓${NC} $1"; log "OK: $1"; } +info() { echo -e " ${CYAN}→${NC} $1"; log "INFO: $1"; } +warn() { echo -e " ${YELLOW}⚠${NC} $1"; log "WARN: $1"; } +fail() { + echo -e "\n ${RED}${BOLD}✗ FEHLER: $1${NC}" + echo -e " ${YELLOW}Mehr Details: tail -50 $LOG_FILE${NC}\n" + log "ERROR: $1" + exit 1 +} + +step() { + echo -e "\n${BOLD}${BLUE}[$1/$TOTAL_STEPS] $2${NC}" + log "=== STEP $1: $2 ===" +} + +ask() { + echo -e "\n ${YELLOW}?${NC} ${BOLD}$1${NC}" + if [[ -n "$2" ]]; then + echo -e " ${CYAN} $2${NC}" + fi + read -r -p " → " REPLY + echo "$REPLY" +} + +ask_yn() { + echo -e "\n ${YELLOW}?${NC} ${BOLD}$1${NC} ${CYAN}[j/n]${NC}" + read -r -p " → " REPLY + [[ "$REPLY" =~ ^[jJyY] ]] +} + +pause() { + echo -e "\n ${CYAN}$1${NC}" + read -r -p " Enter zum Fortfahren..." _ +} + +TOTAL_STEPS=8 + +# PROGRESS + SPINNER + +spinner() { + local pid=$1 + local msg=$2 + local spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' + local i=0 + tput civis 2>/dev/null + while kill -0 "$pid" 2>/dev/null; do + local c="${spin:$((i % ${#spin})):1}" + printf "\r \033[0;36m%s\033[0m %s..." "$c" "$msg" + sleep 0.1 + ((i++)) + done + printf "\r\033[K" + tput cnorm 2>/dev/null +} + +run_with_spinner() { + local msg=$1 + shift + ("$@" >> "$LOG_FILE" 2>&1) & + local pid=$! + spinner "$pid" "$msg" + wait "$pid" + return $? +} + +print_banner() { + clear + echo -e "${CYAN}${BOLD}" + echo " ╔══════════════════════════════════════════════════════╗" + echo " ║ 🐠 CLOWNFISCHSERVER INSTALLER ║" + echo " ║ v0.5.0 | GPL-3.0 ║" + echo " ╚══════════════════════════════════════════════════════╝" + echo -e "${NC}" + echo -e " Willkommen! Dieses Script richtet deinen" + echo -e " Clownfischserver ein – Schritt für Schritt." + echo -e " Du wirst durch alles geführt.\n" + echo -e " ${YELLOW}Log-Datei: $LOG_FILE${NC}\n" +} + +# STEP 1: CHECKS + +step_checks() { + step 1 "Systemcheck" + + # Root check + [[ $EUID -ne 0 ]] && fail "Bitte als root ausführen:\n sudo bash install.sh" + ok "Root-Rechte vorhanden" + + # Distro detection + if command -v apt &>/dev/null; then + PKG_MANAGER="apt" + DISTRO=$(grep -oP '(?<=^ID=).+' /etc/os-release | tr -d '"') + DISTRO_VERSION=$(grep -oP '(?<=^VERSION_ID=).+' /etc/os-release | tr -d '"') + ok "Distribution: $DISTRO $DISTRO_VERSION (apt)" + elif command -v dnf &>/dev/null; then + PKG_MANAGER="dnf" + DISTRO="fedora" + ok "Distribution: Fedora/RHEL (dnf)" + elif command -v pacman &>/dev/null; then + PKG_MANAGER="pacman" + DISTRO="arch" + ok "Distribution: Arch Linux (pacman)" + else + fail "Unbekannte Distribution – apt/dnf/pacman nicht gefunden." + fi + + # RAM check + RAM_MB=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo) + info "RAM: ${RAM_MB}MB verfügbar" + if [[ "$RAM_MB" -lt 2000 ]]; then + warn "Weniger als 2GB RAM – Ollama könnte Probleme haben." + ask_yn "Trotzdem fortfahren?" || fail "Installation abgebrochen." + else + ok "RAM ausreichend" + fi + + # Disk check + DISK_FREE=$(df -BG / | awk 'NR==2 {print $4}' | tr -d 'G') + info "Freier Speicher: ${DISK_FREE}GB" + [[ "$DISK_FREE" -lt 5 ]] && warn "Weniger als 5GB frei – Ollama-Modell braucht ~2GB." + ok "Systemcheck abgeschlossen" +} + +# STEP 2: PAKETE INSTALLIEREN + +step_packages() { + step 2 "Pakete installieren" + + info "Paketliste wird aktualisiert..." + + case "$PKG_MANAGER" in + apt) + run_with_spinner "Paketliste aktualisieren" apt-get update -qq || fail "apt update fehlgeschlagen" + run_with_spinner "Pakete installieren" apt-get install -y -qq curl wget git ufw python3 python3-pip python3-venv sudo htop net-tools lsof rsync openssl knockd || warn "Einige Pakete konnten nicht installiert werden – siehe Log" + ;; + dnf) + run_with_spinner "Pakete aktualisieren" dnf update -y -q + run_with_spinner "Pakete installieren" dnf install -y -q curl wget git python3 python3-pip sudo htop net-tools rsync openssl + warn "knockd und ufw nicht verfügbar auf Fedora – firewalld wird genutzt" + ;; + pacman) + run_with_spinner "System aktualisieren" pacman -Syu --noconfirm + run_with_spinner "Pakete installieren" pacman -S --noconfirm curl wget git python python-pip sudo htop net-tools rsync openssl + ;; + esac + + ok "Pakete installiert" +} + +# STEP 3: BENUTZER EINRICHTEN + +step_user() { + step 3 "Benutzer einrichten" + + echo "" + echo -e " ${BOLD}Welchen User soll der Clownfischserver nutzen?${NC}" + echo -e " ${CYAN} Dieser User verwaltet den Server und bekommt sudo-Rechte.${NC}" + echo -e " ${CYAN} Du kannst einen neuen anlegen oder einen vorhandenen nutzen.${NC}" + echo "" + + # List existing non-system users + EXISTING_USERS=$(awk -F: '$3>=1000 && $3<65534 {print $1}' /etc/passwd | tr '\n' ' ') + if [[ -n "$EXISTING_USERS" ]]; then + info "Vorhandene Benutzer: $EXISTING_USERS" + fi + + echo -e " ${YELLOW}?${NC} ${BOLD}Username (Enter = 'clownfish'):${NC}" + echo -e " ${CYAN} Neu oder vorhandenen Namen eingeben${NC}" + read -r -p " → " MGMT_USER + [[ -z "$MGMT_USER" ]] && MGMT_USER="clownfish" + + if id "$MGMT_USER" &>/dev/null; then + warn "User '$MGMT_USER' existiert bereits – wird wiederverwendet." + else + useradd -m -s /bin/bash "$MGMT_USER" || fail "User konnte nicht angelegt werden" + ok "User '$MGMT_USER' angelegt" + fi + + # Passwordless sudo + echo "$MGMT_USER ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/clownfisch-$MGMT_USER" + chmod 0440 "/etc/sudoers.d/clownfisch-$MGMT_USER" + ok "Sudo-Rechte gesetzt (passwordless)" + + # SSH dir + mkdir -p "/home/$MGMT_USER/.ssh" + chmod 700 "/home/$MGMT_USER/.ssh" + chown -R "$MGMT_USER:$MGMT_USER" "/home/$MGMT_USER" + ok "SSH-Verzeichnis angelegt" + + # Always set a password as backup + TEMP_PASS=$(openssl rand -base64 12) + echo "$MGMT_USER:$TEMP_PASS" | chpasswd + echo "" + echo -e " ${BOLD}${YELLOW}╔════════════════════════════════════════════════════╗${NC}" + echo -e " ${BOLD}${YELLOW}║ SSH Passwort für '$MGMT_USER': ║${NC}" + echo -e " ${BOLD}${YELLOW}║ ║${NC}" + echo -e " ${BOLD}${YELLOW}║ ${TEMP_PASS} ║${NC}" + echo -e " ${BOLD}${YELLOW}║ ║${NC}" + echo -e " ${BOLD}${YELLOW}║ Notiere es jetzt! SSH bleibt offen bis du ║${NC}" + echo -e " ${BOLD}${YELLOW}║ im Telegram-Bot /ssh close sendest. ║${NC}" + echo -e " ${BOLD}${YELLOW}╚════════════════════════════════════════════════════╝${NC}" + pause "Passwort notiert?" +} + +# STEP 4: SSH + FIREWALL + +step_ssh() { + step 4 "SSH + Firewall" + + cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak + ok "sshd_config gesichert" + + # SSH – nur gezielt aendern, bestehende Config behalten! + info "Passe sshd_config an (bestehende Einstellungen bleiben erhalten)..." + + _sshd_set() { + local key="$1" val="$2" + if grep -qE "^#?\s*${key}" /etc/ssh/sshd_config; then + sed -i "s|^#\?\s*${key}.*|${key} ${val}|" /etc/ssh/sshd_config + else + echo "${key} ${val}" >> /etc/ssh/sshd_config + fi + } + + _sshd_set "PasswordAuthentication" "yes" + _sshd_set "PubkeyAuthentication" "yes" + _sshd_set "X11Forwarding" "no" + _sshd_set "PermitRootLogin" "yes" + ok "sshd_config angepasst (nur relevante Zeilen geaendert)" + + systemctl restart sshd >> "$LOG_FILE" 2>&1 + ok "SSH läuft – Passwort + Key erlaubt" + info "SSH wird erst gesperrt wenn du im Bot /ssh close sendest" + + # Firewall + case "$PKG_MANAGER" in + apt) + ufw default deny incoming >> "$LOG_FILE" 2>&1 + ufw default allow outgoing >> "$LOG_FILE" 2>&1 + ufw allow 22/tcp >> "$LOG_FILE" 2>&1 + echo "y" | ufw enable >> "$LOG_FILE" 2>&1 + ok "ufw Firewall aktiv – Port 22 offen" + + # knockd + IFACE=$(ip route | grep default | awk '{print $5}' | head -1) + NOTIFY_CMD="$INSTALL_DIR/venv/bin/python3 $INSTALL_DIR/bot/knock_notify.py" + + # knockd – nur clownfisch-Sektionen schreiben, bestehende Config behalten + if [ -f /etc/knockd.conf ]; then + # Entferne nur unsere alten Sektionen falls vorhanden + sed -i '/^\[openSSH\]/,/^$/d' /etc/knockd.conf 2>/dev/null || true + sed -i '/^\[closeSSH\]/,/^$/d' /etc/knockd.conf 2>/dev/null || true + # Interface in [options] setzen falls vorhanden + if grep -q "^\[options\]" /etc/knockd.conf; then + grep -q "Interface" /etc/knockd.conf || sed -i "/^\[options\]/a\ Interface = $IFACE" /etc/knockd.conf + else + echo -e "[options] + UseSyslog + Interface = $IFACE +" >> /etc/knockd.conf + fi + else + # Neue Config anlegen + cat > /etc/knockd.conf << KNOCKEOF +[options] + UseSyslog + Interface = $IFACE +KNOCKEOF + fi + + # Unsere SSH-Sektionen hinzufuegen + cat >> /etc/knockd.conf << KNOCKEOF + +[openSSH] + sequence = 7000,8000,9000 + seq_timeout = 10 + command = /sbin/iptables -I INPUT -s %IP% -p tcp --dport 22 -j ACCEPT && $NOTIFY_CMD open %IP% + tcpflags = syn + +[closeSSH] + sequence = 9000,8000,7000 + seq_timeout = 10 + command = /sbin/iptables -D INPUT -s %IP% -p tcp --dport 22 -j ACCEPT && $NOTIFY_CMD close %IP% + tcpflags = syn +KNOCKEOF + [ -f /etc/default/knockd ] && sed -i 's/START_KNOCKD=0/START_KNOCKD=1/' /etc/default/knockd + systemctl enable knockd >> "$LOG_FILE" 2>&1 + systemctl restart knockd >> "$LOG_FILE" 2>&1 && ok "Port-Knocking aktiv (7000→8000→9000)" || warn "knockd konnte nicht starten" + ;; + dnf) + systemctl enable firewalld >> "$LOG_FILE" 2>&1 + systemctl start firewalld >> "$LOG_FILE" 2>&1 + firewall-cmd --permanent --add-service=ssh >> "$LOG_FILE" 2>&1 + firewall-cmd --reload >> "$LOG_FILE" 2>&1 + ok "firewalld aktiv – SSH offen" + ;; + pacman) + warn "Bitte Firewall manuell einrichten (iptables/nftables)" + ;; + esac + + echo "" + echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " ${BOLD} myfritz – Home-IP Auto-Update (optional)${NC}" + echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " Wenn du ein AVM-Gerät (Fritzbox) mit myfritz.net hast," + echo -e " kann der Bot deine Home-IP automatisch ins Firewall-Regelwerk" + echo -e " schreiben. Das bedeutet: SSH bleibt immer erreichbar, auch" + echo -e " wenn sich deine IP ändert (z.B. ISP, mobil, VPN)." + echo "" + echo -e " ${YELLOW}?${NC} ${BOLD}Hast du ein AVM myfritz.net Gerät?${NC} [j/n]" + read -r -p " -> " USE_MYFRITZ + + if [[ "$USE_MYFRITZ" =~ ^[jJ]$ ]]; then + echo "" + echo -e " ${YELLOW}?${NC} ${BOLD}Deine myfritz-Domain:${NC}" + echo -e " ${CYAN} Format: vorname.myfritz.net${NC}" + read -r -p " -> " MYFRITZ_DOMAIN + if [[ -z "$MYFRITZ_DOMAIN" ]]; then + MYFRITZ_DOMAIN="" + warn "myfritz übersprungen" + else + ok "myfritz Domain: $MYFRITZ_DOMAIN" + fi + else + MYFRITZ_DOMAIN="" + info "myfritz übersprungen" + fi +} + +# HELPER: Pull an Ollama model with progress bar + +_pull_model() { + local model_name="$1" + echo "" + echo -e " ${CYAN}Lade Modell: ${BOLD}$model_name${NC}" + echo -e " ${CYAN}Das kann je nach Internetgeschwindigkeit 2-10 Minuten dauern.${NC}" + echo "" + + tput civis 2>/dev/null + ollama pull "$model_name" 2>&1 | while IFS= read -r line; do + if [[ "$line" =~ ([0-9]+)% ]]; then + pct="${BASH_REMATCH[1]}" + filled=$(( pct * 30 / 100 )) + empty=$(( 30 - filled )) + bar="" + for ((i=0; i/dev/null + echo "" + + ollama list 2>/dev/null | grep -q "${model_name%%:*}" || fail "Modell konnte nicht geladen werden: $model_name" + ok "Modell geladen: $model_name" +} + +# STEP 5: OLLAMA + +step_ollama() { + step 5 "Ollama (KI-Engine)" + + echo "" + info "Ollama ist die KI die deinen Bot intelligent macht." + info "Es laeuft komplett lokal auf deinem Server – keine Cloud." + + if command -v ollama &>/dev/null; then + warn "Ollama bereits installiert" + else + run_with_spinner "Ollama wird heruntergeladen und installiert" \ + bash -c "curl -fsSL https://ollama.com/install.sh | sh" || fail "Ollama Installation fehlgeschlagen" + ok "Ollama installiert" + fi + + run_with_spinner "Ollama Service starten" systemctl enable ollama + run_with_spinner "Ollama hochfahren" bash -c "systemctl start ollama && sleep 5" + + # ───────────────────────────────────────────────────────── + # SCHRITT 1: Coder-Modell (Aider + /shell Befehlsgenerierung) + # ───────────────────────────────────────────────────────── + echo "" + echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " ${BOLD} [1/2] Coder-Modell${NC}" + echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " Dieses Modell generiert Code (Aider) und Shell-Befehle (/shell)." + echo -e " Es muss gut programmieren koennen – Deutsch ist hier zweitrangig." + echo "" + + # RAM-basierte Empfehlung fuer Coder + if [[ "$RAM_MB" -ge 16000 ]]; then + CODER_DEFAULT="qwen2.5-coder:7b" + CODER_HINT="16GB+ RAM erkannt – bestes Code-Modell fuer CPU" + else + CODER_DEFAULT="qwen2.5-coder:3b" + CODER_HINT="Kompakt und schnell auf CPU" + fi + + echo -e " ${CYAN} Dein RAM: ${BOLD}${RAM_MB}MB${NC}" + echo -e " ${CYAN} Empfehlung: ${BOLD}${CODER_DEFAULT}${NC} ${CYAN}(${CODER_HINT})${NC}" + echo "" + echo -e " ${CYAN} [1] qwen2.5-coder:3b – schnell, ~2GB RAM (unter 16GB)${NC}" + echo -e " ${CYAN} [2] qwen2.5-coder:7b – stark, ~4.5GB RAM (16GB+ empfohlen)${NC}" + echo -e " ${CYAN} [3] eigenes Modell eingeben${NC}" + echo "" + echo -e " ${YELLOW}?${NC} ${BOLD}Auswahl [1/2/3]:${NC} (Enter = Empfehlung)" + read -r -p " → " CODER_CHOICE + case "$CODER_CHOICE" in + 1) OLLAMA_MODEL="qwen2.5-coder:3b" ;; + 2) OLLAMA_MODEL="qwen2.5-coder:7b" ;; + 3) echo -e " ${YELLOW}?${NC} ${BOLD}Modell-Name:${NC} ${CYAN}z.B. codellama:7b${NC}" + read -r -p " → " OLLAMA_MODEL ;; + *) OLLAMA_MODEL="$CODER_DEFAULT" ;; + esac + + _pull_model "$OLLAMA_MODEL" + ok "Coder-Modell: $OLLAMA_MODEL" + + # ───────────────────────────────────────────────────────── + # SCHRITT 2: Chat-Modell (Deutsch, schnelle Antworten) + # ───────────────────────────────────────────────────────── + OLLAMA_MODEL_FAST="" + + echo "" + echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " ${BOLD} [2/2] Chat-Modell (optional)${NC}" + echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " Ein separates Chat-Modell antwortet schneller auf Nachrichten" + echo -e " und spricht besser Deutsch. Das Coder-Modell bleibt fuer Code." + echo "" + echo -e " ${CYAN} Coder-Modell: ${BOLD}$OLLAMA_MODEL${NC} ${CYAN}(bereits installiert)${NC}" + echo "" + + if ask_yn "Separates Chat-Modell installieren? (empfohlen)"; then + echo "" + + # RAM-basierte Empfehlung fuer Chat + if [[ "$RAM_MB" -ge 24000 ]]; then + CHAT_DEFAULT="mistral:7b" + CHAT_HINT="24GB+ RAM – bestes Deutsch (franzoesische KI, exzellent in EU-Sprachen)" + echo -e " ${CYAN} [1] gemma3:1b – 800MB RAM, schnell, brauchbares Deutsch${NC}" + echo -e " ${CYAN} [2] gemma3:4b – 3GB RAM, sehr gutes Deutsch (Google multilingual)${NC}" + echo -e " ${BOLD} [3] mistral:7b – 4.5GB RAM, exzellentes Deutsch ← empfohlen${NC}" + echo -e " ${CYAN} [4] eigenes Modell eingeben${NC}" + elif [[ "$RAM_MB" -ge 12000 ]]; then + CHAT_DEFAULT="gemma3:4b" + CHAT_HINT="12-20GB RAM – sehr gutes Deutsch bei wenig RAM (Google multilingual)" + echo -e " ${CYAN} [1] gemma3:1b – 800MB RAM, schnell, brauchbares Deutsch${NC}" + echo -e " ${BOLD} [2] gemma3:4b – 3GB RAM, sehr gutes Deutsch ← empfohlen${NC}" + echo -e " ${CYAN} [3] mistral:7b – 4.5GB RAM, exzellentes Deutsch (viel RAM noetig)${NC}" + echo -e " ${CYAN} [4] eigenes Modell eingeben${NC}" + else + CHAT_DEFAULT="gemma3:1b" + CHAT_HINT="<12GB RAM – klein und schnell, spart RAM fuer System" + echo -e " ${BOLD} [1] gemma3:1b – 800MB RAM, schnell, brauchbar ← empfohlen${NC}" + echo -e " ${CYAN} [2] gemma3:4b – 3GB RAM, sehr gutes Deutsch (knapp bei <12GB)${NC}" + echo -e " ${CYAN} [3] mistral:7b – 4.5GB RAM, exzellentes Deutsch (zu gross!)${NC}" + echo -e " ${CYAN} [4] eigenes Modell eingeben${NC}" + fi + + echo "" + echo -e " ${CYAN} Empfehlung: ${BOLD}${CHAT_DEFAULT}${NC} ${CYAN}(${CHAT_HINT})${NC}" + echo "" + echo -e " ${YELLOW}?${NC} ${BOLD}Auswahl [1/2/3/4]:${NC} (Enter = Empfehlung)" + read -r -p " → " CHAT_CHOICE + case "$CHAT_CHOICE" in + 1) OLLAMA_MODEL_FAST="gemma3:1b" ;; + 2) OLLAMA_MODEL_FAST="gemma3:4b" ;; + 3) OLLAMA_MODEL_FAST="mistral:7b" ;; + 4) echo -e " ${YELLOW}?${NC} ${BOLD}Modell-Name:${NC} ${CYAN}z.B. llama3.2:3b${NC}" + read -r -p " → " OLLAMA_MODEL_FAST ;; + *) OLLAMA_MODEL_FAST="$CHAT_DEFAULT" ;; + esac + + _pull_model "$OLLAMA_MODEL_FAST" + ok "Chat-Modell: $OLLAMA_MODEL_FAST" + echo "" + ok "Dual-Model aktiv:" + info " Coder: $OLLAMA_MODEL (Code, /shell, Erklaerungen)" + info " Chat: $OLLAMA_MODEL_FAST (Nachrichten, Deutsch)" + else + info "Single-Model: $OLLAMA_MODEL fuer alles" + fi + + # ───────────────────────────────────────────────────────── + # Context-Window (wie viel Text das Modell auf einmal sieht) + # ───────────────────────────────────────────────────────── + echo "" + echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " ${BOLD} Context-Window (KI-Gedaechtnis)${NC}" + echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " Je groesser, desto mehr Code/Text kann die KI auf einmal sehen." + echo -e " Groesseres Fenster = mehr RAM-Verbrauch + etwas langsamer." + echo "" + + if [[ "$RAM_MB" -ge 24000 ]]; then + CTX_DEFAULT=32768 + echo -e " ${CYAN} [1] 4096 – minimal, schnell${NC}" + echo -e " ${CYAN} [2] 8192 – gut fuer /shell${NC}" + echo -e " ${CYAN} [3] 16384 – ideal fuer /code${NC}" + echo -e " ${BOLD} [4] 32768 – maximale Code-Qualitaet ← empfohlen${NC}" + elif [[ "$RAM_MB" -ge 16000 ]]; then + CTX_DEFAULT=16384 + echo -e " ${CYAN} [1] 4096 – minimal, schnell${NC}" + echo -e " ${CYAN} [2] 8192 – gut fuer /shell${NC}" + echo -e " ${BOLD} [3] 16384 – ideal fuer /code ← empfohlen${NC}" + echo -e " ${CYAN} [4] 32768 – maximale Qualitaet (braucht viel RAM)${NC}" + elif [[ "$RAM_MB" -ge 12000 ]]; then + CTX_DEFAULT=8192 + echo -e " ${CYAN} [1] 4096 – minimal${NC}" + echo -e " ${BOLD} [2] 8192 – guter Kompromiss ← empfohlen${NC}" + echo -e " ${CYAN} [3] 16384 – groessere Scripts${NC}" + echo -e " ${CYAN} [4] 32768 – nur wenn genuegend RAM${NC}" + else + CTX_DEFAULT=4096 + echo -e " ${BOLD} [1] 4096 – passend fuer wenig RAM ← empfohlen${NC}" + echo -e " ${CYAN} [2] 8192 – wenn es reicht${NC}" + echo -e " ${CYAN} [3] 16384 – wahrscheinlich zu viel${NC}" + echo -e " ${CYAN} [4] 32768 – nicht empfohlen${NC}" + fi + + echo "" + echo -e " ${YELLOW}?${NC} ${BOLD}Auswahl [1/2/3/4]:${NC} (Enter = Empfehlung)" + read -r -p " → " CTX_CHOICE + case "$CTX_CHOICE" in + 1) OLLAMA_NUM_CTX=4096 ;; + 2) OLLAMA_NUM_CTX=8192 ;; + 3) OLLAMA_NUM_CTX=16384 ;; + 4) OLLAMA_NUM_CTX=32768 ;; + *) OLLAMA_NUM_CTX=$CTX_DEFAULT ;; + esac + ok "Context-Window: $OLLAMA_NUM_CTX Token" +} + +# STEP 6: PYTHON + PAKETE + +step_python() { + step 6 "Python-Umgebung" + + mkdir -p "$INSTALL_DIR" "$SNAPSHOT_DIR" "$WORKSPACE_DIR" + + run_with_spinner "Python Umgebung erstellen" python3 -m venv "$INSTALL_DIR/venv" || fail "venv konnte nicht erstellt werden" + ok "Python venv erstellt" + + run_with_spinner "pip aktualisieren" "$INSTALL_DIR/venv/bin/pip" install --upgrade pip + + run_with_spinner "Telegram-Bot Paket installieren" "$INSTALL_DIR/venv/bin/pip" install "python-telegram-bot==21.0" requests python-dotenv || fail "Basis-Pakete fehlgeschlagen" + ok "Telegram + Basis-Pakete installiert" + + # Aider bekommt ein eigenes venv – seine Dependencies kollidieren mit dem Bot + info "Installiere aider-chat in separatem venv (2-5 Min)..." + AIDER_VENV="$INSTALL_DIR/aider-venv" + + if python3 -m venv "$AIDER_VENV" >> "$LOG_FILE" 2>&1; then + "$AIDER_VENV/bin/pip" install --upgrade pip setuptools wheel >> "$LOG_FILE" 2>&1 + if "$AIDER_VENV/bin/pip" install aider-chat >> "$LOG_FILE" 2>&1; then + ok "aider-chat installiert ($AIDER_VENV)" + elif "$AIDER_VENV/bin/pip" install --no-build-isolation aider-chat >> "$LOG_FILE" 2>&1; then + ok "aider-chat installiert (no-build-isolation)" + else + warn "aider-chat fehlgeschlagen – spaeter manuell:" + info " $AIDER_VENV/bin/pip install aider-chat" + info "Der Bot funktioniert ohne Aider – /code ist dann nicht verfuegbar." + fi + else + warn "Aider venv konnte nicht erstellt werden" + fi +} + +# STEP 7: TELEGRAM + +step_telegram() { + step 7 "Telegram Bot einrichten" + + echo "" + echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " ${BOLD} Was ist ein Telegram Bot?${NC}" + echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " Ein Bot ist ein automatisches Telegram-Konto das auf" + echo -e " Nachrichten reagiert. Du schreibst dem Bot – der Bot" + echo -e " fuehrt Befehle auf deinem Server aus." + echo -e " ${BOLD}Jeder braucht seinen eigenen Bot – das geht in 2 Minuten.${NC}" + echo "" + echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " ${BOLD} Schritt 1: Bot erstellen${NC}" + echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo -e " 1. Oeffne Telegram auf deinem Handy oder PC" + echo -e " 2. Suche nach: ${BOLD}@BotFather${NC}" + echo -e " 3. Sende: ${BOLD}/newbot${NC}" + echo -e " 4. Vergib einen Anzeigenamen (z.B. 'Mein Server Bot')" + echo -e " 5. Vergib einen Username (muss auf 'bot' enden)" + echo -e " 6. Du bekommst einen ${BOLD}Token${NC} – sieht so aus:" + echo -e " ${CYAN}1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ${NC}" + echo "" + pause "Bot erstellt? Dann weiter..." + + echo -e " ${YELLOW}?${NC} ${BOLD}Dein Bot-Token:${NC}" + echo -e " ${CYAN} Den kompletten Token von @BotFather einfuegen${NC}" + read -r -p " -> " BOT_TOKEN + [[ -z "$BOT_TOKEN" ]] && fail "Kein Token eingegeben." + ok "Token gespeichert" + + echo "" + echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " ${BOLD} Schritt 2: Deine Chat-ID herausfinden${NC}" + echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo -e " 1. Suche in Telegram nach: ${BOLD}@userinfobot${NC}" + echo -e " 2. Sende irgendetwas (z.B. 'hi')" + echo -e " 3. Der Bot antwortet mit deiner ${BOLD}Id${NC} – das ist deine Chat-ID" + echo "" + pause "Chat-ID gefunden? Dann weiter..." + + echo -e " ${YELLOW}?${NC} ${BOLD}Deine Chat-ID:${NC}" + echo -e " ${CYAN} Nur die Zahl, keine anderen Zeichen${NC}" + read -r -p " -> " CHAT_ID + [[ -z "$CHAT_ID" ]] && fail "Keine Chat-ID eingegeben." + ok "Chat-ID gespeichert" + + # ───────────────────────────────────────────────────────── + # OpenRouter Fallback (optional, kostenlos) + # ───────────────────────────────────────────────────────── + OPENROUTER_API_KEY="" + + echo "" + echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " ${BOLD} OpenRouter Fallback (optional)${NC}" + echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " Wenn dein Ollama offline ist, kann der Bot auf Cloud-Modelle ausweichen." + echo -e " OpenRouter Free Tier: 50 Requests/Tag kostenlos (keine Kreditkarte!)" + echo "" + + if ask_yn "OpenRouter als Fallback aktivieren?"; then + echo "" + echo -e " ${CYAN}Registrier dich kostenlos: https://openrouter.ai${NC}" + echo -e " ${CYAN}Dann kopier deinen API Key von der Dashboard${NC}" + echo "" + echo -e " ${YELLOW}?${NC} ${BOLD}OpenRouter API Key:${NC}" + echo -e " ${CYAN} Format: sk-or-v1-...${NC}" + read -r -p " → " OPENROUTER_API_KEY + + if [[ -z "$OPENROUTER_API_KEY" ]]; then + warn "Leerer Key – OpenRouter deaktiviert" + OPENROUTER_API_KEY="" + else + ok "OpenRouter aktiviert" + fi + else + info "OpenRouter deaktiviert – Bot nutzt nur lokal Ollama" + fi + + mkdir -p "$INSTALL_DIR/config" + cat > "$INSTALL_DIR/config/.env" << ENVEOF +TELEGRAM_BOT_TOKEN=$BOT_TOKEN +TELEGRAM_CHAT_ID=$CHAT_ID +OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_MODEL=$OLLAMA_MODEL +OLLAMA_MODEL_FAST=$OLLAMA_MODEL_FAST +OLLAMA_NUM_CTX=$OLLAMA_NUM_CTX +OPENROUTER_API_KEY=$OPENROUTER_API_KEY +SNAPSHOT_DIR=$SNAPSHOT_DIR +SNAPSHOT_METHOD=$SNAPSHOT_METHOD +INSTALL_DIR=$INSTALL_DIR +MGMT_USER=$MGMT_USER +AIDER_WORKDIR=$WORKSPACE_DIR +CLOWNFISCHSERVER_HOME_IP_DOMAIN=$MYFRITZ_DOMAIN +ENVEOF + + chmod 600 "$INSTALL_DIR/config/.env" + ok "Konfiguration gespeichert" +} + +# STEP 8: BOT STARTEN + +step_service() { + step 8 "Bot installieren + starten" + + mkdir -p "$INSTALL_DIR/bot" + cp -r "$SCRIPT_DIR/bot/"* "$INSTALL_DIR/bot/" || fail "Bot-Dateien nicht gefunden – ist der bot/ Ordner dabei?" + ok "Bot-Dateien kopiert" + + mkdir -p "$INSTALL_DIR/scripts" + if [ -d "$SCRIPT_DIR/scripts" ]; then + cp -r "$SCRIPT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true + chmod +x "$INSTALL_DIR/scripts/"*.sh 2>/dev/null || true + ok "Scripts kopiert" + fi + + chown -R "$MGMT_USER:$MGMT_USER" "$INSTALL_DIR" "$SNAPSHOT_DIR" "$WORKSPACE_DIR" + chmod -R u+w "$INSTALL_DIR/bot" + ok "Verzeichnis-Rechte gesetzt" + + cat > /etc/systemd/system/clownfisch.service << SERVICEEOF +[Unit] +Description=🐠 Clownfischserver Telegram Bot +Documentation=https://github.com/mehlzoerwer-claude/clownfischserver +After=network-online.target ollama.service +Wants=network-online.target ollama.service + +[Service] +Type=simple +User=$MGMT_USER +WorkingDirectory=$INSTALL_DIR +EnvironmentFile=$INSTALL_DIR/config/.env +ExecStart=$INSTALL_DIR/venv/bin/python3 $INSTALL_DIR/bot/bot.py +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=clownfisch +# Allow sudo and iptables for SSH management +AmbientCapabilities=CAP_NET_ADMIN CAP_SYS_ADMIN +SecureBits=keep-caps +PrivateTmp=yes + +[Install] +WantedBy=multi-user.target +SERVICEEOF + + run_with_spinner "Systemd neu laden" systemctl daemon-reload + run_with_spinner "Bot-Service aktivieren" systemctl enable clownfisch + run_with_spinner "Bot starten" systemctl start clownfisch + sleep 3 + + if systemctl is-active --quiet clownfisch; then + ok "Bot laeuft!" + else + warn "Bot gestartet – check Logs: journalctl -u clownfisch -n 20 --no-pager" + fi + + # Boot-Notification Service + cat > /etc/systemd/system/clownfisch-boot.service << BOOTEOF +[Unit] +Description=🐠 Clownfischserver Boot Notification +Documentation=https://github.com/mehlzoerwer-claude/clownfischserver +After=network-online.target clownfisch.service ollama.service +Wants=network-online.target + +[Service] +Type=oneshot +ExecStartPre=/bin/sleep 120 +ExecStart=$INSTALL_DIR/venv/bin/python3 $INSTALL_DIR/bot/boot_notify.py +EnvironmentFile=$INSTALL_DIR/config/.env +WorkingDirectory=$INSTALL_DIR +StandardOutput=journal +StandardError=journal +SyslogIdentifier=clownfisch-boot + +[Install] +WantedBy=multi-user.target +BOOTEOF + + run_with_spinner "Boot-Notification aktivieren" bash -c "systemctl daemon-reload && systemctl enable clownfisch-boot" + ok "Boot-Notification: Telegram-Meldung 2 Min nach Serverstart" + + # HomeIP Updater – falls myfritz konfiguriert + if [[ -n "$MYFRITZ_DOMAIN" ]]; then + echo "" + info "Installiere HomeIP Auto-Updater (5-Min Cron)..." + + # Copy the homeip-updater script if it exists + if [ -f "$SCRIPT_DIR/scripts/clownfischserver-homeip-updater.sh" ]; then + mkdir -p /opt/clownfischserver-scripts + cp "$SCRIPT_DIR/scripts/clownfischserver-homeip-updater.sh" /opt/clownfischserver/scripts/ + chmod +x /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh + + # Add cron job (every 5 minutes) + CRON_JOB="*/5 * * * * /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh >> /var/log/clownfischserver-homeip-updater.log 2>&1" + (crontab -l 2>/dev/null | grep -v "clownfischserver-homeip-updater" ; echo "$CRON_JOB") | crontab - 2>/dev/null || true + + ok "HomeIP Auto-Updater installiert (myfritz: $MYFRITZ_DOMAIN)" + else + warn "homeip-updater Script nicht gefunden – übersprungen" + fi + fi +} + +# ZUSAMMENFASSUNG + +print_summary() { + SERVER_IP=$(curl -s ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}') + echo "" + echo -e "${GREEN}${BOLD}" + echo " +======================================================+" + echo " | CLOWNFISCHSERVER BEREIT! v0.5.0 |" + echo " +======================================================+" + echo -e "${NC}" + echo -e " ${BOLD}Server:${NC} $SERVER_IP" + echo -e " ${BOLD}SSH User:${NC} $MGMT_USER (Passwort aktiv!)" + echo -e " ${BOLD}KI-Modell:${NC} $OLLAMA_MODEL" + echo -e " ${BOLD}Snapshots:${NC} $SNAPSHOT_DIR ($SNAPSHOT_METHOD)" + echo -e " ${BOLD}Logs:${NC} journalctl -u clownfisch -f" + echo -e " ${BOLD}Install-Log:${NC} $LOG_FILE" + echo "" + echo -e " ${CYAN}${BOLD}Erste Schritte:${NC}" + echo -e " ${CYAN} 1. Schreibe deinem Bot: /start${NC}" + echo -e " ${CYAN} 2. Teste mit: /status${NC}" + echo -e " ${CYAN} 3. Wenn alles laeuft: /ssh close${NC}" + echo "" + echo -e " ${CYAN}${BOLD}Notfall-Befehle im Bot:${NC}" + echo -e " ${CYAN} /run -> direkte Shell ohne KI${NC}" + echo -e " ${CYAN} /ssh open -> SSH oeffnen${NC}" + echo -e " ${CYAN} /ssh open 1.2.3.4 -> SSH nur fuer deine IP${NC}" + echo -e " ${CYAN} /ssh close -> SSH sperren${NC}" + echo "" + echo -e " ${YELLOW}Install-Log (letzte 10 Zeilen):${NC}" + tail -10 "$LOG_FILE" + echo "" +} + +# MAIN + +update_bot() { + echo -e " +${BOLD}${CYAN}🐠 Clownfischserver Update-Modus${NC} +" + log "=== Update gestartet ===" + + # Read MGMT_USER from .env if not set + if [[ -z "$MGMT_USER" ]] && [[ -f "$INSTALL_DIR/config/.env" ]]; then + MGMT_USER=$(grep "^MGMT_USER=" "$INSTALL_DIR/config/.env" | cut -d= -f2) + fi + [[ -z "$MGMT_USER" ]] && MGMT_USER="clownfish" + + # Copy new bot files + cp -r "$SCRIPT_DIR/bot/"* "$INSTALL_DIR/bot/" + chown -R "$MGMT_USER:$MGMT_USER" "$INSTALL_DIR/bot" + ok "Bot-Dateien aktualisiert" + + # Update systemd services – patch hardcoded user with actual MGMT_USER + for svc in clownfisch.service clownfisch-boot.service; do + if [ -f "$SCRIPT_DIR/systemd/$svc" ]; then + sed "s/^User=.*/User=$MGMT_USER/;s/^Group=.*/Group=$MGMT_USER/" \ + "$SCRIPT_DIR/systemd/$svc" > "/etc/systemd/system/$svc" + [[ "$svc" == "clownfisch-boot.service" ]] && systemctl enable clownfisch-boot >> "$LOG_FILE" 2>&1 + fi + done + systemctl daemon-reload >> "$LOG_FILE" 2>&1 + ok "Systemd Services aktualisiert (User=$MGMT_USER)" + + # Restart + systemctl restart clownfisch >> "$LOG_FILE" 2>&1 + sleep 3 + if systemctl is-active --quiet clownfisch; then + ok "Bot neu gestartet – Update abgeschlossen!" + else + warn "Bot-Status unklar: journalctl -u clownfisch -n 20 --no-pager" + fi + + echo "" + echo -e " ${CYAN}Logs: journalctl -u clownfisch -f${NC}" + echo "" + log "=== Update abgeschlossen ===" +} + +main() { + print_banner + touch "$LOG_FILE" + + # Update mode: service already running + if systemctl is-active --quiet clownfisch 2>/dev/null; then + echo -e " ${YELLOW}⚠${NC} Clownfischserver läuft bereits!" + echo "" + echo -e " ${CYAN}[1]${NC} Update (Bot-Dateien + Restart)" + echo -e " ${CYAN}[2]${NC} Neuinstallation (alles neu)" + echo -e " ${CYAN}[3]${NC} Abbrechen" + echo "" + read -r -p " → " CHOICE + case "$CHOICE" in + 1) update_bot; exit 0 ;; + 2) echo "" ;; + *) echo "Abgebrochen."; exit 0 ;; + esac + fi + + log "=== Clownfischserver v0.5.0 Installation gestartet ===" + step_checks + step_packages + step_user + step_ssh + step_ollama + step_python + step_telegram + step_service + print_summary + log "=== Installation abgeschlossen ===" +} + +main "$@" diff --git a/scripts/clownfischserver-homeip-updater.sh b/scripts/clownfischserver-homeip-updater.sh new file mode 100644 index 0000000..f41b924 --- /dev/null +++ b/scripts/clownfischserver-homeip-updater.sh @@ -0,0 +1,174 @@ +#!/bin/bash +# ============================================================================= +# 🐠 CLOWNFISCHSERVER – HomeIP Auto-Update +# Purpose: Auto-update firewall rule for dynamic home IP (myfritz.net) +# Runs: Every 5 minutes (via cron) +# Author: Mehlzoerwer-Claude +# License: GPL-3.0 +# ============================================================================= + +set -e + +# --- Configuration --- +INSTALL_DIR="/opt/clownfischserver" +ENV_FILE="$INSTALL_DIR/config/.env" +STATE_FILE="/var/lib/clownfischserver-homeip-state" +LOG_FILE="/var/log/clownfischserver-homeip-updater.log" +SSH_PORT=22 + +# --- Helpers --- +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE" +} + +# --- Load .env --- +load_env() { + if [ ! -f "$ENV_FILE" ]; then + log "ERROR: .env not found at $ENV_FILE" + exit 1 + fi + # Load and trim CRLF/LF + MYFRITZ_DOMAIN=$(grep "^CLOWNFISCHSERVER_HOME_IP_DOMAIN=" "$ENV_FILE" | cut -d= -f2 | tr -d '\r\n') + if [ -z "$MYFRITZ_DOMAIN" ]; then + log "ERROR: CLOWNFISCHSERVER_HOME_IP_DOMAIN not set in .env" + exit 1 + fi +} + +# --- Detect Firewall --- +detect_firewall() { + if systemctl is-active --quiet ufw; then + echo "ufw" + elif systemctl is-active --quiet firewalld; then + echo "firewalld" + elif command -v iptables &>/dev/null; then + echo "iptables" + else + log "ERROR: No firewall detected (ufw/firewalld/iptables)" + exit 1 + fi +} + +# --- Resolve DNS with fallbacks --- +resolve_ip() { + local domain="$1" + + # Try dig first + if command -v dig &>/dev/null; then + IP=$(dig +short "$domain" A | grep -oE '^[0-9.]+$' | head -1) + [ -n "$IP" ] && echo "$IP" && return 0 + fi + + # Try nslookup + if command -v nslookup &>/dev/null; then + IP=$(nslookup "$domain" 2>/dev/null | grep "Address:" | tail -1 | awk '{print $2}' | grep -oE '^[0-9.]+$') + [ -n "$IP" ] && echo "$IP" && return 0 + fi + + # Try getent + if command -v getent &>/dev/null; then + IP=$(getent hosts "$domain" | awk '{print $1}' | grep -oE '^[0-9.]+$' | head -1) + [ -n "$IP" ] && echo "$IP" && return 0 + fi + + log "ERROR: Could not resolve $domain" + return 1 +} + +# --- Load previous state --- +load_state() { + if [ -f "$STATE_FILE" ]; then + PREVIOUS_IP=$(cat "$STATE_FILE" | grep "^IP=" | cut -d= -f2) + echo "$PREVIOUS_IP" + else + echo "" + fi +} + +# --- Save state --- +save_state() { + local ip="$1" + echo "IP=$ip" > "$STATE_FILE" + chmod 600 "$STATE_FILE" +} + +# --- UFW Functions --- +ufw_add_rule() { + local ip="$1" + ufw allow from "$ip" to any port "$SSH_PORT" comment "clownfisch-homeip" >/dev/null 2>&1 + log "UFW: Added rule for $ip:$SSH_PORT" +} + +ufw_remove_rule() { + local ip="$1" + echo "y" | ufw delete allow from "$ip" to any port "$SSH_PORT" >/dev/null 2>&1 + log "UFW: Deleted rule for $ip" +} + +# --- Firewalld Functions --- +firewalld_add_rule() { + local ip="$1" + firewall-cmd --permanent --add-rich-rule="rule family='ipv4' source address='$ip' port protocol='tcp' port='$SSH_PORT' accept" >/dev/null 2>&1 + firewall-cmd --reload >/dev/null 2>&1 + log "firewalld: Added rule for $ip:$SSH_PORT" +} + +firewalld_remove_rule() { + local ip="$1" + firewall-cmd --permanent --remove-rich-rule="rule family='ipv4' source address='$ip' port protocol='tcp' port='$SSH_PORT' accept" >/dev/null 2>&1 + firewall-cmd --reload >/dev/null 2>&1 + log "firewalld: Deleted rule for $ip" +} + +# --- iptables Functions --- +iptables_add_rule() { + local ip="$1" + iptables -I INPUT -s "$ip" -p tcp --dport "$SSH_PORT" -j ACCEPT + log "iptables: Added rule for $ip:$SSH_PORT" +} + +iptables_remove_rule() { + local ip="$1" + iptables -D INPUT -s "$ip" -p tcp --dport "$SSH_PORT" -j ACCEPT 2>/dev/null || true + log "iptables: Deleted rule for $ip" +} + +# --- Main --- +main() { + log "=== HomeIP Auto-Update started ===" + + load_env + FIREWALL=$(detect_firewall) + NEW_IP=$(resolve_ip "$MYFRITZ_DOMAIN") || exit 1 + PREVIOUS_IP=$(load_state) + + if [ "$NEW_IP" = "$PREVIOUS_IP" ]; then + log "No change: $NEW_IP (same as previous)" + exit 0 + fi + + log "IP change detected: $PREVIOUS_IP → $NEW_IP (firewall: $FIREWALL)" + + # Remove old rule + if [ -n "$PREVIOUS_IP" ]; then + case "$FIREWALL" in + ufw) ufw_remove_rule "$PREVIOUS_IP" ;; + firewalld) firewalld_remove_rule "$PREVIOUS_IP" ;; + iptables) iptables_remove_rule "$PREVIOUS_IP" ;; + esac + fi + + # Add new rule + case "$FIREWALL" in + ufw) ufw_add_rule "$NEW_IP" ;; + firewalld) firewalld_add_rule "$NEW_IP" ;; + iptables) iptables_add_rule "$NEW_IP" ;; + esac + + # Save state + save_state "$NEW_IP" + + log "=== HomeIP Auto-Update completed successfully ===" +} + +main "$@" diff --git a/systemd/clownfisch-boot.service b/systemd/clownfisch-boot.service new file mode 100644 index 0000000..0528b6e --- /dev/null +++ b/systemd/clownfisch-boot.service @@ -0,0 +1,18 @@ +[Unit] +Description=🐠 Clownfischserver Boot Notification +Documentation=https://github.com/mehlzoerwer-claude/clownfischserver +After=network-online.target clownfisch.service ollama.service +Wants=network-online.target + +[Service] +Type=oneshot +ExecStartPre=/bin/sleep 120 +ExecStart=/opt/clownfischserver/venv/bin/python3 /opt/clownfischserver/bot/boot_notify.py +EnvironmentFile=/opt/clownfischserver/config/.env +WorkingDirectory=/opt/clownfischserver +StandardOutput=journal +StandardError=journal +SyslogIdentifier=clownfisch-boot + +[Install] +WantedBy=multi-user.target diff --git a/systemd/clownfisch.service b/systemd/clownfisch.service new file mode 100644 index 0000000..064b9d2 --- /dev/null +++ b/systemd/clownfisch.service @@ -0,0 +1,26 @@ +[Unit] +Description=🐠 Clownfischserver Telegram Bot +Documentation=https://github.com/mehlzoerwer-claude/clownfischserver +After=network-online.target ollama.service +Wants=network-online.target ollama.service + +[Service] +Type=simple +User=clownfish +Group=clownfish +WorkingDirectory=/opt/clownfischserver +EnvironmentFile=/opt/clownfischserver/config/.env +ExecStart=/opt/clownfischserver/venv/bin/python3 /opt/clownfischserver/bot/bot.py +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=clownfisch + +# Security – allow sudo and iptables for SSH management +AmbientCapabilities=CAP_NET_ADMIN CAP_SYS_ADMIN +SecureBits=keep-caps +PrivateTmp=yes + +[Install] +WantedBy=multi-user.target