Author SHA1 Message Date
mehlzoerwerandClaude Haiku 4.5 5c1f56a60e Docs: CLAUDE.md für zukünftige Claude-Instanzen
Context für nachfolgende Entwicklung:
- Architektur & Multi-Distro/Firewall-Patterns
- Dateistruktur & Responsibilities
- Critical Design Decisions (iptables Persistierung, .env Parsing)
- Development Workflow (Ubuntu/Fedora/Arch Testing)
- Git Policies (4 pushes/hour)
- Code Patterns & Gotchas
- Quick Reference Commands

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 00:00:50 +02:00
mehlzoerwerandClaude Haiku 4.5 796aeead06 Feature: Multi-Distro Firewall-Support für HomeIP Updater
Improvement:
- homeip-updater.sh: Firewall-Autodetection (ufw/firewalld/iptables)
- README-HOMEIP.md: Firewall-Support Tabelle + technische Details
- install.sh: dnsutils zu apt-Paketen hinzugefügt

Multi-Distro kompatibel:
  ✅ Ubuntu/Debian (ufw)
  ✅ Fedora/RHEL (firewalld)
  ✅ Arch/Manjaro (iptables)
  ✅ openSUSE (firewalld)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-01 23:58:13 +02:00
mehlzoerwerandClaude Haiku 4.5 4758ee4306 Bugfix: Clownfischserver HomeIP Updater – Fehlerbehandlung & Fallbacks
Fixes:
- dnsutils-Paket (dig) zu install.sh hinzugefügt
- DNS-Fallbacks: dig → nslookup → getent
- Log-File Auto-Create bei Cron-Ausführung
- Zeilenumbrüche in .env-Variable entfernen (tr -d)
- UFW delete mit 'y' Auto-Bestätigung (für Cron nicht-interaktiv)
- Root-Crontab statt sudo-Crontab (install.sh läuft als root)
- ufw status numbered für bessere Regel-Erkennung

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-01 23:38:23 +02:00
mehlzoerwerandClaude Haiku 4.5 eff7d0d98b 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>
2026-05-01 23:28:36 +02:00
18 changed files with 2972 additions and 3904 deletions
+11 -6
View File
@@ -1,15 +1,20 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(gh auth *)",
"PowerShell(gh auth *)",
"PowerShell(winget install *)",
"Bash(gh repo *)",
"Bash(git init *)", "Bash(git init *)",
"Bash(git config *)",
"Bash(git remote *)", "Bash(git remote *)",
"Bash(git checkout *)",
"Bash(git add *)", "Bash(git add *)",
"Bash(git commit -m 'Initial commit: Clownfischserver v0.5.0 *)", "Bash(git commit -m 'v0.5.0 – Clownfischserver HomeIP Auto-Update: myfritz Automatik für SSH \\(5-Min Cron\\) *)",
"Bash(git config *)" "Bash(git push *)",
"Bash(bash -n install.sh)",
"Bash(bash -n scripts/clownfischserver-homeip-updater.sh)",
"Bash(python3 -m py_compile bot/*.py)",
"Bash(awk '{print \"Datei: \" $1 \" Größe: \" $5 \" Bytes\"}')",
"Bash(grep -oE '\\\\b\\([0-9]{1,3}\\\\.\\){3}[0-9]{1,3}\\\\b')",
"Bash(git commit -m 'Bugfix: Clownfischserver HomeIP Updater – Fehlerbehandlung & Fallbacks *)",
"Bash(git commit -m 'Feature: Multi-Distro Firewall-Support für HomeIP Updater *)"
] ]
} }
} }
+318
View File
@@ -0,0 +1,318 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
---
## Project Overview
**Clownfischserver** is a self-hosted, AI-driven Linux server manager controlled exclusively via Telegram. The core principle: admins write naturally, the bot understands, generates commands, creates scripts, manages the server.
- **Version:** v0.5.0 (in development)
- **License:** GPL-3.0
- **Status:** Active Development (feature/homeip-auto-update branch)
- **Core Dev:** Mehlzoerwer-Claude (Thomas Matthes)
---
## Architecture & Key Patterns
### Core Data Flow
```
Telegram User → bot.py → {ollama_client, shell.py, aider_wrapper} → Server Actions
↓
Chat-Model (gemma3) + Coder-Model (qwen2.5-coder)
Fallback: OpenRouter Free (if Ollama unavailable)
```
### Central Patterns
**Multi-Distro Abstraction:** The codebase abstracts firewall & package managers:
- **install.sh Step 2:** Detects `apt`/`dnf`/`pacman` → installs appropriate packages
- **install.sh Step 4:** Installs firewall (`ufw` for apt, `firewalld` for dnf, manual for pacman)
- **homeip-updater.sh:** Detects active firewall at runtime (`detect_firewall()` at line 18-26) → adapts rule syntax
This is critical: **Any Firewall change must support all three backends** (ufw, firewalld, iptables).
**Dual-Model KI Strategy:**
- **Coder-Model (qwen2.5-coder:7b):** `/shell`, `/code`, command generation → requires good programming ability
- **Chat-Model (gemma3:4b):** Freetext, explanations, translations → requires good German + multilingual
- **Fallback:** OpenRouter free tier if Ollama times out (optional, configured in `.env`)
**Telegram Safety Patterns:**
- `safe_reply()` wraps all bot responses (Markdown fallback for special chars)
- Long responses auto-split (Telegram 4096 char limit)
- Command confirmation: `/shell` generates → `/ja` confirms → executes (no auto-exec)
---
## File Structure & Responsibilities
### `bot/` – Core Bot Logic
- **bot.py** (700+ lines): Main handler, all commands, auth guard, self-update dialog
- **ollama_client.py**: Dual-model manager, OpenRouter fallback, context-window config
- **openrouter_client.py**: Cloud fallback (openrouter/free auto-router)
- **shell.py**: Command execution (shlex.quote for safety), result capture
- **aider_wrapper.py**: Code agent wrapper (--edit-format diff, 600s timeout)
- **snapshot.py**: tar.gz backups, workspace tracking, package list snapshots
- **boot_notify.py**: Sends Telegram message 120s after server boot (systemd oneshot)
- **knock_notify.py**: Alerts on port-knock SSH access attempts
### `scripts/` – Utility Scripts (NEW in v0.5.0)
- **clownfischserver-homeip-updater.sh**: Auto-update firewall rule for home IP (5-min cron)
- Detects firewall type (ufw/firewalld/iptables)
- Resolves myfritz DNS (dig/nslookup/getent fallbacks)
- State-file tracking to skip redundant updates
- **Critical:** Must work on Ubuntu/Debian/Fedora/Arch/openSUSE
- **README-HOMEIP.md**: Complete homeip-updater documentation (setup, troubleshooting, firewall details)
### `install.sh` – Multi-Step Installation (900+ lines)
8-step installer with full automation:
1. **System checks** → RAM, disk, distro detection
2. **Packages** → distro-specific (apt/dnf/pacman)
3. **User setup** → custom username, passwordless sudo
4. **SSH + Firewall** → sshd_config tuning, ufw/firewalld setup, **NEW: myfritz prompt**
5. **Ollama** → download, models selection (RAM-aware defaults)
6. **Python venv** → bot + aider (separate venvs due to dependency conflicts)
7. **Telegram bot** → BotFather token, chat-ID, optional OpenRouter key
8. **Systemd** → clownfisch service + boot-notification service, **NEW: cron-job for homeip-updater**
**Key Pattern:** `install.sh` runs **as root** and manages root-level resources. The homeip-updater cron-job runs as root (for firewall access).
### Configuration & Data
- **config/.env** → All secrets, model choices, paths (generated by install.sh, permissions 0600)
- **config/.env.example** → Template (must be kept in sync with install.sh additions)
- **config/knockd.conf** → Port-knocking rules (7000→8000→9000)
- **systemd/clownfisch.service** → Bot service (User patched dynamically from MGMT_USER)
- **systemd/clownfisch-boot.service** → Boot notification (oneshot, 120s delay)
---
## Critical Design Decisions
### 1. Firewall Abstraction (New in v0.5.0)
The homeip-updater.sh demonstrates the new **multi-firewall abstraction pattern**:
```bash
detect_firewall() {
systemctl is-active ufw → use: ufw allow from X port 22
systemctl is-active firewalld → use: firewall-cmd rich-rule
command -v iptables → use: iptables -I INPUT
}
```
**Why this matters:** When adding firewall-dependent features, always implement for all three. This is not optional.
### 2. No Interactive Commands in /shell
The `/shell` command uses a "non-interactive only" prompt. Why?
- **Cron-friendly:** homeip-updater runs in cron, can't wait for user input
- **Safety:** No `nano`, `vim`, `crontab -e` (can't pipe credentials)
- **Pattern:** UFW delete needs `echo "y" |` confirmation (line 81 in homeip-updater.sh)
### 3. .env Parsing Robustness
Variable loading must handle CRLF (Windows git clones):
```bash
MYFRITZ_DOMAIN=$(grep "^VAR=" .env | cut -d= -f2 | tr -d '\r\n')
```
This trims trailing newlines/carriage returns. Critical for .env variables used in firewall rules.
### 4. State File Tracking in homeip-updater
The `/var/lib/clownfischserver-homeip-state` file prevents redundant UFW updates:
- If DNS resolves to same IP → skip rule updates (idempotent)
- If IP changed → remove old rule, add new rule
- Logging includes old IP vs new IP for audit trail
This is important because UFW rule deletion prompts interactively (hence the `echo "y" |` workaround).
### 5. Package Dependency Management
- **dnsutils** must be in apt install (Step 2) for `dig` command
- **firewalld** installed for dnf systems (handled in Step 4)
- **iptables** assumed on pacman (Arch) systems
- The homeip-updater has DNS fallbacks: dig → nslookup → getent (lines 43-52)
---
## Development Workflow
### Testing on Multiple Distros
The project must work on:
- **Ubuntu/Debian** (primary, uses ufw)
- **Fedora/RHEL** (uses firewalld)
- **Arch/Manjaro** (uses iptables/nftables)
- **openSUSE** (uses firewalld)
**Recommended setup (from project notes):**
- Pop!_OS on laptop → development & editing
- Homlab VMs (Arch, openSUSE) → local testing of install.sh + homeip-updater
- VPS Server (Ubuntu) → real-world myfritz testing
### Bash Script Syntax Check
```bash
bash -n install.sh
bash -n scripts/clownfischserver-homeip-updater.sh
```
Run before commits. CRLF warnings are harmless (Windows git clones), but scripts must be syntactically valid.
### Python Syntax Check
```bash
python3 -m py_compile bot/*.py
```
### Testing Firewall Rules
**ufw (Ubuntu/Debian):**
```bash
ufw status numbered
ufw allow from 192.168.1.100 to any port 22 comment "test"
echo "y" | ufw delete allow from 192.168.1.100 to any port 22
```
**firewalld (Fedora/openSUSE):**
```bash
firewall-cmd --list-all
firewall-cmd --permanent --add-rich-rule="rule family='ipv4' source address='192.168.1.100' port protocol='tcp' port='22' accept"
firewall-cmd --reload
```
**iptables (Arch):**
```bash
iptables -L INPUT -n
iptables -I INPUT -s 192.168.1.100 -p tcp --dport 22 -j ACCEPT
iptables -D INPUT -s 192.168.1.100 -p tcp --dport 22 -j ACCEPT
```
---
## Git Workflow & Policies
### Push Policy
**Maximum 4 pushes per hour** (one every ~15 minutes). This is documented in `memory/feedback_github_policy.md`.
**Why:** Reduces GitHub notification spam, allows batching commits logically, leaves review time between updates.
### Branch Strategy
- **main:** Stable releases only (v0.4.13, v0.5.0, etc.)
- **feature/*** → PR reviewed → merged to main after testing
- Current: `feature/homeip-auto-update` (v0.5.0 preparation)
### Commit Message Style
```
<Type>: <Title> – <Subtitle if complex>
Details paragraph (optional, multi-line OK)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
```
Examples:
- `v0.5.0 – Clownfischserver HomeIP Auto-Update: myfritz Automatik für SSH (5-Min Cron)`
- `Bugfix: Clownfischserver HomeIP Updater – Fehlerbehandlung & Fallbacks`
- `Feature: Multi-Distro Firewall-Support für HomeIP Updater`
---
## Important Gotchas & Caveats
### 1. iptables Persistence on Arch
The homeip-updater uses `iptables -I INPUT` to add rules. On some systems, these don't persist after reboot. **Future enhancement:** integrate with `iptables-persistent` or `nftables` for persistence. Current status: evaluating.
### 2. Firewall Rule Comments
UFW and firewalld support rule comments (`comment "..."`) for tracking. **iptables does not.** This makes tracking which rules were added by clownfischserver harder on Arch. Document this if implementing rule deletion on iptables.
### 3. systemd Service User Patching
The MGMT_USER (arbitrary username) must be patched into systemd service files during installation. This uses regex: `User=\S+` (line 822 in install.sh). If systemd syntax changes, update regex.
### 4. Ollama Model Download Brittleness
`ollama pull` can hang or timeout. The installer includes progress bars but no retry logic. Users on slow/intermittent connections may need manual intervention.
### 5. .env Variable Escaping
Variables like `CLOWNFISCHSERVER_HOME_IP_DOMAIN=vorname.myfritz.net` are read with `grep` and `cut`. They must not contain `=` characters. Document constraints in `.env.example`.
---
## Code Patterns to Follow
### Safe Telegram Replies
Always use `safe_reply()` wrapper (bot.py). Never call `send_message()` directly.
### Async Telegram Handlers
All message handlers must be `async def`. Use `@app.message_handler()` decorator pattern from existing code.
### Shell Command Safety
Use `shlex.quote()` for untrusted user input in shell.py. Example:
```python
cmd = f"rm {shlex.quote(user_path)}"
```
### Firewall Rule Matching
When detecting or modifying firewall rules, match exact patterns to avoid false positives:
- UFW: `"22.*ALLOW.*FROM\s+$IP"` (line 79, homeip-updater.sh)
- firewalld: exact rule with family + port + protocol
- iptables: exact `-s $IP -p tcp --dport 22` match
---
## Memory & Context Files
For other Claude instances working on this project:
- **project_status_v05.md** – Current v0.5.0 status, tested scenarios, known limitations
- **feedback_github_policy.md** – Push frequency policy, batching rules
These are in `.claude/projects/.../memory/`. They provide context for continued development.
---
## Key References
- **CLOWNFISCHSERVER.md** – Detailed project context (architecture, .env variables, roadmap)
- **README.md** – User-facing documentation
- **scripts/README-HOMEIP.md** – Complete homeip-updater guide
- **CONTRIBUTING.md** – Contribution guidelines (already aligned with multi-distro goals)
---
## Quick Commands
```bash
# Syntax check
bash -n install.sh
python3 -m py_compile bot/*.py
# Test homeip-updater (dry-run, logs to stdout)
bash -x scripts/clownfischserver-homeip-updater.sh
# Check git status
git status
git log --oneline -5
# Build release ZIP (from install.sh docs)
cd .. && zip -r v0.5.0-clownfischserver.zip clownfischserver/ -x "*__pycache__*" "*.pyc" ".git/*"
```
---
**Last Updated:** 2026-05-01 (v0.5.0 HomeIP Feature)
**Next Version:** v0.5.0 (pending VPS/VM testing, then merge to main)
+490 -467
View File
@@ -1,467 +1,490 @@
# 🐠 Clownfischserver – Projekt-Kontext # 🐠 Clownfischserver – Projekt-Kontext
> Diese Datei ist der zentrale Wissensspeicher für das Clownfischserver-Projekt. > Diese Datei ist der zentrale Wissensspeicher für das Clownfischserver-Projekt.
> Lade sie in jede KI/Chat-Session um sofort produktiv zu sein. > Lade sie in jede KI/Chat-Session um sofort produktiv zu sein.
> Letzte Aktualisierung: v0.5.0 (30.05.2026) > Letzte Aktualisierung: v0.4.13 (18.04.2026)
> **⚠️ Bewusst entfernt (nicht wieder einbauen!):** Ein „Vault/MCP-Context"-System ---
> (`_get_vault_context`, `MCP_VAULT_URL`, `MCP_API_KEY`) wurde in v0.5.0 komplett
> entfernt. Es war toter Code, nutzte `verify=False` und schickte Chat-Nachrichten an ## Was ist der Clownfischserver?
> externe Endpoints – ein klarer DSGVO-Verstoß und Widerspruch zum „local-first"-Prinzip.
> Token-Optimierung über Obsidian gehört NICHT in den Bot. 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
## Was ist der Clownfischserver? **Entwickler:** Mehlzoerwer-Claude (Thorsten Matthes)
**KI-Assistent:** Claude (Anthropic) – Pair-Programming Partner
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 ## Architektur
**Entwickler:** Mehlzoerwer-Claude (Thorsten Matthes)
**KI-Assistent:** Claude (Anthropic) – Pair-Programming Partner ```
┌─────────────────────┐
--- │ Telegram Bot │
│ (bot.py) │
## Architektur └──────┬──────┬────────┘
│ │
``` ┌─────────────────┤ ├─────────────────┐
┌─────────────────────┐ ▼ ▼ ▼ ▼
│ Telegram Bot │ ┌────────────────┐ ┌────────────┐ ┌──────────────────┐
│ (bot.py) │ │ ollama_client │ │ shell.py │ │ aider_wrapper │
└──────┬──────┬────────┘ │ (Dual-Model) │ │ (run_shell)│ │ (Code-Agent) │
│ │ └───────┬────┬────┘ └────────────┘ └──────────────────┘
┌─────────────────┤ ├─────────────────┐ │ │
▼ ▼ ▼ ▼ ┌────────┘ └────────┐
┌────────────────┐ ┌────────────┐ ┌──────────────────┐ ▼ ▼
│ 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.
│ Chat-Model│ │ Coder-Model │ │ OpenRouter │
│ (gemma3) │ │(qwen2.5-coder)│ │ (Fallback) │ ---
└──────────┘ └──────────────┘ └──────────────┘
``` ## Dateistruktur (19 Dateien, ~3250 Zeilen)
**Prinzip:** Freitext = Chat. `/shell` = KI generiert Befehl → Admin bestätigt mit `/ja`. `/code` = Aider erstellt Dateien. `/run` = direkte Shell. ```
clownfischserver/
--- ├── bot/
│ ├── bot.py # Hauptbot: alle Commands, Auth, Self-Update, Update-Dialog
## Dateistruktur (19 Dateien, ~3250 Zeilen) │ ├── 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
clownfischserver/ │ ├── aider_wrapper.py # Aider mit --edit-format diff, 600s Timeout
├── bot/ │ ├── snapshot.py # tar.gz Snapshots, Workspace, Paketliste, sudo rsync
│ ├── bot.py # Hauptbot: alle Commands, Auth, Self-Update, Update-Dialog │ ├── boot_notify.py # Telegram-Nachricht 120s nach Boot
│ ├── ollama_client.py # Dual-Model, OpenRouter Fallback, NUM_CTX, Shell-Prompt │ ├── knock_notify.py # Port-Knocking SSH-Benachrichtigungen
│ ├── openrouter_client.py # OpenRouter Free Models Client (openrouter/free) │ └── requirements.txt # python-telegram-bot, requests, python-dotenv
│ ├── shell.py # run_shell (shlex.quote), execute_command ├── config/
│ ├── aider_wrapper.py # Aider mit --edit-format diff, 600s Timeout │ ├── .env.example # Vorlage mit allen Variablen
│ ├── snapshot.py # tar.gz Snapshots, Workspace, Paketliste, sudo rsync │ └── knockd.conf # Port-Knocking Sequenz (7000→8000→9000)
│ ├── boot_notify.py # Telegram-Nachricht 120s nach Boot ├── systemd/
│ ├── knock_notify.py # Port-Knocking SSH-Benachrichtigungen │ ├── clownfisch.service # Bot-Service (User=clownfish, wird gepatcht)
│ └── requirements.txt # python-telegram-bot, requests, python-dotenv │ └── clownfisch-boot.service # Boot-Notification (oneshot, 120s delay)
├── config/ ├── install.sh # 8-Schritt Installer mit Modell/CTX-Auswahl
│ ├── .env.example # Vorlage mit allen Variablen ├── README.md # Vollständige Dokumentation
│ └── knockd.conf # Port-Knocking Sequenz (7000→8000→9000) ├── LICENSE # GPL-3.0
├── systemd/ ├── .gitignore
│ ├── clownfisch.service # Bot-Service (User=clownfish, wird gepatcht) ├── CONTRIBUTING.md
│ └── clownfisch-boot.service # Boot-Notification (oneshot, 120s delay) └── GITHUB_PROFILE.md
├── install.sh # 8-Schritt Installer mit Modell/CTX-Auswahl ```
├── README.md # Vollständige Dokumentation
├── LICENSE # GPL-3.0 ---
├── .gitignore
├── CONTRIBUTING.md ## Wichtige Konventionen
└── GITHUB_PROFILE.md
``` ### 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)
## Wichtige Konventionen - **ZIP-Benennung:** `v0.4.13-clownfischserver-opus.zip`
### Naming ### Code-Stil
- **Service:** `clownfisch.service` (Deutsch, mit "ch") - Python 3, keine Type Hints in Funktionssignaturen (außer Optional)
- **Linux-User:** Beliebig (z.B. `clownfisch`, `clownfish`, `admin`) – wird aus `MGMT_USER` in `.env` gelesen - Logging über `logging` Modul
- **Branding:** Immer "Clownfisch" (Deutsch) - Async für alle Telegram-Handler
- **ZIP-Benennung:** `v0.5.0-clownfischserver.zip` - `safe_reply()` für alle Telegram-Nachrichten (Markdown-Fallback)
- Fehler werden dem User angezeigt, nie verschluckt
### Code-Stil
- Python 3, keine Type Hints in Funktionssignaturen (außer Optional) ### Sicherheits-Regeln
- Logging über `logging` Modul - Configs (`sshd_config`, `knockd.conf`) NIE überschreiben – nur gezielte Zeilen ändern
- Async für alle Telegram-Handler - `.env` Permissions: 0600
- `safe_reply()` für alle Telegram-Nachrichten (Markdown-Fallback) - SSH Host-Keys, `/etc/shadow` NIE in Snapshots
- Fehler werden dem User angezeigt, nie verschluckt - `.env` seit v0.4.12 aus Snapshots excludiert
- Self-Update: systemd-Services werden mit `MGMT_USER` aus `.env` gepatcht (Regex: `User=\S+`)
### 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 Variablen
- `.env` seit v0.4.12 aus Snapshots excludiert
- Self-Update: systemd-Services werden mit `MGMT_USER` aus `.env` gepatcht (Regex: `User=\S+`) ```
TELEGRAM_BOT_TOKEN=... # Bot-Token von @BotFather
--- TELEGRAM_CHAT_ID=... # Autorisierte Chat-ID (Single-User)
OLLAMA_BASE_URL=http://localhost:11434
## .env Variablen 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)
TELEGRAM_BOT_TOKEN=... # Bot-Token von @BotFather OPENROUTER_API_KEY=sk-or-v1-... # Optional: kostenloser Cloud-Fallback
TELEGRAM_CHAT_ID=... # Autorisierte Chat-ID (Single-User) SNAPSHOT_DIR=/opt/clownfisch-snapshots
OLLAMA_BASE_URL=http://localhost:11434 SNAPSHOT_METHOD=tar
OLLAMA_MODEL=qwen2.5-coder:7b # Coder-Modell für /shell und /code INSTALL_DIR=/opt/clownfischserver
OLLAMA_MODEL_FAST=gemma3:4b # Chat-Modell für Freitext MGMT_USER=clownfisch # Linux-User (beliebig wählbar)
OLLAMA_NUM_CTX=16384 # Context-Window (4096-32768) AIDER_WORKDIR=/opt/clownfisch-workspace
OPENROUTER_API_KEY=sk-or-v1-... # Optional: kostenloser Cloud-Fallback CLOWNFISCHSERVER_HOME_IP_DOMAIN=vorname.myfritz.net # Optional: myfritz für Auto-SSH-Freigabe
SNAPSHOT_DIR=/opt/clownfisch-snapshots ```
SNAPSHOT_METHOD=tar
INSTALL_DIR=/opt/clownfischserver ### Clownfischserver HomeIP Auto (neu in v0.5.0)
MGMT_USER=clownfisch # Linux-User (beliebig wählbar)
AIDER_WORKDIR=/opt/clownfisch-workspace **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:**
## Bot-Befehle 1. DNS-Auflösung: `vorname.myfritz.net` → IP-Adresse
2. Vergleich: Alte IP vs. neue IP
| Befehl | Funktion | 3. Bei Änderung: UFW-Regel aktualisieren
|--------|----------| 4. Logging: `/var/log/clownfischserver-homeip.log`
| Freitext | Chat mit KI (gemma3 lokal, OpenRouter Fallback) |
| `/shell <beschreibung>` | KI generiert Shell-Befehl, non-interaktiv | **Voraussetzungen:**
| `/ja` | Letzten `/shell` Befehl ausführen (mit Snapshot davor) | - myfritz-Account bei https://myfritz.net/ (kostenlos)
| `/code <aufgabe>` | Aider erstellt Dateien (Smart Prompt: Dateiname wird ergänzt) | - `dig` oder `nslookup` für DNS-Auflösung (meist bereits installiert)
| `/run <befehl>` | Direkte Shell, kein KI, kein Bestätigen |
| `/status` | CPU, RAM, Disk, Top-Prozesse | **Beispiel:**
| `/ssh open [ip]` | SSH via ufw öffnen | ```bash
| `/ssh close [ip]` | SSH via ufw schließen | # Installation: wird vom install.sh gefragt
| `/snapshot [label]` | Manuell Snapshot erstellen | ? myfritz Home-IP permanent erlauben? [j/n]
| `/snapshot keep/delete <n>` | Snapshot markieren/löschen | → j
| `/snapshots` | Alle Snapshots anzeigen | ? Deine myfritz Adresse:
| `/rollback <name>` | Zu Snapshot zurückkehren (sudo rsync --delete) | → mehlzoerwer.myfritz.net
| `/start` / `/help` | Bot-Info und Befehlsübersicht |
| ZIP senden | Self-Update mit optionalem OpenRouter-Setup-Dialog | # Dann läuft:
*/5 * * * * /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh >> /var/log/clownfischserver-homeip.log 2>&1
--- ```
## Dual-Model Strategie ---
| Rolle | Modell | RAM | Aufgabe | ## Bot-Befehle
|-------|--------|-----|---------|
| Coder | qwen2.5-coder:7b | ~4.7GB | `/shell`, `/code`, Befehlsgenerierung | | Befehl | Funktion |
| Chat | gemma3:4b | ~3.3GB | Freitext, Erklärungen, Übersetzungen | |--------|----------|
| Fallback | openrouter/free | 0 (Cloud) | Wenn Ollama nicht verfügbar | | Freitext | Chat mit KI (gemma3 lokal, OpenRouter Fallback) |
| `/shell <beschreibung>` | KI generiert Shell-Befehl, non-interaktiv |
**Shell-Prompt:** Few-Shot mit non-interaktiven Regeln (kein nano/vim/crontab -e, sudo wenn nötig). | `/ja` | Letzten `/shell` Befehl ausführen (mit Snapshot davor) |
**Chat-Prompt:** Deutsch als Standard, antwortet in Sprache des Nutzers. | `/code <aufgabe>` | Aider erstellt Dateien (Smart Prompt: Dateiname wird ergänzt) |
**Explain-Prompt:** Erklärt Befehlsergebnisse auf Deutsch. | `/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-System | `/snapshot [label]` | Manuell Snapshot erstellen |
| `/snapshot keep/delete <n>` | Snapshot markieren/löschen |
**Gesichert:** `/etc`, `/opt/clownfischserver`, `/home`, `/opt/clownfisch-workspace` | `/snapshots` | Alle Snapshots anzeigen |
**Excludiert:** SSH Host-Keys, shadow, `.env`, `__pycache__`, `node_modules` | `/rollback <name>` | Zu Snapshot zurückkehren (sudo rsync --delete) |
**Paketliste:** `dpkg --get-selections` pro Snapshot als `.packages.txt` | `/start` / `/help` | Bot-Info und Befehlsübersicht |
**Rollback:** `sudo rsync -av --delete` pro Verzeichnis | ZIP senden | Self-Update mit optionalem OpenRouter-Setup-Dialog |
**Auto-Cleanup:** Max 20 Snapshots, `keep`-markierte bleiben immer
---
---
## Dual-Model Strategie
## OpenRouter Integration
| Rolle | Modell | RAM | Aufgabe |
- **Client:** `openrouter_client.py` mit `openrouter/free` Auto-Router |-------|--------|-----|---------|
- **Fallback-Trigger:** Ollama Timeout ODER "nicht erreichbar" | Coder | qwen2.5-coder:7b | ~4.7GB | `/shell`, `/code`, Befehlsgenerierung |
- **Betrifft:** Chat + `/shell` (nicht `/code` – Aider braucht Ollama direkt) | Chat | gemma3:4b | ~3.3GB | Freitext, Erklärungen, Übersetzungen |
- **Setup:** Beim Self-Update fragt Bot "OpenRouter aktivieren?" → Key eingeben → `.env` | Fallback | openrouter/free | 0 (Cloud) | Wenn Ollama nicht verfügbar |
- **Kosten:** Free Tier: 50 Requests/Tag, 20/Minute, keine Kreditkarte
**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.
## Bekannte Bugs / Einschränkungen
---
- `/code` hat keinen OpenRouter-Fallback (Aider spricht nur Ollama)
- Kleine Modelle (< 8B) halluzinieren bei Erklärungen ## Snapshot-System
- Aider `diff`-Format überfordert kleine Modelle manchmal → Datei wird nicht erstellt
- Shell-Prompt vergisst manchmal `sudo` bei Root-Operationen **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
## Server-Umgebung **Auto-Cleanup:** Max 20 Snapshots, `keep`-markierte bleiben immer
- **Hoster:** netcup VPS ---
- **OS:** Ubuntu 24.04 LTS
- **RAM:** 16GB ## OpenRouter Integration
- **Disk:** 300GB
- **IP:** 92.60.38.175 - **Client:** `openrouter_client.py` mit `openrouter/free` Auto-Router
- **Ollama:** qwen2.5-coder:7b + gemma3:4b (oder gemma4:e2b) - **Fallback-Trigger:** Ollama Timeout ODER "nicht erreichbar"
- **Docker:** installiert - **Betrifft:** Chat + `/shell` (nicht `/code` – Aider braucht Ollama direkt)
- **SSH:** Port-Knocking via knockd (7000→8000→9000) - **Setup:** Beim Self-Update fragt Bot "OpenRouter aktivieren?" → Key eingeben → `.env`
- **Kosten:** Free Tier: 50 Requests/Tag, 20/Minute, keine Kreditkarte
---
---
## Roadmap
## Bekannte Bugs / Einschränkungen
### v0.5.0 – Sicherheit + Features
**Security (DSGVO-Pflicht):** - `/code` hat keinen OpenRouter-Fallback (Aider spricht nur Ollama)
- Sensitive Dateien über Telegram blockieren (`.env`, shadow, Keys) - Kleine Modelle (< 8B) halluzinieren bei Erklärungen
- Output-Sanitizer: API Keys/Tokens in JEDER Bot-Antwort maskieren - Aider `diff`-Format überfordert kleine Modelle manchmal → Datei wird nicht erstellt
- Log-Sanitizer: Keine Secrets im Audit-Log - Shell-Prompt vergisst manchmal `sudo` bei Root-Operationen
- 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 ## Server-Umgebung
- Rate-Limiting: Max 30 Befehle/Minute
- Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut - **Hoster:** netcup VPS
- 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot) - **OS:** Ubuntu 24.04 LTS
- Persönlicher Code-Offset (SECURITY_OFFSET in .env) - **RAM:** 16GB
- **Disk:** 300GB
**Features:** - **IP:** 92.60.38.175
- Snapshot-Scheduling + Export via Telegram - **Ollama:** qwen2.5-coder:7b + gemma3:4b (oder gemma4:e2b)
- Verschlüsselte Snapshots (SNAPSHOT_PASSWORD) - **Docker:** installiert
- `/logs`, `/docker` - **SSH:** Port-Knocking via knockd (7000→8000→9000)
- Port-Knocking Sequenz wählbar
- Open Interpreter als Aider-Alternative evaluieren ---
**Quick-Win Security:** ## Roadmap
- Panik-Wort: Admin-definiertes Wort → Bot löscht .env, sperrt alles
- Honeypot-Befehle: Fake-Commands die Eindringlinge verraten ### v0.5.0 – Sicherheit + Features
- Selbstzerstörende Nachrichten: Bot löscht eigene Outputs nach X Minuten **Security (DSGVO-Pflicht):**
- Rollback-Bombe: 3x falscher 2FA → auto-Rollback + SSH sperren + Alert - Sensitive Dateien über Telegram blockieren (`.env`, shadow, Keys)
- Dead Man's Switch: Kein Lebenszeichen in 24h → Bot sperrt sich selbst - Output-Sanitizer: API Keys/Tokens in JEDER Bot-Antwort maskieren
- Log-Sanitizer: Keine Secrets im Audit-Log
### v0.6.0 – Multi-Server + Gedächtnis - Audit-Log: Jeder Befehl mit Timestamp (DSGVO Art. 5/32)
- Ein Bot steuert mehrere Server - SHA256-Hash bei Self-Update + Admin-Bestätigung
- Audit-Log / Server-Gedächtnis (server_log.txt) - `.env` Permissions-Check beim Bot-Start
- Chat kann vergangene Aktionen abfragen - `bot.log`, `.aider.chat.history.md`, `bash_history` aus Snapshots excluden
- Rate-Limiting: Max 30 Befehle/Minute
### v0.7.0 – DSGVO-bewusster Backup-Manager - Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut
- Verschlüsselte Snapshots (SNAPSHOT_PASSWORD in .env) - 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot)
- Persönlicher Code-Offset (SECURITY_OFFSET in .env)
### v0.8.0 – Erweiterte Cloud-API
- Premium-Modelle auf OpenRouter **Features:**
- Anthropic/OpenAI direkt - Snapshot-Scheduling + Export via Telegram
- Kosten-Tracking - Verschlüsselte Snapshots (SNAPSHOT_PASSWORD)
- `/logs`, `/docker`
### v0.9.0 – Multi-Interface + Advanced Security - Port-Knocking Sequenz wählbar
**Alternative Interfaces (E2E-verschlüsselt):** - Open Interpreter als Aider-Alternative evaluieren
- Matrix/Element Connector (matrix-nio, self-hosted, E2E)
- Signal Connector (signal-cli, E2E) **Quick-Win Security:**
- Web-UI (HTTPS, self-hosted, Let's Encrypt) - Panik-Wort: Admin-definiertes Wort → Bot löscht .env, sperrt alles
- SSH-CLI (interaktive Shell-Session, kein Messenger nötig) - Honeypot-Befehle: Fake-Commands die Eindringlinge verraten
- E-Mail mit GPG-Verschlüsselung - Selbstzerstörende Nachrichten: Bot löscht eigene Outputs nach X Minuten
- Rollback-Bombe: 3x falscher 2FA → auto-Rollback + SSH sperren + Alert
**Advanced Security Features (evaluieren bei v0.8.x Abschluss):** - Dead Man's Switch: Kein Lebenszeichen in 24h → Bot sperrt sich selbst
- 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 ### v0.6.0 – Multi-Server + Gedächtnis
- Geo-Fencing: Bot antwortet nur aus erlaubten Ländern/IP-Bereichen - Ein Bot steuert mehrere Server
- Buddy-System: Kritische Befehle brauchen Bestätigung von zweitem Admin - Audit-Log / Server-Gedächtnis (server_log.txt)
- Canary Token: Unsichtbare Marker-Dateien, Alert bei Zugriff - Chat kann vergangene Aktionen abfragen
- Verschlüsselter Rückkanal: Bot-Antworten verschlüsselt über Telegram
- Hardware-Token: USB-Stick als physischer Schlüssel ### v0.7.0 – DSGVO-bewusster Backup-Manager
- Verschlüsselte Snapshots (SNAPSHOT_PASSWORD in .env)
**Architektur-Ziel:**
``` ### v0.8.0 – Erweiterte Cloud-API
[Telegram] ─┐ - Premium-Modelle auf OpenRouter
[Matrix] ─┤ - Anthropic/OpenAI direkt
[Web-UI] ─┼─→ [bot_core.py] → [Ollama/OpenRouter] - Kosten-Tracking
[SSH-CLI] ─┤
[E-Mail] ─┘ ### v0.9.0 – Multi-Interface + Advanced Security
``` **Alternative Interfaces (E2E-verschlüsselt):**
Ein Bot, viele Eingänge. Admin wählt was er will. - Matrix/Element Connector (matrix-nio, self-hosted, E2E)
- Signal Connector (signal-cli, E2E)
### v1.0.0 – Disaster Recovery - Web-UI (HTTPS, self-hosted, Let's Encrypt)
### v2.0.0 – Cross-Distribution Server Migration - SSH-CLI (interaktive Shell-Session, kein Messenger nötig)
- E-Mail mit GPG-Verschlüsselung
---
**Advanced Security Features (evaluieren bei v0.8.x Abschluss):**
## Getestete Modelle - 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
| Modell | Größe | Rolle | Ergebnis | - Geo-Fencing: Bot antwortet nur aus erlaubten Ländern/IP-Bereichen
|--------|-------|-------|----------| - Buddy-System: Kritische Befehle brauchen Bestätigung von zweitem Admin
| qwen2.5-coder:7b | 4.7GB | Coder | ✅ Beste Wahl für Code | - Canary Token: Unsichtbare Marker-Dateien, Alert bei Zugriff
| qwen2.5-coder:3b | 1.9GB | Coder | ✅ Funktioniert, schwächer | - Verschlüsselter Rückkanal: Bot-Antworten verschlüsselt über Telegram
| gemma3:4b | 3.3GB | Chat | ✅ Gutes Deutsch, schnell | - Hardware-Token: USB-Stick als physischer Schlüssel
| gemma4:e2b | 7.2GB | Beide | ⚠️ Zu groß für Dual-Setup |
| rnj-1:8b | 5.1GB | Coder | ❓ Test ausstehend | **Architektur-Ziel:**
| qwen3.5:4b | – | Coder | ❌ Thinking, zu langsam auf CPU | ```
| openrouter/free | Cloud | Fallback | ✅ Nemotron-H, stark | [Telegram] ─┐
[Matrix] ─┤
--- [Web-UI] ─┼─→ [bot_core.py] → [Ollama/OpenRouter]
[SSH-CLI] ─┤
## Wichtige Design-Entscheidungen [E-Mail] ─┘
```
1. **Local-first:** Cloud ist Opt-in Werkzeug, nicht Abhängigkeit Ein Bot, viele Eingänge. Admin wählt was er will.
2. **Kein Classifier:** Freitext = Chat. Befehle explizit per `/shell`, `/run`, `/code`
3. **Bestätigungs-Flow:** `/shell` generiert → Admin prüft → `/ja` führt aus ### v1.0.0 – Disaster Recovery
4. **Beliebiger Username:** MGMT_USER wird überall per Regex eingesetzt ### v2.0.0 – Cross-Distribution Server Migration
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
## Getestete Modelle
---
| Modell | Größe | Rolle | Ergebnis |
## Security-Analyse (v0.4.13) |--------|-------|-------|----------|
| qwen2.5-coder:7b | 4.7GB | Coder | ✅ Beste Wahl für Code |
### Was wir haben ✅ | qwen2.5-coder:3b | 1.9GB | Coder | ✅ Funktioniert, schwächer |
- Chat-ID Auth (Single-User) | gemma3:4b | 3.3GB | Chat | ✅ Gutes Deutsch, schnell |
- `.env` mit 0600 Permissions | gemma4:e2b | 7.2GB | Beide | ⚠️ Zu groß für Dual-Setup |
- SSH Host-Keys + shadow excluded aus Snapshots | rnj-1:8b | 5.1GB | Coder | ❓ Test ausstehend |
- `.env` excluded aus Snapshots (seit v0.4.12) | qwen3.5:4b | – | Coder | ❌ Thinking, zu langsam auf CPU |
- Port-Knocking statt offenes SSH | openrouter/free | Cloud | Fallback | ✅ Nemotron-H, stark |
- Lokal-first, Cloud opt-in
- Safe-Reply Markdown-Fallback (kein Crash bei Sonderzeichen) ---
### Bekannte Schwachstellen ❌ ## Wichtige Design-Entscheidungen
**Telegram als Angriffsfläche:** 1. **Local-first:** Cloud ist Opt-in Werkzeug, nicht Abhängigkeit
- Bot-Token im Klartext in `.env` – wer den Server hackt hat den Bot 2. **Kein Classifier:** Freitext = Chat. Befehle explizit per `/shell`, `/run`, `/code`
- Telegram Chat-History enthält alle Befehle + Outputs – nicht löschbar vom Bot 3. **Bestätigungs-Flow:** `/shell` generiert → Admin prüft → `/ja` führt aus
- Telegram API ist NICHT E2E-verschlüsselt – Telegram Inc. kann mitlesen 4. **Beliebiger Username:** MGMT_USER wird überall per Regex eingesetzt
- Jemand der das Handy entsperrt hat vollen Zugriff 5. **Smart `/code` Prompt:** Bot ergänzt Dateiname wenn User keinen nennt
- `.env` Inhalte können per `/run cat .env` über Telegram geleakt werden 6. **Snapshot vor destruktiven Aktionen:** Automatisch bei apt install, rm, chmod etc.
7. **DSGVO by design:** Lokal, keine Daten an Cloud, Snapshots ohne Secrets
**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) ## Security-Analyse (v0.4.13)
- `.aider.chat.history.md` loggt alle Code-Prompts im Klartext
- Ollama API auf localhost:11434 hat keine Auth – jeder lokale Prozess kann anfragen ### Was wir haben ✅
- Chat-ID Auth (Single-User)
**Snapshots:** - `.env` mit 0600 Permissions
- Snapshots enthalten `/home` – SSH Keys, bash_history, private Dateien - SSH Host-Keys + shadow excluded aus Snapshots
- Snapshot-Verzeichnis nicht verschlüsselt - `.env` excluded aus Snapshots (seit v0.4.12)
- Kein Integrity-Check ob Snapshots manipuliert wurden - Port-Knocking statt offenes SSH
- Lokal-first, Cloud opt-in
**Netzwerk:** - Safe-Reply Markdown-Fallback (kein Crash bei Sonderzeichen)
- knockd Sequenz steht im Klartext in `knockd.conf`
- ufw Regeln können über `/run` beliebig geändert werden ### Bekannte Schwachstellen ❌
**Server:** **Telegram als Angriffsfläche:**
- NOPASSWD sudo – kompromittierter Bot = Root - Bot-Token im Klartext in `.env` – wer den Server hackt hat den Bot
- `install.sh` lädt Pakete aus dem Internet ohne Signatur-Check - Telegram Chat-History enthält alle Befehle + Outputs – nicht löschbar vom Bot
- Docker Socket zugänglich → Container-Escape möglich - Telegram API ist NICHT E2E-verschlüsselt – Telegram Inc. kann mitlesen
- Jemand der das Handy entsperrt hat vollen Zugriff
### Security-Maßnahmen geplant - `.env` Inhalte können per `/run cat .env` über Telegram geleakt werden
**v0.5.0 (realistisch umsetzbar):** **Bot-Sicherheit:**
1. `.env`/Secrets über Telegram blockieren (Blacklist für sensitive Pfade) - Kein Rate-Limiting – Brute-Force auf Befehle möglich
2. Output-Sanitizer: API Keys/Tokens in JEDER Bot-Antwort maskieren - Kein Session-Timeout – Bot antwortet 24/7 ohne erneute Auth
3. Log-Sanitizer: Keine Secrets im Audit-Log - `bot.log` enthält möglicherweise sensible Daten (unverschlüsselt)
4. `bot.log` + `.aider.chat.history.md` + `bash_history` aus Snapshots excluden - `.aider.chat.history.md` loggt alle Code-Prompts im Klartext
5. Rate-Limiting: Max 30 Befehle/Minute - Ollama API auf localhost:11434 hat keine Auth – jeder lokale Prozess kann anfragen
6. Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut nötig
7. 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot) **Snapshots:**
8. Persönlicher Code-Offset (SECURITY_OFFSET in .env) - Snapshots enthalten `/home` – SSH Keys, bash_history, private Dateien
9. SHA256-Hash bei Self-Update → Admin bestätigt - Snapshot-Verzeichnis nicht verschlüsselt
10. `.env` Permissions-Check beim Bot-Start (warnen wenn nicht 0600) - Kein Integrity-Check ob Snapshots manipuliert wurden
11. Audit-Log mit Timestamp für jeden Befehl (DSGVO Art. 5/32)
**Netzwerk:**
**v0.6.0+ (langfristig):** - knockd Sequenz steht im Klartext in `knockd.conf`
12. Bot-Token Rotation - ufw Regeln können über `/run` beliebig geändert werden
13. Verschlüsselte Snapshots (SNAPSHOT_PASSWORD)
14. Integrity-Hashes für Snapshots (Manipulation erkennen) **Server:**
15. Ollama API Auth - NOPASSWD sudo – kompromittierter Bot = Root
16. Audit-Log mit Tamper-Protection (Signierung) - `install.sh` lädt Pakete aus dem Internet ohne Signatur-Check
- Docker Socket zugänglich → Container-Escape möglich
### DSGVO-Regel für Telegram
`.env` wird NUR angelegt/geändert über SSH. Niemals über Telegram lesbar. ### Security-Maßnahmen geplant
Der Bot BLOCKIERT Befehle die sensitive Dateien über Telegram ausgeben würden.
**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
## ZIP bauen – exakte Vorgehensweise 3. Log-Sanitizer: Keine Secrets im Audit-Log
4. `bot.log` + `.aider.chat.history.md` + `bash_history` aus Snapshots excluden
### Voraussetzungen 5. Rate-Limiting: Max 30 Befehle/Minute
- Alle 20 Dateien müssen im Ordner `clownfischserver/` liegen 6. Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut nötig
- Python-Dateien müssen syntax-geprüft sein 7. 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot)
- install.sh muss bash-syntax-geprüft sein 8. Persönlicher Code-Offset (SECURITY_OFFSET in .env)
9. SHA256-Hash bei Self-Update → Admin bestätigt
### Schritt 1: Syntax-Check (IMMER vor dem ZIP) 10. `.env` Permissions-Check beim Bot-Start (warnen wenn nicht 0600)
```bash 11. Audit-Log mit Timestamp für jeden Befehl (DSGVO Art. 5/32)
cd clownfischserver
python3 -m py_compile bot/bot.py **v0.6.0+ (langfristig):**
python3 -m py_compile bot/ollama_client.py 12. Bot-Token Rotation
python3 -m py_compile bot/openrouter_client.py 13. Verschlüsselte Snapshots (SNAPSHOT_PASSWORD)
python3 -m py_compile bot/shell.py 14. Integrity-Hashes für Snapshots (Manipulation erkennen)
python3 -m py_compile bot/snapshot.py 15. Ollama API Auth
python3 -m py_compile bot/aider_wrapper.py 16. Audit-Log mit Tamper-Protection (Signierung)
python3 -m py_compile bot/boot_notify.py
python3 -m py_compile bot/knock_notify.py ### DSGVO-Regel für Telegram
bash -n install.sh `.env` wird NUR angelegt/geändert über SSH. Niemals über Telegram lesbar.
``` Der Bot BLOCKIERT Befehle die sensitive Dateien über Telegram ausgeben würden.
### Schritt 2: ZIP erstellen ---
```bash
cd .. # Eine Ebene über clownfischserver/ ## ZIP bauen – exakte Vorgehensweise
zip -r v0.5.0-clownfischserver.zip clownfischserver/ -x "*__pycache__*" "*.pyc"
``` ### Voraussetzungen
- Alle 20 Dateien müssen im Ordner `clownfischserver/` liegen
**WICHTIG:** - Python-Dateien müssen syntax-geprüft sein
- ZIP-Name: `v<version>-clownfischserver-opus.zip` - install.sh muss bash-syntax-geprüft sein
- Root-Ordner im ZIP muss `clownfischserver/` heißen (nicht `build/` oder anderer Name)
- Keine `__pycache__` oder `.pyc` Dateien ### Schritt 1: Syntax-Check (IMMER vor dem ZIP)
- Keine `.git/` Ordner ```bash
- Keine `.env` mit echten Credentials cd clownfischserver
python3 -m py_compile bot/bot.py
### Schritt 3: Verifizieren python3 -m py_compile bot/ollama_client.py
```bash python3 -m py_compile bot/openrouter_client.py
unzip -l v0.5.0-clownfischserver.zip | head -30 python3 -m py_compile bot/shell.py
# Muss zeigen: clownfischserver/bot/bot.py etc. (20 Dateien) python3 -m py_compile bot/snapshot.py
``` python3 -m py_compile bot/aider_wrapper.py
python3 -m py_compile bot/boot_notify.py
### Schritt 4: Einspielen python3 -m py_compile bot/knock_notify.py
**Per Telegram:** ZIP an den ClownfischServer Bot senden → Self-Update Dialog bash -n install.sh
**Per Server:** `bash install.sh` im entpackten Ordner ```
### Schritt 5: GitHub Push ### Schritt 2: ZIP erstellen
```bash ```bash
cd clownfischserver cd .. # Eine Ebene über clownfischserver/
rm -rf .git zip -r v0.4.13-clownfischserver-opus.zip clownfischserver/ -x "*__pycache__*" "*.pyc"
git init ```
git remote add origin https://github.com/mehlzoerwer-claude/clownfischserver.git
git add -A **WICHTIG:**
git commit -m "v0.4.13 – Beschreibung der Änderungen" - ZIP-Name: `v<version>-clownfischserver-opus.zip`
git branch -M main - Root-Ordner im ZIP muss `clownfischserver/` heißen (nicht `build/` oder anderer Name)
git push -u origin main --force - Keine `__pycache__` oder `.pyc` Dateien
``` - Keine `.git/` Ordner
- Keine `.env` mit echten Credentials
### Dateiliste (20 Dateien, vollständig)
``` ### Schritt 3: Verifizieren
clownfischserver/ ```bash
├── .gitignore unzip -l v0.4.13-clownfischserver-opus.zip | head -30
├── CLOWNFISCHSERVER.md ← diese Datei # Muss zeigen: clownfischserver/bot/bot.py etc. (20 Dateien)
├── CONTRIBUTING.md ```
├── GITHUB_PROFILE.md
├── LICENSE ### Schritt 4: Einspielen
├── README.md **Per Telegram:** ZIP an den ClownfischServer Bot senden → Self-Update Dialog
├── install.sh **Per Server:** `bash install.sh` im entpackten Ordner
├── bot/
│ ├── aider_wrapper.py ### Schritt 5: GitHub Push
│ ├── boot_notify.py ```bash
│ ├── bot.py cd clownfischserver
│ ├── knock_notify.py rm -rf .git
│ ├── ollama_client.py git init
│ ├── openrouter_client.py git remote add origin https://github.com/mehlzoerwer-claude/clownfischserver.git
│ ├── requirements.txt git add -A
│ ├── shell.py git commit -m "v0.4.13 – Beschreibung der Änderungen"
│ └── snapshot.py git branch -M main
├── config/ git push -u origin main --force
│ ├── .env.example ```
│ └── knockd.conf
└── systemd/ ### Dateiliste (20 Dateien, vollständig)
├── clownfisch-boot.service ```
└── clownfisch.service clownfischserver/
``` ├── .gitignore
├── CLOWNFISCHSERVER.md ← diese Datei
### Versions-Bump Checkliste ├── CONTRIBUTING.md
Bei neuer Version diese Stellen aktualisieren: ├── GITHUB_PROFILE.md
1. `install.sh` – Banner + Log-Meldung (3x `v0.4.13`) ├── LICENSE
2. `bot/bot.py` – `cmd_start` Begrüßung + `main()` Log-Meldung ├── README.md
3. `README.md` – Badge `Version-0.4.13` + Roadmap "Fertig (v0.1 – v0.4.13)" ├── install.sh
4. `CLOWNFISCHSERVER.md` – Header + Dateiliste ├── bot/
5. ZIP-Dateiname │ ├── aider_wrapper.py
│ ├── boot_notify.py
--- │ ├── bot.py
│ ├── knock_notify.py
> *"Angefangen als einfacher Telegram-Bot – gewachsen zu einer Vision."* 🐠 │ ├── 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."* 🐠
+44 -53
View File
@@ -1,53 +1,44 @@
# Contributing to Clownfischserver 🐠 # Contributing to Clownfischserver 🐠
Danke dass du mitmachen willst! Hier sind die Regeln: Danke dass du mitmachen willst! Hier sind die Regeln:
## Grundprinzipien ## Grundprinzipien
- **Open Source bleibt Open Source** – GPL-3.0, keine Ausnahmen - **Open Source bleibt Open Source** – GPL-3.0, keine Ausnahmen
- **Lokal first** – keine Cloud-Dependencies, keine externen APIs - **Lokal first** – keine Cloud-Dependencies, keine externen APIs
- **Einfach bleiben** – ein Nutzer, ein Bot, ein Server - **Einfach bleiben** – ein Nutzer, ein Bot, ein Server
- **Sicherheit** – Safety-Check vor jeder Aktion - **Sicherheit** – Safety-Check vor jeder Aktion
## Pull Requests ## Pull Requests
1. Fork das Repo 1. Fork das Repo
2. Branch erstellen: `git checkout -b feature/dein-feature` 2. Branch erstellen: `git checkout -b feature/dein-feature`
3. Änderungen committen 3. Änderungen committen
4. PR öffnen mit Beschreibung was und warum 4. PR öffnen mit Beschreibung was und warum
## Code Style ## Code Style
- Python: PEP8, async/await - Python: PEP8, async/await
- Bash: shellcheck-kompatibel - Bash: shellcheck-kompatibel
- Kommentare auf Deutsch oder Englisch - Kommentare auf Deutsch oder Englisch
- Header in jeder Datei (siehe bestehende Dateien) - Header in jeder Datei (siehe bestehende Dateien)
## Code-Hygiene (wichtig!) ## Issues
- **Kein toter Code** – keine Funktionen/Module die definiert aber nie aufgerufen werden - Bug? → Issue mit Log-Output
- **Keine versteckten Cloud-Calls** – jeder externe Request muss klar ersichtlich und opt-in sein - Feature-Idee? → Issue mit Use-Case Beschreibung
- **Niemals `verify=False`** bei HTTPS-Requests - Frage? → Issue oder Discussions
- **Keine User-Eingaben an Dritte** ohne explizites Opt-in (DSGVO by design)
- **Klare Conditions** – verschachtelte Ternaries (`A if B else C` innerhalb von `and`-Ketten) vermeiden ## Was wir suchen
- Selbst-Review vor jedem Release: macht jede Zeile noch das was sie soll?
- Distro-Support (getestete Fedora/Arch Fixes)
## Issues - Neue Bot-Commands
- Bessere Safety-Prompts
- Bug? → Issue mit Log-Output - Dokumentation
- Feature-Idee? → Issue mit Use-Case Beschreibung - Tests
- Frage? → Issue oder Discussions
## Was wir NICHT wollen
## Was wir suchen
- Cloud-Dependencies
- Distro-Support (getestete Fedora/Arch Fixes) - Closed-Source Komponenten
- Neue Bot-Commands - Komplexität die Einsteiger abschreckt
- Bessere Safety-Prompts
- Dokumentation
- Tests
## Was wir NICHT wollen
- Cloud-Dependencies
- Closed-Source Komponenten
- Komplexität die Einsteiger abschreckt
-149
View File
@@ -1,149 +0,0 @@
# DSGVO-Konformität – Clownfischserver v0.5.0
> Dieser Stand bezieht sich auf v0.5.0. Frühere Versionen erfüllen nicht den vollen
> Funktionsumfang. v0.6.0 erweitert um Transport-Verschlüsselung und Backup-Konzept.
Clownfischserver ist als **Gateway für lokal gehostete LLMs in Unternehmens­umgebungen**
positioniert. Datenschutz ist kein Add-on, sondern strukturelle Voraussetzung.
Verantwortlich für die DSGVO-konforme Konfiguration im konkreten Einsatz bleibt
**der Betreiber**. Dieses Dokument beschreibt, welche technischen Maßnahmen die
Software dafür bereitstellt.
---
## Art. 5 DSGVO – Grundsätze der Verarbeitung
### Art. 5 (1) (a) – Rechtmäßigkeit, Verarbeitung nach Treu und Glauben, Transparenz
Jeder privilegierte Vorgang wird im Audit-Trail (`audit.jsonl`) protokolliert:
- Wer hat was wann ausgeführt? (`/shell`, `/code`, `/run`, `/ja`, `/ssh`, `/snapshot`, `/rollback`)
- Welches LLM-Modell hat eine Antwort generiert? (Audit-Event `llm.route`)
- Welche Zugriffe wurden abgelehnt? (`result: denied`)
Operatoren können den Trail jederzeit via `/logs` einsehen
(`bot/audit_query.py`, `cmd_logs` in `bot/bot.py`).
### Art. 5 (1) (b) – Zweckbindung
`/shell` und `/code` benötigen eine explizite Bestätigung durch einen Approver
(`/ja`). Dieser Approval-Flow ist nicht umgehbar: jeder Befehl wird *vor* der
Ausführung als Vorschlag im Chat dargestellt, der ausführende Approver
hinterlässt seinerseits einen Audit-Eintrag (`shell.approval`).
### Art. 5 (1) (c) – Datenminimierung
- Audit-Einträge enthalten **nur die numerische Telegram User-ID** – keine
Klarnamen, keine Telegram-Usernames, keine E-Mail-Adressen.
- Befehlstexte werden auf `AUDIT_MAX_DETAIL_LEN` Zeichen (Default 2000) gekürzt.
Konfigurierbar via `.env`.
- Es findet **keine Telemetrie nach außen** statt. Sämtliche LLM-Calls können
konfigurationsabhängig vollständig on-prem gehalten werden
(`LLM_PRIMARY_PROVIDER=ollama`, `LLM_FALLBACK_PROVIDER=none`).
### Art. 5 (1) (d) – Richtigkeit
Audit-Einträge enthalten Eingaben (`description`, `command`) **und** Ergebnis
(`returncode`, ggf. `stderr`). Falschmeldungen lassen sich gegen den realen
Server­zustand verifizieren.
### Art. 5 (1) (e) – Speicherbegrenzung
`audit_log.prune_old_logs()` läuft beim Bot-Start und entfernt Einträge,
die älter als `AUDIT_RETENTION_DAYS` (Default 90 Tage) sind.
- `AUDIT_RETENTION_DAYS=0` deaktiviert das Pruning bewusst (z. B. für
forensische Anforderungen mit längerer Aufbewahrungspflicht).
- Das Pruning nutzt einen atomic-rename, sodass auch ein Crash während des
Bereinigungslaufs keine Audit-Lücken erzeugt.
### Art. 5 (1) (f) – Integrität und Vertraulichkeit
- Audit-Datei wird nach jedem Append auf `0600` gesetzt (nur Eigentümer
lesbar/schreibbar). Auf Windows-Filesystemen ohne POSIX-Permissions wird
der Schreibvorgang nicht blockiert, sondern lediglich geloggt – Produktion
läuft per `install.sh` auf Linux.
- Sämtliche LLM-Kommunikation kann ausschließlich lokal stattfinden
(Ollama via `127.0.0.1:11434`). OpenRouter-Fallback ist **opt-in**.
---
## Art. 32 DSGVO – Sicherheit der Verarbeitung
### Art. 32 (1) (a) – Pseudonymisierung
Audit-Einträge speichern numerische User-IDs, keine Namen oder Handles.
Die Zuordnung User-ID → Person verbleibt beim Betreiber, außerhalb der Software.
### Art. 32 (1) (a) – Verschlüsselung
- **In v0.5.0 nicht enthalten**: Die Audit-Datei liegt unverschlüsselt im
Filesystem. Schutz erfolgt ausschließlich über Dateisystem-Permissions
(`0600`, `MGMT_USER`).
- **Plan für v0.6.0**: HTTPS-only Webhook-Endpoint (Telegram-Bot ist
derzeit Long-Polling), at-rest-Encryption der Audit-Datei.
### Art. 32 (1) (b) – Vertraulichkeit, Integrität, Verfügbarkeit
- **Rollenbasierter Zugriff** (`bot/auth.py`):
- `operator` – darf Befehle vorschlagen, ausführen, Code generieren,
SSH-Firewall steuern, Snapshots verwalten, Rollback fahren.
- `approver` – darf vorgeschlagene Befehle bestätigen (`/ja`).
- `viewer` – darf Status und Snapshots einsehen, im Chat lesen.
- Inheritance: operator ⊃ approver ⊃ viewer.
- Jeder Endpunkt im Bot prüft die Rolle explizit; ein nicht autorisierter
Zugriff wird abgelehnt **und** als `result: denied` im Audit protokolliert.
- Verfügbarkeit: Bot läuft als `systemd`-Service (`clownfisch`); Boot-Notify-
Service informiert beim Neustart.
### Art. 32 (1) (c) – Wiederherstellbarkeit
`bot/snapshot.py` erstellt automatisch Snapshots vor potenziell zerstörerischen
Operationen (`/code`, ZIP-Upload, schreibende `/shell`-Befehle). `/rollback`
stellt einen Snapshot wieder her – ebenfalls per Operator-Rolle abgesichert.
### Art. 32 (1) (d) – Verfahren zur Überprüfung
Tests unter `tests/`:
- `test_auth.py` – Rollenmodell + Backwards-Compat
- `test_audit_log.py` – Schreibverhalten, Pruning, Datenminimierung
- `test_audit_query.py` – Filterlogik, Format
- `test_llm_router.py` – Routing-Verhalten, Audit-Hooks
`tests/_smoke.py`, `tests/_smoke_router.py`, `tests/_smoke_query.py` sind
standalone-Skripte für Einsatz ohne pytest.
---
## Was Clownfischserver **nicht** automatisch erledigt
Diese Punkte verbleiben beim Betreiber:
- **Auftragsverarbeitungs-Vertrag** mit OpenRouter (falls Fallback aktiviert).
OpenRouter-Server stehen außerhalb der EU – aktiviere den Fallback nicht
für Daten mit Personenbezug.
- **Verzeichnis von Verarbeitungstätigkeiten** (Art. 30 DSGVO).
- **Datenschutz-Folgenabschätzung** bei großvolumiger Personendaten­verarbeitung
durch das gehostete LLM.
- **Backup-Strategie** für die Audit-Datei (in v0.6.0 geplant).
- **Schulung der Operatoren** im Umgang mit dem `/shell`-Approval-Flow.
---
## Kurz: Welche `.env`-Variablen sind DSGVO-relevant?
| Variable | Effekt |
|---|---|
| `CLOWNFISCH_OPERATORS` / `_APPROVERS` / `_VIEWERS` | Rollenzuweisung, Art. 32 (1) (b) |
| `AUDIT_LOG_PATH` | Pfad zur Audit-Datei (sollte auf verschlüsseltem Volume liegen) |
| `AUDIT_RETENTION_DAYS` | Speicherbegrenzung, Art. 5 (1) (e) |
| `AUDIT_MAX_DETAIL_LEN` | Datenminimierung, Art. 5 (1) (c) |
| `LLM_PRIMARY_PROVIDER=ollama` | On-prem-LLM, keine Cloud-Übertragung |
| `LLM_FALLBACK_PROVIDER=none` | Cloud-Fallback explizit deaktivieren |
| `OPENROUTER_API_KEY` | nur setzen wenn AV-Vertrag mit OpenRouter besteht |
---
**Stand:** Juni 2026 · v0.5.0 · Mehlzoerwer-Claude
+429 -508
View File
@@ -1,508 +1,429 @@
# 🐠 Clownfischserver # 🐠 Clownfischserver
> *Wie der Clownfisch in seiner Anemone lebt – geschützt, autonom, und beißt überraschend hart.* > *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. Ein selbst-hostbarer, KI-gestützter Server-Manager der ausschließlich über Telegram kommuniziert. Kein Dashboard, kein Web-UI, keine Cloud. Nur du und dein Server – über eine Chat-Nachricht.
![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg) ![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)
![Platform: Linux](https://img.shields.io/badge/Platform-Linux-red.svg) ![Platform: Linux](https://img.shields.io/badge/Platform-Linux-red.svg)
![Version](https://img.shields.io/badge/Version-0.5.0-green.svg) ![Version](https://img.shields.io/badge/Version-0.4.13-green.svg)
![Status](https://img.shields.io/badge/Status-Active%20Development-yellow.svg) ![Status](https://img.shields.io/badge/Status-Active%20Development-yellow.svg)
--- ---
## 🐟 Warum "Clownfisch"? ## 🐟 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. 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): Genauso lebt der Clownfischserver **in seinem Server** (seiner Anemone):
- Er schirmt alles ab was nicht sein muss - Er schirmt alles ab was nicht sein muss
- SSH bleibt standardmäßig zu – nur wer das richtige Klopfmuster kennt kommt rein - SSH bleibt standardmäßig zu – nur wer das richtige Klopfmuster kennt kommt rein
- Die KI entscheidet was sicher ist und was nicht - Die KI entscheidet was sicher ist und was nicht
- Und wie der echte Clownfisch: **beißt er überraschend hart wenn nötig** 🐠 - Und wie der echte Clownfisch: **beißt er überraschend hart wenn nötig** 🐠
--- ---
## Was ist das? ## 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. 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. 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.** **Kein SCP. Kein Dashboard. Kein Cloud-Zwang. Nur Telegram.**
--- ---
## Features ## Features
- 🤖 **Telegram Bot** als primäres Interface – von überall, auch vom Handy - 🤖 **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 - 🧠 **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 - 🔧 **`/shell` + `/ja`** – KI generiert Shell-Befehle, du bestätigst vor der Ausführung
- 🛠️ **`/code` + Aider** – autonome Code- und Script-Generierung direkt auf dem Server - 🛠️ **`/code` + Aider** – autonome Code- und Script-Generierung direkt auf dem Server
- 💬 **Natürlicher Chat** – Fragen stellen, Erklärungen bekommen, übersetzen lassen - 💬 **Natürlicher Chat** – Fragen stellen, Erklärungen bekommen, übersetzen lassen
- 📸 **Automatische Snapshots** vor destruktiven Aktionen (tar.gz mit Keep/Unkeep + Paketliste) - 📸 **Automatische Snapshots** vor destruktiven Aktionen (tar.gz mit Keep/Unkeep + Paketliste)
- 🔐 **Port-Knocking SSH** als sicherer Backup-Zugang (knockd) mit Telegram-Benachrichtigung - 🔐 **Port-Knocking SSH** als sicherer Backup-Zugang (knockd) mit Telegram-Benachrichtigung
- 🔔 **Benachrichtigungen** – SSH-Zugang, Server-Boot, Knock-Versuche via Telegram - 🔔 **Benachrichtigungen** – SSH-Zugang, Server-Boot, Knock-Versuche via Telegram
- 📦 **Self-Update via Telegram** – ZIP an den Bot schicken, fertig (inkl. systemd-Services) - 📦 **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 - ☁️ **OpenRouter Fallback** – kostenlose Cloud-Modelle als Backup wenn Ollama nicht verfügbar
- 👥 **Team-Rollen** *(v0.5.0)* – `operator` / `approver` / `viewer` mit Inheritance, env-gesteuert - 👤 **Eigener User** – beliebiger Name, passwordless sudo, wird überall korrekt eingesetzt
- 📜 **Audit-Trail** *(v0.5.0)* – jeder privilegierte Vorgang in `audit.jsonl`, abfragbar via `/logs`, Retention konfigurierbar - 🌍 **Mehrsprachig** – antwortet in der Sprache in der du schreibst
- 🔀 **LLM-Router** *(v0.5.0)* – Primary/Fallback-Chain via `LLM_PRIMARY_PROVIDER` etc.; pro Provider eigenes Kontextfenster - 🐧 **Multi-Distro** – Debian, Ubuntu, Fedora, Arch (Installer)
- 👤 **Eigener User** – beliebiger Name, passwordless sudo, wird überall korrekt eingesetzt - 🔒 **DSGVO by design** – lokal ist Standard, Cloud ist Opt-in
- 🌍 **Mehrsprachig** – antwortet in der Sprache in der du schreibst
- 🐧 **Multi-Distro** – Debian, Ubuntu, Fedora, Arch (Installer) ---
- 🔒 **DSGVO by design** – lokal ist Standard, Cloud ist Opt-in (siehe [DSGVO.md](DSGVO.md))
## Schnellstart
---
```bash
## For Enterprise Teams (v0.5.0) # Frisches Ubuntu/Debian – als root:
wget https://github.com/mehlzoerwer-claude/clownfischserver/releases/latest/download/clownfischserver.zip
Clownfischserver v0.5.0 ist als **Gateway für Teams mit lokal gehosteten unzip clownfischserver.zip && cd clownfischserver
LLMs** positioniert. Wenn ihr Claude Opus, Qwen 32B oder Gemma 4 auf eigener bash install.sh
Hardware betreibt, gibt euch v0.5.0: ```
### Rollenbasierte Zugriffskontrolle Der Installer führt dich durch alles – Schritt für Schritt, mit Erklärungen.
Drei Rollen mit Inheritance (`operator` ⊃ `approver` ⊃ `viewer`): ---
```dotenv ## Installation
# .env
CLOWNFISCH_OPERATORS=12345,67890 # dürfen /shell /code /run /ssh /ja /snapshot /rollback ### Was du brauchst
CLOWNFISCH_APPROVERS=11111 # dürfen /ja (Bestätigungen)
CLOWNFISCH_VIEWERS=22222,33333 # dürfen /status /snapshots /help + Chat - 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)
Wer keine Rolle hat, wird abgelehnt – inklusive Audit-Eintrag mit
`result: denied`. Backwards-kompatibel: ohne Rollen-Vars wird die alte ### RAM-Empfehlung
`TELEGRAM_CHAT_ID` automatisch zu `operator`.
| RAM | Coder-Modell | Chat-Modell | Gesamt ca. |
### Audit-Trail nach DSGVO Art. 5 + 32 |-----|-------------|-------------|------------|
| 8-12 GB | qwen2.5-coder:3b | gemma3:1b | ~3 GB |
Jeder Vorgang wird in `audit.jsonl` als JSONL-Eintrag protokolliert: | 16+ GB | qwen2.5-coder:7b | gemma3:4b | ~8 GB |
```jsonl ### Der Installer
{"ts":"2026-06-04T14:21:03+00:00","user_id":"12345","action":"shell.execute","result":"ok","role":"operator","details":{"command":"systemctl restart nginx","returncode":0}}
{"ts":"2026-06-04T14:21:07+00:00","action":"llm.route","result":"hit","details":{"tier":"primary","provider":"ollama","model":"qwen2.5-coder:7b","method":"chat"}} ```
``` [1/8] Systemcheck → RAM, Disk, Distro-Erkennung
[2/8] Pakete → alles Nötige mit Spinner
Operatoren fragen den Trail direkt aus Telegram ab: [3/8] User einrichten → eigener User oder vorhandenen nutzen
[4/8] SSH + Firewall → Port-Knocking, ufw, SSH bleibt offen
```text [5/8] Ollama → Dual-Model KI-Setup (Coder + Chat)
/logs # letzte 20 Einträge [6/8] Python + Aider → Bot-Umgebung + Code-Agent
/logs user 12345 days 7 # User-Aktivität, letzte 7 Tage [7/8] Telegram → geführtes Bot-Setup mit BotFather
/logs prefix shell. result error [8/8] Service starten → systemd + Boot-Notification
/logs action llm.route limit 50 ```
```
### Update (wenn bereits installiert)
Retention via `AUDIT_RETENTION_DAYS` (Default 90 Tage, beim Bot-Start automatisch).
```bash
### Konfigurierbares LLM-Routing bash install.sh
# → erkennt laufenden Service
Primary/Fallback-Chain via Env – kein Auto-Selection, keine Halluzinationen: # → [1] Update [2] Neuinstallation [3] Abbrechen
```
```dotenv
# Primary: lokal, sensitive Daten bleiben on-prem Oder noch einfacher: **ZIP direkt an den Bot schicken** – Self-Update!
LLM_PRIMARY_PROVIDER=ollama
LLM_PRIMARY_MODEL=qwen2.5-coder:7b ---
LLM_PRIMARY_NUM_CTX=65536 # z. B. Gemma 4 27B braucht 64K+
## Nutzung
# Fallback nur für non-sensitive Tasks
LLM_FALLBACK_PROVIDER=openrouter ### Bot-Befehle
LLM_FALLBACK_MODEL=deepseek/deepseek-r1:free
| Befehl | Beschreibung |
# Fallback komplett deaktivieren: |--------|-------------|
# LLM_FALLBACK_PROVIDER=none | `/shell <beschreibung>` | KI generiert Shell-Befehl aus natürlicher Sprache |
``` | `/ja` | Letzten vorgeschlagenen Befehl ausführen |
| `/code <aufgabe>` | Aider erstellt/bearbeitet Code und Scripts |
Routable Errors (Timeout, Connection-Refused) lösen Fallback aus; harte | `/run <befehl>` | Direkte Shell – kein Ollama, kein Bestätigen |
Fehler (JSON-Parse, Bad API Key) propagieren – ohne stille Modellwechsel. | `/status` | CPU, RAM, Disk, Top-Prozesse |
| `/snapshots` | Alle Snapshots anzeigen |
### Vollständige Compliance-Dokumentation | `/snapshot [label]` | Manuell Snapshot erstellen |
| `/snapshot keep <n>` | Snapshot als wichtig markieren |
Siehe **[DSGVO.md](DSGVO.md)** – aufgeschlüsselt nach Art. 5 + 32 mit | `/snapshot delete <n>` | Snapshot löschen |
Referenzen auf den konkreten Code. | `/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 |
## Schnellstart
### Freitext-Chat
```bash
# Frisches Ubuntu/Debian – als root: Einfach schreiben – ohne Befehl. Der Bot antwortet als Chat-Partner:
wget https://github.com/mehlzoerwer-claude/clownfischserver/archive/refs/heads/main.zip
unzip main.zip && cd clownfischserver-main ```
bash install.sh "Was ist ein Reverse Proxy?"
``` "Übersetze das ins Englische: ..."
"Erkläre mir den Unterschied zwischen apt und snap"
Der Installer führt dich durch alles – Schritt für Schritt, mit Erklärungen. "Welche Services laufen gerade auf meinem Server?"
```
---
### Workflow: `/shell` + `/ja`
## Installation
```
### Was du brauchst Du: /shell zeig mir die größten Dateien auf dem Server
Bot: 🔧 Vorgeschlagener Befehl:
- Frischer Ubuntu 22/24 oder Debian 12/13 VPS (min. 8GB RAM empfohlen, 20GB Disk) du -ah / | sort -rh | head -20
- Root SSH-Zugang für die Installation → Mit /ja ausführen oder einfach ignorieren.
- Ein Telegram-Account (5 Minuten für Bot-Setup) Du: /ja
Bot: ✅ Ergebnis: ...
### RAM-Empfehlung ```
| RAM | Coder-Modell | Chat-Modell | Gesamt ca. | ### Workflow: `/code`
|-----|-------------|-------------|------------|
| 8-12 GB | qwen2.5-coder:3b | gemma3:1b | ~3 GB | ```
| 16+ GB | qwen2.5-coder:7b | gemma3:4b | ~8 GB | Du: /code erstelle ein bash script das alte Logs älter als 30 Tage löscht
Bot: 🛠️ Aider Ergebnis:
### Der Installer ✅ Aider Task abgeschlossen
Applied edit to cleanup-logs.sh
``` ```
[1/8] Systemcheck → RAM, Disk, Distro-Erkennung
[2/8] Pakete → alles Nötige mit Spinner ### Self-Update
[3/8] User einrichten → eigener User oder vorhandenen nutzen
[4/8] SSH + Firewall → Port-Knocking, ufw, SSH bleibt offen ZIP-Datei direkt an den Bot schicken → Bot erstellt Snapshot → installiert Update → startet neu.
[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 ## Architektur
```
```
### Update (wenn bereits installiert) ┌─────────────────────┐
│ Telegram Bot │
```bash │ (bot.py) │
bash install.sh └──────┬──────┬────────┘
# → erkennt laufenden Service │ │
# → [1] Update [2] Neuinstallation [3] Abbrechen ┌─────────────────┤ ├─────────────────┐
``` ▼ ▼ ▼ ▼
┌────────────────┐ ┌────────────┐ ┌──────────────────┐
Oder noch einfacher: **ZIP direkt an den Bot schicken** – Self-Update! │ ollama_client │ │ shell.py │ │ aider_wrapper │
│ (Dual-Model) │ │ (run_shell)│ │ (Code-Agent) │
--- └───────┬────┬────┘ └────────────┘ └──────────────────┘
│ │
## Nutzung ┌────────┘ └────────┐
▼ ▼
### Bot-Befehle ┌──────────┐ ┌──────────────┐
│ Chat-Model│ │ Coder-Model │
| Befehl | Beschreibung | │ (gemma3) │ │(qwen2.5-coder)│
|--------|-------------| └──────────┘ └──────────────┘
| `/shell <beschreibung>` | KI generiert Shell-Befehl aus natürlicher Sprache |
| `/ja` | Letzten vorgeschlagenen Befehl ausführen | Weitere Module:
| `/code <aufgabe>` | Aider erstellt/bearbeitet Code und Scripts | snapshot.py – Backup-System (tar.gz, keep/unkeep, auto-cleanup)
| `/run <befehl>` | Direkte Shell – kein Ollama, kein Bestätigen | knock_notify.py – SSH Port-Knocking Alerts
| `/status` | CPU, RAM, Disk, Top-Prozesse | boot_notify.py – Server-Neustart Benachrichtigung
| `/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 | ## Sicherheit
| `/ssh open [ip]` | SSH via ufw öffnen (für alle oder eine IP) |
| `/ssh close [ip]` | SSH wieder sperren | ### Port-Knocking
| `/start` / `/help` | Bot-Info und Befehlsübersicht |
SSH ist standardmäßig durch ufw blockiert. Zugang via Klopf-Sequenz:
### Freitext-Chat
```bash
Einfach schreiben – ohne Befehl. Der Bot antwortet als Chat-Partner: # Oeffnen: 7000 -> 8000 -> 9000
knock SERVER_IP 7000 8000 9000
```
"Was ist ein Reverse Proxy?" # Schliessen: 9000 -> 8000 -> 7000
"Übersetze das ins Englische: ..." knock SERVER_IP 9000 8000 7000
"Erkläre mir den Unterschied zwischen apt und snap" ```
"Welche Services laufen gerade auf meinem Server?"
``` Bei jedem Knock kommt eine Telegram-Benachrichtigung mit IP und Zeitstempel.
### Workflow: `/shell` + `/ja` ### KI-gestützte Befehlsgenerierung
``` ```
Du: /shell zeig mir die größten Dateien auf dem Server /shell installiere nginx
Bot: 🔧 Vorgeschlagener Befehl: → KI generiert: apt install -y nginx
du -ah / | sort -rh | head -20 → Gefährlich? Nein → Vorschlag anzeigen
→ Mit /ja ausführen oder einfach ignorieren. → /ja zum Ausführen, oder ignorieren
Du: /ja
Bot: ✅ Ergebnis: ... /shell lösche alles in /etc
``` → KI generiert: rm -rf /etc/*
→ Gefährlich? JA → Verweigert mit Erklärung
### Workflow: `/code` ```
``` Kein Befehl wird ohne Bestätigung ausgeführt. Gefährliche Befehle werden immer verweigert.
Du: /code erstelle ein bash script das alte Logs älter als 30 Tage löscht
Bot: 🛠️ Aider Ergebnis: ### Snapshot-System
✅ Aider Task abgeschlossen
Applied edit to cleanup-logs.sh Vor destruktiven Aktionen automatischer Snapshot (tar.gz). Wichtige Snapshots können mit `/snapshot keep` markiert werden und werden nie automatisch gelöscht.
```
### Boot-Notification
### Self-Update
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.
ZIP-Datei direkt an den Bot schicken → Bot erstellt Snapshot → installiert Update → startet neu.
---
---
## Roadmap
## Architektur
### Fertig (v0.1 – v0.4.13)
```
┌─────────────────────┐ - [x] Basis-Installer mit geführtem Setup (8 Schritte)
│ Telegram Bot │ - [x] Telegram Bot mit Auth Guard (Single-User)
│ (bot.py) │ - [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
│ ollama_client │ │ shell.py │ │ aider_wrapper │ - [x] Freitext-Chat mit History (letzte 10 Exchanges)
│ (Dual-Model) │ │ (run_shell)│ │ (Code-Agent) │ - [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)
│ Chat-Model│ │ Coder-Model │ - [x] Multi-Distro Installer (apt/dnf/pacman)
│ (gemma3) │ │(qwen2.5-coder)│ - [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)
Weitere Module: - [x] `OLLAMA_NUM_CTX` konfigurierbar (4K–32K, RAM-basierte Empfehlung im Installer)
snapshot.py – Backup-System (tar.gz, keep/unkeep, auto-cleanup) - [x] Safe Telegram Output (Markdown-Fallback bei Parse-Fehlern)
knock_notify.py – SSH Port-Knocking Alerts - [x] Lange Antworten werden in Teile gesplittet (kein Abschneiden)
boot_notify.py – Server-Neustart Benachrichtigung - [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
## Sicherheit
### v0.5.0 – Geplant
### Port-Knocking
**Sicherheit & DSGVO:**
SSH ist standardmäßig durch ufw blockiert. Zugang via Klopf-Sequenz: - [ ] Sensitive Dateien über Telegram blockieren (`.env`, `shadow`, Keys)
- [ ] Output-Sanitizer: API Keys/Tokens in jeder Bot-Antwort maskieren
```bash - [ ] Log-Sanitizer: Keine Secrets im Audit-Log
# Oeffnen: 7000 -> 8000 -> 9000 - [ ] Audit-Log: Jeder Befehl mit Timestamp (DSGVO Art. 5/32)
knock SERVER_IP 7000 8000 9000 - [ ] Self-Update Hardening: SHA256-Hash anzeigen, Admin bestätigt
- [ ] `.env` Permissions-Check beim Bot-Start
# Schliessen: 9000 -> 8000 -> 7000 - [ ] `bot.log`, `.aider.chat.history.md`, `bash_history` aus Snapshots excluden
knock SERVER_IP 9000 8000 7000 - [ ] 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)
Bei jedem Knock kommt eine Telegram-Benachrichtigung mit IP und Zeitstempel. - [ ] Persönlicher Code-Offset (SECURITY_OFFSET in `.env`)
### KI-gestützte Befehlsgenerierung **Features:**
- [ ] Snapshot-Scheduling (`/snapshot schedule daily 03:00`)
``` - [ ] Snapshot-Export als ZIP via Telegram (`/snapshot export <n>`)
/shell installiere nginx - [ ] `/logs` – Systemlogs direkt im Bot
→ KI generiert: apt install -y nginx - [ ] `/docker` – Docker Container Management
→ Gefährlich? Nein → Vorschlag anzeigen - [ ] Port-Knocking Sequenz im Installer wählbar
→ /ja zum Ausführen, oder ignorieren - [ ] Verschlüsselte Snapshots (SNAPSHOT_PASSWORD)
- [ ] Open Interpreter als Aider-Alternative evaluieren
/shell lösche alles in /etc
→ KI generiert: rm -rf /etc/* **Quick-Win Security:**
→ Gefährlich? JA → Verweigert mit Erklärung - [ ] 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
Kein Befehl wird ohne Bestätigung ausgeführt. Gefährliche Befehle werden immer verweigert. - [ ] Rollback-Bombe: 3x falscher 2FA → auto-Rollback + SSH sperren
- [ ] Dead Man's Switch: Kein Lebenszeichen in 24h → Bot sperrt sich
### Snapshot-System
### v0.6.0 – Multi-Server
Vor destruktiven Aktionen automatischer Snapshot (tar.gz). Wichtige Snapshots können mit `/snapshot keep` markiert werden und werden nie automatisch gelöscht.
- [ ] Ein Bot steuert mehrere Server
### Boot-Notification - [ ] Zentrale Snapshot-Verwaltung
- [ ] Serverpark-Übersicht und -Steuerung
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. - [ ] Mehrere Nutzer mit unterschiedlichen Rechten
--- ### v0.7.0 – DSGVO-bewusster Backup-Manager
## Roadmap - [ ] `/backup configs` → immer erlaubt
- [ ] `/backup app` → eigene Anwendungsdaten
### Fertig (v0.1 – v0.5.0) - [ ] `/backup db` → Warnung + Bestätigung bei personenbezogenen Daten
- [ ] Lokale KI bewertet ob Daten DSGVO-relevant sein könnten
- [x] Basis-Installer mit geführtem Setup (8 Schritte)
- [x] Telegram Bot mit Auth Guard (Single-User) ### v0.8.0 – Erweiterte Cloud-API Integration
- [x] Dual-Model KI: Coder (qwen2.5-coder) + Chat (gemma3/gemma4)
- [x] `/shell` + `/ja` – Befehlsgenerierung mit Bestätigung (non-interaktive Befehle) - [ ] Wahl zwischen Free- und Premium-Modellen auf OpenRouter
- [x] `/code` – Aider Code-Agent für autonome Script-Erstellung (Smart Prompt) - [ ] Direkte Anthropic/OpenAI API-Anbindung (neben OpenRouter)
- [x] `/run` – Direkte Shell ohne KI - [ ] Admin steuert pro Befehlstyp: lokal oder Cloud
- [x] `/status` – Systemübersicht (CPU, RAM, Disk, Prozesse) - [ ] Kosten-Tracking für Cloud-API-Nutzung
- [x] `/ssh open/close` – ufw-basierter SSH-Zugang
- [x] Freitext-Chat mit History (letzte 10 Exchanges) ### v0.9.0 – Multi-Interface + Advanced Security
- [x] Snapshot-System (tar.gz, keep/unkeep, auto-cleanup max 20, Paketliste)
- [x] `/snapshot`, `/snapshots`, `/rollback` (inkl. Workspace) - [ ] Matrix/Element Connector (E2E-verschlüsselt, self-hosted)
- [x] Port-Knocking SSH (knockd) mit Telegram-Benachrichtigung - [ ] Web-UI mit HTTPS (self-hosted, Let's Encrypt)
- [x] Boot-Notification nach Server-Neustart (Dienste-Status) - [ ] SSH-CLI als direktes Interface
- [x] Self-Update via Telegram ZIP (inkl. systemd-Services + User-Patch) - [ ] Fake-Modus + Distress-Code (falscher 2FA → stiller Alarm)
- [x] Update-Modus im Installer (mit MGMT_USER aus .env) - [ ] Geo-Fencing (nur erlaubte Länder/IP-Bereiche)
- [x] Multi-Distro Installer (apt/dnf/pacman) - [ ] Buddy-System (zwei Admins für kritische Befehle)
- [x] Ollama Warmup im Hintergrund (Bot sofort bereit) - [ ] Verschlüsselter Rückkanal über Telegram
- [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) ## Langzeit-Vision 🚀
- [x] Lange Antworten werden in Teile gesplittet (kein Abschneiden)
- [x] Mehrsprachig (antwortet in der Sprache des Nutzers) ### v1.0.0 – Intelligent Disaster Recovery
- [x] MGMT_USER Regex-Patch (beliebiger Username funktioniert überall)
- [x] Security-Analyse dokumentiert (Schwachstellen + Maßnahmen in CLOWNFISCHSERVER.md) Server komplett aus Snapshot wiederherstellen – auch auf anderer Hardware oder in einer anderen Umgebung. Die KI analysiert den Snapshot und rekonstruiert die Umgebung.
- [x] DSGVO-Regel: `.env` nur per SSH, nie über Telegram lesbar
- [x] Projekt-Kontext-Datei (CLOWNFISCHSERVER.md) für KI-gestütztes Pair-Programming *"Stelle Server 3 auf Stand von gestern 02:00 Uhr wieder her."*
### v0.5.0 – Cleanup & Stabilität (aktuelles Release) Ziel: Open Source Alternative zu teuren Enterprise DR-Lösungen.
**Bugfixes & Aufräumarbeiten (dieser Release):** ### v2.0.0 – Cross-Distribution Server Migration
- [x] Totes Vault/MCP-Context-System komplett entfernt (war nie verdrahtet, `verify=False`-Lücke)
- [x] Privacy-Fix: Chat-Nachrichten gingen via Vault-Suche an externe Endpoints → entfernt Die eigentliche Revolution: **Ein Snapshot, jedes Zielsystem.**
- [x] `/code` Filepath-Logik: fragiler Ternary-Ausdruck sauber umgeschrieben (kein Crash bei leerem Task)
- [x] Aider-Model-Default vereinheitlicht (zieht sich `OLLAMA_MODEL` aus der Umgebung) Der Clownfischserver versteht nicht nur Dateien, sondern die *Logik* eines Servers:
- [x] Irreführende „Token-Optimization"-Logs (Anthropic-Kosten/Obsidian) aus dem Start-Log entfernt - Welche Pakete sind installiert und wofür?
- Welche Services laufen und wie sind sie konfiguriert?
**Sicherheit & DSGVO (geplant):** - Welche Abhängigkeiten bestehen zwischen Komponenten?
- [ ] Sensitive Dateien über Telegram blockieren (`.env`, `shadow`, Keys)
- [ ] Output-Sanitizer: API Keys/Tokens in jeder Bot-Antwort maskieren 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.
- [ ] Log-Sanitizer: Keine Secrets im Audit-Log
- [ ] Audit-Log: Jeder Befehl mit Timestamp (DSGVO Art. 5/32) **Ziel-Plattformen:**
- [ ] Self-Update Hardening: SHA256-Hash anzeigen, Admin bestätigt - Debian / Ubuntu (heute)
- [ ] `.env` Permissions-Check beim Bot-Start - Arch Linux
- [ ] `bot.log`, `.aider.chat.history.md`, `bash_history` aus Snapshots excluden - Red Hat / Fedora / CentOS
- [ ] Rate-Limiting: Max 30 Befehle/Minute - openSUSE
- [ ] Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut nötig - FreeBSD
- [ ] 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot) - Weitere UNIX-Systeme
- [ ] Persönlicher Code-Offset (SECURITY_OFFSET in `.env`)
**Wie es funktioniert:**
**Features:** 1. KI analysiert Snapshot und erstellt ein distro-neutrales "Server-Profil"
- [ ] Snapshot-Scheduling (`/snapshot schedule daily 03:00`) 2. Profil beschreibt: Dienste, Konfigurationen, Daten, Netzwerk, Nutzer
- [ ] Snapshot-Export als ZIP via Telegram (`/snapshot export <n>`) 3. Auf dem Zielsystem: KI übersetzt das Profil in native Befehle
- [ ] `/logs` – Systemlogs direkt im Bot 4. `apt install nginx` → `pacman -S nginx` → `pkg install nginx`
- [ ] `/docker` – Docker Container Management 5. Konfigurationen werden angepasst (Pfade, Syntax, Permissions)
- [ ] Port-Knocking Sequenz im Installer wählbar
- [ ] Verschlüsselte Snapshots (SNAPSHOT_PASSWORD) *"Migriere meinen Debian-Webserver auf Arch Linux."*
- [ ] Open Interpreter als Aider-Alternative evaluieren
### Weltklasse Integration 🌍
**Quick-Win Security:**
- [ ] Panik-Wort: Admin-definiertes Wort → Bot löscht .env, sperrt alles Clownfischserver als Infrastruktur-Layer für die Weltklasse App – natürliche Sprachsteuerung des gesamten Server-Ökosystems. GDPR-konform, vollständig lokal, kein Cloud-Zwang.
- [ ] 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 > *"Angefangen als einfacher Telegram-Bot – gewachsen zu einer Vision."* 🐠
### v0.6.0 – Multi-Server ---
- [ ] Ein Bot steuert mehrere Server ## Prinzipien 🛡️
- [ ] Zentrale Snapshot-Verwaltung
- [ ] Serverpark-Übersicht und -Steuerung ```
- [ ] Mehrere Nutzer mit unterschiedlichen Rechten 1. Local AI first – Lokal ist Standard. Cloud ist Opt-in.
2. Open Source always – GPL-3.0, keine Ausnahmen
### v0.7.0 – DSGVO-bewusster Backup-Manager 3. DSGVO by design – Datenschutz ist kein Feature, es ist Pflicht
4. Security first – SSH zu, KI prüft, Snapshots sichern
- [ ] `/backup configs` → immer erlaubt 5. Einfachheit – ein Mensch, ein Bot, ein Server
- [ ] `/backup app` → eigene Anwendungsdaten 6. Distro-agnostisch – Linux ist Linux. Die KI kennt den Unterschied.
- [ ] `/backup db` → Warnung + Bestätigung bei personenbezogenen Daten ```
- [ ] Lokale KI bewertet ob Daten DSGVO-relevant sein könnten
### Zur Cloud-Frage
### v0.8.0 – Erweiterte Cloud-API Integration
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.
- [ ] Wahl zwischen Free- und Premium-Modellen auf OpenRouter
- [ ] Direkte Anthropic/OpenAI API-Anbindung (neben OpenRouter) Was wir **nicht** tun:
- [ ] Admin steuert pro Befehlstyp: lokal oder Cloud - Cloud als Voraussetzung
- [ ] Kosten-Tracking für Cloud-API-Nutzung - Daten automatisch an externe APIs senden
- Features hinter Cloud-APIs verstecken
### v0.9.0 – Multi-Interface + Advanced Security
Was wir **ermöglichen** (seit v0.4.12/v0.4.13):
- [ ] Matrix/Element Connector (E2E-verschlüsselt, self-hosted) - Optionaler OpenRouter API-Key in `.env` (kostenlos, keine Kreditkarte)
- [ ] Web-UI mit HTTPS (self-hosted, Let's Encrypt) - Automatischer Fallback: Ollama Timeout → OpenRouter übernimmt
- [ ] SSH-CLI als direktes Interface - Wenn Cloud ausfällt, funktioniert alles weiterhin lokal
- [ ] Fake-Modus + Distress-Code (falscher 2FA → stiller Alarm) - Setup direkt im Bot: ZIP schicken → Bot fragt nach Key → fertig
- [ ] Geo-Fencing (nur erlaubte Länder/IP-Bereiche)
- [ ] Buddy-System (zwei Admins für kritische Befehle) > *Als Admin entscheidest du – nicht das Tool.*
- [ ] Verschlüsselter Rückkanal über Telegram
---
---
## Entwicklung
## Langzeit-Vision 🚀
**Entwickler:** [Mehlzoerwer-Claude](https://github.com/mehlzoerwer-claude)
### v1.0.0 – Intelligent Disaster Recovery
**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. 😄
Server komplett aus Snapshot wiederherstellen – auch auf anderer Hardware oder in einer anderen Umgebung. Die KI analysiert den Snapshot und rekonstruiert die Umgebung.
**Philosophie:** Open Source ohne Wenn und Aber. Wer's nutzt, muss es offen lassen (GPL-3.0).
*"Stelle Server 3 auf Stand von gestern 02:00 Uhr wieder her."*
---
Ziel: Open Source Alternative zu teuren Enterprise DR-Lösungen.
## Lizenz
### v2.0.0 – Cross-Distribution Server Migration
GPL-3.0 - Keep it open. Always.
Die eigentliche Revolution: **Ein Snapshot, jedes Zielsystem.**
Copyright (C) 2025-2026 Mehlzoerwer-Claude
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? *Danke an alle die Ollama, Aider, python-telegram-bot, knockd und ufw gebaut haben.*
*Und an den echten Clownfisch – für die Inspiration.* 🐠
Diese Logik ist distro-unabhängig. Ein Debian-Server mit nginx + PostgreSQL + Docker hat die gleiche *Absicht* wie ein Arch-Server mit denselben Diensten – nur andere Paketnamen, Pfade und Init-Systeme.
**Ziel-Plattformen:**
- Debian / Ubuntu (heute)
- Arch Linux
- Red Hat / Fedora / CentOS
- openSUSE
- FreeBSD
- Weitere UNIX-Systeme
**Wie es funktioniert:**
1. KI analysiert Snapshot und erstellt ein distro-neutrales "Server-Profil"
2. Profil beschreibt: Dienste, Konfigurationen, Daten, Netzwerk, Nutzer
3. Auf dem Zielsystem: KI übersetzt das Profil in native Befehle
4. `apt install nginx` → `pacman -S nginx` → `pkg install nginx`
5. Konfigurationen werden angepasst (Pfade, Syntax, Permissions)
*"Migriere meinen Debian-Webserver auf Arch Linux."*
### Weltklasse Integration 🌍
Clownfischserver als Infrastruktur-Layer für die Weltklasse App – natürliche Sprachsteuerung des gesamten Server-Ökosystems. GDPR-konform, vollständig lokal, kein Cloud-Zwang.
---
> *"Angefangen als einfacher Telegram-Bot – gewachsen zu einer Vision."* 🐠
---
## Prinzipien 🛡️
```
1. Local AI first – Lokal ist Standard. Cloud ist Opt-in.
2. Open Source always – GPL-3.0, keine Ausnahmen
3. DSGVO by design – Datenschutz ist kein Feature, es ist Pflicht
4. Security first – SSH zu, KI prüft, Snapshots sichern
5. Einfachheit – ein Mensch, ein Bot, ein Server
6. Distro-agnostisch – Linux ist Linux. Die KI kennt den Unterschied.
```
### Zur Cloud-Frage
Clownfischserver ist und bleibt local-first. Aber wir sind keine Puristen: wenn ein Admin einen OpenRouter-API-Key hat und ihn für komplexe Aufgaben nutzen will, soll er das können. Die Entscheidung liegt beim Admin, nicht beim Tool.
Was wir **nicht** tun:
- Cloud als Voraussetzung
- Daten automatisch an externe APIs senden
- Features hinter Cloud-APIs verstecken
Was wir **ermöglichen** (seit v0.4.12/v0.4.13):
- Optionaler OpenRouter API-Key in `.env` (kostenlos, keine Kreditkarte)
- Automatischer Fallback: Ollama Timeout → OpenRouter übernimmt
- Wenn Cloud ausfällt, funktioniert alles weiterhin lokal
- Setup direkt im Bot: ZIP schicken → Bot fragt nach Key → fertig
> *Als Admin entscheidest du – nicht das Tool.*
---
## Entwicklung
**Entwickler:** [Mehlzoerwer-Claude](https://github.com/mehlzoerwer-claude)
**KI-Assistent:** Dieses Projekt wurde mit [Claude](https://claude.ai) (Anthropic) als Pair-Programming Partner entwickelt. Claude ist der einzige nicht-Open-Source Teil dieser Zusammenarbeit. 😄
**Philosophie:** Open Source ohne Wenn und Aber. Wer's nutzt, muss es offen lassen (GPL-3.0).
---
## Lizenz
GPL-3.0 - Keep it open. Always.
Copyright (C) 2025-2026 Mehlzoerwer-Claude
---
*Danke an alle die Ollama, Aider, python-telegram-bot, knockd und ufw gebaut haben.*
*Und an den echten Clownfisch – für die Inspiration.* 🐠
+128 -128
View File
@@ -1,128 +1,128 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
🐠 Clownfischserver 🐠 Clownfischserver
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always. License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver Repo: https://github.com/mehlzoerwer-claude/clownfischserver
""" """
import asyncio import asyncio
import logging import logging
import os import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
INSTALL_DIR = os.getenv("INSTALL_DIR", "/opt/clownfischserver") INSTALL_DIR = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
AIDER_WORKDIR = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace") AIDER_WORKDIR = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:2b")
TIMEOUT = 600 # 10 minutes for aider tasks on CPU TIMEOUT = 600 # 10 minutes for aider tasks on CPU
class AiderWrapper: class AiderWrapper:
def __init__(self): def __init__(self):
self.workdir = Path(AIDER_WORKDIR) self.workdir = Path(AIDER_WORKDIR)
self.workdir.mkdir(parents=True, exist_ok=True) self.workdir.mkdir(parents=True, exist_ok=True)
self.venv_python = Path(INSTALL_DIR) / "aider-venv" / "bin" / "python3" self.venv_python = Path(INSTALL_DIR) / "aider-venv" / "bin" / "python3"
self.venv_aider = Path(INSTALL_DIR) / "aider-venv" / "bin" / "aider" self.venv_aider = Path(INSTALL_DIR) / "aider-venv" / "bin" / "aider"
logger.info(f"AiderWrapper init: workdir={self.workdir}") logger.info(f"AiderWrapper init: workdir={self.workdir}")
async def run(self, task: str, target_file: str = None) -> str: async def run(self, task: str, target_file: str = None) -> str:
""" """
Run aider in script mode for a coding task. Run aider in script mode for a coding task.
Returns the output/result as string. Returns the output/result as string.
""" """
logger.info(f"Aider task: {task[:100]}") logger.info(f"Aider task: {task[:100]}")
# Determine working directory and files # Determine working directory and files
workdir = self.workdir workdir = self.workdir
# Build aider command # Build aider command
cmd = self._build_aider_command(task, target_file) cmd = self._build_aider_command(task, target_file)
try: try:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
*cmd, *cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=str(workdir), cwd=str(workdir),
env={ env={
**os.environ, **os.environ,
"OLLAMA_API_BASE": OLLAMA_BASE_URL, "OLLAMA_API_BASE": OLLAMA_BASE_URL,
"HOME": f"/home/{os.getenv('MGMT_USER', 'clownfish')}", "HOME": f"/home/{os.getenv('MGMT_USER', 'clownfish')}",
} }
) )
try: try:
stdout, stderr = await asyncio.wait_for( stdout, stderr = await asyncio.wait_for(
process.communicate(), process.communicate(),
timeout=TIMEOUT timeout=TIMEOUT
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
process.kill() process.kill()
return f"⏱️ Aider Timeout nach {TIMEOUT}s. Task zu komplex oder Modell überlastet." return f"⏱️ Aider Timeout nach {TIMEOUT}s. Task zu komplex oder Modell überlastet."
stdout_str = stdout.decode("utf-8", errors="replace").strip() stdout_str = stdout.decode("utf-8", errors="replace").strip()
stderr_str = stderr.decode("utf-8", errors="replace").strip() stderr_str = stderr.decode("utf-8", errors="replace").strip()
# Build response # Build response
result_parts = [] result_parts = []
if stdout_str: if stdout_str:
result_parts.append(stdout_str) result_parts.append(stdout_str)
if stderr_str and process.returncode != 0: if stderr_str and process.returncode != 0:
result_parts.append(f"[STDERR]\n{stderr_str}") result_parts.append(f"[STDERR]\n{stderr_str}")
if process.returncode == 0: if process.returncode == 0:
result_parts.insert(0, f"✅ Aider Task abgeschlossen\nArbeitsverzeichnis: {workdir}\n") result_parts.insert(0, f"✅ Aider Task abgeschlossen\nArbeitsverzeichnis: {workdir}\n")
else: else:
result_parts.insert(0, f"⚠️ Aider beendet mit Code {process.returncode}\n") result_parts.insert(0, f"⚠️ Aider beendet mit Code {process.returncode}\n")
return "\n".join(result_parts) or "✓ Task abgeschlossen (keine Ausgabe)" return "\n".join(result_parts) or "✓ Task abgeschlossen (keine Ausgabe)"
except FileNotFoundError: except FileNotFoundError:
return ( return (
"❌ Aider nicht gefunden. Bitte prüfen:\n" "❌ Aider nicht gefunden. Bitte prüfen:\n"
f" ls {self.venv_aider}\n" f" ls {self.venv_aider}\n"
" Ggf. neu installieren mit: pip install aider-chat" " Ggf. neu installieren mit: pip install aider-chat"
) )
except Exception as e: except Exception as e:
logger.error(f"Aider execution error: {e}", exc_info=True) logger.error(f"Aider execution error: {e}", exc_info=True)
return f"❌ Aider Fehler: {e}" return f"❌ Aider Fehler: {e}"
def _build_aider_command(self, task: str, target_file: str = None) -> list: def _build_aider_command(self, task: str, target_file: str = None) -> list:
"""Build the aider command with appropriate flags""" """Build the aider command with appropriate flags"""
cmd = [ cmd = [
str(self.venv_aider), str(self.venv_aider),
"--model", f"ollama/{OLLAMA_MODEL}", "--model", f"ollama/{OLLAMA_MODEL}",
"--no-git", # Don't require git "--no-git", # Don't require git
"--yes", # Auto-confirm all changes "--yes", # Auto-confirm all changes
"--no-pretty", # Plain output for telegram "--no-pretty", # Plain output for telegram
"--no-show-model-warnings", # Skip env var warnings "--no-show-model-warnings", # Skip env var warnings
"--no-analytics", # No analytics prompts "--no-analytics", # No analytics prompts
"--edit-format", "diff", # Better format for small models "--edit-format", "diff", # Better format for small models
"--message", task, # The task as a message "--message", task, # The task as a message
] ]
if target_file: if target_file:
cmd.append(target_file) cmd.append(target_file)
return cmd return cmd
async def list_workspace_files(self) -> str: async def list_workspace_files(self) -> str:
"""List files in the aider workspace""" """List files in the aider workspace"""
try: try:
files = list(self.workdir.rglob("*")) files = list(self.workdir.rglob("*"))
if not files: if not files:
return f"📁 Workspace {self.workdir} ist leer." return f"📁 Workspace {self.workdir} ist leer."
file_list = "\n".join([ file_list = "\n".join([
f" {'📁' if f.is_dir() else '📄'} {f.relative_to(self.workdir)}" f" {'📁' if f.is_dir() else '📄'} {f.relative_to(self.workdir)}"
for f in sorted(files)[:50] for f in sorted(files)[:50]
]) ])
return f"📁 Workspace: {self.workdir}\n\n{file_list}" return f"📁 Workspace: {self.workdir}\n\n{file_list}"
except Exception as e: except Exception as e:
return f"❌ Fehler beim Lesen des Workspace: {e}" return f"❌ Fehler beim Lesen des Workspace: {e}"
-205
View File
@@ -1,205 +0,0 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver – Audit Trail (DSGVO Art. 5 + 32)
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0
Writes JSON-line entries for every privileged action.
Pseudonymized: stores only numeric Telegram user_ids, never usernames.
Compliance:
- Art. 5(1)(a) lawfulness/transparency: every action is recorded.
- Art. 5(1)(c) data minimization: numeric IDs only, command/result trimmed.
- Art. 5(1)(e) storage limitation: prune_old_logs() drops entries older
than AUDIT_RETENTION_DAYS (default 90).
- Art. 32 integrity: append-only file, 0600 permissions.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# How much of a command/result we keep – longer payloads are truncated
# to honour data minimization. Configurable via env.
_MAX_DETAIL_LEN = int(os.getenv("AUDIT_MAX_DETAIL_LEN", "2000"))
# Lock for append writes – multiple coroutines may log concurrently.
_write_lock = threading.Lock()
def _default_audit_path() -> str:
install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
return os.getenv("AUDIT_LOG_PATH", os.path.join(install_dir, "audit.jsonl"))
def _retention_days() -> int:
try:
return max(0, int(os.getenv("AUDIT_RETENTION_DAYS", "90")))
except ValueError:
return 90
def _trim(value: Any) -> Any:
if isinstance(value, str) and len(value) > _MAX_DETAIL_LEN:
return value[:_MAX_DETAIL_LEN] + f"... (truncated, {len(value)} chars total)"
return value
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def log_action(
user_id: str | int | None,
action: str,
*,
role: str | None = None,
result: str = "ok",
details: dict[str, Any] | None = None,
path: str | None = None,
) -> None:
"""Append a single audit record. Never raises – audit failure must not
break the bot."""
entry: dict[str, Any] = {
"ts": _now_iso(),
"user_id": str(user_id) if user_id is not None else None,
"action": action,
"result": result,
}
if role:
entry["role"] = role
if details:
entry["details"] = {k: _trim(v) for k, v in details.items()}
target = path or _default_audit_path()
try:
Path(target).parent.mkdir(parents=True, exist_ok=True)
line = json.dumps(entry, ensure_ascii=False) + "\n"
with _write_lock:
with open(target, "a", encoding="utf-8") as fh:
fh.write(line)
try:
os.chmod(target, 0o600)
except OSError:
# chmod can fail on non-POSIX FS (e.g. tests on Windows) –
# the JSONL content is still written.
pass
except Exception as e:
logger.error(f"Audit log write failed: {e}")
def log_shell(user_id, command: str, *, role: str | None = None,
status: str = "executed", returncode: int | None = None,
stderr: str | None = None, path: str | None = None) -> None:
details: dict[str, Any] = {"command": command}
if returncode is not None:
details["returncode"] = returncode
if stderr:
details["stderr"] = stderr
log_action(user_id, "shell.execute", role=role, result=status,
details=details, path=path)
def log_shell_proposed(user_id, description: str, command: str,
role: str | None = None, dangerous: bool = False,
path: str | None = None) -> None:
log_action(
user_id,
"shell.proposed",
role=role,
result="dangerous" if dangerous else "ok",
details={"description": description, "command": command},
path=path,
)
def log_code(user_id, task: str, *, role: str | None = None,
status: str = "ok", path: str | None = None) -> None:
log_action(user_id, "code.run", role=role, result=status,
details={"task": task}, path=path)
def log_approval(user_id, command: str, *, role: str | None = None,
approved: bool = True, path: str | None = None) -> None:
log_action(
user_id,
"shell.approval",
role=role,
result="approved" if approved else "rejected",
details={"command": command},
path=path,
)
def log_denied(user_id, action: str, *, role: str | None = None,
reason: str = "insufficient_role",
path: str | None = None) -> None:
log_action(user_id, action, role=role, result="denied",
details={"reason": reason}, path=path)
def prune_old_logs(path: str | None = None,
retention_days: int | None = None) -> int:
"""Rewrite the audit file, dropping entries older than retention_days.
Returns the number of dropped lines. retention_days=0 disables pruning.
Uses an atomic temp-file + rename so a crash mid-prune cannot corrupt
the audit trail.
"""
target = path or _default_audit_path()
days = retention_days if retention_days is not None else _retention_days()
if days <= 0 or not os.path.exists(target):
return 0
cutoff = time.time() - days * 86400
tmp_path = target + ".prune.tmp"
dropped = 0
kept = 0
try:
with _write_lock:
with open(target, "r", encoding="utf-8") as src, \
open(tmp_path, "w", encoding="utf-8") as dst:
for line in src:
raw = line.strip()
if not raw:
continue
try:
record = json.loads(raw)
ts = record.get("ts", "")
# ISO 8601 – parse with fromisoformat (UTC suffix safe)
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
if dt.timestamp() < cutoff:
dropped += 1
continue
except (ValueError, KeyError):
# Malformed line: keep it rather than silently dropping
# forensic evidence.
pass
dst.write(line if line.endswith("\n") else line + "\n")
kept += 1
os.replace(tmp_path, target)
try:
os.chmod(target, 0o600)
except OSError:
pass
logger.info(f"Audit prune: kept={kept} dropped={dropped} retention={days}d")
except Exception as e:
logger.error(f"Audit prune failed: {e}")
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except OSError:
pass
return 0
return dropped
-189
View File
@@ -1,189 +0,0 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver – Audit Trail Query (Phase 2b)
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0
Read-side companion to audit_log.py. Filters/sorts JSONL audit records for
the `/logs` admin command.
The query layer is intentionally separate from the writer so that:
- Tests can exercise filtering without touching real audit data.
- The writer stays append-only and minimal.
- Future query backends (SQLite, OpenSearch) can swap in without touching
the writer.
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime, timezone, timedelta
from typing import Any, Iterable
logger = logging.getLogger(__name__)
def _default_audit_path() -> str:
install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
return os.getenv("AUDIT_LOG_PATH", os.path.join(install_dir, "audit.jsonl"))
def _parse_ts(record: dict) -> datetime | None:
"""Best-effort parse of the record's ts field. Returns None on
malformed timestamps (caller decides how to handle)."""
ts = record.get("ts")
if not isinstance(ts, str):
return None
try:
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
except ValueError:
return None
def read_all(path: str | None = None) -> list[dict]:
"""Load every audit record from the JSONL file. Malformed lines are
skipped with a debug log (the writer keeps them for forensic purposes,
but the query layer prefers a clean result set)."""
target = path or _default_audit_path()
if not os.path.exists(target):
return []
out: list[dict] = []
try:
with open(target, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
logger.debug(f"audit_query: skipping malformed line")
continue
except OSError as e:
logger.error(f"audit_query: cannot read {target}: {e}")
return []
return out
def filter_records(
records: Iterable[dict],
*,
user_id: str | int | None = None,
action: str | None = None,
action_prefix: str | None = None,
result: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
) -> list[dict]:
"""Apply filters and return matching records, newest first.
Filters are AND-combined. None values are ignored. action/result match
exactly; action_prefix matches if the record's action starts with it.
"""
target_user = str(user_id) if user_id is not None else None
matched: list[dict] = []
for rec in records:
if target_user is not None and rec.get("user_id") != target_user:
continue
if action is not None and rec.get("action") != action:
continue
if action_prefix is not None:
rec_action = rec.get("action", "")
if not isinstance(rec_action, str) or not rec_action.startswith(action_prefix):
continue
if result is not None and rec.get("result") != result:
continue
if since is not None or until is not None:
dt = _parse_ts(rec)
if dt is None:
# Malformed ts – exclude when caller asked for a date window.
continue
if since is not None and dt < since:
continue
if until is not None and dt > until:
continue
matched.append(rec)
# Newest first – stable sort by ts (records without ts go to the bottom).
matched.sort(key=lambda r: r.get("ts", ""), reverse=True)
return matched
def query(
*,
user_id: str | int | None = None,
action: str | None = None,
action_prefix: str | None = None,
result: str | None = None,
days: int | None = None,
limit: int = 20,
path: str | None = None,
) -> list[dict]:
"""Convenience wrapper: read + filter in one call.
`days` is a shortcut for `since = now - days`. limit clamps the
returned record count (use limit=0 for no limit)."""
since = None
if days is not None and days > 0:
since = datetime.now(timezone.utc) - timedelta(days=days)
records = read_all(path=path)
matched = filter_records(
records,
user_id=user_id,
action=action,
action_prefix=action_prefix,
result=result,
since=since,
)
if limit and limit > 0:
matched = matched[:limit]
return matched
def format_record(rec: dict) -> str:
"""Render one audit record as a single compact line for Telegram."""
ts = rec.get("ts", "?")
# Trim ts down to HH:MM:SS or YYYY-MM-DD HH:MM:SS for readability.
if isinstance(ts, str) and len(ts) >= 19:
ts_short = ts[:19].replace("T", " ")
else:
ts_short = str(ts)
uid = rec.get("user_id") or "—"
action = rec.get("action", "?")
result = rec.get("result", "?")
role = rec.get("role")
role_part = f" [{role}]" if role else ""
details = rec.get("details") or {}
detail_str = ""
if isinstance(details, dict):
# Pick the single most-informative key per common action.
for key in ("command", "task", "reason", "snapshot", "method", "tier"):
if key in details:
val = str(details[key])
if len(val) > 80:
val = val[:77] + "..."
detail_str = f" {val}"
break
return f"{ts_short} {uid}{role_part} {action}={result}{detail_str}"
def summarize(records: list[dict]) -> dict[str, Any]:
"""Aggregate counts useful for the /logs header."""
by_action: dict[str, int] = {}
by_user: dict[str, int] = {}
by_result: dict[str, int] = {}
for r in records:
act = r.get("action", "?")
by_action[act] = by_action.get(act, 0) + 1
uid = r.get("user_id") or "—"
by_user[uid] = by_user.get(uid, 0) + 1
res = r.get("result", "?")
by_result[res] = by_result.get(res, 0) + 1
return {"total": len(records), "by_action": by_action,
"by_user": by_user, "by_result": by_result}
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver – Role-Based Access Control
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0
Implements DSGVO Art. 32 access control:
- operator: full execution rights (/shell, /code, /run, /ja, /ssh, /snapshot)
- approver: can approve pending operations (/ja)
- viewer: read-only access (/status, /snapshots, /help)
Backwards compatible: if no role env vars are set but TELEGRAM_CHAT_ID
is configured, that user is auto-granted the operator role.
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
ROLE_OPERATOR = "operator"
ROLE_APPROVER = "approver"
ROLE_VIEWER = "viewer"
VALID_ROLES = (ROLE_OPERATOR, ROLE_APPROVER, ROLE_VIEWER)
# Operators implicitly hold approver + viewer rights.
# Approvers implicitly hold viewer rights.
_ROLE_INHERITANCE: dict[str, tuple[str, ...]] = {
ROLE_OPERATOR: (ROLE_OPERATOR, ROLE_APPROVER, ROLE_VIEWER),
ROLE_APPROVER: (ROLE_APPROVER, ROLE_VIEWER),
ROLE_VIEWER: (ROLE_VIEWER,),
}
def _parse_id_list(raw: str | None) -> set[str]:
if not raw:
return set()
return {part.strip() for part in raw.split(",") if part.strip()}
def _load_role_map() -> dict[str, str]:
"""Read role config from env vars.
Returns {user_id_str: highest_role}. If a user appears in multiple
role lists, the strongest role wins (operator > approver > viewer).
"""
operators = _parse_id_list(os.getenv("CLOWNFISCH_OPERATORS"))
approvers = _parse_id_list(os.getenv("CLOWNFISCH_APPROVERS"))
viewers = _parse_id_list(os.getenv("CLOWNFISCH_VIEWERS"))
role_map: dict[str, str] = {}
for uid in viewers:
role_map[uid] = ROLE_VIEWER
for uid in approvers:
role_map[uid] = ROLE_APPROVER
for uid in operators:
role_map[uid] = ROLE_OPERATOR
# Backwards compatibility: legacy TELEGRAM_CHAT_ID becomes operator
# when no explicit role lists are defined.
if not role_map:
legacy = os.getenv("TELEGRAM_CHAT_ID", "").strip()
if legacy:
role_map[legacy] = ROLE_OPERATOR
logger.info(
"Auth: no role lists configured – granting operator to legacy "
"TELEGRAM_CHAT_ID for backwards compatibility."
)
return role_map
def get_role(user_id: str | int | None) -> str | None:
"""Return the role of a user, or None if unauthorized."""
if user_id is None:
return None
return _load_role_map().get(str(user_id))
def has_role(user_id: str | int | None, *required: str) -> bool:
"""Check if the user satisfies ANY of the required roles.
Inheritance applies: an operator passes a check for approver/viewer.
"""
role = get_role(user_id)
if role is None:
return False
granted = _ROLE_INHERITANCE.get(role, (role,))
return any(r in granted for r in required)
def is_authorized(user_id: str | int | None) -> bool:
"""True if the user has any role at all."""
return get_role(user_id) is not None
def list_users_with_role(role: str) -> list[str]:
"""Return all user_ids that hold (or inherit) the given role."""
if role not in VALID_ROLES:
return []
return [uid for uid, r in _load_role_map().items()
if role in _ROLE_INHERITANCE.get(r, (r,))]
def describe_roles() -> dict[str, list[str]]:
"""Snapshot of current role assignments – useful for /status or audits."""
role_map = _load_role_map()
result: dict[str, list[str]] = {r: [] for r in VALID_ROLES}
for uid, role in role_map.items():
result[role].append(uid)
return result
+755 -1081
View File
File diff suppressed because it is too large Load Diff
-322
View File
@@ -1,322 +0,0 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver – LLM Provider Router (Phase 3)
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0
Replaces the previously hard-wired Ollama+OpenRouter fallback logic with a
config-driven primary/fallback chain.
Env vars:
LLM_PRIMARY_PROVIDER = "ollama" | "openrouter" (default: ollama)
LLM_PRIMARY_MODEL = model id for the primary provider
LLM_PRIMARY_MODEL_FAST = optional faster model for chat replies (ollama only)
LLM_PRIMARY_NUM_CTX = context window override (ollama)
LLM_FALLBACK_PROVIDER = "openrouter" | "ollama" | "" ("" disables fallback)
LLM_FALLBACK_MODEL = model id for fallback provider
TRACK_LLM_COSTS = "true"|"false" – currently informational only
Backwards compatibility:
- If LLM_*_PROVIDER vars are absent we still default to Ollama primary and,
when OPENROUTER_API_KEY exists, OpenRouter as fallback. Existing
OLLAMA_MODEL / OLLAMA_MODEL_FAST / OLLAMA_NUM_CTX / OPENROUTER_API_KEY
env vars are honoured.
Routing policy (intentionally explicit, not "self-improving"):
- Primary is tried first. Only routable errors (network, timeout, provider
unavailable) trigger fallback. Hard errors (invalid JSON, malformed prompt,
bad API key) propagate so callers see them.
- Every routing decision is written to the audit trail as `llm.route` so
admins can see which model produced which output (DSGVO Art. 5(1)(a)
transparency).
"""
from __future__ import annotations
import logging
import os
from typing import Any, Awaitable, Callable, Optional
from ollama_client import OllamaClient
from openrouter_client import OpenRouterClient
try: # audit is optional – router must work even if audit module is missing
import audit_log
except ImportError: # pragma: no cover
audit_log = None # type: ignore
logger = logging.getLogger(__name__)
PROVIDER_OLLAMA = "ollama"
PROVIDER_OPENROUTER = "openrouter"
# Error substrings that should trigger a fallback rather than propagate.
# Kept narrow on purpose – we don't want to mask programmer errors.
_ROUTABLE_ERROR_FRAGMENTS = (
"Timeout",
"nicht erreichbar",
"ConnectionError",
"Connection refused",
"API Key nicht konfiguriert",
)
def _is_routable_error(exc: BaseException) -> bool:
msg = str(exc)
return any(frag in msg for frag in _ROUTABLE_ERROR_FRAGMENTS)
class LLMProvider:
"""Thin interface every provider satisfies."""
name: str = "base"
model: str = ""
def is_available(self) -> bool:
raise NotImplementedError
async def chat(self, message: str, history: list | None = None) -> str:
raise NotImplementedError
async def generate_shell_command(self, description: str) -> dict:
raise NotImplementedError
async def explain_output(self, command: str, output: str,
error: str = "") -> str:
raise NotImplementedError
class OllamaProvider(LLMProvider):
name = PROVIDER_OLLAMA
def __init__(self, model: Optional[str] = None,
model_fast: Optional[str] = None,
num_ctx: Optional[int] = None,
base_url: Optional[str] = None):
# Pass openrouter_client=None so the underlying client does NOT
# perform its own fallback – the router owns that decision now.
self.client = OllamaClient(
openrouter_client=None,
model=model,
model_fast=model_fast,
num_ctx=num_ctx,
base_url=base_url,
)
self.model = self.client.model
def is_available(self) -> bool:
# Connection health is checked at call time; we always advertise
# ourselves as available so the router will attempt and trigger
# fallback on actual failure.
return True
async def chat(self, message, history=None):
return await self.client.chat(message, history)
async def generate_shell_command(self, description):
return await self.client.generate_shell_command(description)
async def explain_output(self, command, output, error=""):
return await self.client.explain_output(command, output, error)
class OpenRouterProvider(LLMProvider):
name = PROVIDER_OPENROUTER
def __init__(self, model: Optional[str] = None):
self.client = OpenRouterClient()
# OpenRouterClient reads OPENROUTER_API_KEY at import time. We
# only override the model id, leaving the API key handling alone.
self.model = model or "openrouter/free"
if model:
# Patch the FREE_MODELS dict so chat/coder both use the
# configured model. Limited blast radius – it only affects
# this OpenRouterClient instance, not the module constant.
self._patch_model(model)
def _patch_model(self, model: str) -> None:
# The underlying OpenRouterClient hard-codes FREE_MODELS["coder"]/
# ["chat"] at call sites. Wrap _chat_sync so every call resolves
# to the user-pinned model regardless of what callers pass in.
pinned = model
original_chat_sync = self.client._chat_sync
def wrapper(messages, system=None, model=None):
return original_chat_sync(messages, system=system, model=pinned)
self.client._chat_sync = wrapper # type: ignore[assignment]
def is_available(self) -> bool:
return self.client.is_available()
async def chat(self, message, history=None):
return await self.client.chat(message, history)
async def generate_shell_command(self, description):
return await self.client.generate_shell_command(description)
async def explain_output(self, command, output, error=""):
return await self.client.explain_output(command, output, error)
def _build_provider(kind: str) -> Optional[LLMProvider]:
"""Build the provider for kind ∈ {"primary", "fallback"}.
Returns None if the configuration disables this tier (e.g. fallback
requested but no key available).
"""
explicit = os.getenv(f"LLM_{kind.upper()}_PROVIDER", "").strip().lower()
if kind == "primary":
provider_name = explicit or PROVIDER_OLLAMA
else:
# Fallback default: openrouter only if API key is configured.
if explicit:
provider_name = explicit
elif os.getenv("OPENROUTER_API_KEY"):
provider_name = PROVIDER_OPENROUTER
else:
return None
if provider_name in ("", "none", "off", "disabled"):
return None
model = os.getenv(f"LLM_{kind.upper()}_MODEL", "").strip() or None
if provider_name == PROVIDER_OLLAMA:
model_fast = os.getenv(f"LLM_{kind.upper()}_MODEL_FAST", "").strip() or None
num_ctx_raw = os.getenv(f"LLM_{kind.upper()}_NUM_CTX", "").strip()
num_ctx = int(num_ctx_raw) if num_ctx_raw.isdigit() else None
return OllamaProvider(model=model, model_fast=model_fast,
num_ctx=num_ctx)
if provider_name == PROVIDER_OPENROUTER:
provider = OpenRouterProvider(model=model)
# Skip the tier if OpenRouter is configured but key is missing.
if not provider.is_available():
logger.warning(
f"LLM router: {kind} provider 'openrouter' has no API key – disabled"
)
return None
return provider
logger.error(f"LLM router: unknown {kind} provider '{provider_name}'")
return None
_BUILD_FROM_ENV = object() # sentinel: distinguish "not passed" from "None"
class LLMRouter:
"""Drop-in replacement for the old OllamaClient surface.
Exposes the same async methods (`chat`, `generate_shell_command`,
`explain_output`) plus a small status surface (`primary`, `fallback`,
`describe()`).
Pass `primary=None` / `fallback=None` to explicitly disable a tier;
omit the kwarg entirely to build the tier from environment.
"""
def __init__(self, primary=_BUILD_FROM_ENV, fallback=_BUILD_FROM_ENV):
self.primary = _build_provider("primary") if primary is _BUILD_FROM_ENV else primary
self.fallback = _build_provider("fallback") if fallback is _BUILD_FROM_ENV else fallback
if self.primary is None and self.fallback is None:
logger.error("LLMRouter: neither primary nor fallback configured!")
# ---- public describe ------------------------------------------------
def describe(self) -> dict:
def _info(p: LLMProvider | None) -> dict | None:
if p is None:
return None
return {"provider": p.name, "model": p.model,
"available": p.is_available()}
return {"primary": _info(self.primary), "fallback": _info(self.fallback)}
# ---- dispatcher -----------------------------------------------------
async def _dispatch(self, method_name: str, *args, **kwargs) -> Any:
attempts: list[tuple[str, LLMProvider]] = []
if self.primary is not None:
attempts.append(("primary", self.primary))
if self.fallback is not None:
attempts.append(("fallback", self.fallback))
if not attempts:
raise RuntimeError(
"Kein LLM-Provider konfiguriert (LLM_PRIMARY_PROVIDER fehlt)"
)
last_error: Optional[BaseException] = None
for tier, provider in attempts:
if not provider.is_available():
self._audit("skip", tier, provider, method_name,
reason="provider_unavailable")
continue
try:
result = await getattr(provider, method_name)(*args, **kwargs)
self._audit("hit", tier, provider, method_name)
return result
except Exception as e:
last_error = e
routable = _is_routable_error(e)
self._audit("error", tier, provider, method_name,
reason=str(e)[:200],
routable=routable)
if not routable:
# Hard error – don't mask it with a fallback.
raise
# Otherwise loop to next tier.
continue
# All tiers exhausted.
raise RuntimeError(
f"Alle LLM-Provider fehlgeschlagen für {method_name}: {last_error}"
)
def _audit(self, status: str, tier: str, provider: LLMProvider,
method: str, **extra) -> None:
if audit_log is None:
return
try:
details = {"tier": tier, "provider": provider.name,
"model": provider.model, "method": method}
details.update(extra)
audit_log.log_action(None, "llm.route", result=status,
details=details)
except Exception as e: # pragma: no cover – audit must never fail
logger.debug(f"audit emit failed: {e}")
# ---- public surface -------------------------------------------------
async def chat(self, message: str, history: list | None = None) -> str:
return await self._dispatch("chat", message, history)
async def generate_shell_command(self, description: str) -> dict:
return await self._dispatch("generate_shell_command", description)
async def explain_output(self, command: str, output: str,
error: str = "") -> str:
return await self._dispatch("explain_output", command, output, error)
# ---- convenience for the warmup path in bot.py ----------------------
@property
def ollama_models(self) -> list[str]:
"""Return distinct Ollama model ids configured across tiers –
used by the warmup thread in bot.py."""
models: list[str] = []
for p in (self.primary, self.fallback):
if isinstance(p, OllamaProvider):
if p.client.model and p.client.model not in models:
models.append(p.client.model)
if (p.client.model_fast
and p.client.model_fast not in models):
models.append(p.client.model_fast)
return models
@property
def ollama_base_url(self) -> str:
for p in (self.primary, self.fallback):
if isinstance(p, OllamaProvider):
return p.client.base_url
return os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
def has_fallback(self) -> bool:
return self.fallback is not None and self.fallback.is_available()
+234 -238
View File
@@ -1,238 +1,234 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
🐠 Clownfischserver 🐠 Clownfischserver
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always. License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver Repo: https://github.com/mehlzoerwer-claude/clownfischserver
""" """
import json import json
import logging import logging
import os import os
import re import re
import asyncio import asyncio
import requests import requests
from typing import Optional from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b")
OLLAMA_MODEL_FAST = os.getenv("OLLAMA_MODEL_FAST", "") OLLAMA_MODEL_FAST = os.getenv("OLLAMA_MODEL_FAST", "")
OLLAMA_NUM_CTX = int(os.getenv("OLLAMA_NUM_CTX", "4096")) 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. 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. Convert the user request into a single non-interactive bash command.
Respond with ONLY a JSON object. Respond with ONLY a JSON object.
CRITICAL RULES: CRITICAL RULES:
- Commands run WITHOUT a terminal. No interactive commands like nano, vim, crontab -e, less, top. - 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 non-interactive alternatives: echo/tee for files, /etc/cron.d/ for cron jobs, sed for edits.
- Use sudo when root permissions are needed. - Use sudo when root permissions are needed.
- Chain commands with && if needed. - Chain commands with && if needed.
Example request: "show free ram" Example request: "show free ram"
Example response: {{"dangerous":false,"reason":"","command":"free -h"}} Example response: {{"dangerous":false,"reason":"","command":"free -h"}}
Example request: "installiere nginx" Example request: "installiere nginx"
Example response: {{"dangerous":false,"reason":"","command":"sudo apt install -y 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 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 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 request: "schreibe hello world in eine datei"
Example response: {{"dangerous":false,"reason":"","command":"echo 'hello world' | sudo tee /tmp/hello.txt"}} Example response: {{"dangerous":false,"reason":"","command":"echo 'hello world' | sudo tee /tmp/hello.txt"}}
Example request: "starte nginx neu" Example request: "starte nginx neu"
Example response: {{"dangerous":false,"reason":"","command":"sudo systemctl restart nginx"}} Example response: {{"dangerous":false,"reason":"","command":"sudo systemctl restart nginx"}}
Example request: "lösche alles in /etc" Example request: "lösche alles in /etc"
Example response: {{"dangerous":true,"reason":"Würde Systemkonfiguration zerstören","command":"rm -rf /etc/*"}} Example response: {{"dangerous":true,"reason":"Würde Systemkonfiguration zerstören","command":"rm -rf /etc/*"}}
Now generate JSON for this request: "{description}" Now generate JSON for this request: "{description}"
IMPORTANT: Respond with ONLY the JSON object. No other text. No markdown.""" 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. 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. 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.""" Wenn du ein Befehlsergebnis erklärst, sei präzise und verständlich."""
def _extract_json(raw: str) -> dict: def _extract_json(raw: str) -> dict:
"""Extract JSON from Ollama response, handling markdown wrappers and stray text.""" """Extract JSON from Ollama response, handling markdown wrappers and stray text."""
text = raw.strip() text = raw.strip()
text = re.sub(r"^```(?:json)?\s*", "", text) text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text) text = re.sub(r"\s*```$", "", text)
text = text.strip() text = text.strip()
try: try:
return json.loads(text) return json.loads(text)
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
match = re.search(r"\{[^{}]*\}", text, re.DOTALL) match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
if match: if match:
try: try:
return json.loads(match.group()) return json.loads(match.group())
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
raise json.JSONDecodeError("No valid JSON found", text, 0) raise json.JSONDecodeError("No valid JSON found", text, 0)
class OllamaClient: class OllamaClient:
def __init__(self, openrouter_client=None, model: Optional[str] = None, def __init__(self, openrouter_client=None):
model_fast: Optional[str] = None, self.base_url = OLLAMA_BASE_URL
num_ctx: Optional[int] = None, self.model = OLLAMA_MODEL
base_url: Optional[str] = None): self.model_fast = OLLAMA_MODEL_FAST or OLLAMA_MODEL
self.base_url = base_url or OLLAMA_BASE_URL self.openrouter = openrouter_client
self.model = model or OLLAMA_MODEL
self.model_fast = model_fast or OLLAMA_MODEL_FAST or self.model def _chat_sync(self, messages: list, system: Optional[str] = None, think: Optional[bool] = None, use_fast: bool = False) -> str:
self.num_ctx = num_ctx if num_ctx is not None else OLLAMA_NUM_CTX """Synchronous Ollama API call"""
self.openrouter = openrouter_client model = self.model_fast if use_fast else self.model
payload = {
def _chat_sync(self, messages: list, system: Optional[str] = None, think: Optional[bool] = None, use_fast: bool = False) -> str: "model": model,
"""Synchronous Ollama API call""" "messages": messages,
model = self.model_fast if use_fast else self.model "stream": False,
payload = { "keep_alive": -1,
"model": model, "options": {"num_ctx": OLLAMA_NUM_CTX},
"messages": messages, }
"stream": False, if system:
"keep_alive": -1, payload["system"] = system
"options": {"num_ctx": self.num_ctx}, if think is not None:
} payload["think"] = think
if system:
payload["system"] = system try:
if think is not None: response = requests.post(
payload["think"] = think f"{self.base_url}/api/chat",
json=payload,
try: timeout=180,
response = requests.post( )
f"{self.base_url}/api/chat", response.raise_for_status()
json=payload, data = response.json()
timeout=180, return data.get("message", {}).get("content", "")
) except requests.exceptions.ConnectionError:
response.raise_for_status() raise RuntimeError("Ollama nicht erreichbar – läuft der Service? (systemctl status ollama)")
data = response.json() except requests.exceptions.Timeout:
return data.get("message", {}).get("content", "") raise RuntimeError("Ollama Timeout – Modell lädt möglicherweise noch.")
except requests.exceptions.ConnectionError: except Exception as e:
raise RuntimeError("Ollama nicht erreichbar – läuft der Service? (systemctl status ollama)") raise RuntimeError(f"Ollama Fehler: {e}")
except requests.exceptions.Timeout:
raise RuntimeError("Ollama Timeout – Modell lädt möglicherweise noch.") async def generate_shell_command(self, description: str) -> dict:
except Exception as e: """Generate a shell command from natural language description.
raise RuntimeError(f"Ollama Fehler: {e}") Tries Ollama first, falls back to OpenRouter on timeout."""
loop = asyncio.get_running_loop()
async def generate_shell_command(self, description: str) -> dict: raw = ""
"""Generate a shell command from natural language description.
Tries Ollama first, falls back to OpenRouter on timeout.""" # 1. Try local Ollama
loop = asyncio.get_running_loop() try:
raw = "" prompt = SHELL_PROMPT_TEMPLATE.format(description=description)
raw = await loop.run_in_executor(
# 1. Try local Ollama None,
try: lambda: self._chat_sync(
prompt = SHELL_PROMPT_TEMPLATE.format(description=description) messages=[{"role": "user", "content": prompt}],
raw = await loop.run_in_executor( think=False,
None, use_fast=False,
lambda: self._chat_sync( ),
messages=[{"role": "user", "content": prompt}], )
think=False, result = _extract_json(raw)
use_fast=False,
), if "command" not in result or not result["command"].strip():
) logger.warning(f"Shell generate: no command in result: {result}")
result = _extract_json(raw) return {"dangerous": False, "reason": "", "command": ""}
if "dangerous" not in result:
if "command" not in result or not result["command"].strip(): result["dangerous"] = False
logger.warning(f"Shell generate: no command in result: {result}")
return {"dangerous": False, "reason": "", "command": ""} logger.info(f"Shell generate (Ollama): {result}")
if "dangerous" not in result: return result
result["dangerous"] = False
except json.JSONDecodeError as e:
logger.info(f"Shell generate (Ollama): {result}") logger.warning(f"Shell generate JSON error: {e}, raw: {raw[:500]}")
return result return {"dangerous": False, "reason": "", "command": ""}
except RuntimeError as e:
except json.JSONDecodeError as e: if "Timeout" not in str(e) and "nicht erreichbar" not in str(e):
logger.warning(f"Shell generate JSON error: {e}, raw: {raw[:500]}") raise
return {"dangerous": False, "reason": "", "command": ""} logger.warning(f"Ollama nicht verfügbar für /shell – trying OpenRouter fallback")
except RuntimeError as e:
if "Timeout" not in str(e) and "nicht erreichbar" not in str(e): # 2. Fallback to OpenRouter
raise if self.openrouter and self.openrouter.is_available():
logger.warning("Ollama nicht verfügbar für /shell – trying OpenRouter fallback") try:
logger.info("Using OpenRouter fallback for /shell...")
# 2. Fallback to OpenRouter result = await self.openrouter.generate_shell_command(description)
if self.openrouter and self.openrouter.is_available(): logger.info(f"Shell generate (OpenRouter): {result}")
try: return result
logger.info("Using OpenRouter fallback for /shell...") except Exception as e:
result = await self.openrouter.generate_shell_command(description) logger.error(f"OpenRouter fallback failed: {e}")
logger.info(f"Shell generate (OpenRouter): {result}")
return result return {
except Exception as e: "dangerous": False, "reason": "",
logger.error(f"OpenRouter fallback failed: {e}") "command": "",
"_error": "Ollama Timeout und kein OpenRouter Fallback verfügbar.",
return { }
"dangerous": False, "reason": "",
"command": "", async def explain_output(self, command: str, output: str, error: str = "") -> str:
"_error": "Ollama Timeout und kein OpenRouter Fallback verfügbar.", """Ask Ollama to explain/summarize command output"""
} loop = asyncio.get_running_loop()
async def explain_output(self, command: str, output: str, error: str = "") -> str: content = f"Command: {command}\nOutput:\n{output}"
"""Ask Ollama to explain/summarize command output""" if error:
loop = asyncio.get_running_loop() 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."
content = f"Command: {command}\nOutput:\n{output}"
if error: try:
content += f"\nErrors:\n{error}" return await loop.run_in_executor(
content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer." None,
lambda: self._chat_sync(
try: messages=[{"role": "user", "content": content}],
return await loop.run_in_executor( system=CHAT_SYSTEM_PROMPT,
None, think=False,
lambda: self._chat_sync( use_fast=True,
messages=[{"role": "user", "content": content}], ),
system=CHAT_SYSTEM_PROMPT, )
think=False, except Exception as e:
use_fast=True, logger.error(f"explain_output failed: {e}")
), # Try OpenRouter fallback
) if self.openrouter and self.openrouter.is_available():
except Exception as e: try:
logger.error(f"explain_output failed: {e}") return await self.openrouter.explain_output(command, output, error)
# Try OpenRouter fallback except Exception:
if self.openrouter and self.openrouter.is_available(): pass
try: return ""
return await self.openrouter.explain_output(command, output, error)
except Exception: async def chat(self, message: str, history: list = None) -> str:
pass """General chat with context. Tries Ollama, falls back to OpenRouter."""
return "" loop = asyncio.get_running_loop()
messages = list(history) if history else []
async def chat(self, message: str, history: list = None) -> str: messages.append({"role": "user", "content": message})
"""General chat with context. Tries Ollama, falls back to OpenRouter."""
loop = asyncio.get_running_loop() # 1. Try local Ollama
messages = list(history) if history else [] try:
messages.append({"role": "user", "content": message}) return await loop.run_in_executor(
None,
# 1. Try local Ollama lambda: self._chat_sync(
try: messages=messages,
return await loop.run_in_executor( system=CHAT_SYSTEM_PROMPT,
None, use_fast=True,
lambda: self._chat_sync( ),
messages=messages, )
system=CHAT_SYSTEM_PROMPT, except RuntimeError as e:
use_fast=True, 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")
except RuntimeError as e:
if "Timeout" not in str(e) and "nicht erreichbar" not in str(e): # 2. Fallback to OpenRouter
return f"Fehler: {e}" if self.openrouter and self.openrouter.is_available():
logger.warning("Ollama chat nicht verfügbar – trying OpenRouter") try:
logger.info("Using OpenRouter for chat...")
# 2. Fallback to OpenRouter return await self.openrouter.chat(message, history)
if self.openrouter and self.openrouter.is_available(): except Exception as e:
try: return f"Ollama und OpenRouter nicht verfügbar: {e}"
logger.info("Using OpenRouter for chat...")
return await self.openrouter.chat(message, history) return (
except Exception as e: "Ollama nicht verfügbar und kein OpenRouter Fallback konfiguriert.\n"
return f"Ollama und OpenRouter nicht verfügbar: {e}" "Versuche es später oder konfiguriere OpenRouter in .env"
)
return (
"Ollama nicht verfügbar und kein OpenRouter Fallback konfiguriert.\n"
"Versuche es später oder konfiguriere OpenRouter in .env"
)
+178 -183
View File
@@ -1,183 +1,178 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
🐠 Clownfischserver – OpenRouter Free Models Fallback 🐠 Clownfischserver – OpenRouter Free Models Fallback
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always. License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver Repo: https://github.com/mehlzoerwer-claude/clownfischserver
""" """
import json import json
import logging import logging
import os import os
import re import re
import asyncio import asyncio
import requests import requests
from typing import Optional from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "") OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# Free models on OpenRouter (no credit card needed) # Free models on OpenRouter (no credit card needed)
# openrouter/free auto-selects the best available free model # openrouter/free auto-selects the best available free model
FREE_MODELS = { FREE_MODELS = {
"coder": "openrouter/free", "coder": "openrouter/free",
"chat": "openrouter/free", "chat": "openrouter/free",
} }
def _extract_json(raw: str) -> dict: def _extract_json(raw: str) -> dict:
"""Extract JSON from response.""" """Extract JSON from response."""
text = raw.strip() text = raw.strip()
text = re.sub(r"^```(?:json)?\s*", "", text) text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text) text = re.sub(r"\s*```$", "", text)
text = text.strip() text = text.strip()
try: try:
return json.loads(text) return json.loads(text)
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
match = re.search(r"\{[^{}]*\}", text, re.DOTALL) match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
if match: if match:
try: try:
return json.loads(match.group()) return json.loads(match.group())
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
raise json.JSONDecodeError("No valid JSON found", text, 0) raise json.JSONDecodeError("No valid JSON found", text, 0)
class OpenRouterClient: class OpenRouterClient:
"""OpenRouter Free Models Client – fallback when Ollama times out.""" """OpenRouter Free Models Client – fallback when Ollama times out."""
def __init__(self): def __init__(self):
# Read at construction time, not import time, so env changes between self.api_key = OPENROUTER_API_KEY
# boot and config reloads (or test monkeypatching) take effect. self.base_url = OPENROUTER_BASE_URL
self.api_key = os.getenv("OPENROUTER_API_KEY", "") or OPENROUTER_API_KEY if self.api_key:
self.base_url = OPENROUTER_BASE_URL logger.info("OpenRouter Fallback konfiguriert")
if self.api_key: else:
logger.info("OpenRouter Fallback konfiguriert") logger.info("OpenRouter nicht konfiguriert – Fallback deaktiviert")
else:
logger.info("OpenRouter nicht konfiguriert – Fallback deaktiviert") def is_available(self) -> bool:
return bool(self.api_key)
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."""
def _chat_sync(self, messages: list, system: Optional[str] = None, model: str = None) -> str: if not self.api_key:
"""Synchronous OpenRouter API call.""" raise RuntimeError("OpenRouter API Key nicht konfiguriert")
if not self.api_key:
raise RuntimeError("OpenRouter API Key nicht konfiguriert") model = model or FREE_MODELS["chat"]
model = model or FREE_MODELS["chat"] api_messages = []
if system:
api_messages = [] api_messages.append({"role": "system", "content": system})
if system: api_messages.extend(messages)
api_messages.append({"role": "system", "content": system})
api_messages.extend(messages) try:
response = requests.post(
try: f"{self.base_url}/chat/completions",
response = requests.post( headers={
f"{self.base_url}/chat/completions", "Authorization": f"Bearer {self.api_key}",
headers={ "Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}", },
"Content-Type": "application/json", json={
}, "model": model,
json={ "messages": api_messages,
"model": model, "temperature": 0.7,
"messages": api_messages, },
"temperature": 0.7, timeout=60,
}, )
timeout=60, response.raise_for_status()
) data = response.json()
response.raise_for_status() return data.get("choices", [{}])[0].get("message", {}).get("content", "")
data = response.json() except requests.exceptions.ConnectionError:
return data.get("choices", [{}])[0].get("message", {}).get("content", "") raise RuntimeError("OpenRouter nicht erreichbar")
except requests.exceptions.ConnectionError: except requests.exceptions.Timeout:
raise RuntimeError("OpenRouter nicht erreichbar") raise RuntimeError("OpenRouter Timeout")
except requests.exceptions.Timeout: except Exception as e:
raise RuntimeError("OpenRouter Timeout") raise RuntimeError(f"OpenRouter Fehler: {e}")
except Exception as e:
raise RuntimeError(f"OpenRouter Fehler: {e}") async def generate_shell_command(self, description: str) -> dict:
"""Generate shell command via OpenRouter."""
async def generate_shell_command(self, description: str) -> dict: loop = asyncio.get_running_loop()
"""Generate shell command via OpenRouter."""
loop = asyncio.get_running_loop() prompt = (
"You are a Linux bash command generator for Ubuntu 24.04.\n"
system = "Server: Ubuntu 24.04, Clownfischserver setup." "Convert the request into a single non-interactive bash command.\n"
"Respond ONLY with JSON: {\"dangerous\": bool, \"reason\": string, \"command\": string}\n"
prompt = ( "No interactive commands (nano, vim, crontab -e). Use sudo when needed.\n\n"
"You are a Linux bash command generator for Ubuntu 24.04.\n" f"Request: {description}\n\nJSON only:"
"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" try:
f"Request: {description}\n\nJSON only:" raw = await loop.run_in_executor(
) None,
lambda: self._chat_sync(
try: messages=[{"role": "user", "content": prompt}],
raw = await loop.run_in_executor( model=FREE_MODELS["coder"],
None, ),
lambda: self._chat_sync( )
messages=[{"role": "user", "content": prompt}], result = _extract_json(raw)
system=system, if "command" not in result:
model=FREE_MODELS["coder"], result["command"] = ""
), if "dangerous" not in result:
) result["dangerous"] = False
result = _extract_json(raw) logger.info(f"OpenRouter shell: {result}")
if "command" not in result: return result
result["command"] = "" except json.JSONDecodeError as e:
if "dangerous" not in result: logger.warning(f"OpenRouter JSON error: {e}")
result["dangerous"] = False return {"dangerous": False, "reason": "", "command": ""}
logger.info(f"OpenRouter shell: {result}") except Exception as e:
return result logger.error(f"OpenRouter shell failed: {e}")
except json.JSONDecodeError as e: raise
logger.warning(f"OpenRouter JSON error: {e}")
return {"dangerous": False, "reason": "", "command": ""} async def chat(self, message: str, history: list = None) -> str:
except Exception as e: """General chat via OpenRouter."""
logger.error(f"OpenRouter shell failed: {e}") loop = asyncio.get_running_loop()
raise messages = list(history) if history else []
messages.append({"role": "user", "content": message})
async def chat(self, message: str, history: list = None) -> str:
"""General chat via OpenRouter.""" system = (
loop = asyncio.get_running_loop() "Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.\n"
messages = list(history) if history else [] "Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt."
messages.append({"role": "user", "content": message}) )
system = ( try:
"Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.\n" return await loop.run_in_executor(
"Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt." None,
) lambda: self._chat_sync(
messages=messages,
try: system=system,
return await loop.run_in_executor( model=FREE_MODELS["chat"],
None, ),
lambda: self._chat_sync( )
messages=messages, except Exception as e:
system=system, return f"OpenRouter Fehler: {e}"
model=FREE_MODELS["chat"],
), async def explain_output(self, command: str, output: str, error: str = "") -> str:
) """Explain command output via OpenRouter."""
except Exception as e: loop = asyncio.get_running_loop()
return f"OpenRouter Fehler: {e}"
content = f"Command: {command}\nOutput:\n{output}"
async def explain_output(self, command: str, output: str, error: str = "") -> str: if error:
"""Explain command output via OpenRouter.""" content += f"\nErrors:\n{error}"
loop = asyncio.get_running_loop() content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer."
content = f"Command: {command}\nOutput:\n{output}" try:
if error: return await loop.run_in_executor(
content += f"\nErrors:\n{error}" None,
content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer." lambda: self._chat_sync(
messages=[{"role": "user", "content": content}],
try: model=FREE_MODELS["chat"],
return await loop.run_in_executor( ),
None, )
lambda: self._chat_sync( except Exception as e:
messages=[{"role": "user", "content": content}], logger.error(f"OpenRouter explain failed: {e}")
model=FREE_MODELS["chat"], return ""
),
)
except Exception as e:
logger.error(f"OpenRouter explain failed: {e}")
return ""
+31 -73
View File
@@ -1,73 +1,31 @@
# 🐠 Clownfischserver – Konfiguration # 🐠 Clownfischserver – Konfiguration
# Diese Datei wird automatisch durch install.sh erstellt. # Diese Datei wird automatisch durch install.sh erstellt.
# Niemals diese Datei committen oder teilen! # Niemals diese Datei committen oder teilen!
# Telegram # Telegram
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrSTUvwxYZ TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrSTUvwxYZ
# Legacy single-user mode: dieser Nutzer bekommt automatisch die operator-Rolle, TELEGRAM_CHAT_ID=987654321
# wenn keine CLOWNFISCH_* Rollen-Variablen gesetzt sind (v0.4-Kompatibilität).
TELEGRAM_CHAT_ID=987654321 # Ollama
OLLAMA_BASE_URL=http://localhost:11434
# === Team-Rollen (v0.5.0) =========================================== OLLAMA_MODEL=qwen2.5-coder:7b
# Kommagetrennte Telegram User-IDs (nicht Chat-IDs!). OLLAMA_MODEL_FAST=gemma3:4b
# Operator = darf /shell /code /run /ja /ssh /snapshot /rollback ausführen OLLAMA_NUM_CTX=16384
# Approver = darf /ja (Bestätigungen) ausführen
# Viewer = darf /start /help /status /snapshots /chat lesen # OpenRouter (optional – kostenloser Fallback bei Ollama-Timeout)
# Inheritance: operator > approver > viewer (höhere Rolle erbt niedrigere Rechte). # API Key von openrouter.ai (kostenlos, keine Kreditkarte)
# CLOWNFISCH_OPERATORS=12345,67890 # OPENROUTER_API_KEY=sk-or-v1-...
# CLOWNFISCH_APPROVERS=11111
# CLOWNFISCH_VIEWERS=22222,33333 # Snapshots
SNAPSHOT_DIR=/opt/clownfisch-snapshots
# === Audit-Trail (DSGVO Art. 5, 32) ================================= SNAPSHOT_METHOD=tar
# Pfad zur JSON-Lines-Audit-Datei. Default: $INSTALL_DIR/audit.jsonl
# AUDIT_LOG_PATH=/opt/clownfischserver/audit.jsonl # Paths
# Aufbewahrungsdauer in Tagen (Art. 5(1)(e) – Storage Limitation). INSTALL_DIR=/opt/clownfischserver
# Beim Bot-Start werden ältere Einträge automatisch entfernt. 0 = nie löschen. MGMT_USER=clownfish
AUDIT_RETENTION_DAYS=90 AIDER_WORKDIR=/opt/clownfisch-workspace
# Maximale Länge eines Audit-Detailfeldes (Datenminimierung).
AUDIT_MAX_DETAIL_LEN=2000 # Clownfischserver HomeIP Automatik (optional)
# myfritz Adresse für automatische SSH-Freigabe von Zuhause
# Ollama # Falls aktiviert: Cron-Job prüft alle 5 Min und aktualisiert ufw-Regeln
OLLAMA_BASE_URL=http://localhost:11434 # CLOWNFISCHSERVER_HOME_IP_DOMAIN=vorname.myfritz.net
OLLAMA_MODEL=qwen2.5-coder:7b
OLLAMA_MODEL_FAST=gemma3:4b
OLLAMA_NUM_CTX=16384
# OpenRouter (optional – kostenloser Fallback bei Ollama-Timeout)
# API Key von openrouter.ai (kostenlos, keine Kreditkarte)
# OPENROUTER_API_KEY=sk-or-v1-...
# === LLM Routing (v0.5.0, Phase 3) ==================================
# Konfiguriert welches Modell als Primary und welches als Fallback genutzt wird.
# Wenn nicht gesetzt: defaultet auf Ollama primary, OpenRouter fallback (sofern
# OPENROUTER_API_KEY gesetzt ist). Damit bleibt das v0.4-Verhalten erhalten.
#
# Primary (für sensible Daten – läuft lokal, keine Cloud)
# LLM_PRIMARY_PROVIDER=ollama
# LLM_PRIMARY_MODEL=qwen2.5-coder:7b
# LLM_PRIMARY_MODEL_FAST=gemma3:4b
# Gemma 4 27B benötigt mindestens 64K Token Kontext – sonst halluziniert das Modell.
# Override pro Provider (überschreibt OLLAMA_NUM_CTX).
# LLM_PRIMARY_NUM_CTX=65536
#
# Fallback (nur falls Primary nicht erreichbar / Timeout)
# LLM_FALLBACK_PROVIDER=openrouter
# LLM_FALLBACK_MODEL=deepseek/deepseek-r1:free
# Wert "none" oder leer = Fallback explizit deaktivieren
# LLM_FALLBACK_PROVIDER=none
#
# Optional: Kostentracking für Admins (informativ, keine Limit-Erzwingung).
# TRACK_LLM_COSTS=true
# Snapshots
SNAPSHOT_DIR=/opt/clownfisch-snapshots
SNAPSHOT_METHOD=tar
# HomeIP Auto-Update (optional – myfritz.net Dynamic DNS)
# Domain für automatische Firewall-Regel-Updates bei IP-Wechsel
# CLOWNFISCHSERVER_HOME_IP_DOMAIN=vorname.myfritz.net
# Paths
INSTALL_DIR=/opt/clownfischserver
MGMT_USER=clownfish
AIDER_WORKDIR=/opt/clownfisch-workspace
+64 -60
View File
@@ -29,13 +29,8 @@ MGMT_USER=""
PKG_MANAGER="" PKG_MANAGER=""
DISTRO="" DISTRO=""
OLLAMA_MODEL="" OLLAMA_MODEL=""
OLLAMA_MODEL_FAST=""
SNAPSHOT_METHOD="tar" SNAPSHOT_METHOD="tar"
OLLAMA_NUM_CTX=4096 OLLAMA_NUM_CTX=4096
MYFRITZ_DOMAIN=""
BOT_TOKEN=""
CHAT_ID=""
OPENROUTER_API_KEY=""
# HELPERS # HELPERS
@@ -110,7 +105,7 @@ print_banner() {
echo -e "${CYAN}${BOLD}" echo -e "${CYAN}${BOLD}"
echo " ╔══════════════════════════════════════════════════════╗" echo " ╔══════════════════════════════════════════════════════╗"
echo " ║ 🐠 CLOWNFISCHSERVER INSTALLER ║" echo " ║ 🐠 CLOWNFISCHSERVER INSTALLER ║"
echo " ║ v0.5.0 | GPL-3.0 ║" echo " ║ v0.4.13 | GPL-3.0 ║"
echo " ╚══════════════════════════════════════════════════════╝" echo " ╚══════════════════════════════════════════════════════╝"
echo -e "${NC}" echo -e "${NC}"
echo -e " Willkommen! Dieses Script richtet deinen" echo -e " Willkommen! Dieses Script richtet deinen"
@@ -173,7 +168,7 @@ step_packages() {
case "$PKG_MANAGER" in case "$PKG_MANAGER" in
apt) apt)
run_with_spinner "Paketliste aktualisieren" apt-get update -qq || fail "apt update fehlgeschlagen" 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" 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 dnsutils || warn "Einige Pakete konnten nicht installiert werden – siehe Log"
;; ;;
dnf) dnf)
run_with_spinner "Pakete aktualisieren" dnf update -y -q run_with_spinner "Pakete aktualisieren" dnf update -y -q
@@ -274,6 +269,33 @@ step_ssh() {
ok "SSH läuft – Passwort + Key erlaubt" ok "SSH läuft – Passwort + Key erlaubt"
info "SSH wird erst gesperrt wenn du im Bot /ssh close sendest" 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 # Firewall
case "$PKG_MANAGER" in case "$PKG_MANAGER" in
apt) apt)
@@ -340,34 +362,6 @@ KNOCKEOF
warn "Bitte Firewall manuell einrichten (iptables/nftables)" warn "Bitte Firewall manuell einrichten (iptables/nftables)"
;; ;;
esac esac
echo ""
echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " ${BOLD} myfritz – Home-IP Auto-Update (optional)${NC}"
echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " Wenn du ein AVM-Gerät (Fritzbox) mit myfritz.net hast,"
echo -e " kann der Bot deine Home-IP automatisch ins Firewall-Regelwerk"
echo -e " schreiben. Das bedeutet: SSH bleibt immer erreichbar, auch"
echo -e " wenn sich deine IP ändert (z.B. ISP, mobil, VPN)."
echo ""
echo -e " ${YELLOW}?${NC} ${BOLD}Hast du ein AVM myfritz.net Gerät?${NC} [j/n]"
read -r -p " -> " USE_MYFRITZ
if [[ "$USE_MYFRITZ" =~ ^[jJ]$ ]]; then
echo ""
echo -e " ${YELLOW}?${NC} ${BOLD}Deine myfritz-Domain:${NC}"
echo -e " ${CYAN} Format: vorname.myfritz.net${NC}"
read -r -p " -> " MYFRITZ_DOMAIN
if [[ -z "$MYFRITZ_DOMAIN" ]]; then
MYFRITZ_DOMAIN=""
warn "myfritz übersprungen"
else
ok "myfritz Domain: $MYFRITZ_DOMAIN"
fi
else
MYFRITZ_DOMAIN=""
info "myfritz übersprungen"
fi
} }
# HELPER: Pull an Ollama model with progress bar # HELPER: Pull an Ollama model with progress bar
@@ -714,7 +708,7 @@ SNAPSHOT_METHOD=$SNAPSHOT_METHOD
INSTALL_DIR=$INSTALL_DIR INSTALL_DIR=$INSTALL_DIR
MGMT_USER=$MGMT_USER MGMT_USER=$MGMT_USER
AIDER_WORKDIR=$WORKSPACE_DIR AIDER_WORKDIR=$WORKSPACE_DIR
CLOWNFISCHSERVER_HOME_IP_DOMAIN=$MYFRITZ_DOMAIN CLOWNFISCHSERVER_HOME_IP_DOMAIN=$CLOWNFISCHSERVER_HOME_IP_DOMAIN
ENVEOF ENVEOF
chmod 600 "$INSTALL_DIR/config/.env" chmod 600 "$INSTALL_DIR/config/.env"
@@ -730,13 +724,6 @@ step_service() {
cp -r "$SCRIPT_DIR/bot/"* "$INSTALL_DIR/bot/" || fail "Bot-Dateien nicht gefunden – ist der bot/ Ordner dabei?" cp -r "$SCRIPT_DIR/bot/"* "$INSTALL_DIR/bot/" || fail "Bot-Dateien nicht gefunden – ist der bot/ Ordner dabei?"
ok "Bot-Dateien kopiert" ok "Bot-Dateien kopiert"
mkdir -p "$INSTALL_DIR/scripts"
if [ -d "$SCRIPT_DIR/scripts" ]; then
cp -r "$SCRIPT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true
chmod +x "$INSTALL_DIR/scripts/"*.sh 2>/dev/null || true
ok "Scripts kopiert"
fi
chown -R "$MGMT_USER:$MGMT_USER" "$INSTALL_DIR" "$SNAPSHOT_DIR" "$WORKSPACE_DIR" chown -R "$MGMT_USER:$MGMT_USER" "$INSTALL_DIR" "$SNAPSHOT_DIR" "$WORKSPACE_DIR"
chmod -R u+w "$INSTALL_DIR/bot" chmod -R u+w "$INSTALL_DIR/bot"
ok "Verzeichnis-Rechte gesetzt" ok "Verzeichnis-Rechte gesetzt"
@@ -804,24 +791,27 @@ BOOTEOF
run_with_spinner "Boot-Notification aktivieren" bash -c "systemctl daemon-reload && systemctl enable clownfisch-boot" run_with_spinner "Boot-Notification aktivieren" bash -c "systemctl daemon-reload && systemctl enable clownfisch-boot"
ok "Boot-Notification: Telegram-Meldung 2 Min nach Serverstart" ok "Boot-Notification: Telegram-Meldung 2 Min nach Serverstart"
# HomeIP Updater – falls myfritz konfiguriert # Clownfischserver HomeIP Updater (wenn aktiviert)
if [[ -n "$MYFRITZ_DOMAIN" ]]; then if [[ -n "$CLOWNFISCHSERVER_HOME_IP_DOMAIN" ]]; then
echo "" mkdir -p "$INSTALL_DIR/scripts"
info "Installiere HomeIP Auto-Updater (5-Min Cron)..." cp "$SCRIPT_DIR/scripts/clownfischserver-homeip-updater.sh" "$INSTALL_DIR/scripts/" 2>/dev/null || \
warn "homeip-updater.sh nicht gefunden – manuell nachträglich einrichten"
# Copy the homeip-updater script if it exists if [ -f "$INSTALL_DIR/scripts/clownfischserver-homeip-updater.sh" ]; then
if [ -f "$SCRIPT_DIR/scripts/clownfischserver-homeip-updater.sh" ]; then chmod +x "$INSTALL_DIR/scripts/clownfischserver-homeip-updater.sh"
mkdir -p /opt/clownfischserver-scripts chown root:root "$INSTALL_DIR/scripts/clownfischserver-homeip-updater.sh"
cp "$SCRIPT_DIR/scripts/clownfischserver-homeip-updater.sh" /opt/clownfischserver/scripts/
chmod +x /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh
# Add cron job (every 5 minutes) # Cron-Job für root (da ufw sudo braucht)
CRON_JOB="*/5 * * * * /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh >> /var/log/clownfischserver-homeip-updater.log 2>&1" CRON_JOB="*/5 * * * * $INSTALL_DIR/scripts/clownfischserver-homeip-updater.sh >> /var/log/clownfischserver-homeip.log 2>&1"
(crontab -l 2>/dev/null | grep -v "clownfischserver-homeip-updater" ; echo "$CRON_JOB") | crontab - 2>/dev/null || true
ok "HomeIP Auto-Updater installiert (myfritz: $MYFRITZ_DOMAIN)" # Bestehende clownfischserver Cron-Jobs entfernen (install.sh läuft als root)
else crontab -l 2>/dev/null | grep -v clownfischserver-homeip-updater | crontab - 2>/dev/null || true
warn "homeip-updater Script nicht gefunden – übersprungen"
# Neuen Job hinzufügen
(crontab -l 2>/dev/null; echo "$CRON_JOB") | crontab - >> "$LOG_FILE" 2>&1
ok "Cron-Job aktiviert: alle 5 Min HomeIP prüfen"
info "Log: /var/log/clownfischserver-homeip.log"
fi fi
fi fi
} }
@@ -833,13 +823,20 @@ print_summary() {
echo "" echo ""
echo -e "${GREEN}${BOLD}" echo -e "${GREEN}${BOLD}"
echo " +======================================================+" echo " +======================================================+"
echo " | CLOWNFISCHSERVER BEREIT! v0.5.0 |" echo " | CLOWNFISCHSERVER BEREIT! v0.4.13 |"
echo " +======================================================+" echo " +======================================================+"
echo -e "${NC}" echo -e "${NC}"
echo -e " ${BOLD}Server:${NC} $SERVER_IP" echo -e " ${BOLD}Server:${NC} $SERVER_IP"
echo -e " ${BOLD}SSH User:${NC} $MGMT_USER (Passwort aktiv!)" echo -e " ${BOLD}SSH User:${NC} $MGMT_USER (Passwort aktiv!)"
echo -e " ${BOLD}KI-Modell:${NC} $OLLAMA_MODEL" echo -e " ${BOLD}KI-Modell:${NC} $OLLAMA_MODEL"
echo -e " ${BOLD}Snapshots:${NC} $SNAPSHOT_DIR ($SNAPSHOT_METHOD)" 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}Logs:${NC} journalctl -u clownfisch -f"
echo -e " ${BOLD}Install-Log:${NC} $LOG_FILE" echo -e " ${BOLD}Install-Log:${NC} $LOG_FILE"
echo "" echo ""
@@ -854,6 +851,13 @@ print_summary() {
echo -e " ${CYAN} /ssh open 1.2.3.4 -> SSH nur fuer deine IP${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 -e " ${CYAN} /ssh close -> SSH sperren${NC}"
echo "" 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}" echo -e " ${YELLOW}Install-Log (letzte 10 Zeilen):${NC}"
tail -10 "$LOG_FILE" tail -10 "$LOG_FILE"
echo "" echo ""
@@ -924,7 +928,7 @@ main() {
esac esac
fi fi
log "=== Clownfischserver v0.5.0 Installation gestartet ===" log "=== Clownfischserver v0.4.13 Installation gestartet ==="
step_checks step_checks
step_packages step_packages
step_user step_user
+169
View File
@@ -0,0 +1,169 @@
# 🐠 Clownfischserver HomeIP Auto-Update
Automatische SSH-Freigabe für deine Home-IP – ohne Port-Knocking.
**Multi-Distro Support:** ✅ Ubuntu/Debian (ufw) | ✅ Fedora/RHEL (firewalld) | ✅ Arch/Manjaro (iptables) | ✅ openSUSE (firewalld)
## 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
## Multi-Distro Firewall-Support
| Distribution | Firewall | Support |
|--------------|----------|---------|
| Ubuntu/Debian | `ufw` | ✅ Vollständig |
| Fedora/RHEL/CentOS | `firewalld` | ✅ Vollständig |
| Arch/Manjaro | `iptables` | ✅ Vollständig |
| openSUSE | `firewalld` | ✅ Vollständig |
| Alpine | `ufw`/`iptables` | ✅ Fallback |
**Automatische Erkennung:** Das Script erkennt die aktive Firewall zur Laufzeit und nutzt die entsprechenden Kommandos.
## 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
- **Firewall automatisch erkannt** (ufw/firewalld/iptables)
## 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
```
## Technische Details – Firewall-Kommandos
### ufw (Ubuntu/Debian)
```bash
ufw allow from 192.168.1.100 to any port 22 comment "clownfischserver-homeip-vorname.myfritz.net"
ufw delete allow from 192.168.1.100 to any port 22
```
### firewalld (Fedora/openSUSE)
```bash
firewall-cmd --permanent --add-rich-rule="rule family='ipv4' source address='192.168.1.100' port protocol='tcp' port='22' accept"
firewall-cmd --reload
```
### iptables (Arch/Manjaro)
```bash
iptables -I INPUT -s 192.168.1.100 -p tcp --dport 22 -j ACCEPT
iptables -D INPUT -s 192.168.1.100 -p tcp --dport 22 -j ACCEPT
```
---
**Script-Info:** Clownfischserver v0.5.0+ | GPL-3.0 | Multi-Distro Support
+121 -127
View File
@@ -1,174 +1,168 @@
#!/bin/bash #!/bin/bash
# ============================================================================= # =============================================================================
# 🐠 CLOWNFISCHSERVER – HomeIP Auto-Update # 🐠 Clownfischserver HomeIP Updater
# Purpose: Auto-update firewall rule for dynamic home IP (myfritz.net) # Version: v0.5.0
# Runs: Every 5 minutes (via cron) # Purpose: Automatisch die Home-IP aktualisieren (alle 5 Min via Cron)
# Author: Mehlzoerwer-Claude # Multi-Distro: ufw (Ubuntu/Debian), firewalld (Fedora/openSUSE), iptables (Arch)
# Author: Mehlzoerwer-Claude
# License: GPL-3.0 # License: GPL-3.0
# ============================================================================= # =============================================================================
set -e # Konfiguration
# --- Configuration ---
INSTALL_DIR="/opt/clownfischserver" INSTALL_DIR="/opt/clownfischserver"
ENV_FILE="$INSTALL_DIR/config/.env" ENV_FILE="$INSTALL_DIR/config/.env"
STATE_FILE="/var/lib/clownfischserver-homeip-state" STATE_FILE="/var/lib/clownfischserver-homeip-state"
LOG_FILE="/var/log/clownfischserver-homeip-updater.log" LOG_FILE="/var/log/clownfischserver-homeip.log"
SSH_PORT=22
# --- Helpers --- # Logging
log() { log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE" echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
} }
# --- Load .env --- log_error() {
load_env() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >> "$LOG_FILE"
if [ ! -f "$ENV_FILE" ]; then
log "ERROR: .env not found at $ENV_FILE"
exit 1
fi
# Load and trim CRLF/LF
MYFRITZ_DOMAIN=$(grep "^CLOWNFISCHSERVER_HOME_IP_DOMAIN=" "$ENV_FILE" | cut -d= -f2 | tr -d '\r\n')
if [ -z "$MYFRITZ_DOMAIN" ]; then
log "ERROR: CLOWNFISCHSERVER_HOME_IP_DOMAIN not set in .env"
exit 1
fi
} }
# --- Detect Firewall --- # Erkenne welche Firewall aktiv ist
detect_firewall() { detect_firewall() {
if systemctl is-active --quiet ufw; then if systemctl is-active --quiet ufw 2>/dev/null; then
echo "ufw" echo "ufw"
elif systemctl is-active --quiet firewalld; then elif systemctl is-active --quiet firewalld 2>/dev/null; then
echo "firewalld" echo "firewalld"
elif command -v iptables &>/dev/null; then elif command -v iptables &>/dev/null; then
echo "iptables" echo "iptables"
else else
log "ERROR: No firewall detected (ufw/firewalld/iptables)" echo "unknown"
exit 1
fi fi
} }
# --- Resolve DNS with fallbacks --- FIREWALL=$(detect_firewall)
resolve_ip() { log "Firewall erkannt: $FIREWALL"
local domain="$1"
# Try dig first # Prüfe ob .env existiert
if command -v dig &>/dev/null; then if [[ ! -f "$ENV_FILE" ]]; then
IP=$(dig +short "$domain" A | grep -oE '^[0-9.]+$' | head -1) log_error ".env nicht gefunden: $ENV_FILE"
[ -n "$IP" ] && echo "$IP" && return 0 exit 1
fi fi
# Try nslookup # Erstelle Log-File wenn nicht existiert
if command -v nslookup &>/dev/null; then touch "$LOG_FILE" 2>/dev/null || true
IP=$(nslookup "$domain" 2>/dev/null | grep "Address:" | tail -1 | awk '{print $2}' | grep -oE '^[0-9.]+$')
[ -n "$IP" ] && echo "$IP" && return 0
fi
# Try getent # Lade myfritz-Adresse aus .env (Zeilenumbrüche entfernen)
if command -v getent &>/dev/null; then MYFRITZ_DOMAIN=$(grep "^CLOWNFISCHSERVER_HOME_IP_DOMAIN=" "$ENV_FILE" | cut -d= -f2 | tr -d '\r\n')
IP=$(getent hosts "$domain" | awk '{print $1}' | grep -oE '^[0-9.]+$' | head -1)
[ -n "$IP" ] && echo "$IP" && return 0
fi
log "ERROR: Could not resolve $domain" if [[ -z "$MYFRITZ_DOMAIN" ]]; then
return 1 log "Clownfischserver HomeIP: Deaktiviert (keine myfritz-Adresse)"
} exit 0
fi
# --- Load previous state --- # Versuche IP aufzulösen (mit Fallback)
load_state() { if command -v dig &>/dev/null; then
if [ -f "$STATE_FILE" ]; then 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')
PREVIOUS_IP=$(cat "$STATE_FILE" | grep "^IP=" | cut -d= -f2) elif command -v nslookup &>/dev/null; then
echo "$PREVIOUS_IP" NEW_IP=$(nslookup "$MYFRITZ_DOMAIN" 2>/dev/null | grep -oE '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b' | tail -1)
else elif command -v getent &>/dev/null; then
echo "" NEW_IP=$(getent hosts "$MYFRITZ_DOMAIN" 2>/dev/null | awk '{print $1}')
else
log_error "Kein DNS-Tool verfügbar (dig/nslookup/getent)"
exit 1
fi
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! Aktualisiere Firewall-Regeln..."
# Firewall-spezifische Funktionen
remove_ufw_rule() {
if ufw status numbered | grep -qE "22.*ALLOW.*FROM\s+$1"; then
echo "y" | ufw delete allow from "$1" to any port 22 >> "$LOG_FILE" 2>&1
log "UFW: Alte Regel entfernt: allow from $1 to port 22"
fi fi
} }
# --- Save state --- add_ufw_rule() {
save_state() { ufw allow from "$1" to any port 22 comment "clownfischserver-homeip-$MYFRITZ_DOMAIN" >> "$LOG_FILE" 2>&1
local ip="$1" log "UFW: Neue Regel hinzugefügt: allow from $1 to port 22"
echo "IP=$ip" > "$STATE_FILE"
chmod 600 "$STATE_FILE"
} }
# --- UFW Functions --- remove_firewalld_rule() {
ufw_add_rule() { firewall-cmd --permanent --remove-rich-rule="rule family='ipv4' source address='$1' port protocol='tcp' port='22' accept" >> "$LOG_FILE" 2>&1
local ip="$1" log "firewalld: Alte Regel entfernt: source address $1 port 22"
ufw allow from "$ip" to any port "$SSH_PORT" comment "clownfisch-homeip" >/dev/null 2>&1
log "UFW: Added rule for $ip:$SSH_PORT"
} }
ufw_remove_rule() { add_firewalld_rule() {
local ip="$1" firewall-cmd --permanent --add-rich-rule="rule family='ipv4' source address='$1' port protocol='tcp' port='22' accept" >> "$LOG_FILE" 2>&1
echo "y" | ufw delete allow from "$ip" to any port "$SSH_PORT" >/dev/null 2>&1 firewall-cmd --reload >> "$LOG_FILE" 2>&1
log "UFW: Deleted rule for $ip" log "firewalld: Neue Regel hinzugefügt: source address $1 port 22"
} }
# --- Firewalld Functions --- remove_iptables_rule() {
firewalld_add_rule() { iptables -D INPUT -s "$1" -p tcp --dport 22 -j ACCEPT 2>> "$LOG_FILE" || true
local ip="$1" log "iptables: Alte Regel entfernt: source $1 port 22"
firewall-cmd --permanent --add-rich-rule="rule family='ipv4' source address='$ip' port protocol='tcp' port='$SSH_PORT' accept" >/dev/null 2>&1
firewall-cmd --reload >/dev/null 2>&1
log "firewalld: Added rule for $ip:$SSH_PORT"
} }
firewalld_remove_rule() { add_iptables_rule() {
local ip="$1" iptables -I INPUT -s "$1" -p tcp --dport 22 -j ACCEPT >> "$LOG_FILE" 2>&1
firewall-cmd --permanent --remove-rich-rule="rule family='ipv4' source address='$ip' port protocol='tcp' port='$SSH_PORT' accept" >/dev/null 2>&1 log "iptables: Neue Regel hinzugefügt: source $1 port 22"
firewall-cmd --reload >/dev/null 2>&1
log "firewalld: Deleted rule for $ip"
} }
# --- iptables Functions --- # Entferne alte Regel falls vorhanden
iptables_add_rule() { if [[ -n "$OLD_IP" ]]; then
local ip="$1"
iptables -I INPUT -s "$ip" -p tcp --dport "$SSH_PORT" -j ACCEPT
log "iptables: Added rule for $ip:$SSH_PORT"
}
iptables_remove_rule() {
local ip="$1"
iptables -D INPUT -s "$ip" -p tcp --dport "$SSH_PORT" -j ACCEPT 2>/dev/null || true
log "iptables: Deleted rule for $ip"
}
# --- Main ---
main() {
log "=== HomeIP Auto-Update started ==="
load_env
FIREWALL=$(detect_firewall)
NEW_IP=$(resolve_ip "$MYFRITZ_DOMAIN") || exit 1
PREVIOUS_IP=$(load_state)
if [ "$NEW_IP" = "$PREVIOUS_IP" ]; then
log "No change: $NEW_IP (same as previous)"
exit 0
fi
log "IP change detected: $PREVIOUS_IP → $NEW_IP (firewall: $FIREWALL)"
# Remove old rule
if [ -n "$PREVIOUS_IP" ]; then
case "$FIREWALL" in
ufw) ufw_remove_rule "$PREVIOUS_IP" ;;
firewalld) firewalld_remove_rule "$PREVIOUS_IP" ;;
iptables) iptables_remove_rule "$PREVIOUS_IP" ;;
esac
fi
# Add new rule
case "$FIREWALL" in case "$FIREWALL" in
ufw) ufw_add_rule "$NEW_IP" ;; ufw) remove_ufw_rule "$OLD_IP" ;;
firewalld) firewalld_add_rule "$NEW_IP" ;; firewalld) remove_firewalld_rule "$OLD_IP" ;;
iptables) iptables_add_rule "$NEW_IP" ;; iptables) remove_iptables_rule "$OLD_IP" ;;
*) log_error "Unbekannte Firewall: $FIREWALL" && exit 1 ;;
esac esac
fi
# Save state # Füge neue Regel hinzu
save_state "$NEW_IP" case "$FIREWALL" in
ufw)
if ! add_ufw_rule "$NEW_IP"; then
log_error "UFW: Regel konnte nicht hinzugefügt werden"
exit 1
fi
;;
firewalld)
if ! add_firewalld_rule "$NEW_IP"; then
log_error "firewalld: Regel konnte nicht hinzugefügt werden"
exit 1
fi
;;
iptables)
if ! add_iptables_rule "$NEW_IP"; then
log_error "iptables: Regel konnte nicht hinzugefügt werden"
exit 1
fi
;;
*)
log_error "Unbekannte Firewall: $FIREWALL"
exit 1
;;
esac
log "=== HomeIP Auto-Update completed successfully ===" # Speichere neue IP
} echo "$NEW_IP" > "$STATE_FILE"
chmod 644 "$STATE_FILE"
main "$@" log "✓ HomeIP Update erfolgreich: $MYFRITZ_DOMAIN → $NEW_IP ($FIREWALL)"
exit 0