v0.5.0 – Clownfischserver HomeIP Auto-Update: myfritz Automatik für SSH (5-Min Cron)
Features: - install.sh: Frage in Step 4 ob myfritz-Adresse für Auto-SSH-Freigabe genutzt werden soll - clownfischserver-homeip-updater.sh: DNS-Auflösung + UFW-Update (alle 5 Min via Cron) - .env-Variable: CLOWNFISCHSERVER_HOME_IP_DOMAIN für myfritz-Adresse - State-File tracking zur Vermeidung unnötiger UFW-Updates - Logging: /var/log/clownfischserver-homeip.log - Dokumentation: scripts/README-HOMEIP.md + CLOWNFISCHSERVER.md update Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git init *)",
|
||||||
|
"Bash(git config *)",
|
||||||
|
"Bash(git remote *)",
|
||||||
|
"Bash(git checkout *)",
|
||||||
|
"Bash(git add *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,490 @@
|
|||||||
|
# 🐠 Clownfischserver – Projekt-Kontext
|
||||||
|
|
||||||
|
> Diese Datei ist der zentrale Wissensspeicher für das Clownfischserver-Projekt.
|
||||||
|
> Lade sie in jede KI/Chat-Session um sofort produktiv zu sein.
|
||||||
|
> Letzte Aktualisierung: v0.4.13 (18.04.2026)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was ist der Clownfischserver?
|
||||||
|
|
||||||
|
Ein Open-Source, Telegram-gesteuerter Linux-Server-Manager. Der Admin schreibt dem Bot über Telegram – der Bot versteht, generiert Befehle, erstellt Scripts, verwaltet den Server. Komplett lokal mit Ollama, optional Cloud-Fallback über OpenRouter.
|
||||||
|
|
||||||
|
**Repo:** https://github.com/mehlzoerwer-claude/clownfischserver
|
||||||
|
**Lizenz:** GPL-3.0
|
||||||
|
**Entwickler:** Mehlzoerwer-Claude (Thorsten Matthes)
|
||||||
|
**KI-Assistent:** Claude (Anthropic) – Pair-Programming Partner
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Telegram Bot │
|
||||||
|
│ (bot.py) │
|
||||||
|
└──────┬──────┬────────┘
|
||||||
|
│ │
|
||||||
|
┌─────────────────┤ ├─────────────────┐
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌────────────────┐ ┌────────────┐ ┌──────────────────┐
|
||||||
|
│ ollama_client │ │ shell.py │ │ aider_wrapper │
|
||||||
|
│ (Dual-Model) │ │ (run_shell)│ │ (Code-Agent) │
|
||||||
|
└───────┬────┬────┘ └────────────┘ └──────────────────┘
|
||||||
|
│ │
|
||||||
|
┌────────┘ └────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌──────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ Chat-Model│ │ Coder-Model │ │ OpenRouter │
|
||||||
|
│ (gemma3) │ │(qwen2.5-coder)│ │ (Fallback) │
|
||||||
|
└──────────┘ └──────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prinzip:** Freitext = Chat. `/shell` = KI generiert Befehl → Admin bestätigt mit `/ja`. `/code` = Aider erstellt Dateien. `/run` = direkte Shell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dateistruktur (19 Dateien, ~3250 Zeilen)
|
||||||
|
|
||||||
|
```
|
||||||
|
clownfischserver/
|
||||||
|
├── bot/
|
||||||
|
│ ├── bot.py # Hauptbot: alle Commands, Auth, Self-Update, Update-Dialog
|
||||||
|
│ ├── ollama_client.py # Dual-Model, OpenRouter Fallback, NUM_CTX, Shell-Prompt
|
||||||
|
│ ├── openrouter_client.py # OpenRouter Free Models Client (openrouter/free)
|
||||||
|
│ ├── shell.py # run_shell (shlex.quote), execute_command
|
||||||
|
│ ├── aider_wrapper.py # Aider mit --edit-format diff, 600s Timeout
|
||||||
|
│ ├── snapshot.py # tar.gz Snapshots, Workspace, Paketliste, sudo rsync
|
||||||
|
│ ├── boot_notify.py # Telegram-Nachricht 120s nach Boot
|
||||||
|
│ ├── knock_notify.py # Port-Knocking SSH-Benachrichtigungen
|
||||||
|
│ └── requirements.txt # python-telegram-bot, requests, python-dotenv
|
||||||
|
├── config/
|
||||||
|
│ ├── .env.example # Vorlage mit allen Variablen
|
||||||
|
│ └── knockd.conf # Port-Knocking Sequenz (7000→8000→9000)
|
||||||
|
├── systemd/
|
||||||
|
│ ├── clownfisch.service # Bot-Service (User=clownfish, wird gepatcht)
|
||||||
|
│ └── clownfisch-boot.service # Boot-Notification (oneshot, 120s delay)
|
||||||
|
├── install.sh # 8-Schritt Installer mit Modell/CTX-Auswahl
|
||||||
|
├── README.md # Vollständige Dokumentation
|
||||||
|
├── LICENSE # GPL-3.0
|
||||||
|
├── .gitignore
|
||||||
|
├── CONTRIBUTING.md
|
||||||
|
└── GITHUB_PROFILE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wichtige Konventionen
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
- **Service:** `clownfisch.service` (Deutsch, mit "ch")
|
||||||
|
- **Linux-User:** Beliebig (z.B. `clownfisch`, `clownfish`, `admin`) – wird aus `MGMT_USER` in `.env` gelesen
|
||||||
|
- **Branding:** Immer "Clownfisch" (Deutsch)
|
||||||
|
- **ZIP-Benennung:** `v0.4.13-clownfischserver-opus.zip`
|
||||||
|
|
||||||
|
### Code-Stil
|
||||||
|
- Python 3, keine Type Hints in Funktionssignaturen (außer Optional)
|
||||||
|
- Logging über `logging` Modul
|
||||||
|
- Async für alle Telegram-Handler
|
||||||
|
- `safe_reply()` für alle Telegram-Nachrichten (Markdown-Fallback)
|
||||||
|
- Fehler werden dem User angezeigt, nie verschluckt
|
||||||
|
|
||||||
|
### Sicherheits-Regeln
|
||||||
|
- Configs (`sshd_config`, `knockd.conf`) NIE überschreiben – nur gezielte Zeilen ändern
|
||||||
|
- `.env` Permissions: 0600
|
||||||
|
- SSH Host-Keys, `/etc/shadow` NIE in Snapshots
|
||||||
|
- `.env` seit v0.4.12 aus Snapshots excludiert
|
||||||
|
- Self-Update: systemd-Services werden mit `MGMT_USER` aus `.env` gepatcht (Regex: `User=\S+`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## .env Variablen
|
||||||
|
|
||||||
|
```
|
||||||
|
TELEGRAM_BOT_TOKEN=... # Bot-Token von @BotFather
|
||||||
|
TELEGRAM_CHAT_ID=... # Autorisierte Chat-ID (Single-User)
|
||||||
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
OLLAMA_MODEL=qwen2.5-coder:7b # Coder-Modell für /shell und /code
|
||||||
|
OLLAMA_MODEL_FAST=gemma3:4b # Chat-Modell für Freitext
|
||||||
|
OLLAMA_NUM_CTX=16384 # Context-Window (4096-32768)
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1-... # Optional: kostenloser Cloud-Fallback
|
||||||
|
SNAPSHOT_DIR=/opt/clownfisch-snapshots
|
||||||
|
SNAPSHOT_METHOD=tar
|
||||||
|
INSTALL_DIR=/opt/clownfischserver
|
||||||
|
MGMT_USER=clownfisch # Linux-User (beliebig wählbar)
|
||||||
|
AIDER_WORKDIR=/opt/clownfisch-workspace
|
||||||
|
CLOWNFISCHSERVER_HOME_IP_DOMAIN=vorname.myfritz.net # Optional: myfritz für Auto-SSH-Freigabe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clownfischserver HomeIP Auto (neu in v0.5.0)
|
||||||
|
|
||||||
|
**Feature:** Automatische SSH-Freigabe für deine Home-IP alle 5 Minuten
|
||||||
|
|
||||||
|
- **myfritz.net:** DynDNS-Service (AVM Fritz.Box) – kostenlos
|
||||||
|
- **Cron-Job:** Läuft als `root` alle 5 Min
|
||||||
|
- **Workflow:**
|
||||||
|
1. DNS-Auflösung: `vorname.myfritz.net` → IP-Adresse
|
||||||
|
2. Vergleich: Alte IP vs. neue IP
|
||||||
|
3. Bei Änderung: UFW-Regel aktualisieren
|
||||||
|
4. Logging: `/var/log/clownfischserver-homeip.log`
|
||||||
|
|
||||||
|
**Voraussetzungen:**
|
||||||
|
- myfritz-Account bei https://myfritz.net/ (kostenlos)
|
||||||
|
- `dig` oder `nslookup` für DNS-Auflösung (meist bereits installiert)
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```bash
|
||||||
|
# Installation: wird vom install.sh gefragt
|
||||||
|
? myfritz Home-IP permanent erlauben? [j/n]
|
||||||
|
→ j
|
||||||
|
? Deine myfritz Adresse:
|
||||||
|
→ mehlzoerwer.myfritz.net
|
||||||
|
|
||||||
|
# Dann läuft:
|
||||||
|
*/5 * * * * /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh >> /var/log/clownfischserver-homeip.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.4.13-clownfischserver-opus.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.4.13-clownfischserver-opus.zip | head -30
|
||||||
|
# Muss zeigen: clownfischserver/bot/bot.py etc. (20 Dateien)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 4: Einspielen
|
||||||
|
**Per Telegram:** ZIP an den ClownfischServer Bot senden → Self-Update Dialog
|
||||||
|
**Per Server:** `bash install.sh` im entpackten Ordner
|
||||||
|
|
||||||
|
### Schritt 5: GitHub Push
|
||||||
|
```bash
|
||||||
|
cd clownfischserver
|
||||||
|
rm -rf .git
|
||||||
|
git init
|
||||||
|
git remote add origin https://github.com/mehlzoerwer-claude/clownfischserver.git
|
||||||
|
git add -A
|
||||||
|
git commit -m "v0.4.13 – Beschreibung der Änderungen"
|
||||||
|
git branch -M main
|
||||||
|
git push -u origin main --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dateiliste (20 Dateien, vollständig)
|
||||||
|
```
|
||||||
|
clownfischserver/
|
||||||
|
├── .gitignore
|
||||||
|
├── CLOWNFISCHSERVER.md ← diese Datei
|
||||||
|
├── CONTRIBUTING.md
|
||||||
|
├── GITHUB_PROFILE.md
|
||||||
|
├── LICENSE
|
||||||
|
├── README.md
|
||||||
|
├── install.sh
|
||||||
|
├── bot/
|
||||||
|
│ ├── aider_wrapper.py
|
||||||
|
│ ├── boot_notify.py
|
||||||
|
│ ├── bot.py
|
||||||
|
│ ├── knock_notify.py
|
||||||
|
│ ├── ollama_client.py
|
||||||
|
│ ├── openrouter_client.py
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ ├── shell.py
|
||||||
|
│ └── snapshot.py
|
||||||
|
├── config/
|
||||||
|
│ ├── .env.example
|
||||||
|
│ └── knockd.conf
|
||||||
|
└── systemd/
|
||||||
|
├── clownfisch-boot.service
|
||||||
|
└── clownfisch.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Versions-Bump Checkliste
|
||||||
|
Bei neuer Version diese Stellen aktualisieren:
|
||||||
|
1. `install.sh` – Banner + Log-Meldung (3x `v0.4.13`)
|
||||||
|
2. `bot/bot.py` – `cmd_start` Begrüßung + `main()` Log-Meldung
|
||||||
|
3. `README.md` – Badge `Version-0.4.13` + Roadmap "Fertig (v0.1 – v0.4.13)"
|
||||||
|
4. `CLOWNFISCHSERVER.md` – Header + Dateiliste
|
||||||
|
5. ZIP-Dateiname
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> *"Angefangen als einfacher Telegram-Bot – gewachsen zu einer Vision."* 🐠
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Contributing to Clownfischserver 🐠
|
||||||
|
|
||||||
|
Danke dass du mitmachen willst! Hier sind die Regeln:
|
||||||
|
|
||||||
|
## Grundprinzipien
|
||||||
|
|
||||||
|
- **Open Source bleibt Open Source** – GPL-3.0, keine Ausnahmen
|
||||||
|
- **Lokal first** – keine Cloud-Dependencies, keine externen APIs
|
||||||
|
- **Einfach bleiben** – ein Nutzer, ein Bot, ein Server
|
||||||
|
- **Sicherheit** – Safety-Check vor jeder Aktion
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
1. Fork das Repo
|
||||||
|
2. Branch erstellen: `git checkout -b feature/dein-feature`
|
||||||
|
3. Änderungen committen
|
||||||
|
4. PR öffnen mit Beschreibung was und warum
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Python: PEP8, async/await
|
||||||
|
- Bash: shellcheck-kompatibel
|
||||||
|
- Kommentare auf Deutsch oder Englisch
|
||||||
|
- Header in jeder Datei (siehe bestehende Dateien)
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
- Bug? → Issue mit Log-Output
|
||||||
|
- Feature-Idee? → Issue mit Use-Case Beschreibung
|
||||||
|
- Frage? → Issue oder Discussions
|
||||||
|
|
||||||
|
## Was wir suchen
|
||||||
|
|
||||||
|
- Distro-Support (getestete Fedora/Arch Fixes)
|
||||||
|
- Neue Bot-Commands
|
||||||
|
- Bessere Safety-Prompts
|
||||||
|
- Dokumentation
|
||||||
|
- Tests
|
||||||
|
|
||||||
|
## Was wir NICHT wollen
|
||||||
|
|
||||||
|
- Cloud-Dependencies
|
||||||
|
- Closed-Source Komponenten
|
||||||
|
- Komplexität die Einsteiger abschreckt
|
||||||
@@ -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,429 @@
|
|||||||
|
# 🐠 Clownfischserver
|
||||||
|
|
||||||
|
> *Wie der Clownfisch in seiner Anemone lebt – geschützt, autonom, und beißt überraschend hart.*
|
||||||
|
|
||||||
|
Ein selbst-hostbarer, KI-gestützter Server-Manager der ausschließlich über Telegram kommuniziert. Kein Dashboard, kein Web-UI, keine Cloud. Nur du und dein Server – über eine Chat-Nachricht.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐟 Warum "Clownfisch"?
|
||||||
|
|
||||||
|
Der Clownfisch lebt in seiner Anemone – einem der wenigen Orte im Ozean die für andere Fische tödlich sind. Er verlässt sie kaum, schützt sie aktiv, und fühlt sich darin vollkommen sicher.
|
||||||
|
|
||||||
|
Genauso lebt der Clownfischserver **in seinem Server** (seiner Anemone):
|
||||||
|
- Er schirmt alles ab was nicht sein muss
|
||||||
|
- SSH bleibt standardmäßig zu – nur wer das richtige Klopfmuster kennt kommt rein
|
||||||
|
- Die KI entscheidet was sicher ist und was nicht
|
||||||
|
- Und wie der echte Clownfisch: **beißt er überraschend hart wenn nötig** 🐠
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was ist das?
|
||||||
|
|
||||||
|
Clownfischserver verwandelt einen Linux-Server in einen vollautonomen, Telegram-gesteuerten Assistenten. Du schreibst ihm in natürlicher Sprache – er versteht, generiert Befehle, erstellt Scripts, und verwaltet deinen Server.
|
||||||
|
|
||||||
|
Das Ziel: **Server-Administration so einfach wie eine Chat-Nachricht.** Heute ein Server, morgen eine ganze Farm.
|
||||||
|
|
||||||
|
**Kein SCP. Kein Dashboard. Kein Cloud-Zwang. Nur Telegram.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🤖 **Telegram Bot** als primäres Interface – von überall, auch vom Handy
|
||||||
|
- 🧠 **Dual-Model KI** – Code-Modell (qwen2.5-coder) für Befehle + Chat-Modell (gemma3) für Konversation
|
||||||
|
- 🔧 **`/shell` + `/ja`** – KI generiert Shell-Befehle, du bestätigst vor der Ausführung
|
||||||
|
- 🛠️ **`/code` + Aider** – autonome Code- und Script-Generierung direkt auf dem Server
|
||||||
|
- 💬 **Natürlicher Chat** – Fragen stellen, Erklärungen bekommen, übersetzen lassen
|
||||||
|
- 📸 **Automatische Snapshots** vor destruktiven Aktionen (tar.gz mit Keep/Unkeep + Paketliste)
|
||||||
|
- 🔐 **Port-Knocking SSH** als sicherer Backup-Zugang (knockd) mit Telegram-Benachrichtigung
|
||||||
|
- 🔔 **Benachrichtigungen** – SSH-Zugang, Server-Boot, Knock-Versuche via Telegram
|
||||||
|
- 📦 **Self-Update via Telegram** – ZIP an den Bot schicken, fertig (inkl. systemd-Services)
|
||||||
|
- ☁️ **OpenRouter Fallback** – kostenlose Cloud-Modelle als Backup wenn Ollama nicht verfügbar
|
||||||
|
- 👤 **Eigener User** – beliebiger Name, passwordless sudo, wird überall korrekt eingesetzt
|
||||||
|
- 🌍 **Mehrsprachig** – antwortet in der Sprache in der du schreibst
|
||||||
|
- 🐧 **Multi-Distro** – Debian, Ubuntu, Fedora, Arch (Installer)
|
||||||
|
- 🔒 **DSGVO by design** – lokal ist Standard, Cloud ist Opt-in
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Frisches Ubuntu/Debian – als root:
|
||||||
|
wget https://github.com/mehlzoerwer-claude/clownfischserver/releases/latest/download/clownfischserver.zip
|
||||||
|
unzip clownfischserver.zip && cd clownfischserver
|
||||||
|
bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Installer führt dich durch alles – Schritt für Schritt, mit Erklärungen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Was du brauchst
|
||||||
|
|
||||||
|
- Frischer Ubuntu 22/24 oder Debian 12/13 VPS (min. 8GB RAM empfohlen, 20GB Disk)
|
||||||
|
- Root SSH-Zugang für die Installation
|
||||||
|
- Ein Telegram-Account (5 Minuten für Bot-Setup)
|
||||||
|
|
||||||
|
### RAM-Empfehlung
|
||||||
|
|
||||||
|
| RAM | Coder-Modell | Chat-Modell | Gesamt ca. |
|
||||||
|
|-----|-------------|-------------|------------|
|
||||||
|
| 8-12 GB | qwen2.5-coder:3b | gemma3:1b | ~3 GB |
|
||||||
|
| 16+ GB | qwen2.5-coder:7b | gemma3:4b | ~8 GB |
|
||||||
|
|
||||||
|
### Der Installer
|
||||||
|
|
||||||
|
```
|
||||||
|
[1/8] Systemcheck → RAM, Disk, Distro-Erkennung
|
||||||
|
[2/8] Pakete → alles Nötige mit Spinner
|
||||||
|
[3/8] User einrichten → eigener User oder vorhandenen nutzen
|
||||||
|
[4/8] SSH + Firewall → Port-Knocking, ufw, SSH bleibt offen
|
||||||
|
[5/8] Ollama → Dual-Model KI-Setup (Coder + Chat)
|
||||||
|
[6/8] Python + Aider → Bot-Umgebung + Code-Agent
|
||||||
|
[7/8] Telegram → geführtes Bot-Setup mit BotFather
|
||||||
|
[8/8] Service starten → systemd + Boot-Notification
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update (wenn bereits installiert)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash install.sh
|
||||||
|
# → erkennt laufenden Service
|
||||||
|
# → [1] Update [2] Neuinstallation [3] Abbrechen
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder noch einfacher: **ZIP direkt an den Bot schicken** – Self-Update!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nutzung
|
||||||
|
|
||||||
|
### Bot-Befehle
|
||||||
|
|
||||||
|
| Befehl | Beschreibung |
|
||||||
|
|--------|-------------|
|
||||||
|
| `/shell <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.4.13)
|
||||||
|
|
||||||
|
- [x] Basis-Installer mit geführtem Setup (8 Schritte)
|
||||||
|
- [x] Telegram Bot mit Auth Guard (Single-User)
|
||||||
|
- [x] Dual-Model KI: Coder (qwen2.5-coder) + Chat (gemma3/gemma4)
|
||||||
|
- [x] `/shell` + `/ja` – Befehlsgenerierung mit Bestätigung (non-interaktive Befehle)
|
||||||
|
- [x] `/code` – Aider Code-Agent für autonome Script-Erstellung (Smart Prompt)
|
||||||
|
- [x] `/run` – Direkte Shell ohne KI
|
||||||
|
- [x] `/status` – Systemübersicht (CPU, RAM, Disk, Prozesse)
|
||||||
|
- [x] `/ssh open/close` – ufw-basierter SSH-Zugang
|
||||||
|
- [x] Freitext-Chat mit History (letzte 10 Exchanges)
|
||||||
|
- [x] Snapshot-System (tar.gz, keep/unkeep, auto-cleanup max 20, Paketliste)
|
||||||
|
- [x] `/snapshot`, `/snapshots`, `/rollback` (inkl. Workspace)
|
||||||
|
- [x] Port-Knocking SSH (knockd) mit Telegram-Benachrichtigung
|
||||||
|
- [x] Boot-Notification nach Server-Neustart (Dienste-Status)
|
||||||
|
- [x] Self-Update via Telegram ZIP (inkl. systemd-Services + User-Patch)
|
||||||
|
- [x] Update-Modus im Installer (mit MGMT_USER aus .env)
|
||||||
|
- [x] Multi-Distro Installer (apt/dnf/pacman)
|
||||||
|
- [x] Ollama Warmup im Hintergrund (Bot sofort bereit)
|
||||||
|
- [x] **OpenRouter Free Fallback** – kostenlose Cloud-Modelle wenn Ollama nicht verfügbar
|
||||||
|
- [x] Interaktiver Update-Dialog (OpenRouter Key Eingabe via Telegram)
|
||||||
|
- [x] `OLLAMA_NUM_CTX` konfigurierbar (4K–32K, RAM-basierte Empfehlung im Installer)
|
||||||
|
- [x] Safe Telegram Output (Markdown-Fallback bei Parse-Fehlern)
|
||||||
|
- [x] Lange Antworten werden in Teile gesplittet (kein Abschneiden)
|
||||||
|
- [x] Mehrsprachig (antwortet in der Sprache des Nutzers)
|
||||||
|
- [x] MGMT_USER Regex-Patch (beliebiger Username funktioniert überall)
|
||||||
|
- [x] Security-Analyse dokumentiert (Schwachstellen + Maßnahmen in CLOWNFISCHSERVER.md)
|
||||||
|
- [x] DSGVO-Regel: `.env` nur per SSH, nie über Telegram lesbar
|
||||||
|
- [x] Projekt-Kontext-Datei (CLOWNFISCHSERVER.md) für KI-gestütztes Pair-Programming
|
||||||
|
|
||||||
|
### v0.5.0 – Geplant
|
||||||
|
|
||||||
|
**Sicherheit & DSGVO:**
|
||||||
|
- [ ] Sensitive Dateien über Telegram blockieren (`.env`, `shadow`, Keys)
|
||||||
|
- [ ] Output-Sanitizer: API Keys/Tokens in jeder Bot-Antwort maskieren
|
||||||
|
- [ ] Log-Sanitizer: Keine Secrets im Audit-Log
|
||||||
|
- [ ] Audit-Log: Jeder Befehl mit Timestamp (DSGVO Art. 5/32)
|
||||||
|
- [ ] Self-Update Hardening: SHA256-Hash anzeigen, Admin bestätigt
|
||||||
|
- [ ] `.env` Permissions-Check beim Bot-Start
|
||||||
|
- [ ] `bot.log`, `.aider.chat.history.md`, `bash_history` aus Snapshots excluden
|
||||||
|
- [ ] Rate-Limiting: Max 30 Befehle/Minute
|
||||||
|
- [ ] Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut nötig
|
||||||
|
- [ ] 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot)
|
||||||
|
- [ ] Persönlicher Code-Offset (SECURITY_OFFSET in `.env`)
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- [ ] Snapshot-Scheduling (`/snapshot schedule daily 03:00`)
|
||||||
|
- [ ] Snapshot-Export als ZIP via Telegram (`/snapshot export <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:2b")
|
||||||
|
TIMEOUT = 600 # 10 minutes for aider tasks on CPU
|
||||||
|
|
||||||
|
|
||||||
|
class AiderWrapper:
|
||||||
|
def __init__(self):
|
||||||
|
self.workdir = Path(AIDER_WORKDIR)
|
||||||
|
self.workdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.venv_python = Path(INSTALL_DIR) / "aider-venv" / "bin" / "python3"
|
||||||
|
self.venv_aider = Path(INSTALL_DIR) / "aider-venv" / "bin" / "aider"
|
||||||
|
logger.info(f"AiderWrapper init: workdir={self.workdir}")
|
||||||
|
|
||||||
|
async def run(self, task: str, target_file: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Run aider in script mode for a coding task.
|
||||||
|
Returns the output/result as string.
|
||||||
|
"""
|
||||||
|
logger.info(f"Aider task: {task[:100]}")
|
||||||
|
|
||||||
|
# Determine working directory and files
|
||||||
|
workdir = self.workdir
|
||||||
|
|
||||||
|
# Build aider command
|
||||||
|
cmd = self._build_aider_command(task, target_file)
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=str(workdir),
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
"OLLAMA_API_BASE": OLLAMA_BASE_URL,
|
||||||
|
"HOME": f"/home/{os.getenv('MGMT_USER', 'clownfish')}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
process.communicate(),
|
||||||
|
timeout=TIMEOUT
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
return f"⏱️ Aider Timeout nach {TIMEOUT}s. Task zu komplex oder Modell überlastet."
|
||||||
|
|
||||||
|
stdout_str = stdout.decode("utf-8", errors="replace").strip()
|
||||||
|
stderr_str = stderr.decode("utf-8", errors="replace").strip()
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
result_parts = []
|
||||||
|
|
||||||
|
if stdout_str:
|
||||||
|
result_parts.append(stdout_str)
|
||||||
|
|
||||||
|
if stderr_str and process.returncode != 0:
|
||||||
|
result_parts.append(f"[STDERR]\n{stderr_str}")
|
||||||
|
|
||||||
|
if process.returncode == 0:
|
||||||
|
result_parts.insert(0, f"✅ Aider Task abgeschlossen\nArbeitsverzeichnis: {workdir}\n")
|
||||||
|
else:
|
||||||
|
result_parts.insert(0, f"⚠️ Aider beendet mit Code {process.returncode}\n")
|
||||||
|
|
||||||
|
return "\n".join(result_parts) or "✓ Task abgeschlossen (keine Ausgabe)"
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return (
|
||||||
|
"❌ Aider nicht gefunden. Bitte prüfen:\n"
|
||||||
|
f" ls {self.venv_aider}\n"
|
||||||
|
" Ggf. neu installieren mit: pip install aider-chat"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Aider execution error: {e}", exc_info=True)
|
||||||
|
return f"❌ Aider Fehler: {e}"
|
||||||
|
|
||||||
|
def _build_aider_command(self, task: str, target_file: str = None) -> list:
|
||||||
|
"""Build the aider command with appropriate flags"""
|
||||||
|
cmd = [
|
||||||
|
str(self.venv_aider),
|
||||||
|
"--model", f"ollama/{OLLAMA_MODEL}",
|
||||||
|
"--no-git", # Don't require git
|
||||||
|
"--yes", # Auto-confirm all changes
|
||||||
|
"--no-pretty", # Plain output for telegram
|
||||||
|
"--no-show-model-warnings", # Skip env var warnings
|
||||||
|
"--no-analytics", # No analytics prompts
|
||||||
|
"--edit-format", "diff", # Better format for small models
|
||||||
|
"--message", task, # The task as a message
|
||||||
|
]
|
||||||
|
|
||||||
|
if target_file:
|
||||||
|
cmd.append(target_file)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
async def list_workspace_files(self) -> str:
|
||||||
|
"""List files in the aider workspace"""
|
||||||
|
try:
|
||||||
|
files = list(self.workdir.rglob("*"))
|
||||||
|
if not files:
|
||||||
|
return f"📁 Workspace {self.workdir} ist leer."
|
||||||
|
|
||||||
|
file_list = "\n".join([
|
||||||
|
f" {'📁' if f.is_dir() else '📄'} {f.relative_to(self.workdir)}"
|
||||||
|
for f in sorted(files)[:50]
|
||||||
|
])
|
||||||
|
return f"📁 Workspace: {self.workdir}\n\n{file_list}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"❌ Fehler beim Lesen des Workspace: {e}"
|
||||||
@@ -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()
|
||||||
+755
@@ -0,0 +1,755 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
🐠 Clownfischserver
|
||||||
|
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
|
||||||
|
License: GPL-3.0 – Keep it open. Always.
|
||||||
|
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import (
|
||||||
|
Application,
|
||||||
|
MessageHandler,
|
||||||
|
CommandHandler,
|
||||||
|
filters,
|
||||||
|
ContextTypes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Global Ollama ready flag
|
||||||
|
ollama_ready = None
|
||||||
|
|
||||||
|
# Load local modules
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from ollama_client import OllamaClient
|
||||||
|
from openrouter_client import OpenRouterClient
|
||||||
|
from shell import run_shell, execute_command
|
||||||
|
from snapshot import SnapshotManager, should_snapshot
|
||||||
|
from aider_wrapper import AiderWrapper
|
||||||
|
|
||||||
|
# --- Load env ---
|
||||||
|
_install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
|
||||||
|
load_dotenv(os.path.join(_install_dir, "config", ".env"))
|
||||||
|
|
||||||
|
# --- Logging ---
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
level=logging.INFO,
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(),
|
||||||
|
logging.FileHandler(os.path.join(_install_dir, "bot.log")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||||
|
CHAT_ID = str(os.getenv("TELEGRAM_CHAT_ID"))
|
||||||
|
|
||||||
|
if not BOT_TOKEN or not CHAT_ID:
|
||||||
|
logger.error("TELEGRAM_BOT_TOKEN oder TELEGRAM_CHAT_ID fehlt in .env!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# --- Init modules ---
|
||||||
|
openrouter = OpenRouterClient()
|
||||||
|
ollama = OllamaClient(openrouter_client=openrouter)
|
||||||
|
snapshots = SnapshotManager()
|
||||||
|
aider = AiderWrapper()
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AUTH GUARD + HELPERS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def is_authorized(update: Update) -> bool:
|
||||||
|
return str(update.effective_chat.id) == CHAT_ID
|
||||||
|
|
||||||
|
async def safe_reply(target, text: str, parse_mode: str = "Markdown", edit: bool = False):
|
||||||
|
"""Send or edit a message. Falls back to plain text if Markdown fails."""
|
||||||
|
try:
|
||||||
|
if edit:
|
||||||
|
return await target.edit_text(text, parse_mode=parse_mode)
|
||||||
|
return await target.reply_text(text, parse_mode=parse_mode)
|
||||||
|
except Exception:
|
||||||
|
plain = text.replace("`", "").replace("*", "").replace("_", "")
|
||||||
|
try:
|
||||||
|
if edit:
|
||||||
|
return await target.edit_text(plain)
|
||||||
|
return await target.reply_text(plain)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram send failed: {e}")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# COMMANDS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not is_authorized(update):
|
||||||
|
return
|
||||||
|
|
||||||
|
fallback_info = ""
|
||||||
|
if openrouter.is_available():
|
||||||
|
fallback_info = "\n✅ OpenRouter Fallback aktiv"
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"🐠 *Clownfischserver v0.4.13 online.*{fallback_info}\n\n"
|
||||||
|
"Schreib mir einfach – ich antworte als Chat.\n\n"
|
||||||
|
"*Befehle:*\n"
|
||||||
|
"• `/shell <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()
|
||||||
|
|
||||||
|
if workspace not in task and "/opt/" not in task and "/" not in task.split()[0] if task.split() else True:
|
||||||
|
file_match = re.search(r'(\S+\.(sh|py|js|ts|html|css|yaml|yml|json|conf|cfg|txt))', task)
|
||||||
|
if file_match:
|
||||||
|
filename = file_match.group(1)
|
||||||
|
filepath = f"{workspace}/{filename}"
|
||||||
|
else:
|
||||||
|
words = re.sub(r'[^a-z0-9 ]', '', task_lower).split()[:3]
|
||||||
|
slug = "-".join(words) if words else "script"
|
||||||
|
ext = ".py" if "python" in task_lower else ".sh"
|
||||||
|
filepath = f"{workspace}/{slug}{ext}"
|
||||||
|
|
||||||
|
task = f"erstelle die datei {filepath} mit folgendem inhalt: {task}"
|
||||||
|
logger.info(f"Code task enhanced: {task[:100]}")
|
||||||
|
|
||||||
|
status_msg = await update.message.reply_text("🛠️ Aider arbeitet...")
|
||||||
|
|
||||||
|
snap_name = snapshots.create_snapshot(label="pre-aider")
|
||||||
|
if snap_name:
|
||||||
|
await update.message.reply_text(f"📸 Snapshot: `{snap_name}`", parse_mode="Markdown")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await aider.run(task)
|
||||||
|
if len(result) > 4000:
|
||||||
|
result = result[:4000] + "\n... (gekürzt)"
|
||||||
|
await safe_reply(status_msg, f"🛠️ Aider Ergebnis:\n\n```\n{result}\n```", edit=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"cmd_code failed: {e}", exc_info=True)
|
||||||
|
await safe_reply(status_msg,
|
||||||
|
f"❌ Aider Fehler:\n`{e}`\n\nSnapshot: `{snap_name or 'keiner'}`",
|
||||||
|
edit=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FILE HANDLER – Self-Update via Telegram
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def handle_file(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Handle file uploads – ZIP = self-update"""
|
||||||
|
if not is_authorized(update):
|
||||||
|
return
|
||||||
|
|
||||||
|
doc = update.message.document
|
||||||
|
if not doc:
|
||||||
|
return
|
||||||
|
|
||||||
|
filename = doc.file_name or ""
|
||||||
|
|
||||||
|
if not filename.endswith(".zip"):
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"📎 Datei empfangen: {filename}\nNur .zip Dateien werden als Updates verarbeitet."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
status_msg = await update.message.reply_text(
|
||||||
|
f"📦 Update-Paket empfangen: `{filename}`\n⏳ Verarbeite...",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Snapshot before update
|
||||||
|
await status_msg.edit_text("📸 Erstelle Snapshot vor Update...")
|
||||||
|
snap_name = snapshots.create_snapshot(label="pre-update")
|
||||||
|
if snap_name:
|
||||||
|
await update.message.reply_text(f"📸 Snapshot: `{snap_name}`", parse_mode="Markdown")
|
||||||
|
|
||||||
|
# Download ZIP
|
||||||
|
await status_msg.edit_text("⬇️ Lade Datei herunter...")
|
||||||
|
tmp_dir = tempfile.mkdtemp()
|
||||||
|
zip_path = os.path.join(tmp_dir, filename)
|
||||||
|
tg_file = await context.bot.get_file(doc.file_id)
|
||||||
|
await tg_file.download_to_drive(zip_path)
|
||||||
|
|
||||||
|
# Extract
|
||||||
|
await status_msg.edit_text("📂 Entpacke ZIP...")
|
||||||
|
extract_dir = os.path.join(tmp_dir, "extracted")
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as z:
|
||||||
|
z.extractall(extract_dir)
|
||||||
|
|
||||||
|
# Find bot/ directory
|
||||||
|
bot_dir = None
|
||||||
|
for root, dirs, files in os.walk(extract_dir):
|
||||||
|
if "bot.py" in files:
|
||||||
|
bot_dir = root
|
||||||
|
break
|
||||||
|
|
||||||
|
if not bot_dir:
|
||||||
|
raise FileNotFoundError("Kein bot.py im ZIP gefunden!")
|
||||||
|
|
||||||
|
# Check if openrouter_client.py is in the update
|
||||||
|
has_openrouter = os.path.exists(os.path.join(bot_dir, "openrouter_client.py"))
|
||||||
|
|
||||||
|
if has_openrouter and not openrouter.is_available():
|
||||||
|
# New feature available – ask user if they want to set it up
|
||||||
|
context.user_data["update_tmp_dir"] = tmp_dir
|
||||||
|
context.user_data["update_bot_dir"] = bot_dir
|
||||||
|
context.user_data["update_extract_dir"] = extract_dir
|
||||||
|
context.user_data["update_step"] = "ask_openrouter"
|
||||||
|
|
||||||
|
await status_msg.edit_text(
|
||||||
|
"✅ Update-Paket gültig\n\n"
|
||||||
|
"🆕 *Neues Feature: OpenRouter Fallback*\n\n"
|
||||||
|
"Wenn Ollama mal zu langsam ist, kann der Bot\n"
|
||||||
|
"kostenlose Cloud-Modelle als Backup nutzen.\n"
|
||||||
|
"Kein Zwang, keine Kreditkarte nötig.\n\n"
|
||||||
|
"Möchtest du OpenRouter aktivieren?\n"
|
||||||
|
"Antworte: `ja` oder `nein`",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No OpenRouter setup needed – install directly
|
||||||
|
await _perform_update(update, context, tmp_dir, bot_dir, extract_dir)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Update fehlgeschlagen: {e}", exc_info=True)
|
||||||
|
await status_msg.edit_text(
|
||||||
|
f"❌ Update fehlgeschlagen:\n`{e}`\n\nSnapshot zum Rollback verfügbar.",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _perform_update(update, context, tmp_dir, bot_dir, extract_dir, openrouter_key=None):
|
||||||
|
"""Execute the actual update installation."""
|
||||||
|
try:
|
||||||
|
msg = await update.message.reply_text("📋 Kopiere Bot-Dateien...")
|
||||||
|
install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
|
||||||
|
install_bot_dir = os.path.join(install_dir, "bot")
|
||||||
|
mgmt_user = os.getenv("MGMT_USER", "clownfish")
|
||||||
|
|
||||||
|
for f in os.listdir(bot_dir):
|
||||||
|
if f.endswith(".py"):
|
||||||
|
src = os.path.join(bot_dir, f)
|
||||||
|
dst = os.path.join(install_bot_dir, f)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
os.chmod(dst, 0o644)
|
||||||
|
logger.info(f"Updated: {f}")
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", "chown", "-R", f"{mgmt_user}:{mgmt_user}", install_bot_dir],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy systemd services – patch User= with actual MGMT_USER
|
||||||
|
systemd_dir = None
|
||||||
|
for root, dirs, files in os.walk(extract_dir):
|
||||||
|
if any(f.endswith(".service") for f in files):
|
||||||
|
systemd_dir = root
|
||||||
|
break
|
||||||
|
|
||||||
|
if systemd_dir:
|
||||||
|
for f in os.listdir(systemd_dir):
|
||||||
|
if f.endswith(".service"):
|
||||||
|
src = os.path.join(systemd_dir, f)
|
||||||
|
with open(src, "r") as sf:
|
||||||
|
service_content = sf.read()
|
||||||
|
# Replace ANY User=/Group= value with actual MGMT_USER
|
||||||
|
service_content = re.sub(r'User=\S+', f'User={mgmt_user}', service_content)
|
||||||
|
service_content = re.sub(r'Group=\S+', f'Group={mgmt_user}', service_content)
|
||||||
|
tmp_svc = os.path.join(install_dir, f".tmp_{f}")
|
||||||
|
with open(tmp_svc, "w") as sf:
|
||||||
|
sf.write(service_content)
|
||||||
|
subprocess.run(["sudo", "cp", tmp_svc, f"/etc/systemd/system/{f}"], capture_output=True)
|
||||||
|
os.remove(tmp_svc)
|
||||||
|
logger.info(f"Service updated: {f} (User={mgmt_user})")
|
||||||
|
subprocess.run(["sudo", "systemctl", "daemon-reload"], capture_output=True)
|
||||||
|
subprocess.run(["sudo", "systemctl", "enable", "clownfisch-boot"], capture_output=True)
|
||||||
|
|
||||||
|
# Save OpenRouter key if provided
|
||||||
|
if openrouter_key:
|
||||||
|
env_file = os.path.join(install_dir, "config", ".env")
|
||||||
|
try:
|
||||||
|
with open(env_file, "r") as f:
|
||||||
|
env_content = f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
env_content = ""
|
||||||
|
|
||||||
|
if "OPENROUTER_API_KEY=" in env_content:
|
||||||
|
env_content = re.sub(r'OPENROUTER_API_KEY=.*', f'OPENROUTER_API_KEY={openrouter_key}', env_content)
|
||||||
|
else:
|
||||||
|
env_content += f"\nOPENROUTER_API_KEY={openrouter_key}\n"
|
||||||
|
|
||||||
|
with open(env_file, "w") as f:
|
||||||
|
f.write(env_content)
|
||||||
|
os.chmod(env_file, 0o600)
|
||||||
|
logger.info("OpenRouter Key in .env gespeichert")
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
# Clear update state
|
||||||
|
for key in ["update_tmp_dir", "update_bot_dir", "update_extract_dir", "update_step", "update_openrouter_key"]:
|
||||||
|
context.user_data.pop(key, None)
|
||||||
|
|
||||||
|
or_status = "\n✅ OpenRouter Fallback aktiviert" if openrouter_key else ""
|
||||||
|
await msg.edit_text(
|
||||||
|
f"✅ *Update installiert!*{or_status}\n\nBot startet in 3 Sekunden neu...",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
subprocess.Popen(["sudo", "systemctl", "restart", "clownfisch"])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Update execution failed: {e}", exc_info=True)
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"❌ Update fehlgeschlagen:\n`{e}`\n\nSnapshot zum Rollback verfügbar.",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
for key in ["update_tmp_dir", "update_bot_dir", "update_extract_dir", "update_step"]:
|
||||||
|
context.user_data.pop(key, None)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MAIN MESSAGE HANDLER – Chat + Update Dialog
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Normal messages: either handle update dialog or go to Ollama chat."""
|
||||||
|
if not is_authorized(update):
|
||||||
|
logger.warning(f"Unauthorized access attempt from chat_id: {update.effective_chat.id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_message = update.message.text.strip()
|
||||||
|
|
||||||
|
# --- Active update dialog? Handle that first, don't send to chat ---
|
||||||
|
update_step = context.user_data.get("update_step")
|
||||||
|
if update_step:
|
||||||
|
await _handle_update_dialog(update, context, user_message, update_step)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Normal chat ---
|
||||||
|
logger.info(f"Chat message: {user_message[:100]}")
|
||||||
|
|
||||||
|
if (ollama_ready is None or not ollama_ready.is_set()) and not openrouter.is_available():
|
||||||
|
await update.message.reply_text(
|
||||||
|
"⏳ Ollama lädt noch...\n\n"
|
||||||
|
"Direkte Befehle funktionieren sofort:\n"
|
||||||
|
"• /run <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.4.13 Bot startet...")
|
||||||
|
|
||||||
|
import requests as req
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
|
||||||
|
model = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b")
|
||||||
|
model_fast = os.getenv("OLLAMA_MODEL_FAST", "")
|
||||||
|
base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||||
|
|
||||||
|
global ollama_ready
|
||||||
|
ollama_ready = threading.Event()
|
||||||
|
|
||||||
|
def warmup_model(model_name, label):
|
||||||
|
for attempt in range(60):
|
||||||
|
try:
|
||||||
|
resp = req.post(f"{base_url}/api/chat", json={
|
||||||
|
"model": model_name,
|
||||||
|
"messages": [{"role": "user", "content": "hi"}],
|
||||||
|
"stream": False,
|
||||||
|
"keep_alive": -1,
|
||||||
|
}, timeout=150)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
logger.info(f"✓ {label} warm: {model_name}")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(3)
|
||||||
|
logger.warning(f"{label} nach 3 Minuten nicht bereit: {model_name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def warmup_ollama():
|
||||||
|
logger.info("Ollama Warmup gestartet (Hintergrund)...")
|
||||||
|
models_to_warm = [(model, "Hauptmodell")]
|
||||||
|
if model_fast and model_fast != model:
|
||||||
|
models_to_warm.insert(0, (model_fast, "Fast-Modell"))
|
||||||
|
logger.info(f"Dual-Model: fast={model_fast}, main={model}")
|
||||||
|
for m, label in models_to_warm:
|
||||||
|
warmup_model(m, label)
|
||||||
|
ollama_ready.set()
|
||||||
|
|
||||||
|
threading.Thread(target=warmup_ollama, daemon=True).start()
|
||||||
|
if model_fast and model_fast != model:
|
||||||
|
logger.info(f"🐠 Bot startet – 2 Modelle laden ({model_fast} + {model})")
|
||||||
|
else:
|
||||||
|
logger.info("🐠 Bot startet – Ollama lädt im Hintergrund.")
|
||||||
|
|
||||||
|
if openrouter.is_available():
|
||||||
|
logger.info("✅ OpenRouter Fallback aktiv")
|
||||||
|
|
||||||
|
app = Application.builder().token(BOT_TOKEN).build()
|
||||||
|
|
||||||
|
# Commands
|
||||||
|
app.add_handler(CommandHandler("start", cmd_start))
|
||||||
|
app.add_handler(CommandHandler("status", cmd_status))
|
||||||
|
app.add_handler(CommandHandler("snapshots", cmd_snapshots))
|
||||||
|
app.add_handler(CommandHandler("rollback", cmd_rollback))
|
||||||
|
app.add_handler(CommandHandler("help", cmd_help))
|
||||||
|
app.add_handler(CommandHandler("run", cmd_run))
|
||||||
|
app.add_handler(CommandHandler("snapshot", cmd_snapshot))
|
||||||
|
app.add_handler(CommandHandler("ssh", cmd_ssh))
|
||||||
|
app.add_handler(CommandHandler("shell", cmd_shell))
|
||||||
|
app.add_handler(CommandHandler("ja", cmd_ja))
|
||||||
|
app.add_handler(CommandHandler("code", cmd_code))
|
||||||
|
|
||||||
|
# Messages – single handler for chat AND update dialog (no double-processing)
|
||||||
|
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
|
||||||
|
app.add_handler(MessageHandler(filters.Document.ALL, handle_file))
|
||||||
|
|
||||||
|
logger.info(f"Bot läuft. Authorized Chat-ID: {CHAT_ID}")
|
||||||
|
app.run_polling(allowed_updates=Update.ALL_TYPES)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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(f"Ollama nicht verfügbar für /shell – trying OpenRouter fallback")
|
||||||
|
|
||||||
|
# 2. Fallback to OpenRouter
|
||||||
|
if self.openrouter and self.openrouter.is_available():
|
||||||
|
try:
|
||||||
|
logger.info("Using OpenRouter fallback for /shell...")
|
||||||
|
result = await self.openrouter.generate_shell_command(description)
|
||||||
|
logger.info(f"Shell generate (OpenRouter): {result}")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"OpenRouter fallback failed: {e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dangerous": False, "reason": "",
|
||||||
|
"command": "",
|
||||||
|
"_error": "Ollama Timeout und kein OpenRouter Fallback verfügbar.",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def explain_output(self, command: str, output: str, error: str = "") -> str:
|
||||||
|
"""Ask Ollama to explain/summarize command output"""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
content = f"Command: {command}\nOutput:\n{output}"
|
||||||
|
if error:
|
||||||
|
content += f"\nErrors:\n{error}"
|
||||||
|
content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer."
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self._chat_sync(
|
||||||
|
messages=[{"role": "user", "content": content}],
|
||||||
|
system=CHAT_SYSTEM_PROMPT,
|
||||||
|
think=False,
|
||||||
|
use_fast=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"explain_output failed: {e}")
|
||||||
|
# Try OpenRouter fallback
|
||||||
|
if self.openrouter and self.openrouter.is_available():
|
||||||
|
try:
|
||||||
|
return await self.openrouter.explain_output(command, output, error)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def chat(self, message: str, history: list = None) -> str:
|
||||||
|
"""General chat with context. Tries Ollama, falls back to OpenRouter."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
messages = list(history) if history else []
|
||||||
|
messages.append({"role": "user", "content": message})
|
||||||
|
|
||||||
|
# 1. Try local Ollama
|
||||||
|
try:
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self._chat_sync(
|
||||||
|
messages=messages,
|
||||||
|
system=CHAT_SYSTEM_PROMPT,
|
||||||
|
use_fast=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "Timeout" not in str(e) and "nicht erreichbar" not in str(e):
|
||||||
|
return f"Fehler: {e}"
|
||||||
|
logger.warning("Ollama chat nicht verfügbar – trying OpenRouter")
|
||||||
|
|
||||||
|
# 2. Fallback to OpenRouter
|
||||||
|
if self.openrouter and self.openrouter.is_available():
|
||||||
|
try:
|
||||||
|
logger.info("Using OpenRouter for chat...")
|
||||||
|
return await self.openrouter.chat(message, history)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Ollama und OpenRouter nicht verfügbar: {e}"
|
||||||
|
|
||||||
|
return (
|
||||||
|
"Ollama nicht verfügbar und kein OpenRouter Fallback konfiguriert.\n"
|
||||||
|
"Versuche es später oder konfiguriere OpenRouter in .env"
|
||||||
|
)
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
🐠 Clownfischserver – OpenRouter Free Models Fallback
|
||||||
|
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
|
||||||
|
License: GPL-3.0 – Keep it open. Always.
|
||||||
|
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import asyncio
|
||||||
|
import requests
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
||||||
|
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
|
# Free models on OpenRouter (no credit card needed)
|
||||||
|
# openrouter/free auto-selects the best available free model
|
||||||
|
FREE_MODELS = {
|
||||||
|
"coder": "openrouter/free",
|
||||||
|
"chat": "openrouter/free",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json(raw: str) -> dict:
|
||||||
|
"""Extract JSON from response."""
|
||||||
|
text = raw.strip()
|
||||||
|
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||||
|
text = re.sub(r"\s*```$", "", text)
|
||||||
|
text = text.strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
return json.loads(match.group())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise json.JSONDecodeError("No valid JSON found", text, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterClient:
|
||||||
|
"""OpenRouter Free Models Client – fallback when Ollama times out."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.api_key = OPENROUTER_API_KEY
|
||||||
|
self.base_url = OPENROUTER_BASE_URL
|
||||||
|
if self.api_key:
|
||||||
|
logger.info("OpenRouter Fallback konfiguriert")
|
||||||
|
else:
|
||||||
|
logger.info("OpenRouter nicht konfiguriert – Fallback deaktiviert")
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return bool(self.api_key)
|
||||||
|
|
||||||
|
def _chat_sync(self, messages: list, system: Optional[str] = None, model: str = None) -> str:
|
||||||
|
"""Synchronous OpenRouter API call."""
|
||||||
|
if not self.api_key:
|
||||||
|
raise RuntimeError("OpenRouter API Key nicht konfiguriert")
|
||||||
|
|
||||||
|
model = model or FREE_MODELS["chat"]
|
||||||
|
|
||||||
|
api_messages = []
|
||||||
|
if system:
|
||||||
|
api_messages.append({"role": "system", "content": system})
|
||||||
|
api_messages.extend(messages)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.base_url}/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"messages": api_messages,
|
||||||
|
"temperature": 0.7,
|
||||||
|
},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
raise RuntimeError("OpenRouter nicht erreichbar")
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
raise RuntimeError("OpenRouter Timeout")
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"OpenRouter Fehler: {e}")
|
||||||
|
|
||||||
|
async def generate_shell_command(self, description: str) -> dict:
|
||||||
|
"""Generate shell command via OpenRouter."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
"You are a Linux bash command generator for Ubuntu 24.04.\n"
|
||||||
|
"Convert the request into a single non-interactive bash command.\n"
|
||||||
|
"Respond ONLY with JSON: {\"dangerous\": bool, \"reason\": string, \"command\": string}\n"
|
||||||
|
"No interactive commands (nano, vim, crontab -e). Use sudo when needed.\n\n"
|
||||||
|
f"Request: {description}\n\nJSON only:"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self._chat_sync(
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
model=FREE_MODELS["coder"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result = _extract_json(raw)
|
||||||
|
if "command" not in result:
|
||||||
|
result["command"] = ""
|
||||||
|
if "dangerous" not in result:
|
||||||
|
result["dangerous"] = False
|
||||||
|
logger.info(f"OpenRouter shell: {result}")
|
||||||
|
return result
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"OpenRouter JSON error: {e}")
|
||||||
|
return {"dangerous": False, "reason": "", "command": ""}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"OpenRouter shell failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def chat(self, message: str, history: list = None) -> str:
|
||||||
|
"""General chat via OpenRouter."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
messages = list(history) if history else []
|
||||||
|
messages.append({"role": "user", "content": message})
|
||||||
|
|
||||||
|
system = (
|
||||||
|
"Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.\n"
|
||||||
|
"Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self._chat_sync(
|
||||||
|
messages=messages,
|
||||||
|
system=system,
|
||||||
|
model=FREE_MODELS["chat"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return f"OpenRouter Fehler: {e}"
|
||||||
|
|
||||||
|
async def explain_output(self, command: str, output: str, error: str = "") -> str:
|
||||||
|
"""Explain command output via OpenRouter."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
content = f"Command: {command}\nOutput:\n{output}"
|
||||||
|
if error:
|
||||||
|
content += f"\nErrors:\n{error}"
|
||||||
|
content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer."
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self._chat_sync(
|
||||||
|
messages=[{"role": "user", "content": content}],
|
||||||
|
model=FREE_MODELS["chat"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"OpenRouter explain failed: {e}")
|
||||||
|
return ""
|
||||||
@@ -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,31 @@
|
|||||||
|
# 🐠 Clownfischserver – Konfiguration
|
||||||
|
# Diese Datei wird automatisch durch install.sh erstellt.
|
||||||
|
# Niemals diese Datei committen oder teilen!
|
||||||
|
|
||||||
|
# Telegram
|
||||||
|
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrSTUvwxYZ
|
||||||
|
TELEGRAM_CHAT_ID=987654321
|
||||||
|
|
||||||
|
# Ollama
|
||||||
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
OLLAMA_MODEL=qwen2.5-coder:7b
|
||||||
|
OLLAMA_MODEL_FAST=gemma3:4b
|
||||||
|
OLLAMA_NUM_CTX=16384
|
||||||
|
|
||||||
|
# OpenRouter (optional – kostenloser Fallback bei Ollama-Timeout)
|
||||||
|
# API Key von openrouter.ai (kostenlos, keine Kreditkarte)
|
||||||
|
# OPENROUTER_API_KEY=sk-or-v1-...
|
||||||
|
|
||||||
|
# Snapshots
|
||||||
|
SNAPSHOT_DIR=/opt/clownfisch-snapshots
|
||||||
|
SNAPSHOT_METHOD=tar
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
INSTALL_DIR=/opt/clownfischserver
|
||||||
|
MGMT_USER=clownfish
|
||||||
|
AIDER_WORKDIR=/opt/clownfisch-workspace
|
||||||
|
|
||||||
|
# Clownfischserver HomeIP Automatik (optional)
|
||||||
|
# myfritz Adresse für automatische SSH-Freigabe von Zuhause
|
||||||
|
# Falls aktiviert: Cron-Job prüft alle 5 Min und aktualisiert ufw-Regeln
|
||||||
|
# CLOWNFISCHSERVER_HOME_IP_DOMAIN=vorname.myfritz.net
|
||||||
@@ -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
|
||||||
+944
@@ -0,0 +1,944 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# 🐠 CLOWNFISCHSERVER
|
||||||
|
# Version: see banner
|
||||||
|
# Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
|
||||||
|
# License: GPL-3.0 – Keep it open. Always.
|
||||||
|
# Repo: https://github.com/mehlzoerwer-claude/clownfischserver
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# GPL-3.0 License - Keep it open. Always.
|
||||||
|
# https://github.com/mehlzoerwer-claude/clownfischserver
|
||||||
|
|
||||||
|
# --- Colors ---
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# --- Defaults ---
|
||||||
|
INSTALL_DIR="/opt/clownfischserver"
|
||||||
|
SNAPSHOT_DIR="/opt/clownfisch-snapshots"
|
||||||
|
WORKSPACE_DIR="/opt/clownfisch-workspace"
|
||||||
|
LOG_FILE="/var/log/clownfischserver-install.log"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
MGMT_USER=""
|
||||||
|
PKG_MANAGER=""
|
||||||
|
DISTRO=""
|
||||||
|
OLLAMA_MODEL=""
|
||||||
|
SNAPSHOT_METHOD="tar"
|
||||||
|
OLLAMA_NUM_CTX=4096
|
||||||
|
|
||||||
|
# HELPERS
|
||||||
|
|
||||||
|
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG_FILE"; }
|
||||||
|
ok() { echo -e " ${GREEN}✓${NC} $1"; log "OK: $1"; }
|
||||||
|
info() { echo -e " ${CYAN}→${NC} $1"; log "INFO: $1"; }
|
||||||
|
warn() { echo -e " ${YELLOW}⚠${NC} $1"; log "WARN: $1"; }
|
||||||
|
fail() {
|
||||||
|
echo -e "\n ${RED}${BOLD}✗ FEHLER: $1${NC}"
|
||||||
|
echo -e " ${YELLOW}Mehr Details: tail -50 $LOG_FILE${NC}\n"
|
||||||
|
log "ERROR: $1"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
step() {
|
||||||
|
echo -e "\n${BOLD}${BLUE}[$1/$TOTAL_STEPS] $2${NC}"
|
||||||
|
log "=== STEP $1: $2 ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
ask() {
|
||||||
|
echo -e "\n ${YELLOW}?${NC} ${BOLD}$1${NC}"
|
||||||
|
if [[ -n "$2" ]]; then
|
||||||
|
echo -e " ${CYAN} $2${NC}"
|
||||||
|
fi
|
||||||
|
read -r -p " → " REPLY
|
||||||
|
echo "$REPLY"
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_yn() {
|
||||||
|
echo -e "\n ${YELLOW}?${NC} ${BOLD}$1${NC} ${CYAN}[j/n]${NC}"
|
||||||
|
read -r -p " → " REPLY
|
||||||
|
[[ "$REPLY" =~ ^[jJyY] ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
echo -e "\n ${CYAN}$1${NC}"
|
||||||
|
read -r -p " Enter zum Fortfahren..." _
|
||||||
|
}
|
||||||
|
|
||||||
|
TOTAL_STEPS=8
|
||||||
|
|
||||||
|
# PROGRESS + SPINNER
|
||||||
|
|
||||||
|
spinner() {
|
||||||
|
local pid=$1
|
||||||
|
local msg=$2
|
||||||
|
local spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
||||||
|
local i=0
|
||||||
|
tput civis 2>/dev/null
|
||||||
|
while kill -0 "$pid" 2>/dev/null; do
|
||||||
|
local c="${spin:$((i % ${#spin})):1}"
|
||||||
|
printf "\r \033[0;36m%s\033[0m %s..." "$c" "$msg"
|
||||||
|
sleep 0.1
|
||||||
|
((i++))
|
||||||
|
done
|
||||||
|
printf "\r\033[K"
|
||||||
|
tput cnorm 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
run_with_spinner() {
|
||||||
|
local msg=$1
|
||||||
|
shift
|
||||||
|
("$@" >> "$LOG_FILE" 2>&1) &
|
||||||
|
local pid=$!
|
||||||
|
spinner "$pid" "$msg"
|
||||||
|
wait "$pid"
|
||||||
|
return $?
|
||||||
|
}
|
||||||
|
|
||||||
|
print_banner() {
|
||||||
|
clear
|
||||||
|
echo -e "${CYAN}${BOLD}"
|
||||||
|
echo " ╔══════════════════════════════════════════════════════╗"
|
||||||
|
echo " ║ 🐠 CLOWNFISCHSERVER INSTALLER ║"
|
||||||
|
echo " ║ v0.4.13 | GPL-3.0 ║"
|
||||||
|
echo " ╚══════════════════════════════════════════════════════╝"
|
||||||
|
echo -e "${NC}"
|
||||||
|
echo -e " Willkommen! Dieses Script richtet deinen"
|
||||||
|
echo -e " Clownfischserver ein – Schritt für Schritt."
|
||||||
|
echo -e " Du wirst durch alles geführt.\n"
|
||||||
|
echo -e " ${YELLOW}Log-Datei: $LOG_FILE${NC}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# STEP 1: CHECKS
|
||||||
|
|
||||||
|
step_checks() {
|
||||||
|
step 1 "Systemcheck"
|
||||||
|
|
||||||
|
# Root check
|
||||||
|
[[ $EUID -ne 0 ]] && fail "Bitte als root ausführen:\n sudo bash install.sh"
|
||||||
|
ok "Root-Rechte vorhanden"
|
||||||
|
|
||||||
|
# Distro detection
|
||||||
|
if command -v apt &>/dev/null; then
|
||||||
|
PKG_MANAGER="apt"
|
||||||
|
DISTRO=$(grep -oP '(?<=^ID=).+' /etc/os-release | tr -d '"')
|
||||||
|
DISTRO_VERSION=$(grep -oP '(?<=^VERSION_ID=).+' /etc/os-release | tr -d '"')
|
||||||
|
ok "Distribution: $DISTRO $DISTRO_VERSION (apt)"
|
||||||
|
elif command -v dnf &>/dev/null; then
|
||||||
|
PKG_MANAGER="dnf"
|
||||||
|
DISTRO="fedora"
|
||||||
|
ok "Distribution: Fedora/RHEL (dnf)"
|
||||||
|
elif command -v pacman &>/dev/null; then
|
||||||
|
PKG_MANAGER="pacman"
|
||||||
|
DISTRO="arch"
|
||||||
|
ok "Distribution: Arch Linux (pacman)"
|
||||||
|
else
|
||||||
|
fail "Unbekannte Distribution – apt/dnf/pacman nicht gefunden."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# RAM check
|
||||||
|
RAM_MB=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)
|
||||||
|
info "RAM: ${RAM_MB}MB verfügbar"
|
||||||
|
if [[ "$RAM_MB" -lt 2000 ]]; then
|
||||||
|
warn "Weniger als 2GB RAM – Ollama könnte Probleme haben."
|
||||||
|
ask_yn "Trotzdem fortfahren?" || fail "Installation abgebrochen."
|
||||||
|
else
|
||||||
|
ok "RAM ausreichend"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Disk check
|
||||||
|
DISK_FREE=$(df -BG / | awk 'NR==2 {print $4}' | tr -d 'G')
|
||||||
|
info "Freier Speicher: ${DISK_FREE}GB"
|
||||||
|
[[ "$DISK_FREE" -lt 5 ]] && warn "Weniger als 5GB frei – Ollama-Modell braucht ~2GB."
|
||||||
|
ok "Systemcheck abgeschlossen"
|
||||||
|
}
|
||||||
|
|
||||||
|
# STEP 2: PAKETE INSTALLIEREN
|
||||||
|
|
||||||
|
step_packages() {
|
||||||
|
step 2 "Pakete installieren"
|
||||||
|
|
||||||
|
info "Paketliste wird aktualisiert..."
|
||||||
|
|
||||||
|
case "$PKG_MANAGER" in
|
||||||
|
apt)
|
||||||
|
run_with_spinner "Paketliste aktualisieren" apt-get update -qq || fail "apt update fehlgeschlagen"
|
||||||
|
run_with_spinner "Pakete installieren" apt-get install -y -qq curl wget git ufw python3 python3-pip python3-venv sudo htop net-tools lsof rsync openssl knockd || warn "Einige Pakete konnten nicht installiert werden – siehe Log"
|
||||||
|
;;
|
||||||
|
dnf)
|
||||||
|
run_with_spinner "Pakete aktualisieren" dnf update -y -q
|
||||||
|
run_with_spinner "Pakete installieren" dnf install -y -q curl wget git python3 python3-pip sudo htop net-tools rsync openssl
|
||||||
|
warn "knockd und ufw nicht verfügbar auf Fedora – firewalld wird genutzt"
|
||||||
|
;;
|
||||||
|
pacman)
|
||||||
|
run_with_spinner "System aktualisieren" pacman -Syu --noconfirm
|
||||||
|
run_with_spinner "Pakete installieren" pacman -S --noconfirm curl wget git python python-pip sudo htop net-tools rsync openssl
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
ok "Pakete installiert"
|
||||||
|
}
|
||||||
|
|
||||||
|
# STEP 3: BENUTZER EINRICHTEN
|
||||||
|
|
||||||
|
step_user() {
|
||||||
|
step 3 "Benutzer einrichten"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Welchen User soll der Clownfischserver nutzen?${NC}"
|
||||||
|
echo -e " ${CYAN} Dieser User verwaltet den Server und bekommt sudo-Rechte.${NC}"
|
||||||
|
echo -e " ${CYAN} Du kannst einen neuen anlegen oder einen vorhandenen nutzen.${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# List existing non-system users
|
||||||
|
EXISTING_USERS=$(awk -F: '$3>=1000 && $3<65534 {print $1}' /etc/passwd | tr '\n' ' ')
|
||||||
|
if [[ -n "$EXISTING_USERS" ]]; then
|
||||||
|
info "Vorhandene Benutzer: $EXISTING_USERS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e " ${YELLOW}?${NC} ${BOLD}Username (Enter = 'clownfish'):${NC}"
|
||||||
|
echo -e " ${CYAN} Neu oder vorhandenen Namen eingeben${NC}"
|
||||||
|
read -r -p " → " MGMT_USER
|
||||||
|
[[ -z "$MGMT_USER" ]] && MGMT_USER="clownfish"
|
||||||
|
|
||||||
|
if id "$MGMT_USER" &>/dev/null; then
|
||||||
|
warn "User '$MGMT_USER' existiert bereits – wird wiederverwendet."
|
||||||
|
else
|
||||||
|
useradd -m -s /bin/bash "$MGMT_USER" || fail "User konnte nicht angelegt werden"
|
||||||
|
ok "User '$MGMT_USER' angelegt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Passwordless sudo
|
||||||
|
echo "$MGMT_USER ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/clownfisch-$MGMT_USER"
|
||||||
|
chmod 0440 "/etc/sudoers.d/clownfisch-$MGMT_USER"
|
||||||
|
ok "Sudo-Rechte gesetzt (passwordless)"
|
||||||
|
|
||||||
|
# SSH dir
|
||||||
|
mkdir -p "/home/$MGMT_USER/.ssh"
|
||||||
|
chmod 700 "/home/$MGMT_USER/.ssh"
|
||||||
|
chown -R "$MGMT_USER:$MGMT_USER" "/home/$MGMT_USER"
|
||||||
|
ok "SSH-Verzeichnis angelegt"
|
||||||
|
|
||||||
|
# Always set a password as backup
|
||||||
|
TEMP_PASS=$(openssl rand -base64 12)
|
||||||
|
echo "$MGMT_USER:$TEMP_PASS" | chpasswd
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}${YELLOW}╔════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e " ${BOLD}${YELLOW}║ SSH Passwort für '$MGMT_USER': ║${NC}"
|
||||||
|
echo -e " ${BOLD}${YELLOW}║ ║${NC}"
|
||||||
|
echo -e " ${BOLD}${YELLOW}║ ${TEMP_PASS} ║${NC}"
|
||||||
|
echo -e " ${BOLD}${YELLOW}║ ║${NC}"
|
||||||
|
echo -e " ${BOLD}${YELLOW}║ Notiere es jetzt! SSH bleibt offen bis du ║${NC}"
|
||||||
|
echo -e " ${BOLD}${YELLOW}║ im Telegram-Bot /ssh close sendest. ║${NC}"
|
||||||
|
echo -e " ${BOLD}${YELLOW}╚════════════════════════════════════════════════════╝${NC}"
|
||||||
|
pause "Passwort notiert?"
|
||||||
|
}
|
||||||
|
|
||||||
|
# STEP 4: SSH + FIREWALL
|
||||||
|
|
||||||
|
step_ssh() {
|
||||||
|
step 4 "SSH + Firewall"
|
||||||
|
|
||||||
|
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
|
||||||
|
ok "sshd_config gesichert"
|
||||||
|
|
||||||
|
# SSH – nur gezielt aendern, bestehende Config behalten!
|
||||||
|
info "Passe sshd_config an (bestehende Einstellungen bleiben erhalten)..."
|
||||||
|
|
||||||
|
_sshd_set() {
|
||||||
|
local key="$1" val="$2"
|
||||||
|
if grep -qE "^#?\s*${key}" /etc/ssh/sshd_config; then
|
||||||
|
sed -i "s|^#\?\s*${key}.*|${key} ${val}|" /etc/ssh/sshd_config
|
||||||
|
else
|
||||||
|
echo "${key} ${val}" >> /etc/ssh/sshd_config
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_sshd_set "PasswordAuthentication" "yes"
|
||||||
|
_sshd_set "PubkeyAuthentication" "yes"
|
||||||
|
_sshd_set "X11Forwarding" "no"
|
||||||
|
_sshd_set "PermitRootLogin" "yes"
|
||||||
|
ok "sshd_config angepasst (nur relevante Zeilen geaendert)"
|
||||||
|
|
||||||
|
systemctl restart sshd >> "$LOG_FILE" 2>&1
|
||||||
|
ok "SSH läuft – Passwort + Key erlaubt"
|
||||||
|
info "SSH wird erst gesperrt wenn du im Bot /ssh close sendest"
|
||||||
|
|
||||||
|
# Home IP Auto-Allow via myfritz
|
||||||
|
CLOWNFISCHSERVER_HOME_IP_DOMAIN=""
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e " ${BOLD} myfritz Home-IP Automatik${NC}"
|
||||||
|
echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e " SSH von Zuhause immer offen – ohne Port-Knocking."
|
||||||
|
echo -e " ${CYAN} Clownfischserver aktualisiert deine Home-IP alle 5 Min automatisch.${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if ask_yn "myfritz Home-IP permanent erlauben?"; then
|
||||||
|
echo ""
|
||||||
|
echo -e " ${YELLOW}?${NC} ${BOLD}Deine myfritz Adresse:${NC}"
|
||||||
|
echo -e " ${CYAN} Format: vorname.myfritz.net${NC}"
|
||||||
|
read -r -p " → " CLOWNFISCHSERVER_HOME_IP_DOMAIN
|
||||||
|
|
||||||
|
if [[ -n "$CLOWNFISCHSERVER_HOME_IP_DOMAIN" ]]; then
|
||||||
|
ok "myfritz Adresse gespeichert: $CLOWNFISCHSERVER_HOME_IP_DOMAIN"
|
||||||
|
info "⏱ Clownfischserver-HomeIP Updater wird alle 5 Min gestartet"
|
||||||
|
else
|
||||||
|
warn "Leere Adresse – myfritz Automatik deaktiviert"
|
||||||
|
CLOWNFISCHSERVER_HOME_IP_DOMAIN=""
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "myfritz Automatik deaktiviert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Firewall
|
||||||
|
case "$PKG_MANAGER" in
|
||||||
|
apt)
|
||||||
|
ufw default deny incoming >> "$LOG_FILE" 2>&1
|
||||||
|
ufw default allow outgoing >> "$LOG_FILE" 2>&1
|
||||||
|
ufw allow 22/tcp >> "$LOG_FILE" 2>&1
|
||||||
|
echo "y" | ufw enable >> "$LOG_FILE" 2>&1
|
||||||
|
ok "ufw Firewall aktiv – Port 22 offen"
|
||||||
|
|
||||||
|
# knockd
|
||||||
|
IFACE=$(ip route | grep default | awk '{print $5}' | head -1)
|
||||||
|
NOTIFY_CMD="$INSTALL_DIR/venv/bin/python3 $INSTALL_DIR/bot/knock_notify.py"
|
||||||
|
|
||||||
|
# knockd – nur clownfisch-Sektionen schreiben, bestehende Config behalten
|
||||||
|
if [ -f /etc/knockd.conf ]; then
|
||||||
|
# Entferne nur unsere alten Sektionen falls vorhanden
|
||||||
|
sed -i '/^\[openSSH\]/,/^$/d' /etc/knockd.conf 2>/dev/null || true
|
||||||
|
sed -i '/^\[closeSSH\]/,/^$/d' /etc/knockd.conf 2>/dev/null || true
|
||||||
|
# Interface in [options] setzen falls vorhanden
|
||||||
|
if grep -q "^\[options\]" /etc/knockd.conf; then
|
||||||
|
grep -q "Interface" /etc/knockd.conf || sed -i "/^\[options\]/a\ Interface = $IFACE" /etc/knockd.conf
|
||||||
|
else
|
||||||
|
echo -e "[options]
|
||||||
|
UseSyslog
|
||||||
|
Interface = $IFACE
|
||||||
|
" >> /etc/knockd.conf
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Neue Config anlegen
|
||||||
|
cat > /etc/knockd.conf << KNOCKEOF
|
||||||
|
[options]
|
||||||
|
UseSyslog
|
||||||
|
Interface = $IFACE
|
||||||
|
KNOCKEOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Unsere SSH-Sektionen hinzufuegen
|
||||||
|
cat >> /etc/knockd.conf << KNOCKEOF
|
||||||
|
|
||||||
|
[openSSH]
|
||||||
|
sequence = 7000,8000,9000
|
||||||
|
seq_timeout = 10
|
||||||
|
command = /sbin/iptables -I INPUT -s %IP% -p tcp --dport 22 -j ACCEPT && $NOTIFY_CMD open %IP%
|
||||||
|
tcpflags = syn
|
||||||
|
|
||||||
|
[closeSSH]
|
||||||
|
sequence = 9000,8000,7000
|
||||||
|
seq_timeout = 10
|
||||||
|
command = /sbin/iptables -D INPUT -s %IP% -p tcp --dport 22 -j ACCEPT && $NOTIFY_CMD close %IP%
|
||||||
|
tcpflags = syn
|
||||||
|
KNOCKEOF
|
||||||
|
[ -f /etc/default/knockd ] && sed -i 's/START_KNOCKD=0/START_KNOCKD=1/' /etc/default/knockd
|
||||||
|
systemctl enable knockd >> "$LOG_FILE" 2>&1
|
||||||
|
systemctl restart knockd >> "$LOG_FILE" 2>&1 && ok "Port-Knocking aktiv (7000→8000→9000)" || warn "knockd konnte nicht starten"
|
||||||
|
;;
|
||||||
|
dnf)
|
||||||
|
systemctl enable firewalld >> "$LOG_FILE" 2>&1
|
||||||
|
systemctl start firewalld >> "$LOG_FILE" 2>&1
|
||||||
|
firewall-cmd --permanent --add-service=ssh >> "$LOG_FILE" 2>&1
|
||||||
|
firewall-cmd --reload >> "$LOG_FILE" 2>&1
|
||||||
|
ok "firewalld aktiv – SSH offen"
|
||||||
|
;;
|
||||||
|
pacman)
|
||||||
|
warn "Bitte Firewall manuell einrichten (iptables/nftables)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# HELPER: Pull an Ollama model with progress bar
|
||||||
|
|
||||||
|
_pull_model() {
|
||||||
|
local model_name="$1"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYAN}Lade Modell: ${BOLD}$model_name${NC}"
|
||||||
|
echo -e " ${CYAN}Das kann je nach Internetgeschwindigkeit 2-10 Minuten dauern.${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
tput civis 2>/dev/null
|
||||||
|
ollama pull "$model_name" 2>&1 | while IFS= read -r line; do
|
||||||
|
if [[ "$line" =~ ([0-9]+)% ]]; then
|
||||||
|
pct="${BASH_REMATCH[1]}"
|
||||||
|
filled=$(( pct * 30 / 100 ))
|
||||||
|
empty=$(( 30 - filled ))
|
||||||
|
bar=""
|
||||||
|
for ((i=0; i<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=$CLOWNFISCHSERVER_HOME_IP_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"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
# Clownfischserver HomeIP Updater (wenn aktiviert)
|
||||||
|
if [[ -n "$CLOWNFISCHSERVER_HOME_IP_DOMAIN" ]]; then
|
||||||
|
mkdir -p "$INSTALL_DIR/scripts"
|
||||||
|
cp "$SCRIPT_DIR/scripts/clownfischserver-homeip-updater.sh" "$INSTALL_DIR/scripts/" 2>/dev/null || \
|
||||||
|
warn "homeip-updater.sh nicht gefunden – manuell nachträglich einrichten"
|
||||||
|
|
||||||
|
if [ -f "$INSTALL_DIR/scripts/clownfischserver-homeip-updater.sh" ]; then
|
||||||
|
chmod +x "$INSTALL_DIR/scripts/clownfischserver-homeip-updater.sh"
|
||||||
|
chown root:root "$INSTALL_DIR/scripts/clownfischserver-homeip-updater.sh"
|
||||||
|
|
||||||
|
# Cron-Job für root (da ufw sudo braucht)
|
||||||
|
CRON_JOB="*/5 * * * * $INSTALL_DIR/scripts/clownfischserver-homeip-updater.sh >> /var/log/clownfischserver-homeip.log 2>&1"
|
||||||
|
|
||||||
|
# Bestehende clownfischserver Cron-Jobs entfernen
|
||||||
|
sudo crontab -l 2>/dev/null | grep -v clownfischserver-homeip-updater | sudo crontab - 2>/dev/null || true
|
||||||
|
|
||||||
|
# Neuen Job hinzufügen
|
||||||
|
(sudo crontab -l 2>/dev/null; echo "$CRON_JOB") | sudo crontab - >> "$LOG_FILE" 2>&1
|
||||||
|
|
||||||
|
ok "Cron-Job aktiviert: alle 5 Min HomeIP prüfen"
|
||||||
|
info "Log: /var/log/clownfischserver-homeip.log"
|
||||||
|
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.4.13 |"
|
||||||
|
echo " +======================================================+"
|
||||||
|
echo -e "${NC}"
|
||||||
|
echo -e " ${BOLD}Server:${NC} $SERVER_IP"
|
||||||
|
echo -e " ${BOLD}SSH User:${NC} $MGMT_USER (Passwort aktiv!)"
|
||||||
|
echo -e " ${BOLD}KI-Modell:${NC} $OLLAMA_MODEL"
|
||||||
|
echo -e " ${BOLD}Snapshots:${NC} $SNAPSHOT_DIR ($SNAPSHOT_METHOD)"
|
||||||
|
|
||||||
|
if [[ -n "$CLOWNFISCHSERVER_HOME_IP_DOMAIN" ]]; then
|
||||||
|
echo -e " ${BOLD}${GREEN}HomeIP Auto:${NC} $CLOWNFISCHSERVER_HOME_IP_DOMAIN (alle 5 Min aktualisiert)"
|
||||||
|
else
|
||||||
|
echo -e " ${BOLD}HomeIP Auto:${NC} nicht aktiviert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
if [[ -n "$CLOWNFISCHSERVER_HOME_IP_DOMAIN" ]]; then
|
||||||
|
echo -e " ${GREEN}${BOLD}🐠 Clownfischserver-HomeIP Status:${NC}"
|
||||||
|
echo -e " ${GREEN} Cron-Job aktiv: /var/log/clownfischserver-homeip.log${NC}"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e " ${YELLOW}Install-Log (letzte 10 Zeilen):${NC}"
|
||||||
|
tail -10 "$LOG_FILE"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# MAIN
|
||||||
|
|
||||||
|
update_bot() {
|
||||||
|
echo -e "
|
||||||
|
${BOLD}${CYAN}🐠 Clownfischserver Update-Modus${NC}
|
||||||
|
"
|
||||||
|
log "=== Update gestartet ==="
|
||||||
|
|
||||||
|
# Read MGMT_USER from .env if not set
|
||||||
|
if [[ -z "$MGMT_USER" ]] && [[ -f "$INSTALL_DIR/config/.env" ]]; then
|
||||||
|
MGMT_USER=$(grep "^MGMT_USER=" "$INSTALL_DIR/config/.env" | cut -d= -f2)
|
||||||
|
fi
|
||||||
|
[[ -z "$MGMT_USER" ]] && MGMT_USER="clownfish"
|
||||||
|
|
||||||
|
# Copy new bot files
|
||||||
|
cp -r "$SCRIPT_DIR/bot/"* "$INSTALL_DIR/bot/"
|
||||||
|
chown -R "$MGMT_USER:$MGMT_USER" "$INSTALL_DIR/bot"
|
||||||
|
ok "Bot-Dateien aktualisiert"
|
||||||
|
|
||||||
|
# Update systemd services – patch hardcoded user with actual MGMT_USER
|
||||||
|
for svc in clownfisch.service clownfisch-boot.service; do
|
||||||
|
if [ -f "$SCRIPT_DIR/systemd/$svc" ]; then
|
||||||
|
sed "s/^User=.*/User=$MGMT_USER/;s/^Group=.*/Group=$MGMT_USER/" \
|
||||||
|
"$SCRIPT_DIR/systemd/$svc" > "/etc/systemd/system/$svc"
|
||||||
|
[[ "$svc" == "clownfisch-boot.service" ]] && systemctl enable clownfisch-boot >> "$LOG_FILE" 2>&1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
systemctl daemon-reload >> "$LOG_FILE" 2>&1
|
||||||
|
ok "Systemd Services aktualisiert (User=$MGMT_USER)"
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
systemctl restart clownfisch >> "$LOG_FILE" 2>&1
|
||||||
|
sleep 3
|
||||||
|
if systemctl is-active --quiet clownfisch; then
|
||||||
|
ok "Bot neu gestartet – Update abgeschlossen!"
|
||||||
|
else
|
||||||
|
warn "Bot-Status unklar: journalctl -u clownfisch -n 20 --no-pager"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYAN}Logs: journalctl -u clownfisch -f${NC}"
|
||||||
|
echo ""
|
||||||
|
log "=== Update abgeschlossen ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
print_banner
|
||||||
|
touch "$LOG_FILE"
|
||||||
|
|
||||||
|
# Update mode: service already running
|
||||||
|
if systemctl is-active --quiet clownfisch 2>/dev/null; then
|
||||||
|
echo -e " ${YELLOW}⚠${NC} Clownfischserver läuft bereits!"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYAN}[1]${NC} Update (Bot-Dateien + Restart)"
|
||||||
|
echo -e " ${CYAN}[2]${NC} Neuinstallation (alles neu)"
|
||||||
|
echo -e " ${CYAN}[3]${NC} Abbrechen"
|
||||||
|
echo ""
|
||||||
|
read -r -p " → " CHOICE
|
||||||
|
case "$CHOICE" in
|
||||||
|
1) update_bot; exit 0 ;;
|
||||||
|
2) echo "" ;;
|
||||||
|
*) echo "Abgebrochen."; exit 0 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "=== Clownfischserver v0.4.13 Installation gestartet ==="
|
||||||
|
step_checks
|
||||||
|
step_packages
|
||||||
|
step_user
|
||||||
|
step_ssh
|
||||||
|
step_ollama
|
||||||
|
step_python
|
||||||
|
step_telegram
|
||||||
|
step_service
|
||||||
|
print_summary
|
||||||
|
log "=== Installation abgeschlossen ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# 🐠 Clownfischserver HomeIP Auto-Update
|
||||||
|
|
||||||
|
Automatische SSH-Freigabe für deine Home-IP – ohne Port-Knocking.
|
||||||
|
|
||||||
|
## Wie es funktioniert
|
||||||
|
|
||||||
|
1. **Installation:** `install.sh` fragt nach deiner myfritz-Adresse
|
||||||
|
2. **Cron-Job:** Läuft alle 5 Minuten (root crontab)
|
||||||
|
3. **DNS-Auflösung:** Löst `vorname.myfritz.net` auf
|
||||||
|
4. **UFW-Update:** Aktualisiert die Firewall-Regel wenn IP sich ändert
|
||||||
|
|
||||||
|
## Setup während Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
[4/8] SSH + Firewall
|
||||||
|
|
||||||
|
? myfritz Home-IP permanent erlauben? [j/n]
|
||||||
|
→ j
|
||||||
|
|
||||||
|
? Deine myfritz Adresse:
|
||||||
|
Format: vorname.myfritz.net
|
||||||
|
→ mehlzoerwer.myfritz.net
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Script wird dann automatisch:
|
||||||
|
- In `/opt/clownfischserver/scripts/` installiert
|
||||||
|
- Executable gemacht
|
||||||
|
- In `root`-Crontab als 5-Minuten-Job eingetragen
|
||||||
|
|
||||||
|
## Manuelle Verwaltung
|
||||||
|
|
||||||
|
### Script manuell ausführen
|
||||||
|
```bash
|
||||||
|
sudo /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron-Job prüfen
|
||||||
|
```bash
|
||||||
|
sudo crontab -l | grep clownfischserver-homeip
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs anschauen
|
||||||
|
```bash
|
||||||
|
tail -f /var/log/clownfischserver-homeip.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manuell aktivieren/deaktivieren
|
||||||
|
|
||||||
|
**Aktivieren** (wenn nicht aktiviert während Installation):
|
||||||
|
```bash
|
||||||
|
# myfritz-Adresse in .env eintragen
|
||||||
|
sudo nano /opt/clownfischserver/config/.env
|
||||||
|
# CLOWNFISCHSERVER_HOME_IP_DOMAIN=vorname.myfritz.net
|
||||||
|
|
||||||
|
# Cron-Job hinzufügen
|
||||||
|
(sudo crontab -l 2>/dev/null; echo "*/5 * * * * /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh >> /var/log/clownfischserver-homeip.log 2>&1") | sudo crontab -
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deaktivieren:**
|
||||||
|
```bash
|
||||||
|
# Aus .env entfernen
|
||||||
|
sudo nano /opt/clownfischserver/config/.env
|
||||||
|
# CLOWNFISCHSERVER_HOME_IP_DOMAIN= (leer lassen)
|
||||||
|
|
||||||
|
# Cron-Job entfernen
|
||||||
|
sudo crontab -l 2>/dev/null | grep -v clownfischserver-homeip | sudo crontab -
|
||||||
|
|
||||||
|
# UFW-Regeln cleanup (optional)
|
||||||
|
sudo ufw status | grep "clownfischserver-homeip"
|
||||||
|
sudo ufw delete allow from [IP] to any port 22
|
||||||
|
```
|
||||||
|
|
||||||
|
## Was die myfritz-Adresse ist
|
||||||
|
|
||||||
|
**myfritz** ist ein DynDNS-Service der AVM (Fritz.Box Hersteller):
|
||||||
|
- `https://myfritz.net/`
|
||||||
|
- Deine Home-IP bekommst darin immer einen stabilen Hostnamen
|
||||||
|
- Auch wenn deine DSL-IP sich ändert, bleibt `vorname.myfritz.net` gleich
|
||||||
|
- ✅ Das Script prüft alle 5 Min und aktualisiert die UFW-Regel automatisch
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- **UFW-Kommentar:** `clownfischserver-homeip-vorname.myfritz.net`
|
||||||
|
- Zeigt in `ufw status` an woher die Regel kommt
|
||||||
|
|
||||||
|
- **Logging:**
|
||||||
|
- `/var/log/clownfischserver-homeip.log` – vollständiger Audit-Trail
|
||||||
|
- Timestamp + alte IP + neue IP + Aktion
|
||||||
|
|
||||||
|
- **State-File:**
|
||||||
|
- `/var/lib/clownfischserver-homeip-state` – speichert die letzte bekannte IP
|
||||||
|
- Verhindert unnötige UFW-Updates bei gleicher IP
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Cron-Job läuft nicht
|
||||||
|
```bash
|
||||||
|
# Status prüfen
|
||||||
|
sudo crontab -l | grep clownfischserver-homeip
|
||||||
|
|
||||||
|
# Logs prüfen
|
||||||
|
sudo tail -20 /var/log/clownfischserver-homeip.log
|
||||||
|
|
||||||
|
# Manuell testen
|
||||||
|
sudo /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### DNS-Auflösung schlägt fehl
|
||||||
|
```bash
|
||||||
|
# myfritz-Adresse testen
|
||||||
|
dig vorname.myfritz.net
|
||||||
|
|
||||||
|
# Oder mit nslookup
|
||||||
|
nslookup vorname.myfritz.net
|
||||||
|
|
||||||
|
# In .env prüfen
|
||||||
|
grep CLOWNFISCHSERVER_HOME_IP /opt/clownfischserver/config/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### UFW-Regel wird nicht hinzugefügt
|
||||||
|
```bash
|
||||||
|
# UFW Status prüfen
|
||||||
|
sudo ufw status
|
||||||
|
|
||||||
|
# Manuelle Regel hinzufügen (Debugging)
|
||||||
|
sudo ufw allow from 1.2.3.4 to any port 22 comment "clownfischserver-homeip-test"
|
||||||
|
|
||||||
|
# Logs prüfen
|
||||||
|
sudo journalctl -u ufw -n 20
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Script-Info:** Clownfischserver v0.5.0+ | GPL-3.0
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# 🐠 Clownfischserver HomeIP Updater
|
||||||
|
# Version: v0.5.0
|
||||||
|
# Purpose: Automatisch die Home-IP aktualisieren (alle 5 Min via Cron)
|
||||||
|
# Author: Mehlzoerwer-Claude
|
||||||
|
# License: GPL-3.0
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Konfiguration
|
||||||
|
INSTALL_DIR="/opt/clownfischserver"
|
||||||
|
ENV_FILE="$INSTALL_DIR/config/.env"
|
||||||
|
STATE_FILE="/var/lib/clownfischserver-homeip-state"
|
||||||
|
LOG_FILE="/var/log/clownfischserver-homeip.log"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >> "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prüfe ob .env existiert
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
log_error ".env nicht gefunden: $ENV_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Lade myfritz-Adresse aus .env
|
||||||
|
MYFRITZ_DOMAIN=$(grep "^CLOWNFISCHSERVER_HOME_IP_DOMAIN=" "$ENV_FILE" | cut -d= -f2)
|
||||||
|
|
||||||
|
if [[ -z "$MYFRITZ_DOMAIN" ]]; then
|
||||||
|
log "Clownfischserver HomeIP: Deaktiviert (keine myfritz-Adresse)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Versuche IP aufzulösen
|
||||||
|
NEW_IP=$(dig +short "$MYFRITZ_DOMAIN" A 2>/dev/null | tail -1 | grep -oE '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b')
|
||||||
|
|
||||||
|
if [[ -z "$NEW_IP" ]]; then
|
||||||
|
log_error "DNS-Auflösung fehlgeschlagen: $MYFRITZ_DOMAIN"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Lese alte IP aus State-File
|
||||||
|
OLD_IP=""
|
||||||
|
if [[ -f "$STATE_FILE" ]]; then
|
||||||
|
OLD_IP=$(cat "$STATE_FILE")
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Domain: $MYFRITZ_DOMAIN | Neue IP: $NEW_IP | Alte IP: ${OLD_IP:-keine}"
|
||||||
|
|
||||||
|
# Wenn IP sich nicht geändert hat → nichts tun
|
||||||
|
if [[ "$NEW_IP" == "$OLD_IP" ]] && [[ -n "$OLD_IP" ]]; then
|
||||||
|
log "IP unverändert – keine Aktion nötig"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# IP hat sich geändert oder ist neue Installation
|
||||||
|
log "IP-Änderung erkannt! Updating ufw rules..."
|
||||||
|
|
||||||
|
# Entferne alte Regel falls vorhanden
|
||||||
|
if [[ -n "$OLD_IP" ]]; then
|
||||||
|
if ufw status | grep -q "22/tcp.*ALLOW.*$OLD_IP"; then
|
||||||
|
ufw delete allow from "$OLD_IP" to any port 22 >> "$LOG_FILE" 2>&1
|
||||||
|
log "Alte Regel entfernt: allow from $OLD_IP to port 22"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Füge neue Regel hinzu
|
||||||
|
if ufw allow from "$NEW_IP" to any port 22 comment "clownfischserver-homeip-$MYFRITZ_DOMAIN" >> "$LOG_FILE" 2>&1; then
|
||||||
|
log "Neue Regel hinzugefügt: allow from $NEW_IP to port 22"
|
||||||
|
else
|
||||||
|
log_error "ufw Regel konnte nicht hinzugefügt werden"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Speichere neue IP
|
||||||
|
echo "$NEW_IP" > "$STATE_FILE"
|
||||||
|
chmod 644 "$STATE_FILE"
|
||||||
|
|
||||||
|
log "✓ HomeIP Update erfolgreich: $MYFRITZ_DOMAIN → $NEW_IP"
|
||||||
|
exit 0
|
||||||
@@ -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