v0.5.0: Add RBAC auth, LLM router, audit logging, output sanitization

- New: auth.py – Role-based access (operator/approver/viewer)
- New: llm_router.py – Configurable primary/fallback provider chain
- New: audit_log.py + audit_query.py – DSGVO-compliant audit trail
- New: sanitize_output() – masks secrets in bot responses
- Updated: bot.py – Integrated auth guards, router, audit logging
- Updated: ollama_client.py – Router-compatible interface
- Updated: CLOWNFISCHSERVER.md – Documentation updated
- Updated: README.md – Documentation updated
This commit is contained in:
root
2026-06-26 20:41:35 +02:00
commit f2fdf95897
25 changed files with 5472 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-coder:7b")
TIMEOUT = 600 # 10 minutes for aider tasks on CPU
class AiderWrapper:
def __init__(self):
self.workdir = Path(AIDER_WORKDIR)
self.workdir.mkdir(parents=True, exist_ok=True)
self.venv_python = Path(INSTALL_DIR) / "aider-venv" / "bin" / "python3"
self.venv_aider = Path(INSTALL_DIR) / "aider-venv" / "bin" / "aider"
logger.info(f"AiderWrapper init: workdir={self.workdir}")
async def run(self, task: str, target_file: str = None) -> str:
"""
Run aider in script mode for a coding task.
Returns the output/result as string.
"""
logger.info(f"Aider task: {task[:100]}")
# Determine working directory and files
workdir = self.workdir
# Build aider command
cmd = self._build_aider_command(task, target_file)
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(workdir),
env={
**os.environ,
"OLLAMA_API_BASE": OLLAMA_BASE_URL,
"HOME": f"/home/{os.getenv('MGMT_USER', 'clownfish')}",
}
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=TIMEOUT
)
except asyncio.TimeoutError:
process.kill()
return f"⏱️ Aider Timeout nach {TIMEOUT}s. Task zu komplex oder Modell überlastet."
stdout_str = stdout.decode("utf-8", errors="replace").strip()
stderr_str = stderr.decode("utf-8", errors="replace").strip()
# Build response
result_parts = []
if stdout_str:
result_parts.append(stdout_str)
if stderr_str and process.returncode != 0:
result_parts.append(f"[STDERR]\n{stderr_str}")
if process.returncode == 0:
result_parts.insert(0, f"✅ Aider Task abgeschlossen\nArbeitsverzeichnis: {workdir}\n")
else:
result_parts.insert(0, f"⚠️ Aider beendet mit Code {process.returncode}\n")
return "\n".join(result_parts) or "✓ Task abgeschlossen (keine Ausgabe)"
except FileNotFoundError:
return (
"❌ Aider nicht gefunden. Bitte prüfen:\n"
f" ls {self.venv_aider}\n"
" Ggf. neu installieren mit: pip install aider-chat"
)
except Exception as e:
logger.error(f"Aider execution error: {e}", exc_info=True)
return f"❌ Aider Fehler: {e}"
def _build_aider_command(self, task: str, target_file: str = None) -> list:
"""Build the aider command with appropriate flags"""
cmd = [
str(self.venv_aider),
"--model", f"ollama/{OLLAMA_MODEL}",
"--no-git", # Don't require git
"--yes", # Auto-confirm all changes
"--no-pretty", # Plain output for telegram
"--no-show-model-warnings", # Skip env var warnings
"--no-analytics", # No analytics prompts
"--edit-format", "diff", # Better format for small models
"--message", task, # The task as a message
]
if target_file:
cmd.append(target_file)
return cmd
async def list_workspace_files(self) -> str:
"""List files in the aider workspace"""
try:
files = list(self.workdir.rglob("*"))
if not files:
return f"📁 Workspace {self.workdir} ist leer."
file_list = "\n".join([
f" {'📁' if f.is_dir() else '📄'} {f.relative_to(self.workdir)}"
for f in sorted(files)[:50]
])
return f"📁 Workspace: {self.workdir}\n\n{file_list}"
except Exception as e:
return f"❌ Fehler beim Lesen des Workspace: {e}"
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver – Audit Trail (DSGVO Art. 5 + 32)
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0
Writes JSON-line entries for every privileged action.
Pseudonymized: stores only numeric Telegram user_ids, never usernames.
Compliance:
- Art. 5(1)(a) lawfulness/transparency: every action is recorded.
- Art. 5(1)(c) data minimization: numeric IDs only, command/result trimmed.
- Art. 5(1)(e) storage limitation: prune_old_logs() drops entries older
than AUDIT_RETENTION_DAYS (default 90).
- Art. 32 integrity: append-only file, 0600 permissions.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# How much of a command/result we keep – longer payloads are truncated
# to honour data minimization. Configurable via env.
_MAX_DETAIL_LEN = int(os.getenv("AUDIT_MAX_DETAIL_LEN", "2000"))
# Lock for append writes – multiple coroutines may log concurrently.
_write_lock = threading.Lock()
def _default_audit_path() -> str:
install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
return os.getenv("AUDIT_LOG_PATH", os.path.join(install_dir, "audit.jsonl"))
def _retention_days() -> int:
try:
return max(0, int(os.getenv("AUDIT_RETENTION_DAYS", "90")))
except ValueError:
return 90
def _trim(value: Any) -> Any:
if isinstance(value, str) and len(value) > _MAX_DETAIL_LEN:
return value[:_MAX_DETAIL_LEN] + f"... (truncated, {len(value)} chars total)"
return value
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def log_action(
user_id: str | int | None,
action: str,
*,
role: str | None = None,
result: str = "ok",
details: dict[str, Any] | None = None,
path: str | None = None,
) -> None:
"""Append a single audit record. Never raises – audit failure must not
break the bot."""
entry: dict[str, Any] = {
"ts": _now_iso(),
"user_id": str(user_id) if user_id is not None else None,
"action": action,
"result": result,
}
if role:
entry["role"] = role
if details:
entry["details"] = {k: _trim(v) for k, v in details.items()}
target = path or _default_audit_path()
try:
Path(target).parent.mkdir(parents=True, exist_ok=True)
line = json.dumps(entry, ensure_ascii=False) + "\n"
with _write_lock:
with open(target, "a", encoding="utf-8") as fh:
fh.write(line)
try:
os.chmod(target, 0o600)
except OSError:
# chmod can fail on non-POSIX FS (e.g. tests on Windows) –
# the JSONL content is still written.
pass
except Exception as e:
logger.error(f"Audit log write failed: {e}")
def log_shell(user_id, command: str, *, role: str | None = None,
status: str = "executed", returncode: int | None = None,
stderr: str | None = None, path: str | None = None) -> None:
details: dict[str, Any] = {"command": command}
if returncode is not None:
details["returncode"] = returncode
if stderr:
details["stderr"] = stderr
log_action(user_id, "shell.execute", role=role, result=status,
details=details, path=path)
def log_shell_proposed(user_id, description: str, command: str,
role: str | None = None, dangerous: bool = False,
path: str | None = None) -> None:
log_action(
user_id,
"shell.proposed",
role=role,
result="dangerous" if dangerous else "ok",
details={"description": description, "command": command},
path=path,
)
def log_code(user_id, task: str, *, role: str | None = None,
status: str = "ok", path: str | None = None) -> None:
log_action(user_id, "code.run", role=role, result=status,
details={"task": task}, path=path)
def log_approval(user_id, command: str, *, role: str | None = None,
approved: bool = True, path: str | None = None) -> None:
log_action(
user_id,
"shell.approval",
role=role,
result="approved" if approved else "rejected",
details={"command": command},
path=path,
)
def log_denied(user_id, action: str, *, role: str | None = None,
reason: str = "insufficient_role",
path: str | None = None) -> None:
log_action(user_id, action, role=role, result="denied",
details={"reason": reason}, path=path)
def prune_old_logs(path: str | None = None,
retention_days: int | None = None) -> int:
"""Rewrite the audit file, dropping entries older than retention_days.
Returns the number of dropped lines. retention_days=0 disables pruning.
Uses an atomic temp-file + rename so a crash mid-prune cannot corrupt
the audit trail.
"""
target = path or _default_audit_path()
days = retention_days if retention_days is not None else _retention_days()
if days <= 0 or not os.path.exists(target):
return 0
cutoff = time.time() - days * 86400
tmp_path = target + ".prune.tmp"
dropped = 0
kept = 0
try:
with _write_lock:
with open(target, "r", encoding="utf-8") as src, \
open(tmp_path, "w", encoding="utf-8") as dst:
for line in src:
raw = line.strip()
if not raw:
continue
try:
record = json.loads(raw)
ts = record.get("ts", "")
# ISO 8601 – parse with fromisoformat (UTC suffix safe)
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
if dt.timestamp() < cutoff:
dropped += 1
continue
except (ValueError, KeyError):
# Malformed line: keep it rather than silently dropping
# forensic evidence.
pass
dst.write(line if line.endswith("\n") else line + "\n")
kept += 1
os.replace(tmp_path, target)
try:
os.chmod(target, 0o600)
except OSError:
pass
logger.info(f"Audit prune: kept={kept} dropped={dropped} retention={days}d")
except Exception as e:
logger.error(f"Audit prune failed: {e}")
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except OSError:
pass
return 0
return dropped
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver – Audit Trail Query (Phase 2b)
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0
Read-side companion to audit_log.py. Filters/sorts JSONL audit records for
the `/logs` admin command.
The query layer is intentionally separate from the writer so that:
- Tests can exercise filtering without touching real audit data.
- The writer stays append-only and minimal.
- Future query backends (SQLite, OpenSearch) can swap in without touching
the writer.
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime, timezone, timedelta
from typing import Any, Iterable
logger = logging.getLogger(__name__)
def _default_audit_path() -> str:
install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
return os.getenv("AUDIT_LOG_PATH", os.path.join(install_dir, "audit.jsonl"))
def _parse_ts(record: dict) -> datetime | None:
"""Best-effort parse of the record's ts field. Returns None on
malformed timestamps (caller decides how to handle)."""
ts = record.get("ts")
if not isinstance(ts, str):
return None
try:
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
except ValueError:
return None
def read_all(path: str | None = None) -> list[dict]:
"""Load every audit record from the JSONL file. Malformed lines are
skipped with a debug log (the writer keeps them for forensic purposes,
but the query layer prefers a clean result set)."""
target = path or _default_audit_path()
if not os.path.exists(target):
return []
out: list[dict] = []
try:
with open(target, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
logger.debug(f"audit_query: skipping malformed line")
continue
except OSError as e:
logger.error(f"audit_query: cannot read {target}: {e}")
return []
return out
def filter_records(
records: Iterable[dict],
*,
user_id: str | int | None = None,
action: str | None = None,
action_prefix: str | None = None,
result: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
) -> list[dict]:
"""Apply filters and return matching records, newest first.
Filters are AND-combined. None values are ignored. action/result match
exactly; action_prefix matches if the record's action starts with it.
"""
target_user = str(user_id) if user_id is not None else None
matched: list[dict] = []
for rec in records:
if target_user is not None and rec.get("user_id") != target_user:
continue
if action is not None and rec.get("action") != action:
continue
if action_prefix is not None:
rec_action = rec.get("action", "")
if not isinstance(rec_action, str) or not rec_action.startswith(action_prefix):
continue
if result is not None and rec.get("result") != result:
continue
if since is not None or until is not None:
dt = _parse_ts(rec)
if dt is None:
# Malformed ts – exclude when caller asked for a date window.
continue
if since is not None and dt < since:
continue
if until is not None and dt > until:
continue
matched.append(rec)
# Newest first – stable sort by ts (records without ts go to the bottom).
matched.sort(key=lambda r: r.get("ts", ""), reverse=True)
return matched
def query(
*,
user_id: str | int | None = None,
action: str | None = None,
action_prefix: str | None = None,
result: str | None = None,
days: int | None = None,
limit: int = 20,
path: str | None = None,
) -> list[dict]:
"""Convenience wrapper: read + filter in one call.
`days` is a shortcut for `since = now - days`. limit clamps the
returned record count (use limit=0 for no limit)."""
since = None
if days is not None and days > 0:
since = datetime.now(timezone.utc) - timedelta(days=days)
records = read_all(path=path)
matched = filter_records(
records,
user_id=user_id,
action=action,
action_prefix=action_prefix,
result=result,
since=since,
)
if limit and limit > 0:
matched = matched[:limit]
return matched
def format_record(rec: dict) -> str:
"""Render one audit record as a single compact line for Telegram."""
ts = rec.get("ts", "?")
# Trim ts down to HH:MM:SS or YYYY-MM-DD HH:MM:SS for readability.
if isinstance(ts, str) and len(ts) >= 19:
ts_short = ts[:19].replace("T", " ")
else:
ts_short = str(ts)
uid = rec.get("user_id") or "—"
action = rec.get("action", "?")
result = rec.get("result", "?")
role = rec.get("role")
role_part = f" [{role}]" if role else ""
details = rec.get("details") or {}
detail_str = ""
if isinstance(details, dict):
# Pick the single most-informative key per common action.
for key in ("command", "task", "reason", "snapshot", "method", "tier"):
if key in details:
val = str(details[key])
if len(val) > 80:
val = val[:77] + "..."
detail_str = f" {val}"
break
return f"{ts_short} {uid}{role_part} {action}={result}{detail_str}"
def summarize(records: list[dict]) -> dict[str, Any]:
"""Aggregate counts useful for the /logs header."""
by_action: dict[str, int] = {}
by_user: dict[str, int] = {}
by_result: dict[str, int] = {}
for r in records:
act = r.get("action", "?")
by_action[act] = by_action.get(act, 0) + 1
uid = r.get("user_id") or "—"
by_user[uid] = by_user.get(uid, 0) + 1
res = r.get("result", "?")
by_result[res] = by_result.get(res, 0) + 1
return {"total": len(records), "by_action": by_action,
"by_user": by_user, "by_result": by_result}
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver – Role-Based Access Control
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0
Implements DSGVO Art. 32 access control:
- operator: full execution rights (/shell, /code, /run, /ja, /ssh, /snapshot)
- approver: can approve pending operations (/ja)
- viewer: read-only access (/status, /snapshots, /help)
Backwards compatible: if no role env vars are set but TELEGRAM_CHAT_ID
is configured, that user is auto-granted the operator role.
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
ROLE_OPERATOR = "operator"
ROLE_APPROVER = "approver"
ROLE_VIEWER = "viewer"
VALID_ROLES = (ROLE_OPERATOR, ROLE_APPROVER, ROLE_VIEWER)
# Operators implicitly hold approver + viewer rights.
# Approvers implicitly hold viewer rights.
_ROLE_INHERITANCE: dict[str, tuple[str, ...]] = {
ROLE_OPERATOR: (ROLE_OPERATOR, ROLE_APPROVER, ROLE_VIEWER),
ROLE_APPROVER: (ROLE_APPROVER, ROLE_VIEWER),
ROLE_VIEWER: (ROLE_VIEWER,),
}
def _parse_id_list(raw: str | None) -> set[str]:
if not raw:
return set()
return {part.strip() for part in raw.split(",") if part.strip()}
def _load_role_map() -> dict[str, str]:
"""Read role config from env vars.
Returns {user_id_str: highest_role}. If a user appears in multiple
role lists, the strongest role wins (operator > approver > viewer).
"""
operators = _parse_id_list(os.getenv("CLOWNFISCH_OPERATORS"))
approvers = _parse_id_list(os.getenv("CLOWNFISCH_APPROVERS"))
viewers = _parse_id_list(os.getenv("CLOWNFISCH_VIEWERS"))
role_map: dict[str, str] = {}
for uid in viewers:
role_map[uid] = ROLE_VIEWER
for uid in approvers:
role_map[uid] = ROLE_APPROVER
for uid in operators:
role_map[uid] = ROLE_OPERATOR
# Backwards compatibility: legacy TELEGRAM_CHAT_ID becomes operator
# when no explicit role lists are defined.
if not role_map:
legacy = os.getenv("TELEGRAM_CHAT_ID", "").strip()
if legacy:
role_map[legacy] = ROLE_OPERATOR
logger.info(
"Auth: no role lists configured – granting operator to legacy "
"TELEGRAM_CHAT_ID for backwards compatibility."
)
return role_map
def get_role(user_id: str | int | None) -> str | None:
"""Return the role of a user, or None if unauthorized."""
if user_id is None:
return None
return _load_role_map().get(str(user_id))
def has_role(user_id: str | int | None, *required: str) -> bool:
"""Check if the user satisfies ANY of the required roles.
Inheritance applies: an operator passes a check for approver/viewer.
"""
role = get_role(user_id)
if role is None:
return False
granted = _ROLE_INHERITANCE.get(role, (role,))
return any(r in granted for r in required)
def is_authorized(user_id: str | int | None) -> bool:
"""True if the user has any role at all."""
return get_role(user_id) is not None
def list_users_with_role(role: str) -> list[str]:
"""Return all user_ids that hold (or inherit) the given role."""
if role not in VALID_ROLES:
return []
return [uid for uid, r in _load_role_map().items()
if role in _ROLE_INHERITANCE.get(r, (r,))]
def describe_roles() -> dict[str, list[str]]:
"""Snapshot of current role assignments – useful for /status or audits."""
role_map = _load_role_map()
result: dict[str, list[str]] = {r: [] for r in VALID_ROLES}
for uid, role in role_map.items():
result[role].append(uid)
return result
+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()
+1081
View File
File diff suppressed because it is too large Load Diff
+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}")
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver – LLM Provider Router (Phase 3)
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0
Replaces the previously hard-wired Ollama+OpenRouter fallback logic with a
config-driven primary/fallback chain.
Env vars:
LLM_PRIMARY_PROVIDER = "ollama" | "openrouter" (default: ollama)
LLM_PRIMARY_MODEL = model id for the primary provider
LLM_PRIMARY_MODEL_FAST = optional faster model for chat replies (ollama only)
LLM_PRIMARY_NUM_CTX = context window override (ollama)
LLM_FALLBACK_PROVIDER = "openrouter" | "ollama" | "" ("" disables fallback)
LLM_FALLBACK_MODEL = model id for fallback provider
TRACK_LLM_COSTS = "true"|"false" – currently informational only
Backwards compatibility:
- If LLM_*_PROVIDER vars are absent we still default to Ollama primary and,
when OPENROUTER_API_KEY exists, OpenRouter as fallback. Existing
OLLAMA_MODEL / OLLAMA_MODEL_FAST / OLLAMA_NUM_CTX / OPENROUTER_API_KEY
env vars are honoured.
Routing policy (intentionally explicit, not "self-improving"):
- Primary is tried first. Only routable errors (network, timeout, provider
unavailable) trigger fallback. Hard errors (invalid JSON, malformed prompt,
bad API key) propagate so callers see them.
- Every routing decision is written to the audit trail as `llm.route` so
admins can see which model produced which output (DSGVO Art. 5(1)(a)
transparency).
"""
from __future__ import annotations
import logging
import os
from typing import Any, Awaitable, Callable, Optional
from ollama_client import OllamaClient
from openrouter_client import OpenRouterClient
try: # audit is optional – router must work even if audit module is missing
import audit_log
except ImportError: # pragma: no cover
audit_log = None # type: ignore
logger = logging.getLogger(__name__)
PROVIDER_OLLAMA = "ollama"
PROVIDER_OPENROUTER = "openrouter"
# Error substrings that should trigger a fallback rather than propagate.
# Kept narrow on purpose – we don't want to mask programmer errors.
_ROUTABLE_ERROR_FRAGMENTS = (
"Timeout",
"nicht erreichbar",
"ConnectionError",
"Connection refused",
"API Key nicht konfiguriert",
)
def _is_routable_error(exc: BaseException) -> bool:
msg = str(exc)
return any(frag in msg for frag in _ROUTABLE_ERROR_FRAGMENTS)
class LLMProvider:
"""Thin interface every provider satisfies."""
name: str = "base"
model: str = ""
def is_available(self) -> bool:
raise NotImplementedError
async def chat(self, message: str, history: list | None = None) -> str:
raise NotImplementedError
async def generate_shell_command(self, description: str) -> dict:
raise NotImplementedError
async def explain_output(self, command: str, output: str,
error: str = "") -> str:
raise NotImplementedError
class OllamaProvider(LLMProvider):
name = PROVIDER_OLLAMA
def __init__(self, model: Optional[str] = None,
model_fast: Optional[str] = None,
num_ctx: Optional[int] = None,
base_url: Optional[str] = None):
# Pass openrouter_client=None so the underlying client does NOT
# perform its own fallback – the router owns that decision now.
self.client = OllamaClient(
openrouter_client=None,
model=model,
model_fast=model_fast,
num_ctx=num_ctx,
base_url=base_url,
)
self.model = self.client.model
def is_available(self) -> bool:
# Connection health is checked at call time; we always advertise
# ourselves as available so the router will attempt and trigger
# fallback on actual failure.
return True
async def chat(self, message, history=None):
return await self.client.chat(message, history)
async def generate_shell_command(self, description):
return await self.client.generate_shell_command(description)
async def explain_output(self, command, output, error=""):
return await self.client.explain_output(command, output, error)
class OpenRouterProvider(LLMProvider):
name = PROVIDER_OPENROUTER
def __init__(self, model: Optional[str] = None):
self.client = OpenRouterClient()
# OpenRouterClient reads OPENROUTER_API_KEY at import time. We
# only override the model id, leaving the API key handling alone.
self.model = model or "openrouter/free"
if model:
# Patch the FREE_MODELS dict so chat/coder both use the
# configured model. Limited blast radius – it only affects
# this OpenRouterClient instance, not the module constant.
self._patch_model(model)
def _patch_model(self, model: str) -> None:
# The underlying OpenRouterClient hard-codes FREE_MODELS["coder"]/
# ["chat"] at call sites. Wrap _chat_sync so every call resolves
# to the user-pinned model regardless of what callers pass in.
pinned = model
original_chat_sync = self.client._chat_sync
def wrapper(messages, system=None, model=None):
return original_chat_sync(messages, system=system, model=pinned)
self.client._chat_sync = wrapper # type: ignore[assignment]
def is_available(self) -> bool:
return self.client.is_available()
async def chat(self, message, history=None):
return await self.client.chat(message, history)
async def generate_shell_command(self, description):
return await self.client.generate_shell_command(description)
async def explain_output(self, command, output, error=""):
return await self.client.explain_output(command, output, error)
def _build_provider(kind: str) -> Optional[LLMProvider]:
"""Build the provider for kind ∈ {"primary", "fallback"}.
Returns None if the configuration disables this tier (e.g. fallback
requested but no key available).
"""
explicit = os.getenv(f"LLM_{kind.upper()}_PROVIDER", "").strip().lower()
if kind == "primary":
provider_name = explicit or PROVIDER_OLLAMA
else:
# Fallback default: openrouter only if API key is configured.
if explicit:
provider_name = explicit
elif os.getenv("OPENROUTER_API_KEY"):
provider_name = PROVIDER_OPENROUTER
else:
return None
if provider_name in ("", "none", "off", "disabled"):
return None
model = os.getenv(f"LLM_{kind.upper()}_MODEL", "").strip() or None
if provider_name == PROVIDER_OLLAMA:
model_fast = os.getenv(f"LLM_{kind.upper()}_MODEL_FAST", "").strip() or None
num_ctx_raw = os.getenv(f"LLM_{kind.upper()}_NUM_CTX", "").strip()
num_ctx = int(num_ctx_raw) if num_ctx_raw.isdigit() else None
return OllamaProvider(model=model, model_fast=model_fast,
num_ctx=num_ctx)
if provider_name == PROVIDER_OPENROUTER:
provider = OpenRouterProvider(model=model)
# Skip the tier if OpenRouter is configured but key is missing.
if not provider.is_available():
logger.warning(
f"LLM router: {kind} provider 'openrouter' has no API key – disabled"
)
return None
return provider
logger.error(f"LLM router: unknown {kind} provider '{provider_name}'")
return None
_BUILD_FROM_ENV = object() # sentinel: distinguish "not passed" from "None"
class LLMRouter:
"""Drop-in replacement for the old OllamaClient surface.
Exposes the same async methods (`chat`, `generate_shell_command`,
`explain_output`) plus a small status surface (`primary`, `fallback`,
`describe()`).
Pass `primary=None` / `fallback=None` to explicitly disable a tier;
omit the kwarg entirely to build the tier from environment.
"""
def __init__(self, primary=_BUILD_FROM_ENV, fallback=_BUILD_FROM_ENV):
self.primary = _build_provider("primary") if primary is _BUILD_FROM_ENV else primary
self.fallback = _build_provider("fallback") if fallback is _BUILD_FROM_ENV else fallback
if self.primary is None and self.fallback is None:
logger.error("LLMRouter: neither primary nor fallback configured!")
# ---- public describe ------------------------------------------------
def describe(self) -> dict:
def _info(p: LLMProvider | None) -> dict | None:
if p is None:
return None
return {"provider": p.name, "model": p.model,
"available": p.is_available()}
return {"primary": _info(self.primary), "fallback": _info(self.fallback)}
# ---- dispatcher -----------------------------------------------------
async def _dispatch(self, method_name: str, *args, **kwargs) -> Any:
attempts: list[tuple[str, LLMProvider]] = []
if self.primary is not None:
attempts.append(("primary", self.primary))
if self.fallback is not None:
attempts.append(("fallback", self.fallback))
if not attempts:
raise RuntimeError(
"Kein LLM-Provider konfiguriert (LLM_PRIMARY_PROVIDER fehlt)"
)
last_error: Optional[BaseException] = None
for tier, provider in attempts:
if not provider.is_available():
self._audit("skip", tier, provider, method_name,
reason="provider_unavailable")
continue
try:
result = await getattr(provider, method_name)(*args, **kwargs)
self._audit("hit", tier, provider, method_name)
return result
except Exception as e:
last_error = e
routable = _is_routable_error(e)
self._audit("error", tier, provider, method_name,
reason=str(e)[:200],
routable=routable)
if not routable:
# Hard error – don't mask it with a fallback.
raise
# Otherwise loop to next tier.
continue
# All tiers exhausted.
raise RuntimeError(
f"Alle LLM-Provider fehlgeschlagen für {method_name}: {last_error}"
)
def _audit(self, status: str, tier: str, provider: LLMProvider,
method: str, **extra) -> None:
if audit_log is None:
return
try:
details = {"tier": tier, "provider": provider.name,
"model": provider.model, "method": method}
details.update(extra)
audit_log.log_action(None, "llm.route", result=status,
details=details)
except Exception as e: # pragma: no cover – audit must never fail
logger.debug(f"audit emit failed: {e}")
# ---- public surface -------------------------------------------------
async def chat(self, message: str, history: list | None = None) -> str:
return await self._dispatch("chat", message, history)
async def generate_shell_command(self, description: str) -> dict:
return await self._dispatch("generate_shell_command", description)
async def explain_output(self, command: str, output: str,
error: str = "") -> str:
return await self._dispatch("explain_output", command, output, error)
# ---- convenience for the warmup path in bot.py ----------------------
@property
def ollama_models(self) -> list[str]:
"""Return distinct Ollama model ids configured across tiers –
used by the warmup thread in bot.py."""
models: list[str] = []
for p in (self.primary, self.fallback):
if isinstance(p, OllamaProvider):
if p.client.model and p.client.model not in models:
models.append(p.client.model)
if (p.client.model_fast
and p.client.model_fast not in models):
models.append(p.client.model_fast)
return models
@property
def ollama_base_url(self) -> str:
for p in (self.primary, self.fallback):
if isinstance(p, OllamaProvider):
return p.client.base_url
return os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
def has_fallback(self) -> bool:
return self.fallback is not None and self.fallback.is_available()
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
"""
import json
import logging
import os
import re
import asyncio
import requests
from typing import Optional
logger = logging.getLogger(__name__)
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b")
OLLAMA_MODEL_FAST = os.getenv("OLLAMA_MODEL_FAST", "")
OLLAMA_NUM_CTX = int(os.getenv("OLLAMA_NUM_CTX", "4096"))
SHELL_PROMPT_TEMPLATE = """You are a Linux bash command generator for an Ubuntu 24.04 server.
Convert the user request into a single non-interactive bash command.
Respond with ONLY a JSON object.
CRITICAL RULES:
- Commands run WITHOUT a terminal. No interactive commands like nano, vim, crontab -e, less, top.
- Use non-interactive alternatives: echo/tee for files, /etc/cron.d/ for cron jobs, sed for edits.
- Use sudo when root permissions are needed.
- Chain commands with && if needed.
Example request: "show free ram"
Example response: {{"dangerous":false,"reason":"","command":"free -h"}}
Example request: "installiere nginx"
Example response: {{"dangerous":false,"reason":"","command":"sudo apt install -y nginx"}}
Example request: "erstelle einen cronjob der alle 5 minuten ein script ausfuehrt"
Example response: {{"dangerous":false,"reason":"","command":"echo '*/5 * * * * root /path/to/script.sh' | sudo tee /etc/cron.d/myjob"}}
Example request: "schreibe hello world in eine datei"
Example response: {{"dangerous":false,"reason":"","command":"echo 'hello world' | sudo tee /tmp/hello.txt"}}
Example request: "starte nginx neu"
Example response: {{"dangerous":false,"reason":"","command":"sudo systemctl restart nginx"}}
Example request: "lösche alles in /etc"
Example response: {{"dangerous":true,"reason":"Würde Systemkonfiguration zerstören","command":"rm -rf /etc/*"}}
Now generate JSON for this request: "{description}"
IMPORTANT: Respond with ONLY the JSON object. No other text. No markdown."""
CHAT_SYSTEM_PROMPT = """Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.
Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt.
Wenn du ein Befehlsergebnis erklärst, sei präzise und verständlich."""
def _extract_json(raw: str) -> dict:
"""Extract JSON from Ollama response, handling markdown wrappers and stray text."""
text = raw.strip()
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
raise json.JSONDecodeError("No valid JSON found", text, 0)
class OllamaClient:
def __init__(self, openrouter_client=None, model: Optional[str] = None,
model_fast: Optional[str] = None,
num_ctx: Optional[int] = None,
base_url: Optional[str] = None):
self.base_url = base_url or OLLAMA_BASE_URL
self.model = model or OLLAMA_MODEL
self.model_fast = model_fast or OLLAMA_MODEL_FAST or self.model
self.num_ctx = num_ctx if num_ctx is not None else OLLAMA_NUM_CTX
self.openrouter = openrouter_client
def _chat_sync(self, messages: list, system: Optional[str] = None, think: Optional[bool] = None, use_fast: bool = False) -> str:
"""Synchronous Ollama API call"""
model = self.model_fast if use_fast else self.model
payload = {
"model": model,
"messages": messages,
"stream": False,
"keep_alive": -1,
"options": {"num_ctx": self.num_ctx},
}
if system:
payload["system"] = system
if think is not None:
payload["think"] = think
try:
response = requests.post(
f"{self.base_url}/api/chat",
json=payload,
timeout=180,
)
response.raise_for_status()
data = response.json()
return data.get("message", {}).get("content", "")
except requests.exceptions.ConnectionError:
raise RuntimeError("Ollama nicht erreichbar – läuft der Service? (systemctl status ollama)")
except requests.exceptions.Timeout:
raise RuntimeError("Ollama Timeout – Modell lädt möglicherweise noch.")
except Exception as e:
raise RuntimeError(f"Ollama Fehler: {e}")
async def generate_shell_command(self, description: str) -> dict:
"""Generate a shell command from natural language description.
Tries Ollama first, falls back to OpenRouter on timeout."""
loop = asyncio.get_running_loop()
raw = ""
# 1. Try local Ollama
try:
prompt = SHELL_PROMPT_TEMPLATE.format(description=description)
raw = await loop.run_in_executor(
None,
lambda: self._chat_sync(
messages=[{"role": "user", "content": prompt}],
think=False,
use_fast=False,
),
)
result = _extract_json(raw)
if "command" not in result or not result["command"].strip():
logger.warning(f"Shell generate: no command in result: {result}")
return {"dangerous": False, "reason": "", "command": ""}
if "dangerous" not in result:
result["dangerous"] = False
logger.info(f"Shell generate (Ollama): {result}")
return result
except json.JSONDecodeError as e:
logger.warning(f"Shell generate JSON error: {e}, raw: {raw[:500]}")
return {"dangerous": False, "reason": "", "command": ""}
except RuntimeError as e:
if "Timeout" not in str(e) and "nicht erreichbar" not in str(e):
raise
logger.warning("Ollama nicht verfügbar für /shell – trying OpenRouter fallback")
# 2. Fallback to OpenRouter
if self.openrouter and self.openrouter.is_available():
try:
logger.info("Using OpenRouter fallback for /shell...")
result = await self.openrouter.generate_shell_command(description)
logger.info(f"Shell generate (OpenRouter): {result}")
return result
except Exception as e:
logger.error(f"OpenRouter fallback failed: {e}")
return {
"dangerous": False, "reason": "",
"command": "",
"_error": "Ollama Timeout und kein OpenRouter Fallback verfügbar.",
}
async def explain_output(self, command: str, output: str, error: str = "") -> str:
"""Ask Ollama to explain/summarize command output"""
loop = asyncio.get_running_loop()
content = f"Command: {command}\nOutput:\n{output}"
if error:
content += f"\nErrors:\n{error}"
content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer."
try:
return await loop.run_in_executor(
None,
lambda: self._chat_sync(
messages=[{"role": "user", "content": content}],
system=CHAT_SYSTEM_PROMPT,
think=False,
use_fast=True,
),
)
except Exception as e:
logger.error(f"explain_output failed: {e}")
# Try OpenRouter fallback
if self.openrouter and self.openrouter.is_available():
try:
return await self.openrouter.explain_output(command, output, error)
except Exception:
pass
return ""
async def chat(self, message: str, history: list = None) -> str:
"""General chat with context. Tries Ollama, falls back to OpenRouter."""
loop = asyncio.get_running_loop()
messages = list(history) if history else []
messages.append({"role": "user", "content": message})
# 1. Try local Ollama
try:
return await loop.run_in_executor(
None,
lambda: self._chat_sync(
messages=messages,
system=CHAT_SYSTEM_PROMPT,
use_fast=True,
),
)
except RuntimeError as e:
if "Timeout" not in str(e) and "nicht erreichbar" not in str(e):
return f"Fehler: {e}"
logger.warning("Ollama chat nicht verfügbar – trying OpenRouter")
# 2. Fallback to OpenRouter
if self.openrouter and self.openrouter.is_available():
try:
logger.info("Using OpenRouter for chat...")
return await self.openrouter.chat(message, history)
except Exception as e:
return f"Ollama und OpenRouter nicht verfügbar: {e}"
return (
"Ollama nicht verfügbar und kein OpenRouter Fallback konfiguriert.\n"
"Versuche es später oder konfiguriere OpenRouter in .env"
)
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""
🐠 Clownfischserver – OpenRouter Free Models Fallback
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
License: GPL-3.0 – Keep it open. Always.
Repo: https://github.com/mehlzoerwer-claude/clownfischserver
"""
import json
import logging
import os
import re
import asyncio
import requests
from typing import Optional
logger = logging.getLogger(__name__)
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# Free models on OpenRouter (no credit card needed)
# openrouter/free auto-selects the best available free model
FREE_MODELS = {
"coder": "openrouter/free",
"chat": "openrouter/free",
}
def _extract_json(raw: str) -> dict:
"""Extract JSON from response."""
text = raw.strip()
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
raise json.JSONDecodeError("No valid JSON found", text, 0)
class OpenRouterClient:
"""OpenRouter Free Models Client – fallback when Ollama times out."""
def __init__(self):
# Read at construction time, not import time, so env changes between
# boot and config reloads (or test monkeypatching) take effect.
self.api_key = os.getenv("OPENROUTER_API_KEY", "") or OPENROUTER_API_KEY
self.base_url = OPENROUTER_BASE_URL
if self.api_key:
logger.info("OpenRouter Fallback konfiguriert")
else:
logger.info("OpenRouter nicht konfiguriert – Fallback deaktiviert")
def is_available(self) -> bool:
return bool(self.api_key)
def _chat_sync(self, messages: list, system: Optional[str] = None, model: str = None) -> str:
"""Synchronous OpenRouter API call."""
if not self.api_key:
raise RuntimeError("OpenRouter API Key nicht konfiguriert")
model = model or FREE_MODELS["chat"]
api_messages = []
if system:
api_messages.append({"role": "system", "content": system})
api_messages.extend(messages)
try:
response = requests.post(
f"{self.base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": api_messages,
"temperature": 0.7,
},
timeout=60,
)
response.raise_for_status()
data = response.json()
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
except requests.exceptions.ConnectionError:
raise RuntimeError("OpenRouter nicht erreichbar")
except requests.exceptions.Timeout:
raise RuntimeError("OpenRouter Timeout")
except Exception as e:
raise RuntimeError(f"OpenRouter Fehler: {e}")
async def generate_shell_command(self, description: str) -> dict:
"""Generate shell command via OpenRouter."""
loop = asyncio.get_running_loop()
system = "Server: Ubuntu 24.04, Clownfischserver setup."
prompt = (
"You are a Linux bash command generator for Ubuntu 24.04.\n"
"Convert the request into a single non-interactive bash command.\n"
"Respond ONLY with JSON: {\"dangerous\": bool, \"reason\": string, \"command\": string}\n"
"No interactive commands (nano, vim, crontab -e). Use sudo when needed.\n\n"
f"Request: {description}\n\nJSON only:"
)
try:
raw = await loop.run_in_executor(
None,
lambda: self._chat_sync(
messages=[{"role": "user", "content": prompt}],
system=system,
model=FREE_MODELS["coder"],
),
)
result = _extract_json(raw)
if "command" not in result:
result["command"] = ""
if "dangerous" not in result:
result["dangerous"] = False
logger.info(f"OpenRouter shell: {result}")
return result
except json.JSONDecodeError as e:
logger.warning(f"OpenRouter JSON error: {e}")
return {"dangerous": False, "reason": "", "command": ""}
except Exception as e:
logger.error(f"OpenRouter shell failed: {e}")
raise
async def chat(self, message: str, history: list = None) -> str:
"""General chat via OpenRouter."""
loop = asyncio.get_running_loop()
messages = list(history) if history else []
messages.append({"role": "user", "content": message})
system = (
"Du bist der Clownfischserver KI-Assistent. Du hilfst beim Verwalten eines Linux-Servers.\n"
"Antworte kurz und technisch. Antworte IMMER in der Sprache in der der Nutzer schreibt."
)
try:
return await loop.run_in_executor(
None,
lambda: self._chat_sync(
messages=messages,
system=system,
model=FREE_MODELS["chat"],
),
)
except Exception as e:
return f"OpenRouter Fehler: {e}"
async def explain_output(self, command: str, output: str, error: str = "") -> str:
"""Explain command output via OpenRouter."""
loop = asyncio.get_running_loop()
content = f"Command: {command}\nOutput:\n{output}"
if error:
content += f"\nErrors:\n{error}"
content += "\n\nErkläre kurz was passiert ist und ob es Probleme gibt. Antworte in der gleichen Sprache wie der Nutzer."
try:
return await loop.run_in_executor(
None,
lambda: self._chat_sync(
messages=[{"role": "user", "content": content}],
model=FREE_MODELS["chat"],
),
)
except Exception as e:
logger.error(f"OpenRouter explain failed: {e}")
return ""
+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}")