v0.4.13 – Security-Analyse, DSGVO-Roadmap, Projekt-Kontext (CLOWNFISCHSERVER.md)

This commit is contained in:
Mehlzoerwer-Claude
2026-04-19 22:25:59 +02:00
commit 5a2276021f
20 changed files with 3813 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
"""
import asyncio
import logging
import os
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
INSTALL_DIR = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
AIDER_WORKDIR = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5: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}"
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver – Boot Notification
Sends a Telegram message after server boot with service status.
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
"""
import os
import subprocess
import sys
from datetime import datetime
from dotenv import load_dotenv
import requests
# Load .env
INSTALL_DIR = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
load_dotenv(os.path.join(INSTALL_DIR, "config", ".env"))
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
SERVICES = ["clownfisch", "ollama", "ufw", "ssh", "cron"]
def get_service_status(name: str) -> str:
"""Check if a systemd service is active."""
try:
result = subprocess.run(
["systemctl", "is-active", name],
capture_output=True, text=True, timeout=5
)
status = result.stdout.strip()
if status == "active":
return "✅"
elif status == "inactive":
return "⚪"
else:
return "❌"
except Exception:
return "❓"
def get_uptime() -> str:
"""Get system uptime."""
try:
result = subprocess.run(
["uptime", "-p"], capture_output=True, text=True, timeout=5
)
return result.stdout.strip()
except Exception:
return "unbekannt"
def get_memory() -> str:
"""Get memory usage."""
try:
result = subprocess.run(
["free", "-h", "--si"], capture_output=True, text=True, timeout=5
)
for line in result.stdout.splitlines():
if line.startswith("Mem:"):
parts = line.split()
return f"{parts[2]} / {parts[1]}"
except Exception:
pass
return "unbekannt"
def send_message(text: str):
"""Send Telegram message."""
if not BOT_TOKEN or not CHAT_ID:
print("ERROR: Token oder Chat-ID fehlt in .env")
sys.exit(1)
try:
requests.post(
f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
json={"chat_id": CHAT_ID, "text": text, "parse_mode": "Markdown"},
timeout=10
)
except Exception as e:
print(f"Telegram Fehler: {e}")
sys.exit(1)
def main():
zeit = datetime.now().strftime("%H:%M:%S")
datum = datetime.now().strftime("%d.%m.%Y")
# Build service status list
status_lines = []
for svc in SERVICES:
icon = get_service_status(svc)
status_lines.append(f" {icon} {svc}")
uptime = get_uptime()
memory = get_memory()
msg = (
f"🐠 *Server gestartet!*\n\n"
f"Zeit: {datum} {zeit}\n"
f"Uptime: {uptime}\n"
f"RAM: {memory}\n\n"
f"*Dienste:*\n" +
"\n".join(status_lines)
)
send_message(msg)
print(f"Boot notification sent at {datum} {zeit}")
if __name__ == "__main__":
main()
+755
View File
@@ -0,0 +1,755 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
"""
import asyncio
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from dotenv import load_dotenv
from telegram import Update
from telegram.ext import (
Application,
MessageHandler,
CommandHandler,
filters,
ContextTypes,
)
# Global Ollama ready flag
ollama_ready = None
# Load local modules
sys.path.insert(0, os.path.dirname(__file__))
from ollama_client import OllamaClient
from openrouter_client import OpenRouterClient
from shell import run_shell, execute_command
from snapshot import SnapshotManager, should_snapshot
from aider_wrapper import AiderWrapper
# --- Load env ---
_install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
load_dotenv(os.path.join(_install_dir, "config", ".env"))
# --- Logging ---
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
level=logging.INFO,
handlers=[
logging.StreamHandler(),
logging.FileHandler(os.path.join(_install_dir, "bot.log")),
],
)
logger = logging.getLogger(__name__)
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
CHAT_ID = str(os.getenv("TELEGRAM_CHAT_ID"))
if not BOT_TOKEN or not CHAT_ID:
logger.error("TELEGRAM_BOT_TOKEN oder TELEGRAM_CHAT_ID fehlt in .env!")
sys.exit(1)
# --- Init modules ---
openrouter = OpenRouterClient()
ollama = OllamaClient(openrouter_client=openrouter)
snapshots = SnapshotManager()
aider = AiderWrapper()
# =============================================================================
# AUTH GUARD + HELPERS
# =============================================================================
def is_authorized(update: Update) -> bool:
return str(update.effective_chat.id) == CHAT_ID
async def safe_reply(target, text: str, parse_mode: str = "Markdown", edit: bool = False):
"""Send or edit a message. Falls back to plain text if Markdown fails."""
try:
if edit:
return await target.edit_text(text, parse_mode=parse_mode)
return await target.reply_text(text, parse_mode=parse_mode)
except Exception:
plain = text.replace("`", "").replace("*", "").replace("_", "")
try:
if edit:
return await target.edit_text(plain)
return await target.reply_text(plain)
except Exception as e:
logger.error(f"Telegram send failed: {e}")
# =============================================================================
# COMMANDS
# =============================================================================
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_authorized(update):
return
fallback_info = ""
if openrouter.is_available():
fallback_info = "\n✅ OpenRouter Fallback aktiv"
await update.message.reply_text(
f"🐠 *Clownfischserver v0.4.13 online.*{fallback_info}\n\n"
"Schreib mir einfach – ich antworte als Chat.\n\n"
"*Befehle:*\n"
"• `/shell <beschreibung>` – KI generiert Befehl, du bestätigst\n"
"• `/ja` – letzten Shell-Befehl ausführen\n"
"• `/code <aufgabe>` – Aider generiert Code\n"
"• `/run <befehl>` – direkte Shell, kein Ollama\n"
"• `/status` – CPU, RAM, Disk\n"
"• `/snapshots` – alle Snapshots anzeigen\n"
"• `/rollback <n>` – zu einem Snapshot zurück\n"
"• `/snapshot [label]` – manuell Snapshot erstellen\n"
"• `/ssh open [ip]` – SSH öffnen\n"
"• `/ssh close [ip]` – SSH schließen\n"
"• `/help` – diese Hilfe",
parse_mode="Markdown"
)
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_authorized(update):
return
cmd = (
"echo '=== UPTIME ===' && uptime && "
"echo '=== MEMORY ===' && free -h && "
"echo '=== DISK ===' && df -h && "
"echo '=== TOP PROCESSES ===' && ps aux --sort=-%cpu | head -10"
)
stdout, stderr, rc = await run_shell(cmd)
result = stdout or stderr or "Keine Ausgabe"
if len(result) > 4000:
result = result[:4000] + "\n... (gekürzt)"
await safe_reply(update.message, f"📊 Systemstatus\n\n```\n{result}\n```")
async def cmd_snapshots(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_authorized(update):
return
snap_list = snapshots.list_snapshots()
if not snap_list:
await update.message.reply_text("📸 Keine Snapshots vorhanden.")
return
msg = "📸 *Snapshots:*\n\n"
for s in snap_list[:20]:
star = "⭐ " if s["kept"] else ""
msg += f"• {star}`{s['name']}` ({s['size_mb']}MB)\n"
msg += f"\n_Gesamt: {len(snap_list)} | Max auto: 20 (⭐ bleiben immer)_\n"
msg += "\n`/snapshot keep <n>` – wichtig markieren\n"
msg += "`/snapshot delete <n>` – löschen"
await update.message.reply_text(msg, parse_mode="Markdown")
async def cmd_rollback(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_authorized(update):
return
if not context.args:
await update.message.reply_text(
"Usage: `/rollback <snapshot-name>`\n\nBeispiel: `/rollback 2025-01-15_17-24-00`",
parse_mode="Markdown"
)
return
snap_name = context.args[0]
await update.message.reply_text(f"⏪ Rollback zu `{snap_name}` wird eingeleitet...", parse_mode="Markdown")
result = snapshots.rollback(snap_name)
await update.message.reply_text(result)
async def cmd_snapshot(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Snapshot management: create, keep, delete"""
if not is_authorized(update):
return
args = context.args
subcmd = args[0].lower() if args else "create"
if subcmd == "keep" and len(args) > 1:
result = snapshots.keep_snapshot(args[1])
await update.message.reply_text(result)
elif subcmd == "unkeep" and len(args) > 1:
result = snapshots.unkeep_snapshot(args[1])
await update.message.reply_text(result)
elif subcmd == "delete" and len(args) > 1:
result = snapshots.delete_snapshot(args[1])
await update.message.reply_text(result)
elif subcmd in ("list", "ls"):
await cmd_snapshots(update, context)
else:
label = args[0] if args and subcmd not in ("create",) else "manual"
if subcmd == "create":
label = args[1] if len(args) > 1 else "manual"
await update.message.reply_text(f"📸 Erstelle Snapshot ({label})...")
snap_name = snapshots.create_snapshot(label=label)
if snap_name:
await update.message.reply_text(
f"✅ Snapshot erstellt: `{snap_name}`\n\nAls wichtig markieren: `/snapshot keep {snap_name}`",
parse_mode="Markdown"
)
else:
await update.message.reply_text("❌ Snapshot fehlgeschlagen – check Logs.")
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_authorized(update):
return
await cmd_start(update, context)
async def cmd_run(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Direct shell execution – bypasses Ollama completely"""
if not is_authorized(update):
return
if not context.args:
await update.message.reply_text(
"Usage: /run <befehl>\n\nBeispiel: /run free -h",
parse_mode="Markdown"
)
return
cmd = " ".join(context.args)
await update.message.reply_text(f"⚡ Direkt: `{cmd}`", parse_mode="Markdown")
stdout, stderr, returncode = await run_shell(cmd)
result = stdout or stderr or "✓ Fertig (keine Ausgabe)"
if len(result) > 4000:
result = result[:4000] + "\n... (gekürzt)"
await safe_reply(update.message, f"```\n{result}\n```")
async def cmd_ssh(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Open or close SSH via ufw"""
if not is_authorized(update):
return
action = context.args[0].lower() if context.args else ""
client_ip = context.args[1] if len(context.args) > 1 else None
if action == "open":
if client_ip:
await run_shell(f"sudo ufw allow from {client_ip} to any port 22 comment 'clownfisch-ssh'")
else:
await run_shell("sudo ufw allow 22/tcp comment 'clownfisch-ssh'")
ip_info = f"für {client_ip}" if client_ip else "für alle"
await update.message.reply_text(
f"🔓 SSH geöffnet {ip_info}!\nVergiss nicht: /ssh close danach!",
parse_mode="Markdown"
)
elif action == "close":
if client_ip:
await run_shell(f"sudo ufw delete allow from {client_ip} to any port 22")
else:
await run_shell("sudo ufw delete allow 22/tcp")
await update.message.reply_text("🔒 SSH wieder geschlossen.")
else:
await update.message.reply_text(
"Usage:\n"
"/ssh open – SSH für alle öffnen\n"
"/ssh open 1.2.3.4 – SSH nur für deine IP\n"
"/ssh close – SSH schließen\n"
"/ssh close 1.2.3.4 – SSH für IP schließen",
parse_mode="Markdown"
)
# =============================================================================
# /shell – KI generiert Befehl, Nutzer bestätigt mit /ja
# =============================================================================
async def cmd_shell(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_authorized(update):
return
if not context.args:
await update.message.reply_text(
"Usage: `/shell <was du tun willst>`\n\n"
"Beispiel: `/shell zeig mir den freien RAM`",
parse_mode="Markdown"
)
return
if (ollama_ready is None or not ollama_ready.is_set()) and not openrouter.is_available():
await update.message.reply_text("⏳ Ollama lädt noch – bitte kurz warten.")
return
description = " ".join(context.args)
thinking_msg = await update.message.reply_text("🤔 Generiere Befehl...")
try:
result = await ollama.generate_shell_command(description)
command = result.get("command", "").strip()
error = result.get("_error", "")
if error:
await safe_reply(thinking_msg, f"⚠️ {error}", edit=True)
return
if not command:
await safe_reply(thinking_msg, "❌ Konnte keinen Befehl generieren. Versuch es anders zu formulieren.", edit=True)
return
if result.get("dangerous", False):
await safe_reply(thinking_msg,
f"⚠️ *Gefährlicher Befehl verweigert!*\n\n"
f"Befehl: `{command}`\n"
f"Grund: {result.get('reason', 'Zu riskant')}",
edit=True
)
return
context.user_data["pending_cmd"] = command
await safe_reply(thinking_msg,
f"🔧 Vorgeschlagener Befehl:\n\n"
f"`{command}`\n\n"
f"→ Mit `/ja` ausführen oder einfach ignorieren.",
edit=True
)
except Exception as e:
logger.error(f"cmd_shell failed: {e}", exc_info=True)
await safe_reply(thinking_msg, f"❌ Fehler: `{e}`", edit=True)
# =============================================================================
# /ja – Bestätigung für pending /shell Befehl
# =============================================================================
async def cmd_ja(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_authorized(update):
return
pending = context.user_data.get("pending_cmd")
if not pending:
await update.message.reply_text("❓ Kein ausstehender Befehl. Nutze erst `/shell <beschreibung>`.", parse_mode="Markdown")
return
command = pending
context.user_data.pop("pending_cmd", None)
snap_name = None
if should_snapshot(command):
await update.message.reply_text("📸 Snapshot wird erstellt...")
snap_name = snapshots.create_snapshot()
if snap_name:
await update.message.reply_text(f"📸 Snapshot: `{snap_name}`", parse_mode="Markdown")
status_msg = await update.message.reply_text(f"⚙️ Ausführung: `{command}`", parse_mode="Markdown")
try:
result = await execute_command(command, ollama)
if len(result) > 4000:
chunks = [result[i:i+4000] for i in range(0, len(result), 4000)]
await safe_reply(status_msg, f"✅ Ergebnis (Teil 1/{len(chunks)}):\n\n```\n{chunks[0]}\n```", edit=True)
for i, chunk in enumerate(chunks[1:], 2):
await safe_reply(update.message, f"Teil {i}/{len(chunks)}:\n\n```\n{chunk}\n```")
else:
await safe_reply(status_msg, f"✅ Ergebnis:\n\n```\n{result}\n```", edit=True)
except Exception as e:
logger.error(f"cmd_ja execution failed: {e}", exc_info=True)
await safe_reply(status_msg,
f"❌ Fehler:\n`{e}`\n\nSnapshot: `{snap_name or 'keiner'}`",
edit=True
)
# =============================================================================
# /code – Aider für Code-Generierung
# =============================================================================
async def cmd_code(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not is_authorized(update):
return
if not context.args:
await update.message.reply_text(
"Usage: `/code <aufgabe>`\n\n"
"Beispiel: `/code erstelle eine einfache Flask API`\n\n"
"💡 Tipp: Kläre Details erst im Chat, dann `/code` mit klarer Aufgabe.",
parse_mode="Markdown"
)
return
task = " ".join(context.args)
# Smart prompt: ensure Aider creates a FILE, not just chat output
workspace = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace")
task_lower = task.lower()
if workspace not in task and "/opt/" not in task and "/" not in task.split()[0] if task.split() else True:
file_match = re.search(r'(\S+\.(sh|py|js|ts|html|css|yaml|yml|json|conf|cfg|txt))', task)
if file_match:
filename = file_match.group(1)
filepath = f"{workspace}/{filename}"
else:
words = re.sub(r'[^a-z0-9 ]', '', task_lower).split()[:3]
slug = "-".join(words) if words else "script"
ext = ".py" if "python" in task_lower else ".sh"
filepath = f"{workspace}/{slug}{ext}"
task = f"erstelle die datei {filepath} mit folgendem inhalt: {task}"
logger.info(f"Code task enhanced: {task[:100]}")
status_msg = await update.message.reply_text("🛠️ Aider arbeitet...")
snap_name = snapshots.create_snapshot(label="pre-aider")
if snap_name:
await update.message.reply_text(f"📸 Snapshot: `{snap_name}`", parse_mode="Markdown")
try:
result = await aider.run(task)
if len(result) > 4000:
result = result[:4000] + "\n... (gekürzt)"
await safe_reply(status_msg, f"🛠️ Aider Ergebnis:\n\n```\n{result}\n```", edit=True)
except Exception as e:
logger.error(f"cmd_code failed: {e}", exc_info=True)
await safe_reply(status_msg,
f"❌ Aider Fehler:\n`{e}`\n\nSnapshot: `{snap_name or 'keiner'}`",
edit=True
)
# =============================================================================
# FILE HANDLER – Self-Update via Telegram
# =============================================================================
async def handle_file(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle file uploads – ZIP = self-update"""
if not is_authorized(update):
return
doc = update.message.document
if not doc:
return
filename = doc.file_name or ""
if not filename.endswith(".zip"):
await update.message.reply_text(
f"📎 Datei empfangen: {filename}\nNur .zip Dateien werden als Updates verarbeitet."
)
return
status_msg = await update.message.reply_text(
f"📦 Update-Paket empfangen: `{filename}`\n⏳ Verarbeite...",
parse_mode="Markdown"
)
try:
# Snapshot before update
await status_msg.edit_text("📸 Erstelle Snapshot vor Update...")
snap_name = snapshots.create_snapshot(label="pre-update")
if snap_name:
await update.message.reply_text(f"📸 Snapshot: `{snap_name}`", parse_mode="Markdown")
# Download ZIP
await status_msg.edit_text("⬇️ Lade Datei herunter...")
tmp_dir = tempfile.mkdtemp()
zip_path = os.path.join(tmp_dir, filename)
tg_file = await context.bot.get_file(doc.file_id)
await tg_file.download_to_drive(zip_path)
# Extract
await status_msg.edit_text("📂 Entpacke ZIP...")
extract_dir = os.path.join(tmp_dir, "extracted")
with zipfile.ZipFile(zip_path, "r") as z:
z.extractall(extract_dir)
# Find bot/ directory
bot_dir = None
for root, dirs, files in os.walk(extract_dir):
if "bot.py" in files:
bot_dir = root
break
if not bot_dir:
raise FileNotFoundError("Kein bot.py im ZIP gefunden!")
# Check if openrouter_client.py is in the update
has_openrouter = os.path.exists(os.path.join(bot_dir, "openrouter_client.py"))
if has_openrouter and not openrouter.is_available():
# New feature available – ask user if they want to set it up
context.user_data["update_tmp_dir"] = tmp_dir
context.user_data["update_bot_dir"] = bot_dir
context.user_data["update_extract_dir"] = extract_dir
context.user_data["update_step"] = "ask_openrouter"
await status_msg.edit_text(
"✅ Update-Paket gültig\n\n"
"🆕 *Neues Feature: OpenRouter Fallback*\n\n"
"Wenn Ollama mal zu langsam ist, kann der Bot\n"
"kostenlose Cloud-Modelle als Backup nutzen.\n"
"Kein Zwang, keine Kreditkarte nötig.\n\n"
"Möchtest du OpenRouter aktivieren?\n"
"Antworte: `ja` oder `nein`",
parse_mode="Markdown"
)
else:
# No OpenRouter setup needed – install directly
await _perform_update(update, context, tmp_dir, bot_dir, extract_dir)
except Exception as e:
logger.error(f"Update fehlgeschlagen: {e}", exc_info=True)
await status_msg.edit_text(
f"❌ Update fehlgeschlagen:\n`{e}`\n\nSnapshot zum Rollback verfügbar.",
parse_mode="Markdown"
)
async def _perform_update(update, context, tmp_dir, bot_dir, extract_dir, openrouter_key=None):
"""Execute the actual update installation."""
try:
msg = await update.message.reply_text("📋 Kopiere Bot-Dateien...")
install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
install_bot_dir = os.path.join(install_dir, "bot")
mgmt_user = os.getenv("MGMT_USER", "clownfish")
for f in os.listdir(bot_dir):
if f.endswith(".py"):
src = os.path.join(bot_dir, f)
dst = os.path.join(install_bot_dir, f)
shutil.copy2(src, dst)
os.chmod(dst, 0o644)
logger.info(f"Updated: {f}")
subprocess.run(
["sudo", "chown", "-R", f"{mgmt_user}:{mgmt_user}", install_bot_dir],
capture_output=True,
)
# Copy systemd services – patch User= with actual MGMT_USER
systemd_dir = None
for root, dirs, files in os.walk(extract_dir):
if any(f.endswith(".service") for f in files):
systemd_dir = root
break
if systemd_dir:
for f in os.listdir(systemd_dir):
if f.endswith(".service"):
src = os.path.join(systemd_dir, f)
with open(src, "r") as sf:
service_content = sf.read()
# Replace ANY User=/Group= value with actual MGMT_USER
service_content = re.sub(r'User=\S+', f'User={mgmt_user}', service_content)
service_content = re.sub(r'Group=\S+', f'Group={mgmt_user}', service_content)
tmp_svc = os.path.join(install_dir, f".tmp_{f}")
with open(tmp_svc, "w") as sf:
sf.write(service_content)
subprocess.run(["sudo", "cp", tmp_svc, f"/etc/systemd/system/{f}"], capture_output=True)
os.remove(tmp_svc)
logger.info(f"Service updated: {f} (User={mgmt_user})")
subprocess.run(["sudo", "systemctl", "daemon-reload"], capture_output=True)
subprocess.run(["sudo", "systemctl", "enable", "clownfisch-boot"], capture_output=True)
# Save OpenRouter key if provided
if openrouter_key:
env_file = os.path.join(install_dir, "config", ".env")
try:
with open(env_file, "r") as f:
env_content = f.read()
except FileNotFoundError:
env_content = ""
if "OPENROUTER_API_KEY=" in env_content:
env_content = re.sub(r'OPENROUTER_API_KEY=.*', f'OPENROUTER_API_KEY={openrouter_key}', env_content)
else:
env_content += f"\nOPENROUTER_API_KEY={openrouter_key}\n"
with open(env_file, "w") as f:
f.write(env_content)
os.chmod(env_file, 0o600)
logger.info("OpenRouter Key in .env gespeichert")
# Cleanup
shutil.rmtree(tmp_dir, ignore_errors=True)
# Clear update state
for key in ["update_tmp_dir", "update_bot_dir", "update_extract_dir", "update_step", "update_openrouter_key"]:
context.user_data.pop(key, None)
or_status = "\n✅ OpenRouter Fallback aktiviert" if openrouter_key else ""
await msg.edit_text(
f"✅ *Update installiert!*{or_status}\n\nBot startet in 3 Sekunden neu...",
parse_mode="Markdown"
)
await asyncio.sleep(3)
subprocess.Popen(["sudo", "systemctl", "restart", "clownfisch"])
except Exception as e:
logger.error(f"Update execution failed: {e}", exc_info=True)
await update.message.reply_text(
f"❌ Update fehlgeschlagen:\n`{e}`\n\nSnapshot zum Rollback verfügbar.",
parse_mode="Markdown"
)
for key in ["update_tmp_dir", "update_bot_dir", "update_extract_dir", "update_step"]:
context.user_data.pop(key, None)
# =============================================================================
# MAIN MESSAGE HANDLER – Chat + Update Dialog
# =============================================================================
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Normal messages: either handle update dialog or go to Ollama chat."""
if not is_authorized(update):
logger.warning(f"Unauthorized access attempt from chat_id: {update.effective_chat.id}")
return
user_message = update.message.text.strip()
# --- Active update dialog? Handle that first, don't send to chat ---
update_step = context.user_data.get("update_step")
if update_step:
await _handle_update_dialog(update, context, user_message, update_step)
return
# --- Normal chat ---
logger.info(f"Chat message: {user_message[:100]}")
if (ollama_ready is None or not ollama_ready.is_set()) and not openrouter.is_available():
await update.message.reply_text(
"⏳ Ollama lädt noch...\n\n"
"Direkte Befehle funktionieren sofort:\n"
"• /run <befehl>\n"
"• /ssh open/close\n"
"• /status\n\n"
"Bitte in ~1 Minute nochmal versuchen."
)
return
thinking_msg = await update.message.reply_text("💬 ...")
try:
history = context.user_data.get("chat_history", [])
result = await ollama.chat(user_message, history=history)
history.append({"role": "user", "content": user_message})
history.append({"role": "assistant", "content": result})
if len(history) > 20:
history = history[-20:]
context.user_data["chat_history"] = history
if len(result) > 4000:
chunks = [result[i:i+4000] for i in range(0, len(result), 4000)]
await thinking_msg.edit_text(f"💬 {chunks[0]}")
for i, chunk in enumerate(chunks[1:], 2):
await update.message.reply_text(f"💬 ({i}/{len(chunks)}) {chunk}")
else:
await thinking_msg.edit_text(f"💬 {result}")
except Exception as e:
logger.error(f"Chat error: {e}", exc_info=True)
await thinking_msg.edit_text(f"❌ Chat-Fehler: `{e}`", parse_mode="Markdown")
async def _handle_update_dialog(update, context, user_input, step):
"""Handle interactive update setup dialog (OpenRouter key etc.)."""
user_input_lower = user_input.lower()
if step == "ask_openrouter":
if user_input_lower in ("ja", "yes", "j", "y"):
context.user_data["update_step"] = "ask_openrouter_key"
await update.message.reply_text(
"🔑 *OpenRouter API Key eingeben*\n\n"
"Kostenlos – keine Kreditkarte nötig!\n\n"
"1. Gehe zu openrouter.ai\n"
"2. Sign up (2 Minuten)\n"
"3. Settings → API Keys\n"
"4. Key kopieren und hier senden\n\n"
"Format: `sk-or-v1-...`",
parse_mode="Markdown"
)
elif user_input_lower in ("nein", "no", "n"):
tmp_dir = context.user_data.get("update_tmp_dir")
bot_dir = context.user_data.get("update_bot_dir")
extract_dir = context.user_data.get("update_extract_dir")
await _perform_update(update, context, tmp_dir, bot_dir, extract_dir)
else:
await update.message.reply_text("❓ Bitte antworte mit `ja` oder `nein`", parse_mode="Markdown")
elif step == "ask_openrouter_key":
key = user_input.strip()
if len(key) < 20:
await update.message.reply_text(
"❌ Key zu kurz. Bitte den kompletten Key von openrouter.ai senden.",
parse_mode="Markdown"
)
return
await update.message.reply_text("✅ Key gespeichert – Installation startet...")
tmp_dir = context.user_data.get("update_tmp_dir")
bot_dir = context.user_data.get("update_bot_dir")
extract_dir = context.user_data.get("update_extract_dir")
await _perform_update(update, context, tmp_dir, bot_dir, extract_dir, openrouter_key=key)
# =============================================================================
# MAIN
# =============================================================================
def main():
logger.info("🐠 Clownfischserver v0.4.13 Bot startet...")
import requests as req
import time
import threading
model = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b")
model_fast = os.getenv("OLLAMA_MODEL_FAST", "")
base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
global ollama_ready
ollama_ready = threading.Event()
def warmup_model(model_name, label):
for attempt in range(60):
try:
resp = req.post(f"{base_url}/api/chat", json={
"model": model_name,
"messages": [{"role": "user", "content": "hi"}],
"stream": False,
"keep_alive": -1,
}, timeout=150)
if resp.status_code == 200:
logger.info(f"✓ {label} warm: {model_name}")
return True
except Exception:
pass
time.sleep(3)
logger.warning(f"{label} nach 3 Minuten nicht bereit: {model_name}")
return False
def warmup_ollama():
logger.info("Ollama Warmup gestartet (Hintergrund)...")
models_to_warm = [(model, "Hauptmodell")]
if model_fast and model_fast != model:
models_to_warm.insert(0, (model_fast, "Fast-Modell"))
logger.info(f"Dual-Model: fast={model_fast}, main={model}")
for m, label in models_to_warm:
warmup_model(m, label)
ollama_ready.set()
threading.Thread(target=warmup_ollama, daemon=True).start()
if model_fast and model_fast != model:
logger.info(f"🐠 Bot startet – 2 Modelle laden ({model_fast} + {model})")
else:
logger.info("🐠 Bot startet – Ollama lädt im Hintergrund.")
if openrouter.is_available():
logger.info("✅ OpenRouter Fallback aktiv")
app = Application.builder().token(BOT_TOKEN).build()
# Commands
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("status", cmd_status))
app.add_handler(CommandHandler("snapshots", cmd_snapshots))
app.add_handler(CommandHandler("rollback", cmd_rollback))
app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("run", cmd_run))
app.add_handler(CommandHandler("snapshot", cmd_snapshot))
app.add_handler(CommandHandler("ssh", cmd_ssh))
app.add_handler(CommandHandler("shell", cmd_shell))
app.add_handler(CommandHandler("ja", cmd_ja))
app.add_handler(CommandHandler("code", cmd_code))
# Messages – single handler for chat AND update dialog (no double-processing)
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
app.add_handler(MessageHandler(filters.Document.ALL, handle_file))
logger.info(f"Bot läuft. Authorized Chat-ID: {CHAT_ID}")
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
"""
import os
import sys
import requests
from pathlib import Path
from dotenv import load_dotenv
# Load .env
ENV_FILE = "/opt/clownfischserver/config/.env"
load_dotenv(ENV_FILE)
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
def send_message(text: str):
if not BOT_TOKEN or not CHAT_ID:
print("ERROR: Token oder Chat-ID fehlt in .env")
sys.exit(1)
try:
requests.post(
f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
json={"chat_id": CHAT_ID, "text": text, "parse_mode": "Markdown"},
timeout=10
)
except Exception as e:
print(f"Telegram Fehler: {e}")
if __name__ == "__main__":
event = sys.argv[1] if len(sys.argv) > 1 else "unknown"
ip = sys.argv[2] if len(sys.argv) > 2 else "unbekannt"
from datetime import datetime
zeit = datetime.now().strftime("%H:%M:%S")
datum = datetime.now().strftime("%d.%m.%Y")
if event == "open":
send_message(f"🔓 *SSH geöffnet*\nIP: `{ip}`\nZeit: {datum} {zeit}")
elif event == "close":
send_message(f"🔒 *SSH geschlossen*\nIP: `{ip}`\nZeit: {datum} {zeit}")
elif event == "attempt":
send_message(f"⚠️ *Knock-Versuch*\nIP: `{ip}`\nZeit: {datum} {zeit}")
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
"""
import json
import logging
import os
import re
import asyncio
import requests
from typing import Optional
logger = logging.getLogger(__name__)
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b")
OLLAMA_MODEL_FAST = os.getenv("OLLAMA_MODEL_FAST", "")
OLLAMA_NUM_CTX = int(os.getenv("OLLAMA_NUM_CTX", "4096"))
SHELL_PROMPT_TEMPLATE = """You are a Linux bash command generator for an Ubuntu 24.04 server.
Convert the user request into a single non-interactive bash command.
Respond with ONLY a JSON object.
CRITICAL RULES:
- Commands run WITHOUT a terminal. No interactive commands like nano, vim, crontab -e, less, top.
- Use non-interactive alternatives: echo/tee for files, /etc/cron.d/ for cron jobs, sed for edits.
- Use sudo when root permissions are needed.
- Chain commands with && if needed.
Example request: "show free ram"
Example response: {{"dangerous":false,"reason":"","command":"free -h"}}
Example request: "installiere nginx"
Example response: {{"dangerous":false,"reason":"","command":"sudo apt install -y nginx"}}
Example request: "erstelle einen cronjob der alle 5 minuten ein script ausfuehrt"
Example response: {{"dangerous":false,"reason":"","command":"echo '*/5 * * * * root /path/to/script.sh' | sudo tee /etc/cron.d/myjob"}}
Example request: "schreibe hello world in eine datei"
Example response: {{"dangerous":false,"reason":"","command":"echo 'hello world' | sudo tee /tmp/hello.txt"}}
Example request: "starte nginx neu"
Example response: {{"dangerous":false,"reason":"","command":"sudo systemctl restart nginx"}}
Example request: "lösche alles in /etc"
Example response: {{"dangerous":true,"reason":"Würde Systemkonfiguration zerstören","command":"rm -rf /etc/*"}}
Now generate JSON for this request: "{description}"
IMPORTANT: Respond with ONLY the JSON object. No other text. No markdown."""
CHAT_SYSTEM_PROMPT = """Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.
Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt.
Wenn du ein Befehlsergebnis erklärst, sei präzise und verständlich."""
def _extract_json(raw: str) -> dict:
"""Extract JSON from Ollama response, handling markdown wrappers and stray text."""
text = raw.strip()
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
raise json.JSONDecodeError("No valid JSON found", text, 0)
class OllamaClient:
def __init__(self, openrouter_client=None):
self.base_url = OLLAMA_BASE_URL
self.model = OLLAMA_MODEL
self.model_fast = OLLAMA_MODEL_FAST or OLLAMA_MODEL
self.openrouter = openrouter_client
def _chat_sync(self, messages: list, system: Optional[str] = None, think: Optional[bool] = None, use_fast: bool = False) -> str:
"""Synchronous Ollama API call"""
model = self.model_fast if use_fast else self.model
payload = {
"model": model,
"messages": messages,
"stream": False,
"keep_alive": -1,
"options": {"num_ctx": OLLAMA_NUM_CTX},
}
if system:
payload["system"] = system
if think is not None:
payload["think"] = think
try:
response = requests.post(
f"{self.base_url}/api/chat",
json=payload,
timeout=180,
)
response.raise_for_status()
data = response.json()
return data.get("message", {}).get("content", "")
except requests.exceptions.ConnectionError:
raise RuntimeError("Ollama nicht erreichbar – läuft der Service? (systemctl status ollama)")
except requests.exceptions.Timeout:
raise RuntimeError("Ollama Timeout – Modell lädt möglicherweise noch.")
except Exception as e:
raise RuntimeError(f"Ollama Fehler: {e}")
async def generate_shell_command(self, description: str) -> dict:
"""Generate a shell command from natural language description.
Tries Ollama first, falls back to OpenRouter on timeout."""
loop = asyncio.get_running_loop()
raw = ""
# 1. Try local Ollama
try:
prompt = SHELL_PROMPT_TEMPLATE.format(description=description)
raw = await loop.run_in_executor(
None,
lambda: self._chat_sync(
messages=[{"role": "user", "content": prompt}],
think=False,
use_fast=False,
),
)
result = _extract_json(raw)
if "command" not in result or not result["command"].strip():
logger.warning(f"Shell generate: no command in result: {result}")
return {"dangerous": False, "reason": "", "command": ""}
if "dangerous" not in result:
result["dangerous"] = False
logger.info(f"Shell generate (Ollama): {result}")
return result
except json.JSONDecodeError as e:
logger.warning(f"Shell generate JSON error: {e}, raw: {raw[:500]}")
return {"dangerous": False, "reason": "", "command": ""}
except RuntimeError as e:
if "Timeout" not in str(e) and "nicht erreichbar" not in str(e):
raise
logger.warning(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"
)
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver – OpenRouter Free Models Fallback
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
"""
import json
import logging
import os
import re
import asyncio
import requests
from typing import Optional
logger = logging.getLogger(__name__)
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# Free models on OpenRouter (no credit card needed)
# openrouter/free auto-selects the best available free model
FREE_MODELS = {
"coder": "openrouter/free",
"chat": "openrouter/free",
}
def _extract_json(raw: str) -> dict:
"""Extract JSON from response."""
text = raw.strip()
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
raise json.JSONDecodeError("No valid JSON found", text, 0)
class OpenRouterClient:
"""OpenRouter Free Models Client – fallback when Ollama times out."""
def __init__(self):
self.api_key = OPENROUTER_API_KEY
self.base_url = OPENROUTER_BASE_URL
if self.api_key:
logger.info("OpenRouter Fallback konfiguriert")
else:
logger.info("OpenRouter nicht konfiguriert – Fallback deaktiviert")
def is_available(self) -> bool:
return bool(self.api_key)
def _chat_sync(self, messages: list, system: Optional[str] = None, model: str = None) -> str:
"""Synchronous OpenRouter API call."""
if not self.api_key:
raise RuntimeError("OpenRouter API Key nicht konfiguriert")
model = model or FREE_MODELS["chat"]
api_messages = []
if system:
api_messages.append({"role": "system", "content": system})
api_messages.extend(messages)
try:
response = requests.post(
f"{self.base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": api_messages,
"temperature": 0.7,
},
timeout=60,
)
response.raise_for_status()
data = response.json()
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
except requests.exceptions.ConnectionError:
raise RuntimeError("OpenRouter nicht erreichbar")
except requests.exceptions.Timeout:
raise RuntimeError("OpenRouter Timeout")
except Exception as e:
raise RuntimeError(f"OpenRouter Fehler: {e}")
async def generate_shell_command(self, description: str) -> dict:
"""Generate shell command via OpenRouter."""
loop = asyncio.get_running_loop()
prompt = (
"You are a Linux bash command generator for Ubuntu 24.04.\n"
"Convert the request into a single non-interactive bash command.\n"
"Respond ONLY with JSON: {\"dangerous\": bool, \"reason\": string, \"command\": string}\n"
"No interactive commands (nano, vim, crontab -e). Use sudo when needed.\n\n"
f"Request: {description}\n\nJSON only:"
)
try:
raw = await loop.run_in_executor(
None,
lambda: self._chat_sync(
messages=[{"role": "user", "content": prompt}],
model=FREE_MODELS["coder"],
),
)
result = _extract_json(raw)
if "command" not in result:
result["command"] = ""
if "dangerous" not in result:
result["dangerous"] = False
logger.info(f"OpenRouter shell: {result}")
return result
except json.JSONDecodeError as e:
logger.warning(f"OpenRouter JSON error: {e}")
return {"dangerous": False, "reason": "", "command": ""}
except Exception as e:
logger.error(f"OpenRouter shell failed: {e}")
raise
async def chat(self, message: str, history: list = None) -> str:
"""General chat via OpenRouter."""
loop = asyncio.get_running_loop()
messages = list(history) if history else []
messages.append({"role": "user", "content": message})
system = (
"Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.\n"
"Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt."
)
try:
return await loop.run_in_executor(
None,
lambda: self._chat_sync(
messages=messages,
system=system,
model=FREE_MODELS["chat"],
),
)
except Exception as e:
return f"OpenRouter Fehler: {e}"
async def explain_output(self, command: str, output: str, error: str = "") -> str:
"""Explain command output via OpenRouter."""
loop = asyncio.get_running_loop()
content = f"Command: {command}\nOutput:\n{output}"
if error:
content += f"\nErrors:\n{error}"
content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer."
try:
return await loop.run_in_executor(
None,
lambda: self._chat_sync(
messages=[{"role": "user", "content": content}],
model=FREE_MODELS["chat"],
),
)
except Exception as e:
logger.error(f"OpenRouter explain failed: {e}")
return ""
+4
View File
@@ -0,0 +1,4 @@
python-telegram-bot==21.0
aider-chat
requests
python-dotenv
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
"""
import asyncio
import logging
import os
import shlex
logger = logging.getLogger(__name__)
MGMT_USER = os.getenv("MGMT_USER", "clownfish")
COMMAND_TIMEOUT = 120 # seconds
async def run_shell(command: str) -> tuple[str, str, int]:
"""Run a shell command as mgmt user, return (stdout, stderr, returncode)"""
try:
current_user = os.getenv("USER", "")
if current_user != MGMT_USER:
full_cmd = f"sudo -u {MGMT_USER} bash -c {shlex.quote(command)}"
else:
full_cmd = command
process = await asyncio.create_subprocess_shell(
full_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, "HOME": f"/home/{MGMT_USER}"}
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=COMMAND_TIMEOUT
)
except asyncio.TimeoutError:
process.kill()
return "", f"Timeout nach {COMMAND_TIMEOUT}s", 1
return (
stdout.decode("utf-8", errors="replace").strip(),
stderr.decode("utf-8", errors="replace").strip(),
process.returncode
)
except Exception as e:
logger.error(f"Shell execution error: {e}")
return "", str(e), 1
async def execute_command(shell_cmd: str, ollama_client) -> str:
"""
Execute a pre-determined shell command and return formatted output.
Optionally asks Ollama to explain errors or complex output.
"""
logger.info(f"Executing: {shell_cmd}")
stdout, stderr, returncode = await run_shell(shell_cmd)
# Build response
response_parts = [f"$ {shell_cmd}"]
if stdout:
response_parts.append(stdout)
if stderr and returncode != 0:
response_parts.append(f"[STDERR]\n{stderr}")
if returncode != 0:
response_parts.append(f"[Exit code: {returncode}]")
raw_output = "\n".join(response_parts)
# If there's an error or output is complex, ask Ollama to explain
if returncode != 0 or len(stdout) > 500:
explanation = await ollama_client.explain_output(shell_cmd, stdout, stderr)
if explanation:
raw_output += f"\n\n💡 {explanation}"
return raw_output or "✓ Ausgeführt (keine Ausgabe)"
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
"""
import logging
import os
import subprocess
import shutil
import json
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
SNAPSHOT_DIR = os.getenv("SNAPSHOT_DIR", "/opt/clownfisch-snapshots")
SNAPSHOT_METHOD = os.getenv("SNAPSHOT_METHOD", "tar")
MAX_SNAPSHOTS = 20 # Auto-cleanup limit (kept snapshots are exempt)
KEPT_FILE = os.path.join(SNAPSHOT_DIR, ".kept.json") # Tracks important snapshots
# Commands that trigger automatic snapshots
AUTO_SNAPSHOT_TRIGGERS = [
"apt install", "apt-get install", "apt remove", "apt-get remove",
"apt upgrade", "apt-get upgrade", "dnf install", "dnf remove",
"pacman -S", "pacman -R",
"systemctl enable", "systemctl disable",
"rm -", "mv ", "cp -",
"chmod ", "chown ",
"nano ", "vim ", "vi ", "echo >", "tee ",
"dd ", "mkfs", "fdisk",
]
# Commands that are always read-only – never need a snapshot
READONLY_PREFIXES = [
"ls", "cat ", "grep ", "find ", "df ", "du ", "free",
"top", "ps ", "who", "uptime", "journalctl", "tail ",
"head ", "wc ", "stat ", "file ", "which ", "type ",
"systemctl status", "systemctl is-active", "systemctl show",
"ollama list", "docker ps", "docker images",
]
def should_snapshot(command: str) -> bool:
"""Check if a shell command warrants an automatic snapshot."""
cmd_lower = command.lower().strip()
# Skip read-only commands
for ro in READONLY_PREFIXES:
if cmd_lower.startswith(ro):
return False
# Check triggers
for trigger in AUTO_SNAPSHOT_TRIGGERS:
if trigger in cmd_lower:
return True
return False
class SnapshotManager:
def __init__(self):
self.snapshot_dir = Path(SNAPSHOT_DIR)
self.method = SNAPSHOT_METHOD
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
self._load_kept()
logger.info(f"SnapshotManager init: method={self.method}, dir={self.snapshot_dir}")
def _timestamp(self) -> str:
return datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
def _load_kept(self):
"""Load list of kept (important) snapshots."""
try:
if os.path.exists(KEPT_FILE):
with open(KEPT_FILE, "r") as f:
self.kept = set(json.loads(f.read()))
else:
self.kept = set()
except Exception:
self.kept = set()
def _save_kept(self):
"""Save kept snapshots list."""
try:
with open(KEPT_FILE, "w") as f:
json.dump(list(self.kept), f)
except Exception as e:
logger.error(f"Could not save kept list: {e}")
def create_snapshot(self, label: str = "") -> Optional[str]:
"""Create a snapshot. Returns snapshot name or None on failure."""
name = self._timestamp()
if label:
name += f"_{label}"
try:
result = self._create_tar_snapshot(name)
if result:
logger.info(f"Snapshot created: {name}")
self._cleanup_old_snapshots()
return name
return None
except Exception as e:
logger.error(f"Snapshot creation failed: {e}")
return None
def _create_tar_snapshot(self, name: str) -> bool:
"""Create tar.gz snapshot, excluding sensitive files."""
snap_path = self.snapshot_dir / f"{name}.tar.gz"
# Save current package list for rollback comparison
pkg_list = self.snapshot_dir / f"{name}.packages.txt"
try:
with open(pkg_list, "w") as f:
subprocess.run(
["dpkg", "--get-selections"],
stdout=f,
stderr=subprocess.DEVNULL,
timeout=10,
)
except Exception as e:
logger.warning(f"Could not save package list: {e}")
workspace = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace")
dirs_to_backup = [
"/etc",
"/opt/clownfischserver",
"/home",
workspace,
]
existing = [d for d in dirs_to_backup if os.path.exists(d)]
if not existing:
raise Exception("Keine Verzeichnisse zum Sichern gefunden")
cmd = ["sudo", "tar", "czf", str(snap_path)]
cmd += ["--ignore-failed-read"]
# Exclude sensitive files – NEVER backup secrets
cmd += ["--exclude=/etc/ssh/ssh_host_rsa_key"]
cmd += ["--exclude=/etc/ssh/ssh_host_ecdsa_key"]
cmd += ["--exclude=/etc/ssh/ssh_host_ed25519_key"]
cmd += ["--exclude=/etc/shadow"]
cmd += ["--exclude=/etc/gshadow"]
cmd += ["--exclude=*/config/.env"] # Don't backup API keys/tokens
# Exclude large caches
cmd += ["--exclude=__pycache__"]
cmd += ["--exclude=*.pyc"]
cmd += ["--exclude=node_modules"]
cmd += existing
result = subprocess.run(cmd, capture_output=True)
if result.returncode not in (0, 1): # 1 = warnings only
raise Exception(result.stderr.decode())
return True
def list_snapshots(self) -> list[dict]:
"""List all snapshots with metadata, newest first."""
try:
snaps = []
for f in self.snapshot_dir.iterdir():
if f.name.endswith(".tar.gz"):
name = f.name.replace(".tar.gz", "")
size_mb = f.stat().st_size // (1024 * 1024)
kept = name in self.kept
snaps.append({
"name": name,
"size_mb": size_mb,
"kept": kept
})
snaps.sort(key=lambda x: x["name"], reverse=True)
return snaps
except Exception as e:
logger.error(f"list_snapshots failed: {e}")
return []
def keep_snapshot(self, name: str) -> str:
"""Mark a snapshot as important (won't be auto-deleted)."""
snaps = [s["name"] for s in self.list_snapshots()]
if name not in snaps:
return f"❌ Snapshot '{name}' nicht gefunden."
self.kept.add(name)
self._save_kept()
return f"⭐ Snapshot '{name}' als wichtig markiert – wird nicht automatisch gelöscht."
def unkeep_snapshot(self, name: str) -> str:
"""Remove important mark from snapshot."""
self.kept.discard(name)
self._save_kept()
return f"✅ Markierung von '{name}' entfernt."
def delete_snapshot(self, name: str) -> str:
"""Delete a specific snapshot."""
snap_path = self.snapshot_dir / f"{name}.tar.gz"
pkg_path = self.snapshot_dir / f"{name}.packages.txt"
if not snap_path.exists():
return f"❌ Snapshot '{name}' nicht gefunden."
try:
snap_path.unlink()
if pkg_path.exists():
pkg_path.unlink()
self.kept.discard(name)
self._save_kept()
return f"🗑️ Snapshot '{name}' gelöscht."
except Exception as e:
return f"❌ Fehler beim Löschen: {e}"
def rollback(self, snapshot_name: str) -> str:
"""Roll back to a specific snapshot."""
snap_path = self.snapshot_dir / f"{snapshot_name}.tar.gz"
if not snap_path.exists():
return f"❌ Snapshot '{snapshot_name}' nicht gefunden."
try:
tmp_dir = tempfile.mkdtemp()
# Extract all content
result = subprocess.run(
["sudo", "tar", "xzf", str(snap_path), "-C", tmp_dir],
capture_output=True
)
if result.returncode not in (0, 1):
raise Exception(f"tar extract failed: {result.stderr.decode()}")
restored = []
etc_backup = os.path.join(tmp_dir, "etc")
if os.path.exists(etc_backup):
subprocess.run(["sudo", "rsync", "-av", "--delete",
etc_backup + "/", "/etc/"], capture_output=True)
restored.append("/etc")
cfs_backup = os.path.join(tmp_dir, "opt/clownfischserver")
if os.path.exists(cfs_backup):
subprocess.run(["sudo", "rsync", "-av", "--delete",
cfs_backup + "/", "/opt/clownfischserver/"], capture_output=True)
restored.append("/opt/clownfischserver")
home_backup = os.path.join(tmp_dir, "home")
if os.path.exists(home_backup):
subprocess.run(["sudo", "rsync", "-av", "--delete",
home_backup + "/", "/home/"], capture_output=True)
restored.append("/home")
workspace = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace")
ws_backup = os.path.join(tmp_dir, workspace.lstrip("/"))
if os.path.exists(ws_backup):
subprocess.run(["sudo", "rsync", "-av", "--delete",
ws_backup + "/", workspace + "/"], capture_output=True)
restored.append(workspace)
shutil.rmtree(tmp_dir, ignore_errors=True)
restored_str = ", ".join(restored) if restored else "nichts"
# Check if package list exists for this snapshot
pkg_hint = ""
pkg_path = self.snapshot_dir / f"{snapshot_name}.packages.txt"
if pkg_path.exists():
pkg_hint = "\n\n💡 Paketliste verfügbar – prüfe mit:\n/run diff <(dpkg --get-selections) " + str(pkg_path)
return (
f"✅ Rollback zu '{snapshot_name}' abgeschlossen.\n"
f"Wiederhergestellt: {restored_str}\n"
f"Dienste neu starten: systemctl restart clownfisch ollama"
f"{pkg_hint}"
)
except Exception as e:
return f"❌ Rollback Fehler: {e}"
def _cleanup_old_snapshots(self):
"""Remove oldest snapshots if over MAX_SNAPSHOTS, skip kept ones."""
snaps = self.list_snapshots()
deletable = [s for s in snaps if not s["kept"]]
if len(deletable) > MAX_SNAPSHOTS:
to_delete = deletable[MAX_SNAPSHOTS:]
for snap in to_delete:
snap_path = self.snapshot_dir / f"{snap['name']}.tar.gz"
pkg_path = self.snapshot_dir / f"{snap['name']}.packages.txt"
try:
snap_path.unlink()
if pkg_path.exists():
pkg_path.unlink()
logger.info(f"Auto-cleanup: {snap['name']}")
except Exception as e:
logger.warning(f"Could not delete {snap['name']}: {e}")