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