Initial commit: Clownfischserver v0.5.0

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 14:32:33 +02:00
co-authored by Claude Haiku 4.5
commit 91eeb4834f
22 changed files with 4109 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(gh auth *)",
"PowerShell(gh auth *)",
"PowerShell(winget install *)",
"Bash(gh repo *)",
"Bash(git init *)",
"Bash(git remote *)",
"Bash(git add *)",
"Bash(git commit -m 'Initial commit: Clownfischserver v0.5.0 *)",
"Bash(git config *)"
]
}
}
+33
View File
@@ -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
+467
View File
@@ -0,0 +1,467 @@
# 🐠 Clownfischserver – Projekt-Kontext
> Diese Datei ist der zentrale Wissensspeicher für das Clownfischserver-Projekt.
> Lade sie in jede KI/Chat-Session um sofort produktiv zu sein.
> Letzte Aktualisierung: v0.5.0 (30.05.2026)
> **⚠️ Bewusst entfernt (nicht wieder einbauen!):** Ein „Vault/MCP-Context"-System
> (`_get_vault_context`, `MCP_VAULT_URL`, `MCP_API_KEY`) wurde in v0.5.0 komplett
> entfernt. Es war toter Code, nutzte `verify=False` und schickte Chat-Nachrichten an
> externe Endpoints – ein klarer DSGVO-Verstoß und Widerspruch zum „local-first"-Prinzip.
> Token-Optimierung über Obsidian gehört NICHT in den Bot.
---
## Was ist der Clownfischserver?
Ein Open-Source, Telegram-gesteuerter Linux-Server-Manager. Der Admin schreibt dem Bot über Telegram – der Bot versteht, generiert Befehle, erstellt Scripts, verwaltet den Server. Komplett lokal mit Ollama, optional Cloud-Fallback über OpenRouter.
**Repo:** https://github.com/mehlzoerwer-claude/clownfischserver
**Lizenz:** GPL-3.0
**Entwickler:** Mehlzoerwer-Claude (Thorsten Matthes)
**KI-Assistent:** Claude (Anthropic) – Pair-Programming Partner
---
## Architektur
```
┌─────────────────────┐
│ Telegram Bot │
│ (bot.py) │
└──────┬──────┬────────┘
│ │
┌─────────────────┤ ├─────────────────┐
▼ ▼ ▼ ▼
┌────────────────┐ ┌────────────┐ ┌──────────────────┐
│ ollama_client │ │ shell.py │ │ aider_wrapper │
│ (Dual-Model) │ │ (run_shell)│ │ (Code-Agent) │
└───────┬────┬────┘ └────────────┘ └──────────────────┘
│ │
┌────────┘ └────────┐
▼ ▼
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ Chat-Model│ │ Coder-Model │ │ OpenRouter │
│ (gemma3) │ │(qwen2.5-coder)│ │ (Fallback) │
└──────────┘ └──────────────┘ └──────────────┘
```
**Prinzip:** Freitext = Chat. `/shell` = KI generiert Befehl → Admin bestätigt mit `/ja`. `/code` = Aider erstellt Dateien. `/run` = direkte Shell.
---
## Dateistruktur (19 Dateien, ~3250 Zeilen)
```
clownfischserver/
├── bot/
│ ├── bot.py # Hauptbot: alle Commands, Auth, Self-Update, Update-Dialog
│ ├── ollama_client.py # Dual-Model, OpenRouter Fallback, NUM_CTX, Shell-Prompt
│ ├── openrouter_client.py # OpenRouter Free Models Client (openrouter/free)
│ ├── shell.py # run_shell (shlex.quote), execute_command
│ ├── aider_wrapper.py # Aider mit --edit-format diff, 600s Timeout
│ ├── snapshot.py # tar.gz Snapshots, Workspace, Paketliste, sudo rsync
│ ├── boot_notify.py # Telegram-Nachricht 120s nach Boot
│ ├── knock_notify.py # Port-Knocking SSH-Benachrichtigungen
│ └── requirements.txt # python-telegram-bot, requests, python-dotenv
├── config/
│ ├── .env.example # Vorlage mit allen Variablen
│ └── knockd.conf # Port-Knocking Sequenz (7000→8000→9000)
├── systemd/
│ ├── clownfisch.service # Bot-Service (User=clownfish, wird gepatcht)
│ └── clownfisch-boot.service # Boot-Notification (oneshot, 120s delay)
├── install.sh # 8-Schritt Installer mit Modell/CTX-Auswahl
├── README.md # Vollständige Dokumentation
├── LICENSE # GPL-3.0
├── .gitignore
├── CONTRIBUTING.md
└── GITHUB_PROFILE.md
```
---
## Wichtige Konventionen
### Naming
- **Service:** `clownfisch.service` (Deutsch, mit "ch")
- **Linux-User:** Beliebig (z.B. `clownfisch`, `clownfish`, `admin`) – wird aus `MGMT_USER` in `.env` gelesen
- **Branding:** Immer "Clownfisch" (Deutsch)
- **ZIP-Benennung:** `v0.5.0-clownfischserver.zip`
### Code-Stil
- Python 3, keine Type Hints in Funktionssignaturen (außer Optional)
- Logging über `logging` Modul
- Async für alle Telegram-Handler
- `safe_reply()` für alle Telegram-Nachrichten (Markdown-Fallback)
- Fehler werden dem User angezeigt, nie verschluckt
### Sicherheits-Regeln
- Configs (`sshd_config`, `knockd.conf`) NIE überschreiben – nur gezielte Zeilen ändern
- `.env` Permissions: 0600
- SSH Host-Keys, `/etc/shadow` NIE in Snapshots
- `.env` seit v0.4.12 aus Snapshots excludiert
- Self-Update: systemd-Services werden mit `MGMT_USER` aus `.env` gepatcht (Regex: `User=\S+`)
---
## .env Variablen
```
TELEGRAM_BOT_TOKEN=... # Bot-Token von @BotFather
TELEGRAM_CHAT_ID=... # Autorisierte Chat-ID (Single-User)
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen2.5-coder:7b # Coder-Modell für /shell und /code
OLLAMA_MODEL_FAST=gemma3:4b # Chat-Modell für Freitext
OLLAMA_NUM_CTX=16384 # Context-Window (4096-32768)
OPENROUTER_API_KEY=sk-or-v1-... # Optional: kostenloser Cloud-Fallback
SNAPSHOT_DIR=/opt/clownfisch-snapshots
SNAPSHOT_METHOD=tar
INSTALL_DIR=/opt/clownfischserver
MGMT_USER=clownfisch # Linux-User (beliebig wählbar)
AIDER_WORKDIR=/opt/clownfisch-workspace
```
---
## Bot-Befehle
| Befehl | Funktion |
|--------|----------|
| Freitext | Chat mit KI (gemma3 lokal, OpenRouter Fallback) |
| `/shell <beschreibung>` | KI generiert Shell-Befehl, non-interaktiv |
| `/ja` | Letzten `/shell` Befehl ausführen (mit Snapshot davor) |
| `/code <aufgabe>` | Aider erstellt Dateien (Smart Prompt: Dateiname wird ergänzt) |
| `/run <befehl>` | 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 <n>` | Snapshot markieren/löschen |
| `/snapshots` | Alle Snapshots anzeigen |
| `/rollback <name>` | Zu Snapshot zurückkehren (sudo rsync --delete) |
| `/start` / `/help` | Bot-Info und Befehlsübersicht |
| ZIP senden | Self-Update mit optionalem OpenRouter-Setup-Dialog |
---
## Dual-Model Strategie
| Rolle | Modell | RAM | Aufgabe |
|-------|--------|-----|---------|
| Coder | qwen2.5-coder:7b | ~4.7GB | `/shell`, `/code`, Befehlsgenerierung |
| Chat | gemma3:4b | ~3.3GB | Freitext, Erklärungen, Übersetzungen |
| Fallback | openrouter/free | 0 (Cloud) | Wenn Ollama nicht verfügbar |
**Shell-Prompt:** Few-Shot mit non-interaktiven Regeln (kein nano/vim/crontab -e, sudo wenn nötig).
**Chat-Prompt:** Deutsch als Standard, antwortet in Sprache des Nutzers.
**Explain-Prompt:** Erklärt Befehlsergebnisse auf Deutsch.
---
## Snapshot-System
**Gesichert:** `/etc`, `/opt/clownfischserver`, `/home`, `/opt/clownfisch-workspace`
**Excludiert:** SSH Host-Keys, shadow, `.env`, `__pycache__`, `node_modules`
**Paketliste:** `dpkg --get-selections` pro Snapshot als `.packages.txt`
**Rollback:** `sudo rsync -av --delete` pro Verzeichnis
**Auto-Cleanup:** Max 20 Snapshots, `keep`-markierte bleiben immer
---
## OpenRouter Integration
- **Client:** `openrouter_client.py` mit `openrouter/free` Auto-Router
- **Fallback-Trigger:** Ollama Timeout ODER "nicht erreichbar"
- **Betrifft:** Chat + `/shell` (nicht `/code` – Aider braucht Ollama direkt)
- **Setup:** Beim Self-Update fragt Bot "OpenRouter aktivieren?" → Key eingeben → `.env`
- **Kosten:** Free Tier: 50 Requests/Tag, 20/Minute, keine Kreditkarte
---
## Bekannte Bugs / Einschränkungen
- `/code` hat keinen OpenRouter-Fallback (Aider spricht nur Ollama)
- Kleine Modelle (< 8B) halluzinieren bei Erklärungen
- Aider `diff`-Format überfordert kleine Modelle manchmal → Datei wird nicht erstellt
- Shell-Prompt vergisst manchmal `sudo` bei Root-Operationen
---
## Server-Umgebung
- **Hoster:** netcup VPS
- **OS:** Ubuntu 24.04 LTS
- **RAM:** 16GB
- **Disk:** 300GB
- **IP:** 92.60.38.175
- **Ollama:** qwen2.5-coder:7b + gemma3:4b (oder gemma4:e2b)
- **Docker:** installiert
- **SSH:** Port-Knocking via knockd (7000→8000→9000)
---
## Roadmap
### v0.5.0 – Sicherheit + Features
**Security (DSGVO-Pflicht):**
- Sensitive Dateien über Telegram blockieren (`.env`, shadow, Keys)
- Output-Sanitizer: API Keys/Tokens in JEDER Bot-Antwort maskieren
- Log-Sanitizer: Keine Secrets im Audit-Log
- Audit-Log: Jeder Befehl mit Timestamp (DSGVO Art. 5/32)
- SHA256-Hash bei Self-Update + Admin-Bestätigung
- `.env` Permissions-Check beim Bot-Start
- `bot.log`, `.aider.chat.history.md`, `bash_history` aus Snapshots excluden
- Rate-Limiting: Max 30 Befehle/Minute
- Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut
- 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot)
- Persönlicher Code-Offset (SECURITY_OFFSET in .env)
**Features:**
- Snapshot-Scheduling + Export via Telegram
- Verschlüsselte Snapshots (SNAPSHOT_PASSWORD)
- `/logs`, `/docker`
- Port-Knocking Sequenz wählbar
- Open Interpreter als Aider-Alternative evaluieren
**Quick-Win Security:**
- Panik-Wort: Admin-definiertes Wort → Bot löscht .env, sperrt alles
- Honeypot-Befehle: Fake-Commands die Eindringlinge verraten
- Selbstzerstörende Nachrichten: Bot löscht eigene Outputs nach X Minuten
- Rollback-Bombe: 3x falscher 2FA → auto-Rollback + SSH sperren + Alert
- Dead Man's Switch: Kein Lebenszeichen in 24h → Bot sperrt sich selbst
### v0.6.0 – Multi-Server + Gedächtnis
- Ein Bot steuert mehrere Server
- Audit-Log / Server-Gedächtnis (server_log.txt)
- Chat kann vergangene Aktionen abfragen
### v0.7.0 – DSGVO-bewusster Backup-Manager
- Verschlüsselte Snapshots (SNAPSHOT_PASSWORD in .env)
### v0.8.0 – Erweiterte Cloud-API
- Premium-Modelle auf OpenRouter
- Anthropic/OpenAI direkt
- Kosten-Tracking
### v0.9.0 – Multi-Interface + Advanced Security
**Alternative Interfaces (E2E-verschlüsselt):**
- Matrix/Element Connector (matrix-nio, self-hosted, E2E)
- Signal Connector (signal-cli, E2E)
- Web-UI (HTTPS, self-hosted, Let's Encrypt)
- SSH-CLI (interaktive Shell-Session, kein Messenger nötig)
- E-Mail mit GPG-Verschlüsselung
**Advanced Security Features (evaluieren bei v0.8.x Abschluss):**
- Fake-Modus: Falscher 2FA-Code → Bot tut so als ob, führt nichts aus, stiller Alarm
- Distress-Code: Umgekehrter Offset = "Ich werde gezwungen" → Fake-Modus
- Geo-Fencing: Bot antwortet nur aus erlaubten Ländern/IP-Bereichen
- Buddy-System: Kritische Befehle brauchen Bestätigung von zweitem Admin
- Canary Token: Unsichtbare Marker-Dateien, Alert bei Zugriff
- Verschlüsselter Rückkanal: Bot-Antworten verschlüsselt über Telegram
- Hardware-Token: USB-Stick als physischer Schlüssel
**Architektur-Ziel:**
```
[Telegram] ─┐
[Matrix] ─┤
[Web-UI] ─┼─→ [bot_core.py] → [Ollama/OpenRouter]
[SSH-CLI] ─┤
[E-Mail] ─┘
```
Ein Bot, viele Eingänge. Admin wählt was er will.
### v1.0.0 – Disaster Recovery
### v2.0.0 – Cross-Distribution Server Migration
---
## Getestete Modelle
| Modell | Größe | Rolle | Ergebnis |
|--------|-------|-------|----------|
| qwen2.5-coder:7b | 4.7GB | Coder | ✅ Beste Wahl für Code |
| qwen2.5-coder:3b | 1.9GB | Coder | ✅ Funktioniert, schwächer |
| gemma3:4b | 3.3GB | Chat | ✅ Gutes Deutsch, schnell |
| gemma4:e2b | 7.2GB | Beide | ⚠️ Zu groß für Dual-Setup |
| rnj-1:8b | 5.1GB | Coder | ❓ Test ausstehend |
| qwen3.5:4b | – | Coder | ❌ Thinking, zu langsam auf CPU |
| openrouter/free | Cloud | Fallback | ✅ Nemotron-H, stark |
---
## Wichtige Design-Entscheidungen
1. **Local-first:** Cloud ist Opt-in Werkzeug, nicht Abhängigkeit
2. **Kein Classifier:** Freitext = Chat. Befehle explizit per `/shell`, `/run`, `/code`
3. **Bestätigungs-Flow:** `/shell` generiert → Admin prüft → `/ja` führt aus
4. **Beliebiger Username:** MGMT_USER wird überall per Regex eingesetzt
5. **Smart `/code` Prompt:** Bot ergänzt Dateiname wenn User keinen nennt
6. **Snapshot vor destruktiven Aktionen:** Automatisch bei apt install, rm, chmod etc.
7. **DSGVO by design:** Lokal, keine Daten an Cloud, Snapshots ohne Secrets
---
## Security-Analyse (v0.4.13)
### Was wir haben ✅
- Chat-ID Auth (Single-User)
- `.env` mit 0600 Permissions
- SSH Host-Keys + shadow excluded aus Snapshots
- `.env` excluded aus Snapshots (seit v0.4.12)
- Port-Knocking statt offenes SSH
- Lokal-first, Cloud opt-in
- Safe-Reply Markdown-Fallback (kein Crash bei Sonderzeichen)
### Bekannte Schwachstellen ❌
**Telegram als Angriffsfläche:**
- Bot-Token im Klartext in `.env` – wer den Server hackt hat den Bot
- Telegram Chat-History enthält alle Befehle + Outputs – nicht löschbar vom Bot
- Telegram API ist NICHT E2E-verschlüsselt – Telegram Inc. kann mitlesen
- Jemand der das Handy entsperrt hat vollen Zugriff
- `.env` Inhalte können per `/run cat .env` über Telegram geleakt werden
**Bot-Sicherheit:**
- Kein Rate-Limiting – Brute-Force auf Befehle möglich
- Kein Session-Timeout – Bot antwortet 24/7 ohne erneute Auth
- `bot.log` enthält möglicherweise sensible Daten (unverschlüsselt)
- `.aider.chat.history.md` loggt alle Code-Prompts im Klartext
- Ollama API auf localhost:11434 hat keine Auth – jeder lokale Prozess kann anfragen
**Snapshots:**
- Snapshots enthalten `/home` – SSH Keys, bash_history, private Dateien
- Snapshot-Verzeichnis nicht verschlüsselt
- Kein Integrity-Check ob Snapshots manipuliert wurden
**Netzwerk:**
- knockd Sequenz steht im Klartext in `knockd.conf`
- ufw Regeln können über `/run` beliebig geändert werden
**Server:**
- NOPASSWD sudo – kompromittierter Bot = Root
- `install.sh` lädt Pakete aus dem Internet ohne Signatur-Check
- Docker Socket zugänglich → Container-Escape möglich
### Security-Maßnahmen geplant
**v0.5.0 (realistisch umsetzbar):**
1. `.env`/Secrets über Telegram blockieren (Blacklist für sensitive Pfade)
2. Output-Sanitizer: API Keys/Tokens in JEDER Bot-Antwort maskieren
3. Log-Sanitizer: Keine Secrets im Audit-Log
4. `bot.log` + `.aider.chat.history.md` + `bash_history` aus Snapshots excluden
5. Rate-Limiting: Max 30 Befehle/Minute
6. Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut nötig
7. 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot)
8. Persönlicher Code-Offset (SECURITY_OFFSET in .env)
9. SHA256-Hash bei Self-Update → Admin bestätigt
10. `.env` Permissions-Check beim Bot-Start (warnen wenn nicht 0600)
11. Audit-Log mit Timestamp für jeden Befehl (DSGVO Art. 5/32)
**v0.6.0+ (langfristig):**
12. Bot-Token Rotation
13. Verschlüsselte Snapshots (SNAPSHOT_PASSWORD)
14. Integrity-Hashes für Snapshots (Manipulation erkennen)
15. Ollama API Auth
16. Audit-Log mit Tamper-Protection (Signierung)
### DSGVO-Regel für Telegram
`.env` wird NUR angelegt/geändert über SSH. Niemals über Telegram lesbar.
Der Bot BLOCKIERT Befehle die sensitive Dateien über Telegram ausgeben würden.
---
## ZIP bauen – exakte Vorgehensweise
### Voraussetzungen
- Alle 20 Dateien müssen im Ordner `clownfischserver/` liegen
- Python-Dateien müssen syntax-geprüft sein
- install.sh muss bash-syntax-geprüft sein
### Schritt 1: Syntax-Check (IMMER vor dem ZIP)
```bash
cd clownfischserver
python3 -m py_compile bot/bot.py
python3 -m py_compile bot/ollama_client.py
python3 -m py_compile bot/openrouter_client.py
python3 -m py_compile bot/shell.py
python3 -m py_compile bot/snapshot.py
python3 -m py_compile bot/aider_wrapper.py
python3 -m py_compile bot/boot_notify.py
python3 -m py_compile bot/knock_notify.py
bash -n install.sh
```
### Schritt 2: ZIP erstellen
```bash
cd .. # Eine Ebene über clownfischserver/
zip -r v0.5.0-clownfischserver.zip clownfischserver/ -x "*__pycache__*" "*.pyc"
```
**WICHTIG:**
- ZIP-Name: `v<version>-clownfischserver-opus.zip`
- Root-Ordner im ZIP muss `clownfischserver/` heißen (nicht `build/` oder anderer Name)
- Keine `__pycache__` oder `.pyc` Dateien
- Keine `.git/` Ordner
- Keine `.env` mit echten Credentials
### Schritt 3: Verifizieren
```bash
unzip -l v0.5.0-clownfischserver.zip | head -30
# Muss zeigen: clownfischserver/bot/bot.py etc. (20 Dateien)
```
### Schritt 4: Einspielen
**Per Telegram:** ZIP an den ClownfischServer Bot senden → Self-Update Dialog
**Per Server:** `bash install.sh` im entpackten Ordner
### Schritt 5: GitHub Push
```bash
cd clownfischserver
rm -rf .git
git init
git remote add origin https://github.com/mehlzoerwer-claude/clownfischserver.git
git add -A
git commit -m "v0.4.13 – Beschreibung der Änderungen"
git branch -M main
git push -u origin main --force
```
### Dateiliste (20 Dateien, vollständig)
```
clownfischserver/
├── .gitignore
├── CLOWNFISCHSERVER.md ← diese Datei
├── CONTRIBUTING.md
├── GITHUB_PROFILE.md
├── LICENSE
├── README.md
├── install.sh
├── bot/
│ ├── aider_wrapper.py
│ ├── boot_notify.py
│ ├── bot.py
│ ├── knock_notify.py
│ ├── ollama_client.py
│ ├── openrouter_client.py
│ ├── requirements.txt
│ ├── shell.py
│ └── snapshot.py
├── config/
│ ├── .env.example
│ └── knockd.conf
└── systemd/
├── clownfisch-boot.service
└── clownfisch.service
```
### Versions-Bump Checkliste
Bei neuer Version diese Stellen aktualisieren:
1. `install.sh` – Banner + Log-Meldung (3x `v0.4.13`)
2. `bot/bot.py` – `cmd_start` Begrüßung + `main()` Log-Meldung
3. `README.md` – Badge `Version-0.4.13` + Roadmap "Fertig (v0.1 – v0.4.13)"
4. `CLOWNFISCHSERVER.md` – Header + Dateiliste
5. ZIP-Dateiname
---
> *"Angefangen als einfacher Telegram-Bot – gewachsen zu einer Vision."* 🐠
+53
View File
@@ -0,0 +1,53 @@
# Contributing to Clownfischserver 🐠
Danke dass du mitmachen willst! Hier sind die Regeln:
## Grundprinzipien
- **Open Source bleibt Open Source** – GPL-3.0, keine Ausnahmen
- **Lokal first** – keine Cloud-Dependencies, keine externen APIs
- **Einfach bleiben** – ein Nutzer, ein Bot, ein Server
- **Sicherheit** – Safety-Check vor jeder Aktion
## Pull Requests
1. Fork das Repo
2. Branch erstellen: `git checkout -b feature/dein-feature`
3. Änderungen committen
4. PR öffnen mit Beschreibung was und warum
## Code Style
- Python: PEP8, async/await
- Bash: shellcheck-kompatibel
- Kommentare auf Deutsch oder Englisch
- Header in jeder Datei (siehe bestehende Dateien)
## Code-Hygiene (wichtig!)
- **Kein toter Code** – keine Funktionen/Module die definiert aber nie aufgerufen werden
- **Keine versteckten Cloud-Calls** – jeder externe Request muss klar ersichtlich und opt-in sein
- **Niemals `verify=False`** bei HTTPS-Requests
- **Keine User-Eingaben an Dritte** ohne explizites Opt-in (DSGVO by design)
- **Klare Conditions** – verschachtelte Ternaries (`A if B else C` innerhalb von `and`-Ketten) vermeiden
- Selbst-Review vor jedem Release: macht jede Zeile noch das was sie soll?
## Issues
- Bug? → Issue mit Log-Output
- Feature-Idee? → Issue mit Use-Case Beschreibung
- Frage? → Issue oder Discussions
## Was wir suchen
- Distro-Support (getestete Fedora/Arch Fixes)
- Neue Bot-Commands
- Bessere Safety-Prompts
- Dokumentation
- Tests
## Was wir NICHT wollen
- Cloud-Dependencies
- Closed-Source Komponenten
- Komplexität die Einsteiger abschreckt
+27
View File
@@ -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."*
+17
View File
@@ -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 <https://www.gnu.org/licenses/>.
+436
View File
@@ -0,0 +1,436 @@
# 🐠 Clownfischserver
> *Wie der Clownfisch in seiner Anemone lebt – geschützt, autonom, und beißt überraschend hart.*
Ein selbst-hostbarer, KI-gestützter Server-Manager der ausschließlich über Telegram kommuniziert. Kein Dashboard, kein Web-UI, keine Cloud. Nur du und dein Server – über eine Chat-Nachricht.
![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)
![Platform: Linux](https://img.shields.io/badge/Platform-Linux-red.svg)
![Version](https://img.shields.io/badge/Version-0.5.0-green.svg)
![Status](https://img.shields.io/badge/Status-Active%20Development-yellow.svg)
---
## 🐟 Warum "Clownfisch"?
Der Clownfisch lebt in seiner Anemone – einem der wenigen Orte im Ozean die für andere Fische tödlich sind. Er verlässt sie kaum, schützt sie aktiv, und fühlt sich darin vollkommen sicher.
Genauso lebt der Clownfischserver **in seinem Server** (seiner Anemone):
- Er schirmt alles ab was nicht sein muss
- SSH bleibt standardmäßig zu – nur wer das richtige Klopfmuster kennt kommt rein
- Die KI entscheidet was sicher ist und was nicht
- Und wie der echte Clownfisch: **beißt er überraschend hart wenn nötig** 🐠
---
## Was ist das?
Clownfischserver verwandelt einen Linux-Server in einen vollautonomen, Telegram-gesteuerten Assistenten. Du schreibst ihm in natürlicher Sprache – er versteht, generiert Befehle, erstellt Scripts, und verwaltet deinen Server.
Das Ziel: **Server-Administration so einfach wie eine Chat-Nachricht.** Heute ein Server, morgen eine ganze Farm.
**Kein SCP. Kein Dashboard. Kein Cloud-Zwang. Nur Telegram.**
---
## Features
- 🤖 **Telegram Bot** als primäres Interface – von überall, auch vom Handy
- 🧠 **Dual-Model KI** – Code-Modell (qwen2.5-coder) für Befehle + Chat-Modell (gemma3) für Konversation
- 🔧 **`/shell` + `/ja`** – KI generiert Shell-Befehle, du bestätigst vor der Ausführung
- 🛠️ **`/code` + Aider** – autonome Code- und Script-Generierung direkt auf dem Server
- 💬 **Natürlicher Chat** – Fragen stellen, Erklärungen bekommen, übersetzen lassen
- 📸 **Automatische Snapshots** vor destruktiven Aktionen (tar.gz mit Keep/Unkeep + Paketliste)
- 🔐 **Port-Knocking SSH** als sicherer Backup-Zugang (knockd) mit Telegram-Benachrichtigung
- 🔔 **Benachrichtigungen** – SSH-Zugang, Server-Boot, Knock-Versuche via Telegram
- 📦 **Self-Update via Telegram** – ZIP an den Bot schicken, fertig (inkl. systemd-Services)
- ☁️ **OpenRouter Fallback** – kostenlose Cloud-Modelle als Backup wenn Ollama nicht verfügbar
- 👤 **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/archive/refs/heads/main.zip
unzip main.zip && cd clownfischserver-main
bash install.sh
```
Der Installer führt dich durch alles – Schritt für Schritt, mit Erklärungen.
---
## Installation
### Was du brauchst
- Frischer Ubuntu 22/24 oder Debian 12/13 VPS (min. 8GB RAM empfohlen, 20GB Disk)
- Root SSH-Zugang für die Installation
- Ein Telegram-Account (5 Minuten für Bot-Setup)
### RAM-Empfehlung
| RAM | Coder-Modell | Chat-Modell | Gesamt ca. |
|-----|-------------|-------------|------------|
| 8-12 GB | qwen2.5-coder:3b | gemma3:1b | ~3 GB |
| 16+ GB | qwen2.5-coder:7b | gemma3:4b | ~8 GB |
### Der Installer
```
[1/8] Systemcheck → RAM, Disk, Distro-Erkennung
[2/8] Pakete → alles Nötige mit Spinner
[3/8] User einrichten → eigener User oder vorhandenen nutzen
[4/8] SSH + Firewall → Port-Knocking, ufw, SSH bleibt offen
[5/8] Ollama → Dual-Model KI-Setup (Coder + Chat)
[6/8] Python + Aider → Bot-Umgebung + Code-Agent
[7/8] Telegram → geführtes Bot-Setup mit BotFather
[8/8] Service starten → systemd + Boot-Notification
```
### Update (wenn bereits installiert)
```bash
bash install.sh
# → erkennt laufenden Service
# → [1] Update [2] Neuinstallation [3] Abbrechen
```
Oder noch einfacher: **ZIP direkt an den Bot schicken** – Self-Update!
---
## Nutzung
### Bot-Befehle
| Befehl | Beschreibung |
|--------|-------------|
| `/shell <beschreibung>` | KI generiert Shell-Befehl aus natürlicher Sprache |
| `/ja` | Letzten vorgeschlagenen Befehl ausführen |
| `/code <aufgabe>` | Aider erstellt/bearbeitet Code und Scripts |
| `/run <befehl>` | Direkte Shell – kein Ollama, kein Bestätigen |
| `/status` | CPU, RAM, Disk, Top-Prozesse |
| `/snapshots` | Alle Snapshots anzeigen |
| `/snapshot [label]` | Manuell Snapshot erstellen |
| `/snapshot keep <n>` | Snapshot als wichtig markieren |
| `/snapshot delete <n>` | Snapshot löschen |
| `/rollback <n>` | Zu einem Snapshot zurückkehren |
| `/ssh open [ip]` | SSH via ufw öffnen (für alle oder eine IP) |
| `/ssh close [ip]` | SSH wieder sperren |
| `/start` / `/help` | Bot-Info und Befehlsübersicht |
### Freitext-Chat
Einfach schreiben – ohne Befehl. Der Bot antwortet als Chat-Partner:
```
"Was ist ein Reverse Proxy?"
"Übersetze das ins Englische: ..."
"Erkläre mir den Unterschied zwischen apt und snap"
"Welche Services laufen gerade auf meinem Server?"
```
### Workflow: `/shell` + `/ja`
```
Du: /shell zeig mir die größten Dateien auf dem Server
Bot: 🔧 Vorgeschlagener Befehl:
du -ah / | sort -rh | head -20
→ Mit /ja ausführen oder einfach ignorieren.
Du: /ja
Bot: ✅ Ergebnis: ...
```
### Workflow: `/code`
```
Du: /code erstelle ein bash script das alte Logs älter als 30 Tage löscht
Bot: 🛠️ Aider Ergebnis:
✅ Aider Task abgeschlossen
Applied edit to cleanup-logs.sh
```
### Self-Update
ZIP-Datei direkt an den Bot schicken → Bot erstellt Snapshot → installiert Update → startet neu.
---
## Architektur
```
┌─────────────────────┐
│ Telegram Bot │
│ (bot.py) │
└──────┬──────┬────────┘
│ │
┌─────────────────┤ ├─────────────────┐
▼ ▼ ▼ ▼
┌────────────────┐ ┌────────────┐ ┌──────────────────┐
│ ollama_client │ │ shell.py │ │ aider_wrapper │
│ (Dual-Model) │ │ (run_shell)│ │ (Code-Agent) │
└───────┬────┬────┘ └────────────┘ └──────────────────┘
│ │
┌────────┘ └────────┐
▼ ▼
┌──────────┐ ┌──────────────┐
│ Chat-Model│ │ Coder-Model │
│ (gemma3) │ │(qwen2.5-coder)│
└──────────┘ └──────────────┘
Weitere Module:
snapshot.py – Backup-System (tar.gz, keep/unkeep, auto-cleanup)
knock_notify.py – SSH Port-Knocking Alerts
boot_notify.py – Server-Neustart Benachrichtigung
```
---
## Sicherheit
### Port-Knocking
SSH ist standardmäßig durch ufw blockiert. Zugang via Klopf-Sequenz:
```bash
# Oeffnen: 7000 -> 8000 -> 9000
knock SERVER_IP 7000 8000 9000
# Schliessen: 9000 -> 8000 -> 7000
knock SERVER_IP 9000 8000 7000
```
Bei jedem Knock kommt eine Telegram-Benachrichtigung mit IP und Zeitstempel.
### KI-gestützte Befehlsgenerierung
```
/shell installiere nginx
→ KI generiert: apt install -y nginx
→ Gefährlich? Nein → Vorschlag anzeigen
→ /ja zum Ausführen, oder ignorieren
/shell lösche alles in /etc
→ KI generiert: rm -rf /etc/*
→ Gefährlich? JA → Verweigert mit Erklärung
```
Kein Befehl wird ohne Bestätigung ausgeführt. Gefährliche Befehle werden immer verweigert.
### Snapshot-System
Vor destruktiven Aktionen automatischer Snapshot (tar.gz). Wichtige Snapshots können mit `/snapshot keep` markiert werden und werden nie automatisch gelöscht.
### Boot-Notification
Nach jedem Server-Neustart bekommst du eine Telegram-Nachricht mit Status aller Dienste (Clownfisch, Ollama, ufw, SSH, Cron) – nach 2 Minuten, wenn alles hochgefahren ist.
---
## Roadmap
### Fertig (v0.1 – v0.5.0)
- [x] Basis-Installer mit geführtem Setup (8 Schritte)
- [x] Telegram Bot mit Auth Guard (Single-User)
- [x] Dual-Model KI: Coder (qwen2.5-coder) + Chat (gemma3/gemma4)
- [x] `/shell` + `/ja` – Befehlsgenerierung mit Bestätigung (non-interaktive Befehle)
- [x] `/code` – Aider Code-Agent für autonome Script-Erstellung (Smart Prompt)
- [x] `/run` – Direkte Shell ohne KI
- [x] `/status` – Systemübersicht (CPU, RAM, Disk, Prozesse)
- [x] `/ssh open/close` – ufw-basierter SSH-Zugang
- [x] Freitext-Chat mit History (letzte 10 Exchanges)
- [x] Snapshot-System (tar.gz, keep/unkeep, auto-cleanup max 20, Paketliste)
- [x] `/snapshot`, `/snapshots`, `/rollback` (inkl. Workspace)
- [x] Port-Knocking SSH (knockd) mit Telegram-Benachrichtigung
- [x] Boot-Notification nach Server-Neustart (Dienste-Status)
- [x] Self-Update via Telegram ZIP (inkl. systemd-Services + User-Patch)
- [x] Update-Modus im Installer (mit MGMT_USER aus .env)
- [x] Multi-Distro Installer (apt/dnf/pacman)
- [x] Ollama Warmup im Hintergrund (Bot sofort bereit)
- [x] **OpenRouter Free Fallback** – kostenlose Cloud-Modelle wenn Ollama nicht verfügbar
- [x] Interaktiver Update-Dialog (OpenRouter Key Eingabe via Telegram)
- [x] `OLLAMA_NUM_CTX` konfigurierbar (4K–32K, RAM-basierte Empfehlung im Installer)
- [x] Safe Telegram Output (Markdown-Fallback bei Parse-Fehlern)
- [x] Lange Antworten werden in Teile gesplittet (kein Abschneiden)
- [x] Mehrsprachig (antwortet in der Sprache des Nutzers)
- [x] MGMT_USER Regex-Patch (beliebiger Username funktioniert überall)
- [x] Security-Analyse dokumentiert (Schwachstellen + Maßnahmen in CLOWNFISCHSERVER.md)
- [x] DSGVO-Regel: `.env` nur per SSH, nie über Telegram lesbar
- [x] Projekt-Kontext-Datei (CLOWNFISCHSERVER.md) für KI-gestütztes Pair-Programming
### v0.5.0 – Cleanup & Stabilität (aktuelles Release)
**Bugfixes & Aufräumarbeiten (dieser Release):**
- [x] Totes Vault/MCP-Context-System komplett entfernt (war nie verdrahtet, `verify=False`-Lücke)
- [x] Privacy-Fix: Chat-Nachrichten gingen via Vault-Suche an externe Endpoints → entfernt
- [x] `/code` Filepath-Logik: fragiler Ternary-Ausdruck sauber umgeschrieben (kein Crash bei leerem Task)
- [x] Aider-Model-Default vereinheitlicht (zieht sich `OLLAMA_MODEL` aus der Umgebung)
- [x] Irreführende „Token-Optimization"-Logs (Anthropic-Kosten/Obsidian) aus dem Start-Log entfernt
**Sicherheit & DSGVO (geplant):**
- [ ] Sensitive Dateien über Telegram blockieren (`.env`, `shadow`, Keys)
- [ ] Output-Sanitizer: API Keys/Tokens in jeder Bot-Antwort maskieren
- [ ] Log-Sanitizer: Keine Secrets im Audit-Log
- [ ] Audit-Log: Jeder Befehl mit Timestamp (DSGVO Art. 5/32)
- [ ] Self-Update Hardening: SHA256-Hash anzeigen, Admin bestätigt
- [ ] `.env` Permissions-Check beim Bot-Start
- [ ] `bot.log`, `.aider.chat.history.md`, `bash_history` aus Snapshots excluden
- [ ] Rate-Limiting: Max 30 Befehle/Minute
- [ ] Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut nötig
- [ ] 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot)
- [ ] Persönlicher Code-Offset (SECURITY_OFFSET in `.env`)
**Features:**
- [ ] Snapshot-Scheduling (`/snapshot schedule daily 03:00`)
- [ ] Snapshot-Export als ZIP via Telegram (`/snapshot export <n>`)
- [ ] `/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.* 🐠
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
"""
import asyncio
import logging
import os
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
INSTALL_DIR = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
AIDER_WORKDIR = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b")
TIMEOUT = 600 # 10 minutes for aider tasks on CPU
class AiderWrapper:
def __init__(self):
self.workdir = Path(AIDER_WORKDIR)
self.workdir.mkdir(parents=True, exist_ok=True)
self.venv_python = Path(INSTALL_DIR) / "aider-venv" / "bin" / "python3"
self.venv_aider = Path(INSTALL_DIR) / "aider-venv" / "bin" / "aider"
logger.info(f"AiderWrapper init: workdir={self.workdir}")
async def run(self, task: str, target_file: str = None) -> str:
"""
Run aider in script mode for a coding task.
Returns the output/result as string.
"""
logger.info(f"Aider task: {task[:100]}")
# Determine working directory and files
workdir = self.workdir
# Build aider command
cmd = self._build_aider_command(task, target_file)
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(workdir),
env={
**os.environ,
"OLLAMA_API_BASE": OLLAMA_BASE_URL,
"HOME": f"/home/{os.getenv('MGMT_USER', 'clownfish')}",
}
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=TIMEOUT
)
except asyncio.TimeoutError:
process.kill()
return f"⏱️ Aider Timeout nach {TIMEOUT}s. Task zu komplex oder Modell überlastet."
stdout_str = stdout.decode("utf-8", errors="replace").strip()
stderr_str = stderr.decode("utf-8", errors="replace").strip()
# Build response
result_parts = []
if stdout_str:
result_parts.append(stdout_str)
if stderr_str and process.returncode != 0:
result_parts.append(f"[STDERR]\n{stderr_str}")
if process.returncode == 0:
result_parts.insert(0, f"✅ Aider Task abgeschlossen\nArbeitsverzeichnis: {workdir}\n")
else:
result_parts.insert(0, f"⚠️ Aider beendet mit Code {process.returncode}\n")
return "\n".join(result_parts) or "✓ Task abgeschlossen (keine Ausgabe)"
except FileNotFoundError:
return (
"❌ Aider nicht gefunden. Bitte prüfen:\n"
f" ls {self.venv_aider}\n"
" Ggf. neu installieren mit: pip install aider-chat"
)
except Exception as e:
logger.error(f"Aider execution error: {e}", exc_info=True)
return f"❌ Aider Fehler: {e}"
def _build_aider_command(self, task: str, target_file: str = None) -> list:
"""Build the aider command with appropriate flags"""
cmd = [
str(self.venv_aider),
"--model", f"ollama/{OLLAMA_MODEL}",
"--no-git", # Don't require git
"--yes", # Auto-confirm all changes
"--no-pretty", # Plain output for telegram
"--no-show-model-warnings", # Skip env var warnings
"--no-analytics", # No analytics prompts
"--edit-format", "diff", # Better format for small models
"--message", task, # The task as a message
]
if target_file:
cmd.append(target_file)
return cmd
async def list_workspace_files(self) -> str:
"""List files in the aider workspace"""
try:
files = list(self.workdir.rglob("*"))
if not files:
return f"📁 Workspace {self.workdir} ist leer."
file_list = "\n".join([
f" {'📁' if f.is_dir() else '📄'} {f.relative_to(self.workdir)}"
for f in sorted(files)[:50]
])
return f"📁 Workspace: {self.workdir}\n\n{file_list}"
except Exception as e:
return f"❌ Fehler beim Lesen des Workspace: {e}"
+114
View File
@@ -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()
+771
View File
@@ -0,0 +1,771 @@
#!/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.5.0 online.*{fallback_info}\n\n"
"Schreib mir einfach – ich antworte als Chat.\n\n"
"*Befehle:*\n"
"• `/shell <beschreibung>` – KI generiert Befehl, du bestätigst\n"
"• `/ja` – letzten Shell-Befehl ausführen\n"
"• `/code <aufgabe>` – Aider generiert Code\n"
"• `/run <befehl>` – direkte Shell, kein Ollama\n"
"• `/status` – CPU, RAM, Disk\n"
"• `/snapshots` – alle Snapshots anzeigen\n"
"• `/rollback <n>` – 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 <n>` – wichtig markieren\n"
msg += "`/snapshot delete <n>` – 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 <snapshot-name>`\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 <befehl>\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 <was du tun willst>`\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 <beschreibung>`.", 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 <aufgabe>`\n\n"
"Beispiel: `/code erstelle eine einfache Flask API`\n\n"
"💡 Tipp: Kläre Details erst im Chat, dann `/code` mit klarer Aufgabe.",
parse_mode="Markdown"
)
return
task = " ".join(context.args)
# Smart prompt: ensure Aider creates a FILE, not just chat output
workspace = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace")
task_lower = task.lower()
# Only auto-generate a filepath if the task doesn't already point at one.
words = task.split()
first_word_is_path = bool(words) and "/" in words[0]
needs_filepath = (
workspace not in task
and "/opt/" not in task
and not first_word_is_path
)
if needs_filepath:
file_match = re.search(r'(\S+\.(sh|py|js|ts|html|css|yaml|yml|json|conf|cfg|txt))', task)
if file_match:
filename = file_match.group(1)
filepath = f"{workspace}/{filename}"
else:
words = re.sub(r'[^a-z0-9 ]', '', task_lower).split()[:3]
slug = "-".join(words) if words else "script"
ext = ".py" if "python" in task_lower else ".sh"
filepath = f"{workspace}/{slug}{ext}"
task = f"erstelle die datei {filepath} mit folgendem inhalt: {task}"
logger.info(f"Code task enhanced: {task[:100]}")
status_msg = await update.message.reply_text("🛠️ Aider arbeitet...")
snap_name = snapshots.create_snapshot(label="pre-aider")
if snap_name:
await update.message.reply_text(f"📸 Snapshot: `{snap_name}`", parse_mode="Markdown")
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 <befehl>\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.5.0 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}")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(app.initialize())
app.run_polling(allowed_updates=Update.ALL_TYPES)
finally:
loop.run_until_complete(app.shutdown())
if __name__ == "__main__":
main()
+48
View File
@@ -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}")
+234
View File
@@ -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("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"
)
+181
View File
@@ -0,0 +1,181 @@
#!/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()
system = "Server: Ubuntu 24.04, Clownfischserver setup."
prompt = (
"You are a Linux bash command generator for Ubuntu 24.04.\n"
"Convert the request into a single non-interactive bash command.\n"
"Respond ONLY with JSON: {\"dangerous\": bool, \"reason\": string, \"command\": string}\n"
"No interactive commands (nano, vim, crontab -e). Use sudo when needed.\n\n"
f"Request: {description}\n\nJSON only:"
)
try:
raw = await loop.run_in_executor(
None,
lambda: self._chat_sync(
messages=[{"role": "user", "content": prompt}],
system=system,
model=FREE_MODELS["coder"],
),
)
result = _extract_json(raw)
if "command" not in result:
result["command"] = ""
if "dangerous" not in result:
result["dangerous"] = False
logger.info(f"OpenRouter shell: {result}")
return result
except json.JSONDecodeError as e:
logger.warning(f"OpenRouter JSON error: {e}")
return {"dangerous": False, "reason": "", "command": ""}
except Exception as e:
logger.error(f"OpenRouter shell failed: {e}")
raise
async def chat(self, message: str, history: list = None) -> str:
"""General chat via OpenRouter."""
loop = asyncio.get_running_loop()
messages = list(history) if history else []
messages.append({"role": "user", "content": message})
system = (
"Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.\n"
"Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt."
)
try:
return await loop.run_in_executor(
None,
lambda: self._chat_sync(
messages=messages,
system=system,
model=FREE_MODELS["chat"],
),
)
except Exception as e:
return f"OpenRouter Fehler: {e}"
async def explain_output(self, command: str, output: str, error: str = "") -> str:
"""Explain command output via OpenRouter."""
loop = asyncio.get_running_loop()
content = f"Command: {command}\nOutput:\n{output}"
if error:
content += f"\nErrors:\n{error}"
content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer."
try:
return await loop.run_in_executor(
None,
lambda: self._chat_sync(
messages=[{"role": "user", "content": content}],
model=FREE_MODELS["chat"],
),
)
except Exception as e:
logger.error(f"OpenRouter explain failed: {e}")
return ""
+4
View File
@@ -0,0 +1,4 @@
python-telegram-bot==21.0
aider-chat
requests
python-dotenv
+85
View File
@@ -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)"
+290
View File
@@ -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}")
+30
View File
@@ -0,0 +1,30 @@
# 🐠 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
# HomeIP Auto-Update (optional – myfritz.net Dynamic DNS)
# Domain für automatische Firewall-Regel-Updates bei IP-Wechsel
# CLOWNFISCHSERVER_HOME_IP_DOMAIN=vorname.myfritz.net
# Paths
INSTALL_DIR=/opt/clownfischserver
MGMT_USER=clownfish
AIDER_WORKDIR=/opt/clownfisch-workspace
+18
View File
@@ -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
+940
View File
@@ -0,0 +1,940 @@
#!/bin/bash
# =============================================================================
# 🐠 CLOWNFISCHSERVER
# Version: see banner
# Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
# License: GPL-3.0 – Keep it open. Always.
# Repo: https://github.com/mehlzoerwer-claude/clownfischserver
# =============================================================================
# GPL-3.0 License - Keep it open. Always.
# https://github.com/mehlzoerwer-claude/clownfischserver
# --- Colors ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# --- Defaults ---
INSTALL_DIR="/opt/clownfischserver"
SNAPSHOT_DIR="/opt/clownfisch-snapshots"
WORKSPACE_DIR="/opt/clownfisch-workspace"
LOG_FILE="/var/log/clownfischserver-install.log"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MGMT_USER=""
PKG_MANAGER=""
DISTRO=""
OLLAMA_MODEL=""
OLLAMA_MODEL_FAST=""
SNAPSHOT_METHOD="tar"
OLLAMA_NUM_CTX=4096
MYFRITZ_DOMAIN=""
BOT_TOKEN=""
CHAT_ID=""
OPENROUTER_API_KEY=""
# HELPERS
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG_FILE"; }
ok() { echo -e " ${GREEN}✓${NC} $1"; log "OK: $1"; }
info() { echo -e " ${CYAN}→${NC} $1"; log "INFO: $1"; }
warn() { echo -e " ${YELLOW}⚠${NC} $1"; log "WARN: $1"; }
fail() {
echo -e "\n ${RED}${BOLD}✗ FEHLER: $1${NC}"
echo -e " ${YELLOW}Mehr Details: tail -50 $LOG_FILE${NC}\n"
log "ERROR: $1"
exit 1
}
step() {
echo -e "\n${BOLD}${BLUE}[$1/$TOTAL_STEPS] $2${NC}"
log "=== STEP $1: $2 ==="
}
ask() {
echo -e "\n ${YELLOW}?${NC} ${BOLD}$1${NC}"
if [[ -n "$2" ]]; then
echo -e " ${CYAN} $2${NC}"
fi
read -r -p " → " REPLY
echo "$REPLY"
}
ask_yn() {
echo -e "\n ${YELLOW}?${NC} ${BOLD}$1${NC} ${CYAN}[j/n]${NC}"
read -r -p " → " REPLY
[[ "$REPLY" =~ ^[jJyY] ]]
}
pause() {
echo -e "\n ${CYAN}$1${NC}"
read -r -p " Enter zum Fortfahren..." _
}
TOTAL_STEPS=8
# PROGRESS + SPINNER
spinner() {
local pid=$1
local msg=$2
local spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local i=0
tput civis 2>/dev/null
while kill -0 "$pid" 2>/dev/null; do
local c="${spin:$((i % ${#spin})):1}"
printf "\r \033[0;36m%s\033[0m %s..." "$c" "$msg"
sleep 0.1
((i++))
done
printf "\r\033[K"
tput cnorm 2>/dev/null
}
run_with_spinner() {
local msg=$1
shift
("$@" >> "$LOG_FILE" 2>&1) &
local pid=$!
spinner "$pid" "$msg"
wait "$pid"
return $?
}
print_banner() {
clear
echo -e "${CYAN}${BOLD}"
echo " ╔══════════════════════════════════════════════════════╗"
echo " ║ 🐠 CLOWNFISCHSERVER INSTALLER ║"
echo " ║ v0.5.0 | GPL-3.0 ║"
echo " ╚══════════════════════════════════════════════════════╝"
echo -e "${NC}"
echo -e " Willkommen! Dieses Script richtet deinen"
echo -e " Clownfischserver ein – Schritt für Schritt."
echo -e " Du wirst durch alles geführt.\n"
echo -e " ${YELLOW}Log-Datei: $LOG_FILE${NC}\n"
}
# STEP 1: CHECKS
step_checks() {
step 1 "Systemcheck"
# Root check
[[ $EUID -ne 0 ]] && fail "Bitte als root ausführen:\n sudo bash install.sh"
ok "Root-Rechte vorhanden"
# Distro detection
if command -v apt &>/dev/null; then
PKG_MANAGER="apt"
DISTRO=$(grep -oP '(?<=^ID=).+' /etc/os-release | tr -d '"')
DISTRO_VERSION=$(grep -oP '(?<=^VERSION_ID=).+' /etc/os-release | tr -d '"')
ok "Distribution: $DISTRO $DISTRO_VERSION (apt)"
elif command -v dnf &>/dev/null; then
PKG_MANAGER="dnf"
DISTRO="fedora"
ok "Distribution: Fedora/RHEL (dnf)"
elif command -v pacman &>/dev/null; then
PKG_MANAGER="pacman"
DISTRO="arch"
ok "Distribution: Arch Linux (pacman)"
else
fail "Unbekannte Distribution – apt/dnf/pacman nicht gefunden."
fi
# RAM check
RAM_MB=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)
info "RAM: ${RAM_MB}MB verfügbar"
if [[ "$RAM_MB" -lt 2000 ]]; then
warn "Weniger als 2GB RAM – Ollama könnte Probleme haben."
ask_yn "Trotzdem fortfahren?" || fail "Installation abgebrochen."
else
ok "RAM ausreichend"
fi
# Disk check
DISK_FREE=$(df -BG / | awk 'NR==2 {print $4}' | tr -d 'G')
info "Freier Speicher: ${DISK_FREE}GB"
[[ "$DISK_FREE" -lt 5 ]] && warn "Weniger als 5GB frei – Ollama-Modell braucht ~2GB."
ok "Systemcheck abgeschlossen"
}
# STEP 2: PAKETE INSTALLIEREN
step_packages() {
step 2 "Pakete installieren"
info "Paketliste wird aktualisiert..."
case "$PKG_MANAGER" in
apt)
run_with_spinner "Paketliste aktualisieren" apt-get update -qq || fail "apt update fehlgeschlagen"
run_with_spinner "Pakete installieren" apt-get install -y -qq curl wget git ufw python3 python3-pip python3-venv sudo htop net-tools lsof rsync openssl knockd || warn "Einige Pakete konnten nicht installiert werden – siehe Log"
;;
dnf)
run_with_spinner "Pakete aktualisieren" dnf update -y -q
run_with_spinner "Pakete installieren" dnf install -y -q curl wget git python3 python3-pip sudo htop net-tools rsync openssl
warn "knockd und ufw nicht verfügbar auf Fedora – firewalld wird genutzt"
;;
pacman)
run_with_spinner "System aktualisieren" pacman -Syu --noconfirm
run_with_spinner "Pakete installieren" pacman -S --noconfirm curl wget git python python-pip sudo htop net-tools rsync openssl
;;
esac
ok "Pakete installiert"
}
# STEP 3: BENUTZER EINRICHTEN
step_user() {
step 3 "Benutzer einrichten"
echo ""
echo -e " ${BOLD}Welchen User soll der Clownfischserver nutzen?${NC}"
echo -e " ${CYAN} Dieser User verwaltet den Server und bekommt sudo-Rechte.${NC}"
echo -e " ${CYAN} Du kannst einen neuen anlegen oder einen vorhandenen nutzen.${NC}"
echo ""
# List existing non-system users
EXISTING_USERS=$(awk -F: '$3>=1000 && $3<65534 {print $1}' /etc/passwd | tr '\n' ' ')
if [[ -n "$EXISTING_USERS" ]]; then
info "Vorhandene Benutzer: $EXISTING_USERS"
fi
echo -e " ${YELLOW}?${NC} ${BOLD}Username (Enter = 'clownfish'):${NC}"
echo -e " ${CYAN} Neu oder vorhandenen Namen eingeben${NC}"
read -r -p " → " MGMT_USER
[[ -z "$MGMT_USER" ]] && MGMT_USER="clownfish"
if id "$MGMT_USER" &>/dev/null; then
warn "User '$MGMT_USER' existiert bereits – wird wiederverwendet."
else
useradd -m -s /bin/bash "$MGMT_USER" || fail "User konnte nicht angelegt werden"
ok "User '$MGMT_USER' angelegt"
fi
# Passwordless sudo
echo "$MGMT_USER ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/clownfisch-$MGMT_USER"
chmod 0440 "/etc/sudoers.d/clownfisch-$MGMT_USER"
ok "Sudo-Rechte gesetzt (passwordless)"
# SSH dir
mkdir -p "/home/$MGMT_USER/.ssh"
chmod 700 "/home/$MGMT_USER/.ssh"
chown -R "$MGMT_USER:$MGMT_USER" "/home/$MGMT_USER"
ok "SSH-Verzeichnis angelegt"
# Always set a password as backup
TEMP_PASS=$(openssl rand -base64 12)
echo "$MGMT_USER:$TEMP_PASS" | chpasswd
echo ""
echo -e " ${BOLD}${YELLOW}╔════════════════════════════════════════════════════╗${NC}"
echo -e " ${BOLD}${YELLOW}║ SSH Passwort für '$MGMT_USER': ║${NC}"
echo -e " ${BOLD}${YELLOW}║ ║${NC}"
echo -e " ${BOLD}${YELLOW}║ ${TEMP_PASS} ║${NC}"
echo -e " ${BOLD}${YELLOW}║ ║${NC}"
echo -e " ${BOLD}${YELLOW}║ Notiere es jetzt! SSH bleibt offen bis du ║${NC}"
echo -e " ${BOLD}${YELLOW}║ im Telegram-Bot /ssh close sendest. ║${NC}"
echo -e " ${BOLD}${YELLOW}╚════════════════════════════════════════════════════╝${NC}"
pause "Passwort notiert?"
}
# STEP 4: SSH + FIREWALL
step_ssh() {
step 4 "SSH + Firewall"
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
ok "sshd_config gesichert"
# SSH – nur gezielt aendern, bestehende Config behalten!
info "Passe sshd_config an (bestehende Einstellungen bleiben erhalten)..."
_sshd_set() {
local key="$1" val="$2"
if grep -qE "^#?\s*${key}" /etc/ssh/sshd_config; then
sed -i "s|^#\?\s*${key}.*|${key} ${val}|" /etc/ssh/sshd_config
else
echo "${key} ${val}" >> /etc/ssh/sshd_config
fi
}
_sshd_set "PasswordAuthentication" "yes"
_sshd_set "PubkeyAuthentication" "yes"
_sshd_set "X11Forwarding" "no"
_sshd_set "PermitRootLogin" "yes"
ok "sshd_config angepasst (nur relevante Zeilen geaendert)"
systemctl restart sshd >> "$LOG_FILE" 2>&1
ok "SSH läuft – Passwort + Key erlaubt"
info "SSH wird erst gesperrt wenn du im Bot /ssh close sendest"
# Firewall
case "$PKG_MANAGER" in
apt)
ufw default deny incoming >> "$LOG_FILE" 2>&1
ufw default allow outgoing >> "$LOG_FILE" 2>&1
ufw allow 22/tcp >> "$LOG_FILE" 2>&1
echo "y" | ufw enable >> "$LOG_FILE" 2>&1
ok "ufw Firewall aktiv – Port 22 offen"
# knockd
IFACE=$(ip route | grep default | awk '{print $5}' | head -1)
NOTIFY_CMD="$INSTALL_DIR/venv/bin/python3 $INSTALL_DIR/bot/knock_notify.py"
# knockd – nur clownfisch-Sektionen schreiben, bestehende Config behalten
if [ -f /etc/knockd.conf ]; then
# Entferne nur unsere alten Sektionen falls vorhanden
sed -i '/^\[openSSH\]/,/^$/d' /etc/knockd.conf 2>/dev/null || true
sed -i '/^\[closeSSH\]/,/^$/d' /etc/knockd.conf 2>/dev/null || true
# Interface in [options] setzen falls vorhanden
if grep -q "^\[options\]" /etc/knockd.conf; then
grep -q "Interface" /etc/knockd.conf || sed -i "/^\[options\]/a\ Interface = $IFACE" /etc/knockd.conf
else
echo -e "[options]
UseSyslog
Interface = $IFACE
" >> /etc/knockd.conf
fi
else
# Neue Config anlegen
cat > /etc/knockd.conf << KNOCKEOF
[options]
UseSyslog
Interface = $IFACE
KNOCKEOF
fi
# Unsere SSH-Sektionen hinzufuegen
cat >> /etc/knockd.conf << KNOCKEOF
[openSSH]
sequence = 7000,8000,9000
seq_timeout = 10
command = /sbin/iptables -I INPUT -s %IP% -p tcp --dport 22 -j ACCEPT && $NOTIFY_CMD open %IP%
tcpflags = syn
[closeSSH]
sequence = 9000,8000,7000
seq_timeout = 10
command = /sbin/iptables -D INPUT -s %IP% -p tcp --dport 22 -j ACCEPT && $NOTIFY_CMD close %IP%
tcpflags = syn
KNOCKEOF
[ -f /etc/default/knockd ] && sed -i 's/START_KNOCKD=0/START_KNOCKD=1/' /etc/default/knockd
systemctl enable knockd >> "$LOG_FILE" 2>&1
systemctl restart knockd >> "$LOG_FILE" 2>&1 && ok "Port-Knocking aktiv (7000→8000→9000)" || warn "knockd konnte nicht starten"
;;
dnf)
systemctl enable firewalld >> "$LOG_FILE" 2>&1
systemctl start firewalld >> "$LOG_FILE" 2>&1
firewall-cmd --permanent --add-service=ssh >> "$LOG_FILE" 2>&1
firewall-cmd --reload >> "$LOG_FILE" 2>&1
ok "firewalld aktiv – SSH offen"
;;
pacman)
warn "Bitte Firewall manuell einrichten (iptables/nftables)"
;;
esac
echo ""
echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " ${BOLD} myfritz – Home-IP Auto-Update (optional)${NC}"
echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " Wenn du ein AVM-Gerät (Fritzbox) mit myfritz.net hast,"
echo -e " kann der Bot deine Home-IP automatisch ins Firewall-Regelwerk"
echo -e " schreiben. Das bedeutet: SSH bleibt immer erreichbar, auch"
echo -e " wenn sich deine IP ändert (z.B. ISP, mobil, VPN)."
echo ""
echo -e " ${YELLOW}?${NC} ${BOLD}Hast du ein AVM myfritz.net Gerät?${NC} [j/n]"
read -r -p " -> " USE_MYFRITZ
if [[ "$USE_MYFRITZ" =~ ^[jJ]$ ]]; then
echo ""
echo -e " ${YELLOW}?${NC} ${BOLD}Deine myfritz-Domain:${NC}"
echo -e " ${CYAN} Format: vorname.myfritz.net${NC}"
read -r -p " -> " MYFRITZ_DOMAIN
if [[ -z "$MYFRITZ_DOMAIN" ]]; then
MYFRITZ_DOMAIN=""
warn "myfritz übersprungen"
else
ok "myfritz Domain: $MYFRITZ_DOMAIN"
fi
else
MYFRITZ_DOMAIN=""
info "myfritz übersprungen"
fi
}
# HELPER: Pull an Ollama model with progress bar
_pull_model() {
local model_name="$1"
echo ""
echo -e " ${CYAN}Lade Modell: ${BOLD}$model_name${NC}"
echo -e " ${CYAN}Das kann je nach Internetgeschwindigkeit 2-10 Minuten dauern.${NC}"
echo ""
tput civis 2>/dev/null
ollama pull "$model_name" 2>&1 | while IFS= read -r line; do
if [[ "$line" =~ ([0-9]+)% ]]; then
pct="${BASH_REMATCH[1]}"
filled=$(( pct * 30 / 100 ))
empty=$(( 30 - filled ))
bar=""
for ((i=0; i<filled; i++)); do bar+="="; done
for ((i=0; i<empty; i++)); do bar+="."; done
printf "\r [%-30s] %3s%% " "$bar" "$pct"
elif [[ "$line" == *"pulling manifest"* ]]; then
printf "\r Manifest wird geladen... "
elif [[ "$line" == *"verifying"* ]]; then
printf "\r Verifiziere Modell... "
elif [[ "$line" == *"writing"* ]]; then
printf "\r Schreibe Modell... "
elif [[ "$line" == *"success"* ]]; then
printf "\r Download abgeschlossen! \n"
fi
done
tput cnorm 2>/dev/null
echo ""
ollama list 2>/dev/null | grep -q "${model_name%%:*}" || fail "Modell konnte nicht geladen werden: $model_name"
ok "Modell geladen: $model_name"
}
# STEP 5: OLLAMA
step_ollama() {
step 5 "Ollama (KI-Engine)"
echo ""
info "Ollama ist die KI die deinen Bot intelligent macht."
info "Es laeuft komplett lokal auf deinem Server – keine Cloud."
if command -v ollama &>/dev/null; then
warn "Ollama bereits installiert"
else
run_with_spinner "Ollama wird heruntergeladen und installiert" \
bash -c "curl -fsSL https://ollama.com/install.sh | sh" || fail "Ollama Installation fehlgeschlagen"
ok "Ollama installiert"
fi
run_with_spinner "Ollama Service starten" systemctl enable ollama
run_with_spinner "Ollama hochfahren" bash -c "systemctl start ollama && sleep 5"
# ─────────────────────────────────────────────────────────
# SCHRITT 1: Coder-Modell (Aider + /shell Befehlsgenerierung)
# ─────────────────────────────────────────────────────────
echo ""
echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " ${BOLD} [1/2] Coder-Modell${NC}"
echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " Dieses Modell generiert Code (Aider) und Shell-Befehle (/shell)."
echo -e " Es muss gut programmieren koennen – Deutsch ist hier zweitrangig."
echo ""
# RAM-basierte Empfehlung fuer Coder
if [[ "$RAM_MB" -ge 16000 ]]; then
CODER_DEFAULT="qwen2.5-coder:7b"
CODER_HINT="16GB+ RAM erkannt – bestes Code-Modell fuer CPU"
else
CODER_DEFAULT="qwen2.5-coder:3b"
CODER_HINT="Kompakt und schnell auf CPU"
fi
echo -e " ${CYAN} Dein RAM: ${BOLD}${RAM_MB}MB${NC}"
echo -e " ${CYAN} Empfehlung: ${BOLD}${CODER_DEFAULT}${NC} ${CYAN}(${CODER_HINT})${NC}"
echo ""
echo -e " ${CYAN} [1] qwen2.5-coder:3b – schnell, ~2GB RAM (unter 16GB)${NC}"
echo -e " ${CYAN} [2] qwen2.5-coder:7b – stark, ~4.5GB RAM (16GB+ empfohlen)${NC}"
echo -e " ${CYAN} [3] eigenes Modell eingeben${NC}"
echo ""
echo -e " ${YELLOW}?${NC} ${BOLD}Auswahl [1/2/3]:${NC} (Enter = Empfehlung)"
read -r -p " → " CODER_CHOICE
case "$CODER_CHOICE" in
1) OLLAMA_MODEL="qwen2.5-coder:3b" ;;
2) OLLAMA_MODEL="qwen2.5-coder:7b" ;;
3) echo -e " ${YELLOW}?${NC} ${BOLD}Modell-Name:${NC} ${CYAN}z.B. codellama:7b${NC}"
read -r -p " → " OLLAMA_MODEL ;;
*) OLLAMA_MODEL="$CODER_DEFAULT" ;;
esac
_pull_model "$OLLAMA_MODEL"
ok "Coder-Modell: $OLLAMA_MODEL"
# ─────────────────────────────────────────────────────────
# SCHRITT 2: Chat-Modell (Deutsch, schnelle Antworten)
# ─────────────────────────────────────────────────────────
OLLAMA_MODEL_FAST=""
echo ""
echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " ${BOLD} [2/2] Chat-Modell (optional)${NC}"
echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " Ein separates Chat-Modell antwortet schneller auf Nachrichten"
echo -e " und spricht besser Deutsch. Das Coder-Modell bleibt fuer Code."
echo ""
echo -e " ${CYAN} Coder-Modell: ${BOLD}$OLLAMA_MODEL${NC} ${CYAN}(bereits installiert)${NC}"
echo ""
if ask_yn "Separates Chat-Modell installieren? (empfohlen)"; then
echo ""
# RAM-basierte Empfehlung fuer Chat
if [[ "$RAM_MB" -ge 24000 ]]; then
CHAT_DEFAULT="mistral:7b"
CHAT_HINT="24GB+ RAM – bestes Deutsch (franzoesische KI, exzellent in EU-Sprachen)"
echo -e " ${CYAN} [1] gemma3:1b – 800MB RAM, schnell, brauchbares Deutsch${NC}"
echo -e " ${CYAN} [2] gemma3:4b – 3GB RAM, sehr gutes Deutsch (Google multilingual)${NC}"
echo -e " ${BOLD} [3] mistral:7b – 4.5GB RAM, exzellentes Deutsch ← empfohlen${NC}"
echo -e " ${CYAN} [4] eigenes Modell eingeben${NC}"
elif [[ "$RAM_MB" -ge 12000 ]]; then
CHAT_DEFAULT="gemma3:4b"
CHAT_HINT="12-20GB RAM – sehr gutes Deutsch bei wenig RAM (Google multilingual)"
echo -e " ${CYAN} [1] gemma3:1b – 800MB RAM, schnell, brauchbares Deutsch${NC}"
echo -e " ${BOLD} [2] gemma3:4b – 3GB RAM, sehr gutes Deutsch ← empfohlen${NC}"
echo -e " ${CYAN} [3] mistral:7b – 4.5GB RAM, exzellentes Deutsch (viel RAM noetig)${NC}"
echo -e " ${CYAN} [4] eigenes Modell eingeben${NC}"
else
CHAT_DEFAULT="gemma3:1b"
CHAT_HINT="<12GB RAM – klein und schnell, spart RAM fuer System"
echo -e " ${BOLD} [1] gemma3:1b – 800MB RAM, schnell, brauchbar ← empfohlen${NC}"
echo -e " ${CYAN} [2] gemma3:4b – 3GB RAM, sehr gutes Deutsch (knapp bei <12GB)${NC}"
echo -e " ${CYAN} [3] mistral:7b – 4.5GB RAM, exzellentes Deutsch (zu gross!)${NC}"
echo -e " ${CYAN} [4] eigenes Modell eingeben${NC}"
fi
echo ""
echo -e " ${CYAN} Empfehlung: ${BOLD}${CHAT_DEFAULT}${NC} ${CYAN}(${CHAT_HINT})${NC}"
echo ""
echo -e " ${YELLOW}?${NC} ${BOLD}Auswahl [1/2/3/4]:${NC} (Enter = Empfehlung)"
read -r -p " → " CHAT_CHOICE
case "$CHAT_CHOICE" in
1) OLLAMA_MODEL_FAST="gemma3:1b" ;;
2) OLLAMA_MODEL_FAST="gemma3:4b" ;;
3) OLLAMA_MODEL_FAST="mistral:7b" ;;
4) echo -e " ${YELLOW}?${NC} ${BOLD}Modell-Name:${NC} ${CYAN}z.B. llama3.2:3b${NC}"
read -r -p " → " OLLAMA_MODEL_FAST ;;
*) OLLAMA_MODEL_FAST="$CHAT_DEFAULT" ;;
esac
_pull_model "$OLLAMA_MODEL_FAST"
ok "Chat-Modell: $OLLAMA_MODEL_FAST"
echo ""
ok "Dual-Model aktiv:"
info " Coder: $OLLAMA_MODEL (Code, /shell, Erklaerungen)"
info " Chat: $OLLAMA_MODEL_FAST (Nachrichten, Deutsch)"
else
info "Single-Model: $OLLAMA_MODEL fuer alles"
fi
# ─────────────────────────────────────────────────────────
# Context-Window (wie viel Text das Modell auf einmal sieht)
# ─────────────────────────────────────────────────────────
echo ""
echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " ${BOLD} Context-Window (KI-Gedaechtnis)${NC}"
echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " Je groesser, desto mehr Code/Text kann die KI auf einmal sehen."
echo -e " Groesseres Fenster = mehr RAM-Verbrauch + etwas langsamer."
echo ""
if [[ "$RAM_MB" -ge 24000 ]]; then
CTX_DEFAULT=32768
echo -e " ${CYAN} [1] 4096 – minimal, schnell${NC}"
echo -e " ${CYAN} [2] 8192 – gut fuer /shell${NC}"
echo -e " ${CYAN} [3] 16384 – ideal fuer /code${NC}"
echo -e " ${BOLD} [4] 32768 – maximale Code-Qualitaet ← empfohlen${NC}"
elif [[ "$RAM_MB" -ge 16000 ]]; then
CTX_DEFAULT=16384
echo -e " ${CYAN} [1] 4096 – minimal, schnell${NC}"
echo -e " ${CYAN} [2] 8192 – gut fuer /shell${NC}"
echo -e " ${BOLD} [3] 16384 – ideal fuer /code ← empfohlen${NC}"
echo -e " ${CYAN} [4] 32768 – maximale Qualitaet (braucht viel RAM)${NC}"
elif [[ "$RAM_MB" -ge 12000 ]]; then
CTX_DEFAULT=8192
echo -e " ${CYAN} [1] 4096 – minimal${NC}"
echo -e " ${BOLD} [2] 8192 – guter Kompromiss ← empfohlen${NC}"
echo -e " ${CYAN} [3] 16384 – groessere Scripts${NC}"
echo -e " ${CYAN} [4] 32768 – nur wenn genuegend RAM${NC}"
else
CTX_DEFAULT=4096
echo -e " ${BOLD} [1] 4096 – passend fuer wenig RAM ← empfohlen${NC}"
echo -e " ${CYAN} [2] 8192 – wenn es reicht${NC}"
echo -e " ${CYAN} [3] 16384 – wahrscheinlich zu viel${NC}"
echo -e " ${CYAN} [4] 32768 – nicht empfohlen${NC}"
fi
echo ""
echo -e " ${YELLOW}?${NC} ${BOLD}Auswahl [1/2/3/4]:${NC} (Enter = Empfehlung)"
read -r -p " → " CTX_CHOICE
case "$CTX_CHOICE" in
1) OLLAMA_NUM_CTX=4096 ;;
2) OLLAMA_NUM_CTX=8192 ;;
3) OLLAMA_NUM_CTX=16384 ;;
4) OLLAMA_NUM_CTX=32768 ;;
*) OLLAMA_NUM_CTX=$CTX_DEFAULT ;;
esac
ok "Context-Window: $OLLAMA_NUM_CTX Token"
}
# STEP 6: PYTHON + PAKETE
step_python() {
step 6 "Python-Umgebung"
mkdir -p "$INSTALL_DIR" "$SNAPSHOT_DIR" "$WORKSPACE_DIR"
run_with_spinner "Python Umgebung erstellen" python3 -m venv "$INSTALL_DIR/venv" || fail "venv konnte nicht erstellt werden"
ok "Python venv erstellt"
run_with_spinner "pip aktualisieren" "$INSTALL_DIR/venv/bin/pip" install --upgrade pip
run_with_spinner "Telegram-Bot Paket installieren" "$INSTALL_DIR/venv/bin/pip" install "python-telegram-bot==21.0" requests python-dotenv || fail "Basis-Pakete fehlgeschlagen"
ok "Telegram + Basis-Pakete installiert"
# Aider bekommt ein eigenes venv – seine Dependencies kollidieren mit dem Bot
info "Installiere aider-chat in separatem venv (2-5 Min)..."
AIDER_VENV="$INSTALL_DIR/aider-venv"
if python3 -m venv "$AIDER_VENV" >> "$LOG_FILE" 2>&1; then
"$AIDER_VENV/bin/pip" install --upgrade pip setuptools wheel >> "$LOG_FILE" 2>&1
if "$AIDER_VENV/bin/pip" install aider-chat >> "$LOG_FILE" 2>&1; then
ok "aider-chat installiert ($AIDER_VENV)"
elif "$AIDER_VENV/bin/pip" install --no-build-isolation aider-chat >> "$LOG_FILE" 2>&1; then
ok "aider-chat installiert (no-build-isolation)"
else
warn "aider-chat fehlgeschlagen – spaeter manuell:"
info " $AIDER_VENV/bin/pip install aider-chat"
info "Der Bot funktioniert ohne Aider – /code ist dann nicht verfuegbar."
fi
else
warn "Aider venv konnte nicht erstellt werden"
fi
}
# STEP 7: TELEGRAM
step_telegram() {
step 7 "Telegram Bot einrichten"
echo ""
echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " ${BOLD} Was ist ein Telegram Bot?${NC}"
echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " Ein Bot ist ein automatisches Telegram-Konto das auf"
echo -e " Nachrichten reagiert. Du schreibst dem Bot – der Bot"
echo -e " fuehrt Befehle auf deinem Server aus."
echo -e " ${BOLD}Jeder braucht seinen eigenen Bot – das geht in 2 Minuten.${NC}"
echo ""
echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " ${BOLD} Schritt 1: Bot erstellen${NC}"
echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e " 1. Oeffne Telegram auf deinem Handy oder PC"
echo -e " 2. Suche nach: ${BOLD}@BotFather${NC}"
echo -e " 3. Sende: ${BOLD}/newbot${NC}"
echo -e " 4. Vergib einen Anzeigenamen (z.B. 'Mein Server Bot')"
echo -e " 5. Vergib einen Username (muss auf 'bot' enden)"
echo -e " 6. Du bekommst einen ${BOLD}Token${NC} – sieht so aus:"
echo -e " ${CYAN}1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ${NC}"
echo ""
pause "Bot erstellt? Dann weiter..."
echo -e " ${YELLOW}?${NC} ${BOLD}Dein Bot-Token:${NC}"
echo -e " ${CYAN} Den kompletten Token von @BotFather einfuegen${NC}"
read -r -p " -> " BOT_TOKEN
[[ -z "$BOT_TOKEN" ]] && fail "Kein Token eingegeben."
ok "Token gespeichert"
echo ""
echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " ${BOLD} Schritt 2: Deine Chat-ID herausfinden${NC}"
echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e " 1. Suche in Telegram nach: ${BOLD}@userinfobot${NC}"
echo -e " 2. Sende irgendetwas (z.B. 'hi')"
echo -e " 3. Der Bot antwortet mit deiner ${BOLD}Id${NC} – das ist deine Chat-ID"
echo ""
pause "Chat-ID gefunden? Dann weiter..."
echo -e " ${YELLOW}?${NC} ${BOLD}Deine Chat-ID:${NC}"
echo -e " ${CYAN} Nur die Zahl, keine anderen Zeichen${NC}"
read -r -p " -> " CHAT_ID
[[ -z "$CHAT_ID" ]] && fail "Keine Chat-ID eingegeben."
ok "Chat-ID gespeichert"
# ─────────────────────────────────────────────────────────
# OpenRouter Fallback (optional, kostenlos)
# ─────────────────────────────────────────────────────────
OPENROUTER_API_KEY=""
echo ""
echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " ${BOLD} OpenRouter Fallback (optional)${NC}"
echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " Wenn dein Ollama offline ist, kann der Bot auf Cloud-Modelle ausweichen."
echo -e " OpenRouter Free Tier: 50 Requests/Tag kostenlos (keine Kreditkarte!)"
echo ""
if ask_yn "OpenRouter als Fallback aktivieren?"; then
echo ""
echo -e " ${CYAN}Registrier dich kostenlos: https://openrouter.ai${NC}"
echo -e " ${CYAN}Dann kopier deinen API Key von der Dashboard${NC}"
echo ""
echo -e " ${YELLOW}?${NC} ${BOLD}OpenRouter API Key:${NC}"
echo -e " ${CYAN} Format: sk-or-v1-...${NC}"
read -r -p " → " OPENROUTER_API_KEY
if [[ -z "$OPENROUTER_API_KEY" ]]; then
warn "Leerer Key – OpenRouter deaktiviert"
OPENROUTER_API_KEY=""
else
ok "OpenRouter aktiviert"
fi
else
info "OpenRouter deaktiviert – Bot nutzt nur lokal Ollama"
fi
mkdir -p "$INSTALL_DIR/config"
cat > "$INSTALL_DIR/config/.env" << ENVEOF
TELEGRAM_BOT_TOKEN=$BOT_TOKEN
TELEGRAM_CHAT_ID=$CHAT_ID
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=$OLLAMA_MODEL
OLLAMA_MODEL_FAST=$OLLAMA_MODEL_FAST
OLLAMA_NUM_CTX=$OLLAMA_NUM_CTX
OPENROUTER_API_KEY=$OPENROUTER_API_KEY
SNAPSHOT_DIR=$SNAPSHOT_DIR
SNAPSHOT_METHOD=$SNAPSHOT_METHOD
INSTALL_DIR=$INSTALL_DIR
MGMT_USER=$MGMT_USER
AIDER_WORKDIR=$WORKSPACE_DIR
CLOWNFISCHSERVER_HOME_IP_DOMAIN=$MYFRITZ_DOMAIN
ENVEOF
chmod 600 "$INSTALL_DIR/config/.env"
ok "Konfiguration gespeichert"
}
# STEP 8: BOT STARTEN
step_service() {
step 8 "Bot installieren + starten"
mkdir -p "$INSTALL_DIR/bot"
cp -r "$SCRIPT_DIR/bot/"* "$INSTALL_DIR/bot/" || fail "Bot-Dateien nicht gefunden – ist der bot/ Ordner dabei?"
ok "Bot-Dateien kopiert"
mkdir -p "$INSTALL_DIR/scripts"
if [ -d "$SCRIPT_DIR/scripts" ]; then
cp -r "$SCRIPT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true
chmod +x "$INSTALL_DIR/scripts/"*.sh 2>/dev/null || true
ok "Scripts kopiert"
fi
chown -R "$MGMT_USER:$MGMT_USER" "$INSTALL_DIR" "$SNAPSHOT_DIR" "$WORKSPACE_DIR"
chmod -R u+w "$INSTALL_DIR/bot"
ok "Verzeichnis-Rechte gesetzt"
cat > /etc/systemd/system/clownfisch.service << SERVICEEOF
[Unit]
Description=🐠 Clownfischserver Telegram Bot
Documentation=https://github.com/mehlzoerwer-claude/clownfischserver
After=network-online.target ollama.service
Wants=network-online.target ollama.service
[Service]
Type=simple
User=$MGMT_USER
WorkingDirectory=$INSTALL_DIR
EnvironmentFile=$INSTALL_DIR/config/.env
ExecStart=$INSTALL_DIR/venv/bin/python3 $INSTALL_DIR/bot/bot.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=clownfisch
# Allow sudo and iptables for SSH management
AmbientCapabilities=CAP_NET_ADMIN CAP_SYS_ADMIN
SecureBits=keep-caps
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
SERVICEEOF
run_with_spinner "Systemd neu laden" systemctl daemon-reload
run_with_spinner "Bot-Service aktivieren" systemctl enable clownfisch
run_with_spinner "Bot starten" systemctl start clownfisch
sleep 3
if systemctl is-active --quiet clownfisch; then
ok "Bot laeuft!"
else
warn "Bot gestartet – check Logs: journalctl -u clownfisch -n 20 --no-pager"
fi
# Boot-Notification Service
cat > /etc/systemd/system/clownfisch-boot.service << BOOTEOF
[Unit]
Description=🐠 Clownfischserver Boot Notification
Documentation=https://github.com/mehlzoerwer-claude/clownfischserver
After=network-online.target clownfisch.service ollama.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStartPre=/bin/sleep 120
ExecStart=$INSTALL_DIR/venv/bin/python3 $INSTALL_DIR/bot/boot_notify.py
EnvironmentFile=$INSTALL_DIR/config/.env
WorkingDirectory=$INSTALL_DIR
StandardOutput=journal
StandardError=journal
SyslogIdentifier=clownfisch-boot
[Install]
WantedBy=multi-user.target
BOOTEOF
run_with_spinner "Boot-Notification aktivieren" bash -c "systemctl daemon-reload && systemctl enable clownfisch-boot"
ok "Boot-Notification: Telegram-Meldung 2 Min nach Serverstart"
# HomeIP Updater – falls myfritz konfiguriert
if [[ -n "$MYFRITZ_DOMAIN" ]]; then
echo ""
info "Installiere HomeIP Auto-Updater (5-Min Cron)..."
# Copy the homeip-updater script if it exists
if [ -f "$SCRIPT_DIR/scripts/clownfischserver-homeip-updater.sh" ]; then
mkdir -p /opt/clownfischserver-scripts
cp "$SCRIPT_DIR/scripts/clownfischserver-homeip-updater.sh" /opt/clownfischserver/scripts/
chmod +x /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh
# Add cron job (every 5 minutes)
CRON_JOB="*/5 * * * * /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh >> /var/log/clownfischserver-homeip-updater.log 2>&1"
(crontab -l 2>/dev/null | grep -v "clownfischserver-homeip-updater" ; echo "$CRON_JOB") | crontab - 2>/dev/null || true
ok "HomeIP Auto-Updater installiert (myfritz: $MYFRITZ_DOMAIN)"
else
warn "homeip-updater Script nicht gefunden – übersprungen"
fi
fi
}
# ZUSAMMENFASSUNG
print_summary() {
SERVER_IP=$(curl -s ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}')
echo ""
echo -e "${GREEN}${BOLD}"
echo " +======================================================+"
echo " | CLOWNFISCHSERVER BEREIT! v0.5.0 |"
echo " +======================================================+"
echo -e "${NC}"
echo -e " ${BOLD}Server:${NC} $SERVER_IP"
echo -e " ${BOLD}SSH User:${NC} $MGMT_USER (Passwort aktiv!)"
echo -e " ${BOLD}KI-Modell:${NC} $OLLAMA_MODEL"
echo -e " ${BOLD}Snapshots:${NC} $SNAPSHOT_DIR ($SNAPSHOT_METHOD)"
echo -e " ${BOLD}Logs:${NC} journalctl -u clownfisch -f"
echo -e " ${BOLD}Install-Log:${NC} $LOG_FILE"
echo ""
echo -e " ${CYAN}${BOLD}Erste Schritte:${NC}"
echo -e " ${CYAN} 1. Schreibe deinem Bot: /start${NC}"
echo -e " ${CYAN} 2. Teste mit: /status${NC}"
echo -e " ${CYAN} 3. Wenn alles laeuft: /ssh close${NC}"
echo ""
echo -e " ${CYAN}${BOLD}Notfall-Befehle im Bot:${NC}"
echo -e " ${CYAN} /run <befehl> -> direkte Shell ohne KI${NC}"
echo -e " ${CYAN} /ssh open -> SSH oeffnen${NC}"
echo -e " ${CYAN} /ssh open 1.2.3.4 -> SSH nur fuer deine IP${NC}"
echo -e " ${CYAN} /ssh close -> SSH sperren${NC}"
echo ""
echo -e " ${YELLOW}Install-Log (letzte 10 Zeilen):${NC}"
tail -10 "$LOG_FILE"
echo ""
}
# MAIN
update_bot() {
echo -e "
${BOLD}${CYAN}🐠 Clownfischserver Update-Modus${NC}
"
log "=== Update gestartet ==="
# Read MGMT_USER from .env if not set
if [[ -z "$MGMT_USER" ]] && [[ -f "$INSTALL_DIR/config/.env" ]]; then
MGMT_USER=$(grep "^MGMT_USER=" "$INSTALL_DIR/config/.env" | cut -d= -f2)
fi
[[ -z "$MGMT_USER" ]] && MGMT_USER="clownfish"
# Copy new bot files
cp -r "$SCRIPT_DIR/bot/"* "$INSTALL_DIR/bot/"
chown -R "$MGMT_USER:$MGMT_USER" "$INSTALL_DIR/bot"
ok "Bot-Dateien aktualisiert"
# Update systemd services – patch hardcoded user with actual MGMT_USER
for svc in clownfisch.service clownfisch-boot.service; do
if [ -f "$SCRIPT_DIR/systemd/$svc" ]; then
sed "s/^User=.*/User=$MGMT_USER/;s/^Group=.*/Group=$MGMT_USER/" \
"$SCRIPT_DIR/systemd/$svc" > "/etc/systemd/system/$svc"
[[ "$svc" == "clownfisch-boot.service" ]] && systemctl enable clownfisch-boot >> "$LOG_FILE" 2>&1
fi
done
systemctl daemon-reload >> "$LOG_FILE" 2>&1
ok "Systemd Services aktualisiert (User=$MGMT_USER)"
# Restart
systemctl restart clownfisch >> "$LOG_FILE" 2>&1
sleep 3
if systemctl is-active --quiet clownfisch; then
ok "Bot neu gestartet – Update abgeschlossen!"
else
warn "Bot-Status unklar: journalctl -u clownfisch -n 20 --no-pager"
fi
echo ""
echo -e " ${CYAN}Logs: journalctl -u clownfisch -f${NC}"
echo ""
log "=== Update abgeschlossen ==="
}
main() {
print_banner
touch "$LOG_FILE"
# Update mode: service already running
if systemctl is-active --quiet clownfisch 2>/dev/null; then
echo -e " ${YELLOW}⚠${NC} Clownfischserver läuft bereits!"
echo ""
echo -e " ${CYAN}[1]${NC} Update (Bot-Dateien + Restart)"
echo -e " ${CYAN}[2]${NC} Neuinstallation (alles neu)"
echo -e " ${CYAN}[3]${NC} Abbrechen"
echo ""
read -r -p " → " CHOICE
case "$CHOICE" in
1) update_bot; exit 0 ;;
2) echo "" ;;
*) echo "Abgebrochen."; exit 0 ;;
esac
fi
log "=== Clownfischserver v0.5.0 Installation gestartet ==="
step_checks
step_packages
step_user
step_ssh
step_ollama
step_python
step_telegram
step_service
print_summary
log "=== Installation abgeschlossen ==="
}
main "$@"
+174
View File
@@ -0,0 +1,174 @@
#!/bin/bash
# =============================================================================
# 🐠 CLOWNFISCHSERVER – HomeIP Auto-Update
# Purpose: Auto-update firewall rule for dynamic home IP (myfritz.net)
# Runs: Every 5 minutes (via cron)
# Author: Mehlzoerwer-Claude
# License: GPL-3.0
# =============================================================================
set -e
# --- Configuration ---
INSTALL_DIR="/opt/clownfischserver"
ENV_FILE="$INSTALL_DIR/config/.env"
STATE_FILE="/var/lib/clownfischserver-homeip-state"
LOG_FILE="/var/log/clownfischserver-homeip-updater.log"
SSH_PORT=22
# --- Helpers ---
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
}
# --- Load .env ---
load_env() {
if [ ! -f "$ENV_FILE" ]; then
log "ERROR: .env not found at $ENV_FILE"
exit 1
fi
# Load and trim CRLF/LF
MYFRITZ_DOMAIN=$(grep "^CLOWNFISCHSERVER_HOME_IP_DOMAIN=" "$ENV_FILE" | cut -d= -f2 | tr -d '\r\n')
if [ -z "$MYFRITZ_DOMAIN" ]; then
log "ERROR: CLOWNFISCHSERVER_HOME_IP_DOMAIN not set in .env"
exit 1
fi
}
# --- Detect Firewall ---
detect_firewall() {
if systemctl is-active --quiet ufw; then
echo "ufw"
elif systemctl is-active --quiet firewalld; then
echo "firewalld"
elif command -v iptables &>/dev/null; then
echo "iptables"
else
log "ERROR: No firewall detected (ufw/firewalld/iptables)"
exit 1
fi
}
# --- Resolve DNS with fallbacks ---
resolve_ip() {
local domain="$1"
# Try dig first
if command -v dig &>/dev/null; then
IP=$(dig +short "$domain" A | grep -oE '^[0-9.]+$' | head -1)
[ -n "$IP" ] && echo "$IP" && return 0
fi
# Try nslookup
if command -v nslookup &>/dev/null; then
IP=$(nslookup "$domain" 2>/dev/null | grep "Address:" | tail -1 | awk '{print $2}' | grep -oE '^[0-9.]+$')
[ -n "$IP" ] && echo "$IP" && return 0
fi
# Try getent
if command -v getent &>/dev/null; then
IP=$(getent hosts "$domain" | awk '{print $1}' | grep -oE '^[0-9.]+$' | head -1)
[ -n "$IP" ] && echo "$IP" && return 0
fi
log "ERROR: Could not resolve $domain"
return 1
}
# --- Load previous state ---
load_state() {
if [ -f "$STATE_FILE" ]; then
PREVIOUS_IP=$(cat "$STATE_FILE" | grep "^IP=" | cut -d= -f2)
echo "$PREVIOUS_IP"
else
echo ""
fi
}
# --- Save state ---
save_state() {
local ip="$1"
echo "IP=$ip" > "$STATE_FILE"
chmod 600 "$STATE_FILE"
}
# --- UFW Functions ---
ufw_add_rule() {
local ip="$1"
ufw allow from "$ip" to any port "$SSH_PORT" comment "clownfisch-homeip" >/dev/null 2>&1
log "UFW: Added rule for $ip:$SSH_PORT"
}
ufw_remove_rule() {
local ip="$1"
echo "y" | ufw delete allow from "$ip" to any port "$SSH_PORT" >/dev/null 2>&1
log "UFW: Deleted rule for $ip"
}
# --- Firewalld Functions ---
firewalld_add_rule() {
local ip="$1"
firewall-cmd --permanent --add-rich-rule="rule family='ipv4' source address='$ip' port protocol='tcp' port='$SSH_PORT' accept" >/dev/null 2>&1
firewall-cmd --reload >/dev/null 2>&1
log "firewalld: Added rule for $ip:$SSH_PORT"
}
firewalld_remove_rule() {
local ip="$1"
firewall-cmd --permanent --remove-rich-rule="rule family='ipv4' source address='$ip' port protocol='tcp' port='$SSH_PORT' accept" >/dev/null 2>&1
firewall-cmd --reload >/dev/null 2>&1
log "firewalld: Deleted rule for $ip"
}
# --- iptables Functions ---
iptables_add_rule() {
local ip="$1"
iptables -I INPUT -s "$ip" -p tcp --dport "$SSH_PORT" -j ACCEPT
log "iptables: Added rule for $ip:$SSH_PORT"
}
iptables_remove_rule() {
local ip="$1"
iptables -D INPUT -s "$ip" -p tcp --dport "$SSH_PORT" -j ACCEPT 2>/dev/null || true
log "iptables: Deleted rule for $ip"
}
# --- Main ---
main() {
log "=== HomeIP Auto-Update started ==="
load_env
FIREWALL=$(detect_firewall)
NEW_IP=$(resolve_ip "$MYFRITZ_DOMAIN") || exit 1
PREVIOUS_IP=$(load_state)
if [ "$NEW_IP" = "$PREVIOUS_IP" ]; then
log "No change: $NEW_IP (same as previous)"
exit 0
fi
log "IP change detected: $PREVIOUS_IP → $NEW_IP (firewall: $FIREWALL)"
# Remove old rule
if [ -n "$PREVIOUS_IP" ]; then
case "$FIREWALL" in
ufw) ufw_remove_rule "$PREVIOUS_IP" ;;
firewalld) firewalld_remove_rule "$PREVIOUS_IP" ;;
iptables) iptables_remove_rule "$PREVIOUS_IP" ;;
esac
fi
# Add new rule
case "$FIREWALL" in
ufw) ufw_add_rule "$NEW_IP" ;;
firewalld) firewalld_add_rule "$NEW_IP" ;;
iptables) iptables_add_rule "$NEW_IP" ;;
esac
# Save state
save_state "$NEW_IP"
log "=== HomeIP Auto-Update completed successfully ==="
}
main "$@"
+18
View File
@@ -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
+26
View File
@@ -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