From 2bf3ff9f00bd320c0372b346e7118683e7b4cd43 Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Tue, 25 Nov 2025 02:19:56 +0000 Subject: [PATCH 01/20] Add MCP server --- server/api_server/api_server_start.py | 22 +- server/api_server/tools_routes.py | 687 ++++++++++++++++++ .../api_endpoints/test_mcp_tools_endpoints.py | 279 +++++++ test/api_endpoints/test_tools_endpoints.py | 79 ++ 4 files changed, 1066 insertions(+), 1 deletion(-) create mode 100644 server/api_server/tools_routes.py create mode 100644 test/api_endpoints/test_mcp_tools_endpoints.py create mode 100644 test/api_endpoints/test_tools_endpoints.py diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 980dcbd0..f250ca5e 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -71,6 +71,7 @@ from messaging.in_app import ( # noqa: E402 [flake8 lint suppression] delete_notification, mark_notification_as_read ) +from .tools_routes import tools_bp # noqa: E402 [flake8 lint suppression] # Flask application app = Flask(__name__) @@ -87,7 +88,8 @@ CORS( r"/dbquery/*": {"origins": "*"}, r"/messaging/*": {"origins": "*"}, r"/events/*": {"origins": "*"}, - r"/logs/*": {"origins": "*"} + r"/logs/*": {"origins": "*"}, + r"/api/tools/*": {"origins": "*"} }, supports_credentials=True, allow_headers=["Authorization", "Content-Type"], @@ -97,6 +99,17 @@ CORS( # ------------------------------------------------------------------- # Custom handler for 404 - Route not found # ------------------------------------------------------------------- +@app.before_request +def log_request_info(): + """Log details of every incoming request.""" + # Filter out noisy requests if needed, but user asked for drastic logging + mylog("none", [f"[HTTP] {request.method} {request.path} from {request.remote_addr}"]) + mylog("none", [f"[HTTP] Headers: {dict(request.headers)}"]) + if request.method == "POST": + # Be careful with large bodies, but log first 1000 chars + data = request.get_data(as_text=True) + mylog("none", [f"[HTTP] Body: {data[:1000]}"]) + @app.errorhandler(404) def not_found(error): response = { @@ -775,3 +788,10 @@ def start_server(graphql_port, app_state): # Update the state to indicate the server has started app_state = updateState("Process: Idle", None, None, None, 1) +# Register Blueprints +app.register_blueprint(tools_bp, url_prefix='/api/tools') + +if __name__ == "__main__": + # This block is for running the server directly for testing purposes + # In production, start_server is called from api.py + pass diff --git a/server/api_server/tools_routes.py b/server/api_server/tools_routes.py new file mode 100644 index 00000000..bca3b543 --- /dev/null +++ b/server/api_server/tools_routes.py @@ -0,0 +1,687 @@ +import subprocess +import shutil +import os +import re +from datetime import datetime, timedelta +from flask import Blueprint, request, jsonify +import sqlite3 +from helper import get_setting_value +from database import get_temp_db_connection + +tools_bp = Blueprint('tools', __name__) + + +def check_auth(): + """Check API_TOKEN authorization.""" + token = request.headers.get("Authorization") + expected_token = f"Bearer {get_setting_value('API_TOKEN')}" + return token == expected_token + + +@tools_bp.route('/trigger_scan', methods=['POST']) +def trigger_scan(): + """ + Forces NetAlertX to run a specific scan type immediately. + Arguments: scan_type (Enum: arp, nmap_fast, nmap_deep), target (optional IP/CIDR) + """ + if not check_auth(): + return jsonify({"error": "Unauthorized"}), 401 + + data = request.get_json() + scan_type = data.get('scan_type', 'nmap_fast') + target = data.get('target') + + # Validate scan_type + if scan_type not in ['arp', 'nmap_fast', 'nmap_deep']: + return jsonify({"error": "Invalid scan_type. Must be 'arp', 'nmap_fast', or 'nmap_deep'"}), 400 + + # Determine command + cmd = [] + if scan_type == 'arp': + # ARP scan usually requires sudo or root, assuming container runs as root or has caps + cmd = ["arp-scan", "--localnet", "--interface=eth0"] # Defaulting to eth0, might need detection + if target: + cmd = ["arp-scan", target] + elif scan_type == 'nmap_fast': + cmd = ["nmap", "-F"] + if target: + cmd.append(target) + else: + # Default to local subnet if possible, or error if not easily determined + # For now, let's require target for nmap if not easily deducible, + # or try to get it from settings. + # NetAlertX usually knows its subnet. + # Let's try to get the scan subnet from settings if not provided. + scan_subnets = get_setting_value("SCAN_SUBNETS") + if scan_subnets: + # Take the first one for now + cmd.append(scan_subnets.split(',')[0].strip()) + else: + return jsonify({"error": "Target is required and no default SCAN_SUBNETS found"}), 400 + elif scan_type == 'nmap_deep': + cmd = ["nmap", "-A", "-T4"] + if target: + cmd.append(target) + else: + scan_subnets = get_setting_value("SCAN_SUBNETS") + if scan_subnets: + cmd.append(scan_subnets.split(',')[0].strip()) + else: + return jsonify({"error": "Target is required and no default SCAN_SUBNETS found"}), 400 + + try: + # Run the command + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True + ) + return jsonify({ + "success": True, + "scan_type": scan_type, + "command": " ".join(cmd), + "output": result.stdout.strip().split('\n') + }) + except subprocess.CalledProcessError as e: + return jsonify({ + "success": False, + "error": "Scan failed", + "details": e.stderr.strip() + }), 500 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@tools_bp.route('/list_devices', methods=['POST']) +def list_devices(): + """List all devices.""" + if not check_auth(): + return jsonify({"error": "Unauthorized"}), 401 + + conn = get_temp_db_connection() + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + try: + cur.execute("SELECT devName, devMac, devLastIP as devIP, devVendor, devFirstConnection, devLastConnection FROM Devices ORDER BY devFirstConnection DESC") + rows = cur.fetchall() + devices = [dict(row) for row in rows] + return jsonify(devices) + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + conn.close() + + +@tools_bp.route('/get_device_info', methods=['POST']) +def get_device_info(): + """Get detailed info for a specific device.""" + if not check_auth(): + return jsonify({"error": "Unauthorized"}), 401 + + data = request.get_json() + if not data or 'query' not in data: + return jsonify({"error": "Missing 'query' parameter"}), 400 + + query = data['query'] + + conn = get_temp_db_connection() + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + try: + # Search by MAC, Name, or partial IP + sql = "SELECT * FROM Devices WHERE devMac LIKE ? OR devName LIKE ? OR devLastIP LIKE ?" + cur.execute(sql, (f"%{query}%", f"%{query}%", f"%{query}%")) + rows = cur.fetchall() + + if not rows: + return jsonify({"message": "No devices found"}), 404 + + devices = [dict(row) for row in rows] + return jsonify(devices) + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + conn.close() + + +@tools_bp.route('/get_latest_device', methods=['POST']) +def get_latest_device(): + """Get full details of the most recently discovered device.""" + if not check_auth(): + return jsonify({"error": "Unauthorized"}), 401 + + conn = get_temp_db_connection() + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + try: + # Get the device with the most recent devFirstConnection + cur.execute("SELECT * FROM Devices ORDER BY devFirstConnection DESC LIMIT 1") + row = cur.fetchone() + + if not row: + return jsonify({"message": "No devices found"}), 404 + + # Return as a list to be consistent with other endpoints + return jsonify([dict(row)]) + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + conn.close() + + +@tools_bp.route('/get_open_ports', methods=['POST']) +def get_open_ports(): + """ + Specific query for the port-scan results of a target. + Arguments: target (IP or MAC) + """ + if not check_auth(): + return jsonify({"error": "Unauthorized"}), 401 + + data = request.get_json() + target = data.get('target') + + if not target: + return jsonify({"error": "Target is required"}), 400 + + # If MAC is provided, try to resolve to IP + if re.match(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", target): + conn = get_temp_db_connection() + conn.row_factory = sqlite3.Row + cur = conn.cursor() + try: + cur.execute("SELECT devLastIP FROM Devices WHERE devMac = ?", (target,)) + row = cur.fetchone() + if row and row['devLastIP']: + target = row['devLastIP'] + else: + return jsonify({"error": f"Could not resolve IP for MAC {target}"}), 404 + finally: + conn.close() + + try: + # Run nmap -F for fast port scan + cmd = ["nmap", "-F", target] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True + ) + + # Parse output for open ports + open_ports = [] + for line in result.stdout.split('\n'): + if '/tcp' in line and 'open' in line: + parts = line.split('/') + port = parts[0].strip() + service = line.split()[2] if len(line.split()) > 2 else "unknown" + open_ports.append({"port": int(port), "service": service}) + + return jsonify({ + "success": True, + "target": target, + "open_ports": open_ports, + "raw_output": result.stdout.strip().split('\n') + }) + + except subprocess.CalledProcessError as e: + return jsonify({"success": False, "error": "Port scan failed", "details": e.stderr.strip()}), 500 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@tools_bp.route('/get_network_topology', methods=['GET']) +def get_network_topology(): + """ + Returns the "Parent/Child" relationships. + """ + if not check_auth(): + return jsonify({"error": "Unauthorized"}), 401 + + conn = get_temp_db_connection() + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + try: + cur.execute("SELECT devName, devMac, devParentMAC, devParentPort, devVendor FROM Devices") + rows = cur.fetchall() + + nodes = [] + links = [] + + for row in rows: + nodes.append({ + "id": row['devMac'], + "name": row['devName'], + "vendor": row['devVendor'] + }) + if row['devParentMAC']: + links.append({ + "source": row['devParentMAC'], + "target": row['devMac'], + "port": row['devParentPort'] + }) + + return jsonify({ + "nodes": nodes, + "links": links + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + conn.close() + + +@tools_bp.route('/get_recent_alerts', methods=['POST']) +def get_recent_alerts(): + """ + Fetches the last N system alerts. + Arguments: hours (lookback period, default 24) + """ + if not check_auth(): + return jsonify({"error": "Unauthorized"}), 401 + + data = request.get_json() + hours = data.get('hours', 24) + + conn = get_temp_db_connection() + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + try: + # Calculate cutoff time + cutoff = datetime.now() - timedelta(hours=int(hours)) + cutoff_str = cutoff.strftime('%Y-%m-%d %H:%M:%S') + + cur.execute(""" + SELECT eve_DateTime, eve_EventType, eve_MAC, eve_IP, devName + FROM Events + LEFT JOIN Devices ON Events.eve_MAC = Devices.devMac + WHERE eve_DateTime > ? + ORDER BY eve_DateTime DESC + """, (cutoff_str,)) + + rows = cur.fetchall() + alerts = [dict(row) for row in rows] + + return jsonify(alerts) + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + conn.close() + + +@tools_bp.route('/set_device_alias', methods=['POST']) +def set_device_alias(): + """ + Updates the name (alias) of a device. + Arguments: mac, alias + """ + if not check_auth(): + return jsonify({"error": "Unauthorized"}), 401 + + data = request.get_json() + mac = data.get('mac') + alias = data.get('alias') + + if not mac or not alias: + return jsonify({"error": "MAC and Alias are required"}), 400 + + conn = get_temp_db_connection() + cur = conn.cursor() + + try: + cur.execute("UPDATE Devices SET devName = ? WHERE devMac = ?", (alias, mac)) + conn.commit() + + if cur.rowcount == 0: + return jsonify({"error": "Device not found"}), 404 + + return jsonify({"success": True, "message": f"Device {mac} renamed to {alias}"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + conn.close() + + +@tools_bp.route('/wol_wake_device', methods=['POST']) +def wol_wake_device(): + """ + Sends a Wake-on-LAN magic packet. + Arguments: mac OR ip + """ + if not check_auth(): + return jsonify({"error": "Unauthorized"}), 401 + + data = request.get_json() + mac = data.get('mac') + ip = data.get('ip') + + if not mac and not ip: + return jsonify({"error": "MAC address or IP address is required"}), 400 + + # Resolve IP to MAC if MAC is missing + if not mac and ip: + conn = get_temp_db_connection() + conn.row_factory = sqlite3.Row + cur = conn.cursor() + try: + # Try to find device by IP (devLastIP) + cur.execute("SELECT devMac FROM Devices WHERE devLastIP = ?", (ip,)) + row = cur.fetchone() + if row and row['devMac']: + mac = row['devMac'] + else: + return jsonify({"error": f"Could not resolve MAC for IP {ip}"}), 404 + except Exception as e: + return jsonify({"error": f"Database error: {str(e)}"}), 500 + finally: + conn.close() + + # Validate MAC + if not re.match(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", mac): + return jsonify({"success": False, "error": f"Invalid MAC: {mac}"}), 400 + + try: + # Using wakeonlan command + result = subprocess.run( + ["wakeonlan", mac], capture_output=True, text=True, check=True + ) + return jsonify( + { + "success": True, + "message": f"WOL packet sent to {mac}", + "output": result.stdout.strip(), + } + ) + except subprocess.CalledProcessError as e: + return jsonify( + { + "success": False, + "error": "Failed to send WOL packet", + "details": e.stderr.strip(), + } + ), 500 + + +@tools_bp.route('/openapi.json', methods=['GET']) +def openapi_spec(): + """Return OpenAPI specification for tools.""" + # No auth required for spec to allow easy import, or require it if preferred. + # Open WebUI usually needs to fetch spec without auth first or handles it. + # We'll allow public access to spec for simplicity of import. + + spec = { + "openapi": "3.0.0", + "info": { + "title": "NetAlertX Tools", + "description": "API for NetAlertX device management tools", + "version": "1.1.0" + }, + "servers": [ + {"url": "/api/tools"} + ], + "paths": { + "/list_devices": { + "post": { + "summary": "List all devices (Summary)", + "description": ( + "Retrieve a SUMMARY list of all devices, sorted by newest first. " + "IMPORTANT: This only provides basic info (Name, IP, Vendor). " + "For FULL details (like custom props, alerts, etc.), you MUST use 'get_device_info' or 'get_latest_device'." + ), + "operationId": "list_devices", + "responses": { + "200": { + "description": "List of devices (Summary)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "devName": {"type": "string"}, + "devMac": {"type": "string"}, + "devIP": {"type": "string"}, + "devVendor": {"type": "string"}, + "devStatus": {"type": "string"}, + "devFirstConnection": {"type": "string"}, + "devLastConnection": {"type": "string"} + } + } + } + } + } + } + } + } + }, + "/get_device_info": { + "post": { + "summary": "Get device info (Full Details)", + "description": ( + "Get COMPREHENSIVE information about a specific device by MAC, Name, or partial IP. " + "Use this to see all available properties, alerts, and metadata not shown in the list." + ), + "operationId": "get_device_info", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "MAC address, Device Name, or partial IP to search for" + } + }, + "required": ["query"] + } + } + } + }, + "responses": { + "200": { + "description": "Device details (Full)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"type": "object"} + } + } + } + }, + "404": {"description": "Device not found"} + } + } + }, + "/get_latest_device": { + "post": { + "summary": "Get latest device (Full Details)", + "description": "Get COMPREHENSIVE information about the most recently discovered device (latest devFirstConnection).", + "operationId": "get_latest_device", + "responses": { + "200": { + "description": "Latest device details (Full)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"type": "object"} + } + } + } + }, + "404": {"description": "No devices found"} + } + } + }, + "/trigger_scan": { + "post": { + "summary": "Trigger Active Scan", + "description": "Forces NetAlertX to run a specific scan type immediately.", + "operationId": "trigger_scan", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scan_type": { + "type": "string", + "enum": ["arp", "nmap_fast", "nmap_deep"], + "default": "nmap_fast" + }, + "target": { + "type": "string", + "description": "IP address or CIDR to scan" + } + } + } + } + } + }, + "responses": { + "200": {"description": "Scan started/completed successfully"}, + "400": {"description": "Invalid input"} + } + } + }, + "/get_open_ports": { + "post": { + "summary": "Get Open Ports", + "description": "Specific query for the port-scan results of a target.", + "operationId": "get_open_ports", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "IP or MAC address" + } + }, + "required": ["target"] + } + } + } + }, + "responses": { + "200": {"description": "List of open ports"}, + "404": {"description": "Target not found"} + } + } + }, + "/get_network_topology": { + "get": { + "summary": "Get Network Topology", + "description": "Returns the Parent/Child relationships for network visualization.", + "operationId": "get_network_topology", + "responses": { + "200": {"description": "Graph data (nodes and links)"} + } + } + }, + "/get_recent_alerts": { + "post": { + "summary": "Get Recent Alerts", + "description": "Fetches the last N system alerts.", + "operationId": "get_recent_alerts", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hours": { + "type": "integer", + "default": 24 + } + } + } + } + } + }, + "responses": { + "200": {"description": "List of alerts"} + } + } + }, + "/set_device_alias": { + "post": { + "summary": "Set Device Alias", + "description": "Updates the name (alias) of a device.", + "operationId": "set_device_alias", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mac": {"type": "string"}, + "alias": {"type": "string"} + }, + "required": ["mac", "alias"] + } + } + } + }, + "responses": { + "200": {"description": "Alias updated"}, + "404": {"description": "Device not found"} + } + } + }, + "/wol_wake_device": { + "post": { + "summary": "Wake on LAN", + "description": "Sends a Wake-on-LAN magic packet to the target MAC or IP. If IP is provided, it resolves to MAC first.", + "operationId": "wol_wake_device", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mac": {"type": "string", "description": "Target MAC address"}, + "ip": {"type": "string", "description": "Target IP address (resolves to MAC)"} + } + } + } + } + }, + "responses": { + "200": {"description": "WOL packet sent"}, + "404": {"description": "IP not found"} + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + }, + "security": [ + {"bearerAuth": []} + ] + } + return jsonify(spec) diff --git a/test/api_endpoints/test_mcp_tools_endpoints.py b/test/api_endpoints/test_mcp_tools_endpoints.py new file mode 100644 index 00000000..22bd136d --- /dev/null +++ b/test/api_endpoints/test_mcp_tools_endpoints.py @@ -0,0 +1,279 @@ +import sys +import os +import pytest +from unittest.mock import patch, MagicMock +import subprocess + +INSTALL_PATH = os.getenv('NETALERTX_APP', '/app') +sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) + +from helper import get_setting_value # noqa: E402 +from api_server.api_server_start import app # noqa: E402 + +@pytest.fixture(scope="session") +def api_token(): + return get_setting_value("API_TOKEN") + +@pytest.fixture +def client(): + with app.test_client() as client: + yield client + +def auth_headers(token): + return {"Authorization": f"Bearer {token}"} + +# --- get_device_info Tests --- + +@patch('api_server.tools_routes.get_temp_db_connection') +def test_get_device_info_ip_partial(mock_db_conn, client, api_token): + """Test get_device_info with partial IP search.""" + mock_cursor = MagicMock() + # Mock return of a device with IP ending in .50 + mock_cursor.fetchall.return_value = [ + {"devName": "Test Device", "devMac": "AA:BB:CC:DD:EE:FF", "devLastIP": "192.168.1.50"} + ] + mock_db_conn.return_value.cursor.return_value = mock_cursor + + payload = {"query": ".50"} + response = client.post('/api/tools/get_device_info', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + devices = response.get_json() + assert len(devices) == 1 + assert devices[0]["devLastIP"] == "192.168.1.50" + + # Verify SQL query included 3 params (MAC, Name, IP) + args, _ = mock_cursor.execute.call_args + assert args[0].count("?") == 3 + assert len(args[1]) == 3 + +# --- trigger_scan Tests --- + +@patch('subprocess.run') +def test_trigger_scan_nmap_fast(mock_run, client, api_token): + """Test trigger_scan with nmap_fast.""" + mock_run.return_value = MagicMock(stdout="Scan completed", returncode=0) + + payload = {"scan_type": "nmap_fast", "target": "192.168.1.1"} + response = client.post('/api/tools/trigger_scan', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert "nmap -F 192.168.1.1" in data["command"] + mock_run.assert_called_once() + +@patch('subprocess.run') +def test_trigger_scan_invalid_type(mock_run, client, api_token): + """Test trigger_scan with invalid scan_type.""" + payload = {"scan_type": "invalid_type", "target": "192.168.1.1"} + response = client.post('/api/tools/trigger_scan', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 400 + mock_run.assert_not_called() + +# --- get_open_ports Tests --- + +@patch('subprocess.run') +def test_get_open_ports_ip(mock_run, client, api_token): + """Test get_open_ports with an IP address.""" + mock_output = """ +Starting Nmap 7.80 ( https://nmap.org ) at 2023-10-27 10:00 UTC +Nmap scan report for 192.168.1.1 +Host is up (0.0010s latency). +Not shown: 98 closed ports +PORT STATE SERVICE +22/tcp open ssh +80/tcp open http +Nmap done: 1 IP address (1 host up) scanned in 0.10 seconds +""" + mock_run.return_value = MagicMock(stdout=mock_output, returncode=0) + + payload = {"target": "192.168.1.1"} + response = client.post('/api/tools/get_open_ports', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert len(data["open_ports"]) == 2 + assert data["open_ports"][0]["port"] == 22 + assert data["open_ports"][1]["service"] == "http" + +@patch('api_server.tools_routes.get_temp_db_connection') +@patch('subprocess.run') +def test_get_open_ports_mac_resolve(mock_run, mock_db_conn, client, api_token): + """Test get_open_ports with a MAC address that resolves to an IP.""" + # Mock DB to resolve MAC to IP + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = {"devLastIP": "192.168.1.50"} + mock_db_conn.return_value.cursor.return_value = mock_cursor + + # Mock Nmap output + mock_run.return_value = MagicMock(stdout="80/tcp open http", returncode=0) + + payload = {"target": "AA:BB:CC:DD:EE:FF"} + response = client.post('/api/tools/get_open_ports', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["target"] == "192.168.1.50" # Should be resolved IP + mock_run.assert_called_once() + args, _ = mock_run.call_args + assert "192.168.1.50" in args[0] + +# --- get_network_topology Tests --- + +@patch('api_server.tools_routes.get_temp_db_connection') +def test_get_network_topology(mock_db_conn, client, api_token): + """Test get_network_topology.""" + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [ + {"devName": "Router", "devMac": "AA:AA:AA:AA:AA:AA", "devParentMAC": None, "devParentPort": None, "devVendor": "VendorA"}, + {"devName": "Device1", "devMac": "BB:BB:BB:BB:BB:BB", "devParentMAC": "AA:AA:AA:AA:AA:AA", "devParentPort": "eth1", "devVendor": "VendorB"} + ] + mock_db_conn.return_value.cursor.return_value = mock_cursor + + response = client.get('/api/tools/get_network_topology', + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert len(data["nodes"]) == 2 + assert len(data["links"]) == 1 + assert data["links"][0]["source"] == "AA:AA:AA:AA:AA:AA" + assert data["links"][0]["target"] == "BB:BB:BB:BB:BB:BB" + +# --- get_recent_alerts Tests --- + +@patch('api_server.tools_routes.get_temp_db_connection') +def test_get_recent_alerts(mock_db_conn, client, api_token): + """Test get_recent_alerts.""" + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [ + {"eve_DateTime": "2023-10-27 10:00:00", "eve_EventType": "New Device", "eve_MAC": "CC:CC:CC:CC:CC:CC", "eve_IP": "192.168.1.100", "devName": "Unknown"} + ] + mock_db_conn.return_value.cursor.return_value = mock_cursor + + payload = {"hours": 24} + response = client.post('/api/tools/get_recent_alerts', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 1 + assert data[0]["eve_EventType"] == "New Device" + +# --- set_device_alias Tests --- + +@patch('api_server.tools_routes.get_temp_db_connection') +def test_set_device_alias(mock_db_conn, client, api_token): + """Test set_device_alias.""" + mock_cursor = MagicMock() + mock_cursor.rowcount = 1 # Simulate successful update + mock_db_conn.return_value.cursor.return_value = mock_cursor + + payload = {"mac": "AA:BB:CC:DD:EE:FF", "alias": "New Name"} + response = client.post('/api/tools/set_device_alias', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + +@patch('api_server.tools_routes.get_temp_db_connection') +def test_set_device_alias_not_found(mock_db_conn, client, api_token): + """Test set_device_alias when device is not found.""" + mock_cursor = MagicMock() + mock_cursor.rowcount = 0 # Simulate no rows updated + mock_db_conn.return_value.cursor.return_value = mock_cursor + + payload = {"mac": "AA:BB:CC:DD:EE:FF", "alias": "New Name"} + response = client.post('/api/tools/set_device_alias', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 404 + +# --- wol_wake_device Tests --- + +@patch('subprocess.run') +def test_wol_wake_device(mock_subprocess, client, api_token): + """Test wol_wake_device.""" + mock_subprocess.return_value.stdout = "Sending magic packet to 255.255.255.255:9 with AA:BB:CC:DD:EE:FF" + mock_subprocess.return_value.returncode = 0 + + payload = {"mac": "AA:BB:CC:DD:EE:FF"} + response = client.post('/api/tools/wol_wake_device', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + mock_subprocess.assert_called_with(["wakeonlan", "AA:BB:CC:DD:EE:FF"], capture_output=True, text=True, check=True) + +@patch('api_server.tools_routes.get_temp_db_connection') +@patch('subprocess.run') +def test_wol_wake_device_by_ip(mock_subprocess, mock_db_conn, client, api_token): + """Test wol_wake_device with IP address.""" + # Mock DB for IP resolution + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = {"devMac": "AA:BB:CC:DD:EE:FF"} + mock_db_conn.return_value.cursor.return_value = mock_cursor + + # Mock subprocess + mock_subprocess.return_value.stdout = "Sending magic packet to 255.255.255.255:9 with AA:BB:CC:DD:EE:FF" + mock_subprocess.return_value.returncode = 0 + + payload = {"ip": "192.168.1.50"} + response = client.post('/api/tools/wol_wake_device', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert "AA:BB:CC:DD:EE:FF" in data["message"] + + # Verify DB lookup + mock_cursor.execute.assert_called_with("SELECT devMac FROM Devices WHERE devLastIP = ?", ("192.168.1.50",)) + + # Verify subprocess call + mock_subprocess.assert_called_with(["wakeonlan", "AA:BB:CC:DD:EE:FF"], capture_output=True, text=True, check=True) + +def test_wol_wake_device_invalid_mac(client, api_token): + """Test wol_wake_device with invalid MAC.""" + payload = {"mac": "invalid-mac"} + response = client.post('/api/tools/wol_wake_device', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 400 + +# --- openapi_spec Tests --- + +def test_openapi_spec(client): + """Test openapi_spec endpoint contains new paths.""" + response = client.get('/api/tools/openapi.json') + assert response.status_code == 200 + spec = response.get_json() + + # Check for new endpoints + assert "/trigger_scan" in spec["paths"] + assert "/get_open_ports" in spec["paths"] + assert "/get_network_topology" in spec["paths"] + assert "/get_recent_alerts" in spec["paths"] + assert "/set_device_alias" in spec["paths"] + assert "/wol_wake_device" in spec["paths"] diff --git a/test/api_endpoints/test_tools_endpoints.py b/test/api_endpoints/test_tools_endpoints.py new file mode 100644 index 00000000..297f11b6 --- /dev/null +++ b/test/api_endpoints/test_tools_endpoints.py @@ -0,0 +1,79 @@ +import sys +import os +import pytest + +INSTALL_PATH = os.getenv('NETALERTX_APP', '/app') +sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) + +from helper import get_setting_value # noqa: E402 [flake8 lint suppression] +from api_server.api_server_start import app # noqa: E402 [flake8 lint suppression] + + +@pytest.fixture(scope="session") +def api_token(): + return get_setting_value("API_TOKEN") + + +@pytest.fixture +def client(): + with app.test_client() as client: + yield client + + +def auth_headers(token): + return {"Authorization": f"Bearer {token}"} + + +def test_openapi_spec(client): + """Test OpenAPI spec endpoint.""" + response = client.get('/api/tools/openapi.json') + assert response.status_code == 200 + spec = response.get_json() + assert "openapi" in spec + assert "info" in spec + assert "paths" in spec + assert "/list_devices" in spec["paths"] + assert "/get_device_info" in spec["paths"] + + +def test_list_devices(client, api_token): + """Test list_devices endpoint.""" + response = client.post('/api/tools/list_devices', headers=auth_headers(api_token)) + assert response.status_code == 200 + devices = response.get_json() + assert isinstance(devices, list) + # If there are devices, check structure + if devices: + device = devices[0] + assert "devName" in device + assert "devMac" in device + + +def test_get_device_info(client, api_token): + """Test get_device_info endpoint.""" + # Test with a query that might not exist + payload = {"query": "nonexistent_device"} + response = client.post('/api/tools/get_device_info', + json=payload, + headers=auth_headers(api_token)) + # Should return 404 if no match, or 200 with results + assert response.status_code in [200, 404] + if response.status_code == 200: + devices = response.get_json() + assert isinstance(devices, list) + elif response.status_code == 404: + # Expected for no matches + pass + + +def test_list_devices_unauthorized(client): + """Test list_devices without authorization.""" + response = client.post('/api/tools/list_devices') + assert response.status_code == 401 + + +def test_get_device_info_unauthorized(client): + """Test get_device_info without authorization.""" + payload = {"query": "test"} + response = client.post('/api/tools/get_device_info', json=payload) + assert response.status_code == 401 From 541b932b6dce9a1920bd2cfeda0a8545e0093bde Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Fri, 28 Nov 2025 14:12:06 -0500 Subject: [PATCH 02/20] Add MCP to existing OpenAPI --- requirements.txt | 1 + server/api_server/api_server_start.py | 10 +- server/api_server/mcp_routes.py | 281 ++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 server/api_server/mcp_routes.py diff --git a/requirements.txt b/requirements.txt index 70dc2282..12ab40c9 100755 --- a/requirements.txt +++ b/requirements.txt @@ -30,3 +30,4 @@ urllib3 httplib2 gunicorn git+https://github.com/foreign-sub/aiofreepybox.git +mcp diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index f250ca5e..77d3af36 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -72,9 +72,17 @@ from messaging.in_app import ( # noqa: E402 [flake8 lint suppression] mark_notification_as_read ) from .tools_routes import tools_bp # noqa: E402 [flake8 lint suppression] +from .mcp_routes import mcp_bp # noqa: E402 [flake8 lint suppression] # Flask application app = Flask(__name__) +# ... (CORS settings) ... + +# ... (rest of file) ... + +# Register Blueprints +app.register_blueprint(tools_bp, url_prefix='/api/tools') +app.register_blueprint(mcp_bp, url_prefix='/api/mcp') CORS( app, resources={ @@ -788,8 +796,6 @@ def start_server(graphql_port, app_state): # Update the state to indicate the server has started app_state = updateState("Process: Idle", None, None, None, 1) -# Register Blueprints -app.register_blueprint(tools_bp, url_prefix='/api/tools') if __name__ == "__main__": # This block is for running the server directly for testing purposes diff --git a/server/api_server/mcp_routes.py b/server/api_server/mcp_routes.py new file mode 100644 index 00000000..fa7ddba0 --- /dev/null +++ b/server/api_server/mcp_routes.py @@ -0,0 +1,281 @@ +import json +import uuid +import queue +import requests +import threading +from flask import Blueprint, request, Response, stream_with_context, jsonify + +mcp_bp = Blueprint('mcp', __name__) + +# Store active sessions: session_id -> Queue +sessions = {} +sessions_lock = threading.Lock() + +# Cache for OpenAPI spec to avoid fetching on every request +openapi_spec_cache = None + +API_BASE_URL = "http://localhost:20212/api/tools" + +def get_openapi_spec(): + global openapi_spec_cache + if openapi_spec_cache: + return openapi_spec_cache + + try: + # Fetch from local server + # We use localhost because this code runs on the server + response = requests.get(f"{API_BASE_URL}/openapi.json") + response.raise_for_status() + openapi_spec_cache = response.json() + return openapi_spec_cache + except Exception as e: + print(f"Error fetching OpenAPI spec: {e}") + return None + +def map_openapi_to_mcp_tools(spec): + tools = [] + if not spec or "paths" not in spec: + return tools + + for path, methods in spec["paths"].items(): + for method, details in methods.items(): + if "operationId" in details: + tool = { + "name": details["operationId"], + "description": details.get("description", details.get("summary", "")), + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + } + + # Extract parameters from requestBody if present + if "requestBody" in details: + content = details["requestBody"].get("content", {}) + if "application/json" in content: + schema = content["application/json"].get("schema", {}) + tool["inputSchema"] = schema + + # Extract parameters from 'parameters' list (query/path params) - simplistic support + if "parameters" in details: + for param in details["parameters"]: + if param.get("in") == "query": + tool["inputSchema"]["properties"][param["name"]] = { + "type": param.get("schema", {}).get("type", "string"), + "description": param.get("description", "") + } + if param.get("required"): + if "required" not in tool["inputSchema"]: + tool["inputSchema"]["required"] = [] + tool["inputSchema"]["required"].append(param["name"]) + + tools.append(tool) + return tools + +def process_mcp_request(data): + method = data.get("method") + msg_id = data.get("id") + + response = None + + if method == "initialize": + response = { + "jsonrpc": "2.0", + "id": msg_id, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "NetAlertX", + "version": "1.0.0" + } + } + } + + elif method == "notifications/initialized": + # No response needed for notification + pass + + elif method == "tools/list": + spec = get_openapi_spec() + tools = map_openapi_to_mcp_tools(spec) + response = { + "jsonrpc": "2.0", + "id": msg_id, + "result": { + "tools": tools + } + } + + elif method == "tools/call": + params = data.get("params", {}) + tool_name = params.get("name") + tool_args = params.get("arguments", {}) + + # Find the endpoint for this tool + spec = get_openapi_spec() + target_path = None + target_method = None + + if spec and "paths" in spec: + for path, methods in spec["paths"].items(): + for m, details in methods.items(): + if details.get("operationId") == tool_name: + target_path = path + target_method = m.upper() + break + if target_path: + break + + if target_path: + try: + # Make the request to the local API + # We forward the Authorization header from the incoming request if present + headers = { + "Content-Type": "application/json" + } + if "Authorization" in request.headers: + headers["Authorization"] = request.headers["Authorization"] + + url = f"{API_BASE_URL}{target_path}" + + if target_method == "POST": + api_res = requests.post(url, json=tool_args, headers=headers) + elif target_method == "GET": + api_res = requests.get(url, params=tool_args, headers=headers) + else: + api_res = None + + if api_res: + content = [] + try: + json_content = api_res.json() + content.append({ + "type": "text", + "text": json.dumps(json_content, indent=2) + }) + except: + content.append({ + "type": "text", + "text": api_res.text + }) + + is_error = api_res.status_code >= 400 + response = { + "jsonrpc": "2.0", + "id": msg_id, + "result": { + "content": content, + "isError": is_error + } + } + else: + response = { + "jsonrpc": "2.0", + "id": msg_id, + "error": {"code": -32601, "message": f"Method {target_method} not supported"} + } + + except Exception as e: + response = { + "jsonrpc": "2.0", + "id": msg_id, + "result": { + "content": [{"type": "text", "text": f"Error calling tool: {str(e)}"}], + "isError": True + } + } + else: + response = { + "jsonrpc": "2.0", + "id": msg_id, + "error": {"code": -32601, "message": f"Tool {tool_name} not found"} + } + + elif method == "ping": + response = { + "jsonrpc": "2.0", + "id": msg_id, + "result": {} + } + + else: + # Unknown method + if msg_id: # Only respond if it's a request (has id) + response = { + "jsonrpc": "2.0", + "id": msg_id, + "error": {"code": -32601, "message": "Method not found"} + } + + return response + +@mcp_bp.route('/sse', methods=['GET', 'POST']) +def handle_sse(): + if request.method == 'POST': + # Handle verification or keep-alive pings + try: + data = request.get_json(silent=True) + if data and "method" in data and "jsonrpc" in data: + response = process_mcp_request(data) + if response: + return jsonify(response) + else: + # Notification or no response needed + return "", 202 + except Exception: + pass + + return jsonify({"status": "ok", "message": "MCP SSE endpoint active"}), 200 + + session_id = uuid.uuid4().hex + q = queue.Queue() + + with sessions_lock: + sessions[session_id] = q + + def stream(): + # Send the endpoint event + # The client should POST to /api/mcp/messages?session_id= + yield f"event: endpoint\ndata: /api/mcp/messages?session_id={session_id}\n\n" + + try: + while True: + try: + # Wait for messages + message = q.get(timeout=20) # Keep-alive timeout + yield f"event: message\ndata: {json.dumps(message)}\n\n" + except queue.Empty: + # Send keep-alive comment + yield ": keep-alive\n\n" + except GeneratorExit: + with sessions_lock: + if session_id in sessions: + del sessions[session_id] + + return Response(stream_with_context(stream()), mimetype='text/event-stream') + +@mcp_bp.route('/messages', methods=['POST']) +def handle_messages(): + session_id = request.args.get('session_id') + if not session_id: + return jsonify({"error": "Missing session_id"}), 400 + + with sessions_lock: + if session_id not in sessions: + return jsonify({"error": "Session not found"}), 404 + q = sessions[session_id] + + data = request.json + if not data: + return jsonify({"error": "Invalid JSON"}), 400 + + response = process_mcp_request(data) + + if response: + q.put(response) + + return jsonify({"status": "accepted"}), 202 From 5e4ad10fe0961b8a386af0f3d6ece944879a1a76 Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Fri, 28 Nov 2025 21:13:20 +0000 Subject: [PATCH 03/20] Tidy up --- server/api_server/api_server_start.py | 2 + server/api_server/mcp_routes.py | 63 +++++---- server/api_server/tools_routes.py | 42 +++--- .../api_endpoints/test_mcp_tools_endpoints.py | 128 ++++++++++-------- 4 files changed, 128 insertions(+), 107 deletions(-) diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 77d3af36..39772c65 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -118,6 +118,7 @@ def log_request_info(): data = request.get_data(as_text=True) mylog("none", [f"[HTTP] Body: {data[:1000]}"]) + @app.errorhandler(404) def not_found(error): response = { @@ -797,6 +798,7 @@ def start_server(graphql_port, app_state): # Update the state to indicate the server has started app_state = updateState("Process: Idle", None, None, None, 1) + if __name__ == "__main__": # This block is for running the server directly for testing purposes # In production, start_server is called from api.py diff --git a/server/api_server/mcp_routes.py b/server/api_server/mcp_routes.py index fa7ddba0..b65539a6 100644 --- a/server/api_server/mcp_routes.py +++ b/server/api_server/mcp_routes.py @@ -1,3 +1,5 @@ +"""MCP bridge routes exposing NetAlertX tool endpoints via JSON-RPC.""" + import json import uuid import queue @@ -16,11 +18,13 @@ openapi_spec_cache = None API_BASE_URL = "http://localhost:20212/api/tools" + def get_openapi_spec(): + """Fetch and cache the tools OpenAPI specification from the local API server.""" global openapi_spec_cache if openapi_spec_cache: return openapi_spec_cache - + try: # Fetch from local server # We use localhost because this code runs on the server @@ -32,7 +36,9 @@ def get_openapi_spec(): print(f"Error fetching OpenAPI spec: {e}") return None + def map_openapi_to_mcp_tools(spec): + """Convert OpenAPI paths into MCP tool descriptors.""" tools = [] if not spec or "paths" not in spec: return tools @@ -49,14 +55,14 @@ def map_openapi_to_mcp_tools(spec): "required": [] } } - + # Extract parameters from requestBody if present if "requestBody" in details: content = details["requestBody"].get("content", {}) if "application/json" in content: schema = content["application/json"].get("schema", {}) tool["inputSchema"] = schema - + # Extract parameters from 'parameters' list (query/path params) - simplistic support if "parameters" in details: for param in details["parameters"]: @@ -73,12 +79,14 @@ def map_openapi_to_mcp_tools(spec): tools.append(tool) return tools + def process_mcp_request(data): + """Handle incoming MCP JSON-RPC requests and route them to tools.""" method = data.get("method") msg_id = data.get("id") - + response = None - + if method == "initialize": response = { "jsonrpc": "2.0", @@ -94,11 +102,11 @@ def process_mcp_request(data): } } } - + elif method == "notifications/initialized": # No response needed for notification pass - + elif method == "tools/list": spec = get_openapi_spec() tools = map_openapi_to_mcp_tools(spec) @@ -109,17 +117,17 @@ def process_mcp_request(data): "tools": tools } } - + elif method == "tools/call": params = data.get("params", {}) tool_name = params.get("name") tool_args = params.get("arguments", {}) - + # Find the endpoint for this tool spec = get_openapi_spec() target_path = None target_method = None - + if spec and "paths" in spec: for path, methods in spec["paths"].items(): for m, details in methods.items(): @@ -129,7 +137,7 @@ def process_mcp_request(data): break if target_path: break - + if target_path: try: # Make the request to the local API @@ -139,16 +147,16 @@ def process_mcp_request(data): } if "Authorization" in request.headers: headers["Authorization"] = request.headers["Authorization"] - + url = f"{API_BASE_URL}{target_path}" - + if target_method == "POST": api_res = requests.post(url, json=tool_args, headers=headers) elif target_method == "GET": api_res = requests.get(url, params=tool_args, headers=headers) else: api_res = None - + if api_res: content = [] try: @@ -157,12 +165,12 @@ def process_mcp_request(data): "type": "text", "text": json.dumps(json_content, indent=2) }) - except: + except (ValueError, json.JSONDecodeError): content.append({ "type": "text", "text": api_res.text }) - + is_error = api_res.status_code >= 400 response = { "jsonrpc": "2.0", @@ -194,27 +202,29 @@ def process_mcp_request(data): "id": msg_id, "error": {"code": -32601, "message": f"Tool {tool_name} not found"} } - + elif method == "ping": response = { "jsonrpc": "2.0", "id": msg_id, "result": {} } - + else: # Unknown method - if msg_id: # Only respond if it's a request (has id) + if msg_id: # Only respond if it's a request (has id) response = { "jsonrpc": "2.0", "id": msg_id, "error": {"code": -32601, "message": "Method not found"} } - + return response + @mcp_bp.route('/sse', methods=['GET', 'POST']) def handle_sse(): + """Expose an SSE endpoint that streams MCP responses to connected clients.""" if request.method == 'POST': # Handle verification or keep-alive pings try: @@ -228,25 +238,26 @@ def handle_sse(): return "", 202 except Exception: pass - + return jsonify({"status": "ok", "message": "MCP SSE endpoint active"}), 200 session_id = uuid.uuid4().hex q = queue.Queue() - + with sessions_lock: sessions[session_id] = q def stream(): + """Yield SSE messages for queued MCP responses until the client disconnects.""" # Send the endpoint event # The client should POST to /api/mcp/messages?session_id= yield f"event: endpoint\ndata: /api/mcp/messages?session_id={session_id}\n\n" - + try: while True: try: # Wait for messages - message = q.get(timeout=20) # Keep-alive timeout + message = q.get(timeout=20) # Keep-alive timeout yield f"event: message\ndata: {json.dumps(message)}\n\n" except queue.Empty: # Send keep-alive comment @@ -258,12 +269,14 @@ def handle_sse(): return Response(stream_with_context(stream()), mimetype='text/event-stream') + @mcp_bp.route('/messages', methods=['POST']) def handle_messages(): + """Receive MCP JSON-RPC messages and enqueue responses for an SSE session.""" session_id = request.args.get('session_id') if not session_id: return jsonify({"error": "Missing session_id"}), 400 - + with sessions_lock: if session_id not in sessions: return jsonify({"error": "Session not found"}), 404 diff --git a/server/api_server/tools_routes.py b/server/api_server/tools_routes.py index bca3b543..5e84f781 100644 --- a/server/api_server/tools_routes.py +++ b/server/api_server/tools_routes.py @@ -1,6 +1,4 @@ import subprocess -import shutil -import os import re from datetime import datetime, timedelta from flask import Blueprint, request, jsonify @@ -39,25 +37,25 @@ def trigger_scan(): cmd = [] if scan_type == 'arp': # ARP scan usually requires sudo or root, assuming container runs as root or has caps - cmd = ["arp-scan", "--localnet", "--interface=eth0"] # Defaulting to eth0, might need detection + cmd = ["arp-scan", "--localnet", "--interface=eth0"] # Defaulting to eth0, might need detection if target: - cmd = ["arp-scan", target] + cmd = ["arp-scan", target] elif scan_type == 'nmap_fast': cmd = ["nmap", "-F"] if target: cmd.append(target) else: # Default to local subnet if possible, or error if not easily determined - # For now, let's require target for nmap if not easily deducible, - # or try to get it from settings. + # For now, let's require target for nmap if not easily deducible, + # or try to get it from settings. # NetAlertX usually knows its subnet. # Let's try to get the scan subnet from settings if not provided. scan_subnets = get_setting_value("SCAN_SUBNETS") if scan_subnets: - # Take the first one for now - cmd.append(scan_subnets.split(',')[0].strip()) + # Take the first one for now + cmd.append(scan_subnets.split(',')[0].strip()) else: - return jsonify({"error": "Target is required and no default SCAN_SUBNETS found"}), 400 + return jsonify({"error": "Target is required and no default SCAN_SUBNETS found"}), 400 elif scan_type == 'nmap_deep': cmd = ["nmap", "-A", "-T4"] if target: @@ -65,9 +63,9 @@ def trigger_scan(): else: scan_subnets = get_setting_value("SCAN_SUBNETS") if scan_subnets: - cmd.append(scan_subnets.split(',')[0].strip()) + cmd.append(scan_subnets.split(',')[0].strip()) else: - return jsonify({"error": "Target is required and no default SCAN_SUBNETS found"}), 400 + return jsonify({"error": "Target is required and no default SCAN_SUBNETS found"}), 400 try: # Run the command @@ -212,7 +210,7 @@ def get_open_ports(): text=True, check=True ) - + # Parse output for open ports open_ports = [] for line in result.stdout.split('\n'): @@ -250,10 +248,10 @@ def get_network_topology(): try: cur.execute("SELECT devName, devMac, devParentMAC, devParentPort, devVendor FROM Devices") rows = cur.fetchall() - + nodes = [] links = [] - + for row in rows: nodes.append({ "id": row['devMac'], @@ -299,16 +297,16 @@ def get_recent_alerts(): cutoff_str = cutoff.strftime('%Y-%m-%d %H:%M:%S') cur.execute(""" - SELECT eve_DateTime, eve_EventType, eve_MAC, eve_IP, devName - FROM Events + SELECT eve_DateTime, eve_EventType, eve_MAC, eve_IP, devName + FROM Events LEFT JOIN Devices ON Events.eve_MAC = Devices.devMac - WHERE eve_DateTime > ? + WHERE eve_DateTime > ? ORDER BY eve_DateTime DESC """, (cutoff_str,)) - + rows = cur.fetchall() alerts = [dict(row) for row in rows] - + return jsonify(alerts) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -338,10 +336,10 @@ def set_device_alias(): try: cur.execute("UPDATE Devices SET devName = ? WHERE devMac = ?", (alias, mac)) conn.commit() - + if cur.rowcount == 0: return jsonify({"error": "Device not found"}), 404 - + return jsonify({"success": True, "message": f"Device {mac} renamed to {alias}"}) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -379,7 +377,7 @@ def wol_wake_device(): else: return jsonify({"error": f"Could not resolve MAC for IP {ip}"}), 404 except Exception as e: - return jsonify({"error": f"Database error: {str(e)}"}), 500 + return jsonify({"error": f"Database error: {str(e)}"}), 500 finally: conn.close() diff --git a/test/api_endpoints/test_mcp_tools_endpoints.py b/test/api_endpoints/test_mcp_tools_endpoints.py index 22bd136d..fd221879 100644 --- a/test/api_endpoints/test_mcp_tools_endpoints.py +++ b/test/api_endpoints/test_mcp_tools_endpoints.py @@ -2,7 +2,6 @@ import sys import os import pytest from unittest.mock import patch, MagicMock -import subprocess INSTALL_PATH = os.getenv('NETALERTX_APP', '/app') sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) @@ -10,20 +9,23 @@ sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) from helper import get_setting_value # noqa: E402 from api_server.api_server_start import app # noqa: E402 + @pytest.fixture(scope="session") def api_token(): return get_setting_value("API_TOKEN") + @pytest.fixture def client(): with app.test_client() as client: yield client + def auth_headers(token): return {"Authorization": f"Bearer {token}"} -# --- get_device_info Tests --- +# --- get_device_info Tests --- @patch('api_server.tools_routes.get_temp_db_connection') def test_get_device_info_ip_partial(mock_db_conn, client, api_token): """Test get_device_info with partial IP search.""" @@ -33,53 +35,55 @@ def test_get_device_info_ip_partial(mock_db_conn, client, api_token): {"devName": "Test Device", "devMac": "AA:BB:CC:DD:EE:FF", "devLastIP": "192.168.1.50"} ] mock_db_conn.return_value.cursor.return_value = mock_cursor - + payload = {"query": ".50"} - response = client.post('/api/tools/get_device_info', - json=payload, + response = client.post('/api/tools/get_device_info', + json=payload, headers=auth_headers(api_token)) - + assert response.status_code == 200 devices = response.get_json() assert len(devices) == 1 assert devices[0]["devLastIP"] == "192.168.1.50" - + # Verify SQL query included 3 params (MAC, Name, IP) args, _ = mock_cursor.execute.call_args assert args[0].count("?") == 3 assert len(args[1]) == 3 -# --- trigger_scan Tests --- +# --- trigger_scan Tests --- @patch('subprocess.run') def test_trigger_scan_nmap_fast(mock_run, client, api_token): """Test trigger_scan with nmap_fast.""" mock_run.return_value = MagicMock(stdout="Scan completed", returncode=0) - + payload = {"scan_type": "nmap_fast", "target": "192.168.1.1"} - response = client.post('/api/tools/trigger_scan', - json=payload, + response = client.post('/api/tools/trigger_scan', + json=payload, headers=auth_headers(api_token)) - + assert response.status_code == 200 data = response.get_json() assert data["success"] is True assert "nmap -F 192.168.1.1" in data["command"] mock_run.assert_called_once() + @patch('subprocess.run') def test_trigger_scan_invalid_type(mock_run, client, api_token): """Test trigger_scan with invalid scan_type.""" payload = {"scan_type": "invalid_type", "target": "192.168.1.1"} - response = client.post('/api/tools/trigger_scan', - json=payload, + response = client.post('/api/tools/trigger_scan', + json=payload, headers=auth_headers(api_token)) - + assert response.status_code == 400 mock_run.assert_not_called() # --- get_open_ports Tests --- + @patch('subprocess.run') def test_get_open_ports_ip(mock_run, client, api_token): """Test get_open_ports with an IP address.""" @@ -94,12 +98,12 @@ PORT STATE SERVICE Nmap done: 1 IP address (1 host up) scanned in 0.10 seconds """ mock_run.return_value = MagicMock(stdout=mock_output, returncode=0) - + payload = {"target": "192.168.1.1"} - response = client.post('/api/tools/get_open_ports', - json=payload, + response = client.post('/api/tools/get_open_ports', + json=payload, headers=auth_headers(api_token)) - + assert response.status_code == 200 data = response.get_json() assert data["success"] is True @@ -107,6 +111,7 @@ Nmap done: 1 IP address (1 host up) scanned in 0.10 seconds assert data["open_ports"][0]["port"] == 22 assert data["open_ports"][1]["service"] == "http" + @patch('api_server.tools_routes.get_temp_db_connection') @patch('subprocess.run') def test_get_open_ports_mac_resolve(mock_run, mock_db_conn, client, api_token): @@ -115,24 +120,24 @@ def test_get_open_ports_mac_resolve(mock_run, mock_db_conn, client, api_token): mock_cursor = MagicMock() mock_cursor.fetchone.return_value = {"devLastIP": "192.168.1.50"} mock_db_conn.return_value.cursor.return_value = mock_cursor - + # Mock Nmap output mock_run.return_value = MagicMock(stdout="80/tcp open http", returncode=0) - + payload = {"target": "AA:BB:CC:DD:EE:FF"} - response = client.post('/api/tools/get_open_ports', - json=payload, + response = client.post('/api/tools/get_open_ports', + json=payload, headers=auth_headers(api_token)) - + assert response.status_code == 200 data = response.get_json() - assert data["target"] == "192.168.1.50" # Should be resolved IP + assert data["target"] == "192.168.1.50" # Should be resolved IP mock_run.assert_called_once() args, _ = mock_run.call_args assert "192.168.1.50" in args[0] -# --- get_network_topology Tests --- +# --- get_network_topology Tests --- @patch('api_server.tools_routes.get_temp_db_connection') def test_get_network_topology(mock_db_conn, client, api_token): """Test get_network_topology.""" @@ -142,10 +147,10 @@ def test_get_network_topology(mock_db_conn, client, api_token): {"devName": "Device1", "devMac": "BB:BB:BB:BB:BB:BB", "devParentMAC": "AA:AA:AA:AA:AA:AA", "devParentPort": "eth1", "devVendor": "VendorB"} ] mock_db_conn.return_value.cursor.return_value = mock_cursor - - response = client.get('/api/tools/get_network_topology', + + response = client.get('/api/tools/get_network_topology', headers=auth_headers(api_token)) - + assert response.status_code == 200 data = response.get_json() assert len(data["nodes"]) == 2 @@ -153,8 +158,8 @@ def test_get_network_topology(mock_db_conn, client, api_token): assert data["links"][0]["source"] == "AA:AA:AA:AA:AA:AA" assert data["links"][0]["target"] == "BB:BB:BB:BB:BB:BB" -# --- get_recent_alerts Tests --- +# --- get_recent_alerts Tests --- @patch('api_server.tools_routes.get_temp_db_connection') def test_get_recent_alerts(mock_db_conn, client, api_token): """Test get_recent_alerts.""" @@ -163,67 +168,69 @@ def test_get_recent_alerts(mock_db_conn, client, api_token): {"eve_DateTime": "2023-10-27 10:00:00", "eve_EventType": "New Device", "eve_MAC": "CC:CC:CC:CC:CC:CC", "eve_IP": "192.168.1.100", "devName": "Unknown"} ] mock_db_conn.return_value.cursor.return_value = mock_cursor - + payload = {"hours": 24} - response = client.post('/api/tools/get_recent_alerts', - json=payload, + response = client.post('/api/tools/get_recent_alerts', + json=payload, headers=auth_headers(api_token)) - + assert response.status_code == 200 data = response.get_json() assert len(data) == 1 assert data[0]["eve_EventType"] == "New Device" -# --- set_device_alias Tests --- +# --- set_device_alias Tests --- @patch('api_server.tools_routes.get_temp_db_connection') def test_set_device_alias(mock_db_conn, client, api_token): """Test set_device_alias.""" mock_cursor = MagicMock() - mock_cursor.rowcount = 1 # Simulate successful update + mock_cursor.rowcount = 1 # Simulate successful update mock_db_conn.return_value.cursor.return_value = mock_cursor - + payload = {"mac": "AA:BB:CC:DD:EE:FF", "alias": "New Name"} - response = client.post('/api/tools/set_device_alias', - json=payload, + response = client.post('/api/tools/set_device_alias', + json=payload, headers=auth_headers(api_token)) - + assert response.status_code == 200 data = response.get_json() assert data["success"] is True + @patch('api_server.tools_routes.get_temp_db_connection') def test_set_device_alias_not_found(mock_db_conn, client, api_token): """Test set_device_alias when device is not found.""" mock_cursor = MagicMock() - mock_cursor.rowcount = 0 # Simulate no rows updated + mock_cursor.rowcount = 0 # Simulate no rows updated mock_db_conn.return_value.cursor.return_value = mock_cursor - + payload = {"mac": "AA:BB:CC:DD:EE:FF", "alias": "New Name"} - response = client.post('/api/tools/set_device_alias', - json=payload, + response = client.post('/api/tools/set_device_alias', + json=payload, headers=auth_headers(api_token)) - + assert response.status_code == 404 -# --- wol_wake_device Tests --- +# --- wol_wake_device Tests --- @patch('subprocess.run') def test_wol_wake_device(mock_subprocess, client, api_token): """Test wol_wake_device.""" mock_subprocess.return_value.stdout = "Sending magic packet to 255.255.255.255:9 with AA:BB:CC:DD:EE:FF" mock_subprocess.return_value.returncode = 0 - + payload = {"mac": "AA:BB:CC:DD:EE:FF"} - response = client.post('/api/tools/wol_wake_device', - json=payload, + response = client.post('/api/tools/wol_wake_device', + json=payload, headers=auth_headers(api_token)) - + assert response.status_code == 200 data = response.get_json() assert data["success"] is True mock_subprocess.assert_called_with(["wakeonlan", "AA:BB:CC:DD:EE:FF"], capture_output=True, text=True, check=True) + @patch('api_server.tools_routes.get_temp_db_connection') @patch('subprocess.run') def test_wol_wake_device_by_ip(mock_subprocess, mock_db_conn, client, api_token): @@ -238,38 +245,39 @@ def test_wol_wake_device_by_ip(mock_subprocess, mock_db_conn, client, api_token) mock_subprocess.return_value.returncode = 0 payload = {"ip": "192.168.1.50"} - response = client.post('/api/tools/wol_wake_device', - json=payload, + response = client.post('/api/tools/wol_wake_device', + json=payload, headers=auth_headers(api_token)) - + assert response.status_code == 200 data = response.get_json() assert data["success"] is True assert "AA:BB:CC:DD:EE:FF" in data["message"] - + # Verify DB lookup mock_cursor.execute.assert_called_with("SELECT devMac FROM Devices WHERE devLastIP = ?", ("192.168.1.50",)) - + # Verify subprocess call mock_subprocess.assert_called_with(["wakeonlan", "AA:BB:CC:DD:EE:FF"], capture_output=True, text=True, check=True) + def test_wol_wake_device_invalid_mac(client, api_token): """Test wol_wake_device with invalid MAC.""" payload = {"mac": "invalid-mac"} - response = client.post('/api/tools/wol_wake_device', - json=payload, + response = client.post('/api/tools/wol_wake_device', + json=payload, headers=auth_headers(api_token)) - + assert response.status_code == 400 -# --- openapi_spec Tests --- +# --- openapi_spec Tests --- def test_openapi_spec(client): """Test openapi_spec endpoint contains new paths.""" response = client.get('/api/tools/openapi.json') assert response.status_code == 200 spec = response.get_json() - + # Check for new endpoints assert "/trigger_scan" in spec["paths"] assert "/get_open_ports" in spec["paths"] From 531b66effec403d1d2a6858325dc804e02418d5c Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Sat, 29 Nov 2025 02:44:55 +0000 Subject: [PATCH 04/20] Coderabit changes --- server/api_server/api_server_start.py | 11 +++++------ server/api_server/mcp_routes.py | 24 +++++++++++++++++------- server/api_server/tools_routes.py | 5 +++-- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 39772c65..f63f7836 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -76,9 +76,6 @@ from .mcp_routes import mcp_bp # noqa: E402 [flake8 lint suppression] # Flask application app = Flask(__name__) -# ... (CORS settings) ... - -# ... (rest of file) ... # Register Blueprints app.register_blueprint(tools_bp, url_prefix='/api/tools') @@ -111,12 +108,14 @@ CORS( def log_request_info(): """Log details of every incoming request.""" # Filter out noisy requests if needed, but user asked for drastic logging - mylog("none", [f"[HTTP] {request.method} {request.path} from {request.remote_addr}"]) - mylog("none", [f"[HTTP] Headers: {dict(request.headers)}"]) + mylog("verbose", [f"[HTTP] {request.method} {request.path} from {request.remote_addr}"]) + # Filter sensitive headers before logging + safe_headers = {k: v for k, v in request.headers if k.lower() not in ('authorization', 'cookie', 'x-api-key')} + mylog("debug", [f"[HTTP] Headers: {safe_headers}"]) if request.method == "POST": # Be careful with large bodies, but log first 1000 chars data = request.get_data(as_text=True) - mylog("none", [f"[HTTP] Body: {data[:1000]}"]) + mylog("debug", [f"[HTTP] Body length: {len(data)} chars"]) @app.errorhandler(404) diff --git a/server/api_server/mcp_routes.py b/server/api_server/mcp_routes.py index b65539a6..dc7a33b9 100644 --- a/server/api_server/mcp_routes.py +++ b/server/api_server/mcp_routes.py @@ -5,7 +5,9 @@ import uuid import queue import requests import threading +import logging from flask import Blueprint, request, Response, stream_with_context, jsonify +from helper import get_setting_value mcp_bp = Blueprint('mcp', __name__) @@ -16,7 +18,9 @@ sessions_lock = threading.Lock() # Cache for OpenAPI spec to avoid fetching on every request openapi_spec_cache = None -API_BASE_URL = "http://localhost:20212/api/tools" +BACKEND_PORT = get_setting_value("GRAPHQL_PORT") + +API_BASE_URL = f"http://localhost:{BACKEND_PORT}/api/tools" def get_openapi_spec(): @@ -28,7 +32,7 @@ def get_openapi_spec(): try: # Fetch from local server # We use localhost because this code runs on the server - response = requests.get(f"{API_BASE_URL}/openapi.json") + response = requests.get(f"{API_BASE_URL}/openapi.json", timeout=10) response.raise_for_status() openapi_spec_cache = response.json() return openapi_spec_cache @@ -61,7 +65,11 @@ def map_openapi_to_mcp_tools(spec): content = details["requestBody"].get("content", {}) if "application/json" in content: schema = content["application/json"].get("schema", {}) - tool["inputSchema"] = schema + tool["inputSchema"] = schema.copy() + if "properties" not in tool["inputSchema"]: + tool["inputSchema"]["properties"] = {} + if "required" not in tool["inputSchema"]: + tool["inputSchema"]["required"] = [] # Extract parameters from 'parameters' list (query/path params) - simplistic support if "parameters" in details: @@ -145,15 +153,16 @@ def process_mcp_request(data): headers = { "Content-Type": "application/json" } + if "Authorization" in request.headers: headers["Authorization"] = request.headers["Authorization"] url = f"{API_BASE_URL}{target_path}" if target_method == "POST": - api_res = requests.post(url, json=tool_args, headers=headers) + api_res = requests.post(url, json=tool_args, headers=headers, timeout=30) elif target_method == "GET": - api_res = requests.get(url, params=tool_args, headers=headers) + api_res = requests.get(url, params=tool_args, headers=headers, timeout=30) else: api_res = None @@ -236,8 +245,9 @@ def handle_sse(): else: # Notification or no response needed return "", 202 - except Exception: - pass + except Exception as e: + # Log but don't fail - malformed requests shouldn't crash the endpoint + logging.getLogger(__name__).debug(f"SSE POST processing error: {e}") return jsonify({"status": "ok", "message": "MCP SSE endpoint active"}), 200 diff --git a/server/api_server/tools_routes.py b/server/api_server/tools_routes.py index 5e84f781..0b569201 100644 --- a/server/api_server/tools_routes.py +++ b/server/api_server/tools_routes.py @@ -208,7 +208,8 @@ def get_open_ports(): cmd, capture_output=True, text=True, - check=True + check=True, + timeout=120 ) # Parse output for open ports @@ -388,7 +389,7 @@ def wol_wake_device(): try: # Using wakeonlan command result = subprocess.run( - ["wakeonlan", mac], capture_output=True, text=True, check=True + ["wakeonlan", mac], capture_output=True, text=True, check=True, timeout=10 ) return jsonify( { From e64c490c8a078a2db87525137ce4d8e554e3ed36 Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Sun, 30 Nov 2025 01:04:12 +0000 Subject: [PATCH 05/20] Help ARM runners on github with rust and cargo required by pip --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1cabc8ac..d815550d 100755 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ ENV PATH="/opt/venv/bin:$PATH" # Install build dependencies COPY requirements.txt /tmp/requirements.txt -RUN apk add --no-cache bash shadow python3 python3-dev gcc musl-dev libffi-dev openssl-dev git \ +RUN apk add --no-cache bash shadow python3 python3-dev gcc musl-dev libffi-dev openssl-dev git rust cargo \ && python -m venv /opt/venv # Create virtual environment owned by root, but readable by everyone else. This makes it easy to copy From 8d5a6638170f9eb8c7f5003df8d05b026a1b577a Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Mon, 1 Dec 2025 08:27:14 +0000 Subject: [PATCH 06/20] DevInstance and PluginObjectInstance expansion --- server/models/device_instance.py | 38 +++++++++++++++++++++++++ server/models/plugin_object_instance.py | 9 ++++++ 2 files changed, 47 insertions(+) diff --git a/server/models/device_instance.py b/server/models/device_instance.py index 795950bf..5400e1c0 100755 --- a/server/models/device_instance.py +++ b/server/models/device_instance.py @@ -57,6 +57,44 @@ class DeviceInstance: result = self.db.sql.fetchone() return result["count"] > 0 + # Get a device by its last IP address + def getByIP(self, ip): + self.db.sql.execute("SELECT * FROM Devices WHERE devLastIP = ?", (ip,)) + row = self.db.sql.fetchone() + return dict(row) if row else None + + # Search devices by partial mac, name or IP + def search(self, query): + like = f"%{query}%" + self.db.sql.execute( + "SELECT * FROM Devices WHERE devMac LIKE ? OR devName LIKE ? OR devLastIP LIKE ?", + (like, like, like), + ) + rows = self.db.sql.fetchall() + return [dict(r) for r in rows] + + # Get the most recently discovered device + def getLatest(self): + self.db.sql.execute("SELECT * FROM Devices ORDER BY devFirstConnection DESC LIMIT 1") + row = self.db.sql.fetchone() + return dict(row) if row else None + + def getNetworkTopology(self): + """Returns nodes and links for the current Devices table. + + Nodes: {id, name, vendor} + Links: {source, target, port} + """ + self.db.sql.execute("SELECT devName, devMac, devParentMAC, devParentPort, devVendor FROM Devices") + rows = self.db.sql.fetchall() + nodes = [] + links = [] + for row in rows: + nodes.append({"id": row['devMac'], "name": row['devName'], "vendor": row['devVendor']}) + if row['devParentMAC']: + links.append({"source": row['devParentMAC'], "target": row['devMac'], "port": row['devParentPort']}) + return {"nodes": nodes, "links": links} + # Update a specific field for a device def updateField(self, devGUID, field, value): if not self.exists(devGUID): diff --git a/server/models/plugin_object_instance.py b/server/models/plugin_object_instance.py index 2adaaa6f..95e392c5 100755 --- a/server/models/plugin_object_instance.py +++ b/server/models/plugin_object_instance.py @@ -37,6 +37,15 @@ class PluginObjectInstance: self.db.sql.execute("SELECT * FROM Plugins_Objects WHERE Plugin = ?", (plugin,)) return self.db.sql.fetchall() + # Get plugin objects by primary ID and plugin name + def getByPrimary(self, plugin, primary_id): + self.db.sql.execute( + "SELECT * FROM Plugins_Objects WHERE Plugin = ? AND Object_PrimaryID = ?", + (plugin, primary_id), + ) + rows = self.db.sql.fetchall() + return [dict(r) for r in rows] + # Get objects by status def getByStatus(self, status): self.db.sql.execute("SELECT * FROM Plugins_Objects WHERE Status = ?", (status,)) From dfd836527eae623025a83e00d6fd3c79264ac86c Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Mon, 1 Dec 2025 08:52:50 +0000 Subject: [PATCH 07/20] api endpoints updates --- server/api_server/api_server_start.py | 395 +++++++++++++++++++++++++- 1 file changed, 385 insertions(+), 10 deletions(-) diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index f63f7836..159b3c86 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -2,12 +2,19 @@ import threading import sys import os -from flask import Flask, request, jsonify, Response +from flask import Flask, request, jsonify, Response, stream_with_context +import json +import uuid +import queue +import requests +import logging +from datetime import datetime, timedelta +from models.device_instance import DeviceInstance # noqa: E402 from flask_cors import CORS # Register NetAlertX directories INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") -sys.path.extend([f"{INSTALL_PATH}/server"]) +sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) from logger import mylog # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] @@ -63,6 +70,9 @@ from .dbquery_endpoint import read_query, write_query, update_query, delete_quer from .sync_endpoint import handle_sync_post, handle_sync_get # noqa: E402 [flake8 lint suppression] from .logs_endpoint import clean_log # noqa: E402 [flake8 lint suppression] from models.user_events_queue_instance import UserEventsQueueInstance # noqa: E402 [flake8 lint suppression] +from database import DB # noqa: E402 [flake8 lint suppression] +from models.plugin_object_instance import PluginObjectInstance # noqa: E402 [flake8 lint suppression] +from plugin_helper import is_mac # noqa: E402 [flake8 lint suppression] from messaging.in_app import ( # noqa: E402 [flake8 lint suppression] write_notification, mark_all_notifications_read, @@ -71,15 +81,14 @@ from messaging.in_app import ( # noqa: E402 [flake8 lint suppression] delete_notification, mark_notification_as_read ) -from .tools_routes import tools_bp # noqa: E402 [flake8 lint suppression] -from .mcp_routes import mcp_bp # noqa: E402 [flake8 lint suppression] +from .tools_routes import openapi_spec as tools_openapi_spec # noqa: E402 [flake8 lint suppression] +# tools and mcp routes have been moved into this module (api_server_start) # Flask application app = Flask(__name__) # Register Blueprints -app.register_blueprint(tools_bp, url_prefix='/api/tools') -app.register_blueprint(mcp_bp, url_prefix='/api/mcp') +# No separate blueprints for tools or mcp - routes are registered below CORS( app, resources={ @@ -100,6 +109,195 @@ CORS( allow_headers=["Authorization", "Content-Type"], ) +# ----------------------------------------------- +# DB model instances for helper usage +# ----------------------------------------------- +db_helper = DB() +db_helper.open() +device_handler = DeviceInstance(db_helper) +plugin_object_handler = PluginObjectInstance(db_helper) + +# ------------------------------------------------------------------------------- +# MCP bridge variables + helpers (moved from mcp_routes) +# ------------------------------------------------------------------------------- +mcp_sessions = {} +mcp_sessions_lock = threading.Lock() +mcp_openapi_spec_cache = None + +BACKEND_PORT = get_setting_value("GRAPHQL_PORT") +API_BASE_URL = f"http://localhost:{BACKEND_PORT}/api/tools" + + +def get_openapi_spec_local(): + global mcp_openapi_spec_cache + if mcp_openapi_spec_cache: + return mcp_openapi_spec_cache + try: + resp = requests.get(f"{API_BASE_URL}/openapi.json", timeout=10) + resp.raise_for_status() + mcp_openapi_spec_cache = resp.json() + return mcp_openapi_spec_cache + except Exception as e: + mylog('minimal', [f"Error fetching OpenAPI spec: {e}"]) + return None + + +def map_openapi_to_mcp_tools(spec): + tools = [] + if not spec or 'paths' not in spec: + return tools + for path, methods in spec['paths'].items(): + for method, details in methods.items(): + if 'operationId' in details: + tool = { + 'name': details['operationId'], + 'description': details.get('description', details.get('summary', '')), + 'inputSchema': {'type': 'object', 'properties': {}, 'required': []}, + } + if 'requestBody' in details: + content = details['requestBody'].get('content', {}) + if 'application/json' in content: + schema = content['application/json'].get('schema', {}) + tool['inputSchema'] = schema.copy() + if 'properties' not in tool['inputSchema']: + tool['inputSchema']['properties'] = {} + if 'parameters' in details: + for param in details['parameters']: + if param.get('in') == 'query': + tool['inputSchema']['properties'][param['name']] = { + 'type': param.get('schema', {}).get('type', 'string'), + 'description': param.get('description', ''), + } + if param.get('required'): + tool['inputSchema'].setdefault('required', []).append(param['name']) + tools.append(tool) + return tools + + +def process_mcp_request(data): + method = data.get('method') + msg_id = data.get('id') + response = None + if method == 'initialize': + response = { + 'jsonrpc': '2.0', + 'id': msg_id, + 'result': { + 'protocolVersion': '2024-11-05', + 'capabilities': {'tools': {}}, + 'serverInfo': {'name': 'NetAlertX', 'version': '1.0.0'}, + }, + } + elif method == 'notifications/initialized': + pass + elif method == 'tools/list': + spec = get_openapi_spec_local() + tools = map_openapi_to_mcp_tools(spec) + response = {'jsonrpc': '2.0', 'id': msg_id, 'result': {'tools': tools}} + elif method == 'tools/call': + params = data.get('params', {}) + tool_name = params.get('name') + tool_args = params.get('arguments', {}) + spec = get_openapi_spec_local() + target_path = None + target_method = None + if spec and 'paths' in spec: + for path, methods in spec['paths'].items(): + for m, details in methods.items(): + if details.get('operationId') == tool_name: + target_path = path + target_method = m.upper() + break + if target_path: + break + if target_path: + try: + headers = {'Content-Type': 'application/json'} + if 'Authorization' in request.headers: + headers['Authorization'] = request.headers['Authorization'] + url = f"{API_BASE_URL}{target_path}" + if target_method == 'POST': + api_res = requests.post(url, json=tool_args, headers=headers, timeout=30) + elif target_method == 'GET': + api_res = requests.get(url, params=tool_args, headers=headers, timeout=30) + else: + api_res = None + if api_res: + content = [] + try: + json_content = api_res.json() + content.append({'type': 'text', 'text': json.dumps(json_content, indent=2)}) + except Exception: + content.append({'type': 'text', 'text': api_res.text}) + is_error = api_res.status_code >= 400 + response = {'jsonrpc': '2.0', 'id': msg_id, 'result': {'content': content, 'isError': is_error}} + else: + response = {'jsonrpc': '2.0', 'id': msg_id, 'error': {'code': -32601, 'message': f"Method {target_method} not supported"}} + except Exception as e: + response = {'jsonrpc': '2.0', 'id': msg_id, 'result': {'content': [{'type': 'text', 'text': f"Error calling tool: {str(e)}"}], 'isError': True}} + else: + response = {'jsonrpc': '2.0', 'id': msg_id, 'error': {'code': -32601, 'message': f"Tool {tool_name} not found"}} + elif method == 'ping': + response = {'jsonrpc': '2.0', 'id': msg_id, 'result': {}} + else: + if msg_id: + response = {'jsonrpc': '2.0', 'id': msg_id, 'error': {'code': -32601, 'message': 'Method not found'}} + return response + + +@app.route('/api/mcp/sse', methods=['GET', 'POST']) +def api_mcp_sse(): + if request.method == 'POST': + try: + data = request.get_json(silent=True) + if data and 'method' in data and 'jsonrpc' in data: + response = process_mcp_request(data) + if response: + return jsonify(response) + else: + return '', 202 + except Exception as e: + logging.getLogger(__name__).debug(f'SSE POST processing error: {e}') + return jsonify({'status': 'ok', 'message': 'MCP SSE endpoint active'}), 200 + + session_id = uuid.uuid4().hex + q = queue.Queue() + with mcp_sessions_lock: + mcp_sessions[session_id] = q + + def stream(): + yield f"event: endpoint\ndata: /api/mcp/messages?session_id={session_id}\n\n" + try: + while True: + try: + message = q.get(timeout=20) + yield f"event: message\ndata: {json.dumps(message)}\n\n" + except queue.Empty: + yield ": keep-alive\n\n" + except GeneratorExit: + with mcp_sessions_lock: + if session_id in mcp_sessions: + del mcp_sessions[session_id] + return Response(stream_with_context(stream()), mimetype='text/event-stream') + + +@app.route('/api/mcp/messages', methods=['POST']) +def api_mcp_messages(): + session_id = request.args.get('session_id') + if not session_id: + return jsonify({"error": "Missing session_id"}), 400 + with mcp_sessions_lock: + if session_id not in mcp_sessions: + return jsonify({"error": "Session not found"}), 404 + q = mcp_sessions[session_id] + data = request.json + if not data: + return jsonify({"error": "Invalid JSON"}), 400 + response = process_mcp_request(data) + if response: + q.put(response) + return jsonify({"status": "accepted"}), 202 + # ------------------------------------------------------------------- # Custom handler for 404 - Route not found @@ -109,13 +307,13 @@ def log_request_info(): """Log details of every incoming request.""" # Filter out noisy requests if needed, but user asked for drastic logging mylog("verbose", [f"[HTTP] {request.method} {request.path} from {request.remote_addr}"]) - # Filter sensitive headers before logging - safe_headers = {k: v for k, v in request.headers if k.lower() not in ('authorization', 'cookie', 'x-api-key')} - mylog("debug", [f"[HTTP] Headers: {safe_headers}"]) + # Filter sensitive headers before logging + safe_headers = {k: v for k, v in request.headers if k.lower() not in ('authorization', 'cookie', 'x-api-key')} + mylog("debug", [f"[HTTP] Headers: {safe_headers}"]) if request.method == "POST": # Be careful with large bodies, but log first 1000 chars data = request.get_data(as_text=True) - mylog("debug", [f"[HTTP] Body length: {len(data)} chars"]) + mylog("debug", [f"[HTTP] Body length: {len(data)} chars"]) @app.errorhandler(404) @@ -166,6 +364,183 @@ def graphql_endpoint(): return jsonify(response) +# -------------------------- +# Tools endpoints (moved from tools_routes) +# -------------------------- + + +@app.route('/api/tools/trigger_scan', methods=['POST']) +def api_trigger_scan(): + if not is_authorized(): + return jsonify({"error": "Unauthorized"}), 401 + + data = request.get_json() or {} + scan_type = data.get('scan_type', 'nmap_fast') + # Map requested scan type to plugin prefix + plugin_prefix = None + if scan_type in ['nmap_fast', 'nmap_deep']: + plugin_prefix = 'NMAPDEV' + elif scan_type == 'arp': + plugin_prefix = 'ARPSCAN' + else: + return jsonify({"error": "Invalid scan_type. Must be 'arp', 'nmap_fast', or 'nmap_deep'"}), 400 + + queue_instance = UserEventsQueueInstance() + action = f"run|{plugin_prefix}" + success, message = queue_instance.add_event(action) + if success: + return jsonify({"success": True, "message": f"Triggered plugin {plugin_prefix} via ad-hoc queue."}) + else: + return jsonify({"success": False, "error": message}), 500 + + +@app.route('/api/tools/list_devices', methods=['POST']) +def api_tools_list_devices(): + if not is_authorized(): + return jsonify({"error": "Unauthorized"}), 401 + return get_all_devices() + + +@app.route('/api/tools/get_device_info', methods=['POST']) +def api_tools_get_device_info(): + if not is_authorized(): + return jsonify({"error": "Unauthorized"}), 401 + data = request.get_json(silent=True) or {} + query = data.get('query') + if not query: + return jsonify({"error": "Missing 'query' parameter"}), 400 + # if MAC -> device endpoint + if is_mac(query): + return get_device_data(query) + # search by name or IP + matches = device_handler.search(query) + if not matches: + return jsonify({"message": "No devices found"}), 404 + return jsonify(matches) + + +@app.route('/api/tools/get_latest_device', methods=['POST']) +def api_tools_get_latest_device(): + if not is_authorized(): + return jsonify({"error": "Unauthorized"}), 401 + latest = device_handler.getLatest() + if not latest: + return jsonify({"message": "No devices found"}), 404 + return jsonify([latest]) + + +@app.route('/api/tools/get_open_ports', methods=['POST']) +def api_tools_get_open_ports(): + if not is_authorized(): + return jsonify({"error": "Unauthorized"}), 401 + data = request.get_json(silent=True) or {} + target = data.get('target') + if not target: + return jsonify({"error": "Target is required"}), 400 + + # If MAC is provided, use plugin objects to get port entries + if is_mac(target): + entries = plugin_object_handler.getByPrimary('NMAP', target.lower()) + open_ports = [] + for e in entries: + try: + port = int(e.get('Object_SecondaryID', 0)) + except (ValueError, TypeError): + continue + service = e.get('Watched_Value2', 'unknown') + open_ports.append({"port": port, "service": service}) + return jsonify({"success": True, "target": target, "open_ports": open_ports, "raw": entries}) + + # If IP provided, try to resolve to MAC and proceed + # Use device handler to resolve IP + device = device_handler.getByIP(target) + if device and device.get('devMac'): + mac = device.get('devMac') + entries = plugin_object_handler.getByPrimary('NMAP', mac.lower()) + open_ports = [] + for e in entries: + try: + port = int(e.get('Object_SecondaryID', 0)) + except (ValueError, TypeError): + continue + service = e.get('Watched_Value2', 'unknown') + open_ports.append({"port": port, "service": service}) + return jsonify({"success": True, "target": target, "open_ports": open_ports, "raw": entries}) + + # No plugin data found; as fallback use nettools nmap_scan (may run subprocess) + # Note: Prefer plugin data (NMAP) when available + res = nmap_scan(target, 'fast') + return res + + +@app.route('/api/tools/get_network_topology', methods=['GET']) +def api_tools_get_network_topology(): + if not is_authorized(): + return jsonify({"error": "Unauthorized"}), 401 + topo = device_handler.getNetworkTopology() + return jsonify(topo) + + +@app.route('/api/tools/get_recent_alerts', methods=['POST']) +def api_tools_get_recent_alerts(): + if not is_authorized(): + return jsonify({"error": "Unauthorized"}), 401 + data = request.get_json(silent=True) or {} + hours = int(data.get('hours', 24)) + # Reuse get_events() - which returns a Flask response with JSON containing 'events' + res = get_events() + events_json = res.get_json() if hasattr(res, 'get_json') else None + events = events_json.get('events', []) if events_json else [] + cutoff = datetime.now() - timedelta(hours=hours) + filtered = [e for e in events if 'eve_DateTime' in e and datetime.strptime(e['eve_DateTime'], '%Y-%m-%d %H:%M:%S') > cutoff] + return jsonify(filtered) + + +@app.route('/api/tools/set_device_alias', methods=['POST']) +def api_tools_set_device_alias(): + if not is_authorized(): + return jsonify({"error": "Unauthorized"}), 401 + data = request.get_json(silent=True) or {} + mac = data.get('mac') + alias = data.get('alias') + if not mac or not alias: + return jsonify({"error": "MAC and Alias are required"}), 400 + return update_device_column(mac, 'devName', alias) + + +@app.route('/api/tools/wol_wake_device', methods=['POST']) +def api_tools_wol_wake_device(): + if not is_authorized(): + return jsonify({"error": "Unauthorized"}), 401 + data = request.get_json(silent=True) or {} + mac = data.get('mac') + ip = data.get('ip') + if not mac and not ip: + return jsonify({"error": "MAC or IP is required"}), 400 + # Resolve IP to MAC if needed + if not mac and ip: + device = device_handler.getByIP(ip) + if not device or not device.get('devMac'): + return jsonify({"error": f"Could not resolve MAC for IP {ip}"}), 404 + mac = device.get('devMac') + # Validate mac using is_mac helper + if not is_mac(mac): + return jsonify({"success": False, "error": f"Invalid MAC: {mac}"}), 400 + return wakeonlan(mac) + + +@app.route('/api/tools/openapi.json', methods=['GET']) +def api_tools_openapi_spec(): + # Minimal OpenAPI spec for tools + spec = { + "openapi": "3.0.0", + "info": {"title": "NetAlertX Tools", "version": "1.1.0"}, + "servers": [{"url": "/api/tools"}], + "paths": {} + } + return jsonify(spec) + + # -------------------------- # Settings Endpoints # -------------------------- From 8c982cd476a132f82eec572e8c5067dfe3457898 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 7 Dec 2025 08:20:51 +0000 Subject: [PATCH 08/20] MCP refactor Signed-off-by: GitHub --- .devcontainer/devcontainer.json | 4 +- .github/copilot-instructions.md | 5 +- front/plugins/avahi_scan/avahi_scan.py | 5 +- front/plugins/dig_scan/digscan.py | 7 +- front/plugins/icmp_scan/icmp.py | 7 +- front/plugins/nbtscan_scan/nbtscan.py | 7 +- front/plugins/nslookup_scan/nslookup.py | 7 +- front/plugins/omada_sdn_imp/omada_sdn.py | 6 +- front/plugins/wake_on_lan/wake_on_lan.py | 7 +- server/api_server/api_server_start.py | 573 ++++++--------- server/api_server/devices_endpoint.py | 3 +- server/api_server/mcp_endpoint.py | 207 ++++++ server/api_server/tools_routes.py | 686 ------------------ server/models/device_instance.py | 219 +++--- server/models/event_instance.py | 106 +++ server/models/plugin_object_instance.py | 134 ++-- server/scan/device_handling.py | 2 +- server/workflows/actions.py | 8 +- .../api_endpoints/test_mcp_tools_endpoints.py | 313 ++++---- test/api_endpoints/test_tools_endpoints.py | 79 -- 20 files changed, 900 insertions(+), 1485 deletions(-) create mode 100644 server/api_server/mcp_endpoint.py delete mode 100644 server/api_server/tools_routes.py create mode 100644 server/models/event_instance.py delete mode 100644 test/api_endpoints/test_tools_endpoints.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9a179c80..73f1e89f 100755 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -25,7 +25,7 @@ // even within this container and connect to them as needed. // "--network=host", ], - "mounts": [ + "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" //used for testing various conditions in docker ], // ATTENTION: If running with --network=host, COMMENT `forwardPorts` OR ELSE THERE WILL BE NO WEBUI! @@ -88,7 +88,7 @@ } }, "terminal.integrated.defaultProfile.linux": "zsh", - + // Python testing configuration "python.testing.pytestEnabled": true, "python.testing.unittestEnabled": false, diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 522eed73..5da5e809 100755 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -39,6 +39,7 @@ Backend loop phases (see `server/__main__.py` and `server/plugin.py`): `once`, ` ## API/Endpoints quick map - Flask app: `server/api_server/api_server_start.py` exposes routes like `/device/`, `/devices`, `/devices/export/{csv,json}`, `/devices/import`, `/devices/totals`, `/devices/by-status`, plus `nettools`, `events`, `sessions`, `dbquery`, `metrics`, `sync`. - Authorization: all routes expect header `Authorization: Bearer ` via `get_setting_value('API_TOKEN')`. +- All responses need to return `"success":` and if `False` an "error" message needs to be returned, e.g. `{"success": False, "error": f"No stored open ports for Device"}` ## Conventions & helpers to reuse - Settings: add/modify via `ccd()` in `server/initialise.py` or per‑plugin manifest. Never hardcode ports or secrets; use `get_setting_value()`. @@ -85,7 +86,7 @@ Backend loop phases (see `server/__main__.py` and `server/plugin.py`): `once`, ` - Above all, use the simplest possible code that meets the need so it can be easily audited and maintained. - Always leave logging enabled. If there is a possiblity it will be difficult to debug with current logging, add more logging. - Always run the testFailure tool before executing any tests to gather current failure information and avoid redundant runs. -- Always prioritize using the appropriate tools in the environment first. As an example if a test is failing use `testFailure` then `runTests`. Never `runTests` first. +- Always prioritize using the appropriate tools in the environment first. As an example if a test is failing use `testFailure` then `runTests`. Never `runTests` first. - Docker tests take an extremely long time to run. Avoid changes to docker or tests until you've examined the exisiting testFailures and runTests results. -- Environment tools are designed specifically for your use in this project and running them in this order will give you the best results. +- Environment tools are designed specifically for your use in this project and running them in this order will give you the best results. diff --git a/front/plugins/avahi_scan/avahi_scan.py b/front/plugins/avahi_scan/avahi_scan.py index 5c552181..c45a8b9e 100755 --- a/front/plugins/avahi_scan/avahi_scan.py +++ b/front/plugins/avahi_scan/avahi_scan.py @@ -12,7 +12,6 @@ from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from const import logPath # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] -from database import DB # noqa: E402 [flake8 lint suppression] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression] from pytz import timezone # noqa: E402 [flake8 lint suppression] @@ -98,9 +97,7 @@ def main(): {"devMac": "00:11:22:33:44:57", "devLastIP": "192.168.1.82"}, ] else: - db = DB() - db.open() - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() devices = ( device_handler.getAll() if get_setting_value("REFRESH_FQDN") diff --git a/front/plugins/dig_scan/digscan.py b/front/plugins/dig_scan/digscan.py index 15280af2..1cb345e9 100755 --- a/front/plugins/dig_scan/digscan.py +++ b/front/plugins/dig_scan/digscan.py @@ -11,7 +11,6 @@ from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from const import logPath # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] -from database import DB # noqa: E402 [flake8 lint suppression] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression] from pytz import timezone # noqa: E402 [flake8 lint suppression] @@ -38,15 +37,11 @@ def main(): timeout = get_setting_value('DIGSCAN_RUN_TIMEOUT') - # Create a database connection - db = DB() # instance of class DB - db.open() - # Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) # Create a DeviceInstance instance - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() # Retrieve devices if get_setting_value("REFRESH_FQDN"): diff --git a/front/plugins/icmp_scan/icmp.py b/front/plugins/icmp_scan/icmp.py index 82544800..3e2a2664 100755 --- a/front/plugins/icmp_scan/icmp.py +++ b/front/plugins/icmp_scan/icmp.py @@ -15,7 +15,6 @@ from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] from const import logPath # noqa: E402 [flake8 lint suppression] -from database import DB # noqa: E402 [flake8 lint suppression] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression] from pytz import timezone # noqa: E402 [flake8 lint suppression] @@ -41,15 +40,11 @@ def main(): args = get_setting_value('ICMP_ARGS') in_regex = get_setting_value('ICMP_IN_REGEX') - # Create a database connection - db = DB() # instance of class DB - db.open() - # Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) # Create a DeviceInstance instance - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() # Retrieve devices all_devices = device_handler.getAll() diff --git a/front/plugins/nbtscan_scan/nbtscan.py b/front/plugins/nbtscan_scan/nbtscan.py index 689c093b..780aefd5 100755 --- a/front/plugins/nbtscan_scan/nbtscan.py +++ b/front/plugins/nbtscan_scan/nbtscan.py @@ -12,7 +12,6 @@ from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from const import logPath # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] -from database import DB # noqa: E402 [flake8 lint suppression] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression] from pytz import timezone # noqa: E402 [flake8 lint suppression] @@ -40,15 +39,11 @@ def main(): # timeout = get_setting_value('NBLOOKUP_RUN_TIMEOUT') timeout = 20 - # Create a database connection - db = DB() # instance of class DB - db.open() - # Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) # Create a DeviceInstance instance - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() # Retrieve devices if get_setting_value("REFRESH_FQDN"): diff --git a/front/plugins/nslookup_scan/nslookup.py b/front/plugins/nslookup_scan/nslookup.py index 8d9997ad..135ae14a 100755 --- a/front/plugins/nslookup_scan/nslookup.py +++ b/front/plugins/nslookup_scan/nslookup.py @@ -15,7 +15,6 @@ from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] from const import logPath # noqa: E402 [flake8 lint suppression] -from database import DB # noqa: E402 [flake8 lint suppression] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression] from pytz import timezone # noqa: E402 [flake8 lint suppression] @@ -39,15 +38,11 @@ def main(): timeout = get_setting_value('NSLOOKUP_RUN_TIMEOUT') - # Create a database connection - db = DB() # instance of class DB - db.open() - # Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) # Create a DeviceInstance instance - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() # Retrieve devices if get_setting_value("REFRESH_FQDN"): diff --git a/front/plugins/omada_sdn_imp/omada_sdn.py b/front/plugins/omada_sdn_imp/omada_sdn.py index ae429b01..8ee3ffea 100755 --- a/front/plugins/omada_sdn_imp/omada_sdn.py +++ b/front/plugins/omada_sdn_imp/omada_sdn.py @@ -256,13 +256,11 @@ def main(): start_time = time.time() mylog("verbose", [f"[{pluginName}] starting execution"]) - from database import DB + from models.device_instance import DeviceInstance - db = DB() # instance of class DB - db.open() # Create a DeviceInstance instance - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() # Retrieve configuration settings # these should be self-explanatory omada_sites = [] diff --git a/front/plugins/wake_on_lan/wake_on_lan.py b/front/plugins/wake_on_lan/wake_on_lan.py index 4ef01e84..e65cbbed 100755 --- a/front/plugins/wake_on_lan/wake_on_lan.py +++ b/front/plugins/wake_on_lan/wake_on_lan.py @@ -13,7 +13,6 @@ from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression] from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from const import logPath # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression] -from database import DB # noqa: E402 [flake8 lint suppression] from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression] @@ -44,12 +43,8 @@ def main(): mylog('verbose', [f'[{pluginName}] broadcast_ips value {broadcast_ips}']) - # Create a database connection - db = DB() # instance of class DB - db.open() - # Create a DeviceInstance instance - device_handler = DeviceInstance(db) + device_handler = DeviceInstance() # Retrieve devices if 'offline' in devices_to_wake: diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 06a22a97..39b660dc 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -2,13 +2,8 @@ import threading import sys import os -from flask import Flask, request, jsonify, Response, stream_with_context -import json -import uuid -import queue +from flask import Flask, request, jsonify, Response import requests -import logging -from datetime import datetime, timedelta from models.device_instance import DeviceInstance # noqa: E402 from flask_cors import CORS @@ -70,9 +65,12 @@ from .dbquery_endpoint import read_query, write_query, update_query, delete_quer from .sync_endpoint import handle_sync_post, handle_sync_get # noqa: E402 [flake8 lint suppression] from .logs_endpoint import clean_log # noqa: E402 [flake8 lint suppression] from models.user_events_queue_instance import UserEventsQueueInstance # noqa: E402 [flake8 lint suppression] -from database import DB # noqa: E402 [flake8 lint suppression] -from models.plugin_object_instance import PluginObjectInstance # noqa: E402 [flake8 lint suppression] + +from models.event_instance import EventInstance # noqa: E402 [flake8 lint suppression] +# Import tool logic from the MCP/tools module to reuse behavior (no blueprints) from plugin_helper import is_mac # noqa: E402 [flake8 lint suppression] +# is_mac is provided in mcp_endpoint and used by those handlers +# mcp_endpoint contains helper functions; routes moved into this module to keep a single place for routes from messaging.in_app import ( # noqa: E402 [flake8 lint suppression] write_notification, mark_all_notifications_read, @@ -81,14 +79,17 @@ from messaging.in_app import ( # noqa: E402 [flake8 lint suppression] delete_notification, mark_notification_as_read ) -from .tools_routes import openapi_spec as tools_openapi_spec # noqa: E402 [flake8 lint suppression] +from .mcp_endpoint import ( # noqa: E402 [flake8 lint suppression] + mcp_sse, + mcp_messages, + openapi_spec +) # tools and mcp routes have been moved into this module (api_server_start) # Flask application app = Flask(__name__) -# Register Blueprints -# No separate blueprints for tools or mcp - routes are registered below + CORS( app, resources={ @@ -103,30 +104,22 @@ CORS( r"/messaging/*": {"origins": "*"}, r"/events/*": {"origins": "*"}, r"/logs/*": {"origins": "*"}, - r"/api/tools/*": {"origins": "*"} - r"/auth/*": {"origins": "*"} + r"/api/tools/*": {"origins": "*"}, + r"/auth/*": {"origins": "*"}, + r"/mcp/*": {"origins": "*"} }, supports_credentials=True, allow_headers=["Authorization", "Content-Type"], ) -# ----------------------------------------------- -# DB model instances for helper usage -# ----------------------------------------------- -db_helper = DB() -db_helper.open() -device_handler = DeviceInstance(db_helper) -plugin_object_handler = PluginObjectInstance(db_helper) - # ------------------------------------------------------------------------------- # MCP bridge variables + helpers (moved from mcp_routes) # ------------------------------------------------------------------------------- -mcp_sessions = {} -mcp_sessions_lock = threading.Lock() + mcp_openapi_spec_cache = None BACKEND_PORT = get_setting_value("GRAPHQL_PORT") -API_BASE_URL = f"http://localhost:{BACKEND_PORT}/api/tools" +API_BASE_URL = f"http://localhost:{BACKEND_PORT}" def get_openapi_spec_local(): @@ -134,7 +127,7 @@ def get_openapi_spec_local(): if mcp_openapi_spec_cache: return mcp_openapi_spec_cache try: - resp = requests.get(f"{API_BASE_URL}/openapi.json", timeout=10) + resp = requests.get(f"{API_BASE_URL}/mcp/openapi.json", timeout=10) resp.raise_for_status() mcp_openapi_spec_cache = resp.json() return mcp_openapi_spec_cache @@ -143,161 +136,18 @@ def get_openapi_spec_local(): return None -def map_openapi_to_mcp_tools(spec): - tools = [] - if not spec or 'paths' not in spec: - return tools - for path, methods in spec['paths'].items(): - for method, details in methods.items(): - if 'operationId' in details: - tool = { - 'name': details['operationId'], - 'description': details.get('description', details.get('summary', '')), - 'inputSchema': {'type': 'object', 'properties': {}, 'required': []}, - } - if 'requestBody' in details: - content = details['requestBody'].get('content', {}) - if 'application/json' in content: - schema = content['application/json'].get('schema', {}) - tool['inputSchema'] = schema.copy() - if 'properties' not in tool['inputSchema']: - tool['inputSchema']['properties'] = {} - if 'parameters' in details: - for param in details['parameters']: - if param.get('in') == 'query': - tool['inputSchema']['properties'][param['name']] = { - 'type': param.get('schema', {}).get('type', 'string'), - 'description': param.get('description', ''), - } - if param.get('required'): - tool['inputSchema'].setdefault('required', []).append(param['name']) - tools.append(tool) - return tools - - -def process_mcp_request(data): - method = data.get('method') - msg_id = data.get('id') - response = None - if method == 'initialize': - response = { - 'jsonrpc': '2.0', - 'id': msg_id, - 'result': { - 'protocolVersion': '2024-11-05', - 'capabilities': {'tools': {}}, - 'serverInfo': {'name': 'NetAlertX', 'version': '1.0.0'}, - }, - } - elif method == 'notifications/initialized': - pass - elif method == 'tools/list': - spec = get_openapi_spec_local() - tools = map_openapi_to_mcp_tools(spec) - response = {'jsonrpc': '2.0', 'id': msg_id, 'result': {'tools': tools}} - elif method == 'tools/call': - params = data.get('params', {}) - tool_name = params.get('name') - tool_args = params.get('arguments', {}) - spec = get_openapi_spec_local() - target_path = None - target_method = None - if spec and 'paths' in spec: - for path, methods in spec['paths'].items(): - for m, details in methods.items(): - if details.get('operationId') == tool_name: - target_path = path - target_method = m.upper() - break - if target_path: - break - if target_path: - try: - headers = {'Content-Type': 'application/json'} - if 'Authorization' in request.headers: - headers['Authorization'] = request.headers['Authorization'] - url = f"{API_BASE_URL}{target_path}" - if target_method == 'POST': - api_res = requests.post(url, json=tool_args, headers=headers, timeout=30) - elif target_method == 'GET': - api_res = requests.get(url, params=tool_args, headers=headers, timeout=30) - else: - api_res = None - if api_res: - content = [] - try: - json_content = api_res.json() - content.append({'type': 'text', 'text': json.dumps(json_content, indent=2)}) - except Exception: - content.append({'type': 'text', 'text': api_res.text}) - is_error = api_res.status_code >= 400 - response = {'jsonrpc': '2.0', 'id': msg_id, 'result': {'content': content, 'isError': is_error}} - else: - response = {'jsonrpc': '2.0', 'id': msg_id, 'error': {'code': -32601, 'message': f"Method {target_method} not supported"}} - except Exception as e: - response = {'jsonrpc': '2.0', 'id': msg_id, 'result': {'content': [{'type': 'text', 'text': f"Error calling tool: {str(e)}"}], 'isError': True}} - else: - response = {'jsonrpc': '2.0', 'id': msg_id, 'error': {'code': -32601, 'message': f"Tool {tool_name} not found"}} - elif method == 'ping': - response = {'jsonrpc': '2.0', 'id': msg_id, 'result': {}} - else: - if msg_id: - response = {'jsonrpc': '2.0', 'id': msg_id, 'error': {'code': -32601, 'message': 'Method not found'}} - return response - - -@app.route('/api/mcp/sse', methods=['GET', 'POST']) +@app.route('/mcp/sse', methods=['GET', 'POST']) def api_mcp_sse(): - if request.method == 'POST': - try: - data = request.get_json(silent=True) - if data and 'method' in data and 'jsonrpc' in data: - response = process_mcp_request(data) - if response: - return jsonify(response) - else: - return '', 202 - except Exception as e: - logging.getLogger(__name__).debug(f'SSE POST processing error: {e}') - return jsonify({'status': 'ok', 'message': 'MCP SSE endpoint active'}), 200 - - session_id = uuid.uuid4().hex - q = queue.Queue() - with mcp_sessions_lock: - mcp_sessions[session_id] = q - - def stream(): - yield f"event: endpoint\ndata: /api/mcp/messages?session_id={session_id}\n\n" - try: - while True: - try: - message = q.get(timeout=20) - yield f"event: message\ndata: {json.dumps(message)}\n\n" - except queue.Empty: - yield ": keep-alive\n\n" - except GeneratorExit: - with mcp_sessions_lock: - if session_id in mcp_sessions: - del mcp_sessions[session_id] - return Response(stream_with_context(stream()), mimetype='text/event-stream') + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + return mcp_sse() @app.route('/api/mcp/messages', methods=['POST']) def api_mcp_messages(): - session_id = request.args.get('session_id') - if not session_id: - return jsonify({"error": "Missing session_id"}), 400 - with mcp_sessions_lock: - if session_id not in mcp_sessions: - return jsonify({"error": "Session not found"}), 404 - q = mcp_sessions[session_id] - data = request.json - if not data: - return jsonify({"error": "Invalid JSON"}), 400 - response = process_mcp_request(data) - if response: - q.put(response) - return jsonify({"status": "accepted"}), 202 + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + return mcp_messages() # ------------------------------------------------------------------- @@ -365,188 +215,12 @@ def graphql_endpoint(): return jsonify(response) -# -------------------------- -# Tools endpoints (moved from tools_routes) -# -------------------------- - - -@app.route('/api/tools/trigger_scan', methods=['POST']) -def api_trigger_scan(): - if not is_authorized(): - return jsonify({"error": "Unauthorized"}), 401 - - data = request.get_json() or {} - scan_type = data.get('scan_type', 'nmap_fast') - # Map requested scan type to plugin prefix - plugin_prefix = None - if scan_type in ['nmap_fast', 'nmap_deep']: - plugin_prefix = 'NMAPDEV' - elif scan_type == 'arp': - plugin_prefix = 'ARPSCAN' - else: - return jsonify({"error": "Invalid scan_type. Must be 'arp', 'nmap_fast', or 'nmap_deep'"}), 400 - - queue_instance = UserEventsQueueInstance() - action = f"run|{plugin_prefix}" - success, message = queue_instance.add_event(action) - if success: - return jsonify({"success": True, "message": f"Triggered plugin {plugin_prefix} via ad-hoc queue."}) - else: - return jsonify({"success": False, "error": message}), 500 - - -@app.route('/api/tools/list_devices', methods=['POST']) -def api_tools_list_devices(): - if not is_authorized(): - return jsonify({"error": "Unauthorized"}), 401 - return get_all_devices() - - -@app.route('/api/tools/get_device_info', methods=['POST']) -def api_tools_get_device_info(): - if not is_authorized(): - return jsonify({"error": "Unauthorized"}), 401 - data = request.get_json(silent=True) or {} - query = data.get('query') - if not query: - return jsonify({"error": "Missing 'query' parameter"}), 400 - # if MAC -> device endpoint - if is_mac(query): - return get_device_data(query) - # search by name or IP - matches = device_handler.search(query) - if not matches: - return jsonify({"message": "No devices found"}), 404 - return jsonify(matches) - - -@app.route('/api/tools/get_latest_device', methods=['POST']) -def api_tools_get_latest_device(): - if not is_authorized(): - return jsonify({"error": "Unauthorized"}), 401 - latest = device_handler.getLatest() - if not latest: - return jsonify({"message": "No devices found"}), 404 - return jsonify([latest]) - - -@app.route('/api/tools/get_open_ports', methods=['POST']) -def api_tools_get_open_ports(): - if not is_authorized(): - return jsonify({"error": "Unauthorized"}), 401 - data = request.get_json(silent=True) or {} - target = data.get('target') - if not target: - return jsonify({"error": "Target is required"}), 400 - - # If MAC is provided, use plugin objects to get port entries - if is_mac(target): - entries = plugin_object_handler.getByPrimary('NMAP', target.lower()) - open_ports = [] - for e in entries: - try: - port = int(e.get('Object_SecondaryID', 0)) - except (ValueError, TypeError): - continue - service = e.get('Watched_Value2', 'unknown') - open_ports.append({"port": port, "service": service}) - return jsonify({"success": True, "target": target, "open_ports": open_ports, "raw": entries}) - - # If IP provided, try to resolve to MAC and proceed - # Use device handler to resolve IP - device = device_handler.getByIP(target) - if device and device.get('devMac'): - mac = device.get('devMac') - entries = plugin_object_handler.getByPrimary('NMAP', mac.lower()) - open_ports = [] - for e in entries: - try: - port = int(e.get('Object_SecondaryID', 0)) - except (ValueError, TypeError): - continue - service = e.get('Watched_Value2', 'unknown') - open_ports.append({"port": port, "service": service}) - return jsonify({"success": True, "target": target, "open_ports": open_ports, "raw": entries}) - - # No plugin data found; as fallback use nettools nmap_scan (may run subprocess) - # Note: Prefer plugin data (NMAP) when available - res = nmap_scan(target, 'fast') - return res - - -@app.route('/api/tools/get_network_topology', methods=['GET']) -def api_tools_get_network_topology(): - if not is_authorized(): - return jsonify({"error": "Unauthorized"}), 401 - topo = device_handler.getNetworkTopology() - return jsonify(topo) - - -@app.route('/api/tools/get_recent_alerts', methods=['POST']) -def api_tools_get_recent_alerts(): - if not is_authorized(): - return jsonify({"error": "Unauthorized"}), 401 - data = request.get_json(silent=True) or {} - hours = int(data.get('hours', 24)) - # Reuse get_events() - which returns a Flask response with JSON containing 'events' - res = get_events() - events_json = res.get_json() if hasattr(res, 'get_json') else None - events = events_json.get('events', []) if events_json else [] - cutoff = datetime.now() - timedelta(hours=hours) - filtered = [e for e in events if 'eve_DateTime' in e and datetime.strptime(e['eve_DateTime'], '%Y-%m-%d %H:%M:%S') > cutoff] - return jsonify(filtered) - - -@app.route('/api/tools/set_device_alias', methods=['POST']) -def api_tools_set_device_alias(): - if not is_authorized(): - return jsonify({"error": "Unauthorized"}), 401 - data = request.get_json(silent=True) or {} - mac = data.get('mac') - alias = data.get('alias') - if not mac or not alias: - return jsonify({"error": "MAC and Alias are required"}), 400 - return update_device_column(mac, 'devName', alias) - - -@app.route('/api/tools/wol_wake_device', methods=['POST']) -def api_tools_wol_wake_device(): - if not is_authorized(): - return jsonify({"error": "Unauthorized"}), 401 - data = request.get_json(silent=True) or {} - mac = data.get('mac') - ip = data.get('ip') - if not mac and not ip: - return jsonify({"error": "MAC or IP is required"}), 400 - # Resolve IP to MAC if needed - if not mac and ip: - device = device_handler.getByIP(ip) - if not device or not device.get('devMac'): - return jsonify({"error": f"Could not resolve MAC for IP {ip}"}), 404 - mac = device.get('devMac') - # Validate mac using is_mac helper - if not is_mac(mac): - return jsonify({"success": False, "error": f"Invalid MAC: {mac}"}), 400 - return wakeonlan(mac) - - -@app.route('/api/tools/openapi.json', methods=['GET']) -def api_tools_openapi_spec(): - # Minimal OpenAPI spec for tools - spec = { - "openapi": "3.0.0", - "info": {"title": "NetAlertX Tools", "version": "1.1.0"}, - "servers": [{"url": "/api/tools"}], - "paths": {} - } - return jsonify(spec) +# Tools endpoints are registered via `mcp_endpoint.tools_bp` blueprint. # -------------------------- # Settings Endpoints # -------------------------- - - @app.route("/settings/", methods=["GET"]) def api_get_setting(setKey): if not is_authorized(): @@ -558,8 +232,7 @@ def api_get_setting(setKey): # -------------------------- # Device Endpoints # -------------------------- - - +@app.route('/mcp/sse/device/', methods=['GET', 'POST']) @app.route("/device/", methods=["GET"]) def api_get_device(mac): if not is_authorized(): @@ -625,11 +298,45 @@ def api_update_device_column(mac): return update_device_column(mac, column_name, column_value) +@app.route('/mcp/sse/device//set-alias', methods=['POST']) +@app.route('/device//set-alias', methods=['POST']) +def api_device_set_alias(mac): + """Set the device alias - convenience wrapper around update_device_column.""" + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + data = request.get_json() or {} + alias = data.get('alias') + if not alias: + return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "alias is required"}), 400 + return update_device_column(mac, 'devName', alias) + + +@app.route('/mcp/sse/device/open_ports', methods=['POST']) +@app.route('/device/open_ports', methods=['POST']) +def api_device_open_ports(): + """Get stored NMAP open ports for a target IP or MAC.""" + if not is_authorized(): + return jsonify({"success": False, "error": "Unauthorized"}), 401 + + data = request.get_json(silent=True) or {} + target = data.get('target') + if not target: + return jsonify({"success": False, "error": "Target (IP or MAC) is required"}), 400 + + device_handler = DeviceInstance() + + # Use DeviceInstance method to get stored open ports + open_ports = device_handler.getOpenPorts(target) + + if not open_ports: + return jsonify({"success": False, "error": f"No stored open ports for {target}. Run a scan with `/nettools/trigger-scan`"}), 404 + + return jsonify({"success": True, "target": target, "open_ports": open_ports}) + + # -------------------------- # Devices Collections # -------------------------- - - @app.route("/devices", methods=["GET"]) def api_get_devices(): if not is_authorized(): @@ -685,6 +392,7 @@ def api_devices_totals(): return devices_totals() +@app.route('/mcp/sse/devices/by-status', methods=['GET', 'POST']) @app.route("/devices/by-status", methods=["GET"]) def api_devices_by_status(): if not is_authorized(): @@ -695,15 +403,88 @@ def api_devices_by_status(): return devices_by_status(status) +@app.route('/mcp/sse/devices/search', methods=['POST']) +@app.route('/devices/search', methods=['POST']) +def api_devices_search(): + """Device search: accepts 'query' in JSON and maps to device info/search.""" + if not is_authorized(): + return jsonify({"error": "Unauthorized"}), 401 + + data = request.get_json(silent=True) or {} + query = data.get('query') + + if not query: + return jsonify({"error": "Missing 'query' parameter"}), 400 + + if is_mac(query): + device_data = get_device_data(query) + if device_data: + return jsonify({"success": True, "devices": [device_data.get_json()]}) + else: + return jsonify({"success": False, "error": "Device not found"}), 404 + + # Create fresh DB instance for this thread + device_handler = DeviceInstance() + + matches = device_handler.search(query) + + if not matches: + return jsonify({"success": False, "error": "No devices found"}), 404 + + return jsonify({"success": True, "devices": matches}) + + +@app.route('/mcp/sse/devices/latest', methods=['GET']) +@app.route('/devices/latest', methods=['GET']) +def api_devices_latest(): + """Get latest device (most recent) - maps to DeviceInstance.getLatest().""" + if not is_authorized(): + return jsonify({"error": "Unauthorized"}), 401 + + device_handler = DeviceInstance() + + latest = device_handler.getLatest() + + if not latest: + return jsonify({"message": "No devices found"}), 404 + return jsonify([latest]) + + +@app.route('/mcp/sse/devices/network/topology', methods=['GET']) +@app.route('/devices/network/topology', methods=['GET']) +def api_devices_network_topology(): + """Network topology mapping.""" + if not is_authorized(): + return jsonify({"error": "Unauthorized"}), 401 + + device_handler = DeviceInstance() + + result = device_handler.getNetworkTopology() + + return jsonify(result) + + # -------------------------- # Net tools # -------------------------- +@app.route('/mcp/sse/nettools/wakeonlan', methods=['POST']) @app.route("/nettools/wakeonlan", methods=["POST"]) def api_wakeonlan(): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 - mac = request.json.get("devMac") + data = request.json or {} + mac = data.get("devMac") + ip = data.get("devLastIP") or data.get('ip') + if not mac and ip: + + device_handler = DeviceInstance() + + dev = device_handler.getByIP(ip) + + if not dev or not dev.get('devMac'): + return jsonify({"success": False, "message": "ERROR: Device not found", "error": "MAC not resolved"}), 404 + mac = dev.get('devMac') return wakeonlan(mac) @@ -764,11 +545,42 @@ def api_internet_info(): return internet_info() +@app.route('/mcp/sse/nettools/trigger-scan', methods=['POST']) +@app.route("/nettools/trigger-scan", methods=["GET"]) +def api_trigger_scan(): + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + + data = request.get_json(silent=True) or {} + scan_type = data.get('type', 'ARPSCAN') + + # Validate scan type + loaded_plugins = get_setting_value('LOADED_PLUGINS') + if scan_type not in loaded_plugins: + return jsonify({"success": False, "error": f"Invalid scan type. Must be one of: {', '.join(loaded_plugins)}"}), 400 + + queue = UserEventsQueueInstance() + + action = f"run|{scan_type}" + + queue.add_event(action) + + return jsonify({"success": True, "message": f"Scan triggered for type: {scan_type}"}), 200 + + +# -------------------------- +# MCP Server +# -------------------------- +@app.route('/mcp/sse/openapi.json', methods=['GET']) +def api_openapi_spec(): + if not is_authorized(): + return jsonify({"Success": False, "error": "Unauthorized"}), 401 + return openapi_spec() + + # -------------------------- # DB query # -------------------------- - - @app.route("/dbquery/read", methods=["POST"]) def dbquery_read(): if not is_authorized(): @@ -791,6 +603,7 @@ def dbquery_write(): data = request.get_json() or {} raw_sql_b64 = data.get("rawSql") if not raw_sql_b64: + return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "rawSql is required"}), 400 return write_query(raw_sql_b64) @@ -856,11 +669,13 @@ def api_delete_online_history(): @app.route("/logs", methods=["DELETE"]) def api_clean_log(): + if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 file = request.args.get("file") if not file: + return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "Missing 'file' query parameter"}), 400 return clean_log(file) @@ -895,8 +710,6 @@ def api_add_to_execution_queue(): # -------------------------- # Device Events # -------------------------- - - @app.route("/events/create/", methods=["POST"]) def api_create_event(mac): if not is_authorized(): @@ -960,6 +773,44 @@ def api_get_events_totals(): return get_events_totals(period) +@app.route('/mcp/sse/events/recent', methods=['GET', 'POST']) +@app.route('/events/recent', methods=['GET']) +def api_events_default_24h(): + return api_events_recent(24) # Reuse handler + + +@app.route('/mcp/sse/events/last', methods=['GET', 'POST']) +@app.route('/events/last', methods=['GET']) +def get_last_events(): + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + # Create fresh DB instance for this thread + event_handler = EventInstance() + + return event_handler.get_last_n(10) + + +@app.route('/events/', methods=['GET']) +def api_events_recent(hours): + """Return events from the last hours using EventInstance.""" + + if not is_authorized(): + return jsonify({"success": False, "error": "Unauthorized"}), 401 + + # Validate hours input + if hours <= 0: + return jsonify({"success": False, "error": "Hours must be > 0"}), 400 + try: + # Create fresh DB instance for this thread + event_handler = EventInstance() + + events = event_handler.get_by_hours(hours) + + return jsonify({"success": True, "hours": hours, "count": len(events), "events": events}), 200 + + except Exception as ex: + return jsonify({"success": False, "error": str(ex)}), 500 + # -------------------------- # Sessions # -------------------------- diff --git a/server/api_server/devices_endpoint.py b/server/api_server/devices_endpoint.py index e924aec4..2e850d5e 100755 --- a/server/api_server/devices_endpoint.py +++ b/server/api_server/devices_endpoint.py @@ -228,7 +228,8 @@ def devices_totals(): def devices_by_status(status=None): """ - Return devices filtered by status. + Return devices filtered by status. Returns all if no status provided. + Possible statuses: my, connected, favorites, new, down, archived """ conn = get_temp_db_connection() diff --git a/server/api_server/mcp_endpoint.py b/server/api_server/mcp_endpoint.py new file mode 100644 index 00000000..e1c5f9a7 --- /dev/null +++ b/server/api_server/mcp_endpoint.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python + +import threading +from flask import Blueprint, request, jsonify, Response, stream_with_context +from helper import get_setting_value +from helper import mylog +# from .events_endpoint import get_events # will import locally where needed +import requests +import json +import uuid +import queue + +# Blueprints +mcp_bp = Blueprint('mcp', __name__) +tools_bp = Blueprint('tools', __name__) + +mcp_sessions = {} +mcp_sessions_lock = threading.Lock() + + +def check_auth(): + token = request.headers.get("Authorization") + expected_token = f"Bearer {get_setting_value('API_TOKEN')}" + return token == expected_token + + +# -------------------------- +# Specs +# -------------------------- +def openapi_spec(): + # Spec matching actual available routes for MCP tools + mylog("verbose", ["[MCP] OpenAPI spec requested"]) + spec = { + "openapi": "3.0.0", + "info": {"title": "NetAlertX Tools", "version": "1.1.0"}, + "servers": [{"url": "/"}], + "paths": { + "/devices/by-status": {"post": {"operationId": "list_devices"}}, + "/device/{mac}": {"post": {"operationId": "get_device_info"}}, + "/devices/search": {"post": {"operationId": "search_devices"}}, + "/devices/latest": {"get": {"operationId": "get_latest_device"}}, + "/nettools/trigger-scan": {"post": {"operationId": "trigger_scan"}}, + "/device/open_ports": {"post": {"operationId": "get_open_ports"}}, + "/devices/network/topology": {"get": {"operationId": "get_network_topology"}}, + "/events/recent": {"get": {"operationId": "get_recent_alerts"}, "post": {"operationId": "get_recent_alerts"}}, + "/events/last": {"get": {"operationId": "get_last_events"}, "post": {"operationId": "get_last_events"}}, + "/device/{mac}/set-alias": {"post": {"operationId": "set_device_alias"}}, + "/nettools/wakeonlan": {"post": {"operationId": "wol_wake_device"}} + } + } + return jsonify(spec) + + +# -------------------------- +# MCP SSE/JSON-RPC Endpoint +# -------------------------- + + +# Sessions for SSE +_sessions = {} +_sessions_lock = __import__('threading').Lock() +_openapi_spec_cache = None +API_BASE_URL = f"http://localhost:{get_setting_value('GRAPHQL_PORT')}" + + +def get_openapi_spec(): + global _openapi_spec_cache + # Clear cache on each call for now to ensure fresh spec + _openapi_spec_cache = None + if _openapi_spec_cache: + return _openapi_spec_cache + try: + r = requests.get(f"{API_BASE_URL}/mcp/openapi.json", timeout=10) + r.raise_for_status() + _openapi_spec_cache = r.json() + return _openapi_spec_cache + except Exception: + return None + + +def map_openapi_to_mcp_tools(spec): + tools = [] + if not spec or 'paths' not in spec: + return tools + for path, methods in spec['paths'].items(): + for method, details in methods.items(): + if 'operationId' in details: + tool = {'name': details['operationId'], 'description': details.get('description', ''), 'inputSchema': {'type': 'object', 'properties': {}, 'required': []}} + if 'requestBody' in details: + content = details['requestBody'].get('content', {}) + if 'application/json' in content: + schema = content['application/json'].get('schema', {}) + tool['inputSchema'] = schema.copy() + if 'parameters' in details: + for param in details['parameters']: + if param.get('in') == 'query': + tool['inputSchema']['properties'][param['name']] = {'type': param.get('schema', {}).get('type', 'string'), 'description': param.get('description', '')} + if param.get('required'): + tool['inputSchema']['required'].append(param['name']) + tools.append(tool) + return tools + + +def process_mcp_request(data): + method = data.get('method') + msg_id = data.get('id') + if method == 'initialize': + return {'jsonrpc': '2.0', 'id': msg_id, 'result': {'protocolVersion': '2024-11-05', 'capabilities': {'tools': {}}, 'serverInfo': {'name': 'NetAlertX', 'version': '1.0.0'}}} + if method == 'notifications/initialized': + return None + if method == 'tools/list': + spec = get_openapi_spec() + tools = map_openapi_to_mcp_tools(spec) + return {'jsonrpc': '2.0', 'id': msg_id, 'result': {'tools': tools}} + if method == 'tools/call': + params = data.get('params', {}) + tool_name = params.get('name') + tool_args = params.get('arguments', {}) + spec = get_openapi_spec() + target_path = None + target_method = None + if spec and 'paths' in spec: + for path, methods in spec['paths'].items(): + for m, details in methods.items(): + if details.get('operationId') == tool_name: + target_path = path + target_method = m.upper() + break + if target_path: + break + if not target_path: + return {'jsonrpc': '2.0', 'id': msg_id, 'error': {'code': -32601, 'message': f"Tool {tool_name} not found"}} + try: + headers = {'Content-Type': 'application/json'} + if 'Authorization' in request.headers: + headers['Authorization'] = request.headers['Authorization'] + url = f"{API_BASE_URL}{target_path}" + if target_method == 'POST': + api_res = requests.post(url, json=tool_args, headers=headers, timeout=30) + else: + api_res = requests.get(url, params=tool_args, headers=headers, timeout=30) + content = [] + try: + json_content = api_res.json() + content.append({'type': 'text', 'text': json.dumps(json_content, indent=2)}) + except Exception: + content.append({'type': 'text', 'text': api_res.text}) + is_error = api_res.status_code >= 400 + return {'jsonrpc': '2.0', 'id': msg_id, 'result': {'content': content, 'isError': is_error}} + except Exception as e: + return {'jsonrpc': '2.0', 'id': msg_id, 'result': {'content': [{'type': 'text', 'text': f"Error calling tool: {str(e)}"}], 'isError': True}} + if method == 'ping': + return {'jsonrpc': '2.0', 'id': msg_id, 'result': {}} + if msg_id: + return {'jsonrpc': '2.0', 'id': msg_id, 'error': {'code': -32601, 'message': 'Method not found'}} + + +def mcp_messages(): + session_id = request.args.get('session_id') + if not session_id: + return jsonify({"error": "Missing session_id"}), 400 + with mcp_sessions_lock: + if session_id not in mcp_sessions: + return jsonify({"error": "Session not found"}), 404 + q = mcp_sessions[session_id] + data = request.json + if not data: + return jsonify({"error": "Invalid JSON"}), 400 + response = process_mcp_request(data) + if response: + q.put(response) + return jsonify({"status": "accepted"}), 202 + + +def mcp_sse(): + if request.method == 'POST': + try: + data = request.get_json(silent=True) + if data and 'method' in data and 'jsonrpc' in data: + response = process_mcp_request(data) + if response: + return jsonify(response) + else: + return '', 202 + except Exception as e: + mylog("none", f'SSE POST processing error: {e}') + return jsonify({'status': 'ok', 'message': 'MCP SSE endpoint active'}), 200 + + session_id = uuid.uuid4().hex + q = queue.Queue() + with mcp_sessions_lock: + mcp_sessions[session_id] = q + + def stream(): + yield f"event: endpoint\ndata: /mcp/messages?session_id={session_id}\n\n" + try: + while True: + try: + message = q.get(timeout=20) + yield f"event: message\ndata: {json.dumps(message)}\n\n" + except queue.Empty: + yield ": keep-alive\n\n" + except GeneratorExit: + with mcp_sessions_lock: + if session_id in mcp_sessions: + del mcp_sessions[session_id] + return Response(stream_with_context(stream()), mimetype='text/event-stream') diff --git a/server/api_server/tools_routes.py b/server/api_server/tools_routes.py deleted file mode 100644 index 0b569201..00000000 --- a/server/api_server/tools_routes.py +++ /dev/null @@ -1,686 +0,0 @@ -import subprocess -import re -from datetime import datetime, timedelta -from flask import Blueprint, request, jsonify -import sqlite3 -from helper import get_setting_value -from database import get_temp_db_connection - -tools_bp = Blueprint('tools', __name__) - - -def check_auth(): - """Check API_TOKEN authorization.""" - token = request.headers.get("Authorization") - expected_token = f"Bearer {get_setting_value('API_TOKEN')}" - return token == expected_token - - -@tools_bp.route('/trigger_scan', methods=['POST']) -def trigger_scan(): - """ - Forces NetAlertX to run a specific scan type immediately. - Arguments: scan_type (Enum: arp, nmap_fast, nmap_deep), target (optional IP/CIDR) - """ - if not check_auth(): - return jsonify({"error": "Unauthorized"}), 401 - - data = request.get_json() - scan_type = data.get('scan_type', 'nmap_fast') - target = data.get('target') - - # Validate scan_type - if scan_type not in ['arp', 'nmap_fast', 'nmap_deep']: - return jsonify({"error": "Invalid scan_type. Must be 'arp', 'nmap_fast', or 'nmap_deep'"}), 400 - - # Determine command - cmd = [] - if scan_type == 'arp': - # ARP scan usually requires sudo or root, assuming container runs as root or has caps - cmd = ["arp-scan", "--localnet", "--interface=eth0"] # Defaulting to eth0, might need detection - if target: - cmd = ["arp-scan", target] - elif scan_type == 'nmap_fast': - cmd = ["nmap", "-F"] - if target: - cmd.append(target) - else: - # Default to local subnet if possible, or error if not easily determined - # For now, let's require target for nmap if not easily deducible, - # or try to get it from settings. - # NetAlertX usually knows its subnet. - # Let's try to get the scan subnet from settings if not provided. - scan_subnets = get_setting_value("SCAN_SUBNETS") - if scan_subnets: - # Take the first one for now - cmd.append(scan_subnets.split(',')[0].strip()) - else: - return jsonify({"error": "Target is required and no default SCAN_SUBNETS found"}), 400 - elif scan_type == 'nmap_deep': - cmd = ["nmap", "-A", "-T4"] - if target: - cmd.append(target) - else: - scan_subnets = get_setting_value("SCAN_SUBNETS") - if scan_subnets: - cmd.append(scan_subnets.split(',')[0].strip()) - else: - return jsonify({"error": "Target is required and no default SCAN_SUBNETS found"}), 400 - - try: - # Run the command - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=True - ) - return jsonify({ - "success": True, - "scan_type": scan_type, - "command": " ".join(cmd), - "output": result.stdout.strip().split('\n') - }) - except subprocess.CalledProcessError as e: - return jsonify({ - "success": False, - "error": "Scan failed", - "details": e.stderr.strip() - }), 500 - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -@tools_bp.route('/list_devices', methods=['POST']) -def list_devices(): - """List all devices.""" - if not check_auth(): - return jsonify({"error": "Unauthorized"}), 401 - - conn = get_temp_db_connection() - conn.row_factory = sqlite3.Row - cur = conn.cursor() - - try: - cur.execute("SELECT devName, devMac, devLastIP as devIP, devVendor, devFirstConnection, devLastConnection FROM Devices ORDER BY devFirstConnection DESC") - rows = cur.fetchall() - devices = [dict(row) for row in rows] - return jsonify(devices) - except Exception as e: - return jsonify({"error": str(e)}), 500 - finally: - conn.close() - - -@tools_bp.route('/get_device_info', methods=['POST']) -def get_device_info(): - """Get detailed info for a specific device.""" - if not check_auth(): - return jsonify({"error": "Unauthorized"}), 401 - - data = request.get_json() - if not data or 'query' not in data: - return jsonify({"error": "Missing 'query' parameter"}), 400 - - query = data['query'] - - conn = get_temp_db_connection() - conn.row_factory = sqlite3.Row - cur = conn.cursor() - - try: - # Search by MAC, Name, or partial IP - sql = "SELECT * FROM Devices WHERE devMac LIKE ? OR devName LIKE ? OR devLastIP LIKE ?" - cur.execute(sql, (f"%{query}%", f"%{query}%", f"%{query}%")) - rows = cur.fetchall() - - if not rows: - return jsonify({"message": "No devices found"}), 404 - - devices = [dict(row) for row in rows] - return jsonify(devices) - except Exception as e: - return jsonify({"error": str(e)}), 500 - finally: - conn.close() - - -@tools_bp.route('/get_latest_device', methods=['POST']) -def get_latest_device(): - """Get full details of the most recently discovered device.""" - if not check_auth(): - return jsonify({"error": "Unauthorized"}), 401 - - conn = get_temp_db_connection() - conn.row_factory = sqlite3.Row - cur = conn.cursor() - - try: - # Get the device with the most recent devFirstConnection - cur.execute("SELECT * FROM Devices ORDER BY devFirstConnection DESC LIMIT 1") - row = cur.fetchone() - - if not row: - return jsonify({"message": "No devices found"}), 404 - - # Return as a list to be consistent with other endpoints - return jsonify([dict(row)]) - except Exception as e: - return jsonify({"error": str(e)}), 500 - finally: - conn.close() - - -@tools_bp.route('/get_open_ports', methods=['POST']) -def get_open_ports(): - """ - Specific query for the port-scan results of a target. - Arguments: target (IP or MAC) - """ - if not check_auth(): - return jsonify({"error": "Unauthorized"}), 401 - - data = request.get_json() - target = data.get('target') - - if not target: - return jsonify({"error": "Target is required"}), 400 - - # If MAC is provided, try to resolve to IP - if re.match(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", target): - conn = get_temp_db_connection() - conn.row_factory = sqlite3.Row - cur = conn.cursor() - try: - cur.execute("SELECT devLastIP FROM Devices WHERE devMac = ?", (target,)) - row = cur.fetchone() - if row and row['devLastIP']: - target = row['devLastIP'] - else: - return jsonify({"error": f"Could not resolve IP for MAC {target}"}), 404 - finally: - conn.close() - - try: - # Run nmap -F for fast port scan - cmd = ["nmap", "-F", target] - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=True, - timeout=120 - ) - - # Parse output for open ports - open_ports = [] - for line in result.stdout.split('\n'): - if '/tcp' in line and 'open' in line: - parts = line.split('/') - port = parts[0].strip() - service = line.split()[2] if len(line.split()) > 2 else "unknown" - open_ports.append({"port": int(port), "service": service}) - - return jsonify({ - "success": True, - "target": target, - "open_ports": open_ports, - "raw_output": result.stdout.strip().split('\n') - }) - - except subprocess.CalledProcessError as e: - return jsonify({"success": False, "error": "Port scan failed", "details": e.stderr.strip()}), 500 - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -@tools_bp.route('/get_network_topology', methods=['GET']) -def get_network_topology(): - """ - Returns the "Parent/Child" relationships. - """ - if not check_auth(): - return jsonify({"error": "Unauthorized"}), 401 - - conn = get_temp_db_connection() - conn.row_factory = sqlite3.Row - cur = conn.cursor() - - try: - cur.execute("SELECT devName, devMac, devParentMAC, devParentPort, devVendor FROM Devices") - rows = cur.fetchall() - - nodes = [] - links = [] - - for row in rows: - nodes.append({ - "id": row['devMac'], - "name": row['devName'], - "vendor": row['devVendor'] - }) - if row['devParentMAC']: - links.append({ - "source": row['devParentMAC'], - "target": row['devMac'], - "port": row['devParentPort'] - }) - - return jsonify({ - "nodes": nodes, - "links": links - }) - except Exception as e: - return jsonify({"error": str(e)}), 500 - finally: - conn.close() - - -@tools_bp.route('/get_recent_alerts', methods=['POST']) -def get_recent_alerts(): - """ - Fetches the last N system alerts. - Arguments: hours (lookback period, default 24) - """ - if not check_auth(): - return jsonify({"error": "Unauthorized"}), 401 - - data = request.get_json() - hours = data.get('hours', 24) - - conn = get_temp_db_connection() - conn.row_factory = sqlite3.Row - cur = conn.cursor() - - try: - # Calculate cutoff time - cutoff = datetime.now() - timedelta(hours=int(hours)) - cutoff_str = cutoff.strftime('%Y-%m-%d %H:%M:%S') - - cur.execute(""" - SELECT eve_DateTime, eve_EventType, eve_MAC, eve_IP, devName - FROM Events - LEFT JOIN Devices ON Events.eve_MAC = Devices.devMac - WHERE eve_DateTime > ? - ORDER BY eve_DateTime DESC - """, (cutoff_str,)) - - rows = cur.fetchall() - alerts = [dict(row) for row in rows] - - return jsonify(alerts) - except Exception as e: - return jsonify({"error": str(e)}), 500 - finally: - conn.close() - - -@tools_bp.route('/set_device_alias', methods=['POST']) -def set_device_alias(): - """ - Updates the name (alias) of a device. - Arguments: mac, alias - """ - if not check_auth(): - return jsonify({"error": "Unauthorized"}), 401 - - data = request.get_json() - mac = data.get('mac') - alias = data.get('alias') - - if not mac or not alias: - return jsonify({"error": "MAC and Alias are required"}), 400 - - conn = get_temp_db_connection() - cur = conn.cursor() - - try: - cur.execute("UPDATE Devices SET devName = ? WHERE devMac = ?", (alias, mac)) - conn.commit() - - if cur.rowcount == 0: - return jsonify({"error": "Device not found"}), 404 - - return jsonify({"success": True, "message": f"Device {mac} renamed to {alias}"}) - except Exception as e: - return jsonify({"error": str(e)}), 500 - finally: - conn.close() - - -@tools_bp.route('/wol_wake_device', methods=['POST']) -def wol_wake_device(): - """ - Sends a Wake-on-LAN magic packet. - Arguments: mac OR ip - """ - if not check_auth(): - return jsonify({"error": "Unauthorized"}), 401 - - data = request.get_json() - mac = data.get('mac') - ip = data.get('ip') - - if not mac and not ip: - return jsonify({"error": "MAC address or IP address is required"}), 400 - - # Resolve IP to MAC if MAC is missing - if not mac and ip: - conn = get_temp_db_connection() - conn.row_factory = sqlite3.Row - cur = conn.cursor() - try: - # Try to find device by IP (devLastIP) - cur.execute("SELECT devMac FROM Devices WHERE devLastIP = ?", (ip,)) - row = cur.fetchone() - if row and row['devMac']: - mac = row['devMac'] - else: - return jsonify({"error": f"Could not resolve MAC for IP {ip}"}), 404 - except Exception as e: - return jsonify({"error": f"Database error: {str(e)}"}), 500 - finally: - conn.close() - - # Validate MAC - if not re.match(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", mac): - return jsonify({"success": False, "error": f"Invalid MAC: {mac}"}), 400 - - try: - # Using wakeonlan command - result = subprocess.run( - ["wakeonlan", mac], capture_output=True, text=True, check=True, timeout=10 - ) - return jsonify( - { - "success": True, - "message": f"WOL packet sent to {mac}", - "output": result.stdout.strip(), - } - ) - except subprocess.CalledProcessError as e: - return jsonify( - { - "success": False, - "error": "Failed to send WOL packet", - "details": e.stderr.strip(), - } - ), 500 - - -@tools_bp.route('/openapi.json', methods=['GET']) -def openapi_spec(): - """Return OpenAPI specification for tools.""" - # No auth required for spec to allow easy import, or require it if preferred. - # Open WebUI usually needs to fetch spec without auth first or handles it. - # We'll allow public access to spec for simplicity of import. - - spec = { - "openapi": "3.0.0", - "info": { - "title": "NetAlertX Tools", - "description": "API for NetAlertX device management tools", - "version": "1.1.0" - }, - "servers": [ - {"url": "/api/tools"} - ], - "paths": { - "/list_devices": { - "post": { - "summary": "List all devices (Summary)", - "description": ( - "Retrieve a SUMMARY list of all devices, sorted by newest first. " - "IMPORTANT: This only provides basic info (Name, IP, Vendor). " - "For FULL details (like custom props, alerts, etc.), you MUST use 'get_device_info' or 'get_latest_device'." - ), - "operationId": "list_devices", - "responses": { - "200": { - "description": "List of devices (Summary)", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "devName": {"type": "string"}, - "devMac": {"type": "string"}, - "devIP": {"type": "string"}, - "devVendor": {"type": "string"}, - "devStatus": {"type": "string"}, - "devFirstConnection": {"type": "string"}, - "devLastConnection": {"type": "string"} - } - } - } - } - } - } - } - } - }, - "/get_device_info": { - "post": { - "summary": "Get device info (Full Details)", - "description": ( - "Get COMPREHENSIVE information about a specific device by MAC, Name, or partial IP. " - "Use this to see all available properties, alerts, and metadata not shown in the list." - ), - "operationId": "get_device_info", - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "MAC address, Device Name, or partial IP to search for" - } - }, - "required": ["query"] - } - } - } - }, - "responses": { - "200": { - "description": "Device details (Full)", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": {"type": "object"} - } - } - } - }, - "404": {"description": "Device not found"} - } - } - }, - "/get_latest_device": { - "post": { - "summary": "Get latest device (Full Details)", - "description": "Get COMPREHENSIVE information about the most recently discovered device (latest devFirstConnection).", - "operationId": "get_latest_device", - "responses": { - "200": { - "description": "Latest device details (Full)", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": {"type": "object"} - } - } - } - }, - "404": {"description": "No devices found"} - } - } - }, - "/trigger_scan": { - "post": { - "summary": "Trigger Active Scan", - "description": "Forces NetAlertX to run a specific scan type immediately.", - "operationId": "trigger_scan", - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "scan_type": { - "type": "string", - "enum": ["arp", "nmap_fast", "nmap_deep"], - "default": "nmap_fast" - }, - "target": { - "type": "string", - "description": "IP address or CIDR to scan" - } - } - } - } - } - }, - "responses": { - "200": {"description": "Scan started/completed successfully"}, - "400": {"description": "Invalid input"} - } - } - }, - "/get_open_ports": { - "post": { - "summary": "Get Open Ports", - "description": "Specific query for the port-scan results of a target.", - "operationId": "get_open_ports", - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "target": { - "type": "string", - "description": "IP or MAC address" - } - }, - "required": ["target"] - } - } - } - }, - "responses": { - "200": {"description": "List of open ports"}, - "404": {"description": "Target not found"} - } - } - }, - "/get_network_topology": { - "get": { - "summary": "Get Network Topology", - "description": "Returns the Parent/Child relationships for network visualization.", - "operationId": "get_network_topology", - "responses": { - "200": {"description": "Graph data (nodes and links)"} - } - } - }, - "/get_recent_alerts": { - "post": { - "summary": "Get Recent Alerts", - "description": "Fetches the last N system alerts.", - "operationId": "get_recent_alerts", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "hours": { - "type": "integer", - "default": 24 - } - } - } - } - } - }, - "responses": { - "200": {"description": "List of alerts"} - } - } - }, - "/set_device_alias": { - "post": { - "summary": "Set Device Alias", - "description": "Updates the name (alias) of a device.", - "operationId": "set_device_alias", - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "mac": {"type": "string"}, - "alias": {"type": "string"} - }, - "required": ["mac", "alias"] - } - } - } - }, - "responses": { - "200": {"description": "Alias updated"}, - "404": {"description": "Device not found"} - } - } - }, - "/wol_wake_device": { - "post": { - "summary": "Wake on LAN", - "description": "Sends a Wake-on-LAN magic packet to the target MAC or IP. If IP is provided, it resolves to MAC first.", - "operationId": "wol_wake_device", - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "mac": {"type": "string", "description": "Target MAC address"}, - "ip": {"type": "string", "description": "Target IP address (resolves to MAC)"} - } - } - } - } - }, - "responses": { - "200": {"description": "WOL packet sent"}, - "404": {"description": "IP not found"} - } - } - } - }, - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" - } - } - }, - "security": [ - {"bearerAuth": []} - ] - } - return jsonify(spec) diff --git a/server/models/device_instance.py b/server/models/device_instance.py index 5400e1c0..97ee4400 100755 --- a/server/models/device_instance.py +++ b/server/models/device_instance.py @@ -1,121 +1,134 @@ +from front.plugins.plugin_helper import is_mac from logger import mylog +from models.plugin_object_instance import PluginObjectInstance +from database import get_temp_db_connection -# ------------------------------------------------------------------------------- -# Device object handling (WIP) -# ------------------------------------------------------------------------------- class DeviceInstance: - def __init__(self, db): - self.db = db - # Get all - def getAll(self): - self.db.sql.execute(""" - SELECT * FROM Devices - """) - return self.db.sql.fetchall() - - # Get all with unknown names - def getUnknown(self): - self.db.sql.execute(""" - SELECT * FROM Devices WHERE devName in ("(unknown)", "(name not found)", "" ) - """) - return self.db.sql.fetchall() - - # Get specific column value based on devMac - def getValueWithMac(self, column_name, devMac): - query = f"SELECT {column_name} FROM Devices WHERE devMac = ?" - self.db.sql.execute(query, (devMac,)) - result = self.db.sql.fetchone() - return result[column_name] if result else None - - # Get all down - def getDown(self): - self.db.sql.execute(""" - SELECT * FROM Devices WHERE devAlertDown = 1 and devPresentLastScan = 0 - """) - return self.db.sql.fetchall() - - # Get all down - def getOffline(self): - self.db.sql.execute(""" - SELECT * FROM Devices WHERE devPresentLastScan = 0 - """) - return self.db.sql.fetchall() - - # Get a device by devGUID - def getByGUID(self, devGUID): - self.db.sql.execute("SELECT * FROM Devices WHERE devGUID = ?", (devGUID,)) - result = self.db.sql.fetchone() - return dict(result) if result else None - - # Check if a device exists by devGUID - def exists(self, devGUID): - self.db.sql.execute( - "SELECT COUNT(*) AS count FROM Devices WHERE devGUID = ?", (devGUID,) - ) - result = self.db.sql.fetchone() - return result["count"] > 0 - - # Get a device by its last IP address - def getByIP(self, ip): - self.db.sql.execute("SELECT * FROM Devices WHERE devLastIP = ?", (ip,)) - row = self.db.sql.fetchone() - return dict(row) if row else None - - # Search devices by partial mac, name or IP - def search(self, query): - like = f"%{query}%" - self.db.sql.execute( - "SELECT * FROM Devices WHERE devMac LIKE ? OR devName LIKE ? OR devLastIP LIKE ?", - (like, like, like), - ) - rows = self.db.sql.fetchall() + # --- helpers -------------------------------------------------------------- + def _fetchall(self, query, params=()): + conn = get_temp_db_connection() + rows = conn.execute(query, params).fetchall() + conn.close() return [dict(r) for r in rows] - # Get the most recently discovered device - def getLatest(self): - self.db.sql.execute("SELECT * FROM Devices ORDER BY devFirstConnection DESC LIMIT 1") - row = self.db.sql.fetchone() + def _fetchone(self, query, params=()): + conn = get_temp_db_connection() + row = conn.execute(query, params).fetchone() + conn.close() return dict(row) if row else None - def getNetworkTopology(self): - """Returns nodes and links for the current Devices table. + def _execute(self, query, params=()): + conn = get_temp_db_connection() + cur = conn.cursor() + cur.execute(query, params) + conn.commit() + conn.close() - Nodes: {id, name, vendor} - Links: {source, target, port} - """ - self.db.sql.execute("SELECT devName, devMac, devParentMAC, devParentPort, devVendor FROM Devices") - rows = self.db.sql.fetchall() - nodes = [] - links = [] - for row in rows: - nodes.append({"id": row['devMac'], "name": row['devName'], "vendor": row['devVendor']}) - if row['devParentMAC']: - links.append({"source": row['devParentMAC'], "target": row['devMac'], "port": row['devParentPort']}) + # --- public API ----------------------------------------------------------- + def getAll(self): + return self._fetchall("SELECT * FROM Devices") + + def getUnknown(self): + return self._fetchall(""" + SELECT * FROM Devices + WHERE devName IN ("(unknown)", "(name not found)", "") + """) + + def getValueWithMac(self, column_name, devMac): + row = self._fetchone(f""" + SELECT {column_name} FROM Devices WHERE devMac = ? + """, (devMac,)) + return row.get(column_name) if row else None + + def getDown(self): + return self._fetchall(""" + SELECT * FROM Devices + WHERE devAlertDown = 1 AND devPresentLastScan = 0 + """) + + def getOffline(self): + return self._fetchall(""" + SELECT * FROM Devices + WHERE devPresentLastScan = 0 + """) + + def getByGUID(self, devGUID): + return self._fetchone(""" + SELECT * FROM Devices WHERE devGUID = ? + """, (devGUID,)) + + def exists(self, devGUID): + row = self._fetchone(""" + SELECT COUNT(*) as count FROM Devices WHERE devGUID = ? + """, (devGUID,)) + return row['count'] > 0 if row else False + + def getByIP(self, ip): + return self._fetchone(""" + SELECT * FROM Devices WHERE devLastIP = ? + """, (ip,)) + + def search(self, query): + like = f"%{query}%" + return self._fetchall(""" + SELECT * FROM Devices + WHERE devMac LIKE ? OR devName LIKE ? OR devLastIP LIKE ? + """, (like, like, like)) + + def getLatest(self): + return self._fetchone(""" + SELECT * FROM Devices + ORDER BY devFirstConnection DESC LIMIT 1 + """) + + def getNetworkTopology(self): + rows = self._fetchall(""" + SELECT devName, devMac, devParentMAC, devParentPort, devVendor FROM Devices + """) + nodes = [{"id": r["devMac"], "name": r["devName"], "vendor": r["devVendor"]} for r in rows] + links = [{"source": r["devParentMAC"], "target": r["devMac"], "port": r["devParentPort"]} + for r in rows if r["devParentMAC"]] return {"nodes": nodes, "links": links} - # Update a specific field for a device def updateField(self, devGUID, field, value): if not self.exists(devGUID): - m = f"[Device] In 'updateField': GUID {devGUID} not found." - mylog("none", m) - raise ValueError(m) + msg = f"[Device] updateField: GUID {devGUID} not found" + mylog("none", msg) + raise ValueError(msg) + self._execute(f"UPDATE Devices SET {field}=? WHERE devGUID=?", (value, devGUID)) - self.db.sql.execute( - f""" - UPDATE Devices SET {field} = ? WHERE devGUID = ? - """, - (value, devGUID), - ) - self.db.commitDB() - - # Delete a device by devGUID def delete(self, devGUID): if not self.exists(devGUID): - m = f"[Device] In 'delete': GUID {devGUID} not found." - mylog("none", m) - raise ValueError(m) + msg = f"[Device] delete: GUID {devGUID} not found" + mylog("none", msg) + raise ValueError(msg) + self._execute("DELETE FROM Devices WHERE devGUID=?", (devGUID,)) - self.db.sql.execute("DELETE FROM Devices WHERE devGUID = ?", (devGUID,)) - self.db.commitDB() + def resolvePrimaryID(self, target): + if is_mac(target): + return target.lower() + dev = self.getByIP(target) + return dev['devMac'].lower() if dev else None + + def getOpenPorts(self, target): + primary = self.resolvePrimaryID(target) + if not primary: + return [] + + objs = PluginObjectInstance().getByField( + plugPrefix='NMAP', + matchedColumn='Object_PrimaryID', + matchedKey=primary, + returnFields=['Object_SecondaryID', 'Watched_Value2'] + ) + + ports = [] + for o in objs: + + port = int(o.get('Object_SecondaryID') or 0) + + ports.append({"port": port, "service": o.get('Watched_Value2', '')}) + + return ports diff --git a/server/models/event_instance.py b/server/models/event_instance.py new file mode 100644 index 00000000..548ee413 --- /dev/null +++ b/server/models/event_instance.py @@ -0,0 +1,106 @@ +from datetime import datetime, timedelta +from logger import mylog +from database import get_temp_db_connection + + +# ------------------------------------------------------------------------------- +# Event handling (Matches table: Events) +# ------------------------------------------------------------------------------- +class EventInstance: + + def _conn(self): + """Always return a new DB connection (thread-safe).""" + return get_temp_db_connection() + + def _rows_to_list(self, rows): + return [dict(r) for r in rows] + + # Get all events + def get_all(self): + conn = self._conn() + rows = conn.execute( + "SELECT * FROM Events ORDER BY eve_DateTime DESC" + ).fetchall() + conn.close() + return self._rows_to_list(rows) + + # --- Get last n events --- + def get_last_n(self, n=10): + conn = self._conn() + rows = conn.execute(""" + SELECT * FROM Events + ORDER BY eve_DateTime DESC + LIMIT ? + """, (n,)).fetchall() + return self._rows_to_list(rows) + + # --- Specific helper for last 10 --- + def get_last(self): + return self.get_last_n(10) + + # Get events in the last 24h + def get_recent(self): + since = datetime.now() - timedelta(hours=24) + conn = self._conn() + rows = conn.execute(""" + SELECT * FROM Events + WHERE eve_DateTime >= ? + ORDER BY eve_DateTime DESC + """, (since,)).fetchall() + conn.close() + return self._rows_to_list(rows) + + # Get events from last N hours + def get_by_hours(self, hours: int): + if hours <= 0: + mylog("warn", f"[Events] get_by_hours({hours}) -> invalid value") + return [] + + since = datetime.now() - timedelta(hours=hours) + conn = self._conn() + rows = conn.execute(""" + SELECT * FROM Events + WHERE eve_DateTime >= ? + ORDER BY eve_DateTime DESC + """, (since,)).fetchall() + conn.close() + return self._rows_to_list(rows) + + # Get events in a date range + def get_by_range(self, start: datetime, end: datetime): + if end < start: + mylog("error", f"[Events] get_by_range invalid: {start} > {end}") + raise ValueError("Start must not be after end") + + conn = self._conn() + rows = conn.execute(""" + SELECT * FROM Events + WHERE eve_DateTime BETWEEN ? AND ? + ORDER BY eve_DateTime DESC + """, (start, end)).fetchall() + conn.close() + return self._rows_to_list(rows) + + # Insert new event + def add(self, mac, ip, eventType, info="", pendingAlert=True, pairRow=None): + conn = self._conn() + conn.execute(""" + INSERT INTO Events ( + eve_MAC, eve_IP, eve_DateTime, + eve_EventType, eve_AdditionalInfo, + eve_PendingAlertEmail, eve_PairEventRowid + ) VALUES (?,?,?,?,?,?,?) + """, (mac, ip, datetime.now(), eventType, info, + 1 if pendingAlert else 0, pairRow)) + conn.commit() + conn.close() + + # Delete old events + def delete_older_than(self, days: int): + cutoff = datetime.now() - timedelta(days=days) + conn = self._conn() + result = conn.execute("DELETE FROM Events WHERE eve_DateTime < ?", (cutoff,)) + conn.commit() + deleted_count = result.rowcount + conn.close() + return deleted_count diff --git a/server/models/plugin_object_instance.py b/server/models/plugin_object_instance.py index 95e392c5..3d4ceaf2 100755 --- a/server/models/plugin_object_instance.py +++ b/server/models/plugin_object_instance.py @@ -1,79 +1,91 @@ from logger import mylog +from database import get_temp_db_connection # ------------------------------------------------------------------------------- -# Plugin object handling (WIP) +# Plugin object handling (THREAD-SAFE REWRITE) # ------------------------------------------------------------------------------- class PluginObjectInstance: - def __init__(self, db): - self.db = db - # Get all plugin objects - def getAll(self): - self.db.sql.execute(""" - SELECT * FROM Plugins_Objects - """) - return self.db.sql.fetchall() - - # Get plugin object by ObjectGUID - def getByGUID(self, ObjectGUID): - self.db.sql.execute( - "SELECT * FROM Plugins_Objects WHERE ObjectGUID = ?", (ObjectGUID,) - ) - result = self.db.sql.fetchone() - return dict(result) if result else None - - # Check if a plugin object exists by ObjectGUID - def exists(self, ObjectGUID): - self.db.sql.execute( - "SELECT COUNT(*) AS count FROM Plugins_Objects WHERE ObjectGUID = ?", - (ObjectGUID,), - ) - result = self.db.sql.fetchone() - return result["count"] > 0 - - # Get objects by plugin name - def getByPlugin(self, plugin): - self.db.sql.execute("SELECT * FROM Plugins_Objects WHERE Plugin = ?", (plugin,)) - return self.db.sql.fetchall() - - # Get plugin objects by primary ID and plugin name - def getByPrimary(self, plugin, primary_id): - self.db.sql.execute( - "SELECT * FROM Plugins_Objects WHERE Plugin = ? AND Object_PrimaryID = ?", - (plugin, primary_id), - ) - rows = self.db.sql.fetchall() + # -------------- Internal DB helper wrappers -------------------------------- + def _fetchall(self, query, params=()): + conn = get_temp_db_connection() + rows = conn.execute(query, params).fetchall() + conn.close() return [dict(r) for r in rows] - # Get objects by status - def getByStatus(self, status): - self.db.sql.execute("SELECT * FROM Plugins_Objects WHERE Status = ?", (status,)) - return self.db.sql.fetchall() + def _fetchone(self, query, params=()): + conn = get_temp_db_connection() + row = conn.execute(query, params).fetchone() + conn.close() + return dict(row) if row else None + + def _execute(self, query, params=()): + conn = get_temp_db_connection() + conn.execute(query, params) + conn.commit() + conn.close() + + # --------------------------------------------------------------------------- + # Public API — identical behaviour, now thread-safe + self-contained + # --------------------------------------------------------------------------- + + def getAll(self): + return self._fetchall("SELECT * FROM Plugins_Objects") + + def getByGUID(self, ObjectGUID): + return self._fetchone( + "SELECT * FROM Plugins_Objects WHERE ObjectGUID = ?", (ObjectGUID,) + ) + + def exists(self, ObjectGUID): + row = self._fetchone(""" + SELECT COUNT(*) AS count FROM Plugins_Objects WHERE ObjectGUID = ? + """, (ObjectGUID,)) + return row["count"] > 0 if row else False + + def getByPlugin(self, plugin): + return self._fetchall( + "SELECT * FROM Plugins_Objects WHERE Plugin = ?", (plugin,) + ) + + def getByField(self, plugPrefix, matchedColumn, matchedKey, returnFields=None): + rows = self._fetchall( + f"SELECT * FROM Plugins_Objects WHERE Plugin = ? AND {matchedColumn} = ?", + (plugPrefix, matchedKey.lower()) + ) + + if not returnFields: + return rows + + return [{f: row.get(f) for f in returnFields} for row in rows] + + def getByPrimary(self, plugin, primary_id): + return self._fetchall(""" + SELECT * FROM Plugins_Objects + WHERE Plugin = ? AND Object_PrimaryID = ? + """, (plugin, primary_id)) + + def getByStatus(self, status): + return self._fetchall(""" + SELECT * FROM Plugins_Objects WHERE Status = ? + """, (status,)) - # Update a specific field for a plugin object def updateField(self, ObjectGUID, field, value): if not self.exists(ObjectGUID): - m = f"[PluginObject] In 'updateField': GUID {ObjectGUID} not found." - mylog("none", m) - raise ValueError(m) + msg = f"[PluginObject] updateField: GUID {ObjectGUID} not found." + mylog("none", msg) + raise ValueError(msg) - self.db.sql.execute( - f""" - UPDATE Plugins_Objects SET {field} = ? WHERE ObjectGUID = ? - """, - (value, ObjectGUID), + self._execute( + f"UPDATE Plugins_Objects SET {field}=? WHERE ObjectGUID=?", + (value, ObjectGUID) ) - self.db.commitDB() - # Delete a plugin object by ObjectGUID def delete(self, ObjectGUID): if not self.exists(ObjectGUID): - m = f"[PluginObject] In 'delete': GUID {ObjectGUID} not found." - mylog("none", m) - raise ValueError(m) + msg = f"[PluginObject] delete: GUID {ObjectGUID} not found." + mylog("none", msg) + raise ValueError(msg) - self.db.sql.execute( - "DELETE FROM Plugins_Objects WHERE ObjectGUID = ?", (ObjectGUID,) - ) - self.db.commitDB() + self._execute("DELETE FROM Plugins_Objects WHERE ObjectGUID=?", (ObjectGUID,)) diff --git a/server/scan/device_handling.py b/server/scan/device_handling.py index cf396898..ec767f29 100755 --- a/server/scan/device_handling.py +++ b/server/scan/device_handling.py @@ -650,7 +650,7 @@ def update_devices_names(pm): sql = pm.db.sql resolver = NameResolver(pm.db) - device_handler = DeviceInstance(pm.db) + device_handler = DeviceInstance() nameNotFound = "(name not found)" diff --git a/server/workflows/actions.py b/server/workflows/actions.py index 3df87cb4..da90aced 100755 --- a/server/workflows/actions.py +++ b/server/workflows/actions.py @@ -42,13 +42,13 @@ class UpdateFieldAction(Action): # currently unused if isinstance(obj, dict) and "ObjectGUID" in obj: mylog("debug", f"[WF] Updating Object '{obj}' ") - plugin_instance = PluginObjectInstance(self.db) + plugin_instance = PluginObjectInstance() plugin_instance.updateField(obj["ObjectGUID"], self.field, self.value) processed = True elif isinstance(obj, dict) and "devGUID" in obj: mylog("debug", f"[WF] Updating Device '{obj}' ") - device_instance = DeviceInstance(self.db) + device_instance = DeviceInstance() device_instance.updateField(obj["devGUID"], self.field, self.value) processed = True @@ -79,13 +79,13 @@ class DeleteObjectAction(Action): # currently unused if isinstance(obj, dict) and "ObjectGUID" in obj: mylog("debug", f"[WF] Updating Object '{obj}' ") - plugin_instance = PluginObjectInstance(self.db) + plugin_instance = PluginObjectInstance() plugin_instance.delete(obj["ObjectGUID"]) processed = True elif isinstance(obj, dict) and "devGUID" in obj: mylog("debug", f"[WF] Updating Device '{obj}' ") - device_instance = DeviceInstance(self.db) + device_instance = DeviceInstance() device_instance.delete(obj["devGUID"]) processed = True diff --git a/test/api_endpoints/test_mcp_tools_endpoints.py b/test/api_endpoints/test_mcp_tools_endpoints.py index fd221879..3c1b68cd 100644 --- a/test/api_endpoints/test_mcp_tools_endpoints.py +++ b/test/api_endpoints/test_mcp_tools_endpoints.py @@ -2,6 +2,7 @@ import sys import os import pytest from unittest.mock import patch, MagicMock +from datetime import datetime INSTALL_PATH = os.getenv('NETALERTX_APP', '/app') sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) @@ -25,82 +26,94 @@ def auth_headers(token): return {"Authorization": f"Bearer {token}"} -# --- get_device_info Tests --- -@patch('api_server.tools_routes.get_temp_db_connection') +# --- Device Search Tests --- + +@patch('models.device_instance.get_temp_db_connection') def test_get_device_info_ip_partial(mock_db_conn, client, api_token): - """Test get_device_info with partial IP search.""" - mock_cursor = MagicMock() - # Mock return of a device with IP ending in .50 - mock_cursor.fetchall.return_value = [ + """Test device search with partial IP search.""" + # Mock database connection - DeviceInstance._fetchall calls conn.execute().fetchall() + mock_conn = MagicMock() + mock_execute_result = MagicMock() + mock_execute_result.fetchall.return_value = [ {"devName": "Test Device", "devMac": "AA:BB:CC:DD:EE:FF", "devLastIP": "192.168.1.50"} ] - mock_db_conn.return_value.cursor.return_value = mock_cursor + mock_conn.execute.return_value = mock_execute_result + mock_db_conn.return_value = mock_conn payload = {"query": ".50"} - response = client.post('/api/tools/get_device_info', - json=payload, - headers=auth_headers(api_token)) - - assert response.status_code == 200 - devices = response.get_json() - assert len(devices) == 1 - assert devices[0]["devLastIP"] == "192.168.1.50" - - # Verify SQL query included 3 params (MAC, Name, IP) - args, _ = mock_cursor.execute.call_args - assert args[0].count("?") == 3 - assert len(args[1]) == 3 - - -# --- trigger_scan Tests --- -@patch('subprocess.run') -def test_trigger_scan_nmap_fast(mock_run, client, api_token): - """Test trigger_scan with nmap_fast.""" - mock_run.return_value = MagicMock(stdout="Scan completed", returncode=0) - - payload = {"scan_type": "nmap_fast", "target": "192.168.1.1"} - response = client.post('/api/tools/trigger_scan', + response = client.post('/devices/search', json=payload, headers=auth_headers(api_token)) assert response.status_code == 200 data = response.get_json() assert data["success"] is True - assert "nmap -F 192.168.1.1" in data["command"] - mock_run.assert_called_once() + assert len(data["devices"]) == 1 + assert data["devices"][0]["devLastIP"] == "192.168.1.50" -@patch('subprocess.run') -def test_trigger_scan_invalid_type(mock_run, client, api_token): - """Test trigger_scan with invalid scan_type.""" - payload = {"scan_type": "invalid_type", "target": "192.168.1.1"} - response = client.post('/api/tools/trigger_scan', +# --- Trigger Scan Tests --- + +@patch('api_server.api_server_start.UserEventsQueueInstance') +def test_trigger_scan_ARPSCAN(mock_queue_class, client, api_token): + """Test trigger_scan with ARPSCAN type.""" + mock_queue = MagicMock() + mock_queue_class.return_value = mock_queue + + payload = {"type": "ARPSCAN"} + response = client.post('/mcp/sse/nettools/trigger-scan', + json=payload, + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + mock_queue.add_event.assert_called_once() + call_args = mock_queue.add_event.call_args[0] + assert "run|ARPSCAN" in call_args[0] + + +@patch('api_server.api_server_start.UserEventsQueueInstance') +def test_trigger_scan_invalid_type(mock_queue_class, client, api_token): + """Test trigger_scan with invalid scan type.""" + mock_queue = MagicMock() + mock_queue_class.return_value = mock_queue + + payload = {"type": "invalid_type", "target": "192.168.1.0/24"} + response = client.post('/mcp/sse/nettools/trigger-scan', json=payload, headers=auth_headers(api_token)) assert response.status_code == 400 - mock_run.assert_not_called() + data = response.get_json() + assert data["success"] is False + # --- get_open_ports Tests --- -@patch('subprocess.run') -def test_get_open_ports_ip(mock_run, client, api_token): +@patch('models.plugin_object_instance.get_temp_db_connection') +@patch('models.device_instance.get_temp_db_connection') +def test_get_open_ports_ip(mock_plugin_db_conn, mock_device_db_conn, client, api_token): """Test get_open_ports with an IP address.""" - mock_output = """ -Starting Nmap 7.80 ( https://nmap.org ) at 2023-10-27 10:00 UTC -Nmap scan report for 192.168.1.1 -Host is up (0.0010s latency). -Not shown: 98 closed ports -PORT STATE SERVICE -22/tcp open ssh -80/tcp open http -Nmap done: 1 IP address (1 host up) scanned in 0.10 seconds -""" - mock_run.return_value = MagicMock(stdout=mock_output, returncode=0) + # Mock database connections for both device lookup and plugin objects + mock_conn = MagicMock() + mock_execute_result = MagicMock() + + # Mock for PluginObjectInstance.getByField (returns port data) + mock_execute_result.fetchall.return_value = [ + {"Object_SecondaryID": "22", "Watched_Value2": "ssh"}, + {"Object_SecondaryID": "80", "Watched_Value2": "http"} + ] + # Mock for DeviceInstance.getByIP (returns device with MAC) + mock_execute_result.fetchone.return_value = {"devMac": "AA:BB:CC:DD:EE:FF"} + + mock_conn.execute.return_value = mock_execute_result + mock_plugin_db_conn.return_value = mock_conn + mock_device_db_conn.return_value = mock_conn payload = {"target": "192.168.1.1"} - response = client.post('/api/tools/get_open_ports', + response = client.post('/device/open_ports', json=payload, headers=auth_headers(api_token)) @@ -112,43 +125,46 @@ Nmap done: 1 IP address (1 host up) scanned in 0.10 seconds assert data["open_ports"][1]["service"] == "http" -@patch('api_server.tools_routes.get_temp_db_connection') -@patch('subprocess.run') -def test_get_open_ports_mac_resolve(mock_run, mock_db_conn, client, api_token): +@patch('models.plugin_object_instance.get_temp_db_connection') +def test_get_open_ports_mac_resolve(mock_plugin_db_conn, client, api_token): """Test get_open_ports with a MAC address that resolves to an IP.""" - # Mock DB to resolve MAC to IP - mock_cursor = MagicMock() - mock_cursor.fetchone.return_value = {"devLastIP": "192.168.1.50"} - mock_db_conn.return_value.cursor.return_value = mock_cursor - - # Mock Nmap output - mock_run.return_value = MagicMock(stdout="80/tcp open http", returncode=0) + # Mock database connection for MAC-based open ports query + mock_conn = MagicMock() + mock_execute_result = MagicMock() + mock_execute_result.fetchall.return_value = [ + {"Object_SecondaryID": "80", "Watched_Value2": "http"} + ] + mock_conn.execute.return_value = mock_execute_result + mock_plugin_db_conn.return_value = mock_conn payload = {"target": "AA:BB:CC:DD:EE:FF"} - response = client.post('/api/tools/get_open_ports', + response = client.post('/device/open_ports', json=payload, headers=auth_headers(api_token)) assert response.status_code == 200 data = response.get_json() - assert data["target"] == "192.168.1.50" # Should be resolved IP - mock_run.assert_called_once() - args, _ = mock_run.call_args - assert "192.168.1.50" in args[0] + assert data["success"] is True + assert "target" in data + assert len(data["open_ports"]) == 1 + assert data["open_ports"][0]["port"] == 80 # --- get_network_topology Tests --- -@patch('api_server.tools_routes.get_temp_db_connection') +@patch('models.device_instance.get_temp_db_connection') def test_get_network_topology(mock_db_conn, client, api_token): """Test get_network_topology.""" - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [ + # Mock database connection for topology query + mock_conn = MagicMock() + mock_execute_result = MagicMock() + mock_execute_result.fetchall.return_value = [ {"devName": "Router", "devMac": "AA:AA:AA:AA:AA:AA", "devParentMAC": None, "devParentPort": None, "devVendor": "VendorA"}, {"devName": "Device1", "devMac": "BB:BB:BB:BB:BB:BB", "devParentMAC": "AA:AA:AA:AA:AA:AA", "devParentPort": "eth1", "devVendor": "VendorB"} ] - mock_db_conn.return_value.cursor.return_value = mock_cursor + mock_conn.execute.return_value = mock_execute_result + mock_db_conn.return_value = mock_conn - response = client.get('/api/tools/get_network_topology', + response = client.get('/devices/network/topology', headers=auth_headers(api_token)) assert response.status_code == 200 @@ -160,92 +176,71 @@ def test_get_network_topology(mock_db_conn, client, api_token): # --- get_recent_alerts Tests --- -@patch('api_server.tools_routes.get_temp_db_connection') +@patch('models.event_instance.get_temp_db_connection') def test_get_recent_alerts(mock_db_conn, client, api_token): """Test get_recent_alerts.""" - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [ - {"eve_DateTime": "2023-10-27 10:00:00", "eve_EventType": "New Device", "eve_MAC": "CC:CC:CC:CC:CC:CC", "eve_IP": "192.168.1.100", "devName": "Unknown"} + # Mock database connection for events query + mock_conn = MagicMock() + mock_execute_result = MagicMock() + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + mock_execute_result.fetchall.return_value = [ + {"eve_DateTime": now, "eve_EventType": "New Device", "eve_MAC": "AA:BB:CC:DD:EE:FF"} ] - mock_db_conn.return_value.cursor.return_value = mock_cursor + mock_conn.execute.return_value = mock_execute_result + mock_db_conn.return_value = mock_conn - payload = {"hours": 24} - response = client.post('/api/tools/get_recent_alerts', - json=payload, - headers=auth_headers(api_token)) + response = client.get('/events/recent', + headers=auth_headers(api_token)) assert response.status_code == 200 data = response.get_json() - assert len(data) == 1 - assert data[0]["eve_EventType"] == "New Device" + assert data["success"] is True + assert data["hours"] == 24 -# --- set_device_alias Tests --- -@patch('api_server.tools_routes.get_temp_db_connection') -def test_set_device_alias(mock_db_conn, client, api_token): +# --- Device Alias Tests --- + +@patch('api_server.api_server_start.update_device_column') +def test_set_device_alias(mock_update_col, client, api_token): """Test set_device_alias.""" - mock_cursor = MagicMock() - mock_cursor.rowcount = 1 # Simulate successful update - mock_db_conn.return_value.cursor.return_value = mock_cursor + mock_update_col.return_value = {"success": True, "message": "Device alias updated"} - payload = {"mac": "AA:BB:CC:DD:EE:FF", "alias": "New Name"} - response = client.post('/api/tools/set_device_alias', + payload = {"alias": "New Device Name"} + response = client.post('/device/AA:BB:CC:DD:EE:FF/set-alias', json=payload, headers=auth_headers(api_token)) assert response.status_code == 200 data = response.get_json() assert data["success"] is True + mock_update_col.assert_called_once_with("AA:BB:CC:DD:EE:FF", "devName", "New Device Name") -@patch('api_server.tools_routes.get_temp_db_connection') -def test_set_device_alias_not_found(mock_db_conn, client, api_token): +@patch('api_server.api_server_start.update_device_column') +def test_set_device_alias_not_found(mock_update_col, client, api_token): """Test set_device_alias when device is not found.""" - mock_cursor = MagicMock() - mock_cursor.rowcount = 0 # Simulate no rows updated - mock_db_conn.return_value.cursor.return_value = mock_cursor + mock_update_col.return_value = {"success": False, "error": "Device not found"} - payload = {"mac": "AA:BB:CC:DD:EE:FF", "alias": "New Name"} - response = client.post('/api/tools/set_device_alias', - json=payload, - headers=auth_headers(api_token)) - - assert response.status_code == 404 - - -# --- wol_wake_device Tests --- -@patch('subprocess.run') -def test_wol_wake_device(mock_subprocess, client, api_token): - """Test wol_wake_device.""" - mock_subprocess.return_value.stdout = "Sending magic packet to 255.255.255.255:9 with AA:BB:CC:DD:EE:FF" - mock_subprocess.return_value.returncode = 0 - - payload = {"mac": "AA:BB:CC:DD:EE:FF"} - response = client.post('/api/tools/wol_wake_device', + payload = {"alias": "New Device Name"} + response = client.post('/device/FF:FF:FF:FF:FF:FF/set-alias', json=payload, headers=auth_headers(api_token)) assert response.status_code == 200 data = response.get_json() - assert data["success"] is True - mock_subprocess.assert_called_with(["wakeonlan", "AA:BB:CC:DD:EE:FF"], capture_output=True, text=True, check=True) + assert data["success"] is False + assert "Device not found" in data["error"] -@patch('api_server.tools_routes.get_temp_db_connection') -@patch('subprocess.run') -def test_wol_wake_device_by_ip(mock_subprocess, mock_db_conn, client, api_token): - """Test wol_wake_device with IP address.""" - # Mock DB for IP resolution - mock_cursor = MagicMock() - mock_cursor.fetchone.return_value = {"devMac": "AA:BB:CC:DD:EE:FF"} - mock_db_conn.return_value.cursor.return_value = mock_cursor +# --- Wake-on-LAN Tests --- - # Mock subprocess - mock_subprocess.return_value.stdout = "Sending magic packet to 255.255.255.255:9 with AA:BB:CC:DD:EE:FF" - mock_subprocess.return_value.returncode = 0 +@patch('api_server.api_server_start.wakeonlan') +def test_wol_wake_device(mock_wakeonlan, client, api_token): + """Test wol_wake_device.""" + mock_wakeonlan.return_value = {"success": True, "message": "WOL packet sent to AA:BB:CC:DD:EE:FF"} - payload = {"ip": "192.168.1.50"} - response = client.post('/api/tools/wol_wake_device', + payload = {"devMac": "AA:BB:CC:DD:EE:FF"} + response = client.post('/nettools/wakeonlan', json=payload, headers=auth_headers(api_token)) @@ -254,34 +249,58 @@ def test_wol_wake_device_by_ip(mock_subprocess, mock_db_conn, client, api_token) assert data["success"] is True assert "AA:BB:CC:DD:EE:FF" in data["message"] - # Verify DB lookup - mock_cursor.execute.assert_called_with("SELECT devMac FROM Devices WHERE devLastIP = ?", ("192.168.1.50",)) - - # Verify subprocess call - mock_subprocess.assert_called_with(["wakeonlan", "AA:BB:CC:DD:EE:FF"], capture_output=True, text=True, check=True) - def test_wol_wake_device_invalid_mac(client, api_token): """Test wol_wake_device with invalid MAC.""" - payload = {"mac": "invalid-mac"} - response = client.post('/api/tools/wol_wake_device', + payload = {"devMac": "invalid-mac"} + response = client.post('/nettools/wakeonlan', json=payload, headers=auth_headers(api_token)) assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False -# --- openapi_spec Tests --- -def test_openapi_spec(client): - """Test openapi_spec endpoint contains new paths.""" - response = client.get('/api/tools/openapi.json') +# --- OpenAPI Spec Tests --- + +# --- Latest Device Tests --- + +@patch('models.device_instance.get_temp_db_connection') +def test_get_latest_device(mock_db_conn, client, api_token): + """Test get_latest_device endpoint.""" + # Mock database connection for latest device query + mock_conn = MagicMock() + mock_execute_result = MagicMock() + mock_execute_result.fetchone.return_value = { + "devName": "Latest Device", + "devMac": "AA:BB:CC:DD:EE:FF", + "devLastIP": "192.168.1.100", + "devFirstConnection": "2025-12-07 10:30:00" + } + mock_conn.execute.return_value = mock_execute_result + mock_db_conn.return_value = mock_conn + + response = client.get('/devices/latest', + headers=auth_headers(api_token)) + + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 1 + assert data[0]["devName"] == "Latest Device" + assert data[0]["devMac"] == "AA:BB:CC:DD:EE:FF" + + +def test_openapi_spec(client, api_token): + """Test openapi_spec endpoint contains MCP tool paths.""" + response = client.get('/mcp/sse/openapi.json', headers=auth_headers(api_token)) assert response.status_code == 200 spec = response.get_json() - # Check for new endpoints - assert "/trigger_scan" in spec["paths"] - assert "/get_open_ports" in spec["paths"] - assert "/get_network_topology" in spec["paths"] - assert "/get_recent_alerts" in spec["paths"] - assert "/set_device_alias" in spec["paths"] - assert "/wol_wake_device" in spec["paths"] + # Check for MCP tool endpoints in the spec with correct paths + assert "/nettools/trigger-scan" in spec["paths"] + assert "/device/open_ports" in spec["paths"] + assert "/devices/network/topology" in spec["paths"] + assert "/events/recent" in spec["paths"] + assert "/device/{mac}/set-alias" in spec["paths"] + assert "/nettools/wakeonlan" in spec["paths"] \ No newline at end of file diff --git a/test/api_endpoints/test_tools_endpoints.py b/test/api_endpoints/test_tools_endpoints.py deleted file mode 100644 index 297f11b6..00000000 --- a/test/api_endpoints/test_tools_endpoints.py +++ /dev/null @@ -1,79 +0,0 @@ -import sys -import os -import pytest - -INSTALL_PATH = os.getenv('NETALERTX_APP', '/app') -sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) - -from helper import get_setting_value # noqa: E402 [flake8 lint suppression] -from api_server.api_server_start import app # noqa: E402 [flake8 lint suppression] - - -@pytest.fixture(scope="session") -def api_token(): - return get_setting_value("API_TOKEN") - - -@pytest.fixture -def client(): - with app.test_client() as client: - yield client - - -def auth_headers(token): - return {"Authorization": f"Bearer {token}"} - - -def test_openapi_spec(client): - """Test OpenAPI spec endpoint.""" - response = client.get('/api/tools/openapi.json') - assert response.status_code == 200 - spec = response.get_json() - assert "openapi" in spec - assert "info" in spec - assert "paths" in spec - assert "/list_devices" in spec["paths"] - assert "/get_device_info" in spec["paths"] - - -def test_list_devices(client, api_token): - """Test list_devices endpoint.""" - response = client.post('/api/tools/list_devices', headers=auth_headers(api_token)) - assert response.status_code == 200 - devices = response.get_json() - assert isinstance(devices, list) - # If there are devices, check structure - if devices: - device = devices[0] - assert "devName" in device - assert "devMac" in device - - -def test_get_device_info(client, api_token): - """Test get_device_info endpoint.""" - # Test with a query that might not exist - payload = {"query": "nonexistent_device"} - response = client.post('/api/tools/get_device_info', - json=payload, - headers=auth_headers(api_token)) - # Should return 404 if no match, or 200 with results - assert response.status_code in [200, 404] - if response.status_code == 200: - devices = response.get_json() - assert isinstance(devices, list) - elif response.status_code == 404: - # Expected for no matches - pass - - -def test_list_devices_unauthorized(client): - """Test list_devices without authorization.""" - response = client.post('/api/tools/list_devices') - assert response.status_code == 401 - - -def test_get_device_info_unauthorized(client): - """Test get_device_info without authorization.""" - payload = {"query": "test"} - response = client.post('/api/tools/get_device_info', json=payload) - assert response.status_code == 401 From 5d1c63375b8d2b176bb1115186119a8172816421 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 7 Dec 2025 08:37:55 +0000 Subject: [PATCH 09/20] MCP refactor Signed-off-by: GitHub --- server/api_server/api_server_start.py | 4 ++-- server/api_server/mcp_endpoint.py | 5 +---- server/models/event_instance.py | 1 + 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 39b660dc..aeb91bbc 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -418,7 +418,7 @@ def api_devices_search(): if is_mac(query): device_data = get_device_data(query) - if device_data: + if device_data.status_code == 200: return jsonify({"success": True, "devices": [device_data.get_json()]}) else: return jsonify({"success": False, "error": "Device not found"}), 404 @@ -574,7 +574,7 @@ def api_trigger_scan(): @app.route('/mcp/sse/openapi.json', methods=['GET']) def api_openapi_spec(): if not is_authorized(): - return jsonify({"Success": False, "error": "Unauthorized"}), 401 + return jsonify({"success": False, "error": "Unauthorized"}), 401 return openapi_spec() diff --git a/server/api_server/mcp_endpoint.py b/server/api_server/mcp_endpoint.py index e1c5f9a7..773cd2c2 100644 --- a/server/api_server/mcp_endpoint.py +++ b/server/api_server/mcp_endpoint.py @@ -57,16 +57,13 @@ def openapi_spec(): # Sessions for SSE -_sessions = {} -_sessions_lock = __import__('threading').Lock() _openapi_spec_cache = None API_BASE_URL = f"http://localhost:{get_setting_value('GRAPHQL_PORT')}" def get_openapi_spec(): global _openapi_spec_cache - # Clear cache on each call for now to ensure fresh spec - _openapi_spec_cache = None + if _openapi_spec_cache: return _openapi_spec_cache try: diff --git a/server/models/event_instance.py b/server/models/event_instance.py index 548ee413..bdb5960f 100644 --- a/server/models/event_instance.py +++ b/server/models/event_instance.py @@ -32,6 +32,7 @@ class EventInstance: ORDER BY eve_DateTime DESC LIMIT ? """, (n,)).fetchall() + conn.close() return self._rows_to_list(rows) # --- Specific helper for last 10 --- From 624fd87ee7154e61aca300a65f07821614112c24 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 7 Dec 2025 10:24:33 +0000 Subject: [PATCH 10/20] MCP refactor Signed-off-by: GitHub --- .github/copilot-instructions.md | 2 +- server/api_server/api_server_start.py | 12 ++++++++++-- server/api_server/mcp_endpoint.py | 9 ++++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5da5e809..6d1a3c91 100755 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -43,7 +43,7 @@ Backend loop phases (see `server/__main__.py` and `server/plugin.py`): `once`, ` ## Conventions & helpers to reuse - Settings: add/modify via `ccd()` in `server/initialise.py` or per‑plugin manifest. Never hardcode ports or secrets; use `get_setting_value()`. -- Logging: use `logger.mylog(level, [message])`; levels: none/minimal/verbose/debug/trace. +- Logging: use `mylog(level, [message])`; levels: none/minimal/verbose/debug/trace. `none` is used for most important messages that should always appear, such as exceptions. - Time/MAC/strings: `helper.py` (`timeNowDB`, `normalize_mac`, sanitizers). Validate MACs before DB writes. - DB helpers: prefer `server/db/db_helper.py` functions (e.g., `get_table_json`, device condition helpers) over raw SQL in new paths. diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index aeb91bbc..7725d013 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -169,10 +169,12 @@ def log_request_info(): @app.errorhandler(404) def not_found(error): + # Get the requested path from the request object instead of error.description + requested_url = request.path if request else "unknown" response = { "success": False, "error": "API route not found", - "message": f"The requested URL {error.description if hasattr(error, 'description') else ''} was not found on the server.", + "message": f"The requested URL {requested_url} was not found on the server.", } return jsonify(response), 404 @@ -485,6 +487,11 @@ def api_wakeonlan(): if not dev or not dev.get('devMac'): return jsonify({"success": False, "message": "ERROR: Device not found", "error": "MAC not resolved"}), 404 mac = dev.get('devMac') + + # Validate that we have a valid MAC address + if not mac: + return jsonify({"success": False, "message": "ERROR: Missing device MAC or IP", "error": "Bad Request"}), 400 + return wakeonlan(mac) @@ -787,7 +794,8 @@ def get_last_events(): # Create fresh DB instance for this thread event_handler = EventInstance() - return event_handler.get_last_n(10) + events = event_handler.get_last_n(10) + return jsonify({"success": True, "count": len(events), "events": events}), 200 @app.route('/events/', methods=['GET']) diff --git a/server/api_server/mcp_endpoint.py b/server/api_server/mcp_endpoint.py index 773cd2c2..e9e07bee 100644 --- a/server/api_server/mcp_endpoint.py +++ b/server/api_server/mcp_endpoint.py @@ -71,7 +71,8 @@ def get_openapi_spec(): r.raise_for_status() _openapi_spec_cache = r.json() return _openapi_spec_cache - except Exception: + except Exception as e: + mylog("none", [f"[MCP] Failed to fetch OpenAPI spec: {e}"]) return None @@ -140,11 +141,13 @@ def process_mcp_request(data): try: json_content = api_res.json() content.append({'type': 'text', 'text': json.dumps(json_content, indent=2)}) - except Exception: + except Exception as e: + mylog("none", [f"[MCP] Failed to parse API response as JSON: {e}"]) content.append({'type': 'text', 'text': api_res.text}) is_error = api_res.status_code >= 400 return {'jsonrpc': '2.0', 'id': msg_id, 'result': {'content': content, 'isError': is_error}} except Exception as e: + mylog("none", [f"[MCP] Error calling tool {tool_name}: {e}"]) return {'jsonrpc': '2.0', 'id': msg_id, 'result': {'content': [{'type': 'text', 'text': f"Error calling tool: {str(e)}"}], 'isError': True}} if method == 'ping': return {'jsonrpc': '2.0', 'id': msg_id, 'result': {}} @@ -180,7 +183,7 @@ def mcp_sse(): else: return '', 202 except Exception as e: - mylog("none", f'SSE POST processing error: {e}') + mylog("none", [f"[MCP] SSE POST processing error: {e}"]) return jsonify({'status': 'ok', 'message': 'MCP SSE endpoint active'}), 200 session_id = uuid.uuid4().hex From bd691f01b19bd5056b9e91ea8a00434b011e6799 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 7 Dec 2025 10:51:18 +0000 Subject: [PATCH 11/20] MCP refactor + cryptography build prevention Signed-off-by: GitHub --- Dockerfile | 3 ++- server/api_server/api_server_start.py | 17 ----------------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3ecf53fe..ef86ecae 100755 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,8 @@ RUN apk add --no-cache bash shadow python3 python3-dev gcc musl-dev libffi-dev o # Create virtual environment owned by root, but readable by everyone else. This makes it easy to copy # into hardened stage without worrying about permissions and keeps image size small. Keeping the commands # together makes for a slightly smaller image size. -RUN pip install --no-cache-dir -r /tmp/requirements.txt && \ +RUN python -m pip install --upgrade pip setuptools wheel && \ + pip install --no-cache-dir -r /tmp/requirements.txt && \ chmod -R u-rwx,g-rwx /opt # second stage is the main runtime stage with just the minimum required to run the application diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 7725d013..00b420ad 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -3,7 +3,6 @@ import sys import os from flask import Flask, request, jsonify, Response -import requests from models.device_instance import DeviceInstance # noqa: E402 from flask_cors import CORS @@ -116,26 +115,10 @@ CORS( # MCP bridge variables + helpers (moved from mcp_routes) # ------------------------------------------------------------------------------- -mcp_openapi_spec_cache = None - BACKEND_PORT = get_setting_value("GRAPHQL_PORT") API_BASE_URL = f"http://localhost:{BACKEND_PORT}" -def get_openapi_spec_local(): - global mcp_openapi_spec_cache - if mcp_openapi_spec_cache: - return mcp_openapi_spec_cache - try: - resp = requests.get(f"{API_BASE_URL}/mcp/openapi.json", timeout=10) - resp.raise_for_status() - mcp_openapi_spec_cache = resp.json() - return mcp_openapi_spec_cache - except Exception as e: - mylog('minimal', [f"Error fetching OpenAPI spec: {e}"]) - return None - - @app.route('/mcp/sse', methods=['GET', 'POST']) def api_mcp_sse(): if not is_authorized(): From 5c44fd8fea5d4eea6618488448970deeef19150a Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:09:18 +0000 Subject: [PATCH 12/20] cryptography build prevention Signed-off-by: GitHub --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 12ab40c9..af40c995 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +cryptography<40 openwrt-luci-rpc asusrouter aiohttp From 1dee812ce6022597133a8818c41510aa2596659d Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:33:20 +0000 Subject: [PATCH 13/20] cryptography build prevention + docs Signed-off-by: GitHub --- Dockerfile | 19 +- docs/API.md | 22 +- docs/API_DBQUERY.md | 10 +- docs/API_DEVICE.md | 2 + docs/API_DEVICES.md | 112 ++++++++- docs/API_EVENTS.md | 68 +++++- docs/API_MCP.md | 326 ++++++++++++++++++++++++++ docs/API_NETTOOLS.md | 9 + mkdocs.yml | 1 + server/api_server/api_server_start.py | 14 +- 10 files changed, 563 insertions(+), 20 deletions(-) create mode 100644 docs/API_MCP.md diff --git a/Dockerfile b/Dockerfile index ef86ecae..cd0a034b 100755 --- a/Dockerfile +++ b/Dockerfile @@ -26,16 +26,25 @@ ENV PATH="/opt/venv/bin:$PATH" # Install build dependencies COPY requirements.txt /tmp/requirements.txt -RUN apk add --no-cache bash shadow python3 python3-dev gcc musl-dev libffi-dev openssl-dev git rust cargo \ +RUN apk add --no-cache \ + bash \ + shadow \ + python3 \ + python3-dev \ + gcc \ + musl-dev \ + libffi-dev \ + openssl-dev \ + git \ + rust \ + cargo \ && python -m venv /opt/venv -# Create virtual environment owned by root, but readable by everyone else. This makes it easy to copy -# into hardened stage without worrying about permissions and keeps image size small. Keeping the commands -# together makes for a slightly smaller image size. +# Upgrade pip/wheel/setuptools and install Python packages RUN python -m pip install --upgrade pip setuptools wheel && \ pip install --no-cache-dir -r /tmp/requirements.txt && \ chmod -R u-rwx,g-rwx /opt - + # second stage is the main runtime stage with just the minimum required to run the application # The runner is used for both devcontainer, and as a base for the hardened stage. FROM alpine:3.22 AS runner diff --git a/docs/API.md b/docs/API.md index 3ad69a96..8a11403d 100755 --- a/docs/API.md +++ b/docs/API.md @@ -36,9 +36,15 @@ Authorization: Bearer If the token is missing or invalid, the server will return: ```json -{ "error": "Forbidden" } +{ + "success": false, + "message": "ERROR: Not authorized", + "error": "Forbidden" +} ``` +HTTP Status: **403 Forbidden** + --- ## Base URL @@ -54,6 +60,8 @@ http://:/ > [!TIP] > When retrieving devices or settings try using the GraphQL API endpoint first as it is read-optimized. +### Standard REST Endpoints + * [Device API Endpoints](API_DEVICE.md) – Manage individual devices * [Devices Collection](API_DEVICES.md) – Bulk operations on multiple devices * [Events](API_EVENTS.md) – Device event logging and management @@ -69,6 +77,18 @@ http://:/ * [Logs](API_LOGS.md) – Purging of logs and adding to the event execution queue for user triggered events * [DB query](API_DBQUERY.md) (⚠ Internal) - Low level database access - use other endpoints if possible +### MCP Server Bridge + +NetAlertX includes an **MCP (Model Context Protocol) Server Bridge** that provides AI assistants access to NetAlertX functionality through standardized tools. MCP endpoints are available at `/mcp/sse/*` paths and mirror the functionality of standard REST endpoints: + +* `/mcp/sse` - Server-Sent Events endpoint for MCP client connections +* `/mcp/sse/openapi.json` - OpenAPI specification for available MCP tools +* `/mcp/sse/device/*`, `/mcp/sse/devices/*`, `/mcp/sse/nettools/*`, `/mcp/sse/events/*` - MCP-enabled versions of REST endpoints + +MCP endpoints require the same Bearer token authentication as REST endpoints. + +**📖 See [MCP Server Bridge API](API_MCP.md) for complete documentation, tool specifications, and integration examples.** + See [Testing](API_TESTS.md) for example requests and usage. --- diff --git a/docs/API_DBQUERY.md b/docs/API_DBQUERY.md index a896d1c7..f93211ac 100755 --- a/docs/API_DBQUERY.md +++ b/docs/API_DBQUERY.md @@ -2,7 +2,7 @@ The **Database Query API** provides direct, low-level access to the NetAlertX database. It allows **read, write, update, and delete** operations against tables, using **base64-encoded** SQL or structured parameters. -> [!Warning] +> [!Warning] > This API is primarily used internally to generate and render the application UI. These endpoints are low-level and powerful, and should be used with caution. Wherever possible, prefer the [standard API endpoints](API.md). Invalid or unsafe queries can corrupt data. > If you need data in a specific format that is not already provided, please open an issue or pull request with a clear, broadly useful use case. This helps ensure new endpoints benefit the wider community rather than relying on raw database queries. @@ -16,10 +16,14 @@ All `/dbquery/*` endpoints require an API token in the HTTP headers: Authorization: Bearer ``` -If the token is missing or invalid: +If the token is missing or invalid (HTTP 403): ```json -{ "error": "Forbidden" } +{ + \"success\": false, + \"message\": \"ERROR: Not authorized\", + \"error\": \"Forbidden\" +} ``` --- diff --git a/docs/API_DEVICE.md b/docs/API_DEVICE.md index de9d283c..99692c3c 100755 --- a/docs/API_DEVICE.md +++ b/docs/API_DEVICE.md @@ -41,6 +41,8 @@ Manage a **single device** by its MAC address. Operations include retrieval, upd * Device not found → HTTP 404 * Unauthorized → HTTP 403 +**MCP Integration**: Available as `get_device_info` and `set_device_alias` tools. See [MCP Server Bridge API](API_MCP.md). + --- ## 2. Update Device Fields diff --git a/docs/API_DEVICES.md b/docs/API_DEVICES.md index 1ea390e1..42fa471c 100755 --- a/docs/API_DEVICES.md +++ b/docs/API_DEVICES.md @@ -170,7 +170,7 @@ The Devices Collection API provides operations to **retrieve, manage, import/exp **Response**: ```json -[ +[ 120, // Total devices 85, // Connected 5, // Favorites @@ -207,6 +207,93 @@ The Devices Collection API provides operations to **retrieve, manage, import/exp --- +### 9. Search Devices + +* **POST** `/devices/search` + Search for devices by MAC, name, or IP address. + +**Request Body** (JSON): + +```json +{ + "query": ".50" +} +``` + +**Response**: + +```json +{ + "success": true, + "devices": [ + { + "devName": "Test Device", + "devMac": "AA:BB:CC:DD:EE:FF", + "devLastIP": "192.168.1.50" + } + ] +} +``` + +--- + +### 10. Get Latest Device + +* **GET** `/devices/latest` + Get the most recently connected device. + +**Response**: + +```json +[ + { + "devName": "Latest Device", + "devMac": "AA:BB:CC:DD:EE:FF", + "devLastIP": "192.168.1.100", + "devFirstConnection": "2025-12-07 10:30:00" + } +] +``` + +--- + +### 11. Get Network Topology + +* **GET** `/devices/network/topology` + Get network topology showing device relationships. + +**Response**: + +```json +{ + "nodes": [ + { + "id": "AA:AA:AA:AA:AA:AA", + "name": "Router", + "vendor": "VendorA" + } + ], + "links": [ + { + "source": "AA:AA:AA:AA:AA:AA", + "target": "BB:BB:BB:BB:BB:BB", + "port": "eth1" + } + ] +} +``` + +--- + +## MCP Tools + +These endpoints are also available as **MCP Tools** for AI assistant integration: +- `list_devices`, `search_devices`, `get_latest_device`, `get_network_topology`, `set_device_alias` + +📖 See [MCP Server Bridge API](API_MCP.md) for AI integration details. + +--- + ## Example `curl` Requests **Get All Devices**: @@ -247,3 +334,26 @@ curl -X GET "http://:/devices/by-status?status=online" -H "Authorization: Bearer " ``` +**Search Devices**: + +```sh +curl -X POST "http://:/devices/search" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + --data '{"query": "192.168.1"}' +``` + +**Get Latest Device**: + +```sh +curl -X GET "http://:/devices/latest" \ + -H "Authorization: Bearer " +``` + +**Get Network Topology**: + +```sh +curl -X GET "http://:/devices/network/topology" \ + -H "Authorization: Bearer " +``` + diff --git a/docs/API_EVENTS.md b/docs/API_EVENTS.md index c845e10d..ff423c4f 100755 --- a/docs/API_EVENTS.md +++ b/docs/API_EVENTS.md @@ -88,7 +88,56 @@ The Events API provides access to **device event logs**, allowing creation, retr --- -### 4. Event Totals Over a Period +### 4. Get Recent Events + +* **GET** `/events/recent` → Get events from the last 24 hours +* **GET** `/events/` → Get events from the last N hours + +**Response** (JSON): + +```json +{ + "success": true, + "hours": 24, + "count": 5, + "events": [ + { + "eve_DateTime": "2025-12-07 12:00:00", + "eve_EventType": "New Device", + "eve_MAC": "AA:BB:CC:DD:EE:FF", + "eve_IP": "192.168.1.100", + "eve_AdditionalInfo": "Device detected" + } + ] +} +``` + +--- + +### 5. Get Latest Events + +* **GET** `/events/last` + Get the 10 most recent events. + +**Response** (JSON): + +```json +{ + "success": true, + "count": 10, + "events": [ + { + "eve_DateTime": "2025-12-07 12:00:00", + "eve_EventType": "Device Down", + "eve_MAC": "AA:BB:CC:DD:EE:FF" + } + ] +} +``` + +--- + +### 6. Event Totals Over a Period * **GET** `/sessions/totals?period=` Return event and session totals over a given period. @@ -116,12 +165,25 @@ The Events API provides access to **device event logs**, allowing creation, retr --- +## MCP Tools + +Event endpoints are available as **MCP Tools** for AI assistant integration: +- `get_recent_alerts`, `get_last_events` + +📖 See [MCP Server Bridge API](API_MCP.md) for AI integration details. + +--- + ## Notes -* All endpoints require **authorization** (Bearer token). Unauthorized requests return: +* All endpoints require **authorization** (Bearer token). Unauthorized requests return HTTP 403: ```json -{ "error": "Forbidden" } +{ + "success": false, + "message": "ERROR: Not authorized", + "error": "Forbidden" +} ``` * Events are stored in the **Events table** with the following fields: diff --git a/docs/API_MCP.md b/docs/API_MCP.md new file mode 100644 index 00000000..c52cdf39 --- /dev/null +++ b/docs/API_MCP.md @@ -0,0 +1,326 @@ +# MCP Server Bridge API + +The **MCP (Model Context Protocol) Server Bridge** provides AI assistants with standardized access to NetAlertX functionality through tools and server-sent events. This enables AI systems to interact with your network monitoring data in real-time. + +--- + +## Overview + +The MCP Server Bridge exposes NetAlertX functionality as **MCP Tools** that AI assistants can call to: + +- Search and retrieve device information +- Trigger network scans +- Get network topology and events +- Wake devices via Wake-on-LAN +- Access open port information +- Set device aliases + +All MCP endpoints mirror the functionality of standard REST endpoints but are optimized for AI assistant integration. + +--- + +## Authentication + +MCP endpoints use the same **Bearer token authentication** as REST endpoints: + +```http +Authorization: Bearer +``` + +Unauthorized requests return HTTP 403: + +```json +{ + "success": false, + "message": "ERROR: Not authorized", + "error": "Forbidden" +} +``` + +--- + +## MCP Connection Endpoint + +### Server-Sent Events (SSE) + +* **GET/POST** `/mcp/sse` + + Main MCP connection endpoint for AI clients. Establishes a persistent connection using Server-Sent Events for real-time communication between AI assistants and NetAlertX. + +**Connection Example**: + +```javascript +const eventSource = new EventSource('/mcp/sse', { + headers: { + 'Authorization': 'Bearer ' + } +}); + +eventSource.onmessage = function(event) { + const response = JSON.parse(event.data); + console.log('MCP Response:', response); +}; +``` + +--- + +## OpenAPI Specification + +### Get MCP Tools Specification + +* **GET** `/mcp/sse/openapi.json` + + Returns the OpenAPI specification for all available MCP tools, describing the parameters and schemas for each tool. + +**Response**: + +```json +{ + "openapi": "3.0.0", + "info": { + "title": "NetAlertX Tools", + "version": "1.1.0" + }, + "servers": [{"url": "/"}], + "paths": { + "/devices/by-status": { + "post": {"operationId": "list_devices"} + }, + "/device/{mac}": { + "post": {"operationId": "get_device_info"} + }, + "/devices/search": { + "post": {"operationId": "search_devices"} + } + } +} +``` + +--- + +## Available MCP Tools + +### Device Management Tools + +| Tool | Endpoint | Description | +|------|----------|-------------| +| `list_devices` | `/mcp/sse/devices/by-status` | List devices by online status | +| `get_device_info` | `/mcp/sse/device/` | Get detailed device information | +| `search_devices` | `/mcp/sse/devices/search` | Search devices by MAC, name, or IP | +| `get_latest_device` | `/mcp/sse/devices/latest` | Get most recently connected device | +| `set_device_alias` | `/mcp/sse/device//set-alias` | Set device friendly name | + +### Network Tools + +| Tool | Endpoint | Description | +|------|----------|-------------| +| `trigger_scan` | `/mcp/sse/nettools/trigger-scan` | Trigger network discovery scan | +| `get_open_ports` | `/mcp/sse/device/open_ports` | Get stored NMAP open ports for device | +| `wol_wake_device` | `/mcp/sse/nettools/wakeonlan` | Wake device using Wake-on-LAN | +| `get_network_topology` | `/mcp/sse/devices/network/topology` | Get network topology map | + +### Event & Monitoring Tools + +| Tool | Endpoint | Description | +|------|----------|-------------| +| `get_recent_alerts` | `/mcp/sse/events/recent` | Get events from last 24 hours | +| `get_last_events` | `/mcp/sse/events/last` | Get 10 most recent events | + +--- + +## Tool Usage Examples + +### Search Devices Tool + +**Tool Call**: +```json +{ + "jsonrpc": "2.0", + "id": "1", + "method": "tools/call", + "params": { + "name": "search_devices", + "arguments": { + "query": "192.168.1" + } + } +} +``` + +**Response**: +```json +{ + "jsonrpc": "2.0", + "id": "1", + "result": { + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"devices\": [\n {\n \"devName\": \"Router\",\n \"devMac\": \"AA:BB:CC:DD:EE:FF\",\n \"devLastIP\": \"192.168.1.1\"\n }\n ]\n}" + } + ], + "isError": false + } +} +``` + +### Trigger Network Scan Tool + +**Tool Call**: +```json +{ + "jsonrpc": "2.0", + "id": "2", + "method": "tools/call", + "params": { + "name": "trigger_scan", + "arguments": { + "type": "ARPSCAN" + } + } +} +``` + +**Response**: +```json +{ + "jsonrpc": "2.0", + "id": "2", + "result": { + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"message\": \"Scan triggered for type: ARPSCAN\"\n}" + } + ], + "isError": false + } +} +``` + +### Wake-on-LAN Tool + +**Tool Call**: +```json +{ + "jsonrpc": "2.0", + "id": "3", + "method": "tools/call", + "params": { + "name": "wol_wake_device", + "arguments": { + "devMac": "AA:BB:CC:DD:EE:FF" + } + } +} +``` + +--- + +## Integration with AI Assistants + +### Claude Desktop Integration + +Add to your Claude Desktop `mcp.json` configuration: + +```json +{ + "mcp": { + "servers": { + "netalertx": { + "command": "node", + "args": ["/path/to/mcp-client.js"], + "env": { + "NETALERTX_URL": "http://your-server:", + "NETALERTX_TOKEN": "your-api-token" + } + } + } + } +} +``` + +### Generic MCP Client + +```python +import asyncio +import json +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +async def main(): + # Connect to NetAlertX MCP server + server_params = StdioServerParameters( + command="curl", + args=[ + "-N", "-H", "Authorization: Bearer ", + "http://your-server:/mcp/sse" + ] + ) + + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize connection + await session.initialize() + + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + # Call a tool + result = await session.call_tool("search_devices", {"query": "router"}) + print(f"Search result: {result}") + +if __name__ == "__main__": + asyncio.run(main()) +``` + +--- + +## Error Handling + +MCP tool calls return structured error information: + +**Error Response**: +```json +{ + "jsonrpc": "2.0", + "id": "1", + "result": { + "content": [ + { + "type": "text", + "text": "Error calling tool: Device not found" + } + ], + "isError": true + } +} +``` + +**Common Error Types**: +- `401/403` - Authentication failure +- `400` - Invalid parameters or missing required fields +- `404` - Resource not found (device, scan results, etc.) +- `500` - Internal server error + +--- + +## Notes + +* MCP endpoints require the same API token authentication as REST endpoints +* All MCP tools return JSON responses wrapped in MCP protocol format +* Server-Sent Events maintain persistent connections for real-time updates +* Tool parameters match their REST endpoint equivalents +* Error responses include both HTTP status codes and descriptive messages +* MCP bridge automatically handles request/response serialization + +--- + +## Related Documentation + +* [Main API Overview](API.md) - Core REST API documentation +* [Device API](API_DEVICE.md) - Individual device management +* [Devices Collection API](API_DEVICES.md) - Bulk device operations +* [Network Tools API](API_NETTOOLS.md) - Wake-on-LAN, scans, network utilities +* [Events API](API_EVENTS.md) - Event logging and monitoring \ No newline at end of file diff --git a/docs/API_NETTOOLS.md b/docs/API_NETTOOLS.md index 629ac984..ba71bbb0 100755 --- a/docs/API_NETTOOLS.md +++ b/docs/API_NETTOOLS.md @@ -241,3 +241,12 @@ curl -X POST "http://:/nettools/nmap" \ curl "http://:/nettools/internetinfo" \ -H "Authorization: Bearer " ``` + +--- + +## MCP Tools + +Network tools are available as **MCP Tools** for AI assistant integration: +- `wol_wake_device`, `trigger_scan`, `get_open_ports` + +📖 See [MCP Server Bridge API](API_MCP.md) for AI integration details. diff --git a/mkdocs.yml b/mkdocs.yml index ba00a943..d3516a8e 100755 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -98,6 +98,7 @@ nav: - Sync: API_SYNC.md - GraphQL: API_GRAPHQL.md - DB query: API_DBQUERY.md + - MCP: API_MCP.md - Tests: API_TESTS.md - SUPERSEDED OLD API Overview: API_OLD.md - Integrations: diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 00b420ad..d9088941 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -180,7 +180,7 @@ def graphql_endpoint(): if not is_authorized(): msg = '[graphql_server] Unauthorized access attempt - make sure your GRAPHQL_PORT and API_TOKEN settings are correct.' mylog('verbose', [msg]) - return jsonify({"success": False, "message": msg, "error": "Forbidden"}), 401 + return jsonify({"success": False, "message": msg, "error": "Forbidden"}), 403 # Retrieve and log request data data = request.get_json() @@ -301,7 +301,7 @@ def api_device_set_alias(mac): def api_device_open_ports(): """Get stored NMAP open ports for a target IP or MAC.""" if not is_authorized(): - return jsonify({"success": False, "error": "Unauthorized"}), 401 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 data = request.get_json(silent=True) or {} target = data.get('target') @@ -393,7 +393,7 @@ def api_devices_by_status(): def api_devices_search(): """Device search: accepts 'query' in JSON and maps to device info/search.""" if not is_authorized(): - return jsonify({"error": "Unauthorized"}), 401 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 data = request.get_json(silent=True) or {} query = data.get('query') @@ -424,7 +424,7 @@ def api_devices_search(): def api_devices_latest(): """Get latest device (most recent) - maps to DeviceInstance.getLatest().""" if not is_authorized(): - return jsonify({"error": "Unauthorized"}), 401 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 device_handler = DeviceInstance() @@ -440,7 +440,7 @@ def api_devices_latest(): def api_devices_network_topology(): """Network topology mapping.""" if not is_authorized(): - return jsonify({"error": "Unauthorized"}), 401 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 device_handler = DeviceInstance() @@ -564,7 +564,7 @@ def api_trigger_scan(): @app.route('/mcp/sse/openapi.json', methods=['GET']) def api_openapi_spec(): if not is_authorized(): - return jsonify({"success": False, "error": "Unauthorized"}), 401 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return openapi_spec() @@ -786,7 +786,7 @@ def api_events_recent(hours): """Return events from the last hours using EventInstance.""" if not is_authorized(): - return jsonify({"success": False, "error": "Unauthorized"}), 401 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 # Validate hours input if hours <= 0: From 6ba48e499c7a3eebb87261460212225229bb5271 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 7 Dec 2025 21:14:35 +0000 Subject: [PATCH 14/20] test fix + increase build timeout + add buildd cache --- .github/workflows/docker_dev.yml | 4 +++- test/api_endpoints/test_graphq_endpoints.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker_dev.yml b/.github/workflows/docker_dev.yml index 5314e178..d264f8dc 100755 --- a/.github/workflows/docker_dev.yml +++ b/.github/workflows/docker_dev.yml @@ -13,7 +13,7 @@ on: jobs: docker_dev: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 permissions: contents: read packages: write @@ -96,3 +96,5 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/test/api_endpoints/test_graphq_endpoints.py b/test/api_endpoints/test_graphq_endpoints.py index 26255ffb..374bd524 100644 --- a/test/api_endpoints/test_graphq_endpoints.py +++ b/test/api_endpoints/test_graphq_endpoints.py @@ -41,7 +41,7 @@ def test_graphql_post_unauthorized(client): """POST /graphql without token should return 401""" query = {"query": "{ devices { devName devMac } }"} resp = client.post("/graphql", json=query) - assert resp.status_code == 401 + assert resp.status_code == 403 assert "Unauthorized access attempt" in resp.json.get("message", "") assert "Forbidden" in resp.json.get("error", "") From c38da9db0beaa87274bb4cb74065a0bbef12e45b Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 7 Dec 2025 22:26:44 +0000 Subject: [PATCH 15/20] cryptography build prevention + increase build timeouts + test cleanup Signed-off-by: GitHub --- .github/workflows/docker_prod.yml | 2 +- Dockerfile | 6 ++---- test/api_endpoints/test_graphq_endpoints.py | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker_prod.yml b/.github/workflows/docker_prod.yml index f3039ad9..22e0ae6d 100755 --- a/.github/workflows/docker_prod.yml +++ b/.github/workflows/docker_prod.yml @@ -17,7 +17,7 @@ on: jobs: docker: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 permissions: contents: read packages: write diff --git a/Dockerfile b/Dockerfile index cd0a034b..11b243c3 100755 --- a/Dockerfile +++ b/Dockerfile @@ -36,15 +36,13 @@ RUN apk add --no-cache \ libffi-dev \ openssl-dev \ git \ - rust \ - cargo \ && python -m venv /opt/venv # Upgrade pip/wheel/setuptools and install Python packages RUN python -m pip install --upgrade pip setuptools wheel && \ - pip install --no-cache-dir -r /tmp/requirements.txt && \ + pip install --prefer-binary --no-cache-dir -r /tmp/requirements.txt && \ chmod -R u-rwx,g-rwx /opt - + # second stage is the main runtime stage with just the minimum required to run the application # The runner is used for both devcontainer, and as a base for the hardened stage. FROM alpine:3.22 AS runner diff --git a/test/api_endpoints/test_graphq_endpoints.py b/test/api_endpoints/test_graphq_endpoints.py index 374bd524..d09c9ea3 100644 --- a/test/api_endpoints/test_graphq_endpoints.py +++ b/test/api_endpoints/test_graphq_endpoints.py @@ -38,7 +38,7 @@ def test_graphql_debug_get(client): def test_graphql_post_unauthorized(client): - """POST /graphql without token should return 401""" + """POST /graphql without token should return 403""" query = {"query": "{ devices { devName devMac } }"} resp = client.post("/graphql", json=query) assert resp.status_code == 403 From cfa21f1dc6a4063a54d8a4efd029c64805ad3fb1 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 7 Dec 2025 22:41:06 +0000 Subject: [PATCH 16/20] re-adding rust, cargo Signed-off-by: GitHub --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 11b243c3..babc093f 100755 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,8 @@ RUN apk add --no-cache \ libffi-dev \ openssl-dev \ git \ + rust \ + cargo \ && python -m venv /opt/venv # Upgrade pip/wheel/setuptools and install Python packages From abe3d44369d8dd5e825eca72423f8f0eb488f935 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 7 Dec 2025 22:44:38 +0000 Subject: [PATCH 17/20] test fix, social post delay to 60 min Signed-off-by: GitHub --- .github/workflows/social_post_on_release.yml | 4 ++-- test/test_graphq_endpoints.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/social_post_on_release.yml b/.github/workflows/social_post_on_release.yml index cf559ee3..eed6b3dc 100755 --- a/.github/workflows/social_post_on_release.yml +++ b/.github/workflows/social_post_on_release.yml @@ -7,8 +7,8 @@ jobs: post-discord: runs-on: ubuntu-latest steps: - - name: Wait for 15 minutes - run: sleep 900 # 15 minutes delay + - name: Wait for 60 minutes + run: sleep 3600 # 60 minutes delay - name: Post to Discord run: | diff --git a/test/test_graphq_endpoints.py b/test/test_graphq_endpoints.py index 38788f36..15078194 100755 --- a/test/test_graphq_endpoints.py +++ b/test/test_graphq_endpoints.py @@ -39,10 +39,10 @@ def test_graphql_debug_get(client): def test_graphql_post_unauthorized(client): - """POST /graphql without token should return 401""" + """POST /graphql without token should return 403""" query = {"query": "{ devices { devName devMac } }"} resp = client.post("/graphql", json=query) - assert resp.status_code == 401 + assert resp.status_code == 403 # Check either error field or message field for the unauthorized text error_text = resp.json.get("error", "") or resp.json.get("message", "") assert "Unauthorized" in error_text or "Forbidden" in error_text From 8e10f5eb6632d9b32c8ea55bdde1fa935ec12cc1 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Mon, 8 Dec 2025 01:06:12 +0000 Subject: [PATCH 18/20] test fix, docs fix, removal of duplicate code Signed-off-by: GitHub --- docs/API_DBQUERY.md | 6 +- server/api_server/api_server_start.py | 10 +- server/api_server/mcp_routes.py | 304 -------------------------- 3 files changed, 8 insertions(+), 312 deletions(-) delete mode 100644 server/api_server/mcp_routes.py diff --git a/docs/API_DBQUERY.md b/docs/API_DBQUERY.md index f93211ac..4ad04f22 100755 --- a/docs/API_DBQUERY.md +++ b/docs/API_DBQUERY.md @@ -20,9 +20,9 @@ If the token is missing or invalid (HTTP 403): ```json { - \"success\": false, - \"message\": \"ERROR: Not authorized\", - \"error\": \"Forbidden\" + "success": false, + "message": "ERROR: Not authorized", + "error": "Forbidden" } ``` diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index d9088941..7da8378f 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -112,7 +112,7 @@ CORS( ) # ------------------------------------------------------------------------------- -# MCP bridge variables + helpers (moved from mcp_routes) +# MCP bridge variables + helpers # ------------------------------------------------------------------------------- BACKEND_PORT = get_setting_value("GRAPHQL_PORT") @@ -126,7 +126,7 @@ def api_mcp_sse(): return mcp_sse() -@app.route('/api/mcp/messages', methods=['POST']) +@app.route('/mcp/messages', methods=['POST']) def api_mcp_messages(): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 @@ -399,14 +399,14 @@ def api_devices_search(): query = data.get('query') if not query: - return jsonify({"error": "Missing 'query' parameter"}), 400 + return jsonify({"success": False, "message": "Missing 'query' parameter", "error": "Missing query"}), 400 if is_mac(query): device_data = get_device_data(query) if device_data.status_code == 200: return jsonify({"success": True, "devices": [device_data.get_json()]}) else: - return jsonify({"success": False, "error": "Device not found"}), 404 + return jsonify({"success": False, "message": "Device not found", "error": "Device not found"}), 404 # Create fresh DB instance for this thread device_handler = DeviceInstance() @@ -414,7 +414,7 @@ def api_devices_search(): matches = device_handler.search(query) if not matches: - return jsonify({"success": False, "error": "No devices found"}), 404 + return jsonify({"success": False, "message": "No devices found", "error": "No devices found"}), 404 return jsonify({"success": True, "devices": matches}) diff --git a/server/api_server/mcp_routes.py b/server/api_server/mcp_routes.py deleted file mode 100644 index dc7a33b9..00000000 --- a/server/api_server/mcp_routes.py +++ /dev/null @@ -1,304 +0,0 @@ -"""MCP bridge routes exposing NetAlertX tool endpoints via JSON-RPC.""" - -import json -import uuid -import queue -import requests -import threading -import logging -from flask import Blueprint, request, Response, stream_with_context, jsonify -from helper import get_setting_value - -mcp_bp = Blueprint('mcp', __name__) - -# Store active sessions: session_id -> Queue -sessions = {} -sessions_lock = threading.Lock() - -# Cache for OpenAPI spec to avoid fetching on every request -openapi_spec_cache = None - -BACKEND_PORT = get_setting_value("GRAPHQL_PORT") - -API_BASE_URL = f"http://localhost:{BACKEND_PORT}/api/tools" - - -def get_openapi_spec(): - """Fetch and cache the tools OpenAPI specification from the local API server.""" - global openapi_spec_cache - if openapi_spec_cache: - return openapi_spec_cache - - try: - # Fetch from local server - # We use localhost because this code runs on the server - response = requests.get(f"{API_BASE_URL}/openapi.json", timeout=10) - response.raise_for_status() - openapi_spec_cache = response.json() - return openapi_spec_cache - except Exception as e: - print(f"Error fetching OpenAPI spec: {e}") - return None - - -def map_openapi_to_mcp_tools(spec): - """Convert OpenAPI paths into MCP tool descriptors.""" - tools = [] - if not spec or "paths" not in spec: - return tools - - for path, methods in spec["paths"].items(): - for method, details in methods.items(): - if "operationId" in details: - tool = { - "name": details["operationId"], - "description": details.get("description", details.get("summary", "")), - "inputSchema": { - "type": "object", - "properties": {}, - "required": [] - } - } - - # Extract parameters from requestBody if present - if "requestBody" in details: - content = details["requestBody"].get("content", {}) - if "application/json" in content: - schema = content["application/json"].get("schema", {}) - tool["inputSchema"] = schema.copy() - if "properties" not in tool["inputSchema"]: - tool["inputSchema"]["properties"] = {} - if "required" not in tool["inputSchema"]: - tool["inputSchema"]["required"] = [] - - # Extract parameters from 'parameters' list (query/path params) - simplistic support - if "parameters" in details: - for param in details["parameters"]: - if param.get("in") == "query": - tool["inputSchema"]["properties"][param["name"]] = { - "type": param.get("schema", {}).get("type", "string"), - "description": param.get("description", "") - } - if param.get("required"): - if "required" not in tool["inputSchema"]: - tool["inputSchema"]["required"] = [] - tool["inputSchema"]["required"].append(param["name"]) - - tools.append(tool) - return tools - - -def process_mcp_request(data): - """Handle incoming MCP JSON-RPC requests and route them to tools.""" - method = data.get("method") - msg_id = data.get("id") - - response = None - - if method == "initialize": - response = { - "jsonrpc": "2.0", - "id": msg_id, - "result": { - "protocolVersion": "2024-11-05", - "capabilities": { - "tools": {} - }, - "serverInfo": { - "name": "NetAlertX", - "version": "1.0.0" - } - } - } - - elif method == "notifications/initialized": - # No response needed for notification - pass - - elif method == "tools/list": - spec = get_openapi_spec() - tools = map_openapi_to_mcp_tools(spec) - response = { - "jsonrpc": "2.0", - "id": msg_id, - "result": { - "tools": tools - } - } - - elif method == "tools/call": - params = data.get("params", {}) - tool_name = params.get("name") - tool_args = params.get("arguments", {}) - - # Find the endpoint for this tool - spec = get_openapi_spec() - target_path = None - target_method = None - - if spec and "paths" in spec: - for path, methods in spec["paths"].items(): - for m, details in methods.items(): - if details.get("operationId") == tool_name: - target_path = path - target_method = m.upper() - break - if target_path: - break - - if target_path: - try: - # Make the request to the local API - # We forward the Authorization header from the incoming request if present - headers = { - "Content-Type": "application/json" - } - - if "Authorization" in request.headers: - headers["Authorization"] = request.headers["Authorization"] - - url = f"{API_BASE_URL}{target_path}" - - if target_method == "POST": - api_res = requests.post(url, json=tool_args, headers=headers, timeout=30) - elif target_method == "GET": - api_res = requests.get(url, params=tool_args, headers=headers, timeout=30) - else: - api_res = None - - if api_res: - content = [] - try: - json_content = api_res.json() - content.append({ - "type": "text", - "text": json.dumps(json_content, indent=2) - }) - except (ValueError, json.JSONDecodeError): - content.append({ - "type": "text", - "text": api_res.text - }) - - is_error = api_res.status_code >= 400 - response = { - "jsonrpc": "2.0", - "id": msg_id, - "result": { - "content": content, - "isError": is_error - } - } - else: - response = { - "jsonrpc": "2.0", - "id": msg_id, - "error": {"code": -32601, "message": f"Method {target_method} not supported"} - } - - except Exception as e: - response = { - "jsonrpc": "2.0", - "id": msg_id, - "result": { - "content": [{"type": "text", "text": f"Error calling tool: {str(e)}"}], - "isError": True - } - } - else: - response = { - "jsonrpc": "2.0", - "id": msg_id, - "error": {"code": -32601, "message": f"Tool {tool_name} not found"} - } - - elif method == "ping": - response = { - "jsonrpc": "2.0", - "id": msg_id, - "result": {} - } - - else: - # Unknown method - if msg_id: # Only respond if it's a request (has id) - response = { - "jsonrpc": "2.0", - "id": msg_id, - "error": {"code": -32601, "message": "Method not found"} - } - - return response - - -@mcp_bp.route('/sse', methods=['GET', 'POST']) -def handle_sse(): - """Expose an SSE endpoint that streams MCP responses to connected clients.""" - if request.method == 'POST': - # Handle verification or keep-alive pings - try: - data = request.get_json(silent=True) - if data and "method" in data and "jsonrpc" in data: - response = process_mcp_request(data) - if response: - return jsonify(response) - else: - # Notification or no response needed - return "", 202 - except Exception as e: - # Log but don't fail - malformed requests shouldn't crash the endpoint - logging.getLogger(__name__).debug(f"SSE POST processing error: {e}") - - return jsonify({"status": "ok", "message": "MCP SSE endpoint active"}), 200 - - session_id = uuid.uuid4().hex - q = queue.Queue() - - with sessions_lock: - sessions[session_id] = q - - def stream(): - """Yield SSE messages for queued MCP responses until the client disconnects.""" - # Send the endpoint event - # The client should POST to /api/mcp/messages?session_id= - yield f"event: endpoint\ndata: /api/mcp/messages?session_id={session_id}\n\n" - - try: - while True: - try: - # Wait for messages - message = q.get(timeout=20) # Keep-alive timeout - yield f"event: message\ndata: {json.dumps(message)}\n\n" - except queue.Empty: - # Send keep-alive comment - yield ": keep-alive\n\n" - except GeneratorExit: - with sessions_lock: - if session_id in sessions: - del sessions[session_id] - - return Response(stream_with_context(stream()), mimetype='text/event-stream') - - -@mcp_bp.route('/messages', methods=['POST']) -def handle_messages(): - """Receive MCP JSON-RPC messages and enqueue responses for an SSE session.""" - session_id = request.args.get('session_id') - if not session_id: - return jsonify({"error": "Missing session_id"}), 400 - - with sessions_lock: - if session_id not in sessions: - return jsonify({"error": "Session not found"}), 404 - q = sessions[session_id] - - data = request.json - if not data: - return jsonify({"error": "Invalid JSON"}), 400 - - response = process_mcp_request(data) - - if response: - q.put(response) - - return jsonify({"status": "accepted"}), 202 From 77659afa9e77a99b8cb944a8efd8fc6780ee6573 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Mon, 8 Dec 2025 01:43:32 +0000 Subject: [PATCH 19/20] removal of circular call Signed-off-by: GitHub --- server/api_server/mcp_endpoint.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/api_server/mcp_endpoint.py b/server/api_server/mcp_endpoint.py index e9e07bee..130324c8 100644 --- a/server/api_server/mcp_endpoint.py +++ b/server/api_server/mcp_endpoint.py @@ -67,9 +67,10 @@ def get_openapi_spec(): if _openapi_spec_cache: return _openapi_spec_cache try: - r = requests.get(f"{API_BASE_URL}/mcp/openapi.json", timeout=10) - r.raise_for_status() - _openapi_spec_cache = r.json() + # Call the openapi_spec function directly instead of making HTTP request + # to avoid circular requests and authorization issues + response = openapi_spec() + _openapi_spec_cache = response.get_json() return _openapi_spec_cache except Exception as e: mylog("none", [f"[MCP] Failed to fetch OpenAPI spec: {e}"]) From 7a6a021295918f15967e6888e2f5cf6b7eda0e21 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Mon, 8 Dec 2025 01:53:24 +0000 Subject: [PATCH 20/20] docs, linting, header unpacking fix Signed-off-by: GitHub --- docs/API_MCP.md | 92 +++++++++++++++++++ server/api_server/api_server_start.py | 4 +- .../api_endpoints/test_mcp_tools_endpoints.py | 2 +- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/docs/API_MCP.md b/docs/API_MCP.md index c52cdf39..6ab19c4b 100644 --- a/docs/API_MCP.md +++ b/docs/API_MCP.md @@ -19,6 +19,98 @@ All MCP endpoints mirror the functionality of standard REST endpoints but are op --- +## Architecture Overview + +### MCP Connection Flow + +```mermaid +graph TB + A[AI Assistant
Claude Desktop] -->|SSE Connection| B[NetAlertX MCP Server
:20212/mcp/sse] + B -->|JSON-RPC Messages| C[MCP Bridge
api_server_start.py] + C -->|Tool Calls| D[NetAlertX Tools
Device/Network APIs] + D -->|Response Data| C + C -->|JSON Response| B + B -->|Stream Events| A + + style A fill:#e1f5fe + style B fill:#f3e5f5 + style C fill:#fff3e0 + style D fill:#e8f5e8 +``` + +### MCP Tool Integration + +```mermaid +sequenceDiagram + participant AI as AI Assistant + participant MCP as MCP Server (:20212) + participant API as NetAlertX API (:20211) + participant DB as SQLite Database + + AI->>MCP: 1. Connect via SSE + MCP-->>AI: 2. Session established + AI->>MCP: 3. tools/list request + MCP->>API: 4. GET /mcp/sse/openapi.json + API-->>MCP: 5. Available tools spec + MCP-->>AI: 6. Tool definitions + AI->>MCP: 7. tools/call: search_devices + MCP->>API: 8. POST /mcp/sse/devices/search + API->>DB: 9. Query devices + DB-->>API: 10. Device data + API-->>MCP: 11. JSON response + MCP-->>AI: 12. Tool result +``` + +### Component Architecture + +```mermaid +graph LR + subgraph "AI Client" + A[Claude Desktop] + B[Custom MCP Client] + end + + subgraph "NetAlertX MCP Server (:20212)" + C[SSE Endpoint
/mcp/sse] + D[Message Handler
/mcp/messages] + E[OpenAPI Spec
/mcp/sse/openapi.json] + end + + subgraph "NetAlertX API Server (:20211)" + F[Device APIs
/mcp/sse/devices/*] + G[Network Tools
/mcp/sse/nettools/*] + H[Events API
/mcp/sse/events/*] + end + + subgraph "Backend" + I[SQLite Database] + J[Network Scanners] + K[Plugin System] + end + + A -.->|Bearer Auth| C + B -.->|Bearer Auth| C + C --> D + C --> E + D --> F + D --> G + D --> H + F --> I + G --> J + H --> I + + style A fill:#e1f5fe + style B fill:#e1f5fe + style C fill:#f3e5f5 + style D fill:#f3e5f5 + style E fill:#f3e5f5 + style F fill:#fff3e0 + style G fill:#fff3e0 + style H fill:#fff3e0 +``` + +--- + ## Authentication MCP endpoints use the same **Bearer token authentication** as REST endpoints: diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 7da8378f..184adf93 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -112,7 +112,7 @@ CORS( ) # ------------------------------------------------------------------------------- -# MCP bridge variables + helpers +# MCP bridge variables + helpers # ------------------------------------------------------------------------------- BACKEND_PORT = get_setting_value("GRAPHQL_PORT") @@ -142,7 +142,7 @@ def log_request_info(): # Filter out noisy requests if needed, but user asked for drastic logging mylog("verbose", [f"[HTTP] {request.method} {request.path} from {request.remote_addr}"]) # Filter sensitive headers before logging - safe_headers = {k: v for k, v in request.headers if k.lower() not in ('authorization', 'cookie', 'x-api-key')} + safe_headers = {k: v for k, v in request.headers.items() if k.lower() not in ('authorization', 'cookie', 'x-api-key')} mylog("debug", [f"[HTTP] Headers: {safe_headers}"]) if request.method == "POST": # Be careful with large bodies, but log first 1000 chars diff --git a/test/api_endpoints/test_mcp_tools_endpoints.py b/test/api_endpoints/test_mcp_tools_endpoints.py index 3c1b68cd..92329ba1 100644 --- a/test/api_endpoints/test_mcp_tools_endpoints.py +++ b/test/api_endpoints/test_mcp_tools_endpoints.py @@ -303,4 +303,4 @@ def test_openapi_spec(client, api_token): assert "/devices/network/topology" in spec["paths"] assert "/events/recent" in spec["paths"] assert "/device/{mac}/set-alias" in spec["paths"] - assert "/nettools/wakeonlan" in spec["paths"] \ No newline at end of file + assert "/nettools/wakeonlan" in spec["paths"]