Initial commit: Clownfischserver v0.5.0
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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."* 🐠
|
||||
@@ -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
|
||||
@@ -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."*
|
||||
@@ -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/>.
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🐟 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.* 🐠
|
||||
@@ -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}"
|
||||
@@ -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
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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 ""
|
||||
@@ -0,0 +1,4 @@
|
||||
python-telegram-bot==21.0
|
||||
aider-chat
|
||||
requests
|
||||
python-dotenv
|
||||
@@ -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
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
@@ -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 "$@"
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user