import threading import sys import os # flake8: noqa: E402 from flask import Flask, request, jsonify, Response 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}/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] from db.db_helper import get_date_from_period # noqa: E402 [flake8 lint suppression] from app_state import updateState # noqa: E402 [flake8 lint suppression] from .graphql_endpoint import devicesSchema # noqa: E402 [flake8 lint suppression] from .history_endpoint import delete_online_history # noqa: E402 [flake8 lint suppression] from .prometheus_endpoint import get_metric_stats # noqa: E402 [flake8 lint suppression] from .sessions_endpoint import ( # noqa: E402 [flake8 lint suppression] get_sessions, delete_session, create_session, get_sessions_calendar, get_device_sessions, get_session_events ) from .nettools_endpoint import ( # noqa: E402 [flake8 lint suppression] wakeonlan, traceroute, speedtest, nslookup, nmap_scan, internet_info, network_interfaces ) from .dbquery_endpoint import read_query, write_query, update_query, delete_query # noqa: E402 [flake8 lint suppression] 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 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, delete_notifications, get_unread_notifications, delete_notification, mark_notification_as_read ) from .mcp_endpoint import ( mcp_sse, mcp_messages, openapi_spec, ) # noqa: E402 [flake8 lint suppression] # validation and schemas for MCP v2 from .openapi.validation import validate_request # noqa: E402 [flake8 lint suppression] from .openapi.schemas import ( # noqa: E402 [flake8 lint suppression] DeviceSearchRequest, DeviceSearchResponse, DeviceListRequest, DeviceListResponse, DeviceListWrapperResponse, DeviceExportResponse, DeviceUpdateRequest, DeviceInfo, BaseResponse, DeviceTotalsResponse, DeleteDevicesRequest, DeviceImportRequest, DeviceImportResponse, UpdateDeviceColumnRequest, CopyDeviceRequest, TriggerScanRequest, OpenPortsRequest, OpenPortsResponse, WakeOnLanRequest, WakeOnLanResponse, TracerouteRequest, TracerouteResponse, NmapScanRequest, NmapScanResponse, NslookupRequest, NslookupResponse, RecentEventsResponse, LastEventsResponse, NetworkTopologyResponse, InternetInfoResponse, NetworkInterfacesResponse, CreateEventRequest, CreateSessionRequest, DeleteSessionRequest, CreateNotificationRequest, SyncPushRequest, SyncPullResponse, DbQueryRequest, DbQueryResponse, DbQueryUpdateRequest, DbQueryDeleteRequest, AddToQueueRequest, GetSettingResponse, RecentEventsRequest, SetDeviceAliasRequest ) from .sse_endpoint import ( # noqa: E402 [flake8 lint suppression] create_sse_endpoint ) # tools and mcp routes have been moved into this module (api_server_start) # Flask application app = Flask(__name__) # Parse CORS origins from environment or use safe defaults _cors_origins_env = os.environ.get("CORS_ORIGINS", "") _cors_origins = [ origin.strip() for origin in _cors_origins_env.split(",") if origin.strip() and (origin.strip().startswith("http://") or origin.strip().startswith("https://")) ] # Default to localhost ports commonly used in development if not configured if not _cors_origins: _cors_origins = [ "http://localhost:20211", "http://localhost:20212", "http://127.0.0.1:20211", "http://127.0.0.1:20212", ] CORS( app, resources={r"/*": {"origins": _cors_origins}}, supports_credentials=True, allow_headers=["Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"], methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"] ) # ------------------------------------------------------------------------------- # MCP bridge variables + helpers # ------------------------------------------------------------------------------- BACKEND_PORT = get_setting_value("GRAPHQL_PORT") API_BASE_URL = f"http://localhost:{BACKEND_PORT}" def is_authorized(): # Allow OPTIONS requests (preflight) without auth if request.method == "OPTIONS": return True expected_token = get_setting_value('API_TOKEN') if not expected_token: mylog("verbose", ["[api] API_TOKEN is not set. Access denied."]) return False # Check Authorization header first (primary method) auth_header = request.headers.get("Authorization", "") header_token = auth_header.split()[-1] if auth_header.startswith("Bearer ") else "" # Also check query string token (for SSE and other streaming endpoints) query_token = request.args.get("token", "") is_authorized_result = (header_token == expected_token) or (query_token == expected_token) if not is_authorized_result: msg = "[api] Unauthorized access attempt - make sure your GRAPHQL_PORT and API_TOKEN settings are correct." write_notification(msg, "alert") mylog("verbose", [msg]) return is_authorized_result @app.route('/mcp/sse', methods=['GET', 'POST', 'OPTIONS']) def api_mcp_sse(): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return mcp_sse() @app.route('/mcp/messages', methods=['POST', 'OPTIONS']) def api_mcp_messages(): if not is_authorized(): return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return mcp_messages() # ------------------------------------------------------------------- # 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("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.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 data = request.get_data(as_text=True) mylog("debug", [f"[HTTP] Body length: {len(data)} chars"]) @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 {requested_url} was not found on the server.", } return jsonify(response), 404 # -------------------------- # GraphQL Endpoints # -------------------------- # Endpoint used when accessed via browser @app.route("/graphql", methods=["GET"]) def graphql_debug(): # Handles GET requests return "NetAlertX GraphQL server running." # Endpoint for GraphQL queries @app.route("/graphql", methods=["POST"]) def graphql_endpoint(): # Check for API token in headers 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"}), 403 # Retrieve and log request data data = request.get_json() mylog("verbose", [f"[graphql_server] data: {data}"]) # Execute the GraphQL query result = devicesSchema.execute(data.get("query"), variables=data.get("variables")) # Initialize response response = {} if result.errors: response["errors"] = [str(e) for e in result.errors] if result.data: response["data"] = result.data return jsonify(response) # Tools endpoints are registered via `mcp_endpoint.tools_bp` blueprint. # -------------------------- # Settings Endpoints # -------------------------- @app.route("/settings/", methods=["GET"]) @validate_request( operation_id="get_setting", summary="Get Setting", description="Retrieve the value of a specific setting by key.", path_params=[{ "name": "setKey", "description": "Setting key", "schema": {"type": "string"} }], response_model=GetSettingResponse, tags=["settings"], auth_callable=is_authorized ) def api_get_setting(setKey): value = get_setting_value(setKey) return jsonify({"success": True, "value": value}) # -------------------------- # Device Endpoints # -------------------------- @app.route('/mcp/sse/device/', methods=['GET', 'POST']) @app.route("/device/", methods=["GET"]) @validate_request( operation_id="get_device_info", summary="Get Device Info", description="Retrieve detailed information about a specific device by MAC address.", path_params=[{ "name": "mac", "description": "Device MAC address (e.g., 00:11:22:33:44:55)", "schema": {"type": "string", "pattern": "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"} }], response_model=DeviceInfo, tags=["devices"], validation_error_code=400, auth_callable=is_authorized ) def api_get_device(mac, payload=None): period = request.args.get("period", "") device_handler = DeviceInstance() device_data = device_handler.getDeviceData(mac, period) if device_data is None: return jsonify({"success": False, "message": "Device not found", "error": "Device not found"}), 404 return jsonify(device_data) @app.route("/device/", methods=["POST"]) @validate_request( operation_id="update_device", summary="Update Device", description="Update a device's fields or create a new one if createNew is set to True.", path_params=[{ "name": "mac", "description": "Device MAC address", "schema": {"type": "string"} }], request_model=DeviceUpdateRequest, response_model=BaseResponse, tags=["devices"], auth_callable=is_authorized ) def api_set_device(mac, payload=None): device_handler = DeviceInstance() # Use validated payload if provided, fall back to request.json for backward compatibility data = payload if payload is not None else request.json # Convert Pydantic model to dict if necessary if hasattr(data, "model_dump"): data = data.model_dump(exclude_unset=True) elif hasattr(data, "dict"): data = data.dict(exclude_unset=True) result = device_handler.setDeviceData(mac, data) return jsonify(result) @app.route("/device//delete", methods=["DELETE"]) @validate_request( operation_id="delete_device", summary="Delete Device", description="Delete a device by MAC address.", path_params=[{ "name": "mac", "description": "Device MAC address", "schema": {"type": "string"} }], response_model=BaseResponse, tags=["devices"], auth_callable=is_authorized ) def api_delete_device(mac, payload=None): device_handler = DeviceInstance() result = device_handler.deleteDeviceByMAC(mac) return jsonify(result) @app.route("/device//events/delete", methods=["DELETE"]) @validate_request( operation_id="delete_device_events", summary="Delete Device Events", description="Delete all events associated with a device.", path_params=[{ "name": "mac", "description": "Device MAC address", "schema": {"type": "string"} }], response_model=BaseResponse, tags=["devices"], auth_callable=is_authorized ) def api_delete_device_events(mac, payload=None): device_handler = DeviceInstance() result = device_handler.deleteDeviceEvents(mac) return jsonify(result) @app.route("/device//reset-props", methods=["POST"]) @validate_request( operation_id="reset_device_props", summary="Reset Device Props", description="Reset custom properties of a device.", path_params=[{ "name": "mac", "description": "Device MAC address", "schema": {"type": "string"} }], response_model=BaseResponse, tags=["devices"], auth_callable=is_authorized ) def api_reset_device_props(mac, payload=None): device_handler = DeviceInstance() result = device_handler.resetDeviceProps(mac) return jsonify(result) @app.route("/device/copy", methods=["POST"]) @validate_request( operation_id="copy_device", summary="Copy Device Settings", description="Copy settings and history from one device MAC address to another.", request_model=CopyDeviceRequest, response_model=BaseResponse, tags=["devices"], auth_callable=is_authorized ) def api_device_copy(payload=None): data = request.get_json() or {} mac_from = data.get("macFrom") mac_to = data.get("macTo") if not mac_from or not mac_to: return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "macFrom and macTo are required"}), 400 device_handler = DeviceInstance() result = device_handler.copyDevice(mac_from, mac_to) return jsonify(result) @app.route("/device//update-column", methods=["POST"]) @validate_request( operation_id="update_device_column", summary="Update Device Column", description="Update a specific database column for a device.", path_params=[{ "name": "mac", "description": "Device MAC address", "schema": {"type": "string"} }], request_model=UpdateDeviceColumnRequest, response_model=BaseResponse, tags=["devices"], auth_callable=is_authorized ) def api_device_update_column(mac, payload=None): data = request.get_json() or {} column_name = data.get("columnName") column_value = data.get("columnValue") # columnName is required, but columnValue can be empty string (e.g., for unassigning) if not column_name or "columnValue" not in data: return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "columnName and columnValue are required"}), 400 device_handler = DeviceInstance() result = device_handler.updateDeviceColumn(mac, column_name, column_value) if not result.get("success"): return jsonify(result), 404 return jsonify(result) @app.route('/mcp/sse/device//set-alias', methods=['POST']) @app.route('/device//set-alias', methods=['POST']) @validate_request( operation_id="set_device_alias", summary="Set Device Alias", description="Set or update the display name/alias for a device.", path_params=[{ "name": "mac", "description": "Device MAC address", "schema": {"type": "string"} }], request_model=SetDeviceAliasRequest, response_model=BaseResponse, tags=["devices"], auth_callable=is_authorized ) def api_device_set_alias(mac, payload=None): """Set the device alias - convenience wrapper around updateDeviceColumn.""" 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 device_handler = DeviceInstance() result = device_handler.updateDeviceColumn(mac, 'devName', alias) return jsonify(result) @app.route('/mcp/sse/device/open_ports', methods=['POST']) @app.route('/device/open_ports', methods=['POST']) @validate_request( operation_id="get_open_ports", summary="Get Open Ports", description="Retrieve open ports for a target IP or MAC address. Returns cached NMAP scan results.", request_model=OpenPortsRequest, response_model=OpenPortsResponse, tags=["nettools"], auth_callable=is_authorized ) def api_device_open_ports(payload=None): """Get stored NMAP open ports for a target IP or MAC.""" 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"]) @validate_request( operation_id="get_all_devices", summary="Get All Devices", description="Retrieve a list of all devices in the system.", response_model=DeviceListWrapperResponse, tags=["devices"], auth_callable=is_authorized ) def api_get_devices(payload=None): device_handler = DeviceInstance() devices = device_handler.getAll_AsResponse() return jsonify({"success": True, "devices": devices}) @app.route("/devices", methods=["DELETE"]) @validate_request( operation_id="delete_devices", summary="Delete Multiple Devices", description="Delete multiple devices by MAC address.", request_model=DeleteDevicesRequest, tags=["devices"], auth_callable=is_authorized ) def api_devices_delete(payload=None): data = request.get_json(silent=True) or {} macs = data.get('macs', []) if not macs: return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "macs list is required"}), 400 device_handler = DeviceInstance() return jsonify(device_handler.deleteDevices(macs)) @app.route("/devices/empty-macs", methods=["DELETE"]) @validate_request( operation_id="delete_empty_mac_devices", summary="Delete Devices with Empty MACs", description="Delete all devices that do not have a valid MAC address.", response_model=BaseResponse, tags=["devices"], auth_callable=is_authorized ) def api_delete_all_empty_macs(payload=None): device_handler = DeviceInstance() return jsonify(device_handler.deleteAllWithEmptyMacs()) @app.route("/devices/unknown", methods=["DELETE"]) @validate_request( operation_id="delete_unknown_devices", summary="Delete Unknown Devices", description="Delete devices marked as unknown.", response_model=BaseResponse, tags=["devices"], auth_callable=is_authorized ) def api_delete_unknown_devices(payload=None): device_handler = DeviceInstance() return jsonify(device_handler.deleteUnknownDevices()) @app.route('/mcp/sse/devices/export', methods=['GET']) @app.route("/devices/export", methods=["GET"]) @app.route("/devices/export/", methods=["GET"]) @validate_request( operation_id="export_devices", summary="Export Devices", description="Export all devices in CSV or JSON format.", query_params=[{ "name": "format", "description": "Export format: csv or json", "required": False, "schema": {"type": "string", "enum": ["csv", "json"], "default": "csv"} }], path_params=[{ "name": "format", "description": "Export format: csv or json", "required": False, "schema": {"type": "string", "enum": ["csv", "json"]} }], response_model=DeviceExportResponse, tags=["devices"], auth_callable=is_authorized ) def api_export_devices(format=None, payload=None): export_format = (format or request.args.get("format", "csv")).lower() device_handler = DeviceInstance() result = device_handler.exportDevices(export_format) if "error" in result: return jsonify(result), 400 if result["format"] == "json": return jsonify({"data": result["data"], "columns": result["columns"]}) elif result["format"] == "csv": return Response( result["content"], mimetype="text/csv", headers={"Content-Disposition": "attachment; filename=devices.csv"}, ) @app.route('/mcp/sse/devices/import', methods=['POST']) @app.route("/devices/import", methods=["POST"]) @validate_request( operation_id="import_devices", summary="Import Devices", description="Import devices from CSV or JSON content.", request_model=DeviceImportRequest, response_model=DeviceImportResponse, tags=["devices"], auth_callable=is_authorized, allow_multipart_payload=True ) def api_import_csv(payload=None): device_handler = DeviceInstance() json_content = None file_storage = None if request.is_json and request.json.get("content"): json_content = request.json.get("content") else: file_storage = request.files.get("file") result = device_handler.importCSV(file_storage=file_storage, json_content=json_content) if not result.get("success"): return jsonify(result), 400 return jsonify(result) @app.route('/mcp/sse/devices/totals', methods=['GET']) @app.route("/devices/totals", methods=["GET"]) @validate_request( operation_id="get_device_totals", summary="Get Device Totals", description="Get device statistics including total count, online/offline counts, new devices, and archived devices.", response_model=DeviceTotalsResponse, tags=["devices"], auth_callable=is_authorized ) def api_devices_totals(payload=None): device_handler = DeviceInstance() return jsonify(device_handler.getTotals()) @app.route('/mcp/sse/devices/by-status', methods=['GET', 'POST']) @app.route("/devices/by-status", methods=["GET", "POST"]) @validate_request( operation_id="list_devices_by_status", summary="List Devices by Status", description="List devices filtered by their online/offline status.", request_model=DeviceListRequest, response_model=DeviceListResponse, tags=["devices"], auth_callable=is_authorized, query_params=[{ "name": "status", "in": "query", "required": False, "description": "Filter devices by status", "schema": {"type": "string", "enum": [ "connected", "down", "favorites", "new", "archived", "all", "my", "offline" ]} }] ) def api_devices_by_status(payload: DeviceListRequest = None): status = payload.status if payload else request.args.get("status") device_handler = DeviceInstance() return jsonify(device_handler.getByStatus(status)) @app.route('/mcp/sse/devices/search', methods=['POST']) @app.route('/devices/search', methods=['POST']) @validate_request( operation_id="search_devices", summary="Search Devices", description="Search for devices based on various criteria like name, IP, MAC, or vendor.", request_model=DeviceSearchRequest, response_model=DeviceSearchResponse, tags=["devices"], auth_callable=is_authorized ) def api_devices_search(payload=None): """Device search: accepts 'query' in JSON and maps to device info/search.""" data = request.get_json(silent=True) or {} query = data.get('query') if not query: return jsonify({"success": False, "message": "Missing 'query' parameter", "error": "Missing query"}), 400 device_handler = DeviceInstance() if is_mac(query): device_data = device_handler.getDeviceData(query) if device_data: return jsonify({"success": True, "devices": [device_data]}) else: return jsonify({"success": False, "message": "Device not found", "error": "Device not found"}), 404 matches = device_handler.search(query) if not matches: return jsonify({"success": False, "message": "No devices found", "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']) @validate_request( operation_id="get_latest_device", summary="Get Latest Device", description="Get information about the most recently seen/discovered device.", response_model=DeviceListResponse, tags=["devices"], auth_callable=is_authorized ) def api_devices_latest(payload=None): """Get latest device (most recent) - maps to DeviceInstance.getLatest().""" device_handler = DeviceInstance() latest = device_handler.getLatest() if not latest: return jsonify({"success": False, "message": "No devices found", "error": "No devices found"}), 404 return jsonify([latest]) @app.route('/mcp/sse/devices/favorite', methods=['GET']) @app.route('/devices/favorite', methods=['GET']) @validate_request( operation_id="get_favorite_devices", summary="Get Favorite Devices", description="Get list of devices marked as favorites.", response_model=DeviceListResponse, tags=["devices"], auth_callable=is_authorized ) def api_devices_favorite(payload=None): """Get favorite devices - maps to DeviceInstance.getFavorite().""" device_handler = DeviceInstance() favorite = device_handler.getFavorite() if not favorite: return jsonify({"success": False, "message": "No devices found", "error": "No devices found"}), 404 return jsonify([favorite]) @app.route('/mcp/sse/devices/network/topology', methods=['GET']) @app.route('/devices/network/topology', methods=['GET']) @validate_request( operation_id="get_network_topology", summary="Get Network Topology", description="Retrieve the network topology information showing device connections and network structure.", response_model=NetworkTopologyResponse, tags=["devices"], auth_callable=is_authorized ) def api_devices_network_topology(payload=None): """Network topology mapping.""" 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"]) @validate_request( operation_id="wake_on_lan", summary="Wake-on-LAN", description="Send a Wake-on-LAN magic packet to wake up a device.", request_model=WakeOnLanRequest, response_model=WakeOnLanResponse, tags=["nettools"], auth_callable=is_authorized ) def api_wakeonlan(payload=None): data = request.get_json(silent=True) 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') # 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) @app.route('/mcp/sse/nettools/traceroute', methods=['POST']) @app.route("/nettools/traceroute", methods=["POST"]) @validate_request( operation_id="perform_traceroute", summary="Traceroute", description="Perform a traceroute to a target IP address.", request_model=TracerouteRequest, response_model=TracerouteResponse, tags=["nettools"], auth_callable=is_authorized ) def api_traceroute(payload: TracerouteRequest = None): if payload: ip = payload.devLastIP else: data = request.get_json(silent=True) or {} ip = data.get("devLastIP") return traceroute(ip) @app.route("/nettools/speedtest", methods=["GET"]) @validate_request( operation_id="run_speedtest", summary="Speedtest", description="Run a network speed test.", response_model=BaseResponse, tags=["nettools"], auth_callable=is_authorized ) def api_speedtest(payload=None): return speedtest() @app.route("/nettools/nslookup", methods=["POST"]) @validate_request( operation_id="run_nslookup", summary="NS Lookup", description="Perform an NS lookup for a given IP.", request_model=NslookupRequest, response_model=NslookupResponse, tags=["nettools"], auth_callable=is_authorized ) def api_nslookup(payload: NslookupRequest = None): """ API endpoint to handle nslookup requests. Expects JSON with 'devLastIP'. """ json_data = request.get_json(silent=True) or {} ip = payload.devLastIP if payload else json_data.get("devLastIP") return nslookup(ip) @app.route("/nettools/nmap", methods=["POST"]) @validate_request( operation_id="run_nmap_scan", summary="NMAP Scan", description="Perform an NMAP scan on a target IP.", request_model=NmapScanRequest, response_model=NmapScanResponse, tags=["nettools"], auth_callable=is_authorized ) def api_nmap(payload: NmapScanRequest = None): """ API endpoint to handle nmap scan requests. Expects JSON with 'scan' (IP address) and 'mode' (scan mode). """ if payload: ip = payload.scan mode = payload.mode else: data = request.get_json(silent=True) or {} ip = data.get("scan") mode = data.get("mode") return nmap_scan(ip, mode) @app.route("/nettools/internetinfo", methods=["GET"]) @validate_request( operation_id="get_internet_info", summary="Internet Info", description="Get details about the current internet connection.", response_model=InternetInfoResponse, tags=["nettools"], auth_callable=is_authorized ) def api_internet_info(payload=None): return internet_info() @app.route("/nettools/interfaces", methods=["GET"]) @validate_request( operation_id="get_network_interfaces", summary="Network Interfaces", description="Get details about the system network interfaces.", response_model=NetworkInterfacesResponse, tags=["nettools"], auth_callable=is_authorized ) def api_network_interfaces(payload=None): return network_interfaces() @app.route('/mcp/sse/nettools/trigger-scan', methods=['POST']) @app.route("/nettools/trigger-scan", methods=["GET"]) @validate_request( operation_id="trigger_network_scan", summary="Trigger Network Scan", description="Trigger a network scan to discover devices. Specify scan type matching an enabled plugin.", request_model=TriggerScanRequest, response_model=BaseResponse, tags=["nettools"], validation_error_code=400, auth_callable=is_authorized ) def api_trigger_scan(payload=None): # Check POST body first, then GET args if request.method == "POST": # Payload is validated by request_model if provided data = request.get_json(silent=True) or {} scan_type = data.get("type", "ARPSCAN") else: scan_type = request.args.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 # def trigger_scan(scan_type): # """Trigger a network scan by adding it to the execution queue.""" # if scan_type not in ["ARPSCAN", "NMAPDEV", "NMAP"]: # return {"success": False, "message": f"Invalid scan type: {scan_type}"} # # queue = UserEventsQueueInstance() # res = queue.add_event("run|" + scan_type) # # # Handle mocks in tests that don't return a tuple # if isinstance(res, tuple) and len(res) == 2: # success, message = res # else: # success = True # message = f"Action \"run|{scan_type}\" added to the execution queue." # # return {"success": success, "message": message, "scan_type": scan_type} # -------------------------- # MCP Server # -------------------------- @app.route('/openapi.json', methods=['GET']) @app.route('/mcp/sse/openapi.json', methods=['GET']) def serve_openapi_spec(): # Allow unauthenticated access to the spec itself so Swagger UI can load. # The actual API endpoints remain protected. return openapi_spec() @app.route('/docs') def api_docs(): """Serve Swagger UI for API documentation.""" # We don't require auth for the UI shell, but the openapi.json fetch # will still need the token if accessed directly, or we can allow public access to docs. # For now, let's allow public access to the UI shell. # The user can enter the Bearer token in the "Authorize" button if needed, # or we can auto-inject it if they are already logged in (advanced). # We need to serve the static HTML file we created. import os from flask import send_from_directory # Assuming swagger.html is in the openapi directory api_server_dir = os.path.dirname(os.path.realpath(__file__)) openapi_dir = os.path.join(api_server_dir, 'openapi') return send_from_directory(openapi_dir, 'swagger.html') # -------------------------- # DB query # -------------------------- @app.route("/dbquery/read", methods=["POST"]) @validate_request( operation_id="dbquery_read", summary="DB Query Read", description="Execute a RAW SQL read query.", request_model=DbQueryRequest, response_model=DbQueryResponse, tags=["dbquery"], auth_callable=is_authorized ) def dbquery_read(payload=None): 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 read_query(raw_sql_b64) @app.route("/dbquery/write", methods=["POST"]) @validate_request( operation_id="dbquery_write", summary="DB Query Write", description="Execute a RAW SQL write query.", request_model=DbQueryRequest, response_model=BaseResponse, tags=["dbquery"], auth_callable=is_authorized ) def dbquery_write(payload=None): 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) @app.route("/dbquery/update", methods=["POST"]) @validate_request( operation_id="dbquery_update", summary="DB Query Update", description="Execute a DB update query.", request_model=DbQueryUpdateRequest, response_model=BaseResponse, tags=["dbquery"], auth_callable=is_authorized ) def dbquery_update(payload=None): data = request.get_json() or {} required = ["columnName", "id", "dbtable", "columns", "values"] if not all(data.get(k) for k in required): return jsonify( { "success": False, "message": "ERROR: Missing parameters", "error": "Missing required 'columnName', 'id', 'dbtable', 'columns', or 'values' query parameter" } ), 400 return update_query( column_name=data["columnName"], ids=data["id"], dbtable=data["dbtable"], columns=data["columns"], values=data["values"], ) @app.route("/dbquery/delete", methods=["POST"]) @validate_request( operation_id="dbquery_delete", summary="DB Query Delete", description="Execute a DB delete query.", request_model=DbQueryDeleteRequest, response_model=BaseResponse, tags=["dbquery"], auth_callable=is_authorized ) def dbquery_delete(payload=None): data = request.get_json() or {} required = ["columnName", "id", "dbtable"] if not all(data.get(k) for k in required): return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "Missing required 'columnName', 'id', or 'dbtable' query parameter"}), 400 return delete_query( column_name=data["columnName"], ids=data["id"], dbtable=data["dbtable"], ) # -------------------------- # Online history # -------------------------- @app.route("/history", methods=["DELETE"]) @validate_request( operation_id="delete_online_history", summary="Delete Online History", description="Delete all online history records.", response_model=BaseResponse, tags=["logs"], auth_callable=is_authorized ) def api_delete_online_history(payload=None): return delete_online_history() # -------------------------- # Logs # -------------------------- @app.route("/logs", methods=["DELETE"]) @validate_request( operation_id="clean_log", summary="Clean Log", description="Clean or truncate a specified log file.", query_params=[{ "name": "file", "description": "Log file name", "required": True, "schema": {"type": "string"} }], response_model=BaseResponse, tags=["logs"], auth_callable=is_authorized ) def api_clean_log(payload=None): 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) @app.route("/logs/add-to-execution-queue", methods=["POST"]) @validate_request( operation_id="add_to_execution_queue", summary="Add to Execution Queue", description="Add an action to the system execution queue.", request_model=AddToQueueRequest, response_model=BaseResponse, tags=["logs"], validation_error_code=400, auth_callable=is_authorized ) def api_add_to_execution_queue(payload=None): queue = UserEventsQueueInstance() # Get JSON payload safely data = request.get_json(silent=True) or {} action = data.get("action") if not action: return jsonify({ "success": False, "message": "ERROR: Missing parameters", "error": "Missing required 'action' field in JSON body"}), 400 success, message = queue.add_event(action) status_code = 200 if success else 400 response = {"success": success, "message": message} if not success: response["error"] = "ERROR" return jsonify(response), status_code # -------------------------- # Device Events # -------------------------- @app.route("/events/create/", methods=["POST"]) @validate_request( operation_id="create_device_event", summary="Create Event", description="Manually create an event for a device.", path_params=[{ "name": "mac", "description": "Device MAC address", "schema": {"type": "string"} }], request_model=CreateEventRequest, response_model=BaseResponse, tags=["events"], auth_callable=is_authorized ) def api_create_event(mac, payload=None): data = request.json or {} ip = data.get("ip", "0.0.0.0") event_type = data.get("event_type", "Device Down") additional_info = data.get("additional_info", "") pending_alert = data.get("pending_alert", 1) event_time = data.get("event_time", None) event_handler = EventInstance() result = event_handler.createEvent(mac, ip, event_type, additional_info, pending_alert, event_time) return jsonify(result) @app.route("/events/", methods=["DELETE"]) @validate_request( operation_id="delete_events_by_mac", summary="Delete Events by MAC", description="Delete all events for a specific device MAC address.", path_params=[{ "name": "mac", "description": "Device MAC address", "schema": {"type": "string"} }], response_model=BaseResponse, tags=["events"], auth_callable=is_authorized ) def api_events_by_mac(mac, payload=None): """Delete events for a specific device MAC; string converter keeps this distinct from /events/.""" device_handler = DeviceInstance() result = device_handler.deleteDeviceEvents(mac) return jsonify(result) @app.route("/events", methods=["DELETE"]) @validate_request( operation_id="delete_all_events", summary="Delete All Events", description="Delete all events in the system.", response_model=BaseResponse, tags=["events"], auth_callable=is_authorized ) def api_delete_all_events(payload=None): event_handler = EventInstance() result = event_handler.deleteAllEvents() return jsonify(result) @app.route("/events", methods=["GET"]) @validate_request( operation_id="get_all_events", summary="Get Events", description="Retrieve a list of events, optionally filtered by MAC.", query_params=[{ "name": "mac", "description": "Filter by Device MAC", "required": False, "schema": {"type": "string"} }], response_model=BaseResponse, tags=["events"], auth_callable=is_authorized ) def api_get_events(payload=None): try: mac = request.args.get("mac") event_handler = EventInstance() events = event_handler.getEvents(mac) return jsonify({"success": True, "count": len(events), "events": events}) except (ValueError, RuntimeError) as e: mylog("verbose", [f"[api_get_events] Error: {e}"]) return jsonify({"success": False, "message": str(e), "error": "Internal Server Error"}), 500 @app.route("/events/", methods=["DELETE"]) @validate_request( operation_id="delete_old_events", summary="Delete Old Events", description="Delete events older than a specified number of days.", path_params=[{ "name": "days", "description": "Number of days", "schema": {"type": "integer"} }], response_model=BaseResponse, tags=["events"], auth_callable=is_authorized ) def api_delete_old_events(days: int, payload=None): """ Delete events older than days. Example: DELETE /events/30 """ event_handler = EventInstance() result = event_handler.deleteEventsOlderThan(days) return jsonify(result) @app.route("/sessions/totals", methods=["GET"]) @validate_request( operation_id="get_events_totals", summary="Get Events Totals", description="Retrieve event totals for a specified period.", query_params=[{ "name": "period", "description": "Time period (e.g., '7 days')", "required": False, "schema": {"type": "string", "default": "7 days"} }], tags=["events"], auth_callable=is_authorized ) def api_get_events_totals(payload=None): period = request.args.get("period", "7 days") event_handler = EventInstance() totals = event_handler.getEventsTotals(period) return jsonify(totals) @app.route('/mcp/sse/events/recent', methods=['GET', 'POST']) @app.route('/events/recent', methods=['GET']) @validate_request( operation_id="get_recent_events", summary="Get Recent Events", description="Get recent events from the system.", request_model=RecentEventsRequest, auth_callable=is_authorized ) def api_events_default_24h(payload=None): hours = 24 if request.args: try: hours = int(request.args.get("hours", 24)) except (ValueError, TypeError): hours = 24 return api_events_recent(hours) @app.route('/mcp/sse/events/last', methods=['GET', 'POST']) @app.route('/events/last', methods=['GET']) @validate_request( operation_id="get_last_events", summary="Get Last Events", description="Retrieve the last 10 events from the system.", response_model=LastEventsResponse, tags=["events"], auth_callable=is_authorized ) def get_last_events(payload=None): # Create fresh DB instance for this thread event_handler = EventInstance() events = event_handler.get_last_n(10) return jsonify({"success": True, "count": len(events), "events": events}), 200 @app.route('/events/', methods=['GET']) @validate_request( operation_id="get_events_by_hours", summary="Get Events by Hours", description="Return events from the last hours using EventInstance.", path_params=[{ "name": "hours", "description": "Number of hours", "schema": {"type": "integer"} }], response_model=RecentEventsResponse, tags=["events"], auth_callable=is_authorized ) def api_events_recent(hours, payload=None): """Return events from the last hours using EventInstance.""" # 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: mylog("verbose", [f"[api_events_recent] Unexpected error: {type(ex).__name__}: {ex}"]) return jsonify({"success": False, "error": "Internal server error", "message": "An unexpected error occurred"}), 500 # -------------------------- # Sessions # -------------------------- @app.route("/sessions/create", methods=["POST"]) @validate_request( operation_id="create_session", summary="Create Session", description="Manually create a device session.", request_model=CreateSessionRequest, response_model=BaseResponse, tags=["sessions"], auth_callable=is_authorized ) def api_create_session(payload=None): data = request.json mac = data.get("mac") ip = data.get("ip") start_time = data.get("start_time") end_time = data.get("end_time") event_type_conn = data.get("event_type_conn", "Connected") event_type_disc = data.get("event_type_disc", "Disconnected") if not mac or not ip or not start_time: return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "Missing required 'mac', 'ip', or 'start_time' query parameter"}), 400 return create_session( mac, ip, start_time, end_time, event_type_conn, event_type_disc ) @app.route("/sessions/delete", methods=["DELETE"]) @validate_request( operation_id="delete_session", summary="Delete Session", description="Delete sessions for a specific device MAC address.", request_model=DeleteSessionRequest, response_model=BaseResponse, tags=["sessions"], auth_callable=is_authorized ) def api_delete_session(payload=None): mac = request.json.get("mac") if request.is_json else None if not mac: return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "Missing 'mac' query parameter"}), 400 return delete_session(mac) @app.route("/sessions/list", methods=["GET"]) @validate_request( operation_id="get_sessions", summary="Get Sessions", description="Retrieve a list of device sessions.", query_params=[ {"name": "mac", "description": "Filter by MAC", "required": False, "schema": {"type": "string"}}, {"name": "start_date", "description": "Start date filter", "required": False, "schema": {"type": "string"}}, {"name": "end_date", "description": "End date filter", "required": False, "schema": {"type": "string"}} ], tags=["sessions"], auth_callable=is_authorized ) def api_get_sessions(payload=None): mac = request.args.get("mac") start_date = request.args.get("start_date") end_date = request.args.get("end_date") return get_sessions(mac, start_date, end_date) @app.route("/sessions/calendar", methods=["GET"]) @validate_request( operation_id="get_sessions_calendar", summary="Get Sessions Calendar", description="Retrieve session calendar data.", query_params=[ {"name": "start", "description": "Start date", "required": False, "schema": {"type": "string"}}, {"name": "end", "description": "End date", "required": False, "schema": {"type": "string"}}, {"name": "mac", "description": "Filter by MAC", "required": False, "schema": {"type": "string"}} ], tags=["sessions"], auth_callable=is_authorized ) def api_get_sessions_calendar(payload=None): # Query params: /sessions/calendar?start=2025-08-01&end=2025-08-21 start_date = request.args.get("start") end_date = request.args.get("end") mac = request.args.get("mac") return get_sessions_calendar(start_date, end_date, mac) @app.route("/sessions/", methods=["GET"]) @validate_request( operation_id="get_device_sessions", summary="Get Device Sessions", description="Retrieve sessions for a specific device.", path_params=[{"name": "mac", "description": "Device MAC address", "schema": {"type": "string"}}], query_params=[{"name": "period", "description": "Time period", "required": False, "schema": {"type": "string", "default": "1 day"}}], tags=["sessions"], auth_callable=is_authorized ) def api_device_sessions(mac, payload=None): period = request.args.get("period", "1 day") return get_device_sessions(mac, period) @app.route("/sessions/session-events", methods=["GET"]) @validate_request( operation_id="get_session_events", summary="Get Session Events", description="Retrieve events associated with sessions.", query_params=[ {"name": "type", "description": "Event type", "required": False, "schema": {"type": "string", "default": "all"}}, {"name": "period", "description": "Time period", "required": False, "schema": {"type": "string", "default": "7 days"}} ], tags=["sessions"], auth_callable=is_authorized ) def api_get_session_events(payload=None): session_event_type = request.args.get("type", "all") period = get_date_from_period(request.args.get("period", "7 days")) return get_session_events(session_event_type, period) # -------------------------- # Prometheus metrics endpoint # -------------------------- @app.route("/metrics") @validate_request( operation_id="get_metrics", summary="Get Metrics", description="Get Prometheus-compatible metrics.", response_model=None, tags=["logs"], auth_callable=is_authorized ) def metrics(payload=None): # Return Prometheus metrics as plain text return Response(get_metric_stats(), mimetype="text/plain") # -------------------------- # In-app notifications # -------------------------- @app.route("/messaging/in-app/write", methods=["POST"]) @validate_request( operation_id="write_notification", summary="Write Notification", description="Create a new in-app notification.", request_model=CreateNotificationRequest, response_model=BaseResponse, tags=["messaging"], auth_callable=is_authorized ) def api_write_notification(payload=None): data = request.json or {} content = data.get("content") level = data.get("level", "alert") if not content: return jsonify({"success": False, "message": "ERROR: Missing parameters", "error": "Missing content"}), 400 write_notification(content, level) return jsonify({"success": True}) @app.route("/messaging/in-app/unread", methods=["GET"]) @validate_request( operation_id="get_unread_notifications", summary="Get Unread Notifications", description="Retrieve all unread in-app notifications.", tags=["messaging"], auth_callable=is_authorized ) def api_get_unread_notifications(payload=None): return get_unread_notifications() @app.route("/messaging/in-app/read/all", methods=["POST"]) @validate_request( operation_id="mark_all_notifications_read", summary="Mark All Read", description="Mark all in-app notifications as read.", response_model=BaseResponse, tags=["messaging"], auth_callable=is_authorized ) def api_mark_all_notifications_read(payload=None): return jsonify(mark_all_notifications_read()) @app.route("/messaging/in-app/delete", methods=["DELETE"]) @validate_request( operation_id="delete_all_notifications", summary="Delete All Notifications", description="Delete all in-app notifications.", response_model=BaseResponse, tags=["messaging"], auth_callable=is_authorized ) def api_delete_all_notifications(payload=None): return delete_notifications() @app.route("/messaging/in-app/delete/", methods=["DELETE"]) @validate_request( operation_id="delete_notification", summary="Delete Notification", description="Delete a specific notification by GUID.", path_params=[{ "name": "guid", "description": "Notification GUID", "schema": {"type": "string"} }], response_model=BaseResponse, tags=["messaging"], auth_callable=is_authorized ) def api_delete_notification(guid, payload=None): """Delete a single notification by GUID.""" result = delete_notification(guid) if result.get("success"): return jsonify({"success": True}) else: return jsonify({"success": False, "message": "ERROR", "error": result.get("error")}), 500 @app.route("/messaging/in-app/read/", methods=["POST"]) @validate_request( operation_id="mark_notification_read", summary="Mark Notification Read", description="Mark a specific notification as read by GUID.", path_params=[{ "name": "guid", "description": "Notification GUID", "schema": {"type": "string"} }], response_model=BaseResponse, tags=["messaging"], auth_callable=is_authorized ) def api_mark_notification_read(guid, payload=None): """Mark a single notification as read by GUID.""" result = mark_notification_as_read(guid) if result.get("success"): return jsonify({"success": True}) else: return jsonify({"success": False, "message": "ERROR", "error": result.get("error")}), 500 # -------------------------- # SYNC endpoint # -------------------------- @app.route("/sync", methods=["GET"]) @validate_request( operation_id="sync_data_pull", summary="Sync Data Pull", description="Pull synchronization data.", response_model=SyncPullResponse, tags=["sync"], auth_callable=is_authorized ) def sync_endpoint_get(payload=None): return handle_sync_get() @app.route("/sync", methods=["POST"]) @validate_request( operation_id="sync_data_push", summary="Sync Data Push", description="Push synchronization data.", request_model=SyncPushRequest, tags=["sync"], auth_callable=is_authorized ) def sync_endpoint_post(payload=None): return handle_sync_post() # -------------------------- # Auth endpoint # -------------------------- @app.route("/auth", methods=["GET"]) @validate_request( operation_id="check_auth", summary="Check Authentication", description="Check if the current API token is valid.", response_model=BaseResponse, tags=["auth"], auth_callable=is_authorized ) def check_auth(payload=None): if request.method == "GET": return jsonify({"success": True, "message": "Authentication check successful"}), 200 # -------------------------- # Background Server Start # -------------------------- # Mount SSE endpoints after is_authorized is defined (avoid circular import) create_sse_endpoint(app, is_authorized) def start_server(graphql_port, app_state): """Start the GraphQL server in a background thread.""" if app_state.graphQLServerStarted == 0: mylog("verbose", [f"[graphql endpoint] Starting on port: {graphql_port}"]) # Start Flask app in a separate thread thread = threading.Thread( target=lambda: app.run( host="0.0.0.0", port=graphql_port, debug=False, use_reloader=False ) ) thread.start() # 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 pass