Initial commit with CI workflow
All checks were successful
Build Container / build (push) Successful in 1m18s
All checks were successful
Build Container / build (push) Successful in 1m18s
- Flask backend with SSH discovery and Claude AI integration - React/Vite frontend with Tailwind CSS - Docker multi-stage build - Gitea Actions workflow for container builds 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
444
backend/app.py
Normal file
444
backend/app.py
Normal file
@@ -0,0 +1,444 @@
|
||||
import os
|
||||
import threading
|
||||
from flask import Flask, jsonify, request, send_from_directory
|
||||
from flask_cors import CORS
|
||||
from flask_socketio import SocketIO, emit
|
||||
|
||||
from config import get_config, set_dev_mode
|
||||
|
||||
# Path to frontend build
|
||||
FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist")
|
||||
from services.ssh_manager import get_ssh_manager
|
||||
from services.discovery import get_discovery_service, DiscoveryResult
|
||||
from services.claude_agent import create_agent, AgentResponse
|
||||
from services.monitors import (
|
||||
get_monitor_service,
|
||||
parse_web_ports_from_scan,
|
||||
parse_docker_containers_from_scan,
|
||||
)
|
||||
from services.kuma_client import get_kuma_client
|
||||
from utils.approval import get_approval_queue, ApprovalStatus
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app, origins=["http://localhost:5173", "http://localhost:3000"])
|
||||
socketio = SocketIO(app, cors_allowed_origins="*", async_mode="gevent")
|
||||
|
||||
# Store for active scans and their Claude agents
|
||||
active_scans: dict[str, dict] = {}
|
||||
|
||||
|
||||
# Setup approval queue callbacks for WebSocket notifications
|
||||
def on_approval_added(request):
|
||||
socketio.emit("approval_request", request.to_dict())
|
||||
|
||||
|
||||
def on_approval_resolved(request):
|
||||
socketio.emit("approval_resolved", request.to_dict())
|
||||
|
||||
|
||||
approval_queue = get_approval_queue()
|
||||
approval_queue.set_callbacks(on_approval_added, on_approval_resolved)
|
||||
|
||||
|
||||
# Serve frontend static files
|
||||
@app.route("/", defaults={"path": ""})
|
||||
@app.route("/<path:path>")
|
||||
def serve_frontend(path):
|
||||
if path and os.path.exists(os.path.join(FRONTEND_DIR, path)):
|
||||
return send_from_directory(FRONTEND_DIR, path)
|
||||
return send_from_directory(FRONTEND_DIR, "index.html")
|
||||
|
||||
|
||||
# Health check
|
||||
@app.route("/api/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# Settings endpoints
|
||||
@app.route("/api/settings", methods=["GET"])
|
||||
def get_settings():
|
||||
config = get_config()
|
||||
return jsonify({
|
||||
"dev_mode": config.dev_mode,
|
||||
"uptime_kuma_url": config.uptime_kuma_url,
|
||||
"has_ssh_key": bool(config.ssh_private_key),
|
||||
"has_claude_key": bool(config.claude_api_key),
|
||||
"has_kuma_key": bool(config.uptime_kuma_api_key),
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/settings", methods=["PUT"])
|
||||
def update_settings():
|
||||
data = request.json
|
||||
if "dev_mode" in data:
|
||||
set_dev_mode(data["dev_mode"])
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# Scan endpoints
|
||||
@app.route("/api/scan", methods=["POST"])
|
||||
def start_scan():
|
||||
data = request.json
|
||||
hostname = data.get("hostname")
|
||||
username = data.get("username", "root")
|
||||
port = data.get("port", 22)
|
||||
|
||||
if not hostname:
|
||||
return jsonify({"error": "hostname is required"}), 400
|
||||
|
||||
# Start scan in background thread
|
||||
def run_scan():
|
||||
discovery = get_discovery_service()
|
||||
|
||||
def on_progress(cmd_name, status):
|
||||
socketio.emit("scan_progress", {
|
||||
"hostname": hostname,
|
||||
"command": cmd_name,
|
||||
"status": status,
|
||||
})
|
||||
|
||||
result = discovery.scan_host(hostname, username, port, on_progress)
|
||||
active_scans[result.scan_id] = {
|
||||
"result": result,
|
||||
"agent": None,
|
||||
"suggestions": None,
|
||||
}
|
||||
|
||||
socketio.emit("scan_complete", {
|
||||
"scan_id": result.scan_id,
|
||||
"hostname": hostname,
|
||||
"connected": result.connected,
|
||||
"error": result.error,
|
||||
})
|
||||
|
||||
# If scan succeeded, analyze with Claude
|
||||
if result.connected:
|
||||
analyze_with_claude(result)
|
||||
|
||||
thread = threading.Thread(target=run_scan)
|
||||
thread.start()
|
||||
|
||||
return jsonify({"status": "started", "hostname": hostname})
|
||||
|
||||
|
||||
def analyze_with_claude(result: DiscoveryResult):
|
||||
"""Analyze scan results with Claude agent."""
|
||||
socketio.emit("analysis_started", {"scan_id": result.scan_id})
|
||||
|
||||
try:
|
||||
agent = create_agent()
|
||||
response = agent.analyze_host(result.to_dict(), result.hostname)
|
||||
|
||||
active_scans[result.scan_id]["agent"] = agent
|
||||
active_scans[result.scan_id]["suggestions"] = response
|
||||
|
||||
socketio.emit("analysis_complete", {
|
||||
"scan_id": result.scan_id,
|
||||
"analysis": response.analysis,
|
||||
"monitors": [
|
||||
{
|
||||
"type": m.type,
|
||||
"name": m.name,
|
||||
"target": m.target,
|
||||
"port": m.port,
|
||||
"interval": m.interval,
|
||||
"reason": m.reason,
|
||||
}
|
||||
for m in response.monitors
|
||||
],
|
||||
"additional_commands": [
|
||||
{"command": c.command, "reason": c.reason}
|
||||
for c in response.additional_commands
|
||||
],
|
||||
"questions": response.questions,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
socketio.emit("analysis_error", {
|
||||
"scan_id": result.scan_id,
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/scan/<scan_id>", methods=["GET"])
|
||||
def get_scan(scan_id):
|
||||
scan_data = active_scans.get(scan_id)
|
||||
if not scan_data:
|
||||
return jsonify({"error": "Scan not found"}), 404
|
||||
|
||||
result = scan_data["result"]
|
||||
suggestions = scan_data.get("suggestions")
|
||||
|
||||
response_data = {
|
||||
"scan_id": result.scan_id,
|
||||
"hostname": result.hostname,
|
||||
"connected": result.connected,
|
||||
"error": result.error,
|
||||
"data": result.to_dict(),
|
||||
}
|
||||
|
||||
if suggestions:
|
||||
response_data["suggestions"] = {
|
||||
"analysis": suggestions.analysis,
|
||||
"monitors": [
|
||||
{
|
||||
"type": m.type,
|
||||
"name": m.name,
|
||||
"target": m.target,
|
||||
"port": m.port,
|
||||
"interval": m.interval,
|
||||
"reason": m.reason,
|
||||
}
|
||||
for m in suggestions.monitors
|
||||
],
|
||||
"additional_commands": [
|
||||
{"command": c.command, "reason": c.reason}
|
||||
for c in suggestions.additional_commands
|
||||
],
|
||||
"questions": suggestions.questions,
|
||||
}
|
||||
|
||||
return jsonify(response_data)
|
||||
|
||||
|
||||
# Execute additional command (requires approval in dev mode)
|
||||
@app.route("/api/scan/<scan_id>/command", methods=["POST"])
|
||||
def run_additional_command(scan_id):
|
||||
scan_data = active_scans.get(scan_id)
|
||||
if not scan_data:
|
||||
return jsonify({"error": "Scan not found"}), 404
|
||||
|
||||
data = request.json
|
||||
command = data.get("command")
|
||||
reason = data.get("reason", "User requested")
|
||||
|
||||
if not command:
|
||||
return jsonify({"error": "command is required"}), 400
|
||||
|
||||
result = scan_data["result"]
|
||||
config = get_config()
|
||||
discovery = get_discovery_service()
|
||||
|
||||
# Check if command is safe (built-in)
|
||||
if discovery.is_safe_command(command):
|
||||
# Execute immediately
|
||||
cmd_result = discovery.run_additional_command(
|
||||
result.hostname, command, result.username, result.port
|
||||
)
|
||||
return jsonify({
|
||||
"status": "completed",
|
||||
"output": cmd_result.stdout,
|
||||
"error": cmd_result.stderr,
|
||||
"exit_code": cmd_result.exit_code,
|
||||
})
|
||||
|
||||
# In dev mode, require approval
|
||||
if config.dev_mode:
|
||||
approval_request = approval_queue.add_ssh_command(
|
||||
command, reason, result.hostname
|
||||
)
|
||||
return jsonify({
|
||||
"status": "pending_approval",
|
||||
"approval_id": approval_request.id,
|
||||
"message": "Command requires approval in dev mode",
|
||||
})
|
||||
|
||||
# In production mode, block non-safe commands from Claude
|
||||
return jsonify({
|
||||
"status": "blocked",
|
||||
"message": "This command is not in the safe list and dev mode is disabled",
|
||||
}), 403
|
||||
|
||||
|
||||
# Approval endpoints
|
||||
@app.route("/api/approvals", methods=["GET"])
|
||||
def get_pending_approvals():
|
||||
pending = approval_queue.get_pending()
|
||||
return jsonify({
|
||||
"approvals": [r.to_dict() for r in pending],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/approvals/<approval_id>/approve", methods=["POST"])
|
||||
def approve_request(approval_id):
|
||||
request_obj = approval_queue.approve(approval_id)
|
||||
if not request_obj:
|
||||
return jsonify({"error": "Approval not found or already resolved"}), 404
|
||||
|
||||
# If it was an SSH command, execute it now
|
||||
if request_obj.type.value == "ssh_command":
|
||||
hostname = request_obj.details["hostname"]
|
||||
command = request_obj.details["command"]
|
||||
|
||||
# Find the scan for this hostname
|
||||
for scan_id, scan_data in active_scans.items():
|
||||
if scan_data["result"].hostname == hostname:
|
||||
discovery = get_discovery_service()
|
||||
result = scan_data["result"]
|
||||
cmd_result = discovery.run_additional_command(
|
||||
hostname, command, result.username, result.port
|
||||
)
|
||||
|
||||
# Send result via WebSocket
|
||||
socketio.emit("command_result", {
|
||||
"approval_id": approval_id,
|
||||
"command": command,
|
||||
"output": cmd_result.stdout,
|
||||
"error": cmd_result.stderr,
|
||||
"exit_code": cmd_result.exit_code,
|
||||
})
|
||||
|
||||
# Feed result back to Claude if agent exists
|
||||
agent = scan_data.get("agent")
|
||||
if agent:
|
||||
output = cmd_result.stdout if cmd_result.success else cmd_result.stderr
|
||||
response = agent.process_command_results(command, output)
|
||||
scan_data["suggestions"] = response
|
||||
|
||||
socketio.emit("analysis_update", {
|
||||
"scan_id": scan_id,
|
||||
"analysis": response.analysis,
|
||||
"monitors": [
|
||||
{
|
||||
"type": m.type,
|
||||
"name": m.name,
|
||||
"target": m.target,
|
||||
"port": m.port,
|
||||
"interval": m.interval,
|
||||
"reason": m.reason,
|
||||
}
|
||||
for m in response.monitors
|
||||
],
|
||||
"additional_commands": [
|
||||
{"command": c.command, "reason": c.reason}
|
||||
for c in response.additional_commands
|
||||
],
|
||||
"questions": response.questions,
|
||||
})
|
||||
|
||||
break
|
||||
|
||||
return jsonify({"status": "approved", "request": request_obj.to_dict()})
|
||||
|
||||
|
||||
@app.route("/api/approvals/<approval_id>/reject", methods=["POST"])
|
||||
def reject_request(approval_id):
|
||||
request_obj = approval_queue.reject(approval_id)
|
||||
if not request_obj:
|
||||
return jsonify({"error": "Approval not found or already resolved"}), 404
|
||||
|
||||
return jsonify({"status": "rejected", "request": request_obj.to_dict()})
|
||||
|
||||
|
||||
# Monitor endpoints
|
||||
@app.route("/api/monitors", methods=["GET"])
|
||||
def get_monitors():
|
||||
try:
|
||||
kuma = get_kuma_client()
|
||||
monitors = kuma.get_monitors()
|
||||
return jsonify({"monitors": monitors})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/monitors/create-defaults", methods=["POST"])
|
||||
def create_default_monitors():
|
||||
data = request.json
|
||||
scan_id = data.get("scan_id")
|
||||
|
||||
if not scan_id:
|
||||
return jsonify({"error": "scan_id is required"}), 400
|
||||
|
||||
scan_data = active_scans.get(scan_id)
|
||||
if not scan_data:
|
||||
return jsonify({"error": "Scan not found"}), 404
|
||||
|
||||
result = scan_data["result"]
|
||||
monitor_service = get_monitor_service()
|
||||
|
||||
# Parse discovered services
|
||||
web_ports = parse_web_ports_from_scan(result.open_ports)
|
||||
containers = parse_docker_containers_from_scan(result.docker_containers)
|
||||
has_docker = "Docker not available" not in result.docker_containers
|
||||
|
||||
created = monitor_service.create_default_monitors(
|
||||
hostname=result.hostname,
|
||||
ssh_port=result.port,
|
||||
has_docker=has_docker,
|
||||
containers=containers,
|
||||
web_ports=web_ports,
|
||||
)
|
||||
|
||||
return jsonify({"created": created})
|
||||
|
||||
|
||||
@app.route("/api/monitors/create-suggested", methods=["POST"])
|
||||
def create_suggested_monitors():
|
||||
data = request.json
|
||||
scan_id = data.get("scan_id")
|
||||
monitor_indices = data.get("monitors", []) # List of indices to create
|
||||
|
||||
if not scan_id:
|
||||
return jsonify({"error": "scan_id is required"}), 400
|
||||
|
||||
scan_data = active_scans.get(scan_id)
|
||||
if not scan_data:
|
||||
return jsonify({"error": "Scan not found"}), 404
|
||||
|
||||
suggestions = scan_data.get("suggestions")
|
||||
if not suggestions:
|
||||
return jsonify({"error": "No suggestions available"}), 400
|
||||
|
||||
result = scan_data["result"]
|
||||
monitor_service = get_monitor_service()
|
||||
created = []
|
||||
|
||||
for idx in monitor_indices:
|
||||
if 0 <= idx < len(suggestions.monitors):
|
||||
suggestion = suggestions.monitors[idx]
|
||||
monitor_result = monitor_service.create_from_suggestion(
|
||||
suggestion, result.hostname
|
||||
)
|
||||
created.append(monitor_result)
|
||||
|
||||
return jsonify({"created": created})
|
||||
|
||||
|
||||
# Test Uptime Kuma connection
|
||||
@app.route("/api/kuma/test", methods=["GET"])
|
||||
def test_kuma_connection():
|
||||
try:
|
||||
kuma = get_kuma_client()
|
||||
connected = kuma.test_connection()
|
||||
return jsonify({"connected": connected})
|
||||
except Exception as e:
|
||||
return jsonify({"connected": False, "error": str(e)})
|
||||
|
||||
|
||||
# WebSocket events
|
||||
@socketio.on("connect")
|
||||
def handle_connect():
|
||||
emit("connected", {"status": "ok"})
|
||||
|
||||
|
||||
@socketio.on("disconnect")
|
||||
def handle_disconnect():
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Validate config on startup
|
||||
config = get_config()
|
||||
errors = config.validate()
|
||||
if errors:
|
||||
print("Configuration errors:")
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
print("\nSet the required environment variables and restart.")
|
||||
else:
|
||||
print("Configuration OK")
|
||||
print(f"Dev mode: {'enabled' if config.dev_mode else 'disabled'}")
|
||||
|
||||
socketio.run(app, host="0.0.0.0", port=5000, debug=os.environ.get("DEBUG", "false").lower() == "true")
|
||||
65
backend/config.py
Normal file
65
backend/config.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import os
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Application configuration loaded from environment variables."""
|
||||
|
||||
ssh_private_key: str
|
||||
uptime_kuma_url: str
|
||||
uptime_kuma_api_key: str
|
||||
claude_api_key: str
|
||||
dev_mode: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Config":
|
||||
"""Load configuration from environment variables."""
|
||||
ssh_key_b64 = os.environ.get("SSH_PRIVATE_KEY", "")
|
||||
|
||||
# Decode base64 SSH key
|
||||
try:
|
||||
ssh_private_key = base64.b64decode(ssh_key_b64).decode("utf-8") if ssh_key_b64 else ""
|
||||
except Exception:
|
||||
ssh_private_key = ssh_key_b64 # Allow plain text for development
|
||||
|
||||
return cls(
|
||||
ssh_private_key=ssh_private_key,
|
||||
uptime_kuma_url=os.environ.get("UPTIME_KUMA_URL", "http://localhost:3001"),
|
||||
uptime_kuma_api_key=os.environ.get("UPTIME_KUMA_API_KEY", ""),
|
||||
claude_api_key=os.environ.get("CLAUDE_API_KEY", ""),
|
||||
dev_mode=os.environ.get("DEV_MODE", "false").lower() == "true",
|
||||
)
|
||||
|
||||
def validate(self) -> list[str]:
|
||||
"""Validate configuration and return list of errors."""
|
||||
errors = []
|
||||
if not self.ssh_private_key:
|
||||
errors.append("SSH_PRIVATE_KEY is required")
|
||||
if not self.uptime_kuma_url:
|
||||
errors.append("UPTIME_KUMA_URL is required")
|
||||
if not self.uptime_kuma_api_key:
|
||||
errors.append("UPTIME_KUMA_API_KEY is required")
|
||||
if not self.claude_api_key:
|
||||
errors.append("CLAUDE_API_KEY is required")
|
||||
return errors
|
||||
|
||||
|
||||
# Global config instance
|
||||
_config: Optional[Config] = None
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
"""Get the global configuration instance."""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = Config.from_env()
|
||||
return _config
|
||||
|
||||
|
||||
def set_dev_mode(enabled: bool) -> None:
|
||||
"""Update dev mode setting."""
|
||||
config = get_config()
|
||||
config.dev_mode = enabled
|
||||
9
backend/requirements.txt
Normal file
9
backend/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
flask==3.0.0
|
||||
flask-cors==4.0.0
|
||||
flask-socketio==5.3.6
|
||||
paramiko==3.4.0
|
||||
anthropic==0.39.0
|
||||
requests==2.31.0
|
||||
python-dotenv==1.0.0
|
||||
gevent==23.9.1
|
||||
gevent-websocket==0.10.1
|
||||
1
backend/services/__init__.py
Normal file
1
backend/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
273
backend/services/claude_agent.py
Normal file
273
backend/services/claude_agent.py
Normal file
@@ -0,0 +1,273 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
from anthropic import Anthropic
|
||||
|
||||
from config import get_config
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are an intelligent monitoring configuration assistant for Uptime Kuma. Your role is to analyze system information from hosts and recommend what should be monitored.
|
||||
|
||||
## Your Capabilities
|
||||
1. Analyze host scan results (OS info, running services, Docker containers, open ports)
|
||||
2. Suggest monitors to create in Uptime Kuma
|
||||
3. Request additional SSH commands to gather more information when needed
|
||||
4. Explain your monitoring recommendations
|
||||
|
||||
## Rules for Suggestions
|
||||
1. **Always explain WHY** you want to monitor something - what failure would it detect?
|
||||
2. **Be specific** with monitor configurations (ports, paths, intervals)
|
||||
3. **Prioritize critical services** - databases, web servers, auth services come first
|
||||
4. **Suggest appropriate intervals** based on criticality:
|
||||
- Critical services (databases, auth): 30-60 seconds
|
||||
- Web services: 60-120 seconds
|
||||
- Background jobs: 300 seconds
|
||||
5. **Look for health endpoints** - prefer /health, /healthz, /status over root paths
|
||||
6. **Consider dependencies** - if a service depends on another, both should be monitored
|
||||
|
||||
## Rules for SSH Commands
|
||||
When you need more information, you can request SSH commands. Follow these rules:
|
||||
1. **Read-only only** - never suggest commands that modify the system
|
||||
2. **Be specific** - explain exactly what information you need and why
|
||||
3. **Safe commands only** - no sudo unless absolutely necessary for reading
|
||||
4. **Examples of acceptable commands:**
|
||||
- `curl -s localhost:8080/health` - check if a service responds
|
||||
- `cat /etc/nginx/nginx.conf` - read configuration
|
||||
- `docker inspect <container>` - get container details
|
||||
- `systemctl status <service>` - check service status
|
||||
|
||||
## Response Format
|
||||
Always respond with valid JSON in this structure:
|
||||
{
|
||||
"analysis": "Your analysis of what you found on the host",
|
||||
"monitors": [
|
||||
{
|
||||
"type": "http|tcp|ping|docker|keyword",
|
||||
"name": "Human-readable monitor name",
|
||||
"target": "URL, hostname, or container name",
|
||||
"port": 80,
|
||||
"interval": 60,
|
||||
"reason": "Why this should be monitored"
|
||||
}
|
||||
],
|
||||
"additional_commands": [
|
||||
{
|
||||
"command": "the SSH command to run",
|
||||
"reason": "why you need this information"
|
||||
}
|
||||
],
|
||||
"questions": ["Any questions for the user about what to monitor"]
|
||||
}
|
||||
|
||||
## Monitor Types
|
||||
- **http**: Web endpoints (provide full URL with protocol)
|
||||
- **tcp**: Port connectivity (provide hostname and port)
|
||||
- **ping**: Host availability (provide hostname)
|
||||
- **docker**: Docker container status (provide container name)
|
||||
- **keyword**: Check for specific text in response (provide URL and keyword)
|
||||
|
||||
Be thorough but not excessive. Quality over quantity - suggest monitors that will actually catch real problems."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonitorSuggestion:
|
||||
"""A suggested monitor configuration."""
|
||||
|
||||
type: str
|
||||
name: str
|
||||
target: str
|
||||
port: Optional[int] = None
|
||||
interval: int = 60
|
||||
reason: str = ""
|
||||
keyword: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandRequest:
|
||||
"""A request to run an SSH command."""
|
||||
|
||||
command: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentResponse:
|
||||
"""Response from the Claude agent."""
|
||||
|
||||
analysis: str
|
||||
monitors: list[MonitorSuggestion]
|
||||
additional_commands: list[CommandRequest]
|
||||
questions: list[str]
|
||||
raw_response: str
|
||||
|
||||
|
||||
class ClaudeAgent:
|
||||
"""Claude AI agent for intelligent monitoring suggestions."""
|
||||
|
||||
def __init__(self):
|
||||
config = get_config()
|
||||
self.client = Anthropic(api_key=config.claude_api_key)
|
||||
self.conversation_history: list[dict] = []
|
||||
|
||||
def analyze_host(self, scan_results: dict, hostname: str) -> AgentResponse:
|
||||
"""Analyze host scan results and suggest monitors."""
|
||||
user_message = f"""I've scanned the host '{hostname}' and gathered the following information:
|
||||
|
||||
## System Information
|
||||
```
|
||||
{scan_results.get('system_info', 'Not available')}
|
||||
```
|
||||
|
||||
## OS Release
|
||||
```
|
||||
{scan_results.get('os_release', 'Not available')}
|
||||
```
|
||||
|
||||
## Running Docker Containers
|
||||
```
|
||||
{scan_results.get('docker_containers', 'No Docker or no containers running')}
|
||||
```
|
||||
|
||||
## Running Systemd Services
|
||||
```
|
||||
{scan_results.get('systemd_services', 'Not available')}
|
||||
```
|
||||
|
||||
## Disk Usage
|
||||
```
|
||||
{scan_results.get('disk_usage', 'Not available')}
|
||||
```
|
||||
|
||||
## Memory Usage
|
||||
```
|
||||
{scan_results.get('memory_usage', 'Not available')}
|
||||
```
|
||||
|
||||
## CPU Info
|
||||
```
|
||||
{scan_results.get('cpu_count', 'Not available')} CPU cores
|
||||
```
|
||||
|
||||
## Open Ports (Listening)
|
||||
```
|
||||
{scan_results.get('open_ports', 'Not available')}
|
||||
```
|
||||
|
||||
Please analyze this information and suggest what should be monitored in Uptime Kuma.
|
||||
Respond with JSON as specified in your instructions."""
|
||||
|
||||
return self._send_message(user_message)
|
||||
|
||||
def process_command_results(self, command: str, result: str) -> AgentResponse:
|
||||
"""Process the results of an additional SSH command."""
|
||||
user_message = f"""Here are the results of the command you requested:
|
||||
|
||||
Command: `{command}`
|
||||
|
||||
Output:
|
||||
```
|
||||
{result}
|
||||
```
|
||||
|
||||
Please update your analysis and suggestions based on this new information.
|
||||
Respond with JSON as specified in your instructions."""
|
||||
|
||||
return self._send_message(user_message)
|
||||
|
||||
def answer_question(self, question: str, answer: str) -> AgentResponse:
|
||||
"""Process user's answer to a question."""
|
||||
user_message = f"""You asked: "{question}"
|
||||
|
||||
The user responded: "{answer}"
|
||||
|
||||
Please update your recommendations based on this information.
|
||||
Respond with JSON as specified in your instructions."""
|
||||
|
||||
return self._send_message(user_message)
|
||||
|
||||
def _send_message(self, user_message: str) -> AgentResponse:
|
||||
"""Send a message to Claude and parse the response."""
|
||||
self.conversation_history.append({"role": "user", "content": user_message})
|
||||
|
||||
response = self.client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=4096,
|
||||
system=SYSTEM_PROMPT,
|
||||
messages=self.conversation_history,
|
||||
)
|
||||
|
||||
assistant_message = response.content[0].text
|
||||
self.conversation_history.append({"role": "assistant", "content": assistant_message})
|
||||
|
||||
return self._parse_response(assistant_message)
|
||||
|
||||
def _parse_response(self, response_text: str) -> AgentResponse:
|
||||
"""Parse Claude's JSON response."""
|
||||
# Try to extract JSON from the response
|
||||
try:
|
||||
# Look for JSON block in the response
|
||||
json_start = response_text.find("{")
|
||||
json_end = response_text.rfind("}") + 1
|
||||
|
||||
if json_start != -1 and json_end > json_start:
|
||||
json_str = response_text[json_start:json_end]
|
||||
data = json.loads(json_str)
|
||||
else:
|
||||
# No JSON found, return empty response
|
||||
return AgentResponse(
|
||||
analysis=response_text,
|
||||
monitors=[],
|
||||
additional_commands=[],
|
||||
questions=[],
|
||||
raw_response=response_text,
|
||||
)
|
||||
|
||||
monitors = []
|
||||
for m in data.get("monitors", []):
|
||||
monitors.append(
|
||||
MonitorSuggestion(
|
||||
type=m.get("type", "http"),
|
||||
name=m.get("name", "Unknown"),
|
||||
target=m.get("target", ""),
|
||||
port=m.get("port"),
|
||||
interval=m.get("interval", 60),
|
||||
reason=m.get("reason", ""),
|
||||
keyword=m.get("keyword"),
|
||||
)
|
||||
)
|
||||
|
||||
commands = []
|
||||
for c in data.get("additional_commands", []):
|
||||
commands.append(
|
||||
CommandRequest(
|
||||
command=c.get("command", ""),
|
||||
reason=c.get("reason", ""),
|
||||
)
|
||||
)
|
||||
|
||||
return AgentResponse(
|
||||
analysis=data.get("analysis", ""),
|
||||
monitors=monitors,
|
||||
additional_commands=commands,
|
||||
questions=data.get("questions", []),
|
||||
raw_response=response_text,
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return AgentResponse(
|
||||
analysis=response_text,
|
||||
monitors=[],
|
||||
additional_commands=[],
|
||||
questions=[],
|
||||
raw_response=response_text,
|
||||
)
|
||||
|
||||
def reset_conversation(self) -> None:
|
||||
"""Reset the conversation history."""
|
||||
self.conversation_history = []
|
||||
|
||||
|
||||
def create_agent() -> ClaudeAgent:
|
||||
"""Create a new Claude agent instance."""
|
||||
return ClaudeAgent()
|
||||
205
backend/services/discovery.py
Normal file
205
backend/services/discovery.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Callable
|
||||
import uuid
|
||||
|
||||
from services.ssh_manager import get_ssh_manager, CommandResult
|
||||
|
||||
|
||||
# Built-in safe commands that never require approval
|
||||
SAFE_DISCOVERY_COMMANDS = {
|
||||
"system_info": "uname -a",
|
||||
"os_release": "cat /etc/os-release 2>/dev/null || cat /etc/*-release 2>/dev/null | head -20",
|
||||
"docker_containers": "docker ps --format '{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.Status}}\\t{{.Ports}}' 2>/dev/null || echo 'Docker not available'",
|
||||
"systemd_services": "systemctl list-units --type=service --state=running --no-pager 2>/dev/null | head -50 || echo 'Systemd not available'",
|
||||
"disk_usage": "df -h 2>/dev/null | head -20",
|
||||
"memory_usage": "free -h 2>/dev/null || cat /proc/meminfo 2>/dev/null | head -10",
|
||||
"cpu_count": "nproc 2>/dev/null || grep -c processor /proc/cpuinfo 2>/dev/null || echo 'Unknown'",
|
||||
"open_ports": "ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null | head -30 || echo 'Unable to list ports'",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveryResult:
|
||||
"""Results from a host discovery scan."""
|
||||
|
||||
scan_id: str
|
||||
hostname: str
|
||||
username: str
|
||||
port: int
|
||||
connected: bool
|
||||
error: Optional[str] = None
|
||||
system_info: str = ""
|
||||
os_release: str = ""
|
||||
docker_containers: str = ""
|
||||
systemd_services: str = ""
|
||||
disk_usage: str = ""
|
||||
memory_usage: str = ""
|
||||
cpu_count: str = ""
|
||||
open_ports: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"scan_id": self.scan_id,
|
||||
"hostname": self.hostname,
|
||||
"username": self.username,
|
||||
"port": self.port,
|
||||
"connected": self.connected,
|
||||
"error": self.error,
|
||||
"system_info": self.system_info,
|
||||
"os_release": self.os_release,
|
||||
"docker_containers": self.docker_containers,
|
||||
"systemd_services": self.systemd_services,
|
||||
"disk_usage": self.disk_usage,
|
||||
"memory_usage": self.memory_usage,
|
||||
"cpu_count": self.cpu_count,
|
||||
"open_ports": self.open_ports,
|
||||
}
|
||||
|
||||
|
||||
class DiscoveryService:
|
||||
"""Service for discovering services on remote hosts."""
|
||||
|
||||
def __init__(self):
|
||||
self.active_scans: dict[str, DiscoveryResult] = {}
|
||||
|
||||
def is_safe_command(self, command: str) -> bool:
|
||||
"""Check if a command is in the safe built-in list."""
|
||||
# Check exact matches
|
||||
if command in SAFE_DISCOVERY_COMMANDS.values():
|
||||
return True
|
||||
|
||||
# Check if it's a safe read-only command pattern
|
||||
safe_patterns = [
|
||||
"cat ",
|
||||
"head ",
|
||||
"tail ",
|
||||
"grep ",
|
||||
"ls ",
|
||||
"ps ",
|
||||
"df ",
|
||||
"free ",
|
||||
"uname ",
|
||||
"uptime",
|
||||
"hostname",
|
||||
"whoami",
|
||||
"id ",
|
||||
"docker ps",
|
||||
"docker inspect",
|
||||
"docker logs",
|
||||
"systemctl status",
|
||||
"systemctl list-",
|
||||
"journalctl",
|
||||
"ss ",
|
||||
"netstat ",
|
||||
"curl -s",
|
||||
"wget -q",
|
||||
"nproc",
|
||||
]
|
||||
|
||||
command_lower = command.lower().strip()
|
||||
for pattern in safe_patterns:
|
||||
if command_lower.startswith(pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def scan_host(
|
||||
self,
|
||||
hostname: str,
|
||||
username: str = "root",
|
||||
port: int = 22,
|
||||
on_progress: Optional[Callable[[str, str], None]] = None,
|
||||
) -> DiscoveryResult:
|
||||
"""
|
||||
Scan a host for services and system information.
|
||||
|
||||
Args:
|
||||
hostname: Target hostname or IP
|
||||
username: SSH username
|
||||
port: SSH port
|
||||
on_progress: Callback for progress updates (command_name, status)
|
||||
|
||||
Returns:
|
||||
DiscoveryResult with all gathered information
|
||||
"""
|
||||
scan_id = str(uuid.uuid4())
|
||||
result = DiscoveryResult(
|
||||
scan_id=scan_id,
|
||||
hostname=hostname,
|
||||
username=username,
|
||||
port=port,
|
||||
connected=False,
|
||||
)
|
||||
self.active_scans[scan_id] = result
|
||||
|
||||
ssh = get_ssh_manager()
|
||||
|
||||
# Connect to host
|
||||
if on_progress:
|
||||
on_progress("connect", "connecting")
|
||||
|
||||
try:
|
||||
ssh.connect(hostname, username, port)
|
||||
result.connected = True
|
||||
if on_progress:
|
||||
on_progress("connect", "connected")
|
||||
except Exception as e:
|
||||
result.error = str(e)
|
||||
if on_progress:
|
||||
on_progress("connect", f"failed: {str(e)}")
|
||||
return result
|
||||
|
||||
# Run discovery commands
|
||||
for cmd_name, command in SAFE_DISCOVERY_COMMANDS.items():
|
||||
if on_progress:
|
||||
on_progress(cmd_name, "running")
|
||||
|
||||
try:
|
||||
cmd_result = ssh.execute(hostname, command, username, port, timeout=30)
|
||||
output = cmd_result.stdout if cmd_result.success else cmd_result.stderr
|
||||
setattr(result, cmd_name, output.strip())
|
||||
|
||||
if on_progress:
|
||||
on_progress(cmd_name, "complete" if cmd_result.success else "failed")
|
||||
except Exception as e:
|
||||
setattr(result, cmd_name, f"Error: {str(e)}")
|
||||
if on_progress:
|
||||
on_progress(cmd_name, f"error: {str(e)}")
|
||||
|
||||
return result
|
||||
|
||||
def run_additional_command(
|
||||
self,
|
||||
hostname: str,
|
||||
command: str,
|
||||
username: str = "root",
|
||||
port: int = 22,
|
||||
) -> CommandResult:
|
||||
"""
|
||||
Run an additional command on a host.
|
||||
|
||||
This should only be called after approval if in dev mode.
|
||||
"""
|
||||
ssh = get_ssh_manager()
|
||||
|
||||
if not ssh.is_connected(hostname, username, port):
|
||||
ssh.connect(hostname, username, port)
|
||||
|
||||
return ssh.execute(hostname, command, username, port, timeout=60)
|
||||
|
||||
def get_scan(self, scan_id: str) -> Optional[DiscoveryResult]:
|
||||
"""Get a scan result by ID."""
|
||||
return self.active_scans.get(scan_id)
|
||||
|
||||
|
||||
# Global discovery service instance
|
||||
_discovery_service: Optional[DiscoveryService] = None
|
||||
|
||||
|
||||
def get_discovery_service() -> DiscoveryService:
|
||||
"""Get the global discovery service instance."""
|
||||
global _discovery_service
|
||||
if _discovery_service is None:
|
||||
_discovery_service = DiscoveryService()
|
||||
return _discovery_service
|
||||
174
backend/services/kuma_client.py
Normal file
174
backend/services/kuma_client.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, asdict
|
||||
import requests
|
||||
|
||||
from config import get_config
|
||||
|
||||
|
||||
@dataclass
|
||||
class Monitor:
|
||||
"""Uptime Kuma monitor configuration."""
|
||||
|
||||
type: str # http, tcp, ping, docker, keyword
|
||||
name: str
|
||||
url: Optional[str] = None # For HTTP monitors
|
||||
hostname: Optional[str] = None # For TCP/Ping monitors
|
||||
port: Optional[int] = None # For TCP monitors
|
||||
interval: int = 60
|
||||
keyword: Optional[str] = None # For keyword monitors
|
||||
docker_container: Optional[str] = None # For Docker monitors
|
||||
docker_host: Optional[str] = None # For Docker monitors
|
||||
retries: int = 3
|
||||
retry_interval: int = 60
|
||||
max_redirects: int = 10
|
||||
accepted_statuscodes: list[str] = None
|
||||
notification_id_list: Optional[list[int]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.accepted_statuscodes is None:
|
||||
self.accepted_statuscodes = ["200-299"]
|
||||
|
||||
def to_api_format(self) -> dict:
|
||||
"""Convert to Uptime Kuma API format."""
|
||||
# Map our types to Kuma's type values
|
||||
type_map = {
|
||||
"http": "http",
|
||||
"tcp": "port",
|
||||
"ping": "ping",
|
||||
"docker": "docker",
|
||||
"keyword": "keyword",
|
||||
}
|
||||
|
||||
data = {
|
||||
"type": type_map.get(self.type, self.type),
|
||||
"name": self.name,
|
||||
"interval": self.interval,
|
||||
"retries": self.retries,
|
||||
"retryInterval": self.retry_interval,
|
||||
"maxredirects": self.max_redirects,
|
||||
"accepted_statuscodes": self.accepted_statuscodes,
|
||||
}
|
||||
|
||||
if self.url:
|
||||
data["url"] = self.url
|
||||
if self.hostname:
|
||||
data["hostname"] = self.hostname
|
||||
if self.port:
|
||||
data["port"] = self.port
|
||||
if self.keyword:
|
||||
data["keyword"] = self.keyword
|
||||
if self.docker_container:
|
||||
data["docker_container"] = self.docker_container
|
||||
if self.docker_host:
|
||||
data["docker_host"] = self.docker_host
|
||||
if self.notification_id_list:
|
||||
data["notificationIDList"] = self.notification_id_list
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class UptimeKumaClient:
|
||||
"""Client for Uptime Kuma REST API."""
|
||||
|
||||
def __init__(self):
|
||||
config = get_config()
|
||||
self.base_url = config.uptime_kuma_url.rstrip("/")
|
||||
self.api_key = config.uptime_kuma_api_key
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
|
||||
def _request(self, method: str, endpoint: str, **kwargs) -> dict:
|
||||
"""Make an API request."""
|
||||
url = f"{self.base_url}/api{endpoint}"
|
||||
response = self.session.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response.json() if response.content else {}
|
||||
|
||||
def get_monitors(self) -> list[dict]:
|
||||
"""Get all monitors."""
|
||||
try:
|
||||
result = self._request("GET", "/monitors")
|
||||
return result.get("monitors", [])
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to get monitors: {str(e)}")
|
||||
|
||||
def get_monitor(self, monitor_id: int) -> dict:
|
||||
"""Get a specific monitor."""
|
||||
try:
|
||||
result = self._request("GET", f"/monitors/{monitor_id}")
|
||||
return result.get("monitor", {})
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to get monitor {monitor_id}: {str(e)}")
|
||||
|
||||
def create_monitor(self, monitor: Monitor) -> dict:
|
||||
"""Create a new monitor."""
|
||||
try:
|
||||
data = monitor.to_api_format()
|
||||
result = self._request("POST", "/monitors", json=data)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to create monitor: {str(e)}")
|
||||
|
||||
def update_monitor(self, monitor_id: int, monitor: Monitor) -> dict:
|
||||
"""Update an existing monitor."""
|
||||
try:
|
||||
data = monitor.to_api_format()
|
||||
result = self._request("PUT", f"/monitors/{monitor_id}", json=data)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to update monitor {monitor_id}: {str(e)}")
|
||||
|
||||
def delete_monitor(self, monitor_id: int) -> dict:
|
||||
"""Delete a monitor."""
|
||||
try:
|
||||
result = self._request("DELETE", f"/monitors/{monitor_id}")
|
||||
return result
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to delete monitor {monitor_id}: {str(e)}")
|
||||
|
||||
def pause_monitor(self, monitor_id: int) -> dict:
|
||||
"""Pause a monitor."""
|
||||
try:
|
||||
result = self._request("POST", f"/monitors/{monitor_id}/pause")
|
||||
return result
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to pause monitor {monitor_id}: {str(e)}")
|
||||
|
||||
def resume_monitor(self, monitor_id: int) -> dict:
|
||||
"""Resume a paused monitor."""
|
||||
try:
|
||||
result = self._request("POST", f"/monitors/{monitor_id}/resume")
|
||||
return result
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to resume monitor {monitor_id}: {str(e)}")
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get Uptime Kuma status/info."""
|
||||
try:
|
||||
result = self._request("GET", "/status-page")
|
||||
return result
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to get status: {str(e)}")
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""Test connection to Uptime Kuma."""
|
||||
try:
|
||||
self._request("GET", "/monitors")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Global client instance
|
||||
_kuma_client: Optional[UptimeKumaClient] = None
|
||||
|
||||
|
||||
def get_kuma_client() -> UptimeKumaClient:
|
||||
"""Get the global Uptime Kuma client instance."""
|
||||
global _kuma_client
|
||||
if _kuma_client is None:
|
||||
_kuma_client = UptimeKumaClient()
|
||||
return _kuma_client
|
||||
249
backend/services/monitors.py
Normal file
249
backend/services/monitors.py
Normal file
@@ -0,0 +1,249 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from services.kuma_client import get_kuma_client, Monitor
|
||||
from services.claude_agent import MonitorSuggestion
|
||||
|
||||
|
||||
@dataclass
|
||||
class DefaultMonitorProfile:
|
||||
"""A default monitoring profile that doesn't require approval."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
monitors: list[Monitor]
|
||||
|
||||
|
||||
def create_host_health_monitors(hostname: str, ssh_port: int = 22) -> list[Monitor]:
|
||||
"""Create default host health monitors."""
|
||||
return [
|
||||
Monitor(
|
||||
type="ping",
|
||||
name=f"{hostname} - Ping",
|
||||
hostname=hostname,
|
||||
interval=60,
|
||||
),
|
||||
Monitor(
|
||||
type="tcp",
|
||||
name=f"{hostname} - SSH",
|
||||
hostname=hostname,
|
||||
port=ssh_port,
|
||||
interval=120,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def create_web_server_monitors(hostname: str, port: int = 80, https: bool = False) -> list[Monitor]:
|
||||
"""Create monitors for a detected web server."""
|
||||
protocol = "https" if https else "http"
|
||||
return [
|
||||
Monitor(
|
||||
type="http",
|
||||
name=f"{hostname} - Web ({port})",
|
||||
url=f"{protocol}://{hostname}:{port}/",
|
||||
interval=60,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def create_docker_container_monitors(hostname: str, containers: list[dict]) -> list[Monitor]:
|
||||
"""Create monitors for detected Docker containers."""
|
||||
monitors = []
|
||||
for container in containers:
|
||||
name = container.get("name", container.get("id", "unknown"))
|
||||
monitors.append(
|
||||
Monitor(
|
||||
type="docker",
|
||||
name=f"{hostname} - Container: {name}",
|
||||
docker_container=name,
|
||||
docker_host=hostname,
|
||||
interval=60,
|
||||
)
|
||||
)
|
||||
return monitors
|
||||
|
||||
|
||||
class MonitorService:
|
||||
"""Service for managing monitors in Uptime Kuma."""
|
||||
|
||||
def __init__(self):
|
||||
self.created_monitors: list[dict] = []
|
||||
|
||||
def create_default_monitors(
|
||||
self,
|
||||
hostname: str,
|
||||
ssh_port: int = 22,
|
||||
has_docker: bool = False,
|
||||
containers: Optional[list[dict]] = None,
|
||||
web_ports: Optional[list[int]] = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Create default monitors for a host.
|
||||
These are built-in and never require approval.
|
||||
"""
|
||||
kuma = get_kuma_client()
|
||||
created = []
|
||||
|
||||
# Host health monitors
|
||||
health_monitors = create_host_health_monitors(hostname, ssh_port)
|
||||
for monitor in health_monitors:
|
||||
try:
|
||||
result = kuma.create_monitor(monitor)
|
||||
created.append({
|
||||
"monitor": monitor.name,
|
||||
"type": monitor.type,
|
||||
"status": "created",
|
||||
"result": result,
|
||||
})
|
||||
except Exception as e:
|
||||
created.append({
|
||||
"monitor": monitor.name,
|
||||
"type": monitor.type,
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
# Web server monitors
|
||||
if web_ports:
|
||||
for port in web_ports:
|
||||
https = port == 443 or port == 8443
|
||||
web_monitors = create_web_server_monitors(hostname, port, https)
|
||||
for monitor in web_monitors:
|
||||
try:
|
||||
result = kuma.create_monitor(monitor)
|
||||
created.append({
|
||||
"monitor": monitor.name,
|
||||
"type": monitor.type,
|
||||
"status": "created",
|
||||
"result": result,
|
||||
})
|
||||
except Exception as e:
|
||||
created.append({
|
||||
"monitor": monitor.name,
|
||||
"type": monitor.type,
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
# Docker container monitors
|
||||
if has_docker and containers:
|
||||
docker_monitors = create_docker_container_monitors(hostname, containers)
|
||||
for monitor in docker_monitors:
|
||||
try:
|
||||
result = kuma.create_monitor(monitor)
|
||||
created.append({
|
||||
"monitor": monitor.name,
|
||||
"type": monitor.type,
|
||||
"status": "created",
|
||||
"result": result,
|
||||
})
|
||||
except Exception as e:
|
||||
created.append({
|
||||
"monitor": monitor.name,
|
||||
"type": monitor.type,
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
self.created_monitors.extend(created)
|
||||
return created
|
||||
|
||||
def create_from_suggestion(self, suggestion: MonitorSuggestion, hostname: str) -> dict:
|
||||
"""
|
||||
Create a monitor from a Claude suggestion.
|
||||
In production mode, this executes automatically.
|
||||
In dev mode, this should only be called after approval.
|
||||
"""
|
||||
kuma = get_kuma_client()
|
||||
|
||||
# Build monitor from suggestion
|
||||
monitor = Monitor(
|
||||
type=suggestion.type,
|
||||
name=suggestion.name,
|
||||
interval=suggestion.interval,
|
||||
)
|
||||
|
||||
# Set type-specific fields
|
||||
if suggestion.type == "http" or suggestion.type == "keyword":
|
||||
monitor.url = suggestion.target
|
||||
if suggestion.keyword:
|
||||
monitor.keyword = suggestion.keyword
|
||||
elif suggestion.type == "tcp":
|
||||
monitor.hostname = suggestion.target
|
||||
monitor.port = suggestion.port
|
||||
elif suggestion.type == "ping":
|
||||
monitor.hostname = suggestion.target
|
||||
elif suggestion.type == "docker":
|
||||
monitor.docker_container = suggestion.target
|
||||
monitor.docker_host = hostname
|
||||
|
||||
try:
|
||||
result = kuma.create_monitor(monitor)
|
||||
return {
|
||||
"monitor": monitor.name,
|
||||
"type": monitor.type,
|
||||
"status": "created",
|
||||
"result": result,
|
||||
"reason": suggestion.reason,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"monitor": monitor.name,
|
||||
"type": monitor.type,
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
"reason": suggestion.reason,
|
||||
}
|
||||
|
||||
def get_existing_monitors(self) -> list[dict]:
|
||||
"""Get all existing monitors from Uptime Kuma."""
|
||||
kuma = get_kuma_client()
|
||||
return kuma.get_monitors()
|
||||
|
||||
|
||||
def parse_web_ports_from_scan(open_ports: str) -> list[int]:
|
||||
"""Extract web server ports from port scan output."""
|
||||
common_web_ports = [80, 443, 8080, 8443, 3000, 5000, 8000]
|
||||
found_ports = []
|
||||
|
||||
for port in common_web_ports:
|
||||
if f":{port}" in open_ports or f" {port} " in open_ports:
|
||||
found_ports.append(port)
|
||||
|
||||
return found_ports
|
||||
|
||||
|
||||
def parse_docker_containers_from_scan(docker_output: str) -> list[dict]:
|
||||
"""Parse Docker container info from scan output."""
|
||||
containers = []
|
||||
|
||||
if "Docker not available" in docker_output or not docker_output.strip():
|
||||
return containers
|
||||
|
||||
for line in docker_output.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 2:
|
||||
containers.append({
|
||||
"id": parts[0] if len(parts) > 0 else "",
|
||||
"name": parts[1] if len(parts) > 1 else "",
|
||||
"image": parts[2] if len(parts) > 2 else "",
|
||||
"status": parts[3] if len(parts) > 3 else "",
|
||||
"ports": parts[4] if len(parts) > 4 else "",
|
||||
})
|
||||
|
||||
return containers
|
||||
|
||||
|
||||
# Global monitor service instance
|
||||
_monitor_service: Optional[MonitorService] = None
|
||||
|
||||
|
||||
def get_monitor_service() -> MonitorService:
|
||||
"""Get the global monitor service instance."""
|
||||
global _monitor_service
|
||||
if _monitor_service is None:
|
||||
_monitor_service = MonitorService()
|
||||
return _monitor_service
|
||||
175
backend/services/ssh_manager.py
Normal file
175
backend/services/ssh_manager.py
Normal file
@@ -0,0 +1,175 @@
|
||||
import io
|
||||
import threading
|
||||
from typing import Optional, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import paramiko
|
||||
|
||||
from config import get_config
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Result of an SSH command execution."""
|
||||
|
||||
stdout: str
|
||||
stderr: str
|
||||
exit_code: int
|
||||
success: bool
|
||||
|
||||
|
||||
class SSHManager:
|
||||
"""Manages SSH connections to target hosts."""
|
||||
|
||||
def __init__(self):
|
||||
self._connections: dict[str, paramiko.SSHClient] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _get_private_key(self) -> paramiko.PKey:
|
||||
"""Parse the private key from config."""
|
||||
config = get_config()
|
||||
key_data = config.ssh_private_key
|
||||
|
||||
# Try different key formats
|
||||
key_file = io.StringIO(key_data)
|
||||
|
||||
for key_class in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
|
||||
try:
|
||||
key_file.seek(0)
|
||||
return key_class.from_private_key(key_file)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raise ValueError("Unable to parse SSH private key. Supported formats: RSA, Ed25519, ECDSA")
|
||||
|
||||
def connect(self, hostname: str, username: str = "root", port: int = 22) -> bool:
|
||||
"""Establish SSH connection to a host."""
|
||||
connection_key = f"{username}@{hostname}:{port}"
|
||||
|
||||
with self._lock:
|
||||
if connection_key in self._connections:
|
||||
# Test if connection is still alive
|
||||
try:
|
||||
transport = self._connections[connection_key].get_transport()
|
||||
if transport and transport.is_active():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
# Remove dead connection
|
||||
self._connections.pop(connection_key, None)
|
||||
|
||||
try:
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
private_key = self._get_private_key()
|
||||
client.connect(
|
||||
hostname=hostname,
|
||||
port=port,
|
||||
username=username,
|
||||
pkey=private_key,
|
||||
timeout=30,
|
||||
allow_agent=False,
|
||||
look_for_keys=False,
|
||||
)
|
||||
self._connections[connection_key] = client
|
||||
return True
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"Failed to connect to {connection_key}: {str(e)}")
|
||||
|
||||
def execute(
|
||||
self,
|
||||
hostname: str,
|
||||
command: str,
|
||||
username: str = "root",
|
||||
port: int = 22,
|
||||
timeout: int = 60,
|
||||
on_output: Optional[Callable[[str], None]] = None,
|
||||
) -> CommandResult:
|
||||
"""Execute a command on a remote host."""
|
||||
connection_key = f"{username}@{hostname}:{port}"
|
||||
|
||||
with self._lock:
|
||||
client = self._connections.get(connection_key)
|
||||
if not client:
|
||||
raise ConnectionError(f"Not connected to {connection_key}. Call connect() first.")
|
||||
|
||||
try:
|
||||
stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
|
||||
|
||||
# Read output
|
||||
stdout_data = ""
|
||||
stderr_data = ""
|
||||
|
||||
# Stream stdout if callback provided
|
||||
if on_output:
|
||||
for line in stdout:
|
||||
stdout_data += line
|
||||
on_output(line.rstrip("\n"))
|
||||
else:
|
||||
stdout_data = stdout.read().decode("utf-8", errors="replace")
|
||||
|
||||
stderr_data = stderr.read().decode("utf-8", errors="replace")
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
|
||||
return CommandResult(
|
||||
stdout=stdout_data,
|
||||
stderr=stderr_data,
|
||||
exit_code=exit_code,
|
||||
success=exit_code == 0,
|
||||
)
|
||||
except Exception as e:
|
||||
return CommandResult(
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
exit_code=-1,
|
||||
success=False,
|
||||
)
|
||||
|
||||
def disconnect(self, hostname: str, username: str = "root", port: int = 22) -> None:
|
||||
"""Close SSH connection to a host."""
|
||||
connection_key = f"{username}@{hostname}:{port}"
|
||||
|
||||
with self._lock:
|
||||
client = self._connections.pop(connection_key, None)
|
||||
if client:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def disconnect_all(self) -> None:
|
||||
"""Close all SSH connections."""
|
||||
with self._lock:
|
||||
for client in self._connections.values():
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._connections.clear()
|
||||
|
||||
def is_connected(self, hostname: str, username: str = "root", port: int = 22) -> bool:
|
||||
"""Check if connected to a host."""
|
||||
connection_key = f"{username}@{hostname}:{port}"
|
||||
|
||||
with self._lock:
|
||||
client = self._connections.get(connection_key)
|
||||
if not client:
|
||||
return False
|
||||
try:
|
||||
transport = client.get_transport()
|
||||
return transport is not None and transport.is_active()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Global SSH manager instance
|
||||
_ssh_manager: Optional[SSHManager] = None
|
||||
|
||||
|
||||
def get_ssh_manager() -> SSHManager:
|
||||
"""Get the global SSH manager instance."""
|
||||
global _ssh_manager
|
||||
if _ssh_manager is None:
|
||||
_ssh_manager = SSHManager()
|
||||
return _ssh_manager
|
||||
1
backend/utils/__init__.py
Normal file
1
backend/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Utils package
|
||||
213
backend/utils/approval.py
Normal file
213
backend/utils/approval.py
Normal file
@@ -0,0 +1,213 @@
|
||||
import uuid
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Callable
|
||||
|
||||
|
||||
class ApprovalStatus(Enum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class ApprovalType(Enum):
|
||||
SSH_COMMAND = "ssh_command"
|
||||
CREATE_MONITOR = "create_monitor"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApprovalRequest:
|
||||
"""A request waiting for user approval."""
|
||||
|
||||
id: str
|
||||
type: ApprovalType
|
||||
description: str
|
||||
details: dict
|
||||
status: ApprovalStatus = ApprovalStatus.PENDING
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
resolved_at: Optional[datetime] = None
|
||||
reason: str = "" # Why Claude wants to do this
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"type": self.type.value,
|
||||
"description": self.description,
|
||||
"details": self.details,
|
||||
"status": self.status.value,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
class ApprovalQueue:
|
||||
"""Queue for managing approval requests in dev mode."""
|
||||
|
||||
def __init__(self):
|
||||
self._requests: dict[str, ApprovalRequest] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._on_request_added: Optional[Callable[[ApprovalRequest], None]] = None
|
||||
self._on_request_resolved: Optional[Callable[[ApprovalRequest], None]] = None
|
||||
|
||||
def set_callbacks(
|
||||
self,
|
||||
on_added: Optional[Callable[[ApprovalRequest], None]] = None,
|
||||
on_resolved: Optional[Callable[[ApprovalRequest], None]] = None,
|
||||
) -> None:
|
||||
"""Set callbacks for queue events."""
|
||||
self._on_request_added = on_added
|
||||
self._on_request_resolved = on_resolved
|
||||
|
||||
def add_ssh_command(self, command: str, reason: str, hostname: str) -> ApprovalRequest:
|
||||
"""Add an SSH command approval request."""
|
||||
request = ApprovalRequest(
|
||||
id=str(uuid.uuid4()),
|
||||
type=ApprovalType.SSH_COMMAND,
|
||||
description=f"Execute SSH command on {hostname}",
|
||||
details={
|
||||
"command": command,
|
||||
"hostname": hostname,
|
||||
},
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
self._requests[request.id] = request
|
||||
|
||||
if self._on_request_added:
|
||||
self._on_request_added(request)
|
||||
|
||||
return request
|
||||
|
||||
def add_monitor_creation(
|
||||
self,
|
||||
monitor_name: str,
|
||||
monitor_type: str,
|
||||
target: str,
|
||||
reason: str,
|
||||
) -> ApprovalRequest:
|
||||
"""Add a monitor creation approval request."""
|
||||
request = ApprovalRequest(
|
||||
id=str(uuid.uuid4()),
|
||||
type=ApprovalType.CREATE_MONITOR,
|
||||
description=f"Create {monitor_type} monitor: {monitor_name}",
|
||||
details={
|
||||
"name": monitor_name,
|
||||
"type": monitor_type,
|
||||
"target": target,
|
||||
},
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
self._requests[request.id] = request
|
||||
|
||||
if self._on_request_added:
|
||||
self._on_request_added(request)
|
||||
|
||||
return request
|
||||
|
||||
def approve(self, request_id: str) -> Optional[ApprovalRequest]:
|
||||
"""Approve a request."""
|
||||
with self._lock:
|
||||
request = self._requests.get(request_id)
|
||||
if not request or request.status != ApprovalStatus.PENDING:
|
||||
return None
|
||||
|
||||
request.status = ApprovalStatus.APPROVED
|
||||
request.resolved_at = datetime.now()
|
||||
|
||||
if self._on_request_resolved:
|
||||
self._on_request_resolved(request)
|
||||
|
||||
return request
|
||||
|
||||
def reject(self, request_id: str) -> Optional[ApprovalRequest]:
|
||||
"""Reject a request."""
|
||||
with self._lock:
|
||||
request = self._requests.get(request_id)
|
||||
if not request or request.status != ApprovalStatus.PENDING:
|
||||
return None
|
||||
|
||||
request.status = ApprovalStatus.REJECTED
|
||||
request.resolved_at = datetime.now()
|
||||
|
||||
if self._on_request_resolved:
|
||||
self._on_request_resolved(request)
|
||||
|
||||
return request
|
||||
|
||||
def get_pending(self) -> list[ApprovalRequest]:
|
||||
"""Get all pending requests."""
|
||||
with self._lock:
|
||||
return [r for r in self._requests.values() if r.status == ApprovalStatus.PENDING]
|
||||
|
||||
def get_request(self, request_id: str) -> Optional[ApprovalRequest]:
|
||||
"""Get a specific request."""
|
||||
with self._lock:
|
||||
return self._requests.get(request_id)
|
||||
|
||||
def clear_resolved(self) -> int:
|
||||
"""Clear all resolved requests. Returns count of cleared requests."""
|
||||
with self._lock:
|
||||
to_remove = [
|
||||
rid for rid, req in self._requests.items()
|
||||
if req.status != ApprovalStatus.PENDING
|
||||
]
|
||||
for rid in to_remove:
|
||||
del self._requests[rid]
|
||||
return len(to_remove)
|
||||
|
||||
def wait_for_approval(
|
||||
self,
|
||||
request_id: str,
|
||||
timeout: float = 300.0,
|
||||
check_interval: float = 0.5,
|
||||
) -> Optional[ApprovalRequest]:
|
||||
"""
|
||||
Wait for a request to be approved or rejected.
|
||||
|
||||
Args:
|
||||
request_id: The request ID to wait for
|
||||
timeout: Maximum time to wait in seconds
|
||||
check_interval: How often to check status
|
||||
|
||||
Returns:
|
||||
The resolved request, or None if timeout
|
||||
"""
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
while time.time() - start < timeout:
|
||||
request = self.get_request(request_id)
|
||||
if not request:
|
||||
return None
|
||||
if request.status != ApprovalStatus.PENDING:
|
||||
return request
|
||||
time.sleep(check_interval)
|
||||
|
||||
# Timeout - mark as expired
|
||||
with self._lock:
|
||||
request = self._requests.get(request_id)
|
||||
if request and request.status == ApprovalStatus.PENDING:
|
||||
request.status = ApprovalStatus.EXPIRED
|
||||
request.resolved_at = datetime.now()
|
||||
|
||||
return request
|
||||
|
||||
|
||||
# Global approval queue instance
|
||||
_approval_queue: Optional[ApprovalQueue] = None
|
||||
|
||||
|
||||
def get_approval_queue() -> ApprovalQueue:
|
||||
"""Get the global approval queue instance."""
|
||||
global _approval_queue
|
||||
if _approval_queue is None:
|
||||
_approval_queue = ApprovalQueue()
|
||||
return _approval_queue
|
||||
Reference in New Issue
Block a user