Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83c98ff0b8 | ||
|
|
f2fdf95897 | ||
|
|
91eeb4834f |
@@ -1,20 +1,15 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(gh auth *)",
|
||||
"PowerShell(gh auth *)",
|
||||
"PowerShell(winget install *)",
|
||||
"Bash(gh repo *)",
|
||||
"Bash(git init *)",
|
||||
"Bash(git config *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit -m 'v0.5.0 – Clownfischserver HomeIP Auto-Update: myfritz Automatik für SSH \\(5-Min Cron\\) *)",
|
||||
"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 *)"
|
||||
"Bash(git commit -m 'Initial commit: Clownfischserver v0.5.0 *)",
|
||||
"Bash(git config *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
# 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)
|
||||
+467
-490
@@ -1,490 +1,467 @@
|
||||
# 🐠 Clownfischserver – Projekt-Kontext
|
||||
|
||||
> Diese Datei ist der zentrale Wissensspeicher für das Clownfischserver-Projekt.
|
||||
> Lade sie in jede KI/Chat-Session um sofort produktiv zu sein.
|
||||
> Letzte Aktualisierung: v0.4.13 (18.04.2026)
|
||||
|
||||
---
|
||||
|
||||
## Was ist der Clownfischserver?
|
||||
|
||||
Ein Open-Source, Telegram-gesteuerter Linux-Server-Manager. Der Admin schreibt dem Bot über Telegram – der Bot versteht, generiert Befehle, erstellt Scripts, verwaltet den Server. Komplett lokal mit Ollama, optional Cloud-Fallback über OpenRouter.
|
||||
|
||||
**Repo:** https://github.com/mehlzoerwer-claude/clownfischserver
|
||||
**Lizenz:** GPL-3.0
|
||||
**Entwickler:** Mehlzoerwer-Claude (Thorsten Matthes)
|
||||
**KI-Assistent:** Claude (Anthropic) – Pair-Programming Partner
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Telegram Bot │
|
||||
│ (bot.py) │
|
||||
└──────┬──────┬────────┘
|
||||
│ │
|
||||
┌─────────────────┤ ├─────────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────────────┐ ┌────────────┐ ┌──────────────────┐
|
||||
│ ollama_client │ │ shell.py │ │ aider_wrapper │
|
||||
│ (Dual-Model) │ │ (run_shell)│ │ (Code-Agent) │
|
||||
└───────┬────┬────┘ └────────────┘ └──────────────────┘
|
||||
│ │
|
||||
┌────────┘ └────────┐
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Chat-Model│ │ Coder-Model │ │ OpenRouter │
|
||||
│ (gemma3) │ │(qwen2.5-coder)│ │ (Fallback) │
|
||||
└──────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
**Prinzip:** Freitext = Chat. `/shell` = KI generiert Befehl → Admin bestätigt mit `/ja`. `/code` = Aider erstellt Dateien. `/run` = direkte Shell.
|
||||
|
||||
---
|
||||
|
||||
## Dateistruktur (19 Dateien, ~3250 Zeilen)
|
||||
|
||||
```
|
||||
clownfischserver/
|
||||
├── bot/
|
||||
│ ├── bot.py # Hauptbot: alle Commands, Auth, Self-Update, Update-Dialog
|
||||
│ ├── ollama_client.py # Dual-Model, OpenRouter Fallback, NUM_CTX, Shell-Prompt
|
||||
│ ├── openrouter_client.py # OpenRouter Free Models Client (openrouter/free)
|
||||
│ ├── shell.py # run_shell (shlex.quote), execute_command
|
||||
│ ├── aider_wrapper.py # Aider mit --edit-format diff, 600s Timeout
|
||||
│ ├── snapshot.py # tar.gz Snapshots, Workspace, Paketliste, sudo rsync
|
||||
│ ├── boot_notify.py # Telegram-Nachricht 120s nach Boot
|
||||
│ ├── knock_notify.py # Port-Knocking SSH-Benachrichtigungen
|
||||
│ └── requirements.txt # python-telegram-bot, requests, python-dotenv
|
||||
├── config/
|
||||
│ ├── .env.example # Vorlage mit allen Variablen
|
||||
│ └── knockd.conf # Port-Knocking Sequenz (7000→8000→9000)
|
||||
├── systemd/
|
||||
│ ├── clownfisch.service # Bot-Service (User=clownfish, wird gepatcht)
|
||||
│ └── clownfisch-boot.service # Boot-Notification (oneshot, 120s delay)
|
||||
├── install.sh # 8-Schritt Installer mit Modell/CTX-Auswahl
|
||||
├── README.md # Vollständige Dokumentation
|
||||
├── LICENSE # GPL-3.0
|
||||
├── .gitignore
|
||||
├── CONTRIBUTING.md
|
||||
└── GITHUB_PROFILE.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Konventionen
|
||||
|
||||
### Naming
|
||||
- **Service:** `clownfisch.service` (Deutsch, mit "ch")
|
||||
- **Linux-User:** Beliebig (z.B. `clownfisch`, `clownfish`, `admin`) – wird aus `MGMT_USER` in `.env` gelesen
|
||||
- **Branding:** Immer "Clownfisch" (Deutsch)
|
||||
- **ZIP-Benennung:** `v0.4.13-clownfischserver-opus.zip`
|
||||
|
||||
### Code-Stil
|
||||
- Python 3, keine Type Hints in Funktionssignaturen (außer Optional)
|
||||
- Logging über `logging` Modul
|
||||
- Async für alle Telegram-Handler
|
||||
- `safe_reply()` für alle Telegram-Nachrichten (Markdown-Fallback)
|
||||
- Fehler werden dem User angezeigt, nie verschluckt
|
||||
|
||||
### Sicherheits-Regeln
|
||||
- Configs (`sshd_config`, `knockd.conf`) NIE überschreiben – nur gezielte Zeilen ändern
|
||||
- `.env` Permissions: 0600
|
||||
- SSH Host-Keys, `/etc/shadow` NIE in Snapshots
|
||||
- `.env` seit v0.4.12 aus Snapshots excludiert
|
||||
- Self-Update: systemd-Services werden mit `MGMT_USER` aus `.env` gepatcht (Regex: `User=\S+`)
|
||||
|
||||
---
|
||||
|
||||
## .env Variablen
|
||||
|
||||
```
|
||||
TELEGRAM_BOT_TOKEN=... # Bot-Token von @BotFather
|
||||
TELEGRAM_CHAT_ID=... # Autorisierte Chat-ID (Single-User)
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=qwen2.5-coder:7b # Coder-Modell für /shell und /code
|
||||
OLLAMA_MODEL_FAST=gemma3:4b # Chat-Modell für Freitext
|
||||
OLLAMA_NUM_CTX=16384 # Context-Window (4096-32768)
|
||||
OPENROUTER_API_KEY=sk-or-v1-... # Optional: kostenloser Cloud-Fallback
|
||||
SNAPSHOT_DIR=/opt/clownfisch-snapshots
|
||||
SNAPSHOT_METHOD=tar
|
||||
INSTALL_DIR=/opt/clownfischserver
|
||||
MGMT_USER=clownfisch # Linux-User (beliebig wählbar)
|
||||
AIDER_WORKDIR=/opt/clownfisch-workspace
|
||||
CLOWNFISCHSERVER_HOME_IP_DOMAIN=vorname.myfritz.net # Optional: myfritz für Auto-SSH-Freigabe
|
||||
```
|
||||
|
||||
### Clownfischserver HomeIP Auto (neu in v0.5.0)
|
||||
|
||||
**Feature:** Automatische SSH-Freigabe für deine Home-IP alle 5 Minuten
|
||||
|
||||
- **myfritz.net:** DynDNS-Service (AVM Fritz.Box) – kostenlos
|
||||
- **Cron-Job:** Läuft als `root` alle 5 Min
|
||||
- **Workflow:**
|
||||
1. DNS-Auflösung: `vorname.myfritz.net` → IP-Adresse
|
||||
2. Vergleich: Alte IP vs. neue IP
|
||||
3. Bei Änderung: UFW-Regel aktualisieren
|
||||
4. Logging: `/var/log/clownfischserver-homeip.log`
|
||||
|
||||
**Voraussetzungen:**
|
||||
- myfritz-Account bei https://myfritz.net/ (kostenlos)
|
||||
- `dig` oder `nslookup` für DNS-Auflösung (meist bereits installiert)
|
||||
|
||||
**Beispiel:**
|
||||
```bash
|
||||
# Installation: wird vom install.sh gefragt
|
||||
? myfritz Home-IP permanent erlauben? [j/n]
|
||||
→ j
|
||||
? Deine myfritz Adresse:
|
||||
→ mehlzoerwer.myfritz.net
|
||||
|
||||
# Dann läuft:
|
||||
*/5 * * * * /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh >> /var/log/clownfischserver-homeip.log 2>&1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bot-Befehle
|
||||
|
||||
| Befehl | Funktion |
|
||||
|--------|----------|
|
||||
| Freitext | Chat mit KI (gemma3 lokal, OpenRouter Fallback) |
|
||||
| `/shell <beschreibung>` | KI generiert Shell-Befehl, non-interaktiv |
|
||||
| `/ja` | Letzten `/shell` Befehl ausführen (mit Snapshot davor) |
|
||||
| `/code <aufgabe>` | Aider erstellt Dateien (Smart Prompt: Dateiname wird ergänzt) |
|
||||
| `/run <befehl>` | Direkte Shell, kein KI, kein Bestätigen |
|
||||
| `/status` | CPU, RAM, Disk, Top-Prozesse |
|
||||
| `/ssh open [ip]` | SSH via ufw öffnen |
|
||||
| `/ssh close [ip]` | SSH via ufw schließen |
|
||||
| `/snapshot [label]` | Manuell Snapshot erstellen |
|
||||
| `/snapshot keep/delete <n>` | Snapshot markieren/löschen |
|
||||
| `/snapshots` | Alle Snapshots anzeigen |
|
||||
| `/rollback <name>` | Zu Snapshot zurückkehren (sudo rsync --delete) |
|
||||
| `/start` / `/help` | Bot-Info und Befehlsübersicht |
|
||||
| ZIP senden | Self-Update mit optionalem OpenRouter-Setup-Dialog |
|
||||
|
||||
---
|
||||
|
||||
## Dual-Model Strategie
|
||||
|
||||
| Rolle | Modell | RAM | Aufgabe |
|
||||
|-------|--------|-----|---------|
|
||||
| Coder | qwen2.5-coder:7b | ~4.7GB | `/shell`, `/code`, Befehlsgenerierung |
|
||||
| Chat | gemma3:4b | ~3.3GB | Freitext, Erklärungen, Übersetzungen |
|
||||
| Fallback | openrouter/free | 0 (Cloud) | Wenn Ollama nicht verfügbar |
|
||||
|
||||
**Shell-Prompt:** Few-Shot mit non-interaktiven Regeln (kein nano/vim/crontab -e, sudo wenn nötig).
|
||||
**Chat-Prompt:** Deutsch als Standard, antwortet in Sprache des Nutzers.
|
||||
**Explain-Prompt:** Erklärt Befehlsergebnisse auf Deutsch.
|
||||
|
||||
---
|
||||
|
||||
## Snapshot-System
|
||||
|
||||
**Gesichert:** `/etc`, `/opt/clownfischserver`, `/home`, `/opt/clownfisch-workspace`
|
||||
**Excludiert:** SSH Host-Keys, shadow, `.env`, `__pycache__`, `node_modules`
|
||||
**Paketliste:** `dpkg --get-selections` pro Snapshot als `.packages.txt`
|
||||
**Rollback:** `sudo rsync -av --delete` pro Verzeichnis
|
||||
**Auto-Cleanup:** Max 20 Snapshots, `keep`-markierte bleiben immer
|
||||
|
||||
---
|
||||
|
||||
## OpenRouter Integration
|
||||
|
||||
- **Client:** `openrouter_client.py` mit `openrouter/free` Auto-Router
|
||||
- **Fallback-Trigger:** Ollama Timeout ODER "nicht erreichbar"
|
||||
- **Betrifft:** Chat + `/shell` (nicht `/code` – Aider braucht Ollama direkt)
|
||||
- **Setup:** Beim Self-Update fragt Bot "OpenRouter aktivieren?" → Key eingeben → `.env`
|
||||
- **Kosten:** Free Tier: 50 Requests/Tag, 20/Minute, keine Kreditkarte
|
||||
|
||||
---
|
||||
|
||||
## Bekannte Bugs / Einschränkungen
|
||||
|
||||
- `/code` hat keinen OpenRouter-Fallback (Aider spricht nur Ollama)
|
||||
- Kleine Modelle (< 8B) halluzinieren bei Erklärungen
|
||||
- Aider `diff`-Format überfordert kleine Modelle manchmal → Datei wird nicht erstellt
|
||||
- Shell-Prompt vergisst manchmal `sudo` bei Root-Operationen
|
||||
|
||||
---
|
||||
|
||||
## Server-Umgebung
|
||||
|
||||
- **Hoster:** netcup VPS
|
||||
- **OS:** Ubuntu 24.04 LTS
|
||||
- **RAM:** 16GB
|
||||
- **Disk:** 300GB
|
||||
- **IP:** 92.60.38.175
|
||||
- **Ollama:** qwen2.5-coder:7b + gemma3:4b (oder gemma4:e2b)
|
||||
- **Docker:** installiert
|
||||
- **SSH:** Port-Knocking via knockd (7000→8000→9000)
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### v0.5.0 – Sicherheit + Features
|
||||
**Security (DSGVO-Pflicht):**
|
||||
- Sensitive Dateien über Telegram blockieren (`.env`, shadow, Keys)
|
||||
- Output-Sanitizer: API Keys/Tokens in JEDER Bot-Antwort maskieren
|
||||
- Log-Sanitizer: Keine Secrets im Audit-Log
|
||||
- Audit-Log: Jeder Befehl mit Timestamp (DSGVO Art. 5/32)
|
||||
- SHA256-Hash bei Self-Update + Admin-Bestätigung
|
||||
- `.env` Permissions-Check beim Bot-Start
|
||||
- `bot.log`, `.aider.chat.history.md`, `bash_history` aus Snapshots excluden
|
||||
- Rate-Limiting: Max 30 Befehle/Minute
|
||||
- Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut
|
||||
- 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot)
|
||||
- Persönlicher Code-Offset (SECURITY_OFFSET in .env)
|
||||
|
||||
**Features:**
|
||||
- Snapshot-Scheduling + Export via Telegram
|
||||
- Verschlüsselte Snapshots (SNAPSHOT_PASSWORD)
|
||||
- `/logs`, `/docker`
|
||||
- Port-Knocking Sequenz wählbar
|
||||
- Open Interpreter als Aider-Alternative evaluieren
|
||||
|
||||
**Quick-Win Security:**
|
||||
- Panik-Wort: Admin-definiertes Wort → Bot löscht .env, sperrt alles
|
||||
- Honeypot-Befehle: Fake-Commands die Eindringlinge verraten
|
||||
- Selbstzerstörende Nachrichten: Bot löscht eigene Outputs nach X Minuten
|
||||
- Rollback-Bombe: 3x falscher 2FA → auto-Rollback + SSH sperren + Alert
|
||||
- Dead Man's Switch: Kein Lebenszeichen in 24h → Bot sperrt sich selbst
|
||||
|
||||
### v0.6.0 – Multi-Server + Gedächtnis
|
||||
- Ein Bot steuert mehrere Server
|
||||
- Audit-Log / Server-Gedächtnis (server_log.txt)
|
||||
- Chat kann vergangene Aktionen abfragen
|
||||
|
||||
### v0.7.0 – DSGVO-bewusster Backup-Manager
|
||||
- Verschlüsselte Snapshots (SNAPSHOT_PASSWORD in .env)
|
||||
|
||||
### v0.8.0 – Erweiterte Cloud-API
|
||||
- Premium-Modelle auf OpenRouter
|
||||
- Anthropic/OpenAI direkt
|
||||
- Kosten-Tracking
|
||||
|
||||
### v0.9.0 – Multi-Interface + Advanced Security
|
||||
**Alternative Interfaces (E2E-verschlüsselt):**
|
||||
- Matrix/Element Connector (matrix-nio, self-hosted, E2E)
|
||||
- Signal Connector (signal-cli, E2E)
|
||||
- Web-UI (HTTPS, self-hosted, Let's Encrypt)
|
||||
- SSH-CLI (interaktive Shell-Session, kein Messenger nötig)
|
||||
- E-Mail mit GPG-Verschlüsselung
|
||||
|
||||
**Advanced Security Features (evaluieren bei v0.8.x Abschluss):**
|
||||
- Fake-Modus: Falscher 2FA-Code → Bot tut so als ob, führt nichts aus, stiller Alarm
|
||||
- Distress-Code: Umgekehrter Offset = "Ich werde gezwungen" → Fake-Modus
|
||||
- Geo-Fencing: Bot antwortet nur aus erlaubten Ländern/IP-Bereichen
|
||||
- Buddy-System: Kritische Befehle brauchen Bestätigung von zweitem Admin
|
||||
- Canary Token: Unsichtbare Marker-Dateien, Alert bei Zugriff
|
||||
- Verschlüsselter Rückkanal: Bot-Antworten verschlüsselt über Telegram
|
||||
- Hardware-Token: USB-Stick als physischer Schlüssel
|
||||
|
||||
**Architektur-Ziel:**
|
||||
```
|
||||
[Telegram] ─┐
|
||||
[Matrix] ─┤
|
||||
[Web-UI] ─┼─→ [bot_core.py] → [Ollama/OpenRouter]
|
||||
[SSH-CLI] ─┤
|
||||
[E-Mail] ─┘
|
||||
```
|
||||
Ein Bot, viele Eingänge. Admin wählt was er will.
|
||||
|
||||
### v1.0.0 – Disaster Recovery
|
||||
### v2.0.0 – Cross-Distribution Server Migration
|
||||
|
||||
---
|
||||
|
||||
## Getestete Modelle
|
||||
|
||||
| Modell | Größe | Rolle | Ergebnis |
|
||||
|--------|-------|-------|----------|
|
||||
| qwen2.5-coder:7b | 4.7GB | Coder | ✅ Beste Wahl für Code |
|
||||
| qwen2.5-coder:3b | 1.9GB | Coder | ✅ Funktioniert, schwächer |
|
||||
| gemma3:4b | 3.3GB | Chat | ✅ Gutes Deutsch, schnell |
|
||||
| gemma4:e2b | 7.2GB | Beide | ⚠️ Zu groß für Dual-Setup |
|
||||
| rnj-1:8b | 5.1GB | Coder | ❓ Test ausstehend |
|
||||
| qwen3.5:4b | – | Coder | ❌ Thinking, zu langsam auf CPU |
|
||||
| openrouter/free | Cloud | Fallback | ✅ Nemotron-H, stark |
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Design-Entscheidungen
|
||||
|
||||
1. **Local-first:** Cloud ist Opt-in Werkzeug, nicht Abhängigkeit
|
||||
2. **Kein Classifier:** Freitext = Chat. Befehle explizit per `/shell`, `/run`, `/code`
|
||||
3. **Bestätigungs-Flow:** `/shell` generiert → Admin prüft → `/ja` führt aus
|
||||
4. **Beliebiger Username:** MGMT_USER wird überall per Regex eingesetzt
|
||||
5. **Smart `/code` Prompt:** Bot ergänzt Dateiname wenn User keinen nennt
|
||||
6. **Snapshot vor destruktiven Aktionen:** Automatisch bei apt install, rm, chmod etc.
|
||||
7. **DSGVO by design:** Lokal, keine Daten an Cloud, Snapshots ohne Secrets
|
||||
|
||||
---
|
||||
|
||||
## Security-Analyse (v0.4.13)
|
||||
|
||||
### Was wir haben ✅
|
||||
- Chat-ID Auth (Single-User)
|
||||
- `.env` mit 0600 Permissions
|
||||
- SSH Host-Keys + shadow excluded aus Snapshots
|
||||
- `.env` excluded aus Snapshots (seit v0.4.12)
|
||||
- Port-Knocking statt offenes SSH
|
||||
- Lokal-first, Cloud opt-in
|
||||
- Safe-Reply Markdown-Fallback (kein Crash bei Sonderzeichen)
|
||||
|
||||
### Bekannte Schwachstellen ❌
|
||||
|
||||
**Telegram als Angriffsfläche:**
|
||||
- Bot-Token im Klartext in `.env` – wer den Server hackt hat den Bot
|
||||
- Telegram Chat-History enthält alle Befehle + Outputs – nicht löschbar vom Bot
|
||||
- Telegram API ist NICHT E2E-verschlüsselt – Telegram Inc. kann mitlesen
|
||||
- Jemand der das Handy entsperrt hat vollen Zugriff
|
||||
- `.env` Inhalte können per `/run cat .env` über Telegram geleakt werden
|
||||
|
||||
**Bot-Sicherheit:**
|
||||
- Kein Rate-Limiting – Brute-Force auf Befehle möglich
|
||||
- Kein Session-Timeout – Bot antwortet 24/7 ohne erneute Auth
|
||||
- `bot.log` enthält möglicherweise sensible Daten (unverschlüsselt)
|
||||
- `.aider.chat.history.md` loggt alle Code-Prompts im Klartext
|
||||
- Ollama API auf localhost:11434 hat keine Auth – jeder lokale Prozess kann anfragen
|
||||
|
||||
**Snapshots:**
|
||||
- Snapshots enthalten `/home` – SSH Keys, bash_history, private Dateien
|
||||
- Snapshot-Verzeichnis nicht verschlüsselt
|
||||
- Kein Integrity-Check ob Snapshots manipuliert wurden
|
||||
|
||||
**Netzwerk:**
|
||||
- knockd Sequenz steht im Klartext in `knockd.conf`
|
||||
- ufw Regeln können über `/run` beliebig geändert werden
|
||||
|
||||
**Server:**
|
||||
- NOPASSWD sudo – kompromittierter Bot = Root
|
||||
- `install.sh` lädt Pakete aus dem Internet ohne Signatur-Check
|
||||
- Docker Socket zugänglich → Container-Escape möglich
|
||||
|
||||
### Security-Maßnahmen geplant
|
||||
|
||||
**v0.5.0 (realistisch umsetzbar):**
|
||||
1. `.env`/Secrets über Telegram blockieren (Blacklist für sensitive Pfade)
|
||||
2. Output-Sanitizer: API Keys/Tokens in JEDER Bot-Antwort maskieren
|
||||
3. Log-Sanitizer: Keine Secrets im Audit-Log
|
||||
4. `bot.log` + `.aider.chat.history.md` + `bash_history` aus Snapshots excluden
|
||||
5. Rate-Limiting: Max 30 Befehle/Minute
|
||||
6. Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut nötig
|
||||
7. 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot)
|
||||
8. Persönlicher Code-Offset (SECURITY_OFFSET in .env)
|
||||
9. SHA256-Hash bei Self-Update → Admin bestätigt
|
||||
10. `.env` Permissions-Check beim Bot-Start (warnen wenn nicht 0600)
|
||||
11. Audit-Log mit Timestamp für jeden Befehl (DSGVO Art. 5/32)
|
||||
|
||||
**v0.6.0+ (langfristig):**
|
||||
12. Bot-Token Rotation
|
||||
13. Verschlüsselte Snapshots (SNAPSHOT_PASSWORD)
|
||||
14. Integrity-Hashes für Snapshots (Manipulation erkennen)
|
||||
15. Ollama API Auth
|
||||
16. Audit-Log mit Tamper-Protection (Signierung)
|
||||
|
||||
### DSGVO-Regel für Telegram
|
||||
`.env` wird NUR angelegt/geändert über SSH. Niemals über Telegram lesbar.
|
||||
Der Bot BLOCKIERT Befehle die sensitive Dateien über Telegram ausgeben würden.
|
||||
|
||||
---
|
||||
|
||||
## ZIP bauen – exakte Vorgehensweise
|
||||
|
||||
### Voraussetzungen
|
||||
- Alle 20 Dateien müssen im Ordner `clownfischserver/` liegen
|
||||
- Python-Dateien müssen syntax-geprüft sein
|
||||
- install.sh muss bash-syntax-geprüft sein
|
||||
|
||||
### Schritt 1: Syntax-Check (IMMER vor dem ZIP)
|
||||
```bash
|
||||
cd clownfischserver
|
||||
python3 -m py_compile bot/bot.py
|
||||
python3 -m py_compile bot/ollama_client.py
|
||||
python3 -m py_compile bot/openrouter_client.py
|
||||
python3 -m py_compile bot/shell.py
|
||||
python3 -m py_compile bot/snapshot.py
|
||||
python3 -m py_compile bot/aider_wrapper.py
|
||||
python3 -m py_compile bot/boot_notify.py
|
||||
python3 -m py_compile bot/knock_notify.py
|
||||
bash -n install.sh
|
||||
```
|
||||
|
||||
### Schritt 2: ZIP erstellen
|
||||
```bash
|
||||
cd .. # Eine Ebene über clownfischserver/
|
||||
zip -r v0.4.13-clownfischserver-opus.zip clownfischserver/ -x "*__pycache__*" "*.pyc"
|
||||
```
|
||||
|
||||
**WICHTIG:**
|
||||
- ZIP-Name: `v<version>-clownfischserver-opus.zip`
|
||||
- Root-Ordner im ZIP muss `clownfischserver/` heißen (nicht `build/` oder anderer Name)
|
||||
- Keine `__pycache__` oder `.pyc` Dateien
|
||||
- Keine `.git/` Ordner
|
||||
- Keine `.env` mit echten Credentials
|
||||
|
||||
### Schritt 3: Verifizieren
|
||||
```bash
|
||||
unzip -l v0.4.13-clownfischserver-opus.zip | head -30
|
||||
# Muss zeigen: clownfischserver/bot/bot.py etc. (20 Dateien)
|
||||
```
|
||||
|
||||
### Schritt 4: Einspielen
|
||||
**Per Telegram:** ZIP an den ClownfischServer Bot senden → Self-Update Dialog
|
||||
**Per Server:** `bash install.sh` im entpackten Ordner
|
||||
|
||||
### Schritt 5: GitHub Push
|
||||
```bash
|
||||
cd clownfischserver
|
||||
rm -rf .git
|
||||
git init
|
||||
git remote add origin https://github.com/mehlzoerwer-claude/clownfischserver.git
|
||||
git add -A
|
||||
git commit -m "v0.4.13 – Beschreibung der Änderungen"
|
||||
git branch -M main
|
||||
git push -u origin main --force
|
||||
```
|
||||
|
||||
### Dateiliste (20 Dateien, vollständig)
|
||||
```
|
||||
clownfischserver/
|
||||
├── .gitignore
|
||||
├── CLOWNFISCHSERVER.md ← diese Datei
|
||||
├── CONTRIBUTING.md
|
||||
├── GITHUB_PROFILE.md
|
||||
├── LICENSE
|
||||
├── README.md
|
||||
├── install.sh
|
||||
├── bot/
|
||||
│ ├── aider_wrapper.py
|
||||
│ ├── boot_notify.py
|
||||
│ ├── bot.py
|
||||
│ ├── knock_notify.py
|
||||
│ ├── ollama_client.py
|
||||
│ ├── openrouter_client.py
|
||||
│ ├── requirements.txt
|
||||
│ ├── shell.py
|
||||
│ └── snapshot.py
|
||||
├── config/
|
||||
│ ├── .env.example
|
||||
│ └── knockd.conf
|
||||
└── systemd/
|
||||
├── clownfisch-boot.service
|
||||
└── clownfisch.service
|
||||
```
|
||||
|
||||
### Versions-Bump Checkliste
|
||||
Bei neuer Version diese Stellen aktualisieren:
|
||||
1. `install.sh` – Banner + Log-Meldung (3x `v0.4.13`)
|
||||
2. `bot/bot.py` – `cmd_start` Begrüßung + `main()` Log-Meldung
|
||||
3. `README.md` – Badge `Version-0.4.13` + Roadmap "Fertig (v0.1 – v0.4.13)"
|
||||
4. `CLOWNFISCHSERVER.md` – Header + Dateiliste
|
||||
5. ZIP-Dateiname
|
||||
|
||||
---
|
||||
|
||||
> *"Angefangen als einfacher Telegram-Bot – gewachsen zu einer Vision."* 🐠
|
||||
# 🐠 Clownfischserver – Projekt-Kontext
|
||||
|
||||
> Diese Datei ist der zentrale Wissensspeicher für das Clownfischserver-Projekt.
|
||||
> Lade sie in jede KI/Chat-Session um sofort produktiv zu sein.
|
||||
> Letzte Aktualisierung: v0.5.0 (30.05.2026)
|
||||
|
||||
> **⚠️ Bewusst entfernt (nicht wieder einbauen!):** Ein „Vault/MCP-Context"-System
|
||||
> (`_get_vault_context`, `MCP_VAULT_URL`, `MCP_API_KEY`) wurde in v0.5.0 komplett
|
||||
> entfernt. Es war toter Code, nutzte `verify=False` und schickte Chat-Nachrichten an
|
||||
> externe Endpoints – ein klarer DSGVO-Verstoß und Widerspruch zum „local-first"-Prinzip.
|
||||
> Token-Optimierung über Obsidian gehört NICHT in den Bot.
|
||||
|
||||
---
|
||||
|
||||
## Was ist der Clownfischserver?
|
||||
|
||||
Ein Open-Source, Telegram-gesteuerter Linux-Server-Manager. Der Admin schreibt dem Bot über Telegram – der Bot versteht, generiert Befehle, erstellt Scripts, verwaltet den Server. Komplett lokal mit Ollama, optional Cloud-Fallback über OpenRouter.
|
||||
|
||||
**Repo:** https://github.com/mehlzoerwer-claude/clownfischserver
|
||||
**Lizenz:** GPL-3.0
|
||||
**Entwickler:** Mehlzoerwer-Claude (Thorsten Matthes)
|
||||
**KI-Assistent:** Claude (Anthropic) – Pair-Programming Partner
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Telegram Bot │
|
||||
│ (bot.py) │
|
||||
└──────┬──────┬────────┘
|
||||
│ │
|
||||
┌─────────────────┤ ├─────────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────────────┐ ┌────────────┐ ┌──────────────────┐
|
||||
│ ollama_client │ │ shell.py │ │ aider_wrapper │
|
||||
│ (Dual-Model) │ │ (run_shell)│ │ (Code-Agent) │
|
||||
└───────┬────┬────┘ └────────────┘ └──────────────────┘
|
||||
│ │
|
||||
┌────────┘ └────────┐
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Chat-Model│ │ Coder-Model │ │ OpenRouter │
|
||||
│ (gemma3) │ │(qwen2.5-coder)│ │ (Fallback) │
|
||||
└──────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
**Prinzip:** Freitext = Chat. `/shell` = KI generiert Befehl → Admin bestätigt mit `/ja`. `/code` = Aider erstellt Dateien. `/run` = direkte Shell.
|
||||
|
||||
---
|
||||
|
||||
## Dateistruktur (19 Dateien, ~3250 Zeilen)
|
||||
|
||||
```
|
||||
clownfischserver/
|
||||
├── bot/
|
||||
│ ├── bot.py # Hauptbot: alle Commands, Auth, Self-Update, Update-Dialog
|
||||
│ ├── ollama_client.py # Dual-Model, OpenRouter Fallback, NUM_CTX, Shell-Prompt
|
||||
│ ├── openrouter_client.py # OpenRouter Free Models Client (openrouter/free)
|
||||
│ ├── shell.py # run_shell (shlex.quote), execute_command
|
||||
│ ├── aider_wrapper.py # Aider mit --edit-format diff, 600s Timeout
|
||||
│ ├── snapshot.py # tar.gz Snapshots, Workspace, Paketliste, sudo rsync
|
||||
│ ├── boot_notify.py # Telegram-Nachricht 120s nach Boot
|
||||
│ ├── knock_notify.py # Port-Knocking SSH-Benachrichtigungen
|
||||
│ └── requirements.txt # python-telegram-bot, requests, python-dotenv
|
||||
├── config/
|
||||
│ ├── .env.example # Vorlage mit allen Variablen
|
||||
│ └── knockd.conf # Port-Knocking Sequenz (7000→8000→9000)
|
||||
├── systemd/
|
||||
│ ├── clownfisch.service # Bot-Service (User=clownfish, wird gepatcht)
|
||||
│ └── clownfisch-boot.service # Boot-Notification (oneshot, 120s delay)
|
||||
├── install.sh # 8-Schritt Installer mit Modell/CTX-Auswahl
|
||||
├── README.md # Vollständige Dokumentation
|
||||
├── LICENSE # GPL-3.0
|
||||
├── .gitignore
|
||||
├── CONTRIBUTING.md
|
||||
└── GITHUB_PROFILE.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Konventionen
|
||||
|
||||
### Naming
|
||||
- **Service:** `clownfisch.service` (Deutsch, mit "ch")
|
||||
- **Linux-User:** Beliebig (z.B. `clownfisch`, `clownfish`, `admin`) – wird aus `MGMT_USER` in `.env` gelesen
|
||||
- **Branding:** Immer "Clownfisch" (Deutsch)
|
||||
- **ZIP-Benennung:** `v0.5.0-clownfischserver.zip`
|
||||
|
||||
### Code-Stil
|
||||
- Python 3, keine Type Hints in Funktionssignaturen (außer Optional)
|
||||
- Logging über `logging` Modul
|
||||
- Async für alle Telegram-Handler
|
||||
- `safe_reply()` für alle Telegram-Nachrichten (Markdown-Fallback)
|
||||
- Fehler werden dem User angezeigt, nie verschluckt
|
||||
|
||||
### Sicherheits-Regeln
|
||||
- Configs (`sshd_config`, `knockd.conf`) NIE überschreiben – nur gezielte Zeilen ändern
|
||||
- `.env` Permissions: 0600
|
||||
- SSH Host-Keys, `/etc/shadow` NIE in Snapshots
|
||||
- `.env` seit v0.4.12 aus Snapshots excludiert
|
||||
- Self-Update: systemd-Services werden mit `MGMT_USER` aus `.env` gepatcht (Regex: `User=\S+`)
|
||||
|
||||
---
|
||||
|
||||
## .env Variablen
|
||||
|
||||
```
|
||||
TELEGRAM_BOT_TOKEN=... # Bot-Token von @BotFather
|
||||
TELEGRAM_CHAT_ID=... # Autorisierte Chat-ID (Single-User)
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=qwen2.5-coder:7b # Coder-Modell für /shell und /code
|
||||
OLLAMA_MODEL_FAST=gemma3:4b # Chat-Modell für Freitext
|
||||
OLLAMA_NUM_CTX=16384 # Context-Window (4096-32768)
|
||||
OPENROUTER_API_KEY=sk-or-v1-... # Optional: kostenloser Cloud-Fallback
|
||||
SNAPSHOT_DIR=/opt/clownfisch-snapshots
|
||||
SNAPSHOT_METHOD=tar
|
||||
INSTALL_DIR=/opt/clownfischserver
|
||||
MGMT_USER=clownfisch # Linux-User (beliebig wählbar)
|
||||
AIDER_WORKDIR=/opt/clownfisch-workspace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bot-Befehle
|
||||
|
||||
| Befehl | Funktion |
|
||||
|--------|----------|
|
||||
| Freitext | Chat mit KI (gemma3 lokal, OpenRouter Fallback) |
|
||||
| `/shell <beschreibung>` | KI generiert Shell-Befehl, non-interaktiv |
|
||||
| `/ja` | Letzten `/shell` Befehl ausführen (mit Snapshot davor) |
|
||||
| `/code <aufgabe>` | Aider erstellt Dateien (Smart Prompt: Dateiname wird ergänzt) |
|
||||
| `/run <befehl>` | Direkte Shell, kein KI, kein Bestätigen |
|
||||
| `/status` | CPU, RAM, Disk, Top-Prozesse |
|
||||
| `/ssh open [ip]` | SSH via ufw öffnen |
|
||||
| `/ssh close [ip]` | SSH via ufw schließen |
|
||||
| `/snapshot [label]` | Manuell Snapshot erstellen |
|
||||
| `/snapshot keep/delete <n>` | Snapshot markieren/löschen |
|
||||
| `/snapshots` | Alle Snapshots anzeigen |
|
||||
| `/rollback <name>` | Zu Snapshot zurückkehren (sudo rsync --delete) |
|
||||
| `/start` / `/help` | Bot-Info und Befehlsübersicht |
|
||||
| ZIP senden | Self-Update mit optionalem OpenRouter-Setup-Dialog |
|
||||
|
||||
---
|
||||
|
||||
## Dual-Model Strategie
|
||||
|
||||
| Rolle | Modell | RAM | Aufgabe |
|
||||
|-------|--------|-----|---------|
|
||||
| Coder | qwen2.5-coder:7b | ~4.7GB | `/shell`, `/code`, Befehlsgenerierung |
|
||||
| Chat | gemma3:4b | ~3.3GB | Freitext, Erklärungen, Übersetzungen |
|
||||
| Fallback | openrouter/free | 0 (Cloud) | Wenn Ollama nicht verfügbar |
|
||||
|
||||
**Shell-Prompt:** Few-Shot mit non-interaktiven Regeln (kein nano/vim/crontab -e, sudo wenn nötig).
|
||||
**Chat-Prompt:** Deutsch als Standard, antwortet in Sprache des Nutzers.
|
||||
**Explain-Prompt:** Erklärt Befehlsergebnisse auf Deutsch.
|
||||
|
||||
---
|
||||
|
||||
## Snapshot-System
|
||||
|
||||
**Gesichert:** `/etc`, `/opt/clownfischserver`, `/home`, `/opt/clownfisch-workspace`
|
||||
**Excludiert:** SSH Host-Keys, shadow, `.env`, `__pycache__`, `node_modules`
|
||||
**Paketliste:** `dpkg --get-selections` pro Snapshot als `.packages.txt`
|
||||
**Rollback:** `sudo rsync -av --delete` pro Verzeichnis
|
||||
**Auto-Cleanup:** Max 20 Snapshots, `keep`-markierte bleiben immer
|
||||
|
||||
---
|
||||
|
||||
## OpenRouter Integration
|
||||
|
||||
- **Client:** `openrouter_client.py` mit `openrouter/free` Auto-Router
|
||||
- **Fallback-Trigger:** Ollama Timeout ODER "nicht erreichbar"
|
||||
- **Betrifft:** Chat + `/shell` (nicht `/code` – Aider braucht Ollama direkt)
|
||||
- **Setup:** Beim Self-Update fragt Bot "OpenRouter aktivieren?" → Key eingeben → `.env`
|
||||
- **Kosten:** Free Tier: 50 Requests/Tag, 20/Minute, keine Kreditkarte
|
||||
|
||||
---
|
||||
|
||||
## Bekannte Bugs / Einschränkungen
|
||||
|
||||
- `/code` hat keinen OpenRouter-Fallback (Aider spricht nur Ollama)
|
||||
- Kleine Modelle (< 8B) halluzinieren bei Erklärungen
|
||||
- Aider `diff`-Format überfordert kleine Modelle manchmal → Datei wird nicht erstellt
|
||||
- Shell-Prompt vergisst manchmal `sudo` bei Root-Operationen
|
||||
|
||||
---
|
||||
|
||||
## Server-Umgebung
|
||||
|
||||
- **Hoster:** netcup VPS
|
||||
- **OS:** Ubuntu 24.04 LTS
|
||||
- **RAM:** 16GB
|
||||
- **Disk:** 300GB
|
||||
- **IP:** 92.60.38.175
|
||||
- **Ollama:** qwen2.5-coder:7b + gemma3:4b (oder gemma4:e2b)
|
||||
- **Docker:** installiert
|
||||
- **SSH:** Port-Knocking via knockd (7000→8000→9000)
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### v0.5.0 – Sicherheit + Features
|
||||
**Security (DSGVO-Pflicht):**
|
||||
- Sensitive Dateien über Telegram blockieren (`.env`, shadow, Keys)
|
||||
- Output-Sanitizer: API Keys/Tokens in JEDER Bot-Antwort maskieren
|
||||
- Log-Sanitizer: Keine Secrets im Audit-Log
|
||||
- Audit-Log: Jeder Befehl mit Timestamp (DSGVO Art. 5/32)
|
||||
- SHA256-Hash bei Self-Update + Admin-Bestätigung
|
||||
- `.env` Permissions-Check beim Bot-Start
|
||||
- `bot.log`, `.aider.chat.history.md`, `bash_history` aus Snapshots excluden
|
||||
- Rate-Limiting: Max 30 Befehle/Minute
|
||||
- Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut
|
||||
- 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot)
|
||||
- Persönlicher Code-Offset (SECURITY_OFFSET in .env)
|
||||
|
||||
**Features:**
|
||||
- Snapshot-Scheduling + Export via Telegram
|
||||
- Verschlüsselte Snapshots (SNAPSHOT_PASSWORD)
|
||||
- `/logs`, `/docker`
|
||||
- Port-Knocking Sequenz wählbar
|
||||
- Open Interpreter als Aider-Alternative evaluieren
|
||||
|
||||
**Quick-Win Security:**
|
||||
- Panik-Wort: Admin-definiertes Wort → Bot löscht .env, sperrt alles
|
||||
- Honeypot-Befehle: Fake-Commands die Eindringlinge verraten
|
||||
- Selbstzerstörende Nachrichten: Bot löscht eigene Outputs nach X Minuten
|
||||
- Rollback-Bombe: 3x falscher 2FA → auto-Rollback + SSH sperren + Alert
|
||||
- Dead Man's Switch: Kein Lebenszeichen in 24h → Bot sperrt sich selbst
|
||||
|
||||
### v0.6.0 – Multi-Server + Gedächtnis
|
||||
- Ein Bot steuert mehrere Server
|
||||
- Audit-Log / Server-Gedächtnis (server_log.txt)
|
||||
- Chat kann vergangene Aktionen abfragen
|
||||
|
||||
### v0.7.0 – DSGVO-bewusster Backup-Manager
|
||||
- Verschlüsselte Snapshots (SNAPSHOT_PASSWORD in .env)
|
||||
|
||||
### v0.8.0 – Erweiterte Cloud-API
|
||||
- Premium-Modelle auf OpenRouter
|
||||
- Anthropic/OpenAI direkt
|
||||
- Kosten-Tracking
|
||||
|
||||
### v0.9.0 – Multi-Interface + Advanced Security
|
||||
**Alternative Interfaces (E2E-verschlüsselt):**
|
||||
- Matrix/Element Connector (matrix-nio, self-hosted, E2E)
|
||||
- Signal Connector (signal-cli, E2E)
|
||||
- Web-UI (HTTPS, self-hosted, Let's Encrypt)
|
||||
- SSH-CLI (interaktive Shell-Session, kein Messenger nötig)
|
||||
- E-Mail mit GPG-Verschlüsselung
|
||||
|
||||
**Advanced Security Features (evaluieren bei v0.8.x Abschluss):**
|
||||
- Fake-Modus: Falscher 2FA-Code → Bot tut so als ob, führt nichts aus, stiller Alarm
|
||||
- Distress-Code: Umgekehrter Offset = "Ich werde gezwungen" → Fake-Modus
|
||||
- Geo-Fencing: Bot antwortet nur aus erlaubten Ländern/IP-Bereichen
|
||||
- Buddy-System: Kritische Befehle brauchen Bestätigung von zweitem Admin
|
||||
- Canary Token: Unsichtbare Marker-Dateien, Alert bei Zugriff
|
||||
- Verschlüsselter Rückkanal: Bot-Antworten verschlüsselt über Telegram
|
||||
- Hardware-Token: USB-Stick als physischer Schlüssel
|
||||
|
||||
**Architektur-Ziel:**
|
||||
```
|
||||
[Telegram] ─┐
|
||||
[Matrix] ─┤
|
||||
[Web-UI] ─┼─→ [bot_core.py] → [Ollama/OpenRouter]
|
||||
[SSH-CLI] ─┤
|
||||
[E-Mail] ─┘
|
||||
```
|
||||
Ein Bot, viele Eingänge. Admin wählt was er will.
|
||||
|
||||
### v1.0.0 – Disaster Recovery
|
||||
### v2.0.0 – Cross-Distribution Server Migration
|
||||
|
||||
---
|
||||
|
||||
## Getestete Modelle
|
||||
|
||||
| Modell | Größe | Rolle | Ergebnis |
|
||||
|--------|-------|-------|----------|
|
||||
| qwen2.5-coder:7b | 4.7GB | Coder | ✅ Beste Wahl für Code |
|
||||
| qwen2.5-coder:3b | 1.9GB | Coder | ✅ Funktioniert, schwächer |
|
||||
| gemma3:4b | 3.3GB | Chat | ✅ Gutes Deutsch, schnell |
|
||||
| gemma4:e2b | 7.2GB | Beide | ⚠️ Zu groß für Dual-Setup |
|
||||
| rnj-1:8b | 5.1GB | Coder | ❓ Test ausstehend |
|
||||
| qwen3.5:4b | – | Coder | ❌ Thinking, zu langsam auf CPU |
|
||||
| openrouter/free | Cloud | Fallback | ✅ Nemotron-H, stark |
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Design-Entscheidungen
|
||||
|
||||
1. **Local-first:** Cloud ist Opt-in Werkzeug, nicht Abhängigkeit
|
||||
2. **Kein Classifier:** Freitext = Chat. Befehle explizit per `/shell`, `/run`, `/code`
|
||||
3. **Bestätigungs-Flow:** `/shell` generiert → Admin prüft → `/ja` führt aus
|
||||
4. **Beliebiger Username:** MGMT_USER wird überall per Regex eingesetzt
|
||||
5. **Smart `/code` Prompt:** Bot ergänzt Dateiname wenn User keinen nennt
|
||||
6. **Snapshot vor destruktiven Aktionen:** Automatisch bei apt install, rm, chmod etc.
|
||||
7. **DSGVO by design:** Lokal, keine Daten an Cloud, Snapshots ohne Secrets
|
||||
|
||||
---
|
||||
|
||||
## Security-Analyse (v0.4.13)
|
||||
|
||||
### Was wir haben ✅
|
||||
- Chat-ID Auth (Single-User)
|
||||
- `.env` mit 0600 Permissions
|
||||
- SSH Host-Keys + shadow excluded aus Snapshots
|
||||
- `.env` excluded aus Snapshots (seit v0.4.12)
|
||||
- Port-Knocking statt offenes SSH
|
||||
- Lokal-first, Cloud opt-in
|
||||
- Safe-Reply Markdown-Fallback (kein Crash bei Sonderzeichen)
|
||||
|
||||
### Bekannte Schwachstellen ❌
|
||||
|
||||
**Telegram als Angriffsfläche:**
|
||||
- Bot-Token im Klartext in `.env` – wer den Server hackt hat den Bot
|
||||
- Telegram Chat-History enthält alle Befehle + Outputs – nicht löschbar vom Bot
|
||||
- Telegram API ist NICHT E2E-verschlüsselt – Telegram Inc. kann mitlesen
|
||||
- Jemand der das Handy entsperrt hat vollen Zugriff
|
||||
- `.env` Inhalte können per `/run cat .env` über Telegram geleakt werden
|
||||
|
||||
**Bot-Sicherheit:**
|
||||
- Kein Rate-Limiting – Brute-Force auf Befehle möglich
|
||||
- Kein Session-Timeout – Bot antwortet 24/7 ohne erneute Auth
|
||||
- `bot.log` enthält möglicherweise sensible Daten (unverschlüsselt)
|
||||
- `.aider.chat.history.md` loggt alle Code-Prompts im Klartext
|
||||
- Ollama API auf localhost:11434 hat keine Auth – jeder lokale Prozess kann anfragen
|
||||
|
||||
**Snapshots:**
|
||||
- Snapshots enthalten `/home` – SSH Keys, bash_history, private Dateien
|
||||
- Snapshot-Verzeichnis nicht verschlüsselt
|
||||
- Kein Integrity-Check ob Snapshots manipuliert wurden
|
||||
|
||||
**Netzwerk:**
|
||||
- knockd Sequenz steht im Klartext in `knockd.conf`
|
||||
- ufw Regeln können über `/run` beliebig geändert werden
|
||||
|
||||
**Server:**
|
||||
- NOPASSWD sudo – kompromittierter Bot = Root
|
||||
- `install.sh` lädt Pakete aus dem Internet ohne Signatur-Check
|
||||
- Docker Socket zugänglich → Container-Escape möglich
|
||||
|
||||
### Security-Maßnahmen geplant
|
||||
|
||||
**v0.5.0 (realistisch umsetzbar):**
|
||||
1. `.env`/Secrets über Telegram blockieren (Blacklist für sensitive Pfade)
|
||||
2. Output-Sanitizer: API Keys/Tokens in JEDER Bot-Antwort maskieren
|
||||
3. Log-Sanitizer: Keine Secrets im Audit-Log
|
||||
4. `bot.log` + `.aider.chat.history.md` + `bash_history` aus Snapshots excluden
|
||||
5. Rate-Limiting: Max 30 Befehle/Minute
|
||||
6. Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut nötig
|
||||
7. 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot)
|
||||
8. Persönlicher Code-Offset (SECURITY_OFFSET in .env)
|
||||
9. SHA256-Hash bei Self-Update → Admin bestätigt
|
||||
10. `.env` Permissions-Check beim Bot-Start (warnen wenn nicht 0600)
|
||||
11. Audit-Log mit Timestamp für jeden Befehl (DSGVO Art. 5/32)
|
||||
|
||||
**v0.6.0+ (langfristig):**
|
||||
12. Bot-Token Rotation
|
||||
13. Verschlüsselte Snapshots (SNAPSHOT_PASSWORD)
|
||||
14. Integrity-Hashes für Snapshots (Manipulation erkennen)
|
||||
15. Ollama API Auth
|
||||
16. Audit-Log mit Tamper-Protection (Signierung)
|
||||
|
||||
### DSGVO-Regel für Telegram
|
||||
`.env` wird NUR angelegt/geändert über SSH. Niemals über Telegram lesbar.
|
||||
Der Bot BLOCKIERT Befehle die sensitive Dateien über Telegram ausgeben würden.
|
||||
|
||||
---
|
||||
|
||||
## ZIP bauen – exakte Vorgehensweise
|
||||
|
||||
### Voraussetzungen
|
||||
- Alle 20 Dateien müssen im Ordner `clownfischserver/` liegen
|
||||
- Python-Dateien müssen syntax-geprüft sein
|
||||
- install.sh muss bash-syntax-geprüft sein
|
||||
|
||||
### Schritt 1: Syntax-Check (IMMER vor dem ZIP)
|
||||
```bash
|
||||
cd clownfischserver
|
||||
python3 -m py_compile bot/bot.py
|
||||
python3 -m py_compile bot/ollama_client.py
|
||||
python3 -m py_compile bot/openrouter_client.py
|
||||
python3 -m py_compile bot/shell.py
|
||||
python3 -m py_compile bot/snapshot.py
|
||||
python3 -m py_compile bot/aider_wrapper.py
|
||||
python3 -m py_compile bot/boot_notify.py
|
||||
python3 -m py_compile bot/knock_notify.py
|
||||
bash -n install.sh
|
||||
```
|
||||
|
||||
### Schritt 2: ZIP erstellen
|
||||
```bash
|
||||
cd .. # Eine Ebene über clownfischserver/
|
||||
zip -r v0.5.0-clownfischserver.zip clownfischserver/ -x "*__pycache__*" "*.pyc"
|
||||
```
|
||||
|
||||
**WICHTIG:**
|
||||
- ZIP-Name: `v<version>-clownfischserver-opus.zip`
|
||||
- Root-Ordner im ZIP muss `clownfischserver/` heißen (nicht `build/` oder anderer Name)
|
||||
- Keine `__pycache__` oder `.pyc` Dateien
|
||||
- Keine `.git/` Ordner
|
||||
- Keine `.env` mit echten Credentials
|
||||
|
||||
### Schritt 3: Verifizieren
|
||||
```bash
|
||||
unzip -l v0.5.0-clownfischserver.zip | head -30
|
||||
# Muss zeigen: clownfischserver/bot/bot.py etc. (20 Dateien)
|
||||
```
|
||||
|
||||
### Schritt 4: Einspielen
|
||||
**Per Telegram:** ZIP an den ClownfischServer Bot senden → Self-Update Dialog
|
||||
**Per Server:** `bash install.sh` im entpackten Ordner
|
||||
|
||||
### Schritt 5: GitHub Push
|
||||
```bash
|
||||
cd clownfischserver
|
||||
rm -rf .git
|
||||
git init
|
||||
git remote add origin https://github.com/mehlzoerwer-claude/clownfischserver.git
|
||||
git add -A
|
||||
git commit -m "v0.4.13 – Beschreibung der Änderungen"
|
||||
git branch -M main
|
||||
git push -u origin main --force
|
||||
```
|
||||
|
||||
### Dateiliste (20 Dateien, vollständig)
|
||||
```
|
||||
clownfischserver/
|
||||
├── .gitignore
|
||||
├── CLOWNFISCHSERVER.md ← diese Datei
|
||||
├── CONTRIBUTING.md
|
||||
├── GITHUB_PROFILE.md
|
||||
├── LICENSE
|
||||
├── README.md
|
||||
├── install.sh
|
||||
├── bot/
|
||||
│ ├── aider_wrapper.py
|
||||
│ ├── boot_notify.py
|
||||
│ ├── bot.py
|
||||
│ ├── knock_notify.py
|
||||
│ ├── ollama_client.py
|
||||
│ ├── openrouter_client.py
|
||||
│ ├── requirements.txt
|
||||
│ ├── shell.py
|
||||
│ └── snapshot.py
|
||||
├── config/
|
||||
│ ├── .env.example
|
||||
│ └── knockd.conf
|
||||
└── systemd/
|
||||
├── clownfisch-boot.service
|
||||
└── clownfisch.service
|
||||
```
|
||||
|
||||
### Versions-Bump Checkliste
|
||||
Bei neuer Version diese Stellen aktualisieren:
|
||||
1. `install.sh` – Banner + Log-Meldung (3x `v0.4.13`)
|
||||
2. `bot/bot.py` – `cmd_start` Begrüßung + `main()` Log-Meldung
|
||||
3. `README.md` – Badge `Version-0.4.13` + Roadmap "Fertig (v0.1 – v0.4.13)"
|
||||
4. `CLOWNFISCHSERVER.md` – Header + Dateiliste
|
||||
5. ZIP-Dateiname
|
||||
|
||||
---
|
||||
|
||||
> *"Angefangen als einfacher Telegram-Bot – gewachsen zu einer Vision."* 🐠
|
||||
|
||||
+53
-44
@@ -1,44 +1,53 @@
|
||||
# Contributing to Clownfischserver 🐠
|
||||
|
||||
Danke dass du mitmachen willst! Hier sind die Regeln:
|
||||
|
||||
## Grundprinzipien
|
||||
|
||||
- **Open Source bleibt Open Source** – GPL-3.0, keine Ausnahmen
|
||||
- **Lokal first** – keine Cloud-Dependencies, keine externen APIs
|
||||
- **Einfach bleiben** – ein Nutzer, ein Bot, ein Server
|
||||
- **Sicherheit** – Safety-Check vor jeder Aktion
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Fork das Repo
|
||||
2. Branch erstellen: `git checkout -b feature/dein-feature`
|
||||
3. Änderungen committen
|
||||
4. PR öffnen mit Beschreibung was und warum
|
||||
|
||||
## Code Style
|
||||
|
||||
- Python: PEP8, async/await
|
||||
- Bash: shellcheck-kompatibel
|
||||
- Kommentare auf Deutsch oder Englisch
|
||||
- Header in jeder Datei (siehe bestehende Dateien)
|
||||
|
||||
## Issues
|
||||
|
||||
- Bug? → Issue mit Log-Output
|
||||
- Feature-Idee? → Issue mit Use-Case Beschreibung
|
||||
- Frage? → Issue oder Discussions
|
||||
|
||||
## Was wir suchen
|
||||
|
||||
- Distro-Support (getestete Fedora/Arch Fixes)
|
||||
- Neue Bot-Commands
|
||||
- Bessere Safety-Prompts
|
||||
- Dokumentation
|
||||
- Tests
|
||||
|
||||
## Was wir NICHT wollen
|
||||
|
||||
- Cloud-Dependencies
|
||||
- Closed-Source Komponenten
|
||||
- Komplexität die Einsteiger abschreckt
|
||||
# Contributing to Clownfischserver 🐠
|
||||
|
||||
Danke dass du mitmachen willst! Hier sind die Regeln:
|
||||
|
||||
## Grundprinzipien
|
||||
|
||||
- **Open Source bleibt Open Source** – GPL-3.0, keine Ausnahmen
|
||||
- **Lokal first** – keine Cloud-Dependencies, keine externen APIs
|
||||
- **Einfach bleiben** – ein Nutzer, ein Bot, ein Server
|
||||
- **Sicherheit** – Safety-Check vor jeder Aktion
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Fork das Repo
|
||||
2. Branch erstellen: `git checkout -b feature/dein-feature`
|
||||
3. Änderungen committen
|
||||
4. PR öffnen mit Beschreibung was und warum
|
||||
|
||||
## Code Style
|
||||
|
||||
- Python: PEP8, async/await
|
||||
- Bash: shellcheck-kompatibel
|
||||
- Kommentare auf Deutsch oder Englisch
|
||||
- Header in jeder Datei (siehe bestehende Dateien)
|
||||
|
||||
## Code-Hygiene (wichtig!)
|
||||
|
||||
- **Kein toter Code** – keine Funktionen/Module die definiert aber nie aufgerufen werden
|
||||
- **Keine versteckten Cloud-Calls** – jeder externe Request muss klar ersichtlich und opt-in sein
|
||||
- **Niemals `verify=False`** bei HTTPS-Requests
|
||||
- **Keine User-Eingaben an Dritte** ohne explizites Opt-in (DSGVO by design)
|
||||
- **Klare Conditions** – verschachtelte Ternaries (`A if B else C` innerhalb von `and`-Ketten) vermeiden
|
||||
- Selbst-Review vor jedem Release: macht jede Zeile noch das was sie soll?
|
||||
|
||||
## Issues
|
||||
|
||||
- Bug? → Issue mit Log-Output
|
||||
- Feature-Idee? → Issue mit Use-Case Beschreibung
|
||||
- Frage? → Issue oder Discussions
|
||||
|
||||
## Was wir suchen
|
||||
|
||||
- Distro-Support (getestete Fedora/Arch Fixes)
|
||||
- Neue Bot-Commands
|
||||
- Bessere Safety-Prompts
|
||||
- Dokumentation
|
||||
- Tests
|
||||
|
||||
## Was wir NICHT wollen
|
||||
|
||||
- Cloud-Dependencies
|
||||
- Closed-Source Komponenten
|
||||
- Komplexität die Einsteiger abschreckt
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# 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 Unternehmensumgebungen**
|
||||
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
|
||||
Serverzustand 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 Personendatenverarbeitung
|
||||
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
|
||||
@@ -1,429 +1,508 @@
|
||||
# 🐠 Clownfischserver
|
||||
|
||||
> *Wie der Clownfisch in seiner Anemone lebt – geschützt, autonom, und beißt überraschend hart.*
|
||||
|
||||
Ein selbst-hostbarer, KI-gestützter Server-Manager der ausschließlich über Telegram kommuniziert. Kein Dashboard, kein Web-UI, keine Cloud. Nur du und dein Server – über eine Chat-Nachricht.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🐟 Warum "Clownfisch"?
|
||||
|
||||
Der Clownfisch lebt in seiner Anemone – einem der wenigen Orte im Ozean die für andere Fische tödlich sind. Er verlässt sie kaum, schützt sie aktiv, und fühlt sich darin vollkommen sicher.
|
||||
|
||||
Genauso lebt der Clownfischserver **in seinem Server** (seiner Anemone):
|
||||
- Er schirmt alles ab was nicht sein muss
|
||||
- SSH bleibt standardmäßig zu – nur wer das richtige Klopfmuster kennt kommt rein
|
||||
- Die KI entscheidet was sicher ist und was nicht
|
||||
- Und wie der echte Clownfisch: **beißt er überraschend hart wenn nötig** 🐠
|
||||
|
||||
---
|
||||
|
||||
## Was ist das?
|
||||
|
||||
Clownfischserver verwandelt einen Linux-Server in einen vollautonomen, Telegram-gesteuerten Assistenten. Du schreibst ihm in natürlicher Sprache – er versteht, generiert Befehle, erstellt Scripts, und verwaltet deinen Server.
|
||||
|
||||
Das Ziel: **Server-Administration so einfach wie eine Chat-Nachricht.** Heute ein Server, morgen eine ganze Farm.
|
||||
|
||||
**Kein SCP. Kein Dashboard. Kein Cloud-Zwang. Nur Telegram.**
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- 🤖 **Telegram Bot** als primäres Interface – von überall, auch vom Handy
|
||||
- 🧠 **Dual-Model KI** – Code-Modell (qwen2.5-coder) für Befehle + Chat-Modell (gemma3) für Konversation
|
||||
- 🔧 **`/shell` + `/ja`** – KI generiert Shell-Befehle, du bestätigst vor der Ausführung
|
||||
- 🛠️ **`/code` + Aider** – autonome Code- und Script-Generierung direkt auf dem Server
|
||||
- 💬 **Natürlicher Chat** – Fragen stellen, Erklärungen bekommen, übersetzen lassen
|
||||
- 📸 **Automatische Snapshots** vor destruktiven Aktionen (tar.gz mit Keep/Unkeep + Paketliste)
|
||||
- 🔐 **Port-Knocking SSH** als sicherer Backup-Zugang (knockd) mit Telegram-Benachrichtigung
|
||||
- 🔔 **Benachrichtigungen** – SSH-Zugang, Server-Boot, Knock-Versuche via Telegram
|
||||
- 📦 **Self-Update via Telegram** – ZIP an den Bot schicken, fertig (inkl. systemd-Services)
|
||||
- ☁️ **OpenRouter Fallback** – kostenlose Cloud-Modelle als Backup wenn Ollama nicht verfügbar
|
||||
- 👤 **Eigener User** – beliebiger Name, passwordless sudo, wird überall korrekt eingesetzt
|
||||
- 🌍 **Mehrsprachig** – antwortet in der Sprache in der du schreibst
|
||||
- 🐧 **Multi-Distro** – Debian, Ubuntu, Fedora, Arch (Installer)
|
||||
- 🔒 **DSGVO by design** – lokal ist Standard, Cloud ist Opt-in
|
||||
|
||||
---
|
||||
|
||||
## Schnellstart
|
||||
|
||||
```bash
|
||||
# Frisches Ubuntu/Debian – als root:
|
||||
wget https://github.com/mehlzoerwer-claude/clownfischserver/releases/latest/download/clownfischserver.zip
|
||||
unzip clownfischserver.zip && cd clownfischserver
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
Der Installer führt dich durch alles – Schritt für Schritt, mit Erklärungen.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Was du brauchst
|
||||
|
||||
- Frischer Ubuntu 22/24 oder Debian 12/13 VPS (min. 8GB RAM empfohlen, 20GB Disk)
|
||||
- Root SSH-Zugang für die Installation
|
||||
- Ein Telegram-Account (5 Minuten für Bot-Setup)
|
||||
|
||||
### RAM-Empfehlung
|
||||
|
||||
| RAM | Coder-Modell | Chat-Modell | Gesamt ca. |
|
||||
|-----|-------------|-------------|------------|
|
||||
| 8-12 GB | qwen2.5-coder:3b | gemma3:1b | ~3 GB |
|
||||
| 16+ GB | qwen2.5-coder:7b | gemma3:4b | ~8 GB |
|
||||
|
||||
### Der Installer
|
||||
|
||||
```
|
||||
[1/8] Systemcheck → RAM, Disk, Distro-Erkennung
|
||||
[2/8] Pakete → alles Nötige mit Spinner
|
||||
[3/8] User einrichten → eigener User oder vorhandenen nutzen
|
||||
[4/8] SSH + Firewall → Port-Knocking, ufw, SSH bleibt offen
|
||||
[5/8] Ollama → Dual-Model KI-Setup (Coder + Chat)
|
||||
[6/8] Python + Aider → Bot-Umgebung + Code-Agent
|
||||
[7/8] Telegram → geführtes Bot-Setup mit BotFather
|
||||
[8/8] Service starten → systemd + Boot-Notification
|
||||
```
|
||||
|
||||
### Update (wenn bereits installiert)
|
||||
|
||||
```bash
|
||||
bash install.sh
|
||||
# → erkennt laufenden Service
|
||||
# → [1] Update [2] Neuinstallation [3] Abbrechen
|
||||
```
|
||||
|
||||
Oder noch einfacher: **ZIP direkt an den Bot schicken** – Self-Update!
|
||||
|
||||
---
|
||||
|
||||
## Nutzung
|
||||
|
||||
### Bot-Befehle
|
||||
|
||||
| Befehl | Beschreibung |
|
||||
|--------|-------------|
|
||||
| `/shell <beschreibung>` | KI generiert Shell-Befehl aus natürlicher Sprache |
|
||||
| `/ja` | Letzten vorgeschlagenen Befehl ausführen |
|
||||
| `/code <aufgabe>` | Aider erstellt/bearbeitet Code und Scripts |
|
||||
| `/run <befehl>` | Direkte Shell – kein Ollama, kein Bestätigen |
|
||||
| `/status` | CPU, RAM, Disk, Top-Prozesse |
|
||||
| `/snapshots` | Alle Snapshots anzeigen |
|
||||
| `/snapshot [label]` | Manuell Snapshot erstellen |
|
||||
| `/snapshot keep <n>` | Snapshot als wichtig markieren |
|
||||
| `/snapshot delete <n>` | Snapshot löschen |
|
||||
| `/rollback <n>` | Zu einem Snapshot zurückkehren |
|
||||
| `/ssh open [ip]` | SSH via ufw öffnen (für alle oder eine IP) |
|
||||
| `/ssh close [ip]` | SSH wieder sperren |
|
||||
| `/start` / `/help` | Bot-Info und Befehlsübersicht |
|
||||
|
||||
### Freitext-Chat
|
||||
|
||||
Einfach schreiben – ohne Befehl. Der Bot antwortet als Chat-Partner:
|
||||
|
||||
```
|
||||
"Was ist ein Reverse Proxy?"
|
||||
"Übersetze das ins Englische: ..."
|
||||
"Erkläre mir den Unterschied zwischen apt und snap"
|
||||
"Welche Services laufen gerade auf meinem Server?"
|
||||
```
|
||||
|
||||
### Workflow: `/shell` + `/ja`
|
||||
|
||||
```
|
||||
Du: /shell zeig mir die größten Dateien auf dem Server
|
||||
Bot: 🔧 Vorgeschlagener Befehl:
|
||||
du -ah / | sort -rh | head -20
|
||||
→ Mit /ja ausführen oder einfach ignorieren.
|
||||
Du: /ja
|
||||
Bot: ✅ Ergebnis: ...
|
||||
```
|
||||
|
||||
### Workflow: `/code`
|
||||
|
||||
```
|
||||
Du: /code erstelle ein bash script das alte Logs älter als 30 Tage löscht
|
||||
Bot: 🛠️ Aider Ergebnis:
|
||||
✅ Aider Task abgeschlossen
|
||||
Applied edit to cleanup-logs.sh
|
||||
```
|
||||
|
||||
### Self-Update
|
||||
|
||||
ZIP-Datei direkt an den Bot schicken → Bot erstellt Snapshot → installiert Update → startet neu.
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Telegram Bot │
|
||||
│ (bot.py) │
|
||||
└──────┬──────┬────────┘
|
||||
│ │
|
||||
┌─────────────────┤ ├─────────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────────────┐ ┌────────────┐ ┌──────────────────┐
|
||||
│ ollama_client │ │ shell.py │ │ aider_wrapper │
|
||||
│ (Dual-Model) │ │ (run_shell)│ │ (Code-Agent) │
|
||||
└───────┬────┬────┘ └────────────┘ └──────────────────┘
|
||||
│ │
|
||||
┌────────┘ └────────┐
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────────┐
|
||||
│ Chat-Model│ │ Coder-Model │
|
||||
│ (gemma3) │ │(qwen2.5-coder)│
|
||||
└──────────┘ └──────────────┘
|
||||
|
||||
Weitere Module:
|
||||
snapshot.py – Backup-System (tar.gz, keep/unkeep, auto-cleanup)
|
||||
knock_notify.py – SSH Port-Knocking Alerts
|
||||
boot_notify.py – Server-Neustart Benachrichtigung
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sicherheit
|
||||
|
||||
### Port-Knocking
|
||||
|
||||
SSH ist standardmäßig durch ufw blockiert. Zugang via Klopf-Sequenz:
|
||||
|
||||
```bash
|
||||
# Oeffnen: 7000 -> 8000 -> 9000
|
||||
knock SERVER_IP 7000 8000 9000
|
||||
|
||||
# Schliessen: 9000 -> 8000 -> 7000
|
||||
knock SERVER_IP 9000 8000 7000
|
||||
```
|
||||
|
||||
Bei jedem Knock kommt eine Telegram-Benachrichtigung mit IP und Zeitstempel.
|
||||
|
||||
### KI-gestützte Befehlsgenerierung
|
||||
|
||||
```
|
||||
/shell installiere nginx
|
||||
→ KI generiert: apt install -y nginx
|
||||
→ Gefährlich? Nein → Vorschlag anzeigen
|
||||
→ /ja zum Ausführen, oder ignorieren
|
||||
|
||||
/shell lösche alles in /etc
|
||||
→ KI generiert: rm -rf /etc/*
|
||||
→ Gefährlich? JA → Verweigert mit Erklärung
|
||||
```
|
||||
|
||||
Kein Befehl wird ohne Bestätigung ausgeführt. Gefährliche Befehle werden immer verweigert.
|
||||
|
||||
### Snapshot-System
|
||||
|
||||
Vor destruktiven Aktionen automatischer Snapshot (tar.gz). Wichtige Snapshots können mit `/snapshot keep` markiert werden und werden nie automatisch gelöscht.
|
||||
|
||||
### Boot-Notification
|
||||
|
||||
Nach jedem Server-Neustart bekommst du eine Telegram-Nachricht mit Status aller Dienste (Clownfisch, Ollama, ufw, SSH, Cron) – nach 2 Minuten, wenn alles hochgefahren ist.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Fertig (v0.1 – v0.4.13)
|
||||
|
||||
- [x] Basis-Installer mit geführtem Setup (8 Schritte)
|
||||
- [x] Telegram Bot mit Auth Guard (Single-User)
|
||||
- [x] Dual-Model KI: Coder (qwen2.5-coder) + Chat (gemma3/gemma4)
|
||||
- [x] `/shell` + `/ja` – Befehlsgenerierung mit Bestätigung (non-interaktive Befehle)
|
||||
- [x] `/code` – Aider Code-Agent für autonome Script-Erstellung (Smart Prompt)
|
||||
- [x] `/run` – Direkte Shell ohne KI
|
||||
- [x] `/status` – Systemübersicht (CPU, RAM, Disk, Prozesse)
|
||||
- [x] `/ssh open/close` – ufw-basierter SSH-Zugang
|
||||
- [x] Freitext-Chat mit History (letzte 10 Exchanges)
|
||||
- [x] Snapshot-System (tar.gz, keep/unkeep, auto-cleanup max 20, Paketliste)
|
||||
- [x] `/snapshot`, `/snapshots`, `/rollback` (inkl. Workspace)
|
||||
- [x] Port-Knocking SSH (knockd) mit Telegram-Benachrichtigung
|
||||
- [x] Boot-Notification nach Server-Neustart (Dienste-Status)
|
||||
- [x] Self-Update via Telegram ZIP (inkl. systemd-Services + User-Patch)
|
||||
- [x] Update-Modus im Installer (mit MGMT_USER aus .env)
|
||||
- [x] Multi-Distro Installer (apt/dnf/pacman)
|
||||
- [x] Ollama Warmup im Hintergrund (Bot sofort bereit)
|
||||
- [x] **OpenRouter Free Fallback** – kostenlose Cloud-Modelle wenn Ollama nicht verfügbar
|
||||
- [x] Interaktiver Update-Dialog (OpenRouter Key Eingabe via Telegram)
|
||||
- [x] `OLLAMA_NUM_CTX` konfigurierbar (4K–32K, RAM-basierte Empfehlung im Installer)
|
||||
- [x] Safe Telegram Output (Markdown-Fallback bei Parse-Fehlern)
|
||||
- [x] Lange Antworten werden in Teile gesplittet (kein Abschneiden)
|
||||
- [x] Mehrsprachig (antwortet in der Sprache des Nutzers)
|
||||
- [x] MGMT_USER Regex-Patch (beliebiger Username funktioniert überall)
|
||||
- [x] Security-Analyse dokumentiert (Schwachstellen + Maßnahmen in CLOWNFISCHSERVER.md)
|
||||
- [x] DSGVO-Regel: `.env` nur per SSH, nie über Telegram lesbar
|
||||
- [x] Projekt-Kontext-Datei (CLOWNFISCHSERVER.md) für KI-gestütztes Pair-Programming
|
||||
|
||||
### v0.5.0 – Geplant
|
||||
|
||||
**Sicherheit & DSGVO:**
|
||||
- [ ] Sensitive Dateien über Telegram blockieren (`.env`, `shadow`, Keys)
|
||||
- [ ] Output-Sanitizer: API Keys/Tokens in jeder Bot-Antwort maskieren
|
||||
- [ ] Log-Sanitizer: Keine Secrets im Audit-Log
|
||||
- [ ] Audit-Log: Jeder Befehl mit Timestamp (DSGVO Art. 5/32)
|
||||
- [ ] Self-Update Hardening: SHA256-Hash anzeigen, Admin bestätigt
|
||||
- [ ] `.env` Permissions-Check beim Bot-Start
|
||||
- [ ] `bot.log`, `.aider.chat.history.md`, `bash_history` aus Snapshots excluden
|
||||
- [ ] Rate-Limiting: Max 30 Befehle/Minute
|
||||
- [ ] Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut nötig
|
||||
- [ ] 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot)
|
||||
- [ ] Persönlicher Code-Offset (SECURITY_OFFSET in `.env`)
|
||||
|
||||
**Features:**
|
||||
- [ ] Snapshot-Scheduling (`/snapshot schedule daily 03:00`)
|
||||
- [ ] Snapshot-Export als ZIP via Telegram (`/snapshot export <n>`)
|
||||
- [ ] `/logs` – Systemlogs direkt im Bot
|
||||
- [ ] `/docker` – Docker Container Management
|
||||
- [ ] Port-Knocking Sequenz im Installer wählbar
|
||||
- [ ] Verschlüsselte Snapshots (SNAPSHOT_PASSWORD)
|
||||
- [ ] Open Interpreter als Aider-Alternative evaluieren
|
||||
|
||||
**Quick-Win Security:**
|
||||
- [ ] Panik-Wort: Admin-definiertes Wort → Bot löscht .env, sperrt alles
|
||||
- [ ] Honeypot-Befehle: Fake-Commands die Eindringlinge verraten
|
||||
- [ ] Selbstzerstörende Nachrichten: Bot löscht eigene Outputs nach X Minuten
|
||||
- [ ] Rollback-Bombe: 3x falscher 2FA → auto-Rollback + SSH sperren
|
||||
- [ ] Dead Man's Switch: Kein Lebenszeichen in 24h → Bot sperrt sich
|
||||
|
||||
### v0.6.0 – Multi-Server
|
||||
|
||||
- [ ] Ein Bot steuert mehrere Server
|
||||
- [ ] Zentrale Snapshot-Verwaltung
|
||||
- [ ] Serverpark-Übersicht und -Steuerung
|
||||
- [ ] Mehrere Nutzer mit unterschiedlichen Rechten
|
||||
|
||||
### v0.7.0 – DSGVO-bewusster Backup-Manager
|
||||
|
||||
- [ ] `/backup configs` → immer erlaubt
|
||||
- [ ] `/backup app` → eigene Anwendungsdaten
|
||||
- [ ] `/backup db` → Warnung + Bestätigung bei personenbezogenen Daten
|
||||
- [ ] Lokale KI bewertet ob Daten DSGVO-relevant sein könnten
|
||||
|
||||
### v0.8.0 – Erweiterte Cloud-API Integration
|
||||
|
||||
- [ ] Wahl zwischen Free- und Premium-Modellen auf OpenRouter
|
||||
- [ ] Direkte Anthropic/OpenAI API-Anbindung (neben OpenRouter)
|
||||
- [ ] Admin steuert pro Befehlstyp: lokal oder Cloud
|
||||
- [ ] Kosten-Tracking für Cloud-API-Nutzung
|
||||
|
||||
### v0.9.0 – Multi-Interface + Advanced Security
|
||||
|
||||
- [ ] Matrix/Element Connector (E2E-verschlüsselt, self-hosted)
|
||||
- [ ] Web-UI mit HTTPS (self-hosted, Let's Encrypt)
|
||||
- [ ] SSH-CLI als direktes Interface
|
||||
- [ ] Fake-Modus + Distress-Code (falscher 2FA → stiller Alarm)
|
||||
- [ ] Geo-Fencing (nur erlaubte Länder/IP-Bereiche)
|
||||
- [ ] Buddy-System (zwei Admins für kritische Befehle)
|
||||
- [ ] Verschlüsselter Rückkanal über Telegram
|
||||
|
||||
---
|
||||
|
||||
## Langzeit-Vision 🚀
|
||||
|
||||
### v1.0.0 – Intelligent Disaster Recovery
|
||||
|
||||
Server komplett aus Snapshot wiederherstellen – auch auf anderer Hardware oder in einer anderen Umgebung. Die KI analysiert den Snapshot und rekonstruiert die Umgebung.
|
||||
|
||||
*"Stelle Server 3 auf Stand von gestern 02:00 Uhr wieder her."*
|
||||
|
||||
Ziel: Open Source Alternative zu teuren Enterprise DR-Lösungen.
|
||||
|
||||
### v2.0.0 – Cross-Distribution Server Migration
|
||||
|
||||
Die eigentliche Revolution: **Ein Snapshot, jedes Zielsystem.**
|
||||
|
||||
Der Clownfischserver versteht nicht nur Dateien, sondern die *Logik* eines Servers:
|
||||
- Welche Pakete sind installiert und wofür?
|
||||
- Welche Services laufen und wie sind sie konfiguriert?
|
||||
- Welche Abhängigkeiten bestehen zwischen Komponenten?
|
||||
|
||||
Diese Logik ist distro-unabhängig. Ein Debian-Server mit nginx + PostgreSQL + Docker hat die gleiche *Absicht* wie ein Arch-Server mit denselben Diensten – nur andere Paketnamen, Pfade und Init-Systeme.
|
||||
|
||||
**Ziel-Plattformen:**
|
||||
- Debian / Ubuntu (heute)
|
||||
- Arch Linux
|
||||
- Red Hat / Fedora / CentOS
|
||||
- openSUSE
|
||||
- FreeBSD
|
||||
- Weitere UNIX-Systeme
|
||||
|
||||
**Wie es funktioniert:**
|
||||
1. KI analysiert Snapshot und erstellt ein distro-neutrales "Server-Profil"
|
||||
2. Profil beschreibt: Dienste, Konfigurationen, Daten, Netzwerk, Nutzer
|
||||
3. Auf dem Zielsystem: KI übersetzt das Profil in native Befehle
|
||||
4. `apt install nginx` → `pacman -S nginx` → `pkg install nginx`
|
||||
5. Konfigurationen werden angepasst (Pfade, Syntax, Permissions)
|
||||
|
||||
*"Migriere meinen Debian-Webserver auf Arch Linux."*
|
||||
|
||||
### Weltklasse Integration 🌍
|
||||
|
||||
Clownfischserver als Infrastruktur-Layer für die Weltklasse App – natürliche Sprachsteuerung des gesamten Server-Ökosystems. GDPR-konform, vollständig lokal, kein Cloud-Zwang.
|
||||
|
||||
---
|
||||
|
||||
> *"Angefangen als einfacher Telegram-Bot – gewachsen zu einer Vision."* 🐠
|
||||
|
||||
---
|
||||
|
||||
## Prinzipien 🛡️
|
||||
|
||||
```
|
||||
1. Local AI first – Lokal ist Standard. Cloud ist Opt-in.
|
||||
2. Open Source always – GPL-3.0, keine Ausnahmen
|
||||
3. DSGVO by design – Datenschutz ist kein Feature, es ist Pflicht
|
||||
4. Security first – SSH zu, KI prüft, Snapshots sichern
|
||||
5. Einfachheit – ein Mensch, ein Bot, ein Server
|
||||
6. Distro-agnostisch – Linux ist Linux. Die KI kennt den Unterschied.
|
||||
```
|
||||
|
||||
### Zur Cloud-Frage
|
||||
|
||||
Clownfischserver ist und bleibt local-first. Aber wir sind keine Puristen: wenn ein Admin einen OpenRouter-API-Key hat und ihn für komplexe Aufgaben nutzen will, soll er das können. Die Entscheidung liegt beim Admin, nicht beim Tool.
|
||||
|
||||
Was wir **nicht** tun:
|
||||
- Cloud als Voraussetzung
|
||||
- Daten automatisch an externe APIs senden
|
||||
- Features hinter Cloud-APIs verstecken
|
||||
|
||||
Was wir **ermöglichen** (seit v0.4.12/v0.4.13):
|
||||
- Optionaler OpenRouter API-Key in `.env` (kostenlos, keine Kreditkarte)
|
||||
- Automatischer Fallback: Ollama Timeout → OpenRouter übernimmt
|
||||
- Wenn Cloud ausfällt, funktioniert alles weiterhin lokal
|
||||
- Setup direkt im Bot: ZIP schicken → Bot fragt nach Key → fertig
|
||||
|
||||
> *Als Admin entscheidest du – nicht das Tool.*
|
||||
|
||||
---
|
||||
|
||||
## Entwicklung
|
||||
|
||||
**Entwickler:** [Mehlzoerwer-Claude](https://github.com/mehlzoerwer-claude)
|
||||
|
||||
**KI-Assistent:** Dieses Projekt wurde mit [Claude](https://claude.ai) (Anthropic) als Pair-Programming Partner entwickelt. Claude ist der einzige nicht-Open-Source Teil dieser Zusammenarbeit. 😄
|
||||
|
||||
**Philosophie:** Open Source ohne Wenn und Aber. Wer's nutzt, muss es offen lassen (GPL-3.0).
|
||||
|
||||
---
|
||||
|
||||
## Lizenz
|
||||
|
||||
GPL-3.0 - Keep it open. Always.
|
||||
|
||||
Copyright (C) 2025-2026 Mehlzoerwer-Claude
|
||||
|
||||
---
|
||||
|
||||
*Danke an alle die Ollama, Aider, python-telegram-bot, knockd und ufw gebaut haben.*
|
||||
*Und an den echten Clownfisch – für die Inspiration.* 🐠
|
||||
# 🐠 Clownfischserver
|
||||
|
||||
> *Wie der Clownfisch in seiner Anemone lebt – geschützt, autonom, und beißt überraschend hart.*
|
||||
|
||||
Ein selbst-hostbarer, KI-gestützter Server-Manager der ausschließlich über Telegram kommuniziert. Kein Dashboard, kein Web-UI, keine Cloud. Nur du und dein Server – über eine Chat-Nachricht.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🐟 Warum "Clownfisch"?
|
||||
|
||||
Der Clownfisch lebt in seiner Anemone – einem der wenigen Orte im Ozean die für andere Fische tödlich sind. Er verlässt sie kaum, schützt sie aktiv, und fühlt sich darin vollkommen sicher.
|
||||
|
||||
Genauso lebt der Clownfischserver **in seinem Server** (seiner Anemone):
|
||||
- Er schirmt alles ab was nicht sein muss
|
||||
- SSH bleibt standardmäßig zu – nur wer das richtige Klopfmuster kennt kommt rein
|
||||
- Die KI entscheidet was sicher ist und was nicht
|
||||
- Und wie der echte Clownfisch: **beißt er überraschend hart wenn nötig** 🐠
|
||||
|
||||
---
|
||||
|
||||
## Was ist das?
|
||||
|
||||
Clownfischserver verwandelt einen Linux-Server in einen vollautonomen, Telegram-gesteuerten Assistenten. Du schreibst ihm in natürlicher Sprache – er versteht, generiert Befehle, erstellt Scripts, und verwaltet deinen Server.
|
||||
|
||||
Das Ziel: **Server-Administration so einfach wie eine Chat-Nachricht.** Heute ein Server, morgen eine ganze Farm.
|
||||
|
||||
**Kein SCP. Kein Dashboard. Kein Cloud-Zwang. Nur Telegram.**
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- 🤖 **Telegram Bot** als primäres Interface – von überall, auch vom Handy
|
||||
- 🧠 **Dual-Model KI** – Code-Modell (qwen2.5-coder) für Befehle + Chat-Modell (gemma3) für Konversation
|
||||
- 🔧 **`/shell` + `/ja`** – KI generiert Shell-Befehle, du bestätigst vor der Ausführung
|
||||
- 🛠️ **`/code` + Aider** – autonome Code- und Script-Generierung direkt auf dem Server
|
||||
- 💬 **Natürlicher Chat** – Fragen stellen, Erklärungen bekommen, übersetzen lassen
|
||||
- 📸 **Automatische Snapshots** vor destruktiven Aktionen (tar.gz mit Keep/Unkeep + Paketliste)
|
||||
- 🔐 **Port-Knocking SSH** als sicherer Backup-Zugang (knockd) mit Telegram-Benachrichtigung
|
||||
- 🔔 **Benachrichtigungen** – SSH-Zugang, Server-Boot, Knock-Versuche via Telegram
|
||||
- 📦 **Self-Update via Telegram** – ZIP an den Bot schicken, fertig (inkl. systemd-Services)
|
||||
- ☁️ **OpenRouter Fallback** – kostenlose Cloud-Modelle als Backup wenn Ollama nicht verfügbar
|
||||
- 👥 **Team-Rollen** *(v0.5.0)* – `operator` / `approver` / `viewer` mit Inheritance, env-gesteuert
|
||||
- 📜 **Audit-Trail** *(v0.5.0)* – jeder privilegierte Vorgang in `audit.jsonl`, abfragbar via `/logs`, Retention konfigurierbar
|
||||
- 🔀 **LLM-Router** *(v0.5.0)* – Primary/Fallback-Chain via `LLM_PRIMARY_PROVIDER` etc.; pro Provider eigenes Kontextfenster
|
||||
- 👤 **Eigener User** – beliebiger Name, passwordless sudo, wird überall korrekt eingesetzt
|
||||
- 🌍 **Mehrsprachig** – antwortet in der Sprache in der du schreibst
|
||||
- 🐧 **Multi-Distro** – Debian, Ubuntu, Fedora, Arch (Installer)
|
||||
- 🔒 **DSGVO by design** – lokal ist Standard, Cloud ist Opt-in (siehe [DSGVO.md](DSGVO.md))
|
||||
|
||||
---
|
||||
|
||||
## For Enterprise Teams (v0.5.0)
|
||||
|
||||
Clownfischserver v0.5.0 ist als **Gateway für Teams mit lokal gehosteten
|
||||
LLMs** positioniert. Wenn ihr Claude Opus, Qwen 32B oder Gemma 4 auf eigener
|
||||
Hardware betreibt, gibt euch v0.5.0:
|
||||
|
||||
### Rollenbasierte Zugriffskontrolle
|
||||
|
||||
Drei Rollen mit Inheritance (`operator` ⊃ `approver` ⊃ `viewer`):
|
||||
|
||||
```dotenv
|
||||
# .env
|
||||
CLOWNFISCH_OPERATORS=12345,67890 # dürfen /shell /code /run /ssh /ja /snapshot /rollback
|
||||
CLOWNFISCH_APPROVERS=11111 # dürfen /ja (Bestätigungen)
|
||||
CLOWNFISCH_VIEWERS=22222,33333 # dürfen /status /snapshots /help + Chat
|
||||
```
|
||||
|
||||
Wer keine Rolle hat, wird abgelehnt – inklusive Audit-Eintrag mit
|
||||
`result: denied`. Backwards-kompatibel: ohne Rollen-Vars wird die alte
|
||||
`TELEGRAM_CHAT_ID` automatisch zu `operator`.
|
||||
|
||||
### Audit-Trail nach DSGVO Art. 5 + 32
|
||||
|
||||
Jeder Vorgang wird in `audit.jsonl` als JSONL-Eintrag protokolliert:
|
||||
|
||||
```jsonl
|
||||
{"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"}}
|
||||
```
|
||||
|
||||
Operatoren fragen den Trail direkt aus Telegram ab:
|
||||
|
||||
```text
|
||||
/logs # letzte 20 Einträge
|
||||
/logs user 12345 days 7 # User-Aktivität, letzte 7 Tage
|
||||
/logs prefix shell. result error
|
||||
/logs action llm.route limit 50
|
||||
```
|
||||
|
||||
Retention via `AUDIT_RETENTION_DAYS` (Default 90 Tage, beim Bot-Start automatisch).
|
||||
|
||||
### Konfigurierbares LLM-Routing
|
||||
|
||||
Primary/Fallback-Chain via Env – kein Auto-Selection, keine Halluzinationen:
|
||||
|
||||
```dotenv
|
||||
# Primary: lokal, sensitive Daten bleiben on-prem
|
||||
LLM_PRIMARY_PROVIDER=ollama
|
||||
LLM_PRIMARY_MODEL=qwen2.5-coder:7b
|
||||
LLM_PRIMARY_NUM_CTX=65536 # z. B. Gemma 4 27B braucht 64K+
|
||||
|
||||
# Fallback nur für non-sensitive Tasks
|
||||
LLM_FALLBACK_PROVIDER=openrouter
|
||||
LLM_FALLBACK_MODEL=deepseek/deepseek-r1:free
|
||||
|
||||
# Fallback komplett deaktivieren:
|
||||
# LLM_FALLBACK_PROVIDER=none
|
||||
```
|
||||
|
||||
Routable Errors (Timeout, Connection-Refused) lösen Fallback aus; harte
|
||||
Fehler (JSON-Parse, Bad API Key) propagieren – ohne stille Modellwechsel.
|
||||
|
||||
### Vollständige Compliance-Dokumentation
|
||||
|
||||
Siehe **[DSGVO.md](DSGVO.md)** – aufgeschlüsselt nach Art. 5 + 32 mit
|
||||
Referenzen auf den konkreten Code.
|
||||
|
||||
---
|
||||
|
||||
## Schnellstart
|
||||
|
||||
```bash
|
||||
# Frisches Ubuntu/Debian – als root:
|
||||
wget https://github.com/mehlzoerwer-claude/clownfischserver/archive/refs/heads/main.zip
|
||||
unzip main.zip && cd clownfischserver-main
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
Der Installer führt dich durch alles – Schritt für Schritt, mit Erklärungen.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Was du brauchst
|
||||
|
||||
- Frischer Ubuntu 22/24 oder Debian 12/13 VPS (min. 8GB RAM empfohlen, 20GB Disk)
|
||||
- Root SSH-Zugang für die Installation
|
||||
- Ein Telegram-Account (5 Minuten für Bot-Setup)
|
||||
|
||||
### RAM-Empfehlung
|
||||
|
||||
| RAM | Coder-Modell | Chat-Modell | Gesamt ca. |
|
||||
|-----|-------------|-------------|------------|
|
||||
| 8-12 GB | qwen2.5-coder:3b | gemma3:1b | ~3 GB |
|
||||
| 16+ GB | qwen2.5-coder:7b | gemma3:4b | ~8 GB |
|
||||
|
||||
### Der Installer
|
||||
|
||||
```
|
||||
[1/8] Systemcheck → RAM, Disk, Distro-Erkennung
|
||||
[2/8] Pakete → alles Nötige mit Spinner
|
||||
[3/8] User einrichten → eigener User oder vorhandenen nutzen
|
||||
[4/8] SSH + Firewall → Port-Knocking, ufw, SSH bleibt offen
|
||||
[5/8] Ollama → Dual-Model KI-Setup (Coder + Chat)
|
||||
[6/8] Python + Aider → Bot-Umgebung + Code-Agent
|
||||
[7/8] Telegram → geführtes Bot-Setup mit BotFather
|
||||
[8/8] Service starten → systemd + Boot-Notification
|
||||
```
|
||||
|
||||
### Update (wenn bereits installiert)
|
||||
|
||||
```bash
|
||||
bash install.sh
|
||||
# → erkennt laufenden Service
|
||||
# → [1] Update [2] Neuinstallation [3] Abbrechen
|
||||
```
|
||||
|
||||
Oder noch einfacher: **ZIP direkt an den Bot schicken** – Self-Update!
|
||||
|
||||
---
|
||||
|
||||
## Nutzung
|
||||
|
||||
### Bot-Befehle
|
||||
|
||||
| Befehl | Beschreibung |
|
||||
|--------|-------------|
|
||||
| `/shell <beschreibung>` | KI generiert Shell-Befehl aus natürlicher Sprache |
|
||||
| `/ja` | Letzten vorgeschlagenen Befehl ausführen |
|
||||
| `/code <aufgabe>` | Aider erstellt/bearbeitet Code und Scripts |
|
||||
| `/run <befehl>` | Direkte Shell – kein Ollama, kein Bestätigen |
|
||||
| `/status` | CPU, RAM, Disk, Top-Prozesse |
|
||||
| `/snapshots` | Alle Snapshots anzeigen |
|
||||
| `/snapshot [label]` | Manuell Snapshot erstellen |
|
||||
| `/snapshot keep <n>` | Snapshot als wichtig markieren |
|
||||
| `/snapshot delete <n>` | Snapshot löschen |
|
||||
| `/rollback <n>` | Zu einem Snapshot zurückkehren |
|
||||
| `/ssh open [ip]` | SSH via ufw öffnen (für alle oder eine IP) |
|
||||
| `/ssh close [ip]` | SSH wieder sperren |
|
||||
| `/start` / `/help` | Bot-Info und Befehlsübersicht |
|
||||
|
||||
### Freitext-Chat
|
||||
|
||||
Einfach schreiben – ohne Befehl. Der Bot antwortet als Chat-Partner:
|
||||
|
||||
```
|
||||
"Was ist ein Reverse Proxy?"
|
||||
"Übersetze das ins Englische: ..."
|
||||
"Erkläre mir den Unterschied zwischen apt und snap"
|
||||
"Welche Services laufen gerade auf meinem Server?"
|
||||
```
|
||||
|
||||
### Workflow: `/shell` + `/ja`
|
||||
|
||||
```
|
||||
Du: /shell zeig mir die größten Dateien auf dem Server
|
||||
Bot: 🔧 Vorgeschlagener Befehl:
|
||||
du -ah / | sort -rh | head -20
|
||||
→ Mit /ja ausführen oder einfach ignorieren.
|
||||
Du: /ja
|
||||
Bot: ✅ Ergebnis: ...
|
||||
```
|
||||
|
||||
### Workflow: `/code`
|
||||
|
||||
```
|
||||
Du: /code erstelle ein bash script das alte Logs älter als 30 Tage löscht
|
||||
Bot: 🛠️ Aider Ergebnis:
|
||||
✅ Aider Task abgeschlossen
|
||||
Applied edit to cleanup-logs.sh
|
||||
```
|
||||
|
||||
### Self-Update
|
||||
|
||||
ZIP-Datei direkt an den Bot schicken → Bot erstellt Snapshot → installiert Update → startet neu.
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Telegram Bot │
|
||||
│ (bot.py) │
|
||||
└──────┬──────┬────────┘
|
||||
│ │
|
||||
┌─────────────────┤ ├─────────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────────────┐ ┌────────────┐ ┌──────────────────┐
|
||||
│ ollama_client │ │ shell.py │ │ aider_wrapper │
|
||||
│ (Dual-Model) │ │ (run_shell)│ │ (Code-Agent) │
|
||||
└───────┬────┬────┘ └────────────┘ └──────────────────┘
|
||||
│ │
|
||||
┌────────┘ └────────┐
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────────┐
|
||||
│ Chat-Model│ │ Coder-Model │
|
||||
│ (gemma3) │ │(qwen2.5-coder)│
|
||||
└──────────┘ └──────────────┘
|
||||
|
||||
Weitere Module:
|
||||
snapshot.py – Backup-System (tar.gz, keep/unkeep, auto-cleanup)
|
||||
knock_notify.py – SSH Port-Knocking Alerts
|
||||
boot_notify.py – Server-Neustart Benachrichtigung
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sicherheit
|
||||
|
||||
### Port-Knocking
|
||||
|
||||
SSH ist standardmäßig durch ufw blockiert. Zugang via Klopf-Sequenz:
|
||||
|
||||
```bash
|
||||
# Oeffnen: 7000 -> 8000 -> 9000
|
||||
knock SERVER_IP 7000 8000 9000
|
||||
|
||||
# Schliessen: 9000 -> 8000 -> 7000
|
||||
knock SERVER_IP 9000 8000 7000
|
||||
```
|
||||
|
||||
Bei jedem Knock kommt eine Telegram-Benachrichtigung mit IP und Zeitstempel.
|
||||
|
||||
### KI-gestützte Befehlsgenerierung
|
||||
|
||||
```
|
||||
/shell installiere nginx
|
||||
→ KI generiert: apt install -y nginx
|
||||
→ Gefährlich? Nein → Vorschlag anzeigen
|
||||
→ /ja zum Ausführen, oder ignorieren
|
||||
|
||||
/shell lösche alles in /etc
|
||||
→ KI generiert: rm -rf /etc/*
|
||||
→ Gefährlich? JA → Verweigert mit Erklärung
|
||||
```
|
||||
|
||||
Kein Befehl wird ohne Bestätigung ausgeführt. Gefährliche Befehle werden immer verweigert.
|
||||
|
||||
### Snapshot-System
|
||||
|
||||
Vor destruktiven Aktionen automatischer Snapshot (tar.gz). Wichtige Snapshots können mit `/snapshot keep` markiert werden und werden nie automatisch gelöscht.
|
||||
|
||||
### Boot-Notification
|
||||
|
||||
Nach jedem Server-Neustart bekommst du eine Telegram-Nachricht mit Status aller Dienste (Clownfisch, Ollama, ufw, SSH, Cron) – nach 2 Minuten, wenn alles hochgefahren ist.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Fertig (v0.1 – v0.5.0)
|
||||
|
||||
- [x] Basis-Installer mit geführtem Setup (8 Schritte)
|
||||
- [x] Telegram Bot mit Auth Guard (Single-User)
|
||||
- [x] Dual-Model KI: Coder (qwen2.5-coder) + Chat (gemma3/gemma4)
|
||||
- [x] `/shell` + `/ja` – Befehlsgenerierung mit Bestätigung (non-interaktive Befehle)
|
||||
- [x] `/code` – Aider Code-Agent für autonome Script-Erstellung (Smart Prompt)
|
||||
- [x] `/run` – Direkte Shell ohne KI
|
||||
- [x] `/status` – Systemübersicht (CPU, RAM, Disk, Prozesse)
|
||||
- [x] `/ssh open/close` – ufw-basierter SSH-Zugang
|
||||
- [x] Freitext-Chat mit History (letzte 10 Exchanges)
|
||||
- [x] Snapshot-System (tar.gz, keep/unkeep, auto-cleanup max 20, Paketliste)
|
||||
- [x] `/snapshot`, `/snapshots`, `/rollback` (inkl. Workspace)
|
||||
- [x] Port-Knocking SSH (knockd) mit Telegram-Benachrichtigung
|
||||
- [x] Boot-Notification nach Server-Neustart (Dienste-Status)
|
||||
- [x] Self-Update via Telegram ZIP (inkl. systemd-Services + User-Patch)
|
||||
- [x] Update-Modus im Installer (mit MGMT_USER aus .env)
|
||||
- [x] Multi-Distro Installer (apt/dnf/pacman)
|
||||
- [x] Ollama Warmup im Hintergrund (Bot sofort bereit)
|
||||
- [x] **OpenRouter Free Fallback** – kostenlose Cloud-Modelle wenn Ollama nicht verfügbar
|
||||
- [x] Interaktiver Update-Dialog (OpenRouter Key Eingabe via Telegram)
|
||||
- [x] `OLLAMA_NUM_CTX` konfigurierbar (4K–32K, RAM-basierte Empfehlung im Installer)
|
||||
- [x] Safe Telegram Output (Markdown-Fallback bei Parse-Fehlern)
|
||||
- [x] Lange Antworten werden in Teile gesplittet (kein Abschneiden)
|
||||
- [x] Mehrsprachig (antwortet in der Sprache des Nutzers)
|
||||
- [x] MGMT_USER Regex-Patch (beliebiger Username funktioniert überall)
|
||||
- [x] Security-Analyse dokumentiert (Schwachstellen + Maßnahmen in CLOWNFISCHSERVER.md)
|
||||
- [x] DSGVO-Regel: `.env` nur per SSH, nie über Telegram lesbar
|
||||
- [x] Projekt-Kontext-Datei (CLOWNFISCHSERVER.md) für KI-gestütztes Pair-Programming
|
||||
|
||||
### v0.5.0 – Cleanup & Stabilität (aktuelles Release)
|
||||
|
||||
**Bugfixes & Aufräumarbeiten (dieser Release):**
|
||||
- [x] Totes Vault/MCP-Context-System komplett entfernt (war nie verdrahtet, `verify=False`-Lücke)
|
||||
- [x] Privacy-Fix: Chat-Nachrichten gingen via Vault-Suche an externe Endpoints → entfernt
|
||||
- [x] `/code` Filepath-Logik: fragiler Ternary-Ausdruck sauber umgeschrieben (kein Crash bei leerem Task)
|
||||
- [x] Aider-Model-Default vereinheitlicht (zieht sich `OLLAMA_MODEL` aus der Umgebung)
|
||||
- [x] Irreführende „Token-Optimization"-Logs (Anthropic-Kosten/Obsidian) aus dem Start-Log entfernt
|
||||
|
||||
**Sicherheit & DSGVO (geplant):**
|
||||
- [ ] Sensitive Dateien über Telegram blockieren (`.env`, `shadow`, Keys)
|
||||
- [ ] Output-Sanitizer: API Keys/Tokens in jeder Bot-Antwort maskieren
|
||||
- [ ] Log-Sanitizer: Keine Secrets im Audit-Log
|
||||
- [ ] Audit-Log: Jeder Befehl mit Timestamp (DSGVO Art. 5/32)
|
||||
- [ ] Self-Update Hardening: SHA256-Hash anzeigen, Admin bestätigt
|
||||
- [ ] `.env` Permissions-Check beim Bot-Start
|
||||
- [ ] `bot.log`, `.aider.chat.history.md`, `bash_history` aus Snapshots excluden
|
||||
- [ ] Rate-Limiting: Max 30 Befehle/Minute
|
||||
- [ ] Session-Timeout: Nach 30 Min Inaktivität → 2FA erneut nötig
|
||||
- [ ] 2FA bei kritischen Aktionen (TOTP/Mail/zweiter Bot)
|
||||
- [ ] Persönlicher Code-Offset (SECURITY_OFFSET in `.env`)
|
||||
|
||||
**Features:**
|
||||
- [ ] Snapshot-Scheduling (`/snapshot schedule daily 03:00`)
|
||||
- [ ] Snapshot-Export als ZIP via Telegram (`/snapshot export <n>`)
|
||||
- [ ] `/logs` – Systemlogs direkt im Bot
|
||||
- [ ] `/docker` – Docker Container Management
|
||||
- [ ] Port-Knocking Sequenz im Installer wählbar
|
||||
- [ ] Verschlüsselte Snapshots (SNAPSHOT_PASSWORD)
|
||||
- [ ] Open Interpreter als Aider-Alternative evaluieren
|
||||
|
||||
**Quick-Win Security:**
|
||||
- [ ] Panik-Wort: Admin-definiertes Wort → Bot löscht .env, sperrt alles
|
||||
- [ ] Honeypot-Befehle: Fake-Commands die Eindringlinge verraten
|
||||
- [ ] Selbstzerstörende Nachrichten: Bot löscht eigene Outputs nach X Minuten
|
||||
- [ ] Rollback-Bombe: 3x falscher 2FA → auto-Rollback + SSH sperren
|
||||
- [ ] Dead Man's Switch: Kein Lebenszeichen in 24h → Bot sperrt sich
|
||||
|
||||
### v0.6.0 – Multi-Server
|
||||
|
||||
- [ ] Ein Bot steuert mehrere Server
|
||||
- [ ] Zentrale Snapshot-Verwaltung
|
||||
- [ ] Serverpark-Übersicht und -Steuerung
|
||||
- [ ] Mehrere Nutzer mit unterschiedlichen Rechten
|
||||
|
||||
### v0.7.0 – DSGVO-bewusster Backup-Manager
|
||||
|
||||
- [ ] `/backup configs` → immer erlaubt
|
||||
- [ ] `/backup app` → eigene Anwendungsdaten
|
||||
- [ ] `/backup db` → Warnung + Bestätigung bei personenbezogenen Daten
|
||||
- [ ] Lokale KI bewertet ob Daten DSGVO-relevant sein könnten
|
||||
|
||||
### v0.8.0 – Erweiterte Cloud-API Integration
|
||||
|
||||
- [ ] Wahl zwischen Free- und Premium-Modellen auf OpenRouter
|
||||
- [ ] Direkte Anthropic/OpenAI API-Anbindung (neben OpenRouter)
|
||||
- [ ] Admin steuert pro Befehlstyp: lokal oder Cloud
|
||||
- [ ] Kosten-Tracking für Cloud-API-Nutzung
|
||||
|
||||
### v0.9.0 – Multi-Interface + Advanced Security
|
||||
|
||||
- [ ] Matrix/Element Connector (E2E-verschlüsselt, self-hosted)
|
||||
- [ ] Web-UI mit HTTPS (self-hosted, Let's Encrypt)
|
||||
- [ ] SSH-CLI als direktes Interface
|
||||
- [ ] Fake-Modus + Distress-Code (falscher 2FA → stiller Alarm)
|
||||
- [ ] Geo-Fencing (nur erlaubte Länder/IP-Bereiche)
|
||||
- [ ] Buddy-System (zwei Admins für kritische Befehle)
|
||||
- [ ] Verschlüsselter Rückkanal über Telegram
|
||||
|
||||
---
|
||||
|
||||
## Langzeit-Vision 🚀
|
||||
|
||||
### v1.0.0 – Intelligent Disaster Recovery
|
||||
|
||||
Server komplett aus Snapshot wiederherstellen – auch auf anderer Hardware oder in einer anderen Umgebung. Die KI analysiert den Snapshot und rekonstruiert die Umgebung.
|
||||
|
||||
*"Stelle Server 3 auf Stand von gestern 02:00 Uhr wieder her."*
|
||||
|
||||
Ziel: Open Source Alternative zu teuren Enterprise DR-Lösungen.
|
||||
|
||||
### v2.0.0 – Cross-Distribution Server Migration
|
||||
|
||||
Die eigentliche Revolution: **Ein Snapshot, jedes Zielsystem.**
|
||||
|
||||
Der Clownfischserver versteht nicht nur Dateien, sondern die *Logik* eines Servers:
|
||||
- Welche Pakete sind installiert und wofür?
|
||||
- Welche Services laufen und wie sind sie konfiguriert?
|
||||
- Welche Abhängigkeiten bestehen zwischen Komponenten?
|
||||
|
||||
Diese Logik ist distro-unabhängig. Ein Debian-Server mit nginx + PostgreSQL + Docker hat die gleiche *Absicht* wie ein Arch-Server mit denselben Diensten – nur andere Paketnamen, Pfade und Init-Systeme.
|
||||
|
||||
**Ziel-Plattformen:**
|
||||
- Debian / Ubuntu (heute)
|
||||
- Arch Linux
|
||||
- Red Hat / Fedora / CentOS
|
||||
- openSUSE
|
||||
- FreeBSD
|
||||
- Weitere UNIX-Systeme
|
||||
|
||||
**Wie es funktioniert:**
|
||||
1. KI analysiert Snapshot und erstellt ein distro-neutrales "Server-Profil"
|
||||
2. Profil beschreibt: Dienste, Konfigurationen, Daten, Netzwerk, Nutzer
|
||||
3. Auf dem Zielsystem: KI übersetzt das Profil in native Befehle
|
||||
4. `apt install nginx` → `pacman -S nginx` → `pkg install nginx`
|
||||
5. Konfigurationen werden angepasst (Pfade, Syntax, Permissions)
|
||||
|
||||
*"Migriere meinen Debian-Webserver auf Arch Linux."*
|
||||
|
||||
### Weltklasse Integration 🌍
|
||||
|
||||
Clownfischserver als Infrastruktur-Layer für die Weltklasse App – natürliche Sprachsteuerung des gesamten Server-Ökosystems. GDPR-konform, vollständig lokal, kein Cloud-Zwang.
|
||||
|
||||
---
|
||||
|
||||
> *"Angefangen als einfacher Telegram-Bot – gewachsen zu einer Vision."* 🐠
|
||||
|
||||
---
|
||||
|
||||
## Prinzipien 🛡️
|
||||
|
||||
```
|
||||
1. Local AI first – Lokal ist Standard. Cloud ist Opt-in.
|
||||
2. Open Source always – GPL-3.0, keine Ausnahmen
|
||||
3. DSGVO by design – Datenschutz ist kein Feature, es ist Pflicht
|
||||
4. Security first – SSH zu, KI prüft, Snapshots sichern
|
||||
5. Einfachheit – ein Mensch, ein Bot, ein Server
|
||||
6. Distro-agnostisch – Linux ist Linux. Die KI kennt den Unterschied.
|
||||
```
|
||||
|
||||
### Zur Cloud-Frage
|
||||
|
||||
Clownfischserver ist und bleibt local-first. Aber wir sind keine Puristen: wenn ein Admin einen OpenRouter-API-Key hat und ihn für komplexe Aufgaben nutzen will, soll er das können. Die Entscheidung liegt beim Admin, nicht beim Tool.
|
||||
|
||||
Was wir **nicht** tun:
|
||||
- Cloud als Voraussetzung
|
||||
- Daten automatisch an externe APIs senden
|
||||
- Features hinter Cloud-APIs verstecken
|
||||
|
||||
Was wir **ermöglichen** (seit v0.4.12/v0.4.13):
|
||||
- Optionaler OpenRouter API-Key in `.env` (kostenlos, keine Kreditkarte)
|
||||
- Automatischer Fallback: Ollama Timeout → OpenRouter übernimmt
|
||||
- Wenn Cloud ausfällt, funktioniert alles weiterhin lokal
|
||||
- Setup direkt im Bot: ZIP schicken → Bot fragt nach Key → fertig
|
||||
|
||||
> *Als Admin entscheidest du – nicht das Tool.*
|
||||
|
||||
---
|
||||
|
||||
## Entwicklung
|
||||
|
||||
**Entwickler:** [Mehlzoerwer-Claude](https://github.com/mehlzoerwer-claude)
|
||||
|
||||
**KI-Assistent:** Dieses Projekt wurde mit [Claude](https://claude.ai) (Anthropic) als Pair-Programming Partner entwickelt. Claude ist der einzige nicht-Open-Source Teil dieser Zusammenarbeit. 😄
|
||||
|
||||
**Philosophie:** Open Source ohne Wenn und Aber. Wer's nutzt, muss es offen lassen (GPL-3.0).
|
||||
|
||||
---
|
||||
|
||||
## Lizenz
|
||||
|
||||
GPL-3.0 - Keep it open. Always.
|
||||
|
||||
Copyright (C) 2025-2026 Mehlzoerwer-Claude
|
||||
|
||||
---
|
||||
|
||||
*Danke an alle die Ollama, Aider, python-telegram-bot, knockd und ufw gebaut haben.*
|
||||
*Und an den echten Clownfisch – für die Inspiration.* 🐠
|
||||
|
||||
+128
-128
@@ -1,128 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🐠 Clownfischserver
|
||||
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
|
||||
License: GPL-3.0 – Keep it open. Always.
|
||||
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INSTALL_DIR = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
|
||||
AIDER_WORKDIR = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace")
|
||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:2b")
|
||||
TIMEOUT = 600 # 10 minutes for aider tasks on CPU
|
||||
|
||||
|
||||
class AiderWrapper:
|
||||
def __init__(self):
|
||||
self.workdir = Path(AIDER_WORKDIR)
|
||||
self.workdir.mkdir(parents=True, exist_ok=True)
|
||||
self.venv_python = Path(INSTALL_DIR) / "aider-venv" / "bin" / "python3"
|
||||
self.venv_aider = Path(INSTALL_DIR) / "aider-venv" / "bin" / "aider"
|
||||
logger.info(f"AiderWrapper init: workdir={self.workdir}")
|
||||
|
||||
async def run(self, task: str, target_file: str = None) -> str:
|
||||
"""
|
||||
Run aider in script mode for a coding task.
|
||||
Returns the output/result as string.
|
||||
"""
|
||||
logger.info(f"Aider task: {task[:100]}")
|
||||
|
||||
# Determine working directory and files
|
||||
workdir = self.workdir
|
||||
|
||||
# Build aider command
|
||||
cmd = self._build_aider_command(task, target_file)
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(workdir),
|
||||
env={
|
||||
**os.environ,
|
||||
"OLLAMA_API_BASE": OLLAMA_BASE_URL,
|
||||
"HOME": f"/home/{os.getenv('MGMT_USER', 'clownfish')}",
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=TIMEOUT
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
return f"⏱️ Aider Timeout nach {TIMEOUT}s. Task zu komplex oder Modell überlastet."
|
||||
|
||||
stdout_str = stdout.decode("utf-8", errors="replace").strip()
|
||||
stderr_str = stderr.decode("utf-8", errors="replace").strip()
|
||||
|
||||
# Build response
|
||||
result_parts = []
|
||||
|
||||
if stdout_str:
|
||||
result_parts.append(stdout_str)
|
||||
|
||||
if stderr_str and process.returncode != 0:
|
||||
result_parts.append(f"[STDERR]\n{stderr_str}")
|
||||
|
||||
if process.returncode == 0:
|
||||
result_parts.insert(0, f"✅ Aider Task abgeschlossen\nArbeitsverzeichnis: {workdir}\n")
|
||||
else:
|
||||
result_parts.insert(0, f"⚠️ Aider beendet mit Code {process.returncode}\n")
|
||||
|
||||
return "\n".join(result_parts) or "✓ Task abgeschlossen (keine Ausgabe)"
|
||||
|
||||
except FileNotFoundError:
|
||||
return (
|
||||
"❌ Aider nicht gefunden. Bitte prüfen:\n"
|
||||
f" ls {self.venv_aider}\n"
|
||||
" Ggf. neu installieren mit: pip install aider-chat"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Aider execution error: {e}", exc_info=True)
|
||||
return f"❌ Aider Fehler: {e}"
|
||||
|
||||
def _build_aider_command(self, task: str, target_file: str = None) -> list:
|
||||
"""Build the aider command with appropriate flags"""
|
||||
cmd = [
|
||||
str(self.venv_aider),
|
||||
"--model", f"ollama/{OLLAMA_MODEL}",
|
||||
"--no-git", # Don't require git
|
||||
"--yes", # Auto-confirm all changes
|
||||
"--no-pretty", # Plain output for telegram
|
||||
"--no-show-model-warnings", # Skip env var warnings
|
||||
"--no-analytics", # No analytics prompts
|
||||
"--edit-format", "diff", # Better format for small models
|
||||
"--message", task, # The task as a message
|
||||
]
|
||||
|
||||
if target_file:
|
||||
cmd.append(target_file)
|
||||
|
||||
return cmd
|
||||
|
||||
async def list_workspace_files(self) -> str:
|
||||
"""List files in the aider workspace"""
|
||||
try:
|
||||
files = list(self.workdir.rglob("*"))
|
||||
if not files:
|
||||
return f"📁 Workspace {self.workdir} ist leer."
|
||||
|
||||
file_list = "\n".join([
|
||||
f" {'📁' if f.is_dir() else '📄'} {f.relative_to(self.workdir)}"
|
||||
for f in sorted(files)[:50]
|
||||
])
|
||||
return f"📁 Workspace: {self.workdir}\n\n{file_list}"
|
||||
except Exception as e:
|
||||
return f"❌ Fehler beim Lesen des Workspace: {e}"
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🐠 Clownfischserver
|
||||
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
|
||||
License: GPL-3.0 – Keep it open. Always.
|
||||
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INSTALL_DIR = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
|
||||
AIDER_WORKDIR = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace")
|
||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b")
|
||||
TIMEOUT = 600 # 10 minutes for aider tasks on CPU
|
||||
|
||||
|
||||
class AiderWrapper:
|
||||
def __init__(self):
|
||||
self.workdir = Path(AIDER_WORKDIR)
|
||||
self.workdir.mkdir(parents=True, exist_ok=True)
|
||||
self.venv_python = Path(INSTALL_DIR) / "aider-venv" / "bin" / "python3"
|
||||
self.venv_aider = Path(INSTALL_DIR) / "aider-venv" / "bin" / "aider"
|
||||
logger.info(f"AiderWrapper init: workdir={self.workdir}")
|
||||
|
||||
async def run(self, task: str, target_file: str = None) -> str:
|
||||
"""
|
||||
Run aider in script mode for a coding task.
|
||||
Returns the output/result as string.
|
||||
"""
|
||||
logger.info(f"Aider task: {task[:100]}")
|
||||
|
||||
# Determine working directory and files
|
||||
workdir = self.workdir
|
||||
|
||||
# Build aider command
|
||||
cmd = self._build_aider_command(task, target_file)
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(workdir),
|
||||
env={
|
||||
**os.environ,
|
||||
"OLLAMA_API_BASE": OLLAMA_BASE_URL,
|
||||
"HOME": f"/home/{os.getenv('MGMT_USER', 'clownfish')}",
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=TIMEOUT
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
return f"⏱️ Aider Timeout nach {TIMEOUT}s. Task zu komplex oder Modell überlastet."
|
||||
|
||||
stdout_str = stdout.decode("utf-8", errors="replace").strip()
|
||||
stderr_str = stderr.decode("utf-8", errors="replace").strip()
|
||||
|
||||
# Build response
|
||||
result_parts = []
|
||||
|
||||
if stdout_str:
|
||||
result_parts.append(stdout_str)
|
||||
|
||||
if stderr_str and process.returncode != 0:
|
||||
result_parts.append(f"[STDERR]\n{stderr_str}")
|
||||
|
||||
if process.returncode == 0:
|
||||
result_parts.insert(0, f"✅ Aider Task abgeschlossen\nArbeitsverzeichnis: {workdir}\n")
|
||||
else:
|
||||
result_parts.insert(0, f"⚠️ Aider beendet mit Code {process.returncode}\n")
|
||||
|
||||
return "\n".join(result_parts) or "✓ Task abgeschlossen (keine Ausgabe)"
|
||||
|
||||
except FileNotFoundError:
|
||||
return (
|
||||
"❌ Aider nicht gefunden. Bitte prüfen:\n"
|
||||
f" ls {self.venv_aider}\n"
|
||||
" Ggf. neu installieren mit: pip install aider-chat"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Aider execution error: {e}", exc_info=True)
|
||||
return f"❌ Aider Fehler: {e}"
|
||||
|
||||
def _build_aider_command(self, task: str, target_file: str = None) -> list:
|
||||
"""Build the aider command with appropriate flags"""
|
||||
cmd = [
|
||||
str(self.venv_aider),
|
||||
"--model", f"ollama/{OLLAMA_MODEL}",
|
||||
"--no-git", # Don't require git
|
||||
"--yes", # Auto-confirm all changes
|
||||
"--no-pretty", # Plain output for telegram
|
||||
"--no-show-model-warnings", # Skip env var warnings
|
||||
"--no-analytics", # No analytics prompts
|
||||
"--edit-format", "diff", # Better format for small models
|
||||
"--message", task, # The task as a message
|
||||
]
|
||||
|
||||
if target_file:
|
||||
cmd.append(target_file)
|
||||
|
||||
return cmd
|
||||
|
||||
async def list_workspace_files(self) -> str:
|
||||
"""List files in the aider workspace"""
|
||||
try:
|
||||
files = list(self.workdir.rglob("*"))
|
||||
if not files:
|
||||
return f"📁 Workspace {self.workdir} ist leer."
|
||||
|
||||
file_list = "\n".join([
|
||||
f" {'📁' if f.is_dir() else '📄'} {f.relative_to(self.workdir)}"
|
||||
for f in sorted(files)[:50]
|
||||
])
|
||||
return f"📁 Workspace: {self.workdir}\n\n{file_list}"
|
||||
except Exception as e:
|
||||
return f"❌ Fehler beim Lesen des Workspace: {e}"
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/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
@@ -0,0 +1,115 @@
|
||||
#!/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
|
||||
+1081
-755
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,322 @@
|
||||
#!/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()
|
||||
+238
-234
@@ -1,234 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🐠 Clownfischserver
|
||||
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
|
||||
License: GPL-3.0 – Keep it open. Always.
|
||||
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import requests
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b")
|
||||
OLLAMA_MODEL_FAST = os.getenv("OLLAMA_MODEL_FAST", "")
|
||||
OLLAMA_NUM_CTX = int(os.getenv("OLLAMA_NUM_CTX", "4096"))
|
||||
|
||||
SHELL_PROMPT_TEMPLATE = """You are a Linux bash command generator for an Ubuntu 24.04 server.
|
||||
Convert the user request into a single non-interactive bash command.
|
||||
Respond with ONLY a JSON object.
|
||||
|
||||
CRITICAL RULES:
|
||||
- Commands run WITHOUT a terminal. No interactive commands like nano, vim, crontab -e, less, top.
|
||||
- Use non-interactive alternatives: echo/tee for files, /etc/cron.d/ for cron jobs, sed for edits.
|
||||
- Use sudo when root permissions are needed.
|
||||
- Chain commands with && if needed.
|
||||
|
||||
Example request: "show free ram"
|
||||
Example response: {{"dangerous":false,"reason":"","command":"free -h"}}
|
||||
|
||||
Example request: "installiere nginx"
|
||||
Example response: {{"dangerous":false,"reason":"","command":"sudo apt install -y nginx"}}
|
||||
|
||||
Example request: "erstelle einen cronjob der alle 5 minuten ein script ausfuehrt"
|
||||
Example response: {{"dangerous":false,"reason":"","command":"echo '*/5 * * * * root /path/to/script.sh' | sudo tee /etc/cron.d/myjob"}}
|
||||
|
||||
Example request: "schreibe hello world in eine datei"
|
||||
Example response: {{"dangerous":false,"reason":"","command":"echo 'hello world' | sudo tee /tmp/hello.txt"}}
|
||||
|
||||
Example request: "starte nginx neu"
|
||||
Example response: {{"dangerous":false,"reason":"","command":"sudo systemctl restart nginx"}}
|
||||
|
||||
Example request: "lösche alles in /etc"
|
||||
Example response: {{"dangerous":true,"reason":"Würde Systemkonfiguration zerstören","command":"rm -rf /etc/*"}}
|
||||
|
||||
Now generate JSON for this request: "{description}"
|
||||
|
||||
IMPORTANT: Respond with ONLY the JSON object. No other text. No markdown."""
|
||||
|
||||
CHAT_SYSTEM_PROMPT = """Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.
|
||||
Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt.
|
||||
Wenn du ein Befehlsergebnis erklärst, sei präzise und verständlich."""
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> dict:
|
||||
"""Extract JSON from Ollama response, handling markdown wrappers and stray text."""
|
||||
text = raw.strip()
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
text = text.strip()
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
raise json.JSONDecodeError("No valid JSON found", text, 0)
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
def __init__(self, openrouter_client=None):
|
||||
self.base_url = OLLAMA_BASE_URL
|
||||
self.model = OLLAMA_MODEL
|
||||
self.model_fast = OLLAMA_MODEL_FAST or OLLAMA_MODEL
|
||||
self.openrouter = openrouter_client
|
||||
|
||||
def _chat_sync(self, messages: list, system: Optional[str] = None, think: Optional[bool] = None, use_fast: bool = False) -> str:
|
||||
"""Synchronous Ollama API call"""
|
||||
model = self.model_fast if use_fast else self.model
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"keep_alive": -1,
|
||||
"options": {"num_ctx": OLLAMA_NUM_CTX},
|
||||
}
|
||||
if system:
|
||||
payload["system"] = system
|
||||
if think is not None:
|
||||
payload["think"] = think
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json=payload,
|
||||
timeout=180,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("message", {}).get("content", "")
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise RuntimeError("Ollama nicht erreichbar – läuft der Service? (systemctl status ollama)")
|
||||
except requests.exceptions.Timeout:
|
||||
raise RuntimeError("Ollama Timeout – Modell lädt möglicherweise noch.")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Ollama Fehler: {e}")
|
||||
|
||||
async def generate_shell_command(self, description: str) -> dict:
|
||||
"""Generate a shell command from natural language description.
|
||||
Tries Ollama first, falls back to OpenRouter on timeout."""
|
||||
loop = asyncio.get_running_loop()
|
||||
raw = ""
|
||||
|
||||
# 1. Try local Ollama
|
||||
try:
|
||||
prompt = SHELL_PROMPT_TEMPLATE.format(description=description)
|
||||
raw = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._chat_sync(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
think=False,
|
||||
use_fast=False,
|
||||
),
|
||||
)
|
||||
result = _extract_json(raw)
|
||||
|
||||
if "command" not in result or not result["command"].strip():
|
||||
logger.warning(f"Shell generate: no command in result: {result}")
|
||||
return {"dangerous": False, "reason": "", "command": ""}
|
||||
if "dangerous" not in result:
|
||||
result["dangerous"] = False
|
||||
|
||||
logger.info(f"Shell generate (Ollama): {result}")
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Shell generate JSON error: {e}, raw: {raw[:500]}")
|
||||
return {"dangerous": False, "reason": "", "command": ""}
|
||||
except RuntimeError as e:
|
||||
if "Timeout" not in str(e) and "nicht erreichbar" not in str(e):
|
||||
raise
|
||||
logger.warning(f"Ollama nicht verfügbar für /shell – trying OpenRouter fallback")
|
||||
|
||||
# 2. Fallback to OpenRouter
|
||||
if self.openrouter and self.openrouter.is_available():
|
||||
try:
|
||||
logger.info("Using OpenRouter fallback for /shell...")
|
||||
result = await self.openrouter.generate_shell_command(description)
|
||||
logger.info(f"Shell generate (OpenRouter): {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"OpenRouter fallback failed: {e}")
|
||||
|
||||
return {
|
||||
"dangerous": False, "reason": "",
|
||||
"command": "",
|
||||
"_error": "Ollama Timeout und kein OpenRouter Fallback verfügbar.",
|
||||
}
|
||||
|
||||
async def explain_output(self, command: str, output: str, error: str = "") -> str:
|
||||
"""Ask Ollama to explain/summarize command output"""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
content = f"Command: {command}\nOutput:\n{output}"
|
||||
if error:
|
||||
content += f"\nErrors:\n{error}"
|
||||
content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer."
|
||||
|
||||
try:
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._chat_sync(
|
||||
messages=[{"role": "user", "content": content}],
|
||||
system=CHAT_SYSTEM_PROMPT,
|
||||
think=False,
|
||||
use_fast=True,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"explain_output failed: {e}")
|
||||
# Try OpenRouter fallback
|
||||
if self.openrouter and self.openrouter.is_available():
|
||||
try:
|
||||
return await self.openrouter.explain_output(command, output, error)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
async def chat(self, message: str, history: list = None) -> str:
|
||||
"""General chat with context. Tries Ollama, falls back to OpenRouter."""
|
||||
loop = asyncio.get_running_loop()
|
||||
messages = list(history) if history else []
|
||||
messages.append({"role": "user", "content": message})
|
||||
|
||||
# 1. Try local Ollama
|
||||
try:
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._chat_sync(
|
||||
messages=messages,
|
||||
system=CHAT_SYSTEM_PROMPT,
|
||||
use_fast=True,
|
||||
),
|
||||
)
|
||||
except RuntimeError as e:
|
||||
if "Timeout" not in str(e) and "nicht erreichbar" not in str(e):
|
||||
return f"Fehler: {e}"
|
||||
logger.warning("Ollama chat nicht verfügbar – trying OpenRouter")
|
||||
|
||||
# 2. Fallback to OpenRouter
|
||||
if self.openrouter and self.openrouter.is_available():
|
||||
try:
|
||||
logger.info("Using OpenRouter for chat...")
|
||||
return await self.openrouter.chat(message, history)
|
||||
except Exception as e:
|
||||
return f"Ollama und OpenRouter nicht verfügbar: {e}"
|
||||
|
||||
return (
|
||||
"Ollama nicht verfügbar und kein OpenRouter Fallback konfiguriert.\n"
|
||||
"Versuche es später oder konfiguriere OpenRouter in .env"
|
||||
)
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🐠 Clownfischserver
|
||||
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
|
||||
License: GPL-3.0 – Keep it open. Always.
|
||||
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import requests
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b")
|
||||
OLLAMA_MODEL_FAST = os.getenv("OLLAMA_MODEL_FAST", "")
|
||||
OLLAMA_NUM_CTX = int(os.getenv("OLLAMA_NUM_CTX", "4096"))
|
||||
|
||||
SHELL_PROMPT_TEMPLATE = """You are a Linux bash command generator for an Ubuntu 24.04 server.
|
||||
Convert the user request into a single non-interactive bash command.
|
||||
Respond with ONLY a JSON object.
|
||||
|
||||
CRITICAL RULES:
|
||||
- Commands run WITHOUT a terminal. No interactive commands like nano, vim, crontab -e, less, top.
|
||||
- Use non-interactive alternatives: echo/tee for files, /etc/cron.d/ for cron jobs, sed for edits.
|
||||
- Use sudo when root permissions are needed.
|
||||
- Chain commands with && if needed.
|
||||
|
||||
Example request: "show free ram"
|
||||
Example response: {{"dangerous":false,"reason":"","command":"free -h"}}
|
||||
|
||||
Example request: "installiere nginx"
|
||||
Example response: {{"dangerous":false,"reason":"","command":"sudo apt install -y nginx"}}
|
||||
|
||||
Example request: "erstelle einen cronjob der alle 5 minuten ein script ausfuehrt"
|
||||
Example response: {{"dangerous":false,"reason":"","command":"echo '*/5 * * * * root /path/to/script.sh' | sudo tee /etc/cron.d/myjob"}}
|
||||
|
||||
Example request: "schreibe hello world in eine datei"
|
||||
Example response: {{"dangerous":false,"reason":"","command":"echo 'hello world' | sudo tee /tmp/hello.txt"}}
|
||||
|
||||
Example request: "starte nginx neu"
|
||||
Example response: {{"dangerous":false,"reason":"","command":"sudo systemctl restart nginx"}}
|
||||
|
||||
Example request: "lösche alles in /etc"
|
||||
Example response: {{"dangerous":true,"reason":"Würde Systemkonfiguration zerstören","command":"rm -rf /etc/*"}}
|
||||
|
||||
Now generate JSON for this request: "{description}"
|
||||
|
||||
IMPORTANT: Respond with ONLY the JSON object. No other text. No markdown."""
|
||||
|
||||
CHAT_SYSTEM_PROMPT = """Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.
|
||||
Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt.
|
||||
Wenn du ein Befehlsergebnis erklärst, sei präzise und verständlich."""
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> dict:
|
||||
"""Extract JSON from Ollama response, handling markdown wrappers and stray text."""
|
||||
text = raw.strip()
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
text = text.strip()
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
raise json.JSONDecodeError("No valid JSON found", text, 0)
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
def __init__(self, openrouter_client=None, model: Optional[str] = None,
|
||||
model_fast: Optional[str] = None,
|
||||
num_ctx: Optional[int] = None,
|
||||
base_url: Optional[str] = None):
|
||||
self.base_url = base_url or OLLAMA_BASE_URL
|
||||
self.model = model or OLLAMA_MODEL
|
||||
self.model_fast = model_fast or OLLAMA_MODEL_FAST or self.model
|
||||
self.num_ctx = num_ctx if num_ctx is not None else OLLAMA_NUM_CTX
|
||||
self.openrouter = openrouter_client
|
||||
|
||||
def _chat_sync(self, messages: list, system: Optional[str] = None, think: Optional[bool] = None, use_fast: bool = False) -> str:
|
||||
"""Synchronous Ollama API call"""
|
||||
model = self.model_fast if use_fast else self.model
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"keep_alive": -1,
|
||||
"options": {"num_ctx": self.num_ctx},
|
||||
}
|
||||
if system:
|
||||
payload["system"] = system
|
||||
if think is not None:
|
||||
payload["think"] = think
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json=payload,
|
||||
timeout=180,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("message", {}).get("content", "")
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise RuntimeError("Ollama nicht erreichbar – läuft der Service? (systemctl status ollama)")
|
||||
except requests.exceptions.Timeout:
|
||||
raise RuntimeError("Ollama Timeout – Modell lädt möglicherweise noch.")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Ollama Fehler: {e}")
|
||||
|
||||
async def generate_shell_command(self, description: str) -> dict:
|
||||
"""Generate a shell command from natural language description.
|
||||
Tries Ollama first, falls back to OpenRouter on timeout."""
|
||||
loop = asyncio.get_running_loop()
|
||||
raw = ""
|
||||
|
||||
# 1. Try local Ollama
|
||||
try:
|
||||
prompt = SHELL_PROMPT_TEMPLATE.format(description=description)
|
||||
raw = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._chat_sync(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
think=False,
|
||||
use_fast=False,
|
||||
),
|
||||
)
|
||||
result = _extract_json(raw)
|
||||
|
||||
if "command" not in result or not result["command"].strip():
|
||||
logger.warning(f"Shell generate: no command in result: {result}")
|
||||
return {"dangerous": False, "reason": "", "command": ""}
|
||||
if "dangerous" not in result:
|
||||
result["dangerous"] = False
|
||||
|
||||
logger.info(f"Shell generate (Ollama): {result}")
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Shell generate JSON error: {e}, raw: {raw[:500]}")
|
||||
return {"dangerous": False, "reason": "", "command": ""}
|
||||
except RuntimeError as e:
|
||||
if "Timeout" not in str(e) and "nicht erreichbar" not in str(e):
|
||||
raise
|
||||
logger.warning("Ollama nicht verfügbar für /shell – trying OpenRouter fallback")
|
||||
|
||||
# 2. Fallback to OpenRouter
|
||||
if self.openrouter and self.openrouter.is_available():
|
||||
try:
|
||||
logger.info("Using OpenRouter fallback for /shell...")
|
||||
result = await self.openrouter.generate_shell_command(description)
|
||||
logger.info(f"Shell generate (OpenRouter): {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"OpenRouter fallback failed: {e}")
|
||||
|
||||
return {
|
||||
"dangerous": False, "reason": "",
|
||||
"command": "",
|
||||
"_error": "Ollama Timeout und kein OpenRouter Fallback verfügbar.",
|
||||
}
|
||||
|
||||
async def explain_output(self, command: str, output: str, error: str = "") -> str:
|
||||
"""Ask Ollama to explain/summarize command output"""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
content = f"Command: {command}\nOutput:\n{output}"
|
||||
if error:
|
||||
content += f"\nErrors:\n{error}"
|
||||
content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer."
|
||||
|
||||
try:
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._chat_sync(
|
||||
messages=[{"role": "user", "content": content}],
|
||||
system=CHAT_SYSTEM_PROMPT,
|
||||
think=False,
|
||||
use_fast=True,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"explain_output failed: {e}")
|
||||
# Try OpenRouter fallback
|
||||
if self.openrouter and self.openrouter.is_available():
|
||||
try:
|
||||
return await self.openrouter.explain_output(command, output, error)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
async def chat(self, message: str, history: list = None) -> str:
|
||||
"""General chat with context. Tries Ollama, falls back to OpenRouter."""
|
||||
loop = asyncio.get_running_loop()
|
||||
messages = list(history) if history else []
|
||||
messages.append({"role": "user", "content": message})
|
||||
|
||||
# 1. Try local Ollama
|
||||
try:
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._chat_sync(
|
||||
messages=messages,
|
||||
system=CHAT_SYSTEM_PROMPT,
|
||||
use_fast=True,
|
||||
),
|
||||
)
|
||||
except RuntimeError as e:
|
||||
if "Timeout" not in str(e) and "nicht erreichbar" not in str(e):
|
||||
return f"Fehler: {e}"
|
||||
logger.warning("Ollama chat nicht verfügbar – trying OpenRouter")
|
||||
|
||||
# 2. Fallback to OpenRouter
|
||||
if self.openrouter and self.openrouter.is_available():
|
||||
try:
|
||||
logger.info("Using OpenRouter for chat...")
|
||||
return await self.openrouter.chat(message, history)
|
||||
except Exception as e:
|
||||
return f"Ollama und OpenRouter nicht verfügbar: {e}"
|
||||
|
||||
return (
|
||||
"Ollama nicht verfügbar und kein OpenRouter Fallback konfiguriert.\n"
|
||||
"Versuche es später oder konfiguriere OpenRouter in .env"
|
||||
)
|
||||
|
||||
+183
-178
@@ -1,178 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🐠 Clownfischserver – OpenRouter Free Models Fallback
|
||||
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
|
||||
License: GPL-3.0 – Keep it open. Always.
|
||||
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import requests
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
|
||||
# Free models on OpenRouter (no credit card needed)
|
||||
# openrouter/free auto-selects the best available free model
|
||||
FREE_MODELS = {
|
||||
"coder": "openrouter/free",
|
||||
"chat": "openrouter/free",
|
||||
}
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> dict:
|
||||
"""Extract JSON from response."""
|
||||
text = raw.strip()
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
text = text.strip()
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
raise json.JSONDecodeError("No valid JSON found", text, 0)
|
||||
|
||||
|
||||
class OpenRouterClient:
|
||||
"""OpenRouter Free Models Client – fallback when Ollama times out."""
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = OPENROUTER_API_KEY
|
||||
self.base_url = OPENROUTER_BASE_URL
|
||||
if self.api_key:
|
||||
logger.info("OpenRouter Fallback konfiguriert")
|
||||
else:
|
||||
logger.info("OpenRouter nicht konfiguriert – Fallback deaktiviert")
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
def _chat_sync(self, messages: list, system: Optional[str] = None, model: str = None) -> str:
|
||||
"""Synchronous OpenRouter API call."""
|
||||
if not self.api_key:
|
||||
raise RuntimeError("OpenRouter API Key nicht konfiguriert")
|
||||
|
||||
model = model or FREE_MODELS["chat"]
|
||||
|
||||
api_messages = []
|
||||
if system:
|
||||
api_messages.append({"role": "system", "content": system})
|
||||
api_messages.extend(messages)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": api_messages,
|
||||
"temperature": 0.7,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise RuntimeError("OpenRouter nicht erreichbar")
|
||||
except requests.exceptions.Timeout:
|
||||
raise RuntimeError("OpenRouter Timeout")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"OpenRouter Fehler: {e}")
|
||||
|
||||
async def generate_shell_command(self, description: str) -> dict:
|
||||
"""Generate shell command via OpenRouter."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
prompt = (
|
||||
"You are a Linux bash command generator for Ubuntu 24.04.\n"
|
||||
"Convert the request into a single non-interactive bash command.\n"
|
||||
"Respond ONLY with JSON: {\"dangerous\": bool, \"reason\": string, \"command\": string}\n"
|
||||
"No interactive commands (nano, vim, crontab -e). Use sudo when needed.\n\n"
|
||||
f"Request: {description}\n\nJSON only:"
|
||||
)
|
||||
|
||||
try:
|
||||
raw = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._chat_sync(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
model=FREE_MODELS["coder"],
|
||||
),
|
||||
)
|
||||
result = _extract_json(raw)
|
||||
if "command" not in result:
|
||||
result["command"] = ""
|
||||
if "dangerous" not in result:
|
||||
result["dangerous"] = False
|
||||
logger.info(f"OpenRouter shell: {result}")
|
||||
return result
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"OpenRouter JSON error: {e}")
|
||||
return {"dangerous": False, "reason": "", "command": ""}
|
||||
except Exception as e:
|
||||
logger.error(f"OpenRouter shell failed: {e}")
|
||||
raise
|
||||
|
||||
async def chat(self, message: str, history: list = None) -> str:
|
||||
"""General chat via OpenRouter."""
|
||||
loop = asyncio.get_running_loop()
|
||||
messages = list(history) if history else []
|
||||
messages.append({"role": "user", "content": message})
|
||||
|
||||
system = (
|
||||
"Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.\n"
|
||||
"Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt."
|
||||
)
|
||||
|
||||
try:
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._chat_sync(
|
||||
messages=messages,
|
||||
system=system,
|
||||
model=FREE_MODELS["chat"],
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
return f"OpenRouter Fehler: {e}"
|
||||
|
||||
async def explain_output(self, command: str, output: str, error: str = "") -> str:
|
||||
"""Explain command output via OpenRouter."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
content = f"Command: {command}\nOutput:\n{output}"
|
||||
if error:
|
||||
content += f"\nErrors:\n{error}"
|
||||
content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer."
|
||||
|
||||
try:
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._chat_sync(
|
||||
messages=[{"role": "user", "content": content}],
|
||||
model=FREE_MODELS["chat"],
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"OpenRouter explain failed: {e}")
|
||||
return ""
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🐠 Clownfischserver – OpenRouter Free Models Fallback
|
||||
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
|
||||
License: GPL-3.0 – Keep it open. Always.
|
||||
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import requests
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
|
||||
# Free models on OpenRouter (no credit card needed)
|
||||
# openrouter/free auto-selects the best available free model
|
||||
FREE_MODELS = {
|
||||
"coder": "openrouter/free",
|
||||
"chat": "openrouter/free",
|
||||
}
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> dict:
|
||||
"""Extract JSON from response."""
|
||||
text = raw.strip()
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
text = text.strip()
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
raise json.JSONDecodeError("No valid JSON found", text, 0)
|
||||
|
||||
|
||||
class OpenRouterClient:
|
||||
"""OpenRouter Free Models Client – fallback when Ollama times out."""
|
||||
|
||||
def __init__(self):
|
||||
# Read at construction time, not import time, so env changes between
|
||||
# boot and config reloads (or test monkeypatching) take effect.
|
||||
self.api_key = os.getenv("OPENROUTER_API_KEY", "") or OPENROUTER_API_KEY
|
||||
self.base_url = OPENROUTER_BASE_URL
|
||||
if self.api_key:
|
||||
logger.info("OpenRouter Fallback konfiguriert")
|
||||
else:
|
||||
logger.info("OpenRouter nicht konfiguriert – Fallback deaktiviert")
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
def _chat_sync(self, messages: list, system: Optional[str] = None, model: str = None) -> str:
|
||||
"""Synchronous OpenRouter API call."""
|
||||
if not self.api_key:
|
||||
raise RuntimeError("OpenRouter API Key nicht konfiguriert")
|
||||
|
||||
model = model or FREE_MODELS["chat"]
|
||||
|
||||
api_messages = []
|
||||
if system:
|
||||
api_messages.append({"role": "system", "content": system})
|
||||
api_messages.extend(messages)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": api_messages,
|
||||
"temperature": 0.7,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise RuntimeError("OpenRouter nicht erreichbar")
|
||||
except requests.exceptions.Timeout:
|
||||
raise RuntimeError("OpenRouter Timeout")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"OpenRouter Fehler: {e}")
|
||||
|
||||
async def generate_shell_command(self, description: str) -> dict:
|
||||
"""Generate shell command via OpenRouter."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
system = "Server: Ubuntu 24.04, Clownfischserver setup."
|
||||
|
||||
prompt = (
|
||||
"You are a Linux bash command generator for Ubuntu 24.04.\n"
|
||||
"Convert the request into a single non-interactive bash command.\n"
|
||||
"Respond ONLY with JSON: {\"dangerous\": bool, \"reason\": string, \"command\": string}\n"
|
||||
"No interactive commands (nano, vim, crontab -e). Use sudo when needed.\n\n"
|
||||
f"Request: {description}\n\nJSON only:"
|
||||
)
|
||||
|
||||
try:
|
||||
raw = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._chat_sync(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
system=system,
|
||||
model=FREE_MODELS["coder"],
|
||||
),
|
||||
)
|
||||
result = _extract_json(raw)
|
||||
if "command" not in result:
|
||||
result["command"] = ""
|
||||
if "dangerous" not in result:
|
||||
result["dangerous"] = False
|
||||
logger.info(f"OpenRouter shell: {result}")
|
||||
return result
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"OpenRouter JSON error: {e}")
|
||||
return {"dangerous": False, "reason": "", "command": ""}
|
||||
except Exception as e:
|
||||
logger.error(f"OpenRouter shell failed: {e}")
|
||||
raise
|
||||
|
||||
async def chat(self, message: str, history: list = None) -> str:
|
||||
"""General chat via OpenRouter."""
|
||||
loop = asyncio.get_running_loop()
|
||||
messages = list(history) if history else []
|
||||
messages.append({"role": "user", "content": message})
|
||||
|
||||
system = (
|
||||
"Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.\n"
|
||||
"Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt."
|
||||
)
|
||||
|
||||
try:
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._chat_sync(
|
||||
messages=messages,
|
||||
system=system,
|
||||
model=FREE_MODELS["chat"],
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
return f"OpenRouter Fehler: {e}"
|
||||
|
||||
async def explain_output(self, command: str, output: str, error: str = "") -> str:
|
||||
"""Explain command output via OpenRouter."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
content = f"Command: {command}\nOutput:\n{output}"
|
||||
if error:
|
||||
content += f"\nErrors:\n{error}"
|
||||
content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer."
|
||||
|
||||
try:
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._chat_sync(
|
||||
messages=[{"role": "user", "content": content}],
|
||||
model=FREE_MODELS["chat"],
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"OpenRouter explain failed: {e}")
|
||||
return ""
|
||||
|
||||
+73
-31
@@ -1,31 +1,73 @@
|
||||
# 🐠 Clownfischserver – Konfiguration
|
||||
# Diese Datei wird automatisch durch install.sh erstellt.
|
||||
# Niemals diese Datei committen oder teilen!
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrSTUvwxYZ
|
||||
TELEGRAM_CHAT_ID=987654321
|
||||
|
||||
# Ollama
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=qwen2.5-coder:7b
|
||||
OLLAMA_MODEL_FAST=gemma3:4b
|
||||
OLLAMA_NUM_CTX=16384
|
||||
|
||||
# OpenRouter (optional – kostenloser Fallback bei Ollama-Timeout)
|
||||
# API Key von openrouter.ai (kostenlos, keine Kreditkarte)
|
||||
# OPENROUTER_API_KEY=sk-or-v1-...
|
||||
|
||||
# Snapshots
|
||||
SNAPSHOT_DIR=/opt/clownfisch-snapshots
|
||||
SNAPSHOT_METHOD=tar
|
||||
|
||||
# Paths
|
||||
INSTALL_DIR=/opt/clownfischserver
|
||||
MGMT_USER=clownfish
|
||||
AIDER_WORKDIR=/opt/clownfisch-workspace
|
||||
|
||||
# Clownfischserver HomeIP Automatik (optional)
|
||||
# myfritz Adresse für automatische SSH-Freigabe von Zuhause
|
||||
# Falls aktiviert: Cron-Job prüft alle 5 Min und aktualisiert ufw-Regeln
|
||||
# CLOWNFISCHSERVER_HOME_IP_DOMAIN=vorname.myfritz.net
|
||||
# 🐠 Clownfischserver – Konfiguration
|
||||
# Diese Datei wird automatisch durch install.sh erstellt.
|
||||
# Niemals diese Datei committen oder teilen!
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrSTUvwxYZ
|
||||
# Legacy single-user mode: dieser Nutzer bekommt automatisch die operator-Rolle,
|
||||
# wenn keine CLOWNFISCH_* Rollen-Variablen gesetzt sind (v0.4-Kompatibilität).
|
||||
TELEGRAM_CHAT_ID=987654321
|
||||
|
||||
# === Team-Rollen (v0.5.0) ===========================================
|
||||
# Kommagetrennte Telegram User-IDs (nicht Chat-IDs!).
|
||||
# Operator = darf /shell /code /run /ja /ssh /snapshot /rollback ausführen
|
||||
# Approver = darf /ja (Bestätigungen) ausführen
|
||||
# Viewer = darf /start /help /status /snapshots /chat lesen
|
||||
# Inheritance: operator > approver > viewer (höhere Rolle erbt niedrigere Rechte).
|
||||
# CLOWNFISCH_OPERATORS=12345,67890
|
||||
# CLOWNFISCH_APPROVERS=11111
|
||||
# CLOWNFISCH_VIEWERS=22222,33333
|
||||
|
||||
# === Audit-Trail (DSGVO Art. 5, 32) =================================
|
||||
# Pfad zur JSON-Lines-Audit-Datei. Default: $INSTALL_DIR/audit.jsonl
|
||||
# AUDIT_LOG_PATH=/opt/clownfischserver/audit.jsonl
|
||||
# Aufbewahrungsdauer in Tagen (Art. 5(1)(e) – Storage Limitation).
|
||||
# Beim Bot-Start werden ältere Einträge automatisch entfernt. 0 = nie löschen.
|
||||
AUDIT_RETENTION_DAYS=90
|
||||
# Maximale Länge eines Audit-Detailfeldes (Datenminimierung).
|
||||
AUDIT_MAX_DETAIL_LEN=2000
|
||||
|
||||
# Ollama
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=qwen2.5-coder:7b
|
||||
OLLAMA_MODEL_FAST=gemma3:4b
|
||||
OLLAMA_NUM_CTX=16384
|
||||
|
||||
# OpenRouter (optional – kostenloser Fallback bei Ollama-Timeout)
|
||||
# API Key von openrouter.ai (kostenlos, keine Kreditkarte)
|
||||
# OPENROUTER_API_KEY=sk-or-v1-...
|
||||
|
||||
# === 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
|
||||
|
||||
+60
-64
@@ -29,8 +29,13 @@ MGMT_USER=""
|
||||
PKG_MANAGER=""
|
||||
DISTRO=""
|
||||
OLLAMA_MODEL=""
|
||||
OLLAMA_MODEL_FAST=""
|
||||
SNAPSHOT_METHOD="tar"
|
||||
OLLAMA_NUM_CTX=4096
|
||||
MYFRITZ_DOMAIN=""
|
||||
BOT_TOKEN=""
|
||||
CHAT_ID=""
|
||||
OPENROUTER_API_KEY=""
|
||||
|
||||
# HELPERS
|
||||
|
||||
@@ -105,7 +110,7 @@ print_banner() {
|
||||
echo -e "${CYAN}${BOLD}"
|
||||
echo " ╔══════════════════════════════════════════════════════╗"
|
||||
echo " ║ 🐠 CLOWNFISCHSERVER INSTALLER ║"
|
||||
echo " ║ v0.4.13 | GPL-3.0 ║"
|
||||
echo " ║ v0.5.0 | GPL-3.0 ║"
|
||||
echo " ╚══════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
echo -e " Willkommen! Dieses Script richtet deinen"
|
||||
@@ -168,7 +173,7 @@ step_packages() {
|
||||
case "$PKG_MANAGER" in
|
||||
apt)
|
||||
run_with_spinner "Paketliste aktualisieren" apt-get update -qq || fail "apt update fehlgeschlagen"
|
||||
run_with_spinner "Pakete installieren" apt-get install -y -qq curl wget git ufw python3 python3-pip python3-venv sudo htop net-tools lsof rsync openssl knockd dnsutils || 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 || warn "Einige Pakete konnten nicht installiert werden – siehe Log"
|
||||
;;
|
||||
dnf)
|
||||
run_with_spinner "Pakete aktualisieren" dnf update -y -q
|
||||
@@ -269,33 +274,6 @@ step_ssh() {
|
||||
ok "SSH läuft – Passwort + Key erlaubt"
|
||||
info "SSH wird erst gesperrt wenn du im Bot /ssh close sendest"
|
||||
|
||||
# Home IP Auto-Allow via myfritz
|
||||
CLOWNFISCHSERVER_HOME_IP_DOMAIN=""
|
||||
echo ""
|
||||
echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e " ${BOLD} myfritz Home-IP Automatik${NC}"
|
||||
echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e " SSH von Zuhause immer offen – ohne Port-Knocking."
|
||||
echo -e " ${CYAN} Clownfischserver aktualisiert deine Home-IP alle 5 Min automatisch.${NC}"
|
||||
echo ""
|
||||
|
||||
if ask_yn "myfritz Home-IP permanent erlauben?"; then
|
||||
echo ""
|
||||
echo -e " ${YELLOW}?${NC} ${BOLD}Deine myfritz Adresse:${NC}"
|
||||
echo -e " ${CYAN} Format: vorname.myfritz.net${NC}"
|
||||
read -r -p " → " CLOWNFISCHSERVER_HOME_IP_DOMAIN
|
||||
|
||||
if [[ -n "$CLOWNFISCHSERVER_HOME_IP_DOMAIN" ]]; then
|
||||
ok "myfritz Adresse gespeichert: $CLOWNFISCHSERVER_HOME_IP_DOMAIN"
|
||||
info "⏱ Clownfischserver-HomeIP Updater wird alle 5 Min gestartet"
|
||||
else
|
||||
warn "Leere Adresse – myfritz Automatik deaktiviert"
|
||||
CLOWNFISCHSERVER_HOME_IP_DOMAIN=""
|
||||
fi
|
||||
else
|
||||
info "myfritz Automatik deaktiviert"
|
||||
fi
|
||||
|
||||
# Firewall
|
||||
case "$PKG_MANAGER" in
|
||||
apt)
|
||||
@@ -362,6 +340,34 @@ KNOCKEOF
|
||||
warn "Bitte Firewall manuell einrichten (iptables/nftables)"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e " ${BOLD} myfritz – Home-IP Auto-Update (optional)${NC}"
|
||||
echo -e " ${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e " Wenn du ein AVM-Gerät (Fritzbox) mit myfritz.net hast,"
|
||||
echo -e " kann der Bot deine Home-IP automatisch ins Firewall-Regelwerk"
|
||||
echo -e " schreiben. Das bedeutet: SSH bleibt immer erreichbar, auch"
|
||||
echo -e " wenn sich deine IP ändert (z.B. ISP, mobil, VPN)."
|
||||
echo ""
|
||||
echo -e " ${YELLOW}?${NC} ${BOLD}Hast du ein AVM myfritz.net Gerät?${NC} [j/n]"
|
||||
read -r -p " -> " USE_MYFRITZ
|
||||
|
||||
if [[ "$USE_MYFRITZ" =~ ^[jJ]$ ]]; then
|
||||
echo ""
|
||||
echo -e " ${YELLOW}?${NC} ${BOLD}Deine myfritz-Domain:${NC}"
|
||||
echo -e " ${CYAN} Format: vorname.myfritz.net${NC}"
|
||||
read -r -p " -> " MYFRITZ_DOMAIN
|
||||
if [[ -z "$MYFRITZ_DOMAIN" ]]; then
|
||||
MYFRITZ_DOMAIN=""
|
||||
warn "myfritz übersprungen"
|
||||
else
|
||||
ok "myfritz Domain: $MYFRITZ_DOMAIN"
|
||||
fi
|
||||
else
|
||||
MYFRITZ_DOMAIN=""
|
||||
info "myfritz übersprungen"
|
||||
fi
|
||||
}
|
||||
|
||||
# HELPER: Pull an Ollama model with progress bar
|
||||
@@ -708,7 +714,7 @@ SNAPSHOT_METHOD=$SNAPSHOT_METHOD
|
||||
INSTALL_DIR=$INSTALL_DIR
|
||||
MGMT_USER=$MGMT_USER
|
||||
AIDER_WORKDIR=$WORKSPACE_DIR
|
||||
CLOWNFISCHSERVER_HOME_IP_DOMAIN=$CLOWNFISCHSERVER_HOME_IP_DOMAIN
|
||||
CLOWNFISCHSERVER_HOME_IP_DOMAIN=$MYFRITZ_DOMAIN
|
||||
ENVEOF
|
||||
|
||||
chmod 600 "$INSTALL_DIR/config/.env"
|
||||
@@ -724,6 +730,13 @@ step_service() {
|
||||
cp -r "$SCRIPT_DIR/bot/"* "$INSTALL_DIR/bot/" || fail "Bot-Dateien nicht gefunden – ist der bot/ Ordner dabei?"
|
||||
ok "Bot-Dateien kopiert"
|
||||
|
||||
mkdir -p "$INSTALL_DIR/scripts"
|
||||
if [ -d "$SCRIPT_DIR/scripts" ]; then
|
||||
cp -r "$SCRIPT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true
|
||||
chmod +x "$INSTALL_DIR/scripts/"*.sh 2>/dev/null || true
|
||||
ok "Scripts kopiert"
|
||||
fi
|
||||
|
||||
chown -R "$MGMT_USER:$MGMT_USER" "$INSTALL_DIR" "$SNAPSHOT_DIR" "$WORKSPACE_DIR"
|
||||
chmod -R u+w "$INSTALL_DIR/bot"
|
||||
ok "Verzeichnis-Rechte gesetzt"
|
||||
@@ -791,27 +804,24 @@ BOOTEOF
|
||||
run_with_spinner "Boot-Notification aktivieren" bash -c "systemctl daemon-reload && systemctl enable clownfisch-boot"
|
||||
ok "Boot-Notification: Telegram-Meldung 2 Min nach Serverstart"
|
||||
|
||||
# Clownfischserver HomeIP Updater (wenn aktiviert)
|
||||
if [[ -n "$CLOWNFISCHSERVER_HOME_IP_DOMAIN" ]]; then
|
||||
mkdir -p "$INSTALL_DIR/scripts"
|
||||
cp "$SCRIPT_DIR/scripts/clownfischserver-homeip-updater.sh" "$INSTALL_DIR/scripts/" 2>/dev/null || \
|
||||
warn "homeip-updater.sh nicht gefunden – manuell nachträglich einrichten"
|
||||
# HomeIP Updater – falls myfritz konfiguriert
|
||||
if [[ -n "$MYFRITZ_DOMAIN" ]]; then
|
||||
echo ""
|
||||
info "Installiere HomeIP Auto-Updater (5-Min Cron)..."
|
||||
|
||||
if [ -f "$INSTALL_DIR/scripts/clownfischserver-homeip-updater.sh" ]; then
|
||||
chmod +x "$INSTALL_DIR/scripts/clownfischserver-homeip-updater.sh"
|
||||
chown root:root "$INSTALL_DIR/scripts/clownfischserver-homeip-updater.sh"
|
||||
# Copy the homeip-updater script if it exists
|
||||
if [ -f "$SCRIPT_DIR/scripts/clownfischserver-homeip-updater.sh" ]; then
|
||||
mkdir -p /opt/clownfischserver-scripts
|
||||
cp "$SCRIPT_DIR/scripts/clownfischserver-homeip-updater.sh" /opt/clownfischserver/scripts/
|
||||
chmod +x /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh
|
||||
|
||||
# Cron-Job für root (da ufw sudo braucht)
|
||||
CRON_JOB="*/5 * * * * $INSTALL_DIR/scripts/clownfischserver-homeip-updater.sh >> /var/log/clownfischserver-homeip.log 2>&1"
|
||||
# Add cron job (every 5 minutes)
|
||||
CRON_JOB="*/5 * * * * /opt/clownfischserver/scripts/clownfischserver-homeip-updater.sh >> /var/log/clownfischserver-homeip-updater.log 2>&1"
|
||||
(crontab -l 2>/dev/null | grep -v "clownfischserver-homeip-updater" ; echo "$CRON_JOB") | crontab - 2>/dev/null || true
|
||||
|
||||
# Bestehende clownfischserver Cron-Jobs entfernen (install.sh läuft als root)
|
||||
crontab -l 2>/dev/null | grep -v clownfischserver-homeip-updater | crontab - 2>/dev/null || true
|
||||
|
||||
# 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"
|
||||
ok "HomeIP Auto-Updater installiert (myfritz: $MYFRITZ_DOMAIN)"
|
||||
else
|
||||
warn "homeip-updater Script nicht gefunden – übersprungen"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@@ -823,20 +833,13 @@ print_summary() {
|
||||
echo ""
|
||||
echo -e "${GREEN}${BOLD}"
|
||||
echo " +======================================================+"
|
||||
echo " | CLOWNFISCHSERVER BEREIT! v0.4.13 |"
|
||||
echo " | CLOWNFISCHSERVER BEREIT! v0.5.0 |"
|
||||
echo " +======================================================+"
|
||||
echo -e "${NC}"
|
||||
echo -e " ${BOLD}Server:${NC} $SERVER_IP"
|
||||
echo -e " ${BOLD}SSH User:${NC} $MGMT_USER (Passwort aktiv!)"
|
||||
echo -e " ${BOLD}KI-Modell:${NC} $OLLAMA_MODEL"
|
||||
echo -e " ${BOLD}Snapshots:${NC} $SNAPSHOT_DIR ($SNAPSHOT_METHOD)"
|
||||
|
||||
if [[ -n "$CLOWNFISCHSERVER_HOME_IP_DOMAIN" ]]; then
|
||||
echo -e " ${BOLD}${GREEN}HomeIP Auto:${NC} $CLOWNFISCHSERVER_HOME_IP_DOMAIN (alle 5 Min aktualisiert)"
|
||||
else
|
||||
echo -e " ${BOLD}HomeIP Auto:${NC} nicht aktiviert"
|
||||
fi
|
||||
|
||||
echo -e " ${BOLD}Logs:${NC} journalctl -u clownfisch -f"
|
||||
echo -e " ${BOLD}Install-Log:${NC} $LOG_FILE"
|
||||
echo ""
|
||||
@@ -851,13 +854,6 @@ print_summary() {
|
||||
echo -e " ${CYAN} /ssh open 1.2.3.4 -> SSH nur fuer deine IP${NC}"
|
||||
echo -e " ${CYAN} /ssh close -> SSH sperren${NC}"
|
||||
echo ""
|
||||
|
||||
if [[ -n "$CLOWNFISCHSERVER_HOME_IP_DOMAIN" ]]; then
|
||||
echo -e " ${GREEN}${BOLD}🐠 Clownfischserver-HomeIP Status:${NC}"
|
||||
echo -e " ${GREEN} Cron-Job aktiv: /var/log/clownfischserver-homeip.log${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo -e " ${YELLOW}Install-Log (letzte 10 Zeilen):${NC}"
|
||||
tail -10 "$LOG_FILE"
|
||||
echo ""
|
||||
@@ -928,7 +924,7 @@ main() {
|
||||
esac
|
||||
fi
|
||||
|
||||
log "=== Clownfischserver v0.4.13 Installation gestartet ==="
|
||||
log "=== Clownfischserver v0.5.0 Installation gestartet ==="
|
||||
step_checks
|
||||
step_packages
|
||||
step_user
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
# 🐠 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
|
||||
@@ -1,168 +1,174 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# 🐠 Clownfischserver HomeIP Updater
|
||||
# Version: v0.5.0
|
||||
# Purpose: Automatisch die Home-IP aktualisieren (alle 5 Min via Cron)
|
||||
# Multi-Distro: ufw (Ubuntu/Debian), firewalld (Fedora/openSUSE), iptables (Arch)
|
||||
# Author: Mehlzoerwer-Claude
|
||||
# 🐠 CLOWNFISCHSERVER – HomeIP Auto-Update
|
||||
# Purpose: Auto-update firewall rule for dynamic home IP (myfritz.net)
|
||||
# Runs: Every 5 minutes (via cron)
|
||||
# Author: Mehlzoerwer-Claude
|
||||
# License: GPL-3.0
|
||||
# =============================================================================
|
||||
|
||||
# Konfiguration
|
||||
set -e
|
||||
|
||||
# --- Configuration ---
|
||||
INSTALL_DIR="/opt/clownfischserver"
|
||||
ENV_FILE="$INSTALL_DIR/config/.env"
|
||||
STATE_FILE="/var/lib/clownfischserver-homeip-state"
|
||||
LOG_FILE="/var/log/clownfischserver-homeip.log"
|
||||
LOG_FILE="/var/log/clownfischserver-homeip-updater.log"
|
||||
SSH_PORT=22
|
||||
|
||||
# Logging
|
||||
# --- Helpers ---
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >> "$LOG_FILE"
|
||||
# --- Load .env ---
|
||||
load_env() {
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
log "ERROR: .env not found at $ENV_FILE"
|
||||
exit 1
|
||||
fi
|
||||
# Load and trim CRLF/LF
|
||||
MYFRITZ_DOMAIN=$(grep "^CLOWNFISCHSERVER_HOME_IP_DOMAIN=" "$ENV_FILE" | cut -d= -f2 | tr -d '\r\n')
|
||||
if [ -z "$MYFRITZ_DOMAIN" ]; then
|
||||
log "ERROR: CLOWNFISCHSERVER_HOME_IP_DOMAIN not set in .env"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Erkenne welche Firewall aktiv ist
|
||||
# --- Detect Firewall ---
|
||||
detect_firewall() {
|
||||
if systemctl is-active --quiet ufw 2>/dev/null; then
|
||||
if systemctl is-active --quiet ufw; then
|
||||
echo "ufw"
|
||||
elif systemctl is-active --quiet firewalld 2>/dev/null; then
|
||||
elif systemctl is-active --quiet firewalld; then
|
||||
echo "firewalld"
|
||||
elif command -v iptables &>/dev/null; then
|
||||
echo "iptables"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
FIREWALL=$(detect_firewall)
|
||||
log "Firewall erkannt: $FIREWALL"
|
||||
|
||||
# Prüfe ob .env existiert
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
log_error ".env nicht gefunden: $ENV_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Erstelle Log-File wenn nicht existiert
|
||||
touch "$LOG_FILE" 2>/dev/null || true
|
||||
|
||||
# Lade myfritz-Adresse aus .env (Zeilenumbrüche entfernen)
|
||||
MYFRITZ_DOMAIN=$(grep "^CLOWNFISCHSERVER_HOME_IP_DOMAIN=" "$ENV_FILE" | cut -d= -f2 | tr -d '\r\n')
|
||||
|
||||
if [[ -z "$MYFRITZ_DOMAIN" ]]; then
|
||||
log "Clownfischserver HomeIP: Deaktiviert (keine myfritz-Adresse)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Versuche IP aufzulösen (mit Fallback)
|
||||
if command -v dig &>/dev/null; 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')
|
||||
elif command -v nslookup &>/dev/null; then
|
||||
NEW_IP=$(nslookup "$MYFRITZ_DOMAIN" 2>/dev/null | grep -oE '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b' | tail -1)
|
||||
elif command -v getent &>/dev/null; then
|
||||
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
|
||||
}
|
||||
|
||||
add_ufw_rule() {
|
||||
ufw allow from "$1" to any port 22 comment "clownfischserver-homeip-$MYFRITZ_DOMAIN" >> "$LOG_FILE" 2>&1
|
||||
log "UFW: Neue Regel hinzugefügt: allow from $1 to port 22"
|
||||
}
|
||||
|
||||
remove_firewalld_rule() {
|
||||
firewall-cmd --permanent --remove-rich-rule="rule family='ipv4' source address='$1' port protocol='tcp' port='22' accept" >> "$LOG_FILE" 2>&1
|
||||
log "firewalld: Alte Regel entfernt: source address $1 port 22"
|
||||
}
|
||||
|
||||
add_firewalld_rule() {
|
||||
firewall-cmd --permanent --add-rich-rule="rule family='ipv4' source address='$1' port protocol='tcp' port='22' accept" >> "$LOG_FILE" 2>&1
|
||||
firewall-cmd --reload >> "$LOG_FILE" 2>&1
|
||||
log "firewalld: Neue Regel hinzugefügt: source address $1 port 22"
|
||||
}
|
||||
|
||||
remove_iptables_rule() {
|
||||
iptables -D INPUT -s "$1" -p tcp --dport 22 -j ACCEPT 2>> "$LOG_FILE" || true
|
||||
log "iptables: Alte Regel entfernt: source $1 port 22"
|
||||
}
|
||||
|
||||
add_iptables_rule() {
|
||||
iptables -I INPUT -s "$1" -p tcp --dport 22 -j ACCEPT >> "$LOG_FILE" 2>&1
|
||||
log "iptables: Neue Regel hinzugefügt: source $1 port 22"
|
||||
}
|
||||
|
||||
# Entferne alte Regel falls vorhanden
|
||||
if [[ -n "$OLD_IP" ]]; then
|
||||
case "$FIREWALL" in
|
||||
ufw) remove_ufw_rule "$OLD_IP" ;;
|
||||
firewalld) remove_firewalld_rule "$OLD_IP" ;;
|
||||
iptables) remove_iptables_rule "$OLD_IP" ;;
|
||||
*) log_error "Unbekannte Firewall: $FIREWALL" && exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Füge neue Regel hinzu
|
||||
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"
|
||||
log "ERROR: No firewall detected (ufw/firewalld/iptables)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
# Speichere neue IP
|
||||
echo "$NEW_IP" > "$STATE_FILE"
|
||||
chmod 644 "$STATE_FILE"
|
||||
# --- Resolve DNS with fallbacks ---
|
||||
resolve_ip() {
|
||||
local domain="$1"
|
||||
|
||||
log "✓ HomeIP Update erfolgreich: $MYFRITZ_DOMAIN → $NEW_IP ($FIREWALL)"
|
||||
exit 0
|
||||
# Try dig first
|
||||
if command -v dig &>/dev/null; then
|
||||
IP=$(dig +short "$domain" A | grep -oE '^[0-9.]+$' | head -1)
|
||||
[ -n "$IP" ] && echo "$IP" && return 0
|
||||
fi
|
||||
|
||||
# Try nslookup
|
||||
if command -v nslookup &>/dev/null; then
|
||||
IP=$(nslookup "$domain" 2>/dev/null | grep "Address:" | tail -1 | awk '{print $2}' | grep -oE '^[0-9.]+$')
|
||||
[ -n "$IP" ] && echo "$IP" && return 0
|
||||
fi
|
||||
|
||||
# Try getent
|
||||
if command -v getent &>/dev/null; then
|
||||
IP=$(getent hosts "$domain" | awk '{print $1}' | grep -oE '^[0-9.]+$' | head -1)
|
||||
[ -n "$IP" ] && echo "$IP" && return 0
|
||||
fi
|
||||
|
||||
log "ERROR: Could not resolve $domain"
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- Load previous state ---
|
||||
load_state() {
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
PREVIOUS_IP=$(cat "$STATE_FILE" | grep "^IP=" | cut -d= -f2)
|
||||
echo "$PREVIOUS_IP"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Save state ---
|
||||
save_state() {
|
||||
local ip="$1"
|
||||
echo "IP=$ip" > "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
}
|
||||
|
||||
# --- UFW Functions ---
|
||||
ufw_add_rule() {
|
||||
local ip="$1"
|
||||
ufw allow from "$ip" to any port "$SSH_PORT" comment "clownfisch-homeip" >/dev/null 2>&1
|
||||
log "UFW: Added rule for $ip:$SSH_PORT"
|
||||
}
|
||||
|
||||
ufw_remove_rule() {
|
||||
local ip="$1"
|
||||
echo "y" | ufw delete allow from "$ip" to any port "$SSH_PORT" >/dev/null 2>&1
|
||||
log "UFW: Deleted rule for $ip"
|
||||
}
|
||||
|
||||
# --- Firewalld Functions ---
|
||||
firewalld_add_rule() {
|
||||
local ip="$1"
|
||||
firewall-cmd --permanent --add-rich-rule="rule family='ipv4' source address='$ip' port protocol='tcp' port='$SSH_PORT' accept" >/dev/null 2>&1
|
||||
firewall-cmd --reload >/dev/null 2>&1
|
||||
log "firewalld: Added rule for $ip:$SSH_PORT"
|
||||
}
|
||||
|
||||
firewalld_remove_rule() {
|
||||
local ip="$1"
|
||||
firewall-cmd --permanent --remove-rich-rule="rule family='ipv4' source address='$ip' port protocol='tcp' port='$SSH_PORT' accept" >/dev/null 2>&1
|
||||
firewall-cmd --reload >/dev/null 2>&1
|
||||
log "firewalld: Deleted rule for $ip"
|
||||
}
|
||||
|
||||
# --- iptables Functions ---
|
||||
iptables_add_rule() {
|
||||
local ip="$1"
|
||||
iptables -I INPUT -s "$ip" -p tcp --dport "$SSH_PORT" -j ACCEPT
|
||||
log "iptables: Added rule for $ip:$SSH_PORT"
|
||||
}
|
||||
|
||||
iptables_remove_rule() {
|
||||
local ip="$1"
|
||||
iptables -D INPUT -s "$ip" -p tcp --dport "$SSH_PORT" -j ACCEPT 2>/dev/null || true
|
||||
log "iptables: Deleted rule for $ip"
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
main() {
|
||||
log "=== HomeIP Auto-Update started ==="
|
||||
|
||||
load_env
|
||||
FIREWALL=$(detect_firewall)
|
||||
NEW_IP=$(resolve_ip "$MYFRITZ_DOMAIN") || exit 1
|
||||
PREVIOUS_IP=$(load_state)
|
||||
|
||||
if [ "$NEW_IP" = "$PREVIOUS_IP" ]; then
|
||||
log "No change: $NEW_IP (same as previous)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "IP change detected: $PREVIOUS_IP → $NEW_IP (firewall: $FIREWALL)"
|
||||
|
||||
# Remove old rule
|
||||
if [ -n "$PREVIOUS_IP" ]; then
|
||||
case "$FIREWALL" in
|
||||
ufw) ufw_remove_rule "$PREVIOUS_IP" ;;
|
||||
firewalld) firewalld_remove_rule "$PREVIOUS_IP" ;;
|
||||
iptables) iptables_remove_rule "$PREVIOUS_IP" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Add new rule
|
||||
case "$FIREWALL" in
|
||||
ufw) ufw_add_rule "$NEW_IP" ;;
|
||||
firewalld) firewalld_add_rule "$NEW_IP" ;;
|
||||
iptables) iptables_add_rule "$NEW_IP" ;;
|
||||
esac
|
||||
|
||||
# Save state
|
||||
save_state "$NEW_IP"
|
||||
|
||||
log "=== HomeIP Auto-Update completed successfully ==="
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user