From 5a2276021f74b6e843adb2e71d6a441eab421e2e Mon Sep 17 00:00:00 2001 From: Mehlzoerwer-Claude Date: Sun, 19 Apr 2026 22:25:59 +0200 Subject: [PATCH] =?UTF-8?q?v0.4.13=20=E2=80=93=20Security-Analyse,=20DSGVO?= =?UTF-8?q?-Roadmap,=20Projekt-Kontext=20(CLOWNFISCHSERVER.md)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 33 ++ CLOWNFISCHSERVER.md | 461 +++++++++++++++++ CONTRIBUTING.md | 44 ++ GITHUB_PROFILE.md | 27 + LICENSE | 17 + README.md | 429 ++++++++++++++++ bot/aider_wrapper.py | 128 +++++ bot/boot_notify.py | 114 +++++ bot/bot.py | 755 +++++++++++++++++++++++++++ bot/knock_notify.py | 48 ++ bot/ollama_client.py | 234 +++++++++ bot/openrouter_client.py | 178 +++++++ bot/requirements.txt | 4 + bot/shell.py | 85 ++++ bot/snapshot.py | 290 +++++++++++ config/.env.example | 26 + config/knockd.conf | 18 + install.sh | 878 ++++++++++++++++++++++++++++++++ systemd/clownfisch-boot.service | 18 + systemd/clownfisch.service | 26 + 20 files changed, 3813 insertions(+) create mode 100644 .gitignore create mode 100644 CLOWNFISCHSERVER.md create mode 100644 CONTRIBUTING.md create mode 100644 GITHUB_PROFILE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bot/aider_wrapper.py create mode 100644 bot/boot_notify.py create mode 100644 bot/bot.py create mode 100644 bot/knock_notify.py create mode 100644 bot/ollama_client.py create mode 100644 bot/openrouter_client.py create mode 100644 bot/requirements.txt create mode 100644 bot/shell.py create mode 100644 bot/snapshot.py create mode 100644 config/.env.example create mode 100644 config/knockd.conf create mode 100644 install.sh create mode 100644 systemd/clownfisch-boot.service create mode 100644 systemd/clownfisch.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcfcc49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Secrets – NIEMALS committen! +config/.env +*.env + +# Python +__pycache__/ +*.py[cod] +*.pyo +venv/ +.venv/ +*.egg-info/ + +# Logs +*.log +/var/log/ + +# Snapshots +/opt/clownfisch-snapshots/ + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ +*.swp +*.swo + +# Temp +tmp/ +temp/ +*.tmp diff --git a/CLOWNFISCHSERVER.md b/CLOWNFISCHSERVER.md new file mode 100644 index 0000000..b308dcc --- /dev/null +++ b/CLOWNFISCHSERVER.md @@ -0,0 +1,461 @@ +# 🐠 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.4.13 (18.04.2026) + +--- + +## 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.4.13-clownfischserver-opus.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.4.13-clownfischserver-opus.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.4.13-clownfischserver-opus.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..3f8b02c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# 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) + +## 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/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..dcfe8bb --- /dev/null +++ b/README.md @@ -0,0 +1,429 @@ +# 🐠 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.4.13-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 +- πŸ‘€ **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 + +--- + +## Schnellstart + +```bash +# Frisches Ubuntu/Debian – als root: +wget https://github.com/mehlzoerwer-claude/clownfischserver/releases/latest/download/clownfischserver.zip +unzip clownfischserver.zip && cd clownfischserver +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.4.13) + +- [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 – Geplant + +**Sicherheit & DSGVO:** +- [ ] 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..608e191 --- /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:2b") +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/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..87b3ddf --- /dev/null +++ b/bot/bot.py @@ -0,0 +1,755 @@ +#!/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 + +# --- 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")) + +if not BOT_TOKEN or not CHAT_ID: + logger.error("TELEGRAM_BOT_TOKEN oder TELEGRAM_CHAT_ID fehlt in .env!") + sys.exit(1) + +# --- Init modules --- +openrouter = OpenRouterClient() +ollama = OllamaClient(openrouter_client=openrouter) +snapshots = SnapshotManager() +aider = AiderWrapper() + +# ============================================================================= +# AUTH GUARD + HELPERS +# ============================================================================= + +def is_authorized(update: Update) -> bool: + return str(update.effective_chat.id) == CHAT_ID + +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.""" + 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 is_authorized(update): + return + + fallback_info = "" + if openrouter.is_available(): + fallback_info = "\nβœ… OpenRouter Fallback aktiv" + + await update.message.reply_text( + f"🐠 *Clownfischserver v0.4.13 online.*{fallback_info}\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" + "β€’ `/help` – diese Hilfe", + parse_mode="Markdown" + ) + +async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not is_authorized(update): + return + 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 is_authorized(update): + return + 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 is_authorized(update): + 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] + 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""" + if not is_authorized(update): + return + + args = context.args + subcmd = args[0].lower() if args else "create" + + 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 is_authorized(update): + return + await cmd_start(update, context) + +async def cmd_run(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Direct shell execution – bypasses Ollama completely""" + if not is_authorized(update): + 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) + await update.message.reply_text(f"⚑ Direkt: `{cmd}`", parse_mode="Markdown") + stdout, stderr, returncode = await run_shell(cmd) + 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 is_authorized(update): + return + action = context.args[0].lower() if context.args else "" + client_ip = context.args[1] if len(context.args) > 1 else None + + 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 is_authorized(update): + 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 + + if (ollama_ready is None or not ollama_ready.is_set()) and not openrouter.is_available(): + 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 + + if result.get("dangerous", False): + 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 + + 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): + if not is_authorized(update): + 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) + + 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) + 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) + 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 is_authorized(update): + 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() + + if workspace not in task and "/opt/" not in task and "/" not in task.split()[0] if task.split() else True: + 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") + + try: + result = await aider.run(task) + 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) + 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 is_authorized(update): + 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 + await status_msg.edit_text("πŸ“‚ Entpacke ZIP...") + extract_dir = os.path.join(tmp_dir, "extracted") + with zipfile.ZipFile(zip_path, "r") as z: + z.extractall(extract_dir) + + # 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.""" + if not is_authorized(update): + logger.warning(f"Unauthorized access attempt from chat_id: {update.effective_chat.id}") + 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]}") + + if (ollama_ready is None or not ollama_ready.is_set()) and not openrouter.is_available(): + 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) + + 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) + await thinking_msg.edit_text(f"❌ Chat-Fehler: `{e}`", 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.4.13 Bot startet...") + + import requests as req + import time + import threading + + model = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b") + model_fast = os.getenv("OLLAMA_MODEL_FAST", "") + base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") + + 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(): + logger.info("Ollama Warmup gestartet (Hintergrund)...") + models_to_warm = [(model, "Hauptmodell")] + if model_fast and model_fast != model: + models_to_warm.insert(0, (model_fast, "Fast-Modell")) + logger.info(f"Dual-Model: fast={model_fast}, main={model}") + for m, label in models_to_warm: + warmup_model(m, label) + ollama_ready.set() + + threading.Thread(target=warmup_ollama, daemon=True).start() + if model_fast and model_fast != model: + logger.info(f"🐠 Bot startet – 2 Modelle laden ({model_fast} + {model})") + else: + logger.info("🐠 Bot startet – Ollama lΓ€dt im Hintergrund.") + + if openrouter.is_available(): + logger.info("βœ… OpenRouter Fallback aktiv") + + 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)) + + # 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)) + + logger.info(f"Bot lΓ€uft. Authorized Chat-ID: {CHAT_ID}") + app.run_polling(allowed_updates=Update.ALL_TYPES) + +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/ollama_client.py b/bot/ollama_client.py new file mode 100644 index 0000000..509e162 --- /dev/null +++ b/bot/ollama_client.py @@ -0,0 +1,234 @@ +#!/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): + self.base_url = OLLAMA_BASE_URL + self.model = OLLAMA_MODEL + self.model_fast = OLLAMA_MODEL_FAST or OLLAMA_MODEL + 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": OLLAMA_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(f"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..3a828b4 --- /dev/null +++ b/bot/openrouter_client.py @@ -0,0 +1,178 @@ +#!/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): + self.api_key = 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() + + 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}], + 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..f9ebb5b --- /dev/null +++ b/config/.env.example @@ -0,0 +1,26 @@ +# 🐠 Clownfischserver – Konfiguration +# Diese Datei wird automatisch durch install.sh erstellt. +# Niemals diese Datei committen oder teilen! + +# Telegram +TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrSTUvwxYZ +TELEGRAM_CHAT_ID=987654321 + +# 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-... + +# Snapshots +SNAPSHOT_DIR=/opt/clownfisch-snapshots +SNAPSHOT_METHOD=tar + +# 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..5358120 --- /dev/null +++ b/install.sh @@ -0,0 +1,878 @@ +#!/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="" +SNAPSHOT_METHOD="tar" +OLLAMA_NUM_CTX=4096 + +# 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.4.13 | 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 +} + +# 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 +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" + + 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" +} + +# 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.4.13 |" + 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.4.13 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/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