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")
|
||||
Reference in New Issue
Block a user